Hotdry.
systems

PostgreSQL 作为死信队列:SKIP LOCKED 与追踪跳过事件的工程实践

探讨在事件驱动系统中利用 PostgreSQL SKIP LOCKED 实现死信队列,分析重建投影时的竞争条件,以及接受事务隔离约束、追踪跳过事件的工程策略。

在事件驱动架构中,死信队列是一个无处不在的模式。无论是 Apache Kafka 的 Dead Letter Topics、RabbitMQ 的死信交换器,还是 AWS SQS 的 Redrive 策略,核心问题都指向同一个:消息无法立即处理时,必须有一个地方暂存它们,等待后续处理。许多团队选择引入专门的消息中间件来处理这个问题,但 PostgreSQL 本身的能力往往被低估。当我们深入理解 SKIP LOCKED 和事务级锁机制后,会发现完全可以用数据库本身实现一个可靠的死信队列,同时避免外部基础设施带来的运维复杂性。

事件溯源中的竞争窗口

在事件溯源系统中,投影(Projection)或读取模型(Read Model)需要根据事件流进行重建。常见的重建流程包括四个步骤:将投影标记为「重建中」、跳过内联投影处理历史事件、处理完成后将状态恢复为「活跃」。这套机制听起来完备,但在高并发场景下存在一个隐蔽的时序窗口。

问题的根源在于 PostgreSQL 的事务隔离特性:未提交的事务对其他事务不可见。假设重建进程已经处理完所有可见事件并将状态设为「活跃」,此时一个在状态切换前就已开始的事务可能尚未提交。这个事务在检查状态时看到的是「重建中」,于是决定跳过内联投影,但它的事件已经写入数据库。当这个延迟提交的事务最终完成时,事件存在于事件流中,但对应的投影从未被应用,而重建进程已经结束,不会再处理这个事件。这是一个典型的竞争条件,虽然时间窗口很小,但在高吞吐量系统下几乎是必然发生的。

为什么传统修复方案行不通

面对这个竞争条件,工程师的第一反应往往是「加锁」。常见的修复尝试包括三种思路:等待未完成事务、使用事务 ID 作为边界、以及在状态切换期间锁定整个写入路径。每一种思路都看似合理,但最终都失败了。

第一种思路利用 PostgreSQL 的 pg_snapshot_xmin 获取最老运行中事务的 ID,试图等待所有可能跳过的事务提交后再切换状态。然而,等待的过程中会有新事务开始,它们的跳过决策同样不受控制,目标不断移动,永远无法「追完」。第二种思路使用事务 ID 作为边界,在重建完成时记录当前的 current_transaction_id,要求后续事务检查自己的事务 ID 是否大于这个边界。问题在于事务 ID 的分配顺序与提交顺序无关:一个更早开始的事务可能因为执行时间更长而晚于更晚开始的事务提交,这意味着基于 ID 的检查无法可靠地判断事务的可见性。

第三种思路最为激进:在状态切换期间获取排他性咨询锁,阻止所有新事务进入写入路径。然而,内联投影的跳过决策是在事务内部做出的,即使我们能在状态切换时获取锁,也无法回滚已经做出并执行跳过决策的事务。更棘手的是,咨询锁分为事务级和会话级两种模式,前者在事务提交后自动释放,后者则需要显式释放。如果使用会话级锁来保护状态切换,连接断开会导致锁立即释放,系统可能处于不一致状态;如果使用事务级锁,则无法阻止已经做出跳过决策的事务继续提交。每一种「修复」都只是将竞争条件转移到另一个位置,问题的本质并未解决。

接受约束:追踪而非阻止

经过多轮尝试后,一个更有洞察力的思路浮现出来:与其试图阻止跳过发生,不如让跳过变得可见和可恢复。这个思路的核心转变在于接受 PostgreSQL 事务隔离是一个不可改变的特性,然后在此基础上设计可靠的补偿机制。

具体实现是在事件追加的同一个事务中记录「跳过」信息。当内联投影发现投影状态为「重建中」时,它跳过投影处理,但同时向一个专用的系统消息表写入一条记录,包含被跳过的投影标识、事件位置、跳过原因和时间戳等信息。如果事件追加事务回滚,跳过记录也会一同回滚;如果事务提交,跳过记录与事件一起持久化。这种设计建立了一个简单的不变式:对于任何已存在的事件,要么其投影已经被内联应用(状态为「活跃」时),要么存在对应的跳过记录(状态为「重建中」时),不存在事件存在但投影未被应用且没有记录的情况。

