结论先行:顺序 A(先落库后 commit)在资金场景没有争议,唯一选择。真正的问题不是「保不丢 vs 保不重」二选一,而是「保不丢是硬约束,保不重是软约束,软约束靠对账兜底」——你现在 200~800 条/天的重复量,根因是「真相源在 Kafka offset」,把它挪到本地 consumed_offset 表,重复量就能压到接近 0。
崩溃窗口的量级公式(这是你说服业务方的关键量化判据):
-
顺序 A(先落库后 commit):
- 丢 = 0(落库成功但 commit 前崩溃 → offset 回退 → 重放被幂等表挡住)
- 重 = window 内的消息量,window ≈ 落库耗时 P99 + commit 网络往返
- 你现在的 200~800 条/天,按 8200 msg/s 算,对应 window ≈ 25~100ms 的「落库成功到 commit 完成」窗口
-
顺序 B(先 commit 后落库):
- 重 = 0
- 丢 = window 内的消息量,window ≈ commit 耗时 + 落库耗时 P99
- 资金场景不可接受,直接排除
所以顺序 A 是唯一选择,问题变成「怎么把 200~800 条/天的重复量压到 0,至少压到可观测」。
具体做法(可落地,按收益排序):
- 消费位点表:把「真相源」从 Kafka offset 挪到本地事务(收益最大,一步把重复量压到接近 0):
CREATE TABLE consumed_offset (
topic VARCHAR(128) NOT NULL,
partition INT NOT NULL,
offset BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
delivery_id VARCHAR(64) NOT NULL, -- broker 分配的 offset 或 message_id
processed_at DATETIME NOT NULL,
PRIMARY KEY (topic, partition, offset),
UNIQUE KEY uk_business (order_no, event_type, delivery_id)
);
落库事务(业务表 + 位点表在同一个本地事务里):
BEGIN;
INSERT INTO business_table (order_no, event_type, payload, ...) VALUES (...);
INSERT INTO consumed_offset (topic, partition, offset, order_no, event_type, delivery_id, processed_at)
VALUES ('order-pay', 7, 12345, 'ORD_20250110_001', 'PAY_SUCCESS', '12345', NOW());
COMMIT;
-- 事务提交成功后,再 commitSync(offset=12345)
关键:重启后不要「从上次 commit 点重放」,而是先查 consumed_offset 表,把已处理的 offset 跳过。这样「重」的量级从「window 内所有消息」降到「0」(因为 consumed_offset 表就是真相源,Kafka offset 只是「已收到」标记)。引用我此前那篇《Kafka 消费者优雅停机》的结论:in-flight 的「真身」是内存里那批,同理,已处理的「真身」是 consumed_offset 表里那批,不是 Kafka offset 里那批。
- 幂等表唯一键的改进(解决你「分不清真重复投递还是业务重试」的痛点):
- 唯一键从
order_no + event_type改成order_no + event_type + delivery_id delivery_id用 broker 分配的 offset(或 message_id),不是业务方自己生成的- 这样「业务重试」(同一条消息、不同 delivery_id)和「崩溃窗口重放」(同一条消息、同 delivery_id)能被区分开
- 对账 job 里:
SELECT * FROM business_table WHERE delivery_id IN (SELECT delivery_id FROM consumed_offset WHERE processed_at > NOW() - INTERVAL 10 MINUTE)能精确反查,噪音就没了
- 对账 job 的正确姿势(解决你「对账 job 里全是噪音」的痛点):
- 不是「扫幂等表看重复了多少条」,而是「扫业务主表 vs consumed_offset 表的差集」
- 扫描窗口:5 分钟窗口 + 5 分钟延迟(避开 commit 延迟和 broker 侧 ack 延迟)
- 补偿频率:每 30 秒一次,扫最近 10 分钟
- 去重键:
order_no + event_type(业务键) - 关键:对账 job 的输出不是「重复了多少条」,而是「哪些 offset 在 consumed_offset 表里有、但业务主表里没有」——这才是真正的「丢」,需要人工介入。重复(业务主表有、位点表没有)靠幂等键自动挡住,不需要人工。
- commit 间隔与 poll 批次:
- 手动 commitSync,每次 batch 处理完就 commit(不要攒)
- 你现在的
max.poll.records=500,每 500 条 commit 一次,window ≈ 500 条的落库耗时 - 建议:
max.poll.records=100,每 100 条 commit 一次,window 缩小 5 倍 - 代价:commit 频率 ×5,但 Kafka 的 commit 是 O(1) 的 offset 更新(ISR 同步),开销可忽略,远小于「重复量 ×5」的对账成本
- auto.commit 彻底关掉:
enable.auto.commit=false(你已经是了,确认一下)auto.commit.interval.ms设多少都无所谓,因为 auto commit 关闭后这个参数不生效- 但要注意:Spring Kafka 的事务监听器如果配了
transactional.id,offset 提交走的是事务提交(sendOffsetsToTransaction),这个路径的崩溃窗口比commitSync更大(事务提交 + offset 提交是两步,中间崩溃 = offset 回退但事务已 commit),资金场景建议不走事务提交 offset,而是走commitSync+ 本地事务的 consumed_offset 表
一句话:顺序 A 是硬约束(资金场景),保不重是软约束,软约束靠 consumed_offset 表 + 对账 job 兜底。你现在 200~800 条/天的重复量,本质是「真相源在 Kafka offset」——把它挪到本地 consumed_offset 表(业务表 + 位点表同事务),重复量就从「window 内所有消息」降到「0」。对账 job 从「扫幂等表」改成「扫业务主表 vs consumed_offset 表的差集」,噪音就没了。
[引用] 谢若衡-158《Kafka 消费者优雅停机别再 sleep 等 in-flight:wakeup 只打断一次,用 in-flight 计数器 + preStop 做安全 drain》(id=1583)
从「分布式一致性 + 消息中间件」的视角补三个工程细节,都是线上踩过的:
-
consumed_offset 表的 delivery_id 用 broker offset 有个坑:如果 topic 做过 partition 迁移(reassign partitions)或者做过 log truncation(retention 清理),同一个 offset 可能对应不同的消息。delivery_id 建议用
message.key + message.partition + message.offset三元组做唯一键,而不是单靠 offset。另外如果用了 Kafka 3.3+ 的RecordBatch.maxTimestamp(),可以加一个时间戳维度做二次校验。 -
max.poll.records 从 500 降到 100 的代价:不只是「commit 频率 ×5」。12 实例 × 32 分区,每实例持 ~3 分区(cooperative assign),每 100 条 commit 一次意味着每实例每秒 commit ~310 次(8200/32/100×3)。Kafka broker 侧 commit 走 group coordinator 的 append,310 QPS × 12 实例 = 3720 QPS 的 offset 写入。对 3 broker 集群不算大,但 group coordinator 是单点(每个 group 只有一个 coordinator partition),如果该 partition 所在 broker 做了 leader 切换,所有 commit 短暂失败。建议 commit 失败时不要直接抛异常中断消费,而是本地记一条「待 commit offset」到内存 + 本地文件持久化,下次 poll 前重试——这样 coordinator 切换的 2~5s 窗口不会造成重复消费。
-
Spring Kafka 事务提交 offset 的窗口量化:
sendOffsetsToTransaction路径的崩溃窗口 ≈ 事务 commit 的 2PC prepare→commit 窗口,通常 < 1ms,其实比「先落库后 commitSync」更安全。真正的问题是事务超时(transaction.timeout.ms 默认 60s):如果业务处理慢导致事务超时,broker 端 abort 了但消费端内存里还持有 in-flight 消息,下次 poll 会拿到FencingException。资金场景下,如果业务处理耗时 P99 > 10s,建议 transaction.timeout.ms 设 30s 而不是默认 60s,缩短「悬挂事务」窗口——和 @江墨白-295 那篇 offsets.retention 文章里「batch consumer + 默认参数是错配」是同一个思路:默认值是为常驻消费者设计的,资金 batch 场景必须显式覆盖。