2794 字
约 9 分钟
16
S18-X11 · 幂等的边界:副作用落在哪,幂等就该加在哪

同一个下单请求被处理两次,多出一个订单、库存扣两次。排查结论常是"网关加个锁就好",但锁只解决同时到达,解决不了隔五分钟再来一次。真正要回答的是:这次调用产生哪些副作用,每个落在哪一层,就在那层做幂等。

幂等不是给接口加开关,是逐个副作用做设计。


1. 判定标准与"重复请求"不是一件事

概念 定义 对策
幂等 执行一次与执行 N 次,最终状态相同 逐个副作用设计
重复请求 同一业务意图被投递多次 幂等键 + 唯一约束
并发请求 多个请求时间窗重叠 锁或条件更新
重放 历史请求被重新提交,间隔可能很长 幂等键有效期足够长

四行要分开,对策不同。把"并发"与"重复"混为一谈,就会写出"加锁 3 秒"这类只挡瞬时的方案。

还有一条容易忽略:"状态相同"不等于"副作用次数相同"。扣库存两次又回补两次,库存一样但流水翻倍,对账就错。判定要同时看两者。


2. 先列副作用,再决定加在哪一层

副作用 所在层 幂等手段 不做会怎样
插入业务记录 DAO 唯一索引 重复订单
更新状态 DAO WHERE status=旧值 状态被回退
扣减库存 DAO / 库存服务 唯一流水 + 条件更新 超卖、负库存
发 MQ 消息 应用层 去重键 / 消费端幂等 下游重复处理
调外部接口 集成层 透传幂等键 重复扣款、重复发短信
生成文件、发邮件 应用层 幂等表记已完成 重复发送
记流水、审计 DAO 唯一索引 流水翻倍

第二、四行是重灾区。状态更新用"先查后改"会在并发下回退;发消息若不解决"事务成功但消息没出去或发重了",就得下游兜。幂等责任永远在产生副作用那一侧。


3. 五种实现与适用面

方案 机制 适用 代价
唯一索引 / 去重表 DB 约束拒绝重复插入 创建类、有业务键 要捕获冲突并转成成功
状态机前置校验 只有合法前置状态才变更 有状态流转 并发下有窗口
幂等键 + 结果缓存 命中键直接返回上次结果 对外接口、重试频繁 要存储与过期策略
分布式锁 同键串行化 短时并发 只防并发、不防重放
乐观锁版本号 version 匹配才更新 更新类操作 冲突重试,ABA
CREATE TABLE t_pay_record (
  id         BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_no     VARCHAR(64) NOT NULL,
  order_id   BIGINT NOT NULL,
  created_at DATETIME NOT NULL,
  UNIQUE KEY uk_biz_no (biz_no)
);
// 冲突即成功:走到这里说明已经处理过
try {
    payRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
    return payRecordMapper.selectByBizNo(record.getBizNo());
}

捕获必须显式写:不捕,重复请求变 500;捕获后当成功返回才是幂等语义。异常类型看驱动,有时抛 DataIntegrityViolationException,catch 的父类要选对。

// 幂等键 + 结果缓存:两阶段写入,"处理中"不能被当成"已完成"
String key = "idem:" + idemKey;
if (Boolean.FALSE.equals(redis.opsForValue().setIfAbsent(key, "PROCESSING", TTL))) {
    if ("PROCESSING".equals(redis.opsForValue().get(key))) {
        throw new ConflictException("in progress");     // 让调用方稍后重试
    }
    return Result.fromJson(redis.opsForValue().get(key + ":result"));
}
try {
    Result r = action.get();
    redis.opsForValue().set(key + ":result", r.toJson(), TTL);
    redis.opsForValue().set(key, "DONE", TTL);
    return r;
} catch (RuntimeException e) {
    redis.delete(key);                                 // 失败必须释放
    throw e;
}

PROCESSING 不能省。只占位不写中间态,就会出现"第一个请求还在跑,第二个读到空值直接返回 null"。

// 分布式锁:只防并发
Boolean ok = redis.opsForValue().setIfAbsent("lock:order:" + orderId, token, Duration.ofSeconds(10));
if (Boolean.FALSE.equals(ok)) throw new ConflictException("in progress");
try { doCreateOrder(orderId); }                        // 内部仍须唯一索引兜底
finally { unlockIfTokenMatch("lock:order:" + orderId, token); }

释放必须校验 token,否则误删别人的锁。锁只覆盖 10 秒,挡重放靠幂等键有效期。


4. 幂等键的设计

要素 选择 影响
组成 业务唯一键 / 客户端 UUID / 组合 业务键可重算,UUID 只能透传
生成方 客户端(Idempotency-Key 头) 服务端要校验格式与归属
服务端(由业务参数派生) 更可靠,要求业务键稳定
有效期 ≥ 重试窗口 + MQ 重投窗口 太短等于没做
存储 Redis → 幂等表 → 唯一索引 可靠性递增,成本递增
// 服务端派生:同一业务意图必定得到同一个键
return DigestUtils.sha256Hex(String.join("|",
        req.getUserId() + "", req.getSkuId() + "", req.getQuantity() + "",
        req.getClientOrderNo()));      // 客户端单号是稳定锚点

最高频的错误是把时间戳放进键:userId + timestamp,每次请求都不同,键永不重复,幂等等于没做。用每次新生成的 requestId 当键也是同一个坑。

