SkyPilot 使用备受推崇的 SQLite 进行状态管理。SQLite 可以 处理每秒数百万次查询 (QPS),并处理 TB 级数据。然而,我们试图扩展 托管作业功能 的努力却遇到了 SQLite 的一个致命弱点:大量并发写入器。由于 SkyPilot 通常在您的笔记本电脑上作为 CLI 运行,我们希望继续使用 SQLite,所以我们决定找出如何使其正常工作的方法。我们对我们的一些发现感到非常惊讶。
我们的并发写入器问题
在我们的 托管作业架构 中,有一个单独的 VM 实例或 Kubernetes Pod(“控制器”)来管理许多其他实际运行作业的实例的状态。在控制器上,有一个独立的进程来管理每个作业。该进程
- 监视远程实例上作业的状态。
- 注意到用户是否取消了作业。
- 更新我们内部共享的作业数据库——这就是我们讨论的 SQLite 数据库。
换句话说,如果同时有数千个作业完成,可能会有数千个并发写入到 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 多个作业时,才能稳定地重现该错误。除非彻底重新设计我们的系统或切换到另一个数据库,否则我们能做些什么来使 1000 倍的写入并发真正在 SQLite 中工作呢?这就是我们想要弄清楚的。
太长不看
- 如果您有许多进程同时写入数据库,请尽量避免使用 SQLite。
- SQLite 使用数据库级别的锁,这可能会反直觉地导致不走运的进程“饥饿”。
- 如果必须使用,请使用 WAL 模式并设置较高的锁超时值。
不应将 SQLite 用于大量并发写入器
SQLite 文档非常清楚地说明了 何时应该和不应该使用 SQLite,以及何时应使用标准的客户端/服务器 SQL 数据库。在“客户端/服务器 RDBMS 可能表现更好的情况”一节下
高并发
SQLite 支持无限数量的并发读取器,但在任何给定时刻只允许一个写入器。在许多情况下,这不是问题。写入器会排队。每个应用程序快速完成其数据库工作并继续进行,并且锁的持续时间不超过几十毫秒。但有些应用程序需要更高的并发性,这些应用程序可能需要寻找不同的解决方案。
确实,对于大多数应用程序来说,如果您遇到这种情况,首选的解决方案应该是切换到 PostgreSQL 或 TiDB 等数据库。但如果您必须坚持使用 SQLite,会发生什么呢?
当我们将 SkyPilot 进程数量增加到大约 1000 个时,我们开始看到一些进程因以下错误而崩溃:sqlite3.OperationalError: database is locked
。解决这些锁定导致的崩溃让我们深入研究了 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 中,默认的 timeout
是 5 秒。
如果您收到 database is locked
错误,可能还有其他一些原因导致。在继续之前,您应该排除这些潜在原因
- 如果您的应用程序只有一个进程,请确保它没有打开多个相互冲突的连接。
- 如果您有多个进程,其中一个进程可能无意中长时间保持写入事务打开,从而阻塞所有其他进程。
sqlite3_busy_timeout()
可能未设置并默认为 0。也就是说,它会立即返回SQLITE_BUSY
而不是等待锁。(这在 Python 中不太可能,因为默认值是 5 秒。)- 如果您在不使用
BEGIN IMMEDIATE
的情况下运行事务,您可能会遇到SQLITE_BUSY
,无论您的超时设置如何。
我们非常确定这些都不会影响 SkyPilot,所以我们继续深入研究这些锁到底是什么。
2. WAL 日志模式不是万能药
默认情况下,SQLite 使用回滚日志来强制执行原子性。您可以 阅读关于锁是如何工作的详细信息,但太长不看版本是:许多读取器可以同时访问数据库,但如果您想写入数据库,必须获得对整个数据库的独占锁。
默认回滚日志模式:在任何时候,要么是
- n 个读取器,或者
- 1 个写入器
SQLite 也可以运行在 “WAL”(预写日志)模式 下。与使用回滚日志不同,新的写入操作不会立即写入数据库,而是写入预写日志。这允许读取器在写入发生时继续访问数据库。尽管如此,一次仍然只有一个写入器可以访问数据库,阻塞所有其他写入器直到其完成。
WAL 模式:在任何时候,既有
- n 个读取器,并且
- 1 个写入器
对于这两种模式,还有一些其他情况可能导致 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 };
注意,这不使用条件变量或任何基于通知的同步机制。
这意味着如果多个进程同时尝试获取锁,无法保证先进先出 (FIFO)。进程 1 可能已经等待锁两秒钟了,但进程 2 仍然可以在第一次尝试时就抢到锁。没有机制可以优先考虑进程 1,因为它已经等待了更长时间。
因此,在一台有数百个进程试图获取这个锁的机器上,这基本上是进程之间为了成为那个能写入数据库的幸运儿而进行的持续的争夺战。

