异步任务的可靠性:重试、死信与幂等
电路摇滚
7 min read
判断:异步任务从"能跑"到"可靠",难点不在触发,而在于失败重试不重复、重复不破坏数据、彻底失败不丢消息。核心三件套是幂等、退避重试、死信队列,再配合事务消息(Outbox)解决"本地事务和发消息"的原子性。
至少一次投递带来的重复
消息队列通常保证"至少一次"(at-least-once):网络抖动、消费者处理中崩溃,都会让同一条消息被重新投递。这意味着消费者必然面对重复,必须把"处理重复消息"当成常态设计,而非例外。极少数中间件宣称"恰好一次",本质仍是 at-least-once 投递 + 消费端幂等的组合,并非传输层魔法。
幂等是第一道防线
幂等指同一条消息处理多次,结果与处理一次相同。常见做法:
- 业务唯一键:用 (业务ID, 消息ID) 建唯一索引,重复插入直接冲突跳过。
- 状态机校验:只有"待处理"状态允许流转到"已处理",重复消息因状态已变而被忽略。
- 去重表:记录已处理的消息 ID,先查后处理。
INSERT INTO consumed_msg (msg_id, biz_id) VALUES (?, ?)
ON CONFLICT (msg_id) DO NOTHING;
-- 插入成功才继续业务处理
幂等判断要和真正的业务写放在同一个事务里,否则"判断没处理"和"实际处理"之间崩溃,重启后又会重复处理。这是最容易漏的一点。
退避重试而非立即重试
瞬时故障(网络闪断、锁等待)适合重试,但连续猛重试会放大下游压力。用指数退避 + 抖动:
延迟 = base * 2^attempt + random_jitter
attempt=0 → ~1s
attempt=1 → ~2s
attempt=2 → ~4s
上限封顶,避免无限拉长。重试次数到顶仍失败,转入死信。本地重试(内存内立即重试几次)适合极短瞬断;跨重启的重试要靠消息重投或重试队列,不能只靠进程内存。
死信队列兜住烂消息
连续重试失败的消息不应无限占队列,也不该静默丢弃。转入死信队列(DLQ),保留原始 payload 和失败原因,供人工排查或定时重放:
处理失败 → 重试 N 次 → 仍失败 → 投递到 DLQ
↓
人工/定时任务消费 DLQ,修复后重放
死信队列的价值是把"处理不了的消息"与"正常消息流"隔离,避免一条毒消息阻塞整条消费。DLQ 也要配上监控与重放工具,否则它只是把问题藏起来,并未解决。
消费位点提交的时机
可靠性还取决于"处理成功"与"提交 offset"的顺序。先提交 offset 再处理,处理崩溃会丢消息;先处理再提交,处理崩溃会重复处理(由幂等兜底)。正确做法是处理完成(业务事务提交)后再提交 offset,把"重复处理"交给幂等层,而非冒着丢消息的风险。
事务消息:别让发消息和写库各做各的
经典坑:先写库再发消息,写库成功、发消息时进程崩了,消息没出去,数据状态和下游不一致;先发消息再写库,消息出去了、写库失败,下游处理了本不该存在的事件。
Outbox(发件箱)模式把"写业务数据"和"写消息"放进同一个本地事务:
BEGIN;
UPDATE orders SET status='paid' WHERE id=1;
INSERT INTO outbox (topic, payload) VALUES ('order.paid', '{"id":1}');
COMMIT;
另起一个中继进程轮询 outbox,把消息可靠投递到 MQ,投递成功后标记或删除。这样本地事务成功必定留下消息痕迹,由中继保证最终发出,避免"库成消息没出"。
消息顺序性与 exactly-once 的真相
部分业务要求消息按产生顺序消费(如账户流水)。分区内有序是多数中间件能保证的上限,跨分区无法保证顺序。若业务需要全局顺序,只能单分区或单队列,代价是失去并行度。顺序消费和"至少一次"叠加时,重复消息也必须保持顺序重放,否则状态机可能被乱序打乱。
"恰好一次"常被误解为"消息绝不重复、绝不丢失"。实际工程中它几乎都是 at-least-once 投递叠加消费端幂等的工程结果,传输层仍可能重投,只是下游因幂等而不产生副作用。指望中间件替你保证业务层的恰好一次,会把一致性漏洞埋得很深。
重试的位置也有讲究:进程内同步重试适合毫秒级瞬断,代价是占用当前消费线程;把失败消息投到独立重试队列、由专门消费者按退避节奏重放,能避免阻塞主消费流,更适合耗时较长的下游。两者可组合:本地快速重试几次,仍失败再进重试队列,最终失败进死信。
幂等键的设计
幂等不是泛泛说"要做幂等",关键在于选对幂等键。用消息 ID 做键,能保证同一条消息不重复处理;但若业务上"同一笔订单的多次合法重试"也应被当成一次,则要用业务 ID(如订单号)做键,否则合法的重发会被误判为重复而丢处理。反过来,用业务 ID 做键时,要确保"同一业务 ID 的多次消息"在语义上确实等价。这个选择要在设计期想清楚,上线后再改幂等键意味着历史去重记录作废,风险很大。
消费并发度也要和幂等配合:并发消费的多个线程处理同一业务 ID 的不同消息时,若幂等只靠"插入去重表",并发插入竞争要由数据库唯一约束兜底,应用层判断"不存在再插"在并发下会失效。
重试与业务 SLA 的匹配
重试次数和退避上限要和业务的时效要求对齐。对账类任务允许几分钟内多次重试,可以把上限设高;面向用户的实时链路则要求秒级返回,重试次数应少、退避短,宁可快速失败走降级,也不要让用户长时间等待重试。重试策略没有通用值,要按"这条消息晚一点处理行不行"来定。
踩坑清单
只在业务层做幂等,没在存储层兜底:仅靠代码判断状态,并发下仍可能两条同时过校验。数据库唯一约束/行锁才是兜底的保障。
处理成功但 offset 提交前崩溃:重启后消息被重复投递。必须用幂等兜底,并把 offset 提交放在业务完成后。
重试无上限或退避过短:一条反复失败的消息把队列堵死,还可能打挂下游。封顶次数 + 死信是标配。
把死信当垃圾桶:消息进 DLQ 后没人看,问题永远在那。DLQ 必须配监控和重放机制。
事务消息用"发完再写库"近似:做不到原子,迟早不一致。Outbox 或 MQ 的事务消息才是正解。
1 view
0 likes
0 comments