幂等测试
本书作者:测试开发刚哥,文章版权归个人所有,未经授权请勿转载。
连续调用两次,第一次提交成功,第二次会被驳回——串行重复,系统是防住了的。
但这个BUG是在任务调度时触发的,任务高并发模式下,并行调用了提交申请接口,两个请求几乎同时进来,最终出现了两个都提交成功的申请单。
一、幂等测试是什么
重复:连续请求、超时重试、消息重发
并发:极短时间内,1毫秒,同时有多个请求
重复和并发的区别在于,两个请求的时间间隔,重复的请求之间有间隔,并发的请求之间几乎没有间隔。
很多代码只能防住重复,却防不了并发。在制定测试策略时,要考虑并发幂等的处理。
二、两种实现方式的漏洞
方式1:先查后写的空窗
这是最经典的一种“假幂等”。下面用支付回调来举例,订单支付成功后给用户发优惠券。
def pay_callback(order_no):
if db.get(order_no) is None: # 并发时两个请求都查到 None
db.insert(order_no)
grant_coupon(order_no) # 于是券发了两张
return "success"
db.get(order_no),两个并发请求都查到空,都会进到if分支,造成重复执行,幂等失效。支付成功后发了两张券。
换成状态机也是一样的问题。数据库已经存在记录,靠状态值来判断该不该发券:
def pay_callback(order_no):
record = db.query("SELECT status FROM pay_record WHERE order_no = ?", order_no)
if record.status == 'INIT': # ← 应用层判断
db.execute("UPDATE pay_record SET status = 'PAID' WHERE order_no = ?", order_no)
grant_coupon(order_no) # 发券
两个并发请求同时读到'INIT',都能通过if判断,都能执行完,券还是发了两张。
根因是同一个:先查后写,查和写分成两步,中间有个时间空窗。并发的两个请求都挤在空窗里通过了检查,于是都往下执行。
方式2:分布式锁的释放
def pay_callback(order_no):
with lock(order_no):
exists = db.get(order_no) is not None # 锁只盖住这一句
# ← 锁在这里就释放了!
if not exists:
db.insert(order_no)
grant_coupon(order_no) # 发券在锁外面
return "success"
加了分布式锁,但是锁的粒度没有覆盖整个业务,两个并发请求还是都能执行完,也防不住幂等。
三、幂等怎么测
幂等测试的本质,同一个请求,不管发几次、怎么发,实际结果都跟发一次一样。
哪些场景涉及到幂等?
- 串行重复请求,比如用户连续点击多次
- 并行请求
- 消息重试,超时或异常(最容易忽略)
有哪些幂等测试手段?
- 相同参数连续调多次
- 使用多线程/JMeter并发压测
- 在“检查”和“执行”之间插延迟,把毫秒级竞争窗口加大
- 第1次超时或报错后再调
- 执行到一半中断再重试
- 重复发送相同的消息体
- 幂等标识的值边界,比如requestId空值/超长/特殊字符
幂等验证哪些结果?
- 重复请求返回相同结果
- 结果数据不翻倍(重点)
- 不同幂等key视为不同请求
四、幂等处理的正确姿势
核心:数据库。
Redis分布式锁会超时释放、进程崩溃丢锁、主从切换失效,只能减少冲突,无法保证真的幂等。
真正的幂等是数据库的原子性。
# 方式:把判断放进 SQL,一条语句的原子性
rows = db.execute("""
UPDATE pay_record SET status = 'PAID'
WHERE order_no = ? AND status = 'INIT'
""", order_no)
if rows == 0:
return "已处理" # 别人改过了,我不是第一个
grant_coupon(order_no) # 只有改成功的那个才发券
return "success"
要说明的是,这条SQL解决的是并发下的重复执行——同一时刻只有一个请求能把INIT改成PAID,从根上杜绝了发两张券。
但它没解决另一个问题:改状态成功、grant_coupon却失败了。这属于「后置动作失败」,要靠事务、本地消息表、或发券失败的补偿重试来兜底,跟幂等没关系。
在做幂等测试时,多从并发角度考虑。