竞态条件场景、测试思路讲解
竞态条件是高并发场景下数据正确性的隐形杀手。本文从经典场景 、测试思路 、测试手段 、具体代码与操作步骤四个维度,系统梳理竞态条件测试的设计与落地方法。
一、竞态条件是什么?
竞态条件:多个操作并发执行时,最终结果依赖执行顺序,而顺序不可控,导致数据错乱。
典型问题:读-改-写 非原子。例如:
- 用户 A 读到库存 10,准备扣 1 写成 9
- 用户 B 也读到 10,准备扣 1 写成 9
- 两人都以为自己是「最后一个」,结果库存被错误写成 9,实际应扣 2 次变成 8
二、经典场景
2.1 库存扣减(超卖)
| 场景 | 并发操作 | 风险 |
|---|---|---|
| 秒杀/抢购 | N 人同时下单同一商品 | 库存为 1 时可能成交多单,出现负库存 |
| 多门店调拨 | 多仓同时调出同一商品 | 调拨量超过实际库存 |
| 盘点调整 | 多人同时调整同一商品库存 | 最终库存与预期不符 |
2.2 限量资源领取
| 场景 | 并发操作 | 风险 |
|---|---|---|
| 优惠券 | 多人同时领限量券 | 领取数超过券总量 |
| 福袋/盲盒 | 多人同时抢限量活动 | 中奖数超过配置数量 |
| 会员积分 | 并发消费/充值 | 积分余额错误 |
2.3 同一资源并发更新
| 场景 | 并发操作 | 风险 |
|---|---|---|
| 商品价格 | 多系统同时推送同一商品价格 | 后写入覆盖先写入,或版本错乱 |
| 商品分类 | 同时创建新分类与编辑已有分类 | 分类数据丢失或错乱 |
| 订单状态 | 支付、取消、发货同时发生 | 状态不一致 |
2.4 批量操作与事务
| 场景 | 并发操作 | 风险 |
|---|---|---|
| 批量价格同步 | 多请求同时更新同一批商品 | 部分成功部分失败,数据不一致 |
| 主从同步 | 主库写入后从库未及时同步 | 读从库得到旧数据 |
三、测试思路与流程图
3.1 竞态条件测试整体流程
是
否
识别共享资源
分析读-改-写路径
设计并发场景
选择测试手段
执行并发请求
数据校验
结果正确?
通过
定位问题
开发修复
3.2 场景设计决策流程
库存/限量
价格/配置
状态机
确定被测资源
资源类型?
同资源多请求
同记录多版本更新
多状态并发流转
设计: 库存=1, N人同时扣1
设计: 同商品多version并发更新
设计: 支付/取消/发货同时触发
预期: 仅1单成功
预期: version最大者生效
预期: 状态符合业务规则
3.3 数据校验流程
是
否
是
否
并发测试结束
记录成功请求数
查询数据库实际值
库存类?
库存 = 初始 - 成功订单数
版本类?
version = 最大提交值
业务规则校验
断言通过?
输出报告
3.4 测试设计检查清单
- 识别共享资源 2. 确定并发数 3. 准备边界数据 4. 设计验证SQL 5. 执行并校验
3.5 竞态发生时间线示意
数据库 用户3 用户2 用户1 数据库 用户3 用户2 用户1 库存=1,三人同时下单 三人都读到1,均认为可下单 若无锁:后写覆盖前写,可能3单都成功,库存=-2 正确做法:乐观锁/悲观锁,仅1单成功 读库存(1) 读库存(1) 读库存(1) 写库存(0) 写库存(0) 写库存(0)
四、测试手段
4.1 手段对比
| 手段 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Python ThreadPoolExecutor | 接口并发、自定义逻辑 | 灵活、易集成断言 | 需自己管理线程 |
| k6 | 接口压测、竞态专项 | 并发控制好、有报告 | 需学 k6 语法 |
| Locust | 混合场景、自定义 | Python 编写、易扩展 | 报告不如 k6 直观 |
| JMeter | 传统压测 | 图形化、插件多 | 脚本维护成本高 |
4.2 核心要点
- 同一资源:所有并发请求必须操作同一库存、同一商品、同一券等。
- 尽量同时:减少请求间隔,提高竞态触发概率。
- 边界数据:库存 1、券余量 1 等,更容易暴露问题。
- 结果校验:不只看接口返回,必须查库或业务数据校验。
五、实战一:Python 并发库存扣减测试
5.1 场景
库存为 1 的商品,10 个用户同时下单各买 1 件,预期仅 1 单成功。
5.2 代码
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
竞态条件测试:库存扣减(超卖场景)
场景:库存=1,10人同时下单,预期仅1单成功
"""
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
# 配置
API_URL = "http://your-api.com/order/create"
PRODUCT_ID = "P001"
INITIAL_STOCK = 1
CONCURRENT_USERS = 10
# 用于收集结果
results = []
lock = threading.Lock()
def create_order(user_id):
"""模拟用户下单"""
try:
resp = requests.post(API_URL, json={
"product_id": PRODUCT_ID,
"quantity": 1,
"user_id": f"user_{user_id}",
}, timeout=10)
data = resp.json()
with lock:
results.append({
"user_id": user_id,
"success": data.get("code") == 200,
"order_id": data.get("data", {}).get("order_id"),
"message": data.get("message", ""),
})
return results[-1]
except Exception as e:
with lock:
results.append({"user_id": user_id, "success": False, "error": str(e)})
return results[-1]
def main():
print("=" * 60)
print("竞态条件测试:库存扣减(超卖)")
print("=" * 60)
print(f"商品: {PRODUCT_ID}, 初始库存: {INITIAL_STOCK}")
print(f"并发用户数: {CONCURRENT_USERS}")
print("预期: 仅1单成功")
print("=" * 60)
# 先重置库存为1(需根据实际接口调整)
# reset_stock(PRODUCT_ID, INITIAL_STOCK)
with ThreadPoolExecutor(max_workers=CONCURRENT_USERS) as executor:
futures = [executor.submit(create_order, i) for i in range(CONCURRENT_USERS)]
for f in as_completed(futures):
f.result()
success_count = sum(1 for r in results if r.get("success"))
print(f"\n结果: 成功 {success_count} 单, 失败 {CONCURRENT_USERS - success_count} 单")
print("\n验证步骤:")
print("1. 查询数据库: SELECT stock FROM product WHERE id = ?")
print("2. 预期: stock = 0 (若仅1单成功)")
print("3. 若 stock < 0 或 成功数 > 1 -> 存在超卖")
if success_count > 1:
print("\n[结论] 存在超卖风险!")
elif success_count == 1:
print("\n[结论] 通过: 仅1单成功")
else:
print("\n[结论] 需检查: 可能全部失败或接口异常")
if __name__ == "__main__":
main()
5.3 操作步骤
- 准备测试环境,确保有库存重置接口或可直接改库。
- 将商品库存设为 1。
- 修改
API_URL、PRODUCT_ID为实际值。 - 执行:
python race_stock_test.py - 统计成功订单数,并查库校验库存是否为 0。
六、实战二:k6 并发价格更新(乐观锁验证)
6.1 场景
多个请求同时更新同一商品价格,带 version 乐观锁。预期:version 最大者生效,且 price = version。
6.2 代码
javascript
/**
* 竞态条件测试:同一商品多版本并发更新
* 验证乐观锁:version 最大者生效,price 与 version 一致
*/
import http from 'k6/http';
import { check } from 'k6';
const VUS = 200;
const BASE_URL = 'https://api.example.com';
const PRODUCT_CODE = '25040500127163';
const STORE_CODE = 'MYV001';
let TOKEN = '';
export const options = {
vus: VUS,
iterations: VUS,
};
export function setup() {
// 登录获取 token(示例,需替换实际登录逻辑)
const loginResp = http.post(`${BASE_URL}/login`, JSON.stringify({
username: 'test',
passwd: 'xxx',
}), { headers: { 'Content-Type': 'application/json' } });
const body = JSON.parse(loginResp.body);
TOKEN = body.data?.token || '';
return { token: TOKEN };
}
export default function (data) {
const currentVersion = 100 + __VU;
const price = currentVersion;
const payload = JSON.stringify({
prices: [{
product_code: PRODUCT_CODE,
store_code: STORE_CODE,
price: price.toFixed(10),
version: currentVersion,
}],
});
const res = http.post(`${BASE_URL}/sync/batchPrice`, payload, {
headers: {
'Content-Type': 'application/json',
'Account-Token': data.token,
},
timeout: '30s',
});
check(res, {
'HTTP 200': (r) => r.status === 200,
'业务成功': (r) => {
try {
const b = JSON.parse(r.body);
return b.code === 200;
} catch (e) { return false; }
},
});
}
export function teardown(data) {
console.log('');
console.log('验证方法:');
console.log('1. 查库: SELECT product_code, price, version FROM product_price WHERE ...');
console.log('2. 预期: version = max(所有请求的version), price = version');
console.log('3. 若 price != version -> 存在并发覆盖问题');
}
6.3 操作步骤
- 安装 k6:
brew install k6(macOS) - 替换
BASE_URL、登录逻辑、商品编码、门店编码。 - 执行:
k6 run race_price_test.js - 测试结束后,查库校验
price与version是否一致。
七、实战三:优惠券领取(限量竞态)
7.1 场景
限量 5 张券,20 人同时领取,预期最多 5 人成功。
7.2 思路(无代码)
若暂时无法写脚本,可手动模拟:
- 准备:创建限量 5 张的优惠券活动。
- 并发:用 Postman Runner 或 Apifox 批量执行 20 个领取请求(并发数 20)。
- 校验 :
- 成功领取数 <= 5
- 数据库
coupon.remain_count>= 0 - 用户领取记录数 = 成功数
7.4 无代码时的完整测试思路
若暂时无法编写脚本,可按以下步骤执行:
- 明确共享资源:如优惠券剩余量、库存、价格 version。
- 准备边界数据:库存=1、券=1、或同商品多 version。
- 选择并发工具 :
- Postman Runner:设置 Iterations=20,Delay=0
- Apifox:批量执行,并发数调至最大
- 浏览器多开 + 手动同时点击(适合简单场景)
- 同时触发:尽量让请求在同一时刻发出。
- 结果校验 :
- 统计接口返回的成功数
- 查库核对:库存/券余量/version 是否符合预期
- 多轮执行:竞态非必现,可执行 5~10 轮提高发现概率。
7.5 校验 SQL 示例
sql
-- 券剩余量
SELECT remain_count FROM coupon_activity WHERE id = ?;
-- 领取记录数
SELECT COUNT(*) FROM coupon_receive WHERE activity_id = ?;
八、实战四:Python 通用并发模板
适用于任意接口的竞态测试,只需替换请求逻辑。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
竞态条件测试 - 通用并发模板
替换 request_fn 即可适配不同接口
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
def request_fn(request_id):
"""
替换为实际请求逻辑
返回: {"success": bool, "data": any}
"""
import requests
resp = requests.post("http://api.example.com/your-api", json={"id": request_id})
data = resp.json()
return {"success": data.get("code") == 200, "data": data}
def run_race_test(concurrent_count=50, request_fn=request_fn):
results = []
lock = threading.Lock()
with ThreadPoolExecutor(max_workers=concurrent_count) as executor:
futures = [executor.submit(request_fn, i) for i in range(concurrent_count)]
for f in as_completed(futures):
r = f.result()
with lock:
results.append(r)
success = sum(1 for r in results if r.get("success"))
print(f"并发数: {concurrent_count}, 成功: {success}, 失败: {concurrent_count - success}")
return results
if __name__ == "__main__":
run_race_test(concurrent_count=20)
九、验证方法汇总
| 场景 | 验证方式 | 预期 |
|---|---|---|
| 库存扣减 | SELECT stock FROM product WHERE id=? |
stock = 初始 - 成功订单数 |
| 优惠券 | SELECT remain_count FROM coupon_activity |
remain_count >= 0 |
| 价格更新 | SELECT price, version FROM product_price |
price = version = max(提交的version) |
| 订单状态 | SELECT status FROM order WHERE id=? |
符合状态机规则 |
十、常见问题与对策
| 问题 | 可能原因 | 对策 |
|---|---|---|
| 难以复现 | 并发不够或间隔大 | 提高并发数、减少 sleep |
| 全部失败 | 限流、鉴权、超时 | 检查限流配置、token、超时时间 |
| 结果不稳定 | 随机性、环境差异 | 多次执行、固定测试数据 |
| 不知道如何校验 | 不了解表结构 | 与开发确认读写表、字段含义 |
十一、总结
- 识别共享资源:库存、券、价格、状态等可能被并发修改的数据。
- 设计边界场景:库存 1、券 1、同商品多 version 等。
- 选对工具:Python 灵活,k6 适合接口压测。
- 必须做数据校验:不只依赖接口返回,要查库或业务数据。
- 流程图辅助设计:用流程图梳理「识别资源 -> 设计场景 -> 执行 -> 校验」全流程。
掌握以上思路与代码,可以系统化开展竞态条件测试,有效发现高并发下的数据一致性问题。