使用db-scheduler实现高性能持久队列


由于效率低下和可扩展性的限制,使用数据库作为队列历来被认为是一种反模式,但另一方面,不将数据分布在多个数据存储上也有巨大的好处。在这篇博文中,我将讨论利弊,探讨人们对现代数据库的预期限制以及哪些优化使这些成为可能。
 
db-scheduler 是几年前开发的一个简单的 Java 持久性作业调度程序,旨在尝试在简单性、数据库占用空间和功能集之间找到比我当时认为 Quartz 所做的更好的平衡。主要用例是在集群中执行重复任务,但添加了临时/一次性任务,因为它感觉像是一种自然的扩展。
事实证明,人们发现它很有用,有些人甚至开始将它用作后台作业的持久队列。文档中指出,它主要不适用于高吞吐量,因为它的轮询机制的开销会随着调度程序实例的数量而增加,此外,它的吞吐量从未真正经过测试。尽管如此,还是达到了 1000+ 次执行/秒的吞吐量(但在数据库负载方面有一些成本)。这比预期的要高一些,并且确实验证了用例。

在意识到一种有效的替代轮询机制,即使用PostgreSQL的SKIP LOCKED之后,很自然地将其纳入到db-scheduler中,通过减少每次执行的开销来提高其作为持久化队列的可行性。但是为了知道它是否有效果,需要对它进行测试。
 
使用数据库作为队列
首先,假设您已经有一个数据库,当有专门为此目的设计的产品并且每个公共云都有这样的托管产品时,为什么要将它用作队列?好吧,大部分好处将来自这样一个事实,即您在数据存储中分布的数据越多,复杂性增加的就越多。一些,从良好的旧关系数据库非常熟悉的事实来看。

  • 原子性和一致性。队列操作可以与其他数据库操作参与相同的事务。
  • 简单易懂。_ 如果队列存储为数据库表,开发人员可以使用熟悉的 SQL 与其交互,并且围绕其行为进行推理的障碍可能会低很多。
  • 降低运营成本。需要了解和管理的基础架构组件少了一个,即设置、监控、备份、迁移(到时候)等。

另一方面,最大的缺点是在队列吞吐量达到一定水平时,数据库将开始挣扎。
  • 它比专用队列效率低,如果大量使用,它会增加您可能用于其他事情的数据库的负载。
  • 它使用轮询来获取新事件。数据库需要轮询新事件,并且您希望事情发生得越快,轮询需要的频率就越高,这反过来又会增加数据库的负载。
  • 如果您达到数据库的限制,则可能更难扩展。

因此,这是一种权衡,需要根据具体情况进行评估,同时考虑预期的数量、增长以及数据库的其他用途。但是,如果您已经有一个数据库,那么将其用作队列是一个低成本的简单起点。
 
默认轮询策略
那么,让我们来看看轮询策略。默认情况下,在 db-scheduler 中轮询和执行一次性任务的工作方式如下:
  1. 选择一批到期的执行
  2. 对于每次执行
  3. 尝试更新执行以选择(使用乐观锁定)
  4. 如果更新 ok=> 执行并从数据库中删除执行
  5. 如果更新失败,即被另一个实例选择,=> 跳过

由于使用了乐观锁定,因此几乎没有锁争用,但假设 X 数量的竞争调度程序和 B 批大小,则此策略每批的 sql 语句/事务的理论最坏情况数为:

X selects + 2B updates (pick+delete) + (X-1)B updates (missed picks)

所以对于B=50,X=4,最坏情况下的语句数量是:4个选择+100个更新+150个更新,总共254条语句。没有数据库锁,但每次执行的开销相当大。
 
优化的轮询策略
除了上述的轮询和锁定策略外,还有一种避免漏选开销的方法是使用SELECT FOR UPDATE暂时锁定数据库中的候选行。然而,这些行锁将迫使来自竞争性调度器的选择在能够继续之前等待锁被释放,从而降低了吞吐量。
然而,近年来,几乎所有的普通数据库都支持SKIP LOCKED,这是一种指示数据库跳过已经有行锁的行的方法,也就是消除瓶颈。PostgreSQL走得最远,它支持在一条语句中选择、更新和返回行。
UPDATE scheduled_tasks st1 SET picked = true, …
WHERE <instance> IN (
    SELECT <instance> FROM scheduled_tasks st2
    WHERE <due-condition>
    FOR UPDATE SKIP LOCKED
    LIMIT <limit>)
RETURNING st1.*

使用这样的轮询策略,每批的理论上的sql-statements/transactions的数量是:

1 select-and-update (pick) + B updates (delete)

公平地说,选择和更新语句是非常沉重的,并且在引擎盖下做B的更新,但这里的显著优势是

  • 更少的语句意味着更少的应用程序→数据库的往返次数
  • 更少的事务和更少的提交
  • 没有因漏选造成的语句浪费

 
测试一下
为了了解新的轮询代码有什么效果(如果有的话),在 GCP 上设置了一个简单的测试。4 个竞争调度程序和〜无限的调度执行。
眼镜
  • 4 个运行竞争调度程序的 VM(2 核)
  • Postgres v12,4 核,25gb 内存

配置
  • 每个调度程序实例 100 个线程
  • 2.0 下限,即当queue-size < 2.0 * <nr-threads>时开始获取新的执行
  • 6.0 上限,即每次取数,尝试将队列填充到queue-size = 6.0 * <nr-threads>

通过使用合成数据填充数据库、启动 4 个调度程序并观察吞吐量来执行测试。它显示新的轮询策略显着增加了 11000 次执行/秒,而默认策略达到了约 2500 次执行/秒的吞吐量。所以对于这个特定的设置,增加了 4 倍
 
总结
如上所示,使用像 PostgreSQL 这样的现代数据库,SKIP LOCKED在将数据库用作队列时(例如后台作业),可以达到 10.000 次执行/秒的吞吐量。这是相当高的,并且确实验证了这种用途。它向我们表明,我们可以从简单开始,推迟引入额外基础设施组件的额外复杂性,直到需要,这在软件开发中始终是一个好主意。
如果您有兴趣探索此选项,请查看为 Java 应用程序提供此选项的db-scheduler源码,以及重复任务等。