前面几位把「coordinator 冷加载 vs GC vs 磁盘 IO」的 broker 侧定位讲透了,我以一个 DBA 的身份补一条你们真正要命的下游——「上游重试导致同一订单重复扣减库存」的数据库侧防线。这是 SRE 视角里唯一靠 broker 参数解决不了的部分,得靠表结构和写入路径兜底。
一、幂等键设计:让「重试」在数据库层面天然幂等(这是防重复扣减的地基)
ALTER TABLE inventory_hold
ADD COLUMN idempotency_key VARCHAR(128) NOT NULL,
ADD COLUMN status TINYINT NOT NULL DEFAULT 0, -- 0=init 1=held 2=released 3=compensated
ADD UNIQUE KEY uk_idem (idempotency_key);
-- key = {orderId}:{skuId},orderId 在前,让不同订单散列到不同 B+ 树叶子页
关键:别用 INSERT ... ON DUPLICATE KEY UPDATE 做扣减。它在「重复命中」时仍会走一次 UPDATE,触发 binlog 全行写 + before/after,你 9000 txn/s 峰值下高频重试时这是纯写放大。改用「状态机 + 条件更新」:
-- 第一步:抢占(幂等)
INSERT INTO inventory_hold (idempotency_key, sku_id, qty, status)
VALUES (?, ?, ?, 0)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id); -- 命中已存在则不写数据,只取回 id
-- 第二步:条件推进状态(只有 status=0 才能推进,天然防并发重复扣)
UPDATE inventory_hold SET status=1, held_at=NOW()
WHERE id=LAST_INSERT_ID() AND status=0;
-- 受影响行数=1 才算真正扣减;=0 说明已被其它事务扣过,直接走「已持有」分支
这样「同一 orderId 重试 N 次」在数据库里只会成功扣一次,且重复命中是零写放大的(INSERT 命中唯一键忽略 + UPDATE 影响 0 行)。
二、补偿表:把「事务回滚」变成「可重放的待办」 Kafka 事务回滚后,broker 侧没扣减,但业务侧必须知道「这笔要补」:
CREATE TABLE txn_compensation (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
payload JSON NOT NULL, -- 存 3 个 topic 的完整消息体
retry_cnt INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0=pending 1=done 2=dead
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_retry (status, next_retry_at)
) ENGINE=InnoDB;
重试走指数退避(1s/5s/30s),超过阈值进 dead 队列人工介入。核心是:补偿表的写入必须在「检测到 TimedOutException」的同步路径里完成,不能丢到异步事件里——否则回滚那一刻进程崩了,这笔就永久丢失。
三、一个反例,提醒别踩:别用「先查 inventory_hold 有没有这条,没有才扣减」的 SELECT-then-INSERT。在 RR 隔离下这条 SELECT 拿的是快照读,两个并发重试会同时读到「不存在」然后双双 INSERT,最终靠唯一键撞一个报错——你把幂等性寄托在「异常处理」上,而不是「约束」上。正确姿势是永远让唯一键做裁判,应用只负责捕获 DuplicateKeyException 后转「已持有」分支。
一句话:broker 侧冷加载你们已经能止血了;真正决定「会不会重复扣减」的是数据库这层的唯一键 + 状态机条件更新 + 同步补偿表三件套。这三样建好,即使 Kafka 事务再偶发超时,业务侧也不会多扣一分。
「用唯一键做裁判、别 SELECT-then-INSERT、别用 ON DUPLICATE KEY UPDATE 做扣减」这三条我强烈认同,尤其最后一条在 9000 txn/s 峰值下省掉高频写放大是真金白银。补一个我踩过的反例:你说的「条件推进 WHERE status=0」在 RR 下没问题,但 inventory_hold 的持有记录若会被后续 release/compensate 长期占用,同一个 orderId:skuId 键在「释放后再次下单」场景会命中旧记录直接走「已持有」分支。建议状态机加一个「代次/有效期」字段(generation 或 expire_at),让同一业务键不同代次能区分,否则补偿表回放时容易把新单误判成旧单已持有。想问下你们库存持有记录的生命周期大概多长?
从 SRE 值班视角强烈认同「唯一键做裁判 + 状态机条件更新 + 同步补偿表」这套地基,尤其第 3 点反例戳中了要害——把幂等寄托在异常处理上是 SRE 最忌讳的「把可靠性交给运气」。补一个我们线上真踩过的坑,正好接在江清和说的「代次/有效期」后面:补偿表那条「写入必须在同步路径里完成」,我们当年就栽在把补偿投递丢进异步 MQ 的 2 周里——一次进程 OOM 重启,检测到 TimedOutException 后还没落库就挂了,直接漏了 300+ 笔,靠订单对账脚本(T+1 全量比对 inventory_hold vs order 表)才兜回来。所以我的建议是:补偿表落库走本地事务(和状态推进在同一个 DB 事务里 commit),投递到 MQ 用「本地消息表 + 定时扫描补投」,而不是「先投 MQ 再记表」——顺序反了就是数据丢失。想追问下:你们 inventory_hold 的持有记录 T 恤生命周期是多久?如果 release 后不物理删除只改 status,代次字段是不是比 expire_at 更稳?
陈清和这套「唯一键做裁判 + 状态机条件更新 + 同步补偿表」三件套,把幂等性从「异常处理」挪回「约束」,方向非常对,尤其「别用 ON DUPLICATE KEY UPDATE 做扣减、重复命中零写放大」在 9000 txn/s 峰值下是真金白银的省。我从性能调优视角补一个容易在 staging 被忽略、上线才爆炸的点:WHERE id=LAST_INSERT_ID() AND status=0 这条条件更新,在 RR 下会拿一行记录锁 + 唯一键上的 next-key 锁。当同一 orderId 的多个 sku 并发重试、且这些 idempotency_key 落在同一 B+ 树叶子页区间时,会串行化——你峰值 9000 txn/s 若集中在少数几个热订单,UPDATE 的锁等待会直接体现在 P99 上。建议两点:一,idempotency_key 设计上把「高基数」的 orderId 放最前(你已做,好),让热点尽量散到不同叶子页;二,上线后加一条 SHOW ENGINE INNODB STATUS 的 lock wait / sys.innodb_lock_waits 巡检,专门盯 inventory_hold 这张表的锁等待 P99,别等客诉才看。
陈清和这套「唯一键做裁判 + 状态机条件更新 + 同步补偿表」方向完全对,尤其把幂等性从「异常处理」挪回「约束」。我从 PM 业务代价角度补一个数字:重复扣减库存这件事,在我们 B 端客户那里一次就是「资损 + 客诉升级」,处理成本约 2000 元(对账 3h + 补偿 + 复盘),而且客户信任一旦塌很难重建——所以这条 P0 红线我要求 100% 硬断言、0 容忍,不允许走「统计上大概率没问题」。另外程清和提到「补偿表落库必须和状态推进在同一个 DB 事务里 commit」,我建议直接写进验收单的 P0 断言:模拟进程在 TimedOutException 后崩溃,验证补偿记录已落库——这是一条能自动化、可复现的回归用例,比人工 review 可靠得多。
陈清和这套「唯一键做裁判 + 状态机条件更新 + 同步补偿表」三件套我完全认同,尤其把幂等从「异常处理」挪回「约束」这条原则。从 DBA 落地角度补一个 8.0 上容易踩的坑:你第一步 ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) 这个技巧,在 id 是 AUTO_INCREMENT 时,命中唯一键冲突的那次 INSERT 虽然不写数据,但自增计数器仍会 +1,高频重试下 inventory_hold 的 id 会出现大量空洞。对幂等本身无害,但如果下游有按 id 区间做对账/分片路由的逻辑就会错位——建议幂等表用应用生成的雪花 id 而非自增,把「键的裁判权」和「id 的连续性」解耦。另外接林望舒说的 next-key 锁竞争:同一热订单多 sku 并发时,除了 orderId 前置,更彻底的是给 idempotency_key 加一层 hash 前缀(如 h=hash64(key)%256)把热点叶子页竞争打散,我们压测下这条 UPDATE 的锁等待 P99 能从十几 ms 降到 1ms 量级。