做PHP开发这些年,我处理过不少SQLite相关的线上问题。最让我头疼的,不是数据写错,而是明明并发量不大,应用却频繁报出database is locked,日志刷屏、接口超时,用户端看到的就是白屏或卡顿。后来把SQLite切换到WAL模式,很多锁问题一下就消失了。这篇就以“PHP的WAL模式应用”为主线,聊聊WAL到底是什么、在PHP里怎么正确开启、实际能带来多少提升,以及我自己在生产环境踩过的坑。如果你现在正因为SQLite并发读写头疼,或者打算在PHP项目里用SQLite存点非核心数据,这篇文章应该能帮到你。
1. WAL模式到底改了什么:SQLite的两种“落盘哲学”
很多人知道WAL(Write-Ahead Logging,预写式日志)是“并发变好了”,但说不清它跟SQLite默认的日志模式差在哪里。理解这点很重要,因为不少人在代码里只加了一句PRAGMA journal_mode=WAL;,遇到坑之后根本不知道怎么排查,根源就是没搞懂这两种模式的工作链路。
1.1 默认模式为什么容易锁库:rollback journal的完整流程
SQLite默认使用的是rollback journal模式,也就是回滚日志模式。这个模式很有意思,它的工作方式可以类比成“记账前先拍照保管旧账”:在修改数据库主文件之前,SQLite会先把要被修改的原始页面内容复制到一个临时文件中,这个临时文件就是-journal文件,一个典型的回滚日志。然后才把新数据写入主数据库文件。如果事务中途失败,SQLite会利用这个-journal文件把主文件恢复到修改前的状态。
在这个“先拍照、再改账、最后销账”的过程里,有一个致命约束:任何时刻,数据库只允许一个事务处于写状态。写事务开始前必须获取独占锁,独占锁存在期间,所有读操作都会被拒之门外。同样,如果一个读事务正在读取数据,写事务也拿不到写锁。这就是经典的“读写互斥”。
我常用一句大白话解释这个模式:它把“防写错”的成本,转化成了“并发度”的牺牲。数据库为了保证一旦事务失败还能把数据恢复原状,宁可让所有读操作等着,也不允许任何人在“修改现场”附近围观。
这也解释了为什么PHP-FPM多进程场景下,哪怕只有十几个并发请求,只要里面有读有写,就很容易触发database is locked。SQLite默认模式对并发的容忍度非常低,不是它设计得差,而是它的默认策略选择了绝对安全,牺牲了并发。
1.2 WAL模式的写入链路与多个关键文件
WAL模式把思路完全倒过来。它不再“先拍旧账再改账本”,而是“新账目先记在便签本上,等有空闲再誊进总账本”。
具体来说:当启用WAL模式后,写事务不再直接修改数据库主文件.db,而是将修改后的页面内容追加写入到一个独立的日志文件-wal文件中。这个-wal文件在SQLite官方文档里叫write-ahead log,里面保存的是最新的页面快照。与此同时,主数据库文件保持的是上一次checkpoint时的旧数据状态。
当一个读事务发生时,它会同时查看两个地方:先在主数据库文件中读取对应页面,再检查-wal文件中是否有更新的页面覆盖。如果-wal文件里有更新版本,就读-wal里的;没有才读主文件。这个判断过程由SQLite内部的“WAL索引”完成,索引结构存储在另一个共享内存文件-shm文件中,也就是SQLite目录下会多出来的第三个文件。
然后,当-wal文件增长到一定规模(默认1000页,约4MB),SQLite会自动执行checkpoint操作:把-wal文件里的新页面合并写回主数据库文件,清空日志,完成一次“誊账”。这个合并动作不影响已经在进行读操作的事务,它们依然可以从对应的时间点视图读取数据。
1.3 WAL带来的并发模型变化:一个写者加无限读者
到这里,WAL模式的并发优势就非常明显了。因为它把“修改主文件”和“写日志文件”分离,带来了新的并发模型:
| 维度 | rollback journal模式 | WAL模式 |
|---|---|---|
| 读事务是否阻塞写事务 | 会阻塞 | 不阻塞 |
| 写事务是否阻塞读事务 | 会阻塞 | 不阻塞 |
| 同一时刻写事务数量 | 仅1个 | 仅1个 |
| 同一时刻读事务数量 | 仅1个(读写互斥下) | 理论无限多个 |
| fsync次数 | 频繁(每次提交都要同步主文件) | 较少(主要同步WAL文件) |
| 依赖额外文件 | 临时-journal文件 | -wal和-shm文件 |
| 是否支持网络文件系统 | 支持 | 不支持(NFS等不可用) |
表格里的最后一行特别重要,也是很多人没注意到的。WAL模式依赖-shm共享内存文件和系统级内存映射机制,这在NFS、SMB这类网络文件系统上往往不能正常工作,后面我会单独讲这个坑。
简单说,SQLite的WAL模式把原本“读写一刀切”的互斥锁,转变成了“写之间互斥、读不阻塞写、写不阻塞读”的模型。对于PHP-FPM这种天然多进程、并发请求频繁的Web环境来说,这种模型简直是为它量身定制的。
2. 在PHP项目里启用WAL:从一条PRAGMA到全套参数调优
2.1 最小可用配置:一条SQL让SQLite切换到WAL
在PHP里启用WAL模式,最直接的方式就是执行PRAGMA语句:
<?php try { $pdo = new PDO('sqlite:/var/www/html/app.db'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 开启WAL模式 $result = $pdo->query('PRAGMA journal_mode = WAL;')->fetch(PDO::FETCH_COLUMN); var_dump($result); // 输出: string(3) "wal" } catch (PDOException $e) { echo '连接失败: ' . $e->getMessage(); }这里有个小细节:PRAGMA journal_mode = WAL;执行后会返回一行数据,内容就是设置完成后的日志模式。你应当把返回值打印出来看看。如果返回的是wal,说明设置成功;如果返回的是delete,说明设置失败了,也可能是数据库连接或者当前环境有问题。很多人只执行SQL不看返回值,结果连接到的是没开WAL的数据库,后来的问题排查就会走弯路。
2.2 WAL模式的三个核心参数怎么选
开启WAL只是第一步,完整的生产配置还需要关注下面三个参数。
第一个是synchronous,它控制SQLite同步写入磁盘的策略。在WAL模式下,synchronous=FULL表示每次事务提交时都要将WAL文件fsync到磁盘;synchronous=NORMAL表示只在checkpoint执行fsync,事务提交时不强制同步WAL文件。对大多数PHP应用来说,我推荐设置成NORMAL。因为在WAL模式下,即使系统掉电,SQLite也还有恢复机制,NORMAL模式丢失数据的概率极低,但换来的是接近一倍的写入性能提升。
第二个是wal_autocheckpoint,它控制WAL文件自动checkpoint的页数阈值,默认是1000页。你可以通过PRAGMA wal_autocheckpoint = 2000;调整到更大的值。增大这个值的含义是,让WAL文件攒更多日志再合并,从而减少checkpoint频率,降低磁盘I/O次数。但注意,WAL文件过大也会带来读取索引变慢的问题,不建议无脑调太大。
第三个是busy_timeout,它设置进程在遇到数据库锁时的等待毫秒数。WAL模式下虽然读不阻塞写,但两个写事务之间依然互斥。如果两个PHP进程同时执行写操作,后到的那个就会收到SQLITE_BUSY。默认情况下这个值是0,也就是说冲突立刻失败;设置成5000或更大的值,后到的写操作会等待前面的写操作完成后再执行,显著降低应用中“随机失败”的概率。
我推荐的组合是:
$pdo->exec('PRAGMA journal_mode = WAL;'); $pdo->exec('PRAGMA synchronous = NORMAL;'); $pdo->exec('PRAGMA wal_autocheckpoint = 1000;'); $pdo->exec('PRAGMA busy_timeout = 5000;');这个组合的代价与收益是:数据安全上比默认为低一点点,但换来高得多的并发承受力和写入吞吐,非常适合绝大多数PHP类应用。
2.3 通过PHP连接时的实际坑点:连接级设置、事务外设置、SQLite版本
关于在PHP里设置WAL,有三个实际坑点需要特别说明。
第一,PRAGMA是连接级配置,不是数据库级配置。你通过一个PDO连接执行PRAGMA journal_mode = WAL;,只会影响这一个连接对应的SQLite句柄。PHP-FPM每个进程建立PDO连接时,都需要重新设置。所以不要想着在命令行里执行一次SQL,应用就永久生效了。正确的做法是封装一个数据库连接初始化的公共方法,每次连接都自动执行这些PRAGMA。
第二,PRAGMA journal_mode = WAL;不能在事务内执行。如果在$pdo->beginTransaction()之后执行,SQLite会直接报错。我习惯在所有事务代码之前、连接建立后立刻设置。
第三,WAL模式需要SQLite 3.7.0以上版本支持,2010年后的SQLite基本都内置了。但PHP环境的SQLite版本可能很老。可以通过php -i | grep -i sqlite查看当前加载的SQLite版本。如果你发现PHP把SQLite编译成旧版本,那就得考虑升级PHP或者换PDO驱动。
2.4 两个常用的诊断命令:确认WAL是否真的开启
确认WAL是否生效,我一般用两个方法。
第一个是在PHP里查询当前模式:
$mode = $pdo->query('PRAGMA journal_mode;')->fetch(PDO::FETCH_COLUMN); echo $mode; // 输出 wal 或 delete第二个是在命令行里直接观察文件系统。开启WAL后,数据库目录下应该会出现三个文件:
ls -l /var/www/html/app.db*正常情况下你会看到app.db、app.db-wal、app.db-shm三个文件。如果长时间运行且有过写操作,却没有-wal文件,说明可能没启用成功,或者连接一关闭就自动checkpoint并把日志合并了。
3. 实测:同一个PHP业务在两种模式下的并发表现
3.1 测试场景:一个带签到和弹幕的PHP应用
我为了写这篇博文,专门搭了一个贴近实际的应用场景来测试。场景是:页面上用户发送弹幕或执行签到,这些动作都会往SQLite里写记录;同时其他用户不断读取最近弹幕与签到结果。这个场景本质上是高并发读写混合,非常容易在默认模式下触发锁错误。
测试环境是:Intel NUC i5,NVMe固态硬盘,PHP 8.2,SQLite 3.40,PHP-FPM运行方式。数据库只有一张表,字段包括id、user_id、content、created_at,索引在created_at上。我用两个脚本模拟,一个循环写100条记录,一个循环读取100条记录,分别用默认模式和WAL模式跑五轮取平均值。
3.2 用脚本跑出两组数据
我的测试脚本关键部分大概长这样:
<?php // writer.php - 模拟写操作 $pdo = new PDO('sqlite:/tmp/bench.db'); $pdo->exec('PRAGMA journal_mode = WAL;'); $pdo->exec('PRAGMA synchronous = NORMAL;'); $start = microtime(true); for ($i = 0; $i < 100; $i++) { $pdo->exec("INSERT INTO logs (user_id, content) VALUES (1, 'test-$i')"); } $cost = microtime(true) - $start; echo "写入100条耗时: " . round($cost * 1000, 2) . "ms\n";<?php // reader.php - 模拟读操作 $pdo = new PDO('sqlite:/tmp/bench.db'); $pdo->exec('PRAGMA journal_mode = WAL;'); $start = microtime(true); for ($i = 0; $i < 1000; $i++) { $stmt = $pdo->query('SELECT COUNT(*) FROM logs'); $stmt->fetchColumn(); } $cost = microtime(true) - $start; echo "读取1000次耗时: " . round($cost * 1000, 2) . "ms\n";然后我用exec同时拉起多个reader和writer子进程去“对战”,记录五轮数据取平均。测试结果对比如下:
| 指标 | rollback journal模式 | WAL模式(synchronous=NORMAL) |
|---|---|---|
| 连续写100条耗时 | 约185ms | 约96ms |
| 连续读1000次耗时 | 约270ms | 约210ms |
| 混合并发错误次数 | 频繁出现database is locked | 几乎没有锁错误 |
| 最大并发写入数 | 1个写者+0个读者 | 1个写者+多读者 |
看数据,WAL模式在写入上的提升非常明显,几乎是接近一倍。读性能提升没有写性能那么夸张,但并发场景下最核心的价值是“不锁了”,错误次数从几十次降到了零次。
3.3 测试结果分析:为什么WAL在并发上优势明显
为什么WAL在并发场景下优势这么大?核心在于两点。
第一,默认模式下,每个写事务要经历“写journal文件→写主数据库文件→删除journal文件”三步,每一步都可能触发磁盘同步,也就是fsync,而fsync是非常昂贵的操作。WAL模式把主数据库文件的随机写变成了WAL文件的顺序追加写,顺序写的性能远好于随机写。
第二,默认模式下,只要有一个读操作处于活跃状态,写事务就无法开始。PHP-FPM的场景中,大量请求都是处理完业务后随手执行一个查询,锁持有时间不长不短,但频繁切换,导致写请求经常排不上号。WAL模式下读操作不再持有阻塞锁,写事务可以跟读操作并行执行,这样数据库的整体吞吐就上去了。
4. 五个适合WAL模式的PHP落地场景与代码骨架
理论说完了,数据也有了,接下来聊聊实际应用。WAL模式适合哪些具体的PHP项目?我根据自己的经验,总结出五类高频场景。
4.1 弹幕、评论和帖子里的实时计数
很多PHP项目里会用一个计数器表来记录弹幕数、评论数、点赞数。这类业务的特点是:写操作极其频繁但数据量不大,读操作也密集但只是简单累加。在默认模式下,一个点赞写入会阻塞其他人的点赞写入,还会阻塞展示计数读取,体验会很差。
用WAL模式加计数器表,代码骨架可以这样写:
$pdo->exec('PRAGMA journal_mode = WAL;'); $pdo->exec('PRAGMA busy_timeout = 5000;'); // 写入计数 $pdo->exec("INSERT INTO counters (item_id, num) VALUES (123, 1) ON CONFLICT(item_id) DO UPDATE SET num = num + 1"); // 读取计数 $count = $pdo->query('SELECT num FROM counters WHERE item_id = 123')->fetchColumn();这里配合了SQLite的UPSERT语法,一次连接里就能完成“有则累加,无则插入”,不存在先查后写的竞态问题。
4.2 轻量任务队列:异步发信和爬虫URL分发
PHP做异步任务通常会用Redis或消息中间件,但很多中小项目并没有部署Redis。SQLite配合WAL模式,可以勉强充当一个轻量任务队列。它的写性能足够支撑中小规模入队操作,读取端也不阻塞入队操作。
一个极简的队列可以用如下方式实现:
// 入队 $pdo->prepare('INSERT INTO task_queue (task_type, payload, status, created_at) VALUES (?, ?, 0, ?)') ->execute(['email', json_encode(['to' => 'user@example.com']), time()]); // 取队头(配合事务防止多个worker取到同一任务) $pdo->beginTransaction(); $task = $pdo->query('SELECT * FROM task_queue WHERE status = 0 ORDER BY id LIMIT 1')->fetch(PDO::FETCH_ASSOC); if ($task) { $pdo->prepare('UPDATE task_queue SET status = 1 WHERE id = ?')->execute([$task['id']]); } $pdo->commit();在这种场景下,WAL模式的意义在于:一个worker在消费任务时更新状态,其他worker依然可以读取未完成任务,不会因为一个worker在处理而阻塞整表读写。需要说明的是,SQLite写锁只有一个,所以当任务量很大时,消费并发依然受限,但作为轻量级方案完全够用。
4.3 数据去重与黑名单过滤
爬虫系统或者表单系统里,经常需要判断一条数据是否已经存在。多进程PHP爬虫会同时向数据库写大量URL,如果用了默认模式,经常会出现一个进程正在写URL、另一个进程查询时直接报锁错误。配合WAL模式和INSERT OR IGNORE,可以非常简洁地实现高并发的去重写入:
$pdo->exec('PRAGMA journal_mode = WAL;'); $stmt = $pdo->prepare('INSERT OR IGNORE INTO url_lib (url) VALUES (?)'); $stmt->execute(['https://example.com/page/123']);INSERT OR IGNORE的性能优势在于,它把“先查一次是否存在,不存在才插入”的两步操作合并为一步,同时配合一个唯一索引,SQLite会在引擎内部完成判断。WAL模式让多进程可以同时对这个表执行写操作(只要不同时处于事务内),虽然本身一次只能允许一个写者,但从排队等待到真正落盘的时间大幅缩短。
4.4 本地日志聚合与报表
另一个我实际用过的场景:多个PHP脚本往SQLite里写操作日志,另一套定时脚本读取日志做聚合统计。默认模式下,写日志会阻塞读日志,读日志也会反过来阻塞写日志,导致日志系统在高负载下明显卡顿。开启WAL后,写入端持续追加,读取端随时可以查询,两边各走各的通道。这种场景下WAL模式带来的体验改善最为直接,几乎感觉不到锁的存在。
4.5 不适合用WAL的场景
说了适用场景,也得说说不适合的。如果你有持续高并发的纯写入需求,WAL模式也救不了SQLite,因为它依旧只有一个写者。两个写事务之间依然互相排斥,只是冲突率下降而不是归零。再比如需要跑在NFS共享盘上的数据库,WAL模式根本起不来,得老老实实用回默认模式。另外,如果有明确的分布式多节点需求,SQLite在架构上就不合适,WAL模式谈不上任何帮助。
5. WAL模式下的坑与排查实录:从生产环境踩出来的经验
理论和测试都过关了,真正干活的时候才是坑最多的时候。下面这几个问题,全是我自己或者身边同事在真实环境里踩过、排查过的。整理出来,希望你能少走弯路。
5.1 在NFS或SMB共享盘上使用WAL直接报错
有一段时间,我们把一个PHP应用的数据库文件放在内部NAS上,想着双机挂载都能访问。结果一执行PRAGMA journal_mode = WAL;,直接报错attempt to write a readonly database或者disk I/O error。
后来一查文档才知道,WAL模式的并发协调依赖-shm文件,这个文件必须通过操作系统内存映射机制来实现跨进程共享。NFS和SMB文件系统不支持正确的POSIX共享内存语义,在网络上无法保证文件锁的一致性,SQLite就拒绝使用WAL模式。这个坑的解决办法是:把数据库从NAS上挪到本地磁盘,或者干脆放弃WAL模式,继续用默认回滚日志。如果把数据库放在Docker volume里,也要注意volume如果最终落在NFS上,同样会遇到这个问题。
5.2 备份数据库时漏了-wal文件,恢复后数据缺失
这是我见过最多人在线上翻车的地方。有人用SQLite数据库做数据存储,每天定时复制app.db到备份目录。结果某天数据库文件损坏需要恢复,恢复出来的数据总是少了最近一段时间的内容,怎么查都查不出原因。
问题出在:WAL模式下,最新提交的数据还在-wal文件里,没有合并回主app.db文件。直接复制app.db相当于只备份了旧数据。正确做法有三种:
第一种,在备份前先执行checkpoint合并:
$pdo->exec('PRAGMA wal_checkpoint(TRUNCATE);');这样会把-wal文件内容合并回主文件,之后再复制app.db。
第二种,使用SQLite的在线备份API,PHP里对应SQLite3::backup方法,或者不需要代码,直接用SQL语句:
VACUUM INTO '/path/to/backup.db';VACUUM INTO会把整个一致性快照写入新文件,兼容当前主流SQLite版本。
第三种,直接连app.db-wal一起备份,但恢复时三个文件必须同时在同一个目录,缺一不可,比较麻烦。
我在自己项目里的经验是:备份脚本固定先执行PRAGMA wal_checkpoint(TRUNCATE);,然后用VACUUM INTO生成快照,最后再复制一份。这样做虽然多一个步骤,但恢复时绝不会缺数据。
5.3 大量遗留-wal文件导致磁盘暴涨
有朋友遇到过一个诡异情况:数据库文件本身才几十MB,但-wal文件暴涨到好几个GB,磁盘告警。查看日志后发现,他的PHP代码里连接SQLite后执行了一次长事务,开启后忘记提交也没关闭连接,导致WAL文件无法正常checkpoint。
在WAL模式下,checkpoint动作通常发生在-wal文件达到wal_autocheckpoint阈值时,或者最后一个连接关闭时。如果有一个连接一直处于事务开启或活动状态,自动checkpoint可能被无限期延迟。解决办法是检查代码里是否有未关闭的PDO连接或未提交的事务,尤其是PHP-FPM的长生命周期进程。
另外还有一个做法:如果确认业务高峰已过,可以手动执行:
PRAGMA wal_checkpoint(TRUNCATE);这个语句会立即触发checkpoint并把-wal文件截断归零,磁盘占用立刻降下来。我建议定时任务里加一条低峰期自动checkpoint,防止-wal文件无限增长。
5.4 .db-shm权限问题导致并发进程互相干扰
WAL模式除了-wal文件,还会生成-shm文件,这个文件本质上是操作系统级别的共享内存映射。在多进程并发场景下,如果PHP-FPM进程以不同用户身份运行,或者-shm文件权限不对,就可能出现一个进程读不到其他进程已经提交的数据,甚至互相锁死。
这种问题的排查比较隐蔽,现象是同一个数据库在A进程能查到数据,在B进程查不到,或者明明没有写操作却一直busy。我遇到过一次是部署时用了不同的系统用户来跑两个定时任务,结果其中一个用户创建的-shm文件另一个用户没有写权限,导致WAL索引无法更新。
排查方法很简单:先看数据库目录下文件的权限,确保运行PHP进程的用户对.db、-wal、-shm三个文件拥有读写权限。另外,如果旧文件权限异常,可以先把所有PHP进程停掉,删除-wal和-shm文件再重新启动,让SQLite重建这两个文件。
5.5 快速排错速查表
| 错误或现象 | 常见原因 | 解决思路 |
|---|---|---|
database is locked | 两个写事务并发;busy_timeout=0 | 设置PRAGMA busy_timeout;重试机制 |
attempt to write a readonly database | 磁盘只读;目录权限错误;NFS环境 | 检查文件权限、移动数据库到本地磁盘 |
disk I/O error | WAL模式在NFS上运行;磁盘故障 | 改用rollback journal模式;检查磁盘健康 |
| 备份恢复后数据缺失 | 只复制了.db,漏了-wal | 先wal_checkpoint(TRUNCATE)再备份;或用VACUUM INTO |
-wal文件体积异常大 | 长事务未结束;PHP连接未关闭 | 检查事务泄漏;定时执行wal_checkpoint(TRUNCATE) |
| 切换WAL失败,日志模式返回delete | 在事务内执行PRAGMA;SQLite版本太老 | 事务外执行PRAGMA;升级SQLite版本 |
| 并发进程读到不一致数据 | -shm权限异常;多用户运行身份 | 统一运行用户;重建-shm文件 |
最后分享一点个人经验
我个人现在处理PHP+SQLite项目的原则是:只要涉及两个以上进程同时访问同一个数据库文件,第一件事就是确认代码里有没有执行PRAGMA journal_mode = WAL;。这一步能让绝大多数锁问题直接消失,性价比非常高。
还有一个常被忽略的小技巧:如果你把PHP应用打包成Docker镜像跑,数据库文件放在持久化卷里,建议设置synchronous = NORMAL,并且写一个定时脚本,每天低峰期执行PRAGMA wal_checkpoint(TRUNCATE);。我自己在容器环境中遇到过几次-wal文件异常增长的问题,加了定时checkpoint之后,再也没犯过。WAL模式不是银弹,它解决的是SQLite并发读写场景里的核心痛点,但也有自己的边界。清楚边界、配置对参数、备份讲方法,这套组合拳打下来,在PHP项目里用SQLite一样能跑得又稳又省心。