系统消息表的设计值得细致考虑。它应该独立于业务事件表,避免将系统级关注点混入业务事件流。表结构可以包含全局位置序列(用于排序和去重)、事务 ID(用于可见性检查)、归档标志(用于分区和清理),以及处理器 ID 和跳过原因等元数据。这种设计虽然比简单的「跳过事件表」更复杂,但它为未来的扩展预留了空间:我们可以用同一张表记录其他系统事件,如投影失败、毒消息处理等,而不需要重新设计基础设施。

排干跳过记录与归档策略

记录跳过事件只是第一步,还需要一个机制来处理这些跳过记录。重建进程在完成历史事件处理后,进入「排干跳过记录」阶段:查询系统消息表中属于该投影的未归档跳过记录,对每条记录找到对应的事件并应用投影,然后将该跳过记录标记为已归档。这个过程可以由重建进程自身作为第二阶段执行,也可以由专门的处理器或人工触发。

需要注意的是,排干过程本身也可能遇到新的跳过记录:在状态切换后,一些仍在进行中的事务可能刚刚完成提交,它们的跳过决策是在看到「活跃」状态之前做出的,因此仍然会产生跳过记录。解决方案是持续轮询直到没有可见的跳过记录 —— 当重建进程停止后,不再有新的内联投影会被跳过,剩余的跳过记录迟早会被处理完毕。

归档策略是防止系统消息表无限增长的关键。通过 is_archived 标志和分区机制,可以将归档记录移动到单独的分区,甚至存放在不同的存储介质上。归档记录保留审计追踪,便于事后调试;同时,保留策略可以比业务事件更激进 —— 毕竟系统跳过记录是运营数据而非业务历史,保留几周通常足够排查问题。

SKIP LOCKED 与队列语义

除了追踪跳过事件外,PostgreSQL 还提供了 SKIP LOCKED 语法,使得实现无阻塞队列变得异常简单。在典型的消息队列场景中,多个工作进程并发地从同一张表获取待处理任务。使用 SELECT FOR UPDATE SKIP LOCKED 可以让每个事务跳过已被其他事务锁定(正在处理中)的行,确保每个任务只被一个进程获取,避免了复杂的分布式锁协调。这正是许多团队选择用 PostgreSQL 替代 RabbitMQ 的原因:无需维护额外的消息中间件,学习曲线更低,运维负担更轻。

当然,PostgreSQL 作为消息队列也有其局限性。它没有消息路由、内置重试、死信转发等高级特性,这些在专用消息中间件中是开箱即用的。但如果你的需求是简单的任务分发、事件传递,或者像本文讨论的死信追踪,PostgreSQL 的能力已经足够,而且集成度更高,失败模式更少。

工程启示:约束与权衡

这篇文章的真正主题不是 PostgreSQL 的咨询锁或事件溯源的重建流程,而是我们面对分布式系统问题时的思维方式。当发现竞争条件时,工程师的自然反应是「修复它」—— 添加锁、添加检查、添加协调阶段。但每次「修复」往往只是将问题转移到另一个位置,或者引入新的复杂性。

最终胜出的方案不是最复杂的那个,而是最诚实地面对约束的那个。PostgreSQL 的事务隔离规则不会因为我们的锁而改变,未提交事务的不可见性是数据库保证一致性的基础,而非需要规避的缺陷。我们无法同时实现零停机重建、完美的内联投影一致性和无协调开销 —— 这是 CAP 定理在更细粒度上的体现。接受这个事实,然后专注于可观测性和可恢复性:一个系统不是因为从不失败而值得信任,而是因为你知道它会怎样失败、如何恢复、缺失了什么而值得信任。

跳过追踪的设计正是这种思路的体现。它不试图阻止重建期间的投影跳过(这在事务隔离下是不可能的),而是确保每次跳过都被记录,保证这些跳过最终会被处理,让整个过程可观测、可调试。这不是向复杂性投降,而是将复杂性转化为可管理的工程实践。


参考资料

查看归档