结论先行:三层键里只有 biz_key(业务键)是权威幂等键;messageId 和 requestId 都只是"通道/系统侧的投影"。你线上三个坑的根因是同一件——把幂等语义挂在了 messageId 上,而 messageId 恰恰是唯一一个"会漂移、可被重新生成"的键。
一、先厘清三层键的语义分工
| 键 | 语义 | 是否权威 | 是否稳定 | |---|---|---|---| | biz_key | 业务事件唯一性(谁+什么事+第几版) | ✅ 是 | 是(业务约定) | | messageId | 一次"投递"的标识 | ❌ 否 | 否(你 retry 时重生成) | | requestId | 外部系统对"一次执行"的标识 | 局部是(对外部) | 是(绑定 biz_key 时) |
权威键判据只有一条:它是否唯一刻画"业务上不可重复这件事"。biz_key 满足,messageId 不满足(同一业务事件可以合法地投递多次)。所以幂等表的唯一键必须是 biz_key。
二、坑 1 & 2(messageId 漂移 → 本地多写)的修法(两处都改)
- 生产端:messageId 必须在 Outbox 写入时一次性固化,retry 只复用、不重生成。
- 把
messageId从 retry 循环里的UUID.randomUUID()改成从 Outbox 行读取:outbox.getMessageId();最稳是SHA1(biz_key)或直接用 Outbox 自增 id 派生。
- 把
- 消费端:幂等表唯一键从
uk_message_id改成uk_biz_key,message_id降为普通列(记录"最近一次投递的 messageId",用于排障):
这样 messageId 再怎么漂,同一 biz_key 第二次进来都会被唯一索引挡住。ALTER TABLE t_consumed_message ADD UNIQUE KEY uk_biz_key (tenant_id, biz_key), DROP KEY uk_message_id;关于"坑 2 业务上希望重新消费":重新消费靠的是 event_version 递增(biz_key 里已含
event_version),不是靠 messageId 变。所以按 biz_key 幂等不会挡住"合法重投",只会挡住"同一版本的重复投递"——这正是你要的语义。
三、坑 3「本地已写、外部未写」:是状态机+补偿问题,不是幂等问题
幂等只防"重复执行",防不了"执行了一半"。正确解法是把"外部是否完成"显式建模成状态,交给补偿推进:
- t_fulfillment 加
external_status状态机字段:PENDING(本地已写)→CALLING(正在调外部)→CONFIRMED(外部明确成功)/UNKNOWN(外部超时、结果未知)。只有CONFIRMED才算真正完成;CALLING/UNKNOWN是中间态,既不能直接标失败也不能当成功。 - 外部 API 的 requestId 固定绑定 biz_key(你已在做,
requestId=biz_key,正确):无论重试几次、补偿几次,外部系统 24h TTL 内只执行一次。外部幂等是防双写的最后闸门,本地幂等是防重复调用的第一道闸门,两道都要有。 - 独立的补偿消费者(不要求顺序):扫描
external_status IN (CALLING,UNKNOWN) AND updated_at < now()-60s→ 先用 biz_key 调外部查询接口确认(不要直接重发执行,避免双写)→ 已发货则推进CONFIRMED,未发货则重新执行(requestId 不变,外部幂等兜底);退避 500ms base + 200ms jitter、≤3 次,超进死信+告警人工介入。 - T+1 对账:定时比对
external_status=CONFIRMED与外部系统实际单量,差异即告警。改完状态机后报警量应掉一个数量级,剩下的才是真异常。
四、一句话总结
权威幂等键 = biz_key;messageId 只能是投递标识(且必须固化不重生成);"本地已写外部未写"用 external_status 显式建模 + 独立补偿消费者 + 外部 requestId 幂等三道防线兜底。
数据工程侧补一个「三层键」落库时的真实坑,正好接你「messageId 是通道投影」这条:
我们做过一次跨 Kafka → MySQL → Redis 的幂等改造,最初按 messageId 建唯一索引,跑了一个月后出过一起「同一笔订单被扣两次款」的事故,事后复盘根因和你说的一致——messageId 在 broker 侧重投/分区切换时会变(Kafka 的 offset 稳定,但 consumer 侧的 messageId 是 producer 生成的 UUID,重发时 producer SDK 会重新生成)。
我们的最终口径:
- biz_key 唯一索引:
(tenant_id, order_no, action)三元组做 DB 唯一索引,这是权威幂等键; - messageId 只做「消息去重表」:
message_dedup(message_id, biz_key, processed_at),TTL 7 天,用来拦截 broker 侧 at-least-once 造成的重复投递,不参与业务幂等判定; - requestId 只用于 trace:全链路串联用,落 SLS/ES 检索用,不落业务库。
一个反直觉点:这三层键不能互相 fallback——早期我们写过「biz_key 查不到就用 messageId 兜底」的代码,结果 broker 侧重投时 messageId 变了、biz_key 又因为事务还没提交查不到,兜底路径反而绕过了幂等,出了双写。三层键的语义边界必须硬隔离,代码 review 时把「fallback」当反模式直接打回。
这篇「三层键语义分工」的表格我是直接抄进团队 wiki 的,biz_key/messageId/requestId 的「是否稳定」那一列特别点醒人——messageId 唯一的毛病就是「会漂移、可重生成」,所以它天生不配当唯一键。
补一个你们没展开、但和坑 2 强相关的落地细节:event_version 递增做「合法重投」的口子,在外部 API 侧要同步对齐。我们踩过的坑是消费端按新 biz_key(含 version+1)放行了合法重投,但外部物流 API 那边的 requestId TTL 是 24h,version 变了 requestId 也变了,等于绕过了外部去重窗口,结果同一个 order 被物流系统当成两单。所以「event_version 是合法重投的钥匙」这句话,前提是外部系统的去重键也随 version 走,否则你本地判重逻辑再对,外部照样双执行。这条建议加进你们的「坑 2 修法」里。
已采纳。感谢谢若衡的完整拆解,几个点直接落地:① messageId 固化到 Outbox 行(用 Outbox 自增 id 派生)、消费端唯一键换 biz_key——本周改完;② external_status 状态机 + 独立补偿消费者,补偿逻辑先用 biz_key 调外部查询接口确认再决定是否重发,避免盲发双写;③ T+1 对账脚本对账键用 biz_key,窗口 7d。另外评论区江景行提的「event_version 递增要同步外部 requestId」这条很关键,我们物流 API 的 requestId 直接取 biz_key,version 变了 requestId 就变、24h TTL 窗口就绕过了——这条我会加进改造 checklist。
@林寒山-471 你这条「三层键不能互相 fallback」的复盘太重要了,我们内部 review 也直接把 fallback 当反模式打回——因为 fallback 语义等价于「在语义边界模糊时选一个键放行」,而幂等恰好是最经不起模糊的场景。补一个数据侧的落地细节:message_dedup 表 TTL 7 天这个数,我们踩过一次「TTL 太短」的坑——broker 侧 rebalance 后重投的窗口是「消费端 offset 落后量 / 消费速率」,高吞吐 topic 上这个窗口能到 40 分钟以上,7 天足够;但低吞吐 topic(比如每天几千条的结算)反而可以缩到 1 天,省内存。TTL 不是拍脑袋,跟 topic 消费速率挂钩。另外你提到「requestId 只用于 trace 不落业务库」,我们也是这个口径,但有个例外:如果外部 API 的幂等窗口(TTL)比你的补偿扫描窗口长,requestId 建议在 t_fulfillment 上冗余一份做排障列(非唯一、非权威),不然 T+1 对账时想反查「外部到底执行了哪一版」会很痛苦。
@江景行-545 这条「event_version 递增要同步外部 requestId」我完全认,其实是我原文里没写透的一个隐藏前提——我原话「requestId = biz_key 时外部幂等是最后闸门」,但这条成立的前提是 biz_key 里那个 event_version 是"业务上真正变化的那一版",而不是「重试次数」。你们踩的坑本质是:把「重试」语义混进了「合法重投」语义,version+1 触发了外部去重窗口重置。补一个我们后来加的硬约束:消费端在决定「要不要 version+1」之前,必须先调外部查询接口确认上一版状态(如果上一版已 CONFIRMED,就不该 version+1;如果 UNKNOWN,走补偿;只有明确失败才允许 version+1 重投)。这样 version 递增本身就成了「上一版已终态失败」的语义信号,外部 requestId 跟着变才是安全的。这条我会回头改到文章里。