一个进程需要多久才能获得锁?从系统层面和进程层面思考这个问题,能让我们进一步理解 SQLITE_BUSY
。
4. 增加超时时间可以指数级降低命中超时的概率
从整个系统的角度来看,情况是这样的
- 进程 1 持有锁,进程 2-20 每隔 100ms 尝试获取锁,但它们并未同步
- 进程 1 释放锁
- 碰巧进程 17 是下一个尝试获取锁的进程,在 4ms 后
- 现在,进程 17 持有锁,而其他进程 2-20 继续尝试
从等待锁定的单个进程的角度来看
- 等待 100ms
- 尝试获取锁
- 如果我们是锁释放后第一个幸运的客户端,我们就获得了锁
- 否则,我们没有获得锁,并再等待 100ms。
获得锁的机会可以被模拟为一个独立的随机事件。
由于每次尝试获取锁都是独立的,获取锁的尝试次数/总时间应遵循几何分布。确实,在测试中,我们观察到的正是如此 (n=100,000)
在最初的 0.5 秒内有一个峰值,因为重试仍在进行指数退避,所以第一个时间段内的尝试次数大约是所有其他时间段内的 3 倍。
毫不意外,如果我们将竞争减半,平均延迟也会减半。但分布看起来是一样的。
即使在 1000 倍并发下——远超 SQLite 的设计处理能力——p50 写入时间也只有 2.3 秒。只有 0.13% 的写入耗时超过 60 秒。
模拟细节
创建一个简单的表
CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY AUTOINCREMENT)
执行大量写入
for _ in range(100): start = time.time() conn = sqlite3.connect("test.db", timeout=120) cursor = conn.cursor() cursor.execute('INSERT INTO test DEFAULT VALUES') conn.commit() conn.close() print(f'{time.time() - start}', flush=True) # Random sleep between transactions (0-100ms) time.sleep(random.random() / 10)
需要随机睡眠,否则进程通常会立即重新获得锁。这也会在连续写入之间引入抖动。
我在 1000 个并行进程中运行了此测试并汇总了所有打印的延迟,然后又在 500 个并行进程中运行了一次。
图示结果来自 AWS m6i.4xlarge 实例(16 vCPU,64GB 内存)。
这里有两个重要的发现
- 获取锁所需的时间不一致。这基本上取决于运气。在您的应用程序中,这可能看起来像数据库延迟的随机峰值,但实际上,这仅仅是一些进程在尝试获取锁时运气非常不好。
- 您可以直接看到增加锁超时时间会指数级降低命中超时的概率。没有任何超时时间可以 100% 保证您不会命中超时,因此请选择一个能将概率降低到您应用程序合理水平的超时时间。在 1000 倍并发下,超时时间每增加 5.6 秒,命中超时的概率就会减半。
那么,如何才能让高并发工作呢?
如果您读到这里,您就可以看到我们是如何得出主要结论的
如果可能,避免高写入并发。如果可以,请使用单个进程并在应用程序代码中序列化写入。(我们将来会看看是否可以这样做。)
使用 WAL 模式,这将允许读取进程与写入并发运行。但要知道 WAL 并不能解决并发写入的问题。
如果您对延迟不敏感,请增加超时时间,要比您认为应该增加的更多。根据上面的测试
- 在 1000 倍并发下,将超时时间增加约 20 秒,可以将超时概率降低 10 倍。
- 在 500 倍并发下,将超时时间增加约 10 秒,可以将超时概率降低 10 倍。
- 这种关系还取决于您的写入事务需要多长时间(一旦它们获得了锁)。
在 SkyPilot 中,当前超时设置为 60 秒。如果需要,我们可能会进一步增加它。
未来探索
SQLite 有一种实验性的事务类型,称为 BEGIN CONCURRENT,它允许非冲突的写入部分重叠。不幸的是,此功能目前仅在一个特殊分支中可用,并且不是主 sqlite 分支的一部分。因此,您需要自己构建 SQLite 才能使用此功能。由于 SkyPilot 依赖于编译到 Python 中的 sqlite 库,这对我们来说是一个决定性障碍。但是,如果您的应用程序可以控制您的 sqlite 依赖项,您或许可以使用此功能来减少冲突!
P.S. 如果您需要在云上运行数千个 GPU 作业,请试试 SkyPilot
我们努力解决了这些问题,以便您可以在 SkyPilot 中运行数千个并行云作业,而无需担心扩展问题。SkyPilot 非常擅长寻找云算力,包括 GPU。如果您有批量推理运行或其他可并行化的工作负载,它是绝佳的选择。即使我们在底层使用了 SQLite,我们也能处理您的规模需求!如果您尝试一下,请在 Slack 中告诉我们体验如何。