背景 上周我们一条退款链路出了一次典型的「幂等设计翻车」:退款服务对同一笔订单 R-20240618-000713 做了三步副作用——① 调支付渠道冲正(外部 API)、② 写账务流水(MySQL 唯一键 order_no + biz_type)、③ 发站内信通知。第 ② 步因 DB 慢查询超时失败,消费者按 at-least-once 重试。重试时我们幂等键用的是 Kafka 的 (topic, partition, offset) + 消费组实例 ID,于是第 ① 步被判定为「新消息」又调了一次渠道冲正,第 ② 步这次成功了。结果:渠道侧两笔冲正,账务侧一笔流水——两边对不上,人工对账补了两天。
常见分歧(我在团队里听过三种典型立场)
- 消息级幂等:以 messageId / (partition, offset) 为去重键,Redis SETNX + TTL(通常 24~72h)。优点是简单、无业务侵入;缺点是重试换了消息 ID 就失效,且「同一笔业务、两次投递」和「两次投递、同一笔业务」分不开。
- 业务级幂等:以业务主键(orderNo + actionType)为去重键,落 DB 唯一约束。优点是语义正确、天然抗重放;缺点是每加一个副作用都要显式声明幂等键,且「部分成功 + 重试」时键已存在,新步骤会被跳过(上面例子里如果把 ① 也挂到 orderNo 键下,重试时 ① 会被跳过,账务侧反而缺数据)。
- 混合:消息级做粗筛挡重复投递,业务级做细筛挡语义重复,两层都过才执行。工程上最稳,但实现成本最高,且两层键的 TTL / 清理策略要分别维护。
我的倾向与理由 我倾向 以业务操作为幂等键的「细粒度状态机」,而不是单纯的业务主键去重。具体做法:每笔业务操作有一个 outbox 表,记录 (biz_key, step_seq, step_status, idempotent_payload),每个副作用步骤对应一行;执行前先查该行,SUCCESS 则跳过,PENDING 则重新执行(要求该步骤本身可重入),FAILED 走补偿。渠道冲正这种外部调用,把渠道返回的 requestId 也存进 outbox,重试时优先用同一 requestId 调渠道的「查询」接口确认状态,而不是盲目重调。这套东西不新,本质是把 Saga 的补偿语义和幂等键绑在一起,但好处是「部分失败 + 重试」不再二选一。
代价也得说清楚:每加一步副作用要改表、改代码,团队里新同学上手成本高;outbox 表需要清理策略(我们按 biz_key 分区 + 90 天归档);而且它只保证「同一业务操作」的幂等,跨业务操作的乱序(比如退款和改单并发)还是得靠业务层加锁。
【修订补充 1:outbox 状态机的 DDL 骨架(之前说"拆出来发一篇",现在直接补在这)】
CREATE TABLE outbox_step (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_key VARCHAR(128) NOT NULL, -- 业务主键(orderNo + actionType 的组合哈希)
step_seq TINYINT NOT NULL, -- 步骤序号:1=冲正, 2=账务, 3=通知
step_status TINYINT NOT NULL, -- 0=PENDING, 1=SUCCESS, 2=FAILED, 3=COMPENSATED
idempotent_payload JSON, -- 该步骤的幂等 payload(含渠道 requestId)
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_step (biz_key, step_seq),
KEY idx_status_retry (step_status, next_retry_at)
) PARTITION BY HASH (biz_key) PARTITIONS 256;
执行伪代码(每步都幂等可重入):
def execute_step(biz_key, step_seq):
row = SELECT * FROM outbox_step WHERE biz_key=? AND step_seq=?
if row.step_status == SUCCESS:
return SKIP # 已成功,跳过
if row.step_status == PENDING:
# 先查外部 API 状态(用 idempotent_payload 里的 requestId),避免盲目重调
if is_external(step_seq):
state = query_external(request_id=row.idempotent_payload['request_id'])
if state == 'DONE':
mark_success(row); return SKIP
do_step(step_seq) # 重入执行
mark_success(row)
elif row.step_status == FAILED and retry_count < MAX:
do_step_with_compensation(step_seq)
mark_success(row); retry_count += 1
else:
raise CompensateRequired(biz_key, step_seq)
关键约束:step_status = PENDING 的步骤必须可重入——渠道冲正、写账务、发通知这三步都满足(前两步靠 requestId/唯一键,第三步靠站内信自己的 messageId 去重)。
【修订补充 2:上游"重投窗口"才是幂等 TTL 的真正锚点(之前只说"90 天归档",没解释为什么)】 幂等表的 TTL 不是拍脑袋定的 90 天,而是按上游最大重投窗口 + 对账周期反推:
TTL = max(
上游最大重投窗口, # Kafka ISR + 消费者 max.poll.interval + rebalance 时间
对账周期, # 财务对账 T+1,但跨月冲正可能 T+30
下游确认窗口 # 渠道冲正的"最终确认"通常 T+3(退款渠道侧的清算周期)
) + 安全余量
我们这条退款链路的实际取值:
- 上游最大重投窗口:Kafka ISR 3 副本 + max.poll.interval 5min + rebalance 30s ≈ 10min(但跨月冲正场景会到 T+30)
- 对账周期:T+1(财务)
- 渠道确认窗口:T+3(清算)
- 取 max = T+30,加 60 天安全余量 = 90 天
所以 90 天不是"拍脑袋",是渠道清算周期(T+3)+ 跨月冲正场景(T+30)+ 60 天安全余量三段叠加。如果你们的渠道清算周期是 T+7,TTL 就该定到 120 天,而不是 90 天——TTL 定短了,跨月冲正场景下幂等失效,和"幂等键取错"是同一类事故。
【修订补充 3:网络抖动 → 误踢 → 重投 这条下游链路(之前没提,现在补上)】 幂等失效不只是"键取错",还有一个网络抖动 → Kafka 消费者误踢 → 重投的源头链路,容易被忽略:
网络抖动(conntrack GC / softnet backlog / 链路拥塞)
→ Kafka consumer 心跳超时 / poll 超时
→ rebalance + 重投(at-least-once 语义)
→ 消费者侧幂等键失效(msgId / offset 重生成)
→ 下游副作用重复执行
→ 资损 / 数据不一致
验证方法(在排查网络抖动时加一步):
# 抖动时段抓 Kafka consumer 的 rebalance 事件
kafka-consumer-groups --describe --group <group> --bootstrap-server <broker>
# 看 consumer 的 heartbeat 超时日志
grep -i "has failed to heartbeat\|rebalance" /var/log/kafka/consumer.log
# 把网络抖动时刻和 rebalance 时刻做时间对齐
# 如果 80% 以上的 rebalance 都发生在网络抖动窗口内,
# 结论就坐实是"网络抖动 → 误踢 → 重投"这条链路
治法(在修网络之外加两步):
- 消费者侧幂等键不要依赖 msgId / offset,用业务主键 + 事件类型 + 业务版本号(即本文 outbox 状态机的做法);
session.timeout和heartbeat.interval按网络抖动的 P99 时长反推(我们设 heartbeat=10s、session=30s,比默认值宽松 30%),给抖动尖刺留缓冲。
一句话:幂等失效不只是"键取错",它还是"网络抖动 → 误踢 → 重投"这条链路的下游表现。单点修网络不修幂等,重投照样翻车;单点修幂等不修网络,重投概率只是变低,不会变 0。
想听大家
- 你们生产上用的判定单位是什么?纯消息级 / 纯业务级 / 混合?各自的「翻车案例」是什么?
- 部分失败重试时,你们怎么保证「已成功的步骤不重做、未成功的步骤能补做」?是 outbox 状态机、TCC,还是干脆人工介入?
- 外部 API(支付渠道、短信网关)的幂等,你们是要求对方支持 requestId 复用,还是自己维护一张「调用日志」表做对账?
- 有没有人踩过「幂等键本身设计错」的坑——比如用 UUID 当键、用时间戳当键——导致幂等形同虚设?
- 有没有人踩过"网络抖动 → 误踢 → 重投"这条链路?你们怎么把网络抖动和 rebalance 时刻对齐的?
欢迎贴具体配置、表结构、踩坑时间线。