很多人提到 SQLite 的 WAL 模式,习惯把它说成“把并发问题解决了”的那个开关。更贴近工程现实的理解,是它重新安排了阻塞关系,却没有放宽 SQLite 的协调边界。写事务仍然一次只容纳一个,checkpoint 从此进入日常性能路径,所有参与进来的进程也仍然要落在同一台主机上。[1][2]
团队评估 SQLite 是否还能留在当前架构里,真正有分量的问题,落在工作负载能否把写事务压短,把读事务收住,让 checkpoint 持续往前走。benchmark 能打出多少 reads/sec,分量反而靠后一些。这才是它的合同正文。
1)WAL 改写的是写入路径,拓扑边界仍然很窄
在 rollback journal 模式下,SQLite 直接改写主数据库文件,并把旧页面放进回滚日志里。切到 WAL 模式后,变更先追加到独立的 WAL 文件,再由后续 checkpoint 回写到主数据库。[1]
并发收益正是从这里长出来的。读事务继续读取主数据库文件,写事务把新 frame 追加进 WAL,旧有 rollback journal 路径上的正面冲突于是松开了。[1] 这层收益很实在,系统形状仍然紧凑:
- 同一时刻只允许一个写事务处于活动状态;[2]
- WAL 依赖共享内存里的 wal-index,因此所有进程都要在同一台机器上。[1]
第二条常常在架构讨论里被说轻了。WAL 扩展的是 SQLite 作为本地文件数据库的活动空间,跨主机共享协调仍然留在 SQLite 的边界之外。
2)reader end mark 把 checkpoint 变成真正的压力中心
SQLite 的 WAL 文档把核心机制解释得很清楚。每个读事务开始时,都会记住自己看到的 WAL 末端位置,也就是它的 snapshot 边界。这个位置之后再追加进来的 frame,已经落在这个读者的视野之外。[1]
问题从 checkpoint 开始显影。checkpoint 负责把 WAL 内容搬回主数据库文件,但它推进到某个位置时,如果那里之后的 frame 仍然被某个更早启动的读事务需要,它就要停下来。[1] 文档同时写明,默认的自动 checkpoint 阈值是 1000 pages。[1]
于是,长时间存在的读事务很容易变成第一类真问题:
- checkpoint 无法顺畅排空 WAL,
- 新写入继续把 WAL 往后推长,
- 读路径即使有 wal-index,也要面对更大的 WAL 查找范围,读性能随之下沉。[1]
顺着这个角度去看,WAL 把竞争从传统的读写锁边缘,转移到了 checkpoint debt(checkpoint 债务) 这条线上。Ten Thousand Meters 的独立工程分析把这件事说得更接地气:长读事务会把系统拖进 checkpoint starvation,写入量保持在常见区间,体感上的“越来越慢”已经会出现。[5]
3)写事务持续多久,就是并发预算本身
事务文档给出的硬边界很直接:SQLite 允许多个读事务同时存在,写事务同时只能有一个。[2] 在 WAL 模式下,BEGIN IMMEDIATE 会立刻启动写事务;BEGIN DEFERRED 要等第一条写语句出现时才升级;BEGIN EXCLUSIVE 在 WAL 下与 IMMEDIATE 的行为一致。[2]
由此展开,真正决定并发质量的,往往落在每个写者把锁拿在手里多久。
应用代码一旦在事务开启后还要等待 HTTP 调用、重试业务逻辑、跑一段很慢的上游流程,再去 COMMIT,后面的写者就会整齐排队。Ten Thousand Meters 那篇文章的价值,就在于它把 SQLite 的形式规则翻译成了团队更熟悉的故障表情:database is locked 这种报错,背后常常站着一个拖得过长的写路径。[5]
放进设计评审里,最值得问的一句是:事务开始以后,到提交之前,系统还夹带了多少与 SQL 无关的工作。这个指标,往往比平均查询延迟更能说明问题。
4)PRAGMA 调的是压力形状,不会把压力拿走
SQLite 给了几组直接控制 WAL 行为的开关。PRAGMA journal_mode=WAL 用来启用 WAL;PRAGMA wal_autocheckpoint 设置自动 checkpoint 的页面阈值;PRAGMA busy_timeout 则规定连接在数据库被锁住时愿意等待多久,再返回 SQLITE_BUSY。[3]
这些参数有用,是因为它们会改变故障的展开方式:
wal_autocheckpoint较低时,WAL 更短,checkpoint 次数更密;- 阈值抬高后,checkpoint 抖动会少一些,最坏情况下的 WAL 体积与读拖累会一起上升;
busy_timeout能把短暂的锁竞争化成等待,却接不住结构性过长的写事务。[3]
用得稳,这些 PRAGMA 像压力调节阀;用得草率,它们只是在把原本该收短的事务路径往后拖。
5)部署边界依然很好说清
SQLite 在 “Appropriate Uses” 页面里把适配范围写得很明确。它面向本地数据、嵌入式系统、应用文件、缓存,以及其他由单个应用或单台设备持有的持久状态。客户端/服务器型数据库更适合多台机器并发访问同一份数据,或把集中式管理与更高写入并发放在需求中心的位置。[4]
换成架构评审的语言,WAL 较稳的落点通常是:
- 数据库文件归属于一个应用、一个服务,或者一台主机边界;
- 写事务持续时间短,路径容易推演;
- 长时间的分析型读事务很少出现,或者已经被显式管理。[1][2][4]
风险上升的区域也很清楚:
- 多台主机都要写同一个数据库;
- 热写路径旁边并排站着很长的 snapshot reader;
- 团队最顺手的修补动作变成“把 timeout 再调大一点”,事务本身却没有缩短。[1][3][4][5]
从 SQLite 官方给出的边界继续往前推一步,WAL 最强的时候,往往是一支团队完整拥有写路径,能把每个事务从调用点一路看到提交点。等数据库开始承担共享协调面的角色,WAL 的优势就会很快收窄。
结语
SQLite WAL 更适合被看成一笔精确的交换。它通过 append-first 的写入路径和显式 checkpoint,换来了读写重叠的空间;与之相伴的,是一套始终收束得很紧的系统边界:一次一个写者、同主机协调、性能高度依赖事务长度与 checkpoint 纪律。[1][2][3]
工作负载若正好落在这组边界里面,WAL 是把持久 SQL 状态留在应用边界内的极稳方案。若系统不断朝多主机写入协调、长读事务并存、锁窗口难以收短的方向延伸,架构已经在提示另一种数据库形状。[4][5]
来源
- SQLite,《Write-Ahead Logging》—— WAL 设计、reader end mark、自动 checkpoint 与同主机边界。
- SQLite,《Transaction》—— 单写者规则、读写事务行为,以及
DEFERRED/IMMEDIATE/EXCLUSIVE语义。 - SQLite,《PRAGMA Statements》——
journal_mode、wal_autocheckpoint与busy_timeout控制项。 - SQLite,《Appropriate Uses For SQLite》—— 本地状态适配边界,以及与客户端/服务器数据库的分界。
- Ten Thousand Meters,《SQLite, concurrent writes, and
database is lockederrors》—— 对 WAL 模式争用与 checkpoint starvation 的独立工程分析。