跳至主内容

SQLite 并发机制及其重要性解析

· 1 分钟阅读
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

SQLite 是一个功能强大的数据库引擎,但由于其设计原理,存在不容忽视的局限性。

多年来,Jellyfin 一直使用基于 SQLite 的数据库存储核心数据,但在众多系统上都遭遇过相关问题。本文将阐述我们如何突破这些限制,以及 SQLite 开发者如何应用同类解决方案。

这是一篇面向开发者的技术博文,也适合所有希望深入了解并发机制的读者。

若您遇到相同问题,Jellyfin 的 SQLite 锁机制实现方案可轻松移植到其他 EF Core 应用程序中。

- JPVenson

核心前提

SQLite 是运行在应用内部的基于文件的数据库引擎,支持以关系结构存储数据。 它让应用程序能够将结构化数据存储为单一文件,无需依赖外部数据库服务。 但这种便利是有代价的:当应用完全掌控数据文件时,必须确保应用是该文件的唯一所有者,且在写入期间禁止其他进程操作该文件。

因此使用 SQLite 的应用程序必须独占数据库访问权。 确立此原则后,关键问题随之浮现:若单个文件每次只应执行一个写入操作,那么同一应用程序内的操作也必须遵循此规则。

WAL 模式解析

SQLite 通过预写日志(Write-Ahead-Log, WAL)机制突破此限制。 WAL 作为独立文件,记录所有待执行的数据库操作。 这种设计允许多个并行写入操作进入队列。 当其他应用模块读取数据时,会先读取主数据库文件,再即时扫描并应用 WAL 中的变更记录。 但这并非完美方案:某些场景下 WAL 仍无法避免锁冲突。

SQLite 事务机制

事务机制需确保两大特性:

  1. 事务内的修改可回滚(无论因错误还是主动撤销)
  2. 事务可能阻塞其他读取者访问修改中的数据 这正是问题的关键所在,也是本文的核心议题。 在某些运行 Jellyfin 的系统上,当事务发生时 SQLite 引擎会报告数据库锁定,但它拒绝等待事务完成,而是直接崩溃。 这并非罕见问题,相关报告屡见不鲜。

该问题的严重性在于其不可复现性。目前团队中仅有一位成员能(较)稳定复现此问题,更凸显其隐蔽性。 据报告,该问题跨越所有操作系统、硬盘速度及虚拟化环境。 我们尚未识别出任何影响问题发生概率的决定性因素。

Jellyfin 的症结

在理解 SQLite 行为原理后,需聚焦 Jellyfin 的具体使用场景。 在推荐配置(本地非网络存储 + SSD)下,常规操作通常无异常。但 10.11 版本前,Jellyfin 的 SQLite 使用方式存在严重缺陷。 10.11 之前版本存在并行任务限制漏洞,导致媒体库扫描任务呈指数级超额调度,使数据库引擎承受数千个 SQLite 无法处理的并行写入请求。 尽管多数 SQLite 引擎实现具备重试机制,但它们同时设有超时保护以防止无限等待。当引擎压力过载时,将直接报错崩溃。 加之运行时间过长且未优化的事务,最终导致数据库因请求过载而失效。

解决方案

自我们将代码库迁移至 EF Core 框架后,便获得了解决此问题的有效工具——EF Core 提供了结构化的抽象层。 它支持通过创建拦截器介入每个命令执行或事务过程。 借助拦截器,我们终于能实现一个对调用方透明的直接思路:避免并行写入数据库。 核心设计是采用多级锁定策略。由于任何层级的同步操作都不可避免地带来性能损耗,我们仅在实际必要时启用。 因此最终确立了三级锁定策略:

  1. 无锁模式

  2. 乐观锁定

  3. 悲观锁定

默认的无锁模式正如其名:不进行任何锁定。选择该模式作为默认策略是因为研究表明,99%的场景下都不会出现并发问题,而此层级的任何交互都会拖慢整个应用速度。

乐观与悲观两种模式均使用两个拦截器——一个处理事务,一个处理命令——并在 JellyfinDbContext 中重写了 SaveChanges 方法。

乐观锁定行为

乐观锁定假定当前操作会成功,仅在事后处理问题。其本质可归结为"尝试→重试→再重试..."的循环模式,在设定次数内持续重试直至操作成功或完全失败。该策略仍存在写入失败的可能性,但其引入的性能开销远低于悲观锁定。

其运作原理很简单:当两个操作尝试写入数据库时,总有一个会成功。失败的操作将等待一段时间后重试数次。

Jellyfin 使用 Polly 库实现重试机制,且仅重试因该特定问题而被锁定的操作。

悲观锁定行为

悲观锁定在每次需要向 SQLite 写入时都会进行锁定。本质上,每当通过 EF Core 启动事务或执行数据库写操作时,Jellyfin 将等待所有其他读操作完成,然后阻塞所有后续操作(无论读/写),直至当前写操作完成。这意味着即使技术上无需如此,Jellyfin 在此模式下每次也只能执行一次数据库写入。

理论上,应用在写入"Bob"表时同时读取"Alice"表应无冲突,但为了彻底消除所有并发锁定风险源,Jellyfin 在此模式下仅允许单次写操作执行。虽然这能带来最稳定的运行效果,但也必然导致最慢的执行速度。

Jellyfin 使用 ReaderWriterLockSlim 实现操作锁定,这意味着我们允许无限数量的读操作并发执行,而写操作一次只能执行一个。

未来的智能锁定行为

未来我们可能考虑结合两种模式,以兼得二者之长。

实施效果

初步测试表明,两种模式在解决底层问题上都取得了显著成效。虽然我们仍未查明为何此问题仅在某些系统出现,但至少为之前无法使用 Jellyfin 的用户提供了解决方案。

在研究该课题时,我发现全网存在大量相同错误的报告,但无人能给出确切解释。虽然也有类似处理建议,但始终缺乏能应对所有场景的"开箱即用"方案——要么需要大幅修改每个 EF Core 查询。Jellyfin 的锁定行为实现应可作为现成解决方案直接复用,因其采用拦截器机制,调用方无需感知实际的锁定行为。

祝您好运,

- JPVenson