有效期要算清:MQ 最长重投 24 小时、人工补数据可能隔天重放,1 小时缓存会把后两类漏掉。


5. 幂等与事务

情形 幂等表位置 做法
同库 与业务表同库 同一事务,一起回滚
跨库 各自库内 业务库记状态 + 补偿
幂等表在 Redis 与 DB 不同源 只能减少重复,不能保证唯一
调外部接口 无事务 透传幂等键,或本地记"已调用"
@Transactional
public void handle(PayCallback cb) {
    payRecordMapper.insertIgnore(cb.toRecord());   // 幂等记录与业务变更同事务
    orderMapper.markPaid(cb.getOrderId());
    stockMapper.deduct(cb.getSkuId(), cb.getQty());
}

INSERT IGNORE 比捕获异常好:异常会把事务标成 rollback-only,Spring 里表现为 UnexpectedRollbackException,排查很痛。坚持用异常,就得把幂等写入放进独立事务。

第三行是边界:Redis 只能降低重复概率,唯一性必须靠 DB 约束。


6. 接口层与消费层分开做

层 重复来源 幂等依据 不能复用对方的原因
接口层 双击、网关重试、超时重发 请求头 / 业务键 没有位点概念
消费层 至少一次投递、rebalance、位点回退 业务键 + 事件类型 没有 HTTP 请求头

两层的键长得像但生命周期不同:接口层按用户重试窗口(分钟到小时),消费层要覆盖重投与位点回退(小时到天)。

@KafkaListener(topics = "order-paid", groupId = "settle")
public void onMessage(String payload) {
    OrderPaidEvent e = Json.decode(payload, OrderPaidEvent.class);
    String key = e.getOrderId() + ":SETTLE";      // 事件类型进键,避免跨事件串味
    if (idemRepo.exists(key)) return;             // 已处理
    settleService.settle(e);                      // 内部另有唯一索引兜底
    idemRepo.mark(key, Duration.ofDays(7));
}

顺序是先处理再标记。反过来在崩溃时会永久跳过这条消息,变成漏处理——比重复严重。


7. 部分成功:中途失败怎么重试

四步流程"扣库存 → 建订单 → 发券 → 发消息",第三步失败,重试时不能把前两步再做一遍。

做法 机制 代价
步骤表 + 唯一索引 (biz_key, step) 唯一,每步成功即落记录 多一张表、多一次写
状态机断点续跑 状态字段标记走到哪步 状态组合会膨胀
全包一个事务 同库可行 跨服务不成立
补偿(Saga) 每步有反向操作 补偿本身也要幂等
CREATE TABLE t_idem_step (
  biz_key VARCHAR(64) NOT NULL,
  step    VARCHAR(32) NOT NULL,
  done_at DATETIME NOT NULL,
  PRIMARY KEY (biz_key, step)
);
private void runStep(String bizKey, String step, Runnable action) {
    if (stepMapper.tryInsert(bizKey, step) == 0) return;   // 该步已完成,跳过
    action.run();
}

重试时从第一步跑起,已完成的秒过,只有失败那步真正执行。代价是"步骤"要提前设计。


8. 观测

指标 含义 异常信号
幂等命中次数 重复请求被拦下的次数 长期为 0
重复率 命中 / 总请求 突增说明重试风暴
唯一键冲突数 DuplicateKeyException 计数 与重复率不吻合
冲突分布 按业务类型统计 集中在某接口
meterRegistry.counter("idem.hit", "biz", bizType).increment();

"长期为 0"最值得警惕:要么上游从不重试,要么幂等键每次都不同,后者等于没做。


9. 排错

现象 原因 处理
同一订单出现两条 只在入口加锁,DB 无唯一键 补唯一索引,锁只作辅助
幂等一直不命中 键里含时间戳或随机 UUID 改用业务键派生
重试后数据缺失 先标记后处理 改成先处理再标记
幂等表有记录但业务没执行 幂等写入在独立事务,业务回滚了 同事务,或用 insertIgnore
缓存过期后重复 TTL 小于重试窗口 延长 TTL 覆盖最大重放间隔
锁被误删 释放未校验 token 比对 token 后删除
UnexpectedRollbackException 用异常做幂等判断污染事务 换 insertIgnore

10. 边界

场景 建议 理由
查询类接口 天然幂等 无副作用
创建类、有业务键 唯一索引 最可靠、成本最低
无天然唯一键 客户端幂等键 服务端无从派生
跨服务多步 步骤表 + 补偿 单事务不成立
热点秒杀 唯一索引 + 前置限流 锁在热点上成瓶颈
内部批处理重跑 幂等键 + 结果表 重跑是常态

一句话:幂等的位置由副作用的位置决定。写在 DAO 的约束管创建,写在消费端的去重管消息;在入口加一把锁,只挡住并发,重放照样穿透。


11. 自测

  1. "状态相同"为什么不足以判定幂等?
  2. 分布式锁为什么挡不住隔天重放?
  3. 幂等键里含时间戳会导致什么后果?
  4. 为什么建议 insertIgnore 而不是捕获唯一键异常?
  5. 消费端为什么必须"先处理再标记"?
  6. 步骤表方案在重试时是怎么跳过已完成步骤的?
S18-X11 · 幂等的边界:副作用落在哪,幂等就该加在哪
https://www.clxhxhhr.top/posts/5812/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。