news 2026/9/19 2:50:56

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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_EX,PHP 会保证一次写入调用是原子的,但如果业务逻辑是"先读文件内容,修改,再写回",这个读-改-写三步操作没有一把锁保护,两个进程就可能同时读到旧值,然后各自写回,后写的一方直接覆盖先写的一方。这就叫竞态条件。

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 和 chown:

mkdir -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 trace=flock,fcntl

strace 会实时输出该进程的 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 锁。没有数据支撑的锁优化,都是在瞎猜。

根据我个人经验,文件锁这类问题,出现频率最高的时段往往是新功能上线后的第一个高峰。提前把锁的收益和代价想清楚,写注释说明每个锁保护的不变量,比什么都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 2:48:51

Unity游戏音频系统实战:仙剑复刻项目的架构设计与性能优化

1. 复刻仙三不是"放个BGM"&#xff0c;音频系统的需求比想象中多这一篇是这个系列里我自己最期待动手的部分。Pal3.Unity项目定位是复刻《仙剑奇侠传三》&#xff0c;音频模块听起来简单——不就是AudioSource.PlayClipAtPoint嘛——但真正梳理需求后你会发现&#x…

作者头像 李华
网站建设 2026/9/19 2:44:44

AI编程工具双雄对决:Cursor与OpenCode的搭配使用指南

最近这两周&#xff0c;我身边的开发者几乎都在讨论同一个话题&#xff1a;AI编程工具到底选哪一个。有人吹Cursor&#xff0c;有人安利OpenCode&#xff0c;还有人把这两个名字放在一起当成了开源项目的组合。作为一个把大半工作流都迁到AI辅助编程上的老开发者&#xff0c;我…

作者头像 李华
网站建设 2026/9/19 2:43:51

ant-design Progress 进度条组件设计解析:从行为模型到源码实现

ant-design Progress 进度条组件设计解析&#xff1a;从行为模型到源码实现 【免费下载链接】ant-design An enterprise-class UI design language and React UI library 项目地址: https://gitcode.com/gh_mirrors/ant/ant-design Progress 是 ant-design 反馈类组件中…

作者头像 李华
网站建设 2026/9/19 2:43:38

Unity与Visual Studio环境配置避坑指南:从安装到调试的全流程排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华