尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP多进程文件锁实战:从flock原理到防重入与竞态处理

PHP多进程文件锁实战:从flock原理到防重入与竞态处理 做 PHP 后端这几年真正让我觉得这语言跑在 Web 上很爽一上 CLI 多进程就原形毕露的场景就是文件系统锁定。你单机跑一个 PHP 脚本写个文件、读个缓存完全没问题。可一旦上了队列消费者、定时任务、图片批量生成这种多进程环境同一个文件被多个进程同时读、同时写问题就来了——日志内容互相覆盖、缓存文件变成半个、队列任务重复处理甚至直接把文件搞坏。这些事故的根源几乎都指向同一个东西文件系统锁定没做或者做错了。这篇内容我会围绕多进程 PHP 文件系统锁定这套组合展开从原理讲到实战再讲到底层的坑。适合正在写队列脚本、定时任务、图片处理管线的 PHP 开发者也适合那些被线上偶发故障折磨得头大的同学。全文没有废话都是可以直接抄走的方案。1. 锁问题的本质并发源头与竞态现场1.1 多进程到底从哪里来在 PHP 的 Web 场景里PHP-FPM 本身就是多进程模型——每一个请求可能由一个独立的 worker 进程处理。但真正把多进程这个词摆上台面的通常是 CLI 模式下自己写出来的并发用 Supervisor 配置了多个 worker 进程去消费同一个 Redis 队列用 Crontab 每 5 分钟跑一次定时任务结果跑慢了上一批还没结束下一批又启动了用 pcntl_fork 在脚本里拆出多个子进程并行处理一批图片缩放或视频转码任务用 Swoole 等多进程常驻框架处理异步任务task 进程之间共享文件资源。这些场景的共性是多个独立进程同时操作同一个文件。PHP 这门语言没有像 Java 那种内置的 synchronized 关键字也没有像 Go 那样的 goroutine 锁它最朴素、最可靠的跨进程同步手段就是 flock()。1.2 没有锁的现场一个并发写入事故模拟我先说一个真实发生过的例子。当时我做一个图片批量生成任务脚本按商品 ID 分批生成缩略图处理结果统一追加到一个 progress.log 文件里。逻辑写得很简单file_put_contents(/data/logs/progress.log, {$id} done\n, FILE_APPEND | LOCK_EX);你以为加了 LOCK_EX 就安全了对吧但我当时为了加快速度用 pcntl_fork 起了 8 个子进程同时处理。8 个进程同时 fopen 同一个日志文件虽然每次写入用了 LOCK_EX但打开文件、拼字符串、写入、关闭这个流程中间没有任何保护导致日志行与行之间出现错位、半行数据、甚至几条日志互相咬住。更典型的踩坑点在这里如果用 file_put_contents 的 FILE_APPEND | LOCK_EXPHP 会保证一次写入调用是原子的但如果业务逻辑是先读文件内容修改再写回这个读-改-写三步操作没有一把锁保护两个进程就可能同时读到旧值然后各自写回后写的一方直接覆盖先写的一方。这就叫竞态条件。1.3 为什么说数据库锁不是首选碰到这种情况很多人第一反应是我用 Redis 锁或者用数据库行锁。这两个方案不是不行而是引入的成本和复杂度往往超出一个文件操作的性价比。数据库锁你得保证事务隔离级别、索引唯一性稍不注意就出现死锁Redis 锁你得处理过期时间、锁续期、主从切换时锁丢失的问题Redlock 的争议至今没停。单机多进程之间互斥文件锁是开销最低、依赖最少、语义最直接的手段PHP 内置 flock() 直接调操作系统底层的锁机制不用安装额外扩展不依赖网络组件挂掉还能自动释放。这就是我把 flock 当成首选方案的根本原因在做单机内多进程互斥这件事上文件系统锁就是最合适的工具没有之一。2. flock 细节搞懂它才算入门2.1 四种锁模式怎么选flock() 的原型很简单但参数里藏着不少坑flock(resource $handle, int $operation, int $wouldblock null): bool第二个参数 $operation 支持四种组合常量含义典型场景LOCK_SH共享锁读锁多个进程同时读一个文件允许并发LOCK_EX独占锁写锁写入、修改、删除文件时互斥保护LOCK_UN释放锁手动释放或关闭文件句柄时自动释放LOCK_NB非阻塞配合以上使用拿不到锁立刻返回而不是干等设计原则很简单读文件用共享锁写文件用独占锁。如果你对读-改-写整个工作流都要保护那就别拆锁直接对整个流程加独占锁。注意LOCK_SH 和 LOCK_EX 是互斥的——当一个进程持有 LOCK_EX 时其他进程无论是请求 LOCK_SH 还是 LOCK_EX 都会阻塞除非加 LOCK_NB当一个进程持有 LOCK_SH 时其他进程还可以继续拿 LOCK_SH但拿 LOCK_EX 会被阻塞。这个机制跟读写锁的语义一致读读不冲突读写冲突写写冲突。2.2 阻塞与非阻塞不同场景的取舍这是最容易拍脑袋做错的地方。阻塞模式不加 LOCK_NB适合那种必须等锁拿不到就原地等待的任务比如队列消费者——前面的任务没做完后面的进程就等着等它释放了继续干。但问题是你永远不知道要等多久万一持有锁的进程卡死在 I/O 上你的进程也跟着一起卡住看起来就像任务hang 死了。非阻塞模式加 LOCK_NB适合拿不到锁就算了的场景比如定时任务防重入。上一批任务还在跑下一批启动发现锁被占用直接退出不要傻等。这是我最常用的姿势因为定时任务最怕的不是任务失败而是重叠执行导致的数据错乱。还有第三种折中方案用非阻塞 有超时的忙等循环。拿不到锁就 sleep 一小段时间再试超过阈值就放弃。这种方式既不会无限期等待也能给持锁方留出合理时间。下面实战部分的工具类我就按这个思路封装。do { $locked flock($fp, LOCK_EX | LOCK_NB, $wouldblock); if ($locked) { break; } usleep(200000); // 200ms } while ((microtime(true) - $start) $timeout);2.3 锁自动释放的生命周期flock 的锁绑定的是文件句柄 进程不是文件本身。这个理解非常关键。锁在文件句柄关闭fclose时自动释放锁在持有锁的进程退出时自动释放不管是正常退出还是被 kill -9锁在持有锁的进程 fork 产生子进程后子进程会继承父进程的文件描述符但 flock 锁不会被继承到一个独立的锁状态——多个进程对同一个文件描述符进行操作时行为可能和你预期不同这个我放在后面的坑里细讲。所以文件锁的一个好处是即使代码里忘了释放锁PHP 脚本执行结束、进程退出操作系统也会回收这个锁不会像 Redis 锁那样因为忘了删除而永久死锁。当然你也不能因此故意写得不规范显式释放永远是良好习惯。3. 实操从封装到落地3.1 封装一个自带超时的 FileLock 工具类直接裸写 flock 容易在业务代码里发散我习惯先封装成一个可复用的工具类。这个类不算复杂但把超时、非阻塞、PID 记录、异常安全都处理好了?php class FileLock { private $fp; private $lockFile; public function __construct(string $lockFile) { $this-lockFile $lockFile; } public function acquire(bool $blocking true, float $timeout 10): bool { $this-fp fopen($this-lockFile, c); if ($this-fp false) { throw new RuntimeException(无法打开锁文件: {$this-lockFile}); } $start microtime(true); do { // 先尝试非阻塞加独占锁 $locked flock($this-fp, LOCK_EX | LOCK_NB, $wouldblock); if ($locked) { // 拿到锁之后把当前进程 PID 记录到锁文件中方便排查死锁问题 ftruncate($this-fp, 0); fwrite($this-fp, getmypid()); fflush($this-fp); return true; } if (!$blocking) { return false; // 非阻塞模式拿不到直接返回 } // 阻塞模式小步重试避免 CPU 空转 usleep(200000); } while ((microtime(true) - $start) $timeout); return false; } public function release(): void { if (is_resource($this-fp)) { flock($this-fp, LOCK_UN); fclose($this-fp); $this-fp null; } } public function __destruct() { $this-release(); } }几个细节我说一下。fopen 的第二个参数用了 c 模式文件不存在则创建存在则打开且不截断这个模式和锁文件天然契合。注意不要用 w因为 w 会直接清空文件一旦多个进程同时打开就有清空竞争也不要用 a因为追加模式没法写 PID 进去每次都写到文件末尾。拿到锁后写入 PID 是个非常好用的习惯——线上排查时打开锁文件一眼就能看出是哪个进程占着锁这个习惯救过我至少三次。3.2 场景一定时任务防止重复执行最常见的需求就是 Cron 任务防重入。我见过太多线上事故10 分钟跑一次的脚本实际执行了 20 分钟第二个实例启动后两个进程同时读写同一批数据报表数据错了一半。用上面这个类解决核心代码很简洁require FileLock.php; $lock new FileLock(/var/run/locks/sync_orders.lock); // 非阻塞拿不到锁说明上一个实例还没跑完直接放弃本次执行 if (!$lock-acquire(false)) { exit(上一个同步任务仍在运行跳过本次执行\n); } try { // 正常的业务逻辑 // syncOrders(); } finally { $lock-release(); }我重点说下 try-finally 的意义。业务逻辑里如果抛出异常没有 finally 的话锁就一直持有到 PHP 进程结束。虽然 PHP 脚本结束会自动释放锁但如果你在常驻进程或长生命周期框架里这就是个隐形的坑。用 finally 保证无论正常结束还是异常锁都被释放。另外注意锁文件路径。我在生产环境统一放在 /var/run/locks/ 目录下这个目录在系统重启后会清空对锁这种临时状态来说正好合适。别放在 /tmp 下也别放在项目目录里——/tmp 所有用户都可写容易被别人干扰项目目录在代码发布时可能被清掉导致锁状态丢失。3.3 场景二多进程队列消费与图片生成多进程消费队列时文件锁通常用来做全局互斥或分片互斥。比如我有一个图片生成脚本按商品 ID 分片每个 worker 处理其中一段 ID。如果分配到同一段的两个 worker 同时启动它们会生成重复的缩略图还可能在写同一张图时互相覆盖。这种情况我用锁做分片保护$lock new FileLock(/var/run/locks/image_gen_{$shardId}.lock); if (!$lock-acquire(false)) { exit(分片 {$shardId} 已有任务在处理\n); } try { foreach ($productIds as $pid) { // 生成图片、裁剪、压缩 // generateThumb($pid); } } finally { $lock-release(); }锁的粒度从全局一把细化到每个分片一把并行效率高很多也不会互相干扰。队列消费也一样可以用队列名称做锁文件确保同一队列只有一个消费者在跑也可以用具体的业务主键做锁防止同一条消息被多个消费者重复处理。在并发较高的多进程图片生成场景里我还遇到过磁盘 I/O 瓶颈远比锁冲突更明显的现象。这时候与其纠结几百微妙的锁等待不如把重 I/O 操作串行化反而更快文件锁天然帮我们做到了这种串行化副作用反而变成了优点。3.4 锁的粒度与性能如何平衡锁的粒度决定了你能跑多快。全局锁性能最低但实现最简单细粒度锁性能高但锁文件数量会膨胀。我在实际项目中总结的经验是按不变量划分锁。比如处理用户订单和生成商品图片这两类任务之间没有任何共享资源就完全没必要用同一把锁同一个商品的两个图片任务写的是同一批文件就必须用同一把锁。锁文件命名用业务维度来区分就好比如 order_lock、product_img_lock或者更细的 product_{$id}_lock。还需要注意锁文件的清理策略。锁文件长期不删除数量一多就成了垃圾文件。我的做法是锁文件很小基本只有几个字节的 PID保留它并不浪费磁盘真正要清理的是那些长时间不被使用的业务锁可以定期删除超过 N 天没修改的锁文件但前提是删除时确认对应进程确实已退出否则就踩了后面要讲的 unlink 坑。4. 常见问题与排查速查4.1 最隐蔽的坑锁文件被 unlink 之后这是 flock 使用中最经典的坑没有之一。很多人会有这种洁癖用完锁觉得锁文件没用了顺手 unlink 掉。看起来没问题实际上会直接把锁底裤扒了。看这个场景// 进程 A 持有锁 $fp fopen(/tmp/app.lock, c); flock($fp, LOCK_EX); // 进程 A 结束后主动 unlink unlink(/tmp/app.lock);问题在于unlink 是把文件的目录项删掉了但进程 A 的锁仍然绑定在已打开的文件句柄上。此时进程 B 重新 fopen(/tmp/app.lock, c)操作系统会创建一个全新的文件跟进程 A 锁的不是同一个 inode。所以进程 B 可以轻松获取新文件的锁——两个进程各锁各的文件互斥彻底失效。进程 A 释放锁后旧文件句柄对应的 inode 才真正被回收。解决方式很简单锁文件只创建、不删除让它作为一个持久的锁标识存在。文件内容不用管反正我们会写入 PID。如果实在想清理只有在确认没有任何进程再引用该锁文件的情况下才能删但与其担惊受怕不如就直接不删。4.2 NFS 上 flock 的表现如果你的项目文件放在 NFS 网络存储上flock 的行为就要打个问号。早期 NFS 协议对 flock 的支持不稳定不同版本、不同 mount 选项差异非常大有些环境 flock 直接返回 false有些则完全没有互斥效果。我踩过的真实案例是服务器 A 和服务器 B 通过 NFS 共享一个目录两边同时跑同一个队列消费者。本地测试时锁一切正常上了灰度环境就疯狂重复处理。查了一圈问题出在 NFS 的 flock 支持上——两端进程根本感知不到对方的锁。这里给出两条建议如果多个服务器共享同一份文件系统就不要把 flock 当作跨机器的锁方案。文件锁只适合单机多进程互斥。跨机器的并发控制用 Redis 锁或数据库锁会更可靠它们不受文件系统协议影响。如果你确实要在 NFS 上用文件锁至少先在测试环境写个小脚本验证一下 flock 的互斥行为别等上了生产才发现。4.3 锁文件放哪里、权限怎么设置锁文件的位置和权限是个容易被忽视但后果很直接的细节。第一目录要提前创建好并且对运行 PHP 的用户可写。很多事故的发生是因为运维用了 www-data 用户跑 PHP-FPM但 /var/run/locks/ 目录的属主是 root导致 fopen 返回 false整个服务直接 500。我当时解决方式是在部署脚本里加上 mkdir 和 chownmkdir -p /var/run/locks chown www-data:www-data /var/run/locks chmod 755 /var/run/locks第二锁文件的权限建议设置为 0644 或 0660。不要用 0666那样其他用户也能写锁文件内容虽然不影响 flock 本身的互斥性但 PID 信息可能被别人污染干扰排查。第三锁文件所在的文件系统尽量用本地磁盘。上面说过 NFS 的问题边车容器、分布式存储目录也可能出现意外的锁行为本地磁盘最可靠。4.4 排查命令与工具线上如果发现任务互相干扰或者进程疑似卡在锁上我一般按这个顺序排查。先看锁文件内容是什么——如果里面写着 PID直接找到占用进程cat /var/run/locks/app.lock ps -ef | grep PID如果文件内容没写 PID或者想看实时锁状态Linux 下的 lsof 很有用lsof /var/run/locks/app.lock这个命令会列出所有打开该文件的进程输出里能看到是读锁还是写锁。如果没有任何进程打开这个文件但业务还在重复执行那极有可能是锁文件被 unlink 后的 inode 分裂问题。再看进程到底卡在哪。可以给进程发 SIGQUIT 或者用 strace 跟踪系统调用strace -p PID -e traceflock,fcntlstrace 会实时输出该进程的 flock 调用能看到它是阻塞在锁上还是在反复重试。这一步基本能定位 90% 的问题。如果怀疑是锁没有释放导致的僵尸锁fuser 命令可以直接把持有锁文件的进程列出来fuser -v /var/run/locks/app.lock确认无误后可以手动 kill 对应进程锁会自动释放不需要去删锁文件。4.5 死锁、超时与优雅退出经验死锁在单层 flock 的场景里其实很少见因为 flock 本身不会嵌套等待多个锁。但如果你在多进程里同时获取多个锁比如同时锁订单文件和库存文件两个进程各拿了一把锁又互相等对方释放死锁就来了。我的建议是一把锁解决不了一件事就归类好锁的顺序。所有进程都按相同的顺序加锁不要一个进程先锁 A 再锁 B另一个进程先锁 B 再锁 A。一旦出现死锁重启进程的同时锁会因为进程退出自动释放所以不用太慌。超时设计方面前面封装的类已经实现了带超时的等待锁。我调整超时参数的经验是普通文件操作 5 秒足够重 I/O 任务可以放宽到 30 秒到 60 秒如果超过 60 秒还没拿到锁多半是持锁方已经异常了这时候应该放弃并告警而不是无限等下去。最后再分享一个我在生产环境用的排查技巧日志里统一打印关键锁的 acquire 耗时和持有时间。刚开始加锁可能性能开销微乎其微但随着并发量上来锁竞争时间会成为性能瓶颈。有了耗时数据你才知道该不该拆分锁粒度、该不该换 Redis 锁。没有数据支撑的锁优化都是在瞎猜。根据我个人经验文件锁这类问题出现频率最高的时段往往是新功能上线后的第一个高峰。提前把锁的收益和代价想清楚写注释说明每个锁保护的不变量比什么都管用。
返回列表