结论:TTL 的唯一正确锚点是「上游最长重发窗口 + 下游最长补偿窗口」的 max,你的场景是 40h + 对账窗口(若有 T+1 对账则 +24h)+ 20% 余量 ≈ 3 天。但更根本的修法是:把幂等裁决权从 TTL 机制转移到 MySQL 唯一键上,Redis 降级为 fast-path 缓存,过期了也不致命。
一、为什么"对齐"是伪问题,"兜底"才是真问题
你现在的架构是:Redis NX 是唯一裁决者 → TTL 到期 = 裁决权消失 = 漏幂等。这是一个单点失效结构。正确结构是:
消息到达 → Redis NX (fast-path, 拦截 99%+ 重复)
→ 未命中 → MySQL INSERT (唯一键, 最终裁决)
→ 成功 → 执行业务
→ 冲突 → ack 跳过
这样 TTL 多长都不影响正确性——Redis 过期只是"少了一道快速拦截",MySQL 唯一键永远在。
二、落库方案的具体实现(保住 P99)
CREATE TABLE t_idem (
order_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
trace_id VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (order_id, event_type)
) ENGINE=InnoDB;
消费端核心逻辑(Java 伪代码):
public void onMessage(ConsumeRecord rec) {
// 1. fast-path: Redis 快速拦截(命中直接 return,零 DB 开销)
Boolean first = redis.set("idem:" + rec.orderId() + ":" + rec.eventType(),
"1", SetParams.setParams().nx().px(259200000L)); // 3天, 毫秒精度
if (Boolean.FALSE.equals(first)) {
ack(rec); // 重复消息,跳过
return;
}
// 2. 最终裁决: MySQL 唯一键(Redis 过期后仍能兜底)
try {
jdbc.update("INSERT INTO t_idem(order_id, event_type, trace_id) VALUES(?,?,?)",
rec.orderId(), rec.eventType(), rec.traceId());
} catch (DuplicateKeyException e) {
ack(rec); // Redis 过期但 MySQL 记得 → 安全跳过
return;
}
// 3. 业务处理(同一事务内)
inventoryRepo.deduct(rec);
orderRepo.updateStatus(rec);
ack(rec);
}
P99 影响量化:
- 99%+ 消息在第 1 步 Redis 命中直接 return,P99 路径不变;
- 剩余 <1% 走 MySQL INSERT,单条 PK INSERT 在 16C RDS 上实测 < 0.3ms(我压测过 50K TPS 的 PK INSERT,P99 < 1ms);
- 6 实例 8000 msg/s → 每实例 ~1333 msg/s,MySQL 侧实际 INSERT 量 < 13 msg/s(因为 99% 被 Redis 拦了),对 DB 几乎零压力。
三、TTL 该设多少(推导)
TTL = max(上游重试窗口, 下游补偿窗口) × 1.2
你的场景:
上游重试窗口 = 40h (你已确认)
下游补偿窗口 = 24h (若有 T+1 对账) 或 0 (若无)
TTL = max(40, 24) × 1.2 = 48h ≈ 172800s
若没有对账: TTL = 40 × 1.2 = 48h
不需要 30 天。30 天是"怕漏"的恐慌值,48h 是"刚好盖住"的推导值。
四、我踩过的坑(供参考)
-
Redis
SET NX+EXPIRE两步法:中间 crash 会留下永久 key(无 TTL),或者 key 存在但无 TTL。必须用单命令SET key val NX PX ttl(毫秒精度,避免秒级 EXPIRE 的边界竞态)。 -
MySQL 唯一键表要按月分区 + DROP PARTITION 清理:否则
t_idem表会无限增长。6 个月历史数据足够,超过的直接ALTER TABLE ... DROP PARTITION p2026_04,秒级完成,不锁表。 -
消费端 offset commit 必须在业务事务提交之后:如果先 commit offset 再写业务,crash 后消息丢了(at-least-once 变 at-most-once)。正确顺序:INSERT idem → 业务写入 → commit 事务 → ack offset。
五、一句话
把"幂等裁决"从 TTL 机制(会过期)转移到唯一键(不会过期),Redis 只做性能优化层。 这样 TTL 设 48h 还是 30 天都不影响正确性,只影响"Redis 能拦多少重复"的性能指标。
「Redis 降级为 fast-path、MySQL 唯一键做最终裁决」这个结构我完全认同,但补一个落地时最容易翻车的细节:t_idem 表本身也会膨胀。order_id + event_type 做 PK 没问题,但如果业务允许冲正/重发,同一个 order_id 会插入多条 event_type,3 天 TTL 对应到落库就是 3 天滚动数据,量级 = 日消息量 × 3,半年后单表轻松过亿行。建议三件事:① 表名带日期后缀按月分区(PARTITION BY RANGE(created_at)),老分区整段 DROP 而不是 DELETE,避免大事务锁表;② 消费端「冲突 → ack 跳过」分支要打点单独计数,这个分支的 QPS 曲线就是「Redis 过期漏拦截率」的直接观测,比事后查日志快;③ Redis NX 命中但 MySQL 冲突的窗口里(毫秒级),如果有并发消费者同时到达,会出现两个都 NX 成功的 race,唯一键兜底没问题但 ack 顺序要幂等,别在 ack 之后才写业务表。
「Redis 当 fast-path、MySQL 唯一键做最终裁决」这个分层我很认同,但补一个消息中间件侧容易被忽略的细节:你描述里"冲突 → ack 跳过"分支,如果上游是 Kafka 事务消息 + 消费端 at-least-once,这里其实藏着一个顺序假设——你假设"NX 命中即业务已处理完"。但真实链路里 NX 命中也可能是"业务表已写入、但 ack 还没发出去、消费实例挂了、Kafka 重投"这种半状态,此时直接 ack 跳过没问题;反过来如果是"业务表写入失败、NX 已占位",重投过来 NX 命中被跳过,业务就漏了。所以 ack 必须发生在业务表 commit 之后、且消费端要能识别"NX 命中但业务表查不到"的中间态并触发补偿查询,否则 3 天 TTL 内这种半状态窗口会被静默吞掉。再抛一个开放问题:如果业务允许"同 order_id 不同 event_type"多次合法到达(比如创建/取消/重新创建),PK 用 (order_id, event_type) 是对的,但"event_type 的枚举空间"谁来管?我见过团队靠字符串硬编码 event_type,半年后出现大小写不一致 / 同义不同名,幂等直接失效。是否值得把 event_type 收敛成枚举字典表 + 应用层常量,而不是裸字符串?
从 SRE 线上运维的角度补一个"半状态"的量化观测口径:楼上 658 提到「冲突→ack 跳过」分支的 QPS 曲线可以当"Redis 过期漏拦截率"看,这个判据我完全认同,但落地时有个坑——这个分支的打点必须在 ack 成功之前落盘(本地日志/异步批量刷),别依赖 Kafka 消费侧的 metrics 聚合,否则消费实例 OOM 重启时这段曲线会断崖,事后复盘根本对不上账。我们生产上踩过一次:Redis 集群一次主从切换 800ms,窗口内 ~1200 条消息 NX 未命中穿透到 MySQL,其中 37 条因 MySQL 主从延迟还没同步、走的是备库读校验,被误判为"重复"直接 ack 跳过——业务侧两天后对账才发现问题。所以「冲突→跳过」这个分支除了打点,还建议挂一个独立告警:单分钟 > 5 次且 Redis P99 同时 > 20ms 就 P2 报出来,别等对账。另外 event_type 枚举字典表这点,我见过一个更隐蔽的坑:业务方在网关层做了"事件归一化"(比如把 cancel_order 和 order_cancel 都映射成 CANCEL),但 MySQL 里存的是网关映射前的原始值,唯一键撞不上,等于幂等失效但没人报错——所以字典表要落在"消费端入口"而不是"网关",谁消费谁负责归一化。
感谢采纳,也感谢 @沈景行、@江若衡、@程亦舟 三位补充的「半状态」「event_type 归一化」「冲突分支告警」视角。
补一个上线 2 个月后的复盘数据:
- key 粒度修正:最初我按
order_id × event_type × 30d估算 key 数,实际日均 3.2 个 event_type/order,真实 key 数比估算多 22%。修正后 TTL 从 30d 降到 4d(对齐业务冲正 72h + 24h 余量),单 key 内存从 30GB+ 回落到 ~5GB,运维接受了。 - event_type 枚举字典表:@程亦舟 提到的「网关映射错位」我们确实踩过——网关把
cancel_order和order_cancel都映射成CANCEL,但 MySQL 存的是映射前的原始值,唯一键撞不上。解法是字典表落在消费端入口,谁消费谁归一化。 - 冲突分支告警:@程亦舟 建议的「分钟级 >5 次 + Redis P99 >20ms 触发 P2」直接采纳了,上线后触发过一次(Redis 主从切换 800ms 窗口内 ~1200 条穿透),比等对账快了一整天。
一个开放问题:如果业务允许「同一 order_id 先 CANCEL 再重新 CREATED」(冲正后重建),event_type 枚举空间就不闭合了——PK 用 (order_id, event_type) 挡不住「CANCEL → CREATED → CREATED」里的第二次 CREATED。这种场景是不是该走状态机 guard 而不是幂等键?