滥用 SQLite 处理并发性
SkyPilot 使用著名的 SQLite 进行状态管理。SQLite 可以处理数百万的 QPS 和 TB 级的数据。但是,我们在扩展托管工作功能时遇到了 SQLite 的一个缺点:并发写入者众多。由于 SkyPilot 通常在笔记本电脑上以 CLI 方式运行,我们希望继续使用 SQLite,因此我们决定研究如何让它发挥作用。我们的一些发现让我们大吃一惊。
我们的并发写入器问题
在我们的托管作业架构中,有一个单独的虚拟机实例或 Kubernetes pod(“控制器”)管理着许多其他实例的状态,这些实例实际运行着作业。在控制器上,有一个单独的进程来管理每个作业。这个进程
- 监控远程实例上的作业状态。
- 通知用户是否取消作业。
- 更新我们的内部共享任务数据库–这是 SQLite 数据库。
换句话说,如果有 1000 个作业同时完成,就会有 1000 个并发写入 SQLite 数据库。
有一段时间,我们收到过一些与 SQLite 相关的崩溃报告,看起来就像这样:
Traceback (most recent call last): ... File "/home/ubuntu/skypilot-runtime/lib/python3.10/site-packages/sky/jobs/state.py", line 645, in set_cancelling rows = cursor.execute( sqlite3.OperationalError: database is locked
但是,我们只有在同时扩展到 1000 多个作业时才能持续重现该错误。除了重新构建系统或改用其他数据库外,我们还能做些什么来使 SQLite 真正实现 1000 倍写并发呢?这就是我们想要找出的答案。
简要说明
- 如果有很多进程同时向数据库写入数据,尽量避免使用 SQLite。
- SQLite 使用数据库级锁,这会反直觉地让不走运的进程陷入困境。
- 如果必须使用,请使用 WAL 模式和较高的锁超时值。
不应将 SQLite 用于多个并发写入程序
SQLite 文档非常清楚地说明了与标准客户端/服务器 SQL 数据库相比,什么情况下应该使用 SQLite,什么情况下不应该使用 SQLite。请参阅 “客户端/服务器 RDBMS 可能效果更好的情况”:
高并发性
SQLite 支持无限数量的同时读取器,但在任何时候都只允许一个写入器。在很多情况下,这都不是问题。写入器排队。每个应用程序都能快速完成其数据库工作,然后继续前行,任何锁定都不会超过几十毫秒。但有些应用程序需要更高的并发性,这些应用程序可能需要寻求不同的解决方案。
事实上,对于大多数应用来说,如果你发现自己处于这种情况,你的首选答案应该是转而使用 PostgreSQL 或 TiDB。但如果您需要坚持使用 SQLite,会发生什么情况呢?
当我们将 SkyPilot 进程的数量增加到 1000 个左右时,我们开始看到一些进程崩溃并出现以下错误:SQLite3.OperationalError: database is locked
(SQLite3.操作错误:数据库已锁定)。解决这些锁定崩溃问题需要我们深入研究 SQLite 的内部结构。
我们了解到的 SQLite 内部结构
1. sqlite3.OperationalError 可能有多种原因
SQLite 有多种锁定机制来确保一致性。如果一个事务试图执行但无法获得必要的锁,它将给出 SQLITE_BUSY 错误。在 Python 中,这被翻译成 sqlite3.OperationalError: database is locked
。
如果当前持有必要的锁,SQLite 将尝试获取该锁,直到超时为止。超时时间由 C API 中的 sqlite3_busy_timeout() 设置,在 Python API 中对应于 sqlite3.connect() 调用中的 timeout= kwarg。
static int pysqlite_connection_init(PyObject *self, PyObject *args, PyObject *kwargs) { // ... double timeout = 5.0; // ... }
在 Python 中,默认超时为 5 秒。
如果您遇到database is locked
的错误,可能还有其他一些原因。在继续之前,您应该排除这些潜在的原因:
- 如果应用程序只有一个进程,请确保它没有打开相互冲突的多个连接。
- 如果有多个进程,一个进程可能无意中长时间保持写事务打开状态,从而阻塞了所有其他进程。
sqlite3_busy_timeout()
可能未设置,默认值为 0,即立即返回SQLITE_BUSY
,而不是等待锁。(这在 Python 中是不可能的,默认值是 5s)。- 如果不使用
BEGIN IMMEDIATE
就运行事务,那么无论超时设置如何,都可能会触发SQLITE_BUSY
。
我们非常确定这些情况都不会影响 SkyPilot,因此我们继续研究这些锁到底是什么。
2. WAL 日志模式并非灵丹妙药
默认情况下,SQLite 使用回滚日志来执行原子性。你可以阅读有关锁工作原理的具体信息,但简而言之就是:许多读者可以同时访问数据库,但如果你想写入数据库,就必须获得对整个数据库的独占锁。
默认回滚日志:在任何时候,无论是
- n readers, OR
- 1 writer
SQLite 也可以在 “WAL”(前置写日志)模式下运行。与使用回滚日志不同,新写入的内容不会立即写入数据库,而是会写入先写日志。这允许读者在写入的同时继续访问数据库。不过,一次只能有一个写入器访问数据库,它会阻塞所有其他写入器,直到写入完成。
WAL 模式:在任何时候,都有
- n readers, PLUS
- 1 writer
对于这两种模式,还有一些其他情况会导致 SQLITE_BUSY
。例如,在 WAL 模式下,读者通常不会出现锁竞争。但是,在某些边缘情况下,可能需要独占锁来阻塞读者,例如将 WAL 刷新回数据库。
SkyPilot 已经在使用 WAL 模式
,但当许多进程需要同时向数据库写入数据时,我们仍然会陷入困境。我们需要深入研究,找出锁的具体工作原理,以及导致锁超时的原因。
最初,我从 beets 博客上找到了这篇旧文,描述了 SQLite 每秒只能尝试获取一次数据库锁的故障模式!但这篇文章来自 2012 年,现在已经非常过时了–SQLite 源代码不再使用 HAVE_USLEEP
,而且这篇文章中描述的问题也不适用于任何现代系统。
不过,这确实给我指明了 SQLite C 源代码中的 sqliteDefaultBusyCallback
函数,这对我来说是一个巨大的启示……
3. 锁获取是一场大屠杀
当 SQLite 尝试获取数据库锁时,它基本上是这样做的:
try to get lock sleep 1 ms try to get lock sleep 2 ms try to get lock sleep 5 ms ... (sleep interval increases) try to get lock sleep 100 ms try to get lock sleep 100 ms try to get lock sleep 100 ms ... continue until timeout
睡眠时间间隔列表实际上是 SQLite C 代码中的硬编码!
static const u8 delays[] = { 1, 2, 5, 10, 15, 20, 25, 25, 25, 50, 50, 100 };
请注意,这并没有使用条件变量或任何基于通知的同步。
这意味着,如果多个进程同时试图获取锁,就无法保证先进先出。进程 1 可能已经等待了两秒钟,但进程 2 仍然可以在第一时间抢到锁。没有任何机制能让进程 1 因为等待时间更长而获得优先权。
因此,在一台有数百个进程试图获得这个锁的机器中,基本上就是一场进程之间的持续 “血战”,所有进程都在争夺成为能向数据库写入数据的幸运儿。

进程获得锁需要多长时间?从系统层面和进程层面来思考这个问题,会让我们进一步了解 SQLITE_BUSY
。
4. 增加超时可以成倍地减少命中的机会
从整个系统的角度来看,情况是这样的:
- 进程 1 持有锁,进程 2-20 每隔 100 毫秒尝试获取锁,但它们并不同步
- 进程 1 释放锁
- 进程 17 恰好是下一个试图获取锁的进程,4 毫秒后
- 现在,进程 17 持有锁,其他进程 2-20 继续尝试
从等待锁的单个进程的角度来看:
- 等待 100 毫秒
- 尝试获取锁
- 如果我们是幸运的客户端,是锁释放后的第一个,我们就能获得锁
- 否则,我们就得不到锁,还要再等 100 毫秒。
获得锁的几率可以模拟为一个独立的随机事件。
由于每次获取锁的尝试都是独立的,因此获取锁的尝试次数/总时间应遵循几何分布。事实上,在测试中,我们看到的情况正是如此(n=100,000):
在最初的 0.5 秒内会出现一个峰值,因为重试仍在后退,所以在第一个分仓中的尝试次数大约是其他分仓的 3 倍。
不出所料,如果我们将竞争减半,平均延迟也会减半。但分布看起来是一样的。
即使在 1000 倍并发的情况下(远远超出 SQLite 的处理能力),p50 的写入时间也仅为 2.3 秒。只有 0.13% 的写入时间超过 60 秒。
这里有两个重要的启示:
- 获得锁的时间并不一致。这基本上取决于运气。在您的应用程序中,这可能看起来像是数据库延迟的随机峰值,但实际上,这只是某些进程在尝试获取锁时运气不好而已。
- 您可以直接看到,增加锁定超时会以指数方式降低命中超时的几率。没有任何超时可以 100% 保证不会发生超时,因此请选择任何可以将超时几率降低到合理水平的超时。在并发量为 1000 倍的情况下,超时时间每增加 5.6 秒,发生超时的几率就会减半。
那么,如何实现高并发呢?
如果您已经了解到这一点,您就会明白我们是如何得出主要结论的:
- 尽可能避免高写入并发。如果可以,使用单一进程并在应用程序代码中序列化写入。(我们将来会看看能否做到这一点)。
- 使用 WAL 模式,这将允许读取进程与写入进程同时运行。但要知道,WAL 并不能将你从并发写入中解救出来。
- 如果你对延迟不敏感,可以适当延长超时时间。根据上面的测试:
- 并发量为 1000 倍时,超时时间增加约 20 秒,超时概率就会降低 10 倍。
- 并发量为 500 倍时,超时时间增加 ~10 秒,超时概率就会降低 10 倍。
- 这种关系还取决于写入事务(获得锁后)所需的时间。
在 SkyPilot 中,超时时间目前设置为 60 秒。如果需要,我们可能会进一步增加超时时间。
未来探索
SQLite 有一种名为 “BEGIN CONCURRENT ”的实验性事务类型,允许非冲突写入部分重叠。遗憾的是,该功能目前只在一个特殊分支中可用,并不是 sqlite 主干的一部分。因此,您需要构建自己的 SQLite 才能使用该功能。由于 SkyPilot 依赖于编译到 Python 中的 sqlite 库,因此这对我们来说是个难题。但如果您的应用程序可以控制 sqlite 的依赖关系,您也许可以使用它来减少冲突!
附:如果您需要在云上运行数千个 GPU 作业,请尝试 SkyPilot
我们努力克服了这些问题,因此您可以在 SkyPilot 中运行数千个并行云作业,而无需担心扩展问题。SkyPilot 非常擅长寻找云计算,包括 GPU。如果您有批量推理运行或其他可并行化的工作负载,它将非常适合。尽管我们在引擎盖下使用的是 SQLite,但我们可以处理您的规模!如果你想试试,请在 Slack 中告诉我们进展如何。
你也许感兴趣的:
- 【外评】为什么 SQLite(在生产中)的声誉如此糟糕?
- WordPress正在测试对SQLite的支持
- 发布至今18年,为什么SQLite一定要用C语言来开发?
- 为什么 SQLite 不使用 Git 进行版本管理?
- SQLite 中的各种限制
- 最新的SQLite 3.8.7比3.7.17性能提升50%
- 大话程序猿眼里的高并发
- iOS并发(concurrency)概念浅析
- 性能测试中如何确定并发用户数
- 【外评】一位中国程序员的开源之旅
你对本文的反应是: