1. 事故回放两个进程同时写一个缓存文件最终被写成了什么先从一个真实案例说起。有段时间我发现生产环境上偶尔会冒出一个“半截”的 JSON 缓存文件前端请求时能正常读到完整数据但后台清理任务跑完后接口偶发报错提示json_decode(): null而且这个错误只在请求量最高的几分钟内出现手动重现又怎么都复现不出来。最后把出问题的时间点和日志一对照发现每次报错恰好发生在后台写缓存任务执行期间而且每次损坏的文件大小都比正常文件小很多只有几 KB 到十几 KB 不等。这个现象用 PHP 单进程思维很难理解file_put_contents($path, $json)一行代码写入文件失败时返回 false怎么会写出“半截”内容真正的元凶是多进程并发写同一个文件。当 PHP-FPM 的多个 worker 同时执行“读旧文件-组装新内容-写入文件”这个流程时文件内容会发生交错覆盖。file_put_contents默认使用的是fopen($path, wb)加fwrite这个过程分三步截断文件、写入数据、关闭文件。两个进程同时执行时可能出现进程 A 把文件截断成 0 字节进程 B 紧接着把文件截断成 0 字节进程 A 写入了一半内容进程 B 从第 0 字节开始覆盖写入但它要写的内容更长先把 A 的内容覆盖了一部分接着自己也被调度打断最终文件里是 A 和 B 内容的混合体甚至直接是 B 刚写一半的结果。这就是典型的竞态条件。关键点在于“读-改-写”并不是一个原子操作中间存在时间窗口。这个窗口在毫秒级但架不住请求量大、写操作频繁。并发越高窗口被撞上的概率就越大。尤其是做缓存预热、定时任务、队列消费这类场景多个 worker 同时开工几乎必然触发。我见过不少人把这个锅甩给磁盘或 PHP 本身其实 PHP 的fwrite本身没有错错的是没有做进程间同步。PHP 是单线程模型单个请求内不需要考虑并发写同一资源可一旦放到多进程环境——PHP-FPM 多 worker、CLI 脚本开多进程、crontab 和队列消费同时跑——就必须自己处理并发问题。2. flock 到底锁住的是什么正确用法与常见误用2.1 flock 加锁模型共享锁与独占锁PHP 提供的最直接方案是flock()它的名字是 file lock 的缩写作用是对一个已打开的文件句柄施加建议性锁。两种最常用的锁模式LOCK_EX独占锁写文件前申请同一时刻只允许一个进程持有LOCK_SH共享锁读文件时申请允许多个进程同时持有但不允许与独占锁共存LOCK_UN释放锁LOCK_NB非阻塞模式拿不到锁时立即返回 false而不是等待。注意“建议性锁”这个词。flock 不是强制锁它只对“同样调用了 flock 的进程”生效。别的进程不理会这个锁、直接读写文件时锁形同虚设。所以在多进程写同一个文件的场景里所有参与读写的代码路径都必须统一走加锁逻辑缺任何一方都可能破坏互斥。一个典型用法如下?php $path /tmp/data.json; $fp fopen($path, c); if (!$fp) { throw new RuntimeException(无法打开文件: {$path}); } // 等待锁最多阻塞 5 秒 $start microtime(true); while (true) { if (flock($fp, LOCK_EX | LOCK_NB)) { break; } if (microtime(true) - $start 5) { fclose($fp); throw new RuntimeException(获取文件锁超时); } usleep(100000); // 100ms 后重试 } // 读取旧内容如果有 $content stream_get_contents($fp); // 业务计算生成新内容 $newContent buildData(); // 清空文件并从头部写入 ftruncate($fp, 0); rewind($fp); fwrite($fp, $newContent); fflush($fp); // 释放锁并关闭 flock($fp, LOCK_UN); fclose($fp);这段代码有几个容易忽略的细节第一文件打开模式用的是c而不是w或wb。w模式会在打开文件时就把文件截断如果此时还没拿到锁等于别人还在读文件时你就把内容清空了锁的意义直接归零。c模式不会截断文件而且能创建文件非常适合“先打开、后加锁、再读写”的流程。第二阻塞模式下如果用flock($fp, LOCK_EX)在没有释放锁的情况下会一直挂起脚本可能长时间卡死。生产环境里我更推荐上LOCK_NB加重试循环并且设置超时上限。这样即使某个进程异常持锁不释放其他进程也不会无限期阻塞而是能快速失败、进入降级流程。第三ftruncate($fp, 0)之后必须rewind($fp)否则写入位置还在文件末尾前面的旧内容虽然被截断但写指针不在 0 的位置数据会错位。这个坑很隐蔽我第一次写的时候就没注意结果锁加上了文件内容照样错乱。2.2 自锁陷阱锁文件和目标文件混为一谈的后果使用 flock 的第一个高频误用是“锁定目标文件本身”。比如$fp fopen(/tmp/data.json, c); flock($fp, LOCK_EX); // 重新打开同一个文件来写 file_put_contents(/tmp/data.json, $newContent, LOCK_EX); fclose($fp);这段代码看起来没问题实际上申请了两把独立的锁而它们互不干扰。第一次flock($fp, LOCK_EX)锁的是文件句柄 A第二次file_put_contents(..., LOCK_EX)打开一个新的文件句柄 B 并加锁。flock 的锁是和“打开的文件描述”绑定的同样的文件路径两次独立的fopen会产生两个不同的内核文件对象锁也是两把独立的锁。两个进程分别持有自己的锁时完全感知不到对方。所以正确的做法是自始至终只使用同一个文件句柄完成加锁、读写、解锁。上面 2.1 节的代码已经展示了这个原则。文件锁不是“文件路径锁”而是“打开的文件描述上的锁”这一点必须刻在脑子里。实践中也会见到用独立锁文件的方式$lockFp fopen(/tmp/data.json.lock, c); if (!flock($lockFp, LOCK_EX | LOCK_NB)) { // 没拿到锁直接返回上次的缓存 return readCache(); } // 在锁保护下写真正的数据文件 writeDataFile($newContent); flock($lockFp, LOCK_UN); fclose($lockFp);这种“锁文件与数据文件分离”的设计有个好处加锁时不用打开或创建真实数据文件避免因为数据文件被误删除或权限变更导致锁失效。但是要特别注意加锁的文件句柄$lockFp必须在整个临界区存活直到fclose才会自动解锁。在重构代码时很容易不小心把fclose($lockFp)提前导致后面的写入操作实际上在无锁状态下进行。3. 把锁写进代码依然失效完整排查链路3.1 用 strace 验证锁是否真的加上了如果你明明写了 flock可问题照旧第一步不是看业务代码而是确认锁在内核层面是否生效。Linus 有句名言Talk is cheap, show me the code。放到这里就是代码里调用 flock 不代表内核真的拿到了锁必须观察系统调用结果。对 CLI 脚本可以直接用 stracestrace -f -e traceflock,fcntl,openat,ftruncate,write php /path/to/worker.php-f参数会跟踪子进程-e traceflock,fcntl,...只输出和文件操作相关的系统调用。正常加锁时你会看到类似openat(AT_FDCWD, /tmp/data.json.lock, O_RDWR|O_CREAT, 0644) 3 flock(3, LOCK_EX|LOCK_NB) 0 ftruncate(3, 0) 0 write(3, {\data\:...}, 123) 123 flock(3, LOCK_UN) 0 close(3) 0如果 flock 返回 EWOULDBLOCK表示锁冲突代码里就应该走了重试或降级逻辑。如果 strace 里根本没有 flock 调用说明代码执行路径根本没走到加锁逻辑比如有提前 return 或异常分支跳过了加锁区域。对 PHP-FPM 环境因为进程是常驻的用 strace 直接跟踪不太方便。这时可以临时把其中一个 worker 的请求打到日志里在加锁前后各记一条日志输出microtime(true)和进程 PID。如果多个请求的日志显示同一时刻进入了临界区说明锁没有生效如果日志显示某个 PID 在等待锁、其他 PID 完成后才继续说明锁是有效的。这种土办法在生产环境反而最方便。3.2 NFS、容器挂载和 Windows 上的锁不可靠flock 的行为依赖底层文件系统。在本地磁盘、ext4、XFS 上表现良好但换到 NFS、SMB/CIFS 共享目录上经常出现“加锁等于没加”的情况。老版本 NFS 对 flock 的支持是通过 rpc.lockd 模拟的锁语义和本地文件系统差异很大可能表现为锁请求成功返回但另一个节点上的进程也能成功加锁进程崩溃后锁不能自动释放需要手动清理高并发下 flock 系统调用本身成为性能瓶颈。如果业务目录是 NFS 挂载的常见于容器集群和云上共享存储就别指望 flock 能严格互斥。比较务实的做法是数据文件保持在本地磁盘锁文件也放在本地不要放共享存储如果非要跨节点共享只能换分布式锁方案如 Redis、数据库锁。Docker 容器内也容易踩坑。容器内的文件系统如果用的是 overlay2flock 在某些内核版本上的行为会有些微妙差异特别是在容器内创建、宿主机也同时访问同一文件时。实测下来容器内对本地 bind mount 的目录使用 flock 基本没问题但对 overlay 层的文件加锁时要多留个心眼最好做一轮压测验证。Windows 环境下 PHP 的 flock 行为也值得注意。Windows 没有 POSIX flock 的完全等价物PHP 官方文档明确说过 flock 在 Windows 上使用的是强制锁定而不是建议性锁定某些场景下会被翻译成 LockFileEx可能带来额外的行为差异比如无法对同一文件以不同句柄多次加锁、锁的范围是字节范围而不是整个文件等。跨平台项目里如果对文件锁的行为做了强假设很容易在 Windows 上翻车。线上环境如果必须跑 Windows我倾向于直接放弃 flock改用数据库锁或者信号量。3.3 死锁场景阻塞锁与重入的连环陷阱用阻塞模式flock($fp, LOCK_EX)还有一个死锁风险。当代码中存在“嵌套加锁”结构时比如进程 A 持有文件 X 的锁等待文件 Y 的锁进程 B 持有文件 Y 的锁等待文件 X 的锁就形成了经典的死锁。PHP 里写嵌套加锁的机会不多但在写队列消费者、批量任务处理器时完全可能发生任务 A 处理时需要锁 cache.lock执行到一半调用的子函数又尝试锁同一个 cache.lock。同一进程内对同一个文件句柄重复调用 LOCK_EX 通常不会死锁flock 允许同一个文件描述重复加锁锁会被重入计数但如果每次打开新的句柄去锁同一个文件就可能把自己阻塞住。解决思路有两个全局统一入口所有加锁操作走同一个封装函数并且内部用静态数组记录当前进程已持有的锁避免同一个进程重复申请全用非阻塞模式加超时超时后强制返回失败不做无限等待。后面这种策略在工程上更省心。毕竟多进程环境下最不该有的就是无限阻塞——一次排队客户端超时进程堆积最后整个服务雪崩。4. 比 flock 更稳的原子写方案临时文件加 rename 的底层逻辑4.1 为什么 rename 是原子操作只做 flock 其实还不够。flock 解决的是“多进程互斥进入临界区”但没有解决“写入过程中读者看到半截文件”的问题。即使写操作全程持锁如果某个读请求不走锁比如只读缓存文件的接口为了性能没有加锁它依然可能在写入中途读到被截断或写了一半的文件。这个问题的终极解法是原子替换先写一个临时文件写完后rename()覆盖目标文件。rename()在 POSIX 系统上是一个原子系统调用要么指向新文件要么指向旧文件不存在中间状态。读者不管何时打开目标路径看到的都是完整的旧文件或完整的新文件永远不会看到半个文件。代码非常简单?php $dir /tmp/; $target $dir . data.json; $tmp $dir . data.json. . getmypid() . . . uniqid() . .tmp; // 写临时文件 $fp fopen($tmp, wb); if ($fp false) { throw new RuntimeException(无法创建临时文件: {$tmp}); } $data buildData(); if (fwrite($fp, $data) false) { fclose($fp); unlink($tmp); throw new RuntimeException(写入临时文件失败); } fflush($fp); // 如果要保证掉电后数据不丢还需要 fsync fsync($fp); fclose($fp); // 原子替换 if (!rename($tmp, $target)) { unlink($tmp); throw new RuntimeException(替换目标文件失败); }临时文件的命名必须保证唯一性。用getmypid()加uniqid()组合基本够用避免多进程同时生成同一个临时文件名否则写临时文件这一步就发生竞争了。写完临时文件后如果rename失败一定要unlink清理临时文件防止垃圾文件堆积。4.2 原子写和 flock 怎么配合到这里你可能要问既然 rename 已经保证读端不会看到半截文件那还需要 flock 吗答案是需要但作用变了。rename 解决的是“数据完整性”flock 解决的是“逻辑互斥”。考虑一个常见场景进程 A 和进程 B 并发生成最终数据两者各自计算自己的版本然后各自 rename。最后的效果是“后完成的进程覆盖先完成的进程”而非“先加锁的进程写入、另一个进程排队等待”。如果业务要求严格的串行执行比如每个进程必须基于上一次的结果做增量计算只有原子写是不够的必须加锁保证读-改-写整个流程的串联。所以我的推荐组合是// 在锁的保护下完成“读旧文件-计算新内容-写临时文件-rename” $lockFp fopen($dir . data.json.lock, c); if (!flock($lockFp, LOCK_EX)) { /* 获取锁失败处理 */ } $old file_get_contents($target); // 读完整旧文件 $new mergeData($old); // 基于旧数据计算 atomicWrite($target, $new); // 临时文件 rename flock($lockFp, LOCK_UN); fclose($lockFp);这个结构里锁负责“同一时刻只有一个进程在做读-改-写”原子写负责“即使有读者闯进来也不会看到坏数据”。两件事分开考虑少了哪层都有可能出问题。不过还有一个细节容易被忽略rename替换文件后之前用fopen打开的旧文件句柄依然指向旧 inode。如果你依赖 filemtime 或 inode 变化做缓存失效判断要注意这一点。另外某些文件监听库如 inotify能感知到 rename 事件这反而是好事因为它比文件内容变化更难被漏掉。4.3 要不要 fsync性能与持久性的权衡fsync 是很多人会跳过的步骤。它的作用是强制把文件数据从内核页缓存刷到持久化存储。不加 fsync 时如果机器突然断电临时文件的内容可能还没落到磁盘rename 之后的文件可能变成空文件或旧内容数据安全性无法保证。但是 fsync 很慢。随机写入场景下一次 fsync 的耗时可能在几毫秒到几十毫秒而且它会把整个文件的数据都刷一遍。对高吞吐的缓存写入场景每次都 fsync 会让性能显著下降。我的取舍标准是缓存类数据允许丢失、允许从源头重建不统一加 fsync只在进程退出前做一次全局 fsync订单、账单、状态记录这类关键数据必须fsync($fp)有时还需要对目录执行 fsync确保目录项本身也落盘如果追求更高性能可以引入批量提交机制攒够 N 条记录或经过固定时间间隔再落盘一次但需要评估崩溃窗口内丢失多少数据是可以接受的。另外注意fsync($fp)在 PHP 中只对文件流有效对某些包装器可能不支持执行时记得判断返回值或使用error_clear_last()辅助排查。5. 多进程 高并发下的锁策略升级从文件锁到队列消费5.1 文件锁的边界性能瓶颈与锁粒度当你把 flock 用得越来越熟练一定会碰到新的问题性能不够了。一个每秒钟执行几百次写操作的业务所有进程都在等待同一把锁临界区变得越来越长吞吐量上不去日志里全是“获取锁超时”。这说明“锁粒度”出了问题。文件锁的粒度是整个文件只要两个进程需要写同一个文件就必须串行。此时需要考虑的不是“怎么让锁更快”而是“为什么这么多进程都在写同一个文件”。最常见的优化方向是分片。把一个大文件拆成多个小文件每个进程根据自己的业务维度写入不同的小文件然后用独立的锁保护各自的小文件。比如用户缓存按 UID 分片cache/0/123.json、cache/4/456.json锁文件也对应分片cache/0/123.json.lock。这样不同用户的数据允许并行写只有同一个用户的写请求会彼此等待。第二个方向是“加锁前先判断是否需要写”。很多缓存更新其实是无效更新——数据内容根本没变只是因为请求触发就重写了文件。可以先读一次文件用内容的 hash 或版本号判断只有发生变化才进入临界区。这一步能用掉大量无意义的锁竞争。5.2 从文件锁升级到信号量锁semPHP 有专门的进程信号量扩展sysvsem提供的是进程级别的互斥信号量与文件系统无关。它的语义比 flock 更轻量不需要文件打开/关闭操作也没有 NFS 兼容性问题因为信号量不在文件系统上而是由操作系统内核管理。?php $key ftok(/tmp/queue.lock, S); $sem sem_get($key, 1, 0666, 1); // 1 表示初值 1即互斥锁 sem_acquire($sem); try { // 临界区 atomicWrite(/tmp/data.json, buildData()); } finally { sem_release($sem); }注意ftok()需要一个路径参数但这个路径是否真实存在会影响 key 的生成结果。更稳妥的做法是直接用sem_get(固定数字键, ...)找一个不会和其他应用冲突的键值范围。信号量的缺点是无法跨机器共享只能在单台机器的进程间使用而且 PHP 脚本崩溃时信号量可能不会被自动释放可能导致整个队列卡死——只是概率比文件锁更小。从工程角度看单机多进程内部的锁信号量比 flock 更好用性能略高、语义干净、不需要关心文件系统差异。但跨机器、跨容器部署时必须放弃它这时候只能引入外部协调器。5.3 真正治本把“多进程同时写”改成“串行消费”再往后走一步你会发现锁只是止损手段架构上更好的方案是让多进程不要同时去写同一份文件。这就是队列模型的价值。把写文件这个动作变成一个任务投递到消息队列由一个或少数几个消费者进程负责实际写入。多进程只负责把任务推送到队列写文件的冲突从根源上消失了。PHP 侧最简单的队列甚至不需要 Redis// 投递任务往队列目录写一个任务文件文件名以时间戳随机串区分 $taskId microtime(true) . . . getmypid() . . . uniqid(); file_put_contents(/queue/tasks/ . $taskId, $data); // 消费者只读取队列目录中的文件处理完成后删除 foreach (glob(/queue/tasks/*) as $taskFile) { $data file_get_contents($taskFile); writeDataFile($data); unlink($taskFile); }生产环境可以直接用 Redis List 或 Beanstalkd 之类的队列系统。这样做的收益不仅是不需要锁还能带来限流、重试、失败隔离等额外能力。对于“多个进程同时写一个缓存文件”这种问题队列化往往是效率最高、心智负担最低的结局方案。5.4 压测实测本地磁盘、不同策略的耗时对比为了验证不同方案的差距我在 4 核 8GB 的虚拟机上用 50 个并发进程做了一组快速压测每个进程循环写同一个文件 100 次。结果如下环境差异会带来偏差但相对趋势可参考| 方案 | 平均单次耗时 | 是否出现文件损坏 | 备注 | |---|---|---:|---|---| | 直接 file_put_contents | 0.8ms | 频繁出现 | 竞态最严重 | | file_put_contents 加 LOCK_EX | 3.2ms | 无 | 大量等待时间 | | c 句柄 flock ftruncate rewind | 3.5ms | 无 | 稳定但临界区偏长 | | 临时文件 rename无锁 | 1.1ms | 无半截文件但可能后覆盖先 | 读端安全逻辑不互斥 | | flock 临时文件 rename | 4.1ms | 无 | 最稳但耗时最高 | | 队列化消费 | 0.9ms投递端 | 无 | 把压力转移给消费者 |结论很清晰单次写入耗时差异不大真正的差异在“边际效应”——加锁之后临界区变长进程数量越多等待时间增长越明显。到了几十甚至上百个并发进程时锁等待会成为主要的性能瓶颈这时候就该往分片或队列方向走了。结尾一些实操上的经验写了好几年 PHP 的多进程代码我自己的体会是文件锁绝对不是万能的但如果你只是单机、单目录内的多进程想保证数据一致性flock 加原子写完全够用。这套组合在 PHP-FPM 的 worker 之间、CLI 脚本的子进程之间、定时任务和队列消费之间都能正常工作前提是搞清楚锁是绑定在文件描述上的、读写双方必须走同一套锁逻辑。有一个很值得养成的习惯把加锁逻辑封装成一个统一的方法所有需要写共享文件的代码都通过它进入临界区。不要在不同模块里各自实现一遍 flock一旦某个分支忘记加锁或者打开文件的模式写错线上事故就会以极小概率但极其麻烦的方式出现。最后再分享一个小技巧写锁代码时把临时文件命名设计成目标文件名 唯一标识 .tmp再在临时文件内容开头写入一个版本号字段。这样即使将来出现诡异问题你也能从残留的临时文件内容里反向定位是哪个进程、哪个时间点写入的排查效率会高很多。