多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP中SQLite WAL模式:解决database is locked

PHP中SQLite WAL模式:解决database is locked 做 PHP 这么多年真正让我愿意花时间去研究 WAL 模式的转折点是一次报表服务的高频锁冲突。当时服务用 SQLite 存中间结果请求量一上来日志里全是SQLSTATE[HY000]: General error: 5 database is locked。我一度想直接换 MySQL后来发现问题的根子不在数据库选型而在于我根本没让 SQLite 用上 WAL 模式。这篇文章就把我后来在 PHP 里把 WAL 模式落地、调优、排坑的完整经验写出来适合正在用 PDO 或 SQLite3 做轻量服务、本地缓存、指令队列的 PHP 开发者。先说结论WAL 模式在 PHP 生态里最典型的使用对象就是 SQLite但它的思想并不局限于 SQLite。理解写前日志你才能知道什么时候该开 WAL、开了之后要关注哪些参数以及哪些“灵异问题”其实是这个模式本身带来的副作用。1. 先讲清楚PHP语境下“WAL模式”到底解决什么问题1.1 “写前日志”四个字要怎么理解WAL 全称是 Write-Ahead Logging直译过来就是“写前日志”。看名字容易误解以为它是“在正式写入之前先写一份日志文件”这句话只说对了一半。它的本质是任何对数据的修改不是直接改主数据文件而是先把修改行为追加到日志文件里等条件合适了再把日志里的变更回放到主数据文件。生活里有个很贴切的类比餐厅后厨点菜时服务员不会直接去改菜谱而是先在点单小票上写下“3号桌要两条烤鱼”传菜员拿着小票去后厨按顺序做菜。小票就是日志菜谱就是主数据。哪怕某个瞬间后厨一片混乱点单小票已经完整记录了所有需求后面可以随时按小票重新做菜。放到 SQLite 上这个日志文件就是主库旁边出现的app.db-wal。原来的模式journal_modeDELETE是另一种写法事务提交前先建一个回滚日志提交时把修改直接写进主库文件再清理回滚日志。WAL 模式则完全反过来提交的事务先落在-wal文件里主库文件暂时不动之后由 checkpoint 机制把-wal中的页面合并回去。这个顺序上的差异决定了锁行为完全不同。DELETE 模式下写入和读取是互斥的一个连接在写其他地方读就会被堵住WAL 模式下读者看到的永远是某个时间点的数据库快照写者只在-wal文件尾部追加两边不抢同一把锁。1.2 PHP生态里的WAL不只是SQLite一家在 PHP 工程里聊 WAL其实会遇到三个常见对象类型典型代表在 PHP 里怎么接触共同点嵌入式数据库SQLite 的 WAL journalPDOsqlite:驱动、SQLite3 扩展单机、零配置、适合轻量读写服务端数据库日志MySQL InnoDB redo log、PostgreSQL WALPDOmysql:、pgsql:驱动崩溃恢复依赖日志重放内存存储持久化Redis AOFphpredis、Predis 客户端把写操作追加到磁盘文件重启后重放表里这三类都对“先落日志再改主数据”有依赖只是实现粒度不同。MySQL 的 redo log 是事务引擎内部的 WAL数据库内部维护Redis AOF 相对更简单就是把每个写命令追加到文件里重启后重新执行一遍。PHP 开发者最容易控制、最容易看到实际效果的就是 SQLite 的 WAL因为PRAGMA journal_modeWAL;一行语句就能切过去数据库没有另外部署成本。1.3 读多写少、写后必须可恢复这是WAL真正主战场WAL 模式不是银弹。它最适合的是“读多写少、写入量不大但要求读写互不干扰”的场景。我做报表服务时中间结果的读取频率远大于写入频率一批任务后台写入汇总数据前端多个请求同时读取。用 DELETE 模式时后台一提交大事务前端读取就容易吃database is locked。切到 WAL 模式后后台写-wal文件前端读主库加-wal里的已提交页面两边不再抢同一个文件锁database is locked瞬间少了很多。但要注意一个边界WAL 模式只优化读写并发不优化写写并发。SQLite 本身仍然只允许一个写者在同一时刻提交事务。如果你的业务场景是十几个 PHP-FPM 进程同时在疯狂写同一张表换 WAL 并不能让它们并行写只是让锁冲突的表现从“读取被阻塞”变成“写者需要短暂等待”。这一点不理解后面会走不少弯路。2. 在PHP里真正把SQLite切换到WAL模式的实操过程2.1 最小步骤一条PRAGMA和一条busy_timeout切 WAL 的代码非常简单简单到很多人写完之后反而会怀疑“就这么点东西”确实就这么点但有几个配套设置别落下。$pdo new PDO(sqlite:/data/app.db); $pdo-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 切到 WAL 模式返回 wal 表示成功 $journalMode $pdo-query(PRAGMA journal_modeWAL;)-fetchColumn(); // 写锁等待时间单位毫秒 $pdo-exec(PRAGMA busy_timeout 5000;); // WAL 下把同步级别设为 NORMAL兼顾安全和性能 $pdo-exec(PRAGMA synchronous NORMAL;);PRAGMA journal_modeWAL;的结果需要读取返回值wal才代表切换成功。如果返回delete或者memory说明 WAL 没有生效常见原因包括数据库是内存数据库、当前连接已经在一个事务里、目录权限不够导致 WAL 文件创建失败。busy_timeout很多人会忽略。它解决的是多写者竞争时的排队问题一个写者在提交另一个写者也想提交后者不是立刻报错而是先等 5000 毫秒。这个参数对 PHP 并发场景特别重要因为 PHP-FPM 每个请求都是独立连接多个写请求几乎不可能精确错开。synchronousNORMAL是 WAL 模式下的推荐配置。在 DELETE 模式下把 synchronous 调成 NORMAL 很危险可能数据库崩溃后无法恢复但 WAL 模式下只要不是多数据库跨库事务NORMAL 依然能保证数据库一致性只是极端断电情况下可能丢失最近一两笔已提交事务。业务上能接受这种粒度就用 NORMAL完全不能接受就保持 FULL代价是 checkpoint 期间写阻塞的概率会增加。2.2 权限、文件形态和关闭顺序里的坑SQLite 开启 WAL 后目录里会多出两个文件app.db-wal和app.db-shm。前一个是实际的日志文件后一个是共享内存索引文件。这两个文件不是临时文件而是运行期间一直存在。PHP-FPM 的运行用户必须对存放目录有写权限否则即使PRAGMA journal_modeWAL;执行成功真正写数据时也可能触发“attempt to write a readonly database”。有一个细节我踩过把 SQLite 数据库放在/tmp等共享目录然后两个不同的 PHP 项目共用同一个数据库文件。这种情况文件锁复杂WAL 模式下容易出现“数据库被锁定”的诡异现象。SQLite 官方文档也明确说了WAL 模式依赖操作系统原生的读写锁语义如果把数据库放在不保证 POSIX 锁行为的共享文件系统上问题会很多。另一个问题是关闭顺序。WAL 文件不存在于数据库连接建立期间而是在第一个写事务出现后生成。如果你在测试时发现某个目录下只有app.db没有-wal文件别慌说明当前连接还没有发生过写入或者数据库刚被完整 checkpoint 过。正常状态下数据库连接关闭时checkpoint 会把-wal合并回主库但只要有连接还开着这些文件就不会消失。提示不要手动删除正在使用中的-wal或-shm文件。删除正在使用的 WAL 文件轻则丢数据重则数据库直接拒绝打开。就算要清理也应该先通过 checkpoint 确认状态再在完全没有连接的情况下操作。2.3 SQLite3扩展下的等价写法以及两种API的取舍很多老项目不用 PDO而是用 PHP 的 SQLite3 扩展。写法也很直接$db new SQLite3(/data/app.db); $db-exec(PRAGMA journal_modeWAL;); $db-busyTimeout(5000); $db-exec(PRAGMA synchronous NORMAL;);PDO 的优势是预处理语句方便业务代码统一SQLite3 扩展提供了SQLite3::backup这类 PDO 没有的方法做在线备份时更顺手。实际项目里如果整个应用都是 PDO没必要为一个备份方法引入第二套 API你可以用VACUUM INTO替代备份。这个问题我在后面备份章节详细展开。还有一点在 PHP 里一条连接对应一次请求连接生命周期极短WAL 模式相当于是“反复开关”的。虽然 SQLite 的 WAL 状态会保留在主库文件头里但我依然建议在每个连接初始化时都执行一遍PRAGMA journal_modeWAL;原因不只是保险而是busy_timeout和synchronous这些参数本来就是按连接生效的你不设置新连接就吃默认值。3. WAL模式在PHP业务里的典型落地场景3.1 配置表与本地缓存把“偶尔写、频繁读”的痛点拆掉PHP 项目里最常见的 SQLite 用法不是存业务主数据而是存配置、字典、开关状态。这类数据读的频率远高于写而且写操作基本来自后台管理操作。以前用 DELETE 模式时缓存重建的瞬间如果赶上后台保存配置页面就会出现零星锁等待。切到 WAL 模式之后后台写配置不再阻塞前台读取前台读到的是写事务开始前的一致快照等事务提交后新请求自然读到新值。更好的写法是配置表之上加一层 PHP 内存缓存function loadConfig(PDO $pdo): array { static $cache null; if ($cache null) { $stmt $pdo-query(SELECT config_key, config_value FROM config WHERE is_active 1); $cache $stmt-fetchAll(PDO::FETCH_KEY_PAIR); } return $cache; }这里内存数组本身就是“读缓存”SQLite 的 WAL 模式保证的是“缓存重建那一刻后台写入不把数据库搞锁”。如果你用 APCu 做跨请求缓存思路也一样。不要天真地以为有了 WAL 就可以让所有读全部打到 SQLite那是把数据库当内存用WAL 优化不了那种量级。3.2 轻量任务队列让消费进程不再频繁撞锁很多 PHP 项目用 SQLite 当轻量队列表结构大概长这样CREATE TABLE job_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT NOT NULL, status INTEGER DEFAULT 0, created_at INTEGER ); CREATE INDEX idx_job_status ON job_queue(status);消费者逻辑通常是先查一条status0的任务再把它更新为status1处理完之后删除或更新为完成状态。多个消费者进程同时跑就一定会出现并发写。DELETE 模式下查和写互相阻塞任务只要一多日志里全是 busy。WAL 模式让读和写不再互斥但同一时刻多个消费者同时执行“UPDATE”依然可能撞写锁。我的经验是配合busy_timeout并让每个消费者的“拿任务”和“改状态”放在一个短事务里完成不要先查出来再等 10 秒再做第二次更新。长事务是 WAL 最大的敌人后面专门说。另外SQLite 本身不适合做高吞吐队列。如果任务是每秒几千条级别的直接用 Redis 或专门的消息队列。SQLite WAL 适合的是几十到几百 TPS 的本地任务队列这时候它比引入一套消息中间件要省心得多。3.3 事件流水与审计日志依托“写前日志”的尾部追加特性WAL 模式的日志文件本质上是追加写的。对“事件流水”这种只追加、很少修改的数据WAL 的 IO 模式非常契合一条条事件写进-wal文件等到 checkpoint 时再批量合并。我在一个 PHP 写的审计系统里就是这么用的每次用户操作往audit_log表插入一条记录。表很小但写入频率固定运维后台需要实时按用户查询最近操作。切到 WAL 模式后事件写入和后台查询分开走后台的长查询再也不会把业务写入堵住。不过要注意审计查询如果一直跑长事务会把 checkpoint 挡住导致-wal文件持续膨胀。所以凡是这类查询场景都应该只读不开事务PHP 的 PDO 设置PDO::ATTR_AUTOCOMMIT true查询语句跑完马上释放资源。4. 并发事务、checkpoint和备份容易被忽略的三件事4.1 事务快照和读写并发的关系WAL 模式下一个事务只要开启就能看到事务启动那一刻的一致性快照。哪怕另一个写者在事务执行期间提交了很多新数据这个事务依然读旧快照不会读到“改了一半”的状态。这对 PHP 后台脚本特别有用。比如批量处理一批订单处理前先开一个事务循环 500 条记录做更新。处理过程中用户刷新订单列表看到的是事务开始前的老数据不会看到更新一半的结果。事务提交后新连接立刻能看到完整的新状态。但这也带来一个容易被误解的点长事务虽然保证了读一致性代价却是阻塞 checkpoint。因为 checkpoint 必须保证不会覆盖掉事务正在读取的旧页面所以一有长事务挂着-wal文件就会越来越大。以前我处理的一个 PHP CLI 脚本每晚会遍历几十万行数据做汇总一个事务跑几分钟第二天早上发现-wal文件比主库还大。解决思路很朴素不要开一个超大事务改成批量提交。每处理 500 条提交一次既不影响整体进度也能让 checkpoint 有机会运行。如果需要“要么全部成功要么全部失败”的强一致再考虑大事务如果只是批量更新分批提交往往更合适。4.2 checkpoint不是随写随“落库”要理解自动和手动边界WAL 模式下数据并不是写完就立刻进主库文件而是先存在-wal里。checkpoint 的任务就是把-wal的内容合回主库。SQLite 默认在-wal文件达到 1000 页时自动触发 checkpoint。1000 页听着多实际一页 4KB大约 4MB。如果一个 PHP 进程频繁写大事务自动 checkpoint 可能跟不上-wal会持续膨胀。我建议在长期运行的常驻进程里定期手动执行一次主动 checkpointPRAGMA wal_checkpoint(TRUNCATE);TRUNCATE和默认的PASSIVE不一样。PASSIVE是能合并多少合并多少不主动清理文件TRUNCATE在 checkpoint 完成后会把-wal文件截断回零让磁盘尽快释放空间。但有连接正在读取时TRUNCATE可能没法立即截断这时它会等待。所以频率别太高我一般让常驻进程每隔几分钟或者每处理一批任务调用一次即可。另有场景很多 PHP 项目用cron每分钟启动一个脚本脚本处理业务后匆匆结束。这种情况反而不用担心-wal膨胀因为连接关闭时 SQLite 会自动做最后的 checkpoint。4.3 备份的时候不要“拷贝大法”WAL 模式下直接复制主库文件做备份是一个特别常见的错误。原因是主库文件里缺了-wal中尚未合并的已提交事务。你复制出来的备份库可能丢掉了最近一笔关键数据。正确做法之一是使用VACUUM INTO$pdo-exec(VACUUM INTO /backup/app- . date(YmdHis) . .db);VACUUM INTO会把当前数据库的一致快照写到另一个文件里过程中不需要手动处理-wal而且不影响线上连接继续使用。注意这个语句要求 SQLite 版本在 3.27 以上现代 PHP 8 环境基本都满足。另一种方式是 SQLite3 扩展的在线备份$source new SQLite3(/data/app.db); $backup new SQLite3(/backup/app-backup.db); $source-backup($backup); $backup-close(); $source-close();有了这两种方式就不要再去手动复制app.db加-wal文件了顺序、锁、一致性都很难处理好。5. 把WAL的“日志先行”思路搬到PHP自研组件里5.1 给轻量请求日志做追加文件与定期压缩WAL 的思想完全可以脱离数据库单独使用。我在一些监控脚本和定时任务里会自己设计一种“追加日志 后台合并”的存储按天生成一个 JSON Lines 日志文件PHP 写入时用file_put_contents($file, $line, FILE_APPEND | LOCK_EX)查询时按文件读取需要加速就定时把这些日志聚合成一个摘要表。这个结构和 SQLite 的 WAL 几乎同构新数据先写日志成熟后再做一次压缩归并。好处是写入路径非常简单不依赖数据库连接坏处是查询逻辑要自己实现。适合那种“写多、读少、读时能接受短期批量重建”的场景。5.2 用PHP配合Redis AOF或MySQL redo但不是无脑开如果你的 PHP 应用用了 Redis 做缓存和队列AOF 也是 WAL 思想的一个变种。Redis 配置里开启appendonly yes每个写命令都会追加到 AOF 文件重启后重放。PHP 客户端只是发命令不需要感知底层持久化细节但调优时要知道appendfsync everysec和always的区别前者每秒刷盘一次性能好极端场景最多丢一秒数据后者每条命令都刷盘安全但慢。MySQL 的 redo log 更底层PHP 框架基本碰不到。我反而建议 PHP 开发者不要试图去“调优” InnoDB 的 redo 参数那是 DBA 的活。你要做的只是理解为什么 MySQL 在系统崩溃后能恢复因为 redo log 帮它做了写前日志。理解这一点后你在设计自己的 PHP 数据组件时就会下意识地考虑“如果进程突然死了能不能靠日志重放回来”。5.3 事件溯源与重放把日志本身当成“唯一事实来源”前两年我在一个 PHP 写的订单状态机设计里真正用到了 WAL 思路所有订单状态变更不直接更新订单表而是先往order_events表追加一条事件记录订单的当前状态由这些事件通过回调逻辑推算出来。这就是事件溯源。事件表是“写前日志”订单状态是“读模型”。好处是任何一条状态异常都能追溯到当初的事件链坏处是读订单详情时必须经过事件重放性能完全依赖事件表的设计。在这个项目里我把事件表单独放在一个 SQLite 数据库并开启 WAL 模式。事件写入本身是追加天然适合-wal文件读模型则定期从事件表构建放进 PHP 内存数组或 Redis。实践下来这种方式在中小规模下比直接在一张订单表上频繁更新要稳定得多。如果对事件溯源不熟悉可以先从审计日志做起把“日志先行”思维带入日常开发。6. 我踩过的坑和最终选择建议6.1 NFS和共享磁盘上绝不盲目开WAL我吃过一次大亏把 SQLite 数据库放在 NFS 共享目录上开启 WAL 模式结果多个服务器同时读写时频繁出现database is locked和disk I/O error。后来查资料才确认WAL 模式对底层文件系统的锁语义要求很高NFS 这类网络文件系统很难保证与本地磁盘一致的锁行为。所以我的建议很简单SQLite 永远放在本地磁盘不要放在网络存储上。如果你的服务需要多个节点共享同一个数据库不要硬用 SQLite换 PostgreSQL 或 MySQL 才是正路。PHP 生态里很多“本地缓存服务”踩这个坑往往不是 WAL 的问题而是部署环境的问题。6.2 WAL不解决“多个写者”问题只把读写冲突摊开网上很多文章把 WAL 说得像万能解药我实际用下来的感受是它解决的是读与写之间的互斥解决不了写与写之间的竞争。SQLite 的写入能力上限就在那里单个写事务的提交速度受限于磁盘刷盘频率。如果你的服务是“多个 PHP 工作进程同时高频写同一张表”WAL 模式下的表现会从“大量 database is locked”变成“请求偶尔等待几十毫秒”。看起来好了不少但吞吐量依然上不去。这种场景应该从架构上拆分热数据放 Redis最终落库放 MySQL。SQLite 加上 WAL适合的是“一个主写入者 多个读取者”这个定位别搞反。6.3 长事务是WAL杀手控制提交粒度长事务让-wal文件膨胀是最隐蔽的问题。膨胀的-wal文件不仅占磁盘还会拖慢后续所有连接的打开速度因为 SQLite 打开数据库时要扫描并回放大量日志页。我在一个定时任务里每半小时检查一次 WAL 文件大小超过 64MB 就输出警告超过 256MB 就主动触发PRAGMA wal_checkpoint(TRUNCATE);并检查是否有长事务挂在数据库上。这个监控脚本帮我定位了好几个因为循环里忘记提交事务导致的问题。ls -lh /data/*.db-wal sqlite3 /data/app.db PRAGMA wal_checkpoint(PASSIVE);6.4 排查时的几行命令开发阶段排查 WAL 状态推荐在命令行直接跑sqlite3 /data/app.db PRAGMA journal_mode; sqlite3 /data/app.db PRAGMA wal_autocheckpoint; sqlite3 /data/app.db PRAGMA wal_checkpoint(PASSIVE); php -r $pdonew PDO(sqlite:/data/app.db); var_dump($pdo-query(PRAGMA journal_mode)-fetchColumn());第一行确认当前 journal 模式是不是wal第二行看自动 checkpoint 阈值第三行手动跑一次被动 checkpoint能快速压缩-wal文件最后一行确认 PHP 进程实际拿到的模式避免被命令行误导。这几个命令我几乎每次排查锁问题都会用省掉了很多“配置文件看起来没问题但线上锁死”的玄学时间。最后再分享一点个人感受把 SQLite 切换到 WAL 模式真的不复杂一条 PRAGMA 就能改变锁行为但它不是孤立配置要跟busy_timeout、synchronous、事务粒度、checkpoint 频率、备份方式一起调整才完整。我的项目从那次database is locked危机里走出来之后最大的收获不是“学会了 WAL”而是“锁问题要靠理解日志机制来解决”。如果你现在也正好面对同样的锁冲突先别急着换数据库把 WAL 模式吃透很多场景真的不需要动架构。
返回列表