并发问题几乎是所有PHP业务系统绕不开的坑。库存超卖、订单重复创建、定时任务被多台机器同时触发,这些线上事故十有八九都和锁的使用不当有关。单机环境下一个flock就能解决大部分问题,可一旦服务上了集群,文件锁就彻底失效了,这时候分布式锁就成了必选项。本文从单机锁讲到Redis分布式锁,把加锁、续期、解锁的完整链路和容易踩的坑一次讲透。

一、先搞清楚:单机锁和分布式锁的区别在哪
很多PHP开发者对锁的理解停留在flock阶段。文件锁在单机部署下确实好用,比如防止同一个cron脚本并发执行,代码非常简单:
$fp = fopen('/tmp/my_task.lock', 'w+');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
exit("任务正在执行中\n");
}
// 执行业务逻辑
flock($fp, LOCK_UN);
fclose($fp);
这段代码在单台服务器上没有任何问题,但项目一旦横向扩容到两台机器,每台机器上都有自己的/tmp/my_task.lock,锁就形同虚设。同理,MySQL的行锁(SELECT ... FOR UPDATE)虽然能跨机器生效,但它依赖事务连接,持有时间受长连接和超时配置限制,高并发场景下容易把数据库连接池打满,性能开销也不小。
分布式锁的核心诉求是:多个进程、多台机器抢同一把锁,且这把锁要有一个所有节点都能访问的第三方存储来托管。可选方案主要有三种:数据库(性能差)、Redis(性能最好,使用最广)、ZooKeeper(一致性最强但部署重)。对绝大多数PHP项目来说,Redis是性价比最高的选择,PHP的phpredis扩展也提供了完善的原子命令支持。
二、Redis分布式锁的正确加锁姿势
网上流传最广的错误写法是用SETNX加锁,再用EXPIRE设置过期时间。这两步不是原子操作,如果程序在两步之间崩溃,锁就永远无法释放,形成死锁。正确的做法是使用一条命令完成加锁和设置过期时间:
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$lockKey = 'lock:stock:1001';
$lockValue = uniqid('', true) . getmypid(); // 唯一标识,防止误删别人的锁
$expire = 10; // 秒
// 一条命令原子完成 SETNX + EXPIRE
$result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => $expire]);
if ($result) {
echo "加锁成功\n";
try {
// 执行业务逻辑
} finally {
releaseLock($redis, $lockKey, $lockValue);
}
} else {
echo "获取锁失败\n";
}
这里有几个关键细节必须强调。第一,EX过期时间一定要设置,这是防止死锁的最后防线——即使进程被kill,锁到期后也会自动释放。第二,锁的value必须是每个客户端唯一的标识,目的是在释放锁时校验这把锁是不是自己加的。如果不做校验,就会出现这样的悲剧:客户端A的业务执行太慢导致锁过期,客户端B拿到了锁开始执行,随后A执行完毕直接删除了锁,结果C又拿到了锁,B和C并发执行,锁彻底失效。
第三,过期时间设多长是个经验活。设太短,业务没执行完锁就没了;设太长,进程崩溃后其他节点要干等。稳妥的做法是结合「看门狗」机制:开一个后台定时任务,每隔过期时间的三分之一就检查锁是否还属于自己,属于则续期。如果用Swoole或Workerman常驻进程,实现起来很自然;FPM模式下可以用子进程实现,或者直接把过期时间设得宽裕一些。
三、释放锁为什么必须用Lua脚本
释放锁听起来简单,不就是DEL一下?但「判断锁的值 + 删除锁」这两步在PHP中是两次网络往返,中间存在竞态窗口:判断的时候锁还是自己的,删除的时候可能已经过期易主了。Redis单线程执行Lua脚本是原子的,天然解决这个问题:
function releaseLock(Redis $redis, string $key, string $value): bool
{
$lua = <<<LUA
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
LUA;
return (bool)$redis->eval($lua, [$key, $value], 1);
}
脚本的逻辑很直白:只有锁的值和自己持有的标识一致时才删除,否则返回0。这样即使客户端A的锁早已过期,它尝试释放时校验不通过,不会误删客户端B的锁。这个看似不起眼的校验,恰恰是很多自研分布式锁最常缺失的一环。
另外要理解一个前提:Redis分布式锁保证的是「尽力而为」的安全性。如果Redis是主从架构,主节点写入锁后还没同步到从节点就宕机,从节点晋升为新主后,锁数据丢失,另一个客户端可以再次加锁,出现两个持有者。如果业务对这种极端情况零容忍,要么使用Redlock算法在多个独立Redis节点上同时加锁,要么直接换ZooKeeper或etcd。对库存扣减这类场景,更务实的做法是分布式锁加数据库层面的乐观锁兜底,双保险。
四、典型场景下的并发控制策略
1. 秒杀扣库存:锁加乐观锁兜底
秒杀场景下,Redis锁用来拦截绝大多数无效请求,真正落到数据库的请求再用条件更新兜底:
UPDATE goods SET stock = stock - 1 WHERE goods_id = 1001 AND stock > 0;
受影响行数为0就说明库存不足或已被并发扣减,直接返回失败。这样即使锁出现极端失效,数据库层也能保证不超卖。
2. 防接口重复提交:锁加唯一约束
用户快速双击提交按钮,两次请求几乎同时到达。以用户ID加业务类型作为锁键,第一个请求加锁成功,第二个直接返回「请勿重复提交」。更彻底的方案是给订单表加业务唯一索引,从存储层面杜绝重复数据,锁只是用来提升用户体验和减少无效写入。
3. 多机定时任务:抢锁执行或任务分片
定时任务部署到多台机器时,通过Redis抢锁决定由哪台执行。如果任务量大,还可以把锁粒度细化到任务分片,每台机器抢不同分片的锁,并行执行互不干扰,这就是很多PHP任务调度框架的底层原理。
五、现成方案推荐
如果不想自己造轮子,可以关注这些方案。PHP生态里有基于Redis封装的分布式锁组件,Swoole生态则有Swoole官方的Lock以及各种协程安全的锁实现。Java领域的Redisson提供了完整的可重入锁、读写锁、看门狗实现,其设计思路值得PHP开发者借鉴:可重入性可以通过Hash结构记录持有者和重入次数,读写锁则通过两个关联的键配合Lua脚本控制读写比例。
最后总结几条实践原则:加锁和过期时间必须原子完成;锁的value要唯一并在释放时校验;业务执行时间不可控时要有续期机制;关键数据操作永远要有数据库层的兜底约束。锁不是万能药,它是并发控制的最后一道闸门,设计时优先考虑幂等设计和唯一约束,锁作为性能优化手段来用,系统才能既快又稳。