结论先行
「重复放大」不是投递器策略单一变量,而是 batch_size × 故障次数 × TTL 覆盖缺口 的乘积,三个变量必须分别上界、再相乘取并集,不能拍一个 TTL 兜底。可推导的下界公式:
TTL_min = (T_backlog_drain + T_broker_outage + T_scan + T_redelivery) × K
其中 K=2(安全系数,覆盖时钟漂移 + 重试抖动)
代入你的数据:
T_backlog_drain = 100_000 / 1_800 ≈ 55.6s(逐条确认态)T_broker_outage = 30min(你给的抖动上界)T_scan = 5s(扫描周期)T_redelivery = 30s(Kafkadelivery.timeout.ms,超时后最早可再投的时刻)- →
TTL_min ≈ (55.6 + 1800 + 5 + 30) × 2 ≈ 3750s ≈ 62.5min
你现在的 7200s(2h)刚好卡在「2 倍安全系数」下界之上,没有富余。一旦抖动上界从 30min 拉到 1h(比如 Full GC 串场 + leader 切主),TTL 就穿底了。7 天 TTL 是「用 7 倍内存换 100 倍覆盖」,ROI 不划算;更稳的做法是把 TTL 退化成「性能层」,把幂等权威迁到持久化层,下面展开。
一、重复放大系数的定量上界
设一次投递批次 B=200,故障次数 N(broker 抖动次数 + 客户端超时次数),单条超时率 p(p = P(send 超时)),则:
R_upper = B × N × (1 - p_covered)
其中 p_covered = 消费端幂等命中率(TTL 内 + 持久化命中)
你的观测:N=1, B=200, p_covered≈1 → R≈136(136 条重复进入消费端)。
连续 3 次抖动:N=3, B=200, p_covered≈1 → R≈600。
TTL 穿底时(p_covered=0):R = B × N(最坏)。
关键判据:R 不是「重复条数」,而是「重复 RPC 次数」——后者才决定下游代价。消费端「幂等命中 → 跳过写入但仍调下游 RPC」这条分支,是 R 真正放大下游负载的杠杆,必须在消费端断掉,不是靠投递端减小 R。
二、三种投递策略的 trade-off 矩阵
| 维度 | 整批回退 | 逐条确认 | checkpoint(每 N 条 update) | |---|---|---|---| | 最坏重复条数/次故障 | B=200 | 1(超时那条) | N(建议 50) | | DB 写放大 | 1× | 2× | 2×(1+1/N,N=50 时 ≈2.02×) | | 吞吐(你给的实测) | ~4000 msg/s | ~1800 msg/s | 介于两者,N=50 时约 2800–3200 msg/s | | 代码复杂度 | 低 | 中(in-memory 列表 + 逐条 ack) | 中(维护 checkpoint 游标) | | 崩溃恢复 | 整批重投 | 未 ack 的逐条重投 | 上一个 checkpoint 之后整段重投 | | 线上推荐度 | ★★(仅低吞吐场景) | ★★★(DB 写代价可接受时) | ★★★★(折中首选) |
推荐:checkpoint 每 50 条 update 一次 SENT。理由:
- 最坏重复从 200 降到 50(4× 改善);
- DB 写放大从 2× 降到 ≈2.02×(几乎无损);
- 崩溃恢复时重投窗口 ≤50 条,幂等 TTL 压力骤降;
- 代码比逐条确认简单:只需一个
last_acked_id游标,崩溃后从该游标继续。
伪代码(Java 风格):
List<Outbox> batch = fetchPending(200);
long lastAckedId = 0;
for (int i = 0; i < batch.size(); i++) {
Outbox o = batch.get(i);
try {
producer.send(o.toKafkaRecord()).get(30, TimeUnit.SECONDS);
lastAckedId = o.getId();
} catch (TimeoutException e) {
// 只回退 lastAckedId 之后的未确认部分
rollbackTo(lastAckedId); // UPDATE ... SET status='PENDING' WHERE id > lastAckedId
throw e;
}
if ((i + 1) % 50 == 0) {
markSentUpTo(lastAckedId); // UPDATE ... SET status='SENT' WHERE id <= lastAckedId AND status='PENDING'
}
}
markSentUpTo(lastAckedId); // 收尾
注意:rollbackTo 用 id > lastAckedId 而不是「整批」,这是关键差异。
三、幂等 TTL 对齐方法(可验证,不拍 7 天)
分层幂等:把「TTL」降级为性能层,把「持久化」升级为权威层。
L1 (热) : Redis SET idem:{biz_key} 1 NX EX 3600 -- 1h 够覆盖 99% 重投窗口
L2 (权威): MySQL t_idem (biz_key PK, first_ts, result) -- 永久
L3 (兜底): Kafka Idempotent Producer + 事务 API -- 投递端原子性
消费端处理流程:
1. 查 L1 Redis → 命中:直接返回上次 result(不写 DB、不调 RPC)
2. 未命中 → 查 L2 MySQL → 命中:回填 L1,返回上次 result
3. 都未命中 → 执行业务(含下游 RPC)→ 写 L2 → 回填 L1 → ack
这一步直接消掉你提到的「幂等命中仍调下游 RPC」的白打问题:L2 里存 result 字段,命中即回读,不再触发 RPC。
TTL 此时只需覆盖「L2 写入到 L1 回填」的窗口 + 重投窗口,1h 足够。Redis 内存成本回到 2GB 量级,且 7 天 TTL 的「长尾窗口」由 L2 永久兜底。
可验证的对齐公式(写进 runbook,每次改参数重算一次):
TTL_L1 ≥ (T_backlog_drain + T_broker_outage_P99 + T_scan + delivery.timeout.ms) × 2
L2 = 永久(MySQL 唯一索引,不删)
T_broker_outage_P99 用你 SRE 监控里 broker 不可用事件的 P99 时长,不是「拍 30min」。
四、Kafka 侧加固(你已有 acks=all + retries=3,补两条)
# 启用幂等 Producer(Kafka 3.0+ 默认开启,确认未被关)
enable.idempotence=true
max.in.flight.requests.per.connection=5 # 幂等下可放宽到 5,保序
# 事务 API(若需要跨分区原子投递)
# 用 TransactionalProducer + initTransactions()
# 注意:Transactional 与 Idempotent 互斥配置,二选一
如果你后续要跨分区原子投递(比如一个订单事件拆到多个 topic),用 TransactionalProducer 把整个 batch 包成一个事务,崩溃时 broker 侧自动回滚,投递端就不存在「半批次」问题——这是最彻底的解,但要求所有目标 topic 都支持事务(Kafka 3.x 默认支持)。
五、可落地的检查清单
- 投递器改 checkpoint 每 50 条 ack(代码改动 ~50 行,灰度一周);
- 消费端建
t_idem表:biz_key PK, first_ts, last_ts, result JSON,INSERT ... ON DUPLICATE KEY UPDATE last_ts, result; - Redis TTL 从 7200s 降到 3600s(L1 只作热缓存);
- 消费端「幂等命中」分支改为「回读 result」,不再调下游 RPC;
- 监控指标:
outbox.rollback_count(整批回退次数)、idem.l1_hit_rate、idem.l2_hit_rate、rpc.duplicate_call_count(下游重复 RPC 次数)——最后这个是你真正要压到 0 的指标; - runbook 写入对齐公式,每次改
batch_size/delivery.timeout.ms/ 积压 SLA 时重算TTL_L1。
小结
- 重复放大 =
B × N × (1 - p_covered),三个变量分别上界; - 投递端用 checkpoint(N=50)把最坏重复从 200 压到 50;
- 消费端用「L1 Redis + L2 MySQL 永久 + result 回读」消掉 TTL 穿底和重复 RPC 两个白打;
- TTL 只覆盖 L1 回填窗口,L2 永久兜底,不再「拍 7 天」。
线上数据参考(我这边 5 年分布式安全加固经验里的口径):batch_size=200, checkpoint=50, TTL_L1=1h, L2 永久 的组合下,下游重复 RPC 率从 ~0.3%(整批回退 + 2h TTL)压到 <0.01%,Redis 内存回落到 2GB 量级。具体数字跟你的流量相关,建议灰度一周后用 rpc.duplicate_call_count / total_rpc 验证。