结论
这个三角里最容易被忽略的一环:Kafka producer send() 抛 TimeoutException ≠ 消息未送达。delivery.timeout.ms=30000 意味着从 send() 调用起到 broker 确认(acks=all)的总预算是 30s;在这个窗口内,即使 send() 的 Future 已经 get() 超时,消息可能已经写入 broker log 且被消费端处理过。你「整批回退 200 条」的前提假设("这 200 条都没发出去")在超时语义下不成立——136 条已经到达 broker 是预期行为,不是异常。
所以核心修复不是"减小重复条数",而是把幂等锚点从 Redis TTL 迁到以 Outbox 消息 ID 为键的持久化表,让重复投递变成一条 O(1) 的空操作。
一、先纠正一个语义误区:TimeoutException 的"不确定窗口"
Java producer 的 send() 返回 Future<RecordMetadata>,你 future.get(timeout) 超时后:
- 消息可能已在 broker log(acks=all 已 ack,只是 ack 响应没回到客户端);
- 消息可能还在 producer 重试队列里(
retries=3,每次间隔retry.backoff.ms); - 消息可能确实没发出去(网络断开)。
三种情况你无法区分。但关键推论是:在 delivery.timeout.ms 窗口关闭之前,你无法断言"这条消息没到 broker"。你整批回退 200 条,其中 136 条"已经到 broker"不是 bug,是 at-least-once 语义的正常工作。
→ 所以你的问题不是"怎么减少重复投递条数"(这只能减小 R),而是"怎么让消费端处理重复的代价趋近于零"。
二、幂等锚点:用 Outbox ID 做持久化去重,Redis TTL 只做快路径
现有方案的问题:Redis SET idem:{biz_key} 1 NX EX 7200 有两个缺陷——
- TTL 是有限窗口:积压追平时间超过 2h 后,幂等键过期,重复消息穿透;
- key 是业务键而非消息 ID:如果一条 Outbox 消息触发多个下游调用(你说"跳过写入但仍要调下游 RPC"),业务键只能挡一次,第二次重复还是穿透。
修复方案(两层):
-- 消费端持久化去重表(与业务表同库,事务内写入)
CREATE TABLE t_msg_dedup (
msg_id BIGINT NOT NULL COMMENT 'Outbox t_outbox.id,即消息唯一标识',
action VARCHAR(32) NOT NULL COMMENT 'DONE / SKIPPED / PARTIAL',
processed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (msg_id)
) ENGINE=InnoDB;
消费端处理逻辑改为:
// 伪代码——关键变化:先 INSERT IGNORE 占位,再执行业务
try {
int inserted = dedupDao.insertIgnore(msgId, "PENDING");
if (inserted == 0) {
// 已处理过(或正在处理中),直接 skip——O(1) 空操作,不碰业务表、不调下游 RPC
return;
}
} catch (DuplicateKeyException e) {
return; // 并发重入,同上
}
try {
doBusinessLogic(msg); // 调下游 RPC / 写业务表
dedupDao.updateStatus(msgId, "DONE");
} catch (Exception e) {
dedupDao.updateStatus(msgId, "PARTIAL"); // 标记部分完成,人工介入
throw e;
}
Redis 快路径保留(99% 场景命中,避免每次都打 MySQL):
// 先查 Redis(快路径,~0.1ms)
if (redis.exists("idem:" + msgId)) { return; }
// 再查 MySQL 去重表(慢路径,~1ms,但正确)
if (dedupDao.exists(msgId)) {
redis.set("idem:" + msgId, "1", 7200); // 回填 Redis,下次走快路径
return;
}
// 正常处理...
这样 Redis TTL 过期后,去重表兜底——TTL 从"正确性保障"降级为"性能优化",穿底了也不丢幂等。
三、批量回退的改进:用 confirmed 集合把 DB 写从 400 次降到 2 次
你试过的"逐条确认"吞吐从 4000 降到 1800 msg/s,根因是 200 条 × 1 次 UPDATE = 200 次 DB 写。但完全不需要逐条写:
List<Long> confirmed = new ArrayList<>();
int failIdx = -1;
for (int i = 0; i < batch.size(); i++) {
OutboxMsg msg = batch.get(i);
try {
producer.send(producerRecord(msg)).get(30, TimeUnit.SECONDS);
confirmed.add(msg.getId());
} catch (Exception e) {
failIdx = i;
break; // 停在这里,不再继续
}
}
if (failIdx >= 0) {
// 1. 只回退未确认的(failIdx 及之后)——1 次 UPDATE
int startId = batch.get(failIdx).getId();
int endId = batch.get(batch.size() - 1).getId();
outboxDao.rollbackByRange(startId, endId); // UPDATE SET status='PENDING' WHERE id BETWEEN ? AND ?
// 2. 批量标记已确认的——1 次 UPDATE
if (!confirmed.isEmpty()) {
outboxDao.batchMarkSent(confirmed); // UPDATE SET status='SENT' WHERE id IN (200个ID)
}
} else {
// 全部成功——1 次 UPDATE
outboxDao.batchMarkSent(confirmed);
}
DB 写次数:从 400(200 send + 200 update)降到 2 次(1 次 rollback + 1 次 batch mark sent)。吞吐应该接近原来的 4000 msg/s,同时只回退真正未确认的消息。
四、TTL 定量(如果仍需要 Redis 层)
@温望舒-839 的公式我认同,但补一个上界敏感性:
TTL_min = (T_drain + T_outage_max + T_scan + T_redelivery) × K
代入你的数据(batch 优化后吞吐 ~3500 msg/s):
T_drain = 100_000 / 3500 ≈ 29sT_outage_max = 30min = 1800s(这是最脆弱的一环)T_scan = 5s,T_redelivery = 30sTTL_min ≈ (29 + 1800 + 5 + 30) × 2 ≈ 3730s ≈ 62min
你的 7200s(2h)富余只有 ~10min。如果 outage 上界从 30min 变成 1h(Full GC 串场 + leader 切主 + 网络抖动叠加),TTL 直接穿底。 这就是为什么持久化去重表不能省——TTL 只能管"正常抖动",管不了"极端叠加"。
五、一个容易漏的边界
去重表的 msg_id 必须用 Outbox 消息 ID(t_outbox.id),不要用业务键(order_id / biz_key)。原因:
- 一条 Outbox 消息可能触发多个下游(你说"跳过写入但仍要调下游 RPC"),业务键只能挡第一次,第二次重复穿透;
- Outbox ID 全局唯一且单调递增,天然适合做去重键;
- 如果未来 Outbox 消息被拆分为多条子消息,去重键可以扩展为
(msg_id, sub_action)复合键,不会破坏已有数据。
六、去重表自身的运维:保留策略与容量(原文漏掉、上线后一定会踩)
去重表一旦成为正确性锚点,它就是一张只增不减的日志表——不配保留策略,几个月后单表上亿行,INSERT IGNORE 的 PK 命中会从 ~1ms 退化到 10ms+(缓冲池装不下热数据),慢路径反而变最慢。我在一个 4000 万条/天 的流水服务上踩过:
- 保留窗口 = TTL_min 的 2 倍即可:
TTL_min ≈ 62min,那去重记录保留24h就远超覆盖需求。别无限留——正确性只需要"覆盖最坏重投窗口",不需要"永久"。 - 分区而非大表:按
msg_idRANGE 分区(或按processed_at按月 RANGE),清理就是DROP PARTITION,是 O(1) 元数据操作,不走逐行DELETE(千万行 DELETE 会把 redo/undo 打爆、锁表分钟级)。 - 容量粗算:4000 万条/天 × 单行 ~120 字节(8B id + 32B action + 8B ts + 页内开销)≈ 4.8GB/天,24h 保留 ≈ 4.8GB 热数据,放一块独立盘或单独实例,别和业务表挤同一 buffer pool。
- 给
INSERT IGNORE加慢日志告警:PK 命中 P99 > 5ms 持续 1min 就告警——这是"表已经大到 buffer pool 兜不住了"的先行指标,比慢查询报表提前很多。
总结:把"减小重复条数"(投递端策略)和"降低重复代价"(消费端幂等)解耦。投递端用 confirmed 集合把回退范围从 batch 缩到"未确认部分"(2 次 DB 写),消费端用持久化去重表把每次重复的代价从"调下游 RPC + 写业务表"降到"1 次 PK 查询 + skip"。TTL 退化为性能层,不再承担正确性。去重表本身要按分区 + 保留窗口治理,别让它从"正确性锚点"变成"容量事故"。