内容提要
本文介绍事务性发件箱模式,用于解决数据库与消息队列双写不一致问题。通过将事件写入同一数据库事务,由中继进程异步发布到SQS,消费者幂等处理。文章用Node.js、PostgreSQL、SQS和DynamoDB实现示例,涵盖设置、代码、运行及生产注意事项,确保系统可靠解耦。
延伸解读
双写问题的本质
文章指出,双写问题源于数据库和消息队列是两个独立系统,无法共享原子事务。任何一次进程崩溃、网络中断或部署重启,都可能导致一侧写入成功而另一侧失败,造成数据不一致。这种不一致往往没有错误日志,难以察觉,直到下游系统出现异常才暴露。
Outbox模式的核心机制
Outbox模式的关键在于将事件写入与业务操作放在同一个数据库事务中,由独立的中继进程异步发布到消息队列。这样既保证了原子性,又避免了在事务中直接调用外部系统带来的性能问题和一致性风险。中继进程通过FOR UPDATE SKIP LOCKED支持水平扩展,确保多实例安全。
消费者幂等性的重要性
由于中继进程可能重复发送消息,消费者必须实现幂等处理。文章示例使用DynamoDB的ConditionExpression检查主键是否存在,避免重复创建记录。这种设计保证了即使消息被多次投递,系统状态依然一致,是Outbox模式可靠性的重要一环。
生产环境注意事项
文章建议在生产环境中为中继进程增加失败状态和重试计数,并配置死信队列以处理无法消费的消息。对于高吞吐场景,可考虑使用CDC工具(如Debezium)替代轮询中继,但轮询方式更简单,适合大多数系统起步。
Q&A
什么是双写问题?在什么场景下会出现?
双写问题是指应用程序需要同时向数据库和消息队列(或其他外部系统)写入数据,但这两个写入操作无法保证原子性,可能导致数据不一致。例如,在电商平台中,订单服务需要将订单保存到数据库,并发布事件到消息队列,如果两个写入之间发生故障,可能导致订单已保存但事件未发布,或事件已发布但订单未保存。
事务性发件箱模式(Transactional Outbox)的核心思想是什么?
事务性发件箱模式的核心思想是将消息发布操作与业务数据写入放在同一个数据库事务中。具体做法是:在业务数据表中插入记录的同时,向一个专门的 outbox 表中插入一条待发送的事件记录,两者在同一个事务中提交。之后由一个独立的 relay 进程异步读取 outbox 表中的待发送记录,并发布到消息队列。这样,如果事务回滚,outbox 记录也会回滚,不会产生孤立消息;如果事务提交成功,即使 relay 尚未运行,outbox 记录也会保留,最终会被 relay 处理。
为什么不能直接在数据库事务中调用 SQS 发送消息?
直接在数据库事务中调用 SQS 发送消息是错误的,因为数据库事务无法控制 SQS 的操作。如果 SQS 发送成功但数据库事务提交失败,消息已经进入队列,无法撤回;如果数据库事务提交成功但 SQS 发送失败,则消息丢失。此外,在事务中调用 SQS 会持有数据库连接和行锁,增加延迟,可能导致连接池耗尽。
在事务性发件箱模式中,relay 进程如何保证消息不丢失?
relay 进程定期轮询 outbox 表中状态为 pending 的记录,将其发布到 SQS,并在 SQS 确认接收后更新状态为 sent。如果 relay 在发送后、更新状态前崩溃,记录仍为 pending,下次轮询会重新发送,因此消息至少会被发送一次。这保证了消息不会丢失,但可能导致重复发送,因此消费者需要具备幂等性。
消费者如何实现幂等处理?
消费者在写入自己的数据存储时,使用条件写入(如 DynamoDB 的 ConditionExpression: attribute_not_exists(orderId))来确保同一订单只处理一次。如果消息重复,条件写入会失败,消费者捕获该异常并忽略,然后删除消息。这样即使消息被重复投递,也不会产生重复数据。
在事务性发件箱模式中,如何处理 relay 或消费者的永久性失败?
对于 relay,建议增加 failed 状态和重试计数器,当重试次数达到上限后标记为 failed 并停止重试。对于消费者,可以配置死信队列(DLQ),将无法处理的消息在达到最大重试次数后放入死信队列,以便人工检查。
事务性发件箱模式相比直接双写有什么优势?
事务性发件箱模式通过将消息发布与业务数据写入放在同一个数据库事务中,保证了数据的一致性,避免了双写问题。同时,relay 进程异步处理网络调用,不阻塞请求处理,提高了系统的可靠性和解耦性。