先给结论:用「一笔业务一个状态机表(你的方案 B)+ 幂等键落在状态表的唯一键上 + TTL 必须 ≥ offset retention + RPC 不幂等走『先记后发 + 对账补偿』,这四件事同时做到,半完成态问题才能收敛。下面按你列的四个期望逐条给可落地细节。
1. 幂等键粒度:一笔业务一个状态机,不是每步一个键
你的方案 B(biz_operation 表)方向是对的,但有三个实现细节必须拧紧,否则它只是「换了个地方分散幂等」:
状态表的 schema 建议(MySQL 8.0):
CREATE TABLE biz_operation (
biz_id VARCHAR(64) NOT NULL COMMENT '业务单号,唯一锚点',
status TINYINT NOT NULL COMMENT '0=INIT 1=DEBIT_DONE 2=LEDGER_DONE 3=NOTIFY_DONE 4=DONE',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'biz_id + 步骤序号,防重',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (biz_id),
UNIQUE KEY uk_idem (idempotency_key),
KEY idx_status_updated (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键:状态推进和业务操作必须在同一个 MySQL 事务里。 你已意识到「同库可以放同一事务」,这是对的,但要明确事务边界:
@Transactional
void executeStep1(String bizId) {
// 1) 状态检查 + 推进(CAS 语义)
int affected = mapper.casStatus(bizId, STATUS_INIT, STATUS_DEBIT_DONE);
if (affected == 0) {
// 已经是 DEBIT_DONE 或更高 → 幂等跳过
return;
}
// 2) 扣款(同事务)
int rows = accountMapper.debit(accountId, amount);
if (rows == 0) throw new InsufficientBalanceException(bizId);
}
casStatus 的 SQL:UPDATE biz_operation SET status = ? WHERE biz_id = ? AND status = ?——这就是你的「每步幂等键」,不需要额外加 idempotency_key 列做唯一约束(除非你要跨业务类型防重)。状态机天然就是幂等:status 只能往前走,不能回退,重复执行时 affected = 0 直接返回。
并发安全(你问的 rebalance 窗口): 用 CAS UPDATE 而不是 SELECT FOR UPDATE。原因:
FOR UPDATE持锁时间长(后续还有 RPC 调用),rebalance 窗口内两个实例同时锁同一行,后到的要等前一个事务结束(可能 3s+),线程堆积;CAS UPDATE是行锁 + 立即返回 affected rows,失败方立刻知道「我输了」,零等待;- 32 分区、6 实例的场景下,同一
biz_id被两个实例同时处理的概率极低(Kafka 分区内有序 + rebalance 前旧实例已pause()),CAS 足够。
2. TTL 与 offset retention 对齐:幂等表保留期 ≥ offset retention + 最大重放窗口
你问「有没有人踩过幂等键过期但 offset 还在的窗口」——这是真实会踩的坑,而且比你想的更隐蔽。
量化计算:
- Kafka 默认
offsets.retention.minutes = 10080(7 天),你 4200 万条/天 × 7 天 ≈ 3 亿条 offset 记录; - 如果某条消息在 offset 中「卡住」了 6 天(极端情况:broker 故障后 offset 推进异常),第 7 天 offset 过期被清,但你的
biz_operation表如果 TTL 设了 30 天——没问题,状态还在,幂等能兜住; - 反过来:如果你的
biz_operation表 TTL 设了 7 天,而 offset retention 也是 7 天——存在一个窗口:消息在 offset 中存活到第 7 天被重读,但biz_operation记录在第 7 天 0 点被清理任务删掉 → 幂等失效。
安全规则:biz_operation 保留期 ≥ offsets.retention + 最大业务重放窗口(通常取 7 天)+ 安全余量(7 天)= 至少 21 天。
清理策略建议:
-- 不要直接 DELETE(大表 DELETE 会锁行 + undo 膨胀)
-- 用「软删除 + 分区表」或「按月归档」
ALTER TABLE biz_operation PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
PARTITION p202610 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01')),
PARTITION p202611 VALUES LESS THAN (UNIX_TIMESTAMP('2026-12-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- 归档:DROP PARTITION 比 DELETE 快且无 undo 压力
-- 只 DROP status = DONE 且 updated_at < NOW() - INTERVAL 21 DAY 的分区
极晚重读的兜底: 如果一条消息真的在 30 天后被重读(offset retention 没覆盖到),你的状态表记录还在(21 天 TTL 已过),这时靠 ledger_entry 的 biz_order_id + direction 唯一键做第二道防线——扣款会重放,但记账会被唯一键挡住,对账脚本 T+1 能发现扣款异常。这是「最后一道兜底」,不是常态路径。
3. RPC 不幂等:「先记后发 + 对账补偿」,责任边界在 consumer 侧
你问「谁的责任边界」——在 consumer 侧,理由:
- 下游(风控/清结算)是黑盒,你无法要求它们改代码加幂等(15 年全栈你比我清楚,跨团队推动的成本);
- consumer 是唯一**知道「这笔业务该不该发通知」**的角色,只有它能做「先记后发」的决策。
具体实现:
@Transactional
void executeStep3(String bizId) {
// 1) CAS 推进状态
int affected = mapper.casStatus(bizId, STATUS_LEDGER_DONE, STATUS_NOTIFY_DONE);
if (affected == 0) return; // 已通知过,幂等跳过
// 2) 写 notification_log(同事务,先记)
notificationMapper.insert(bizId, notifyPayload, NOT_SENT);
// 3) 发 RPC(同事务外!)
try {
rpcClient.notify(bizId, payload);
notificationMapper.markSent(bizId); // 单独事务,标记已发
} catch (TimeoutException e) {
// RPC 超时 → 状态停在 NOTIFY_DONE(已记、未确认)
// 对账任务负责补偿
}
}
对账补偿任务(独立 cron,每 5 分钟跑一次):
-- 找出「已记但状态不明确」的通知
SELECT biz_id, payload FROM notification_log
WHERE status = 'NOT_SENT'
AND created_at < NOW() - INTERVAL 30 SECOND
LIMIT 100;
对账任务逐条重发 RPC,重发前先查下游(如果下游有查询接口)确认是否已收到;如果下游没有查询接口,就靠下游自己的幂等兜底(风控系统通常有 request_id 去重,你 10 月那次误冻 2 个账户 3 分钟,说明风控侧其实有去重逻辑但你的 request_id 没传对——检查是不是每次重发都用了同一个 request_id,而不是每次生成新的)。
关键:request_id 必须等于 biz_id(或 biz_id + step),不能每次重发生成新的 UUID。 这是你 10 月事故的根因之一——风控侧按 request_id 去重,你每次重发换了 ID,风控就认为是新请求。
4. 半完成态排查:一条命令查清「这笔业务卡在哪一步」
biz_operation 表 + 一个简单 CLI,比 ELK 串日志高效 10 倍:
# 查某笔业务的完整状态
$ bizops status --biz-id SETTLE-20261024-001234
biz_id: SETTLE-20261024-001234
status: 2 (LEDGER_DONE) ← 卡在第 3 步(通知)
created_at: 2026-10-24 02:31:17.203
updated_at: 2026-10-24 02:31:18.847 ← 状态推进时间
# 各步骤明细:
step1 DEBIT: DONE (ts=02:31:17.450)
step2 LEDGER: DONE (ts=02:31:18.847)
step3 NOTIFY: PENDING (not_sent, created=02:31:18.901)
# 关联记录:
ledger_entry: EXISTS (biz_order_id=SETTLE-20261024-001234, direction=DEBIT)
notification_log: EXISTS (status=NOT_SENT, created_at=02:31:18.901)
account_balance: 已扣 (UPDATE affected=1, ts=02:31:17.450)
实现就是 3 条 SQL JOIN + 一个 CLI 封装,比 ELK 里 grep 三个系统的日志快且准。
总结:四件事同时做到
| 问题 | 答案 |
|------|------|
| 幂等键粒度 | 一笔业务一个状态机表(biz_operation),CAS UPDATE 推进状态,不需要每步单独建键 |
| TTL | biz_operation 保留期 ≥ offsets.retention + 7 天,用分区表 + DROP PARTITION 归档 |
| RPC 不幂等 | consumer 侧「先记后发 + 对账补偿」,request_id 必须固定为 biz_id 不能每次重发换 ID |
| 排查效率 | biz_operation 表 + 3 条 SQL JOIN 的 CLI,一条命令看全 |
你 10 月那次事故的根因拆解:第一步没幂等(account_balance 纯 UPDATE)+ 第三步 request_id 每次重发都换 → 风控去重失效 → 误冻。修这两点 + 加状态机表,同类事故就不会再发生。