幂等测试

本书作者:测试开发刚哥,文章版权归个人所有,未经授权请勿转载。

连续调用两次,第一次提交成功,第二次会被驳回——串行重复,系统是防住了的。

但这个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却失败了。这属于「后置动作失败」,要靠事务、本地消息表、或发券失败的补偿重试来兜底,跟幂等没关系。

在做幂等测试时,多从并发角度考虑。