SQLite 并发机制及其重要性解析
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
SQLite 是一个功能强大的数据库引擎,但由于其设计原理,存在不容忽视的局限性。
多年来,Jellyfin 一直使用基于 SQLite 的数据库存储核心数据,但在众多系统上都遭遇过相关问题。本文将阐述我们如何突破这些限制,以及 SQLite 开发者如何应用同类解决方案。
这是一篇面向开发者的技术博文,也适合所有希望深入了解并发机制的读者。
若您遇到相同问题,Jellyfin 的 SQLite 锁机制实现方案可轻松移植到其他 EF Core 应用程序中。
- JPVenson
核心前提
SQLite 是运行在应用内部的基于文件的数据库引擎,支持以关系结构存储数据。 它让应用程序能够将结构化数据存储为单一文件,无需依赖外部数据库服务。 但这种便利是有代价的:当应用完全掌控数据文件时,必须确保应用是该文件的唯一所有者,且在写入期间禁止其他进程操作该文件。
因此使用 SQLite 的应用程序必须独占数据库访问权。 确立此原则后,关键问题随之浮现:若单个文件每次只应执行一个写入操作,那么同一应用程序内的操作也必须遵循此规则。
WAL 模式解析
SQLite 通过预写日志(Write-Ahead-Log, WAL)机制突破此限制。 WAL 作为独立文件,记录所有待执行的数据库操作。 这种设计允许多个并行写入操作进入队列。 当其他应用模块读取数据时,会先读取主数据库文件,再即时扫描并应用 WAL 中的变更记录。 但这并非完美方案:某些场景下 WAL 仍无法避免锁冲突。
SQLite 事务机制
事务机制需确保两大特性:
- 事务内的修改可回滚(无论因错误还是主动撤销)
- 事务可能阻塞其他读取者访问修改中的数据 这正是问题的关键所在,也是本文的核心议题。 在某些运行 Jellyfin 的系统上,当事务发生时 SQLite 引擎会报告数据库锁定,但它拒绝等待事务完成,而是直接崩溃。 这并非罕见问题,相关报告屡见不鲜。
该问题的严重性在于其不可复现性。目前团队中仅有一位成员能(较)稳定复现此问题,更凸显其隐蔽性。 据报告,该问题跨越所有操作系统、硬盘速度及虚拟化环境。 我们尚未识别出任何影响问题发生概率的决定性因素。
Jellyfin 的症结
在理解 SQLite 行为原理后,需聚焦 Jellyfin 的具体使用场景。 在推荐配置(本地非网络存储 + SSD)下,常规操作通常无异常。但 10.11 版本前,Jellyfin 的 SQLite 使用方式存在严重缺陷。 10.11 之前版本存在并行任务限制漏洞,导致媒体库扫描任务呈指数级超额调度,使数据库引擎承受数千个 SQLite 无法处理的并行写入请求。 尽管多数 SQLite 引擎实现具备重试机制,但它们同时设有超时保护以防止无限等待。当引擎压力过载时,将直接报错崩溃。 加之运行时间过长且未优化的事务,最终导致数据库因请求过载而失效。
解决方案
自我们将代码库迁移至 EF Core 框架后,便获得了解决此问题的有效工具——EF Core 提供了结构化的抽象层。 它支持通过创建拦截器介入每个命令执行或事务过程。 借助拦截器,我们终于能实现一个对调用方透明的直接思路:避免并行写入数据库。 核心设计是采用多级锁定策略。由于任何层级的同步操作都不可避免地带来性能损耗,我们仅在实际必要时启用。 因此最终确立了三级锁定策略:
-
无锁模式
-
乐观锁定
-
悲观锁定
默认的无锁模式正如其名:不进行任何锁定。选择该模式作为默认策略是因为研究表明,99%的场景下都不会出现并发问题,而此层级的任何交互都会拖慢整个应用速度。
乐观与悲观两种模式均使用两个拦截器——一个处理事务,一个处理命令——并在 JellyfinDbContext 中重写了 SaveChanges 方法。