ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP项目中SQLite WAL模式实战:解决并发锁与性能优化指南

PHP项目中SQLite WAL模式实战:解决并发锁与性能优化指南 做PHP开发这些年我处理过不少SQLite相关的线上问题。最让我头疼的不是数据写错而是明明并发量不大应用却频繁报出database is locked日志刷屏、接口超时用户端看到的就是白屏或卡顿。后来把SQLite切换到WAL模式很多锁问题一下就消失了。这篇就以“PHP的WAL模式应用”为主线聊聊WAL到底是什么、在PHP里怎么正确开启、实际能带来多少提升以及我自己在生产环境踩过的坑。如果你现在正因为SQLite并发读写头疼或者打算在PHP项目里用SQLite存点非核心数据这篇文章应该能帮到你。1. WAL模式到底改了什么SQLite的两种“落盘哲学”很多人知道WALWrite-Ahead Logging预写式日志是“并发变好了”但说不清它跟SQLite默认的日志模式差在哪里。理解这点很重要因为不少人在代码里只加了一句PRAGMA journal_modeWAL;遇到坑之后根本不知道怎么排查根源就是没搞懂这两种模式的工作链路。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页约4MBSQLite会自动执行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模式下synchronousFULL表示每次事务提交时都要将WAL文件fsync到磁盘synchronousNORMAL表示只在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 i5NVMe固态硬盘PHP 8.2SQLite 3.40PHP-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模式synchronousNORMAL连续写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 userexample.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的在线备份APIPHP里对应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_timeout0设置PRAGMA busy_timeout重试机制attempt to write a readonly database磁盘只读目录权限错误NFS环境检查文件权限、移动数据库到本地磁盘disk I/O errorWAL模式在NFS上运行磁盘故障改用rollback journal模式检查磁盘健康备份恢复后数据缺失只复制了.db漏了-wal先wal_checkpoint(TRUNCATE)再备份或用VACUUM INTO-wal文件体积异常大长事务未结束PHP连接未关闭检查事务泄漏定时执行wal_checkpoint(TRUNCATE)切换WAL失败日志模式返回delete在事务内执行PRAGMASQLite版本太老事务外执行PRAGMA升级SQLite版本并发进程读到不一致数据-shm权限异常多用户运行身份统一运行用户重建-shm文件最后分享一点个人经验我个人现在处理PHPSQLite项目的原则是只要涉及两个以上进程同时访问同一个数据库文件第一件事就是确认代码里有没有执行PRAGMA journal_mode WAL;。这一步能让绝大多数锁问题直接消失性价比非常高。还有一个常被忽略的小技巧如果你把PHP应用打包成Docker镜像跑数据库文件放在持久化卷里建议设置synchronous NORMAL并且写一个定时脚本每天低峰期执行PRAGMA wal_checkpoint(TRUNCATE);。我自己在容器环境中遇到过几次-wal文件异常增长的问题加了定时checkpoint之后再也没犯过。WAL模式不是银弹它解决的是SQLite并发读写场景里的核心痛点但也有自己的边界。清楚边界、配置对参数、备份讲方法这套组合拳打下来在PHP项目里用SQLite一样能跑得又稳又省心。
返回列表