不引入框架也能做最终一致:本地消息表

米阳 2024-10-17 4 10/17

「下单成功后发一条通知」这类需求,最容易写成:先写订单,再发消息。可这两步之间一旦进程崩了,订单成了、消息没了;反过来先发消息再写订单,又可能消息发出去了订单没成。分布式事务框架能解决,但对多数项目太重。本地消息表是个更轻的思路。

一、核心思路:把消息和业务写进同一个事务

BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (aggregate_id, topic, payload, status)
  VALUES ('o8891', 'order.created', '{...}', 'PENDING');
COMMIT;

因为两张表在同一个数据库事务里,要么都成功要么都回滚,不存在「业务成功但消息没留下」的中间状态。之后由独立的投递任务把 PENDING 记录发出去。

二、投递任务

1. 批量取 status='PENDING' 且 next_retry_at <= now 的记录
2. 发送到消息队列
3. 成功后标记 status='SENT'
4. 失败则累加 retry_count,按退避策略更新 next_retry_at
5. 超过最大次数标记 FAILED,告警等人工处理

多实例部署时要用 SELECT ... FOR UPDATE SKIP LOCKED 或抢占式更新来避免重复投递。

三、必须接受「至少一次」

投递成功和标记成功之间仍可能崩溃,于是消息会被重发一次。所以消费端必须幂等:用 aggregate_id 或消息里的业务唯一键做去重表,重复消息直接返回成功。

这是这个方案唯一硬要求,做不到就不要用。

四、什么时候该清理

outbox 表会一直增长。给 SENT 记录设保留期(比如 7 天),定时归档或删除。表上按 (status, next_retry_at) 建索引,别让扫描变成全表查询。

五、和 CDC 的对比

如果已经有 binlog 订阅能力(Canal、Debezium),可以直接监听业务表变更生成事件,连 outbox 表都不用写。但 CDC 的运维成本和延迟都更高,小项目没必要。

小结

本地消息表用「一个本地事务 + 一个投递任务 + 消费端幂等」换来最终一致,代码量不到两百行,却能覆盖绝大多数跨服务通知场景。先想清楚业务能不能接受短暂不一致,再决定要不要上更重的方案。

- THE END -

米阳

10月17日20:30

最后修改:2024年10月17日
0

非特殊说明,本博所有文章均为博主原创。

共有 0 条评论