1. 为什么PHP需要互斥锁?
在Web开发中,PHP通常以单线程模式运行,但在某些特殊场景下,线程安全问题依然会显现。最常见的情况是使用Swoole等协程框架时,或者在处理共享资源(如文件、数据库记录)的高并发请求时。想象一下电商秒杀场景:当1000个用户同时点击"立即购买"时,如果没有互斥机制,可能会导致库存超卖、订单重复创建等严重问题。
PHP的线程安全问题主要源于几个典型场景:
- 多进程环境下(如PHP-FPM)对共享文件的读写
- 使用Swoole协程时的共享变量访问
- 分布式系统中多个服务实例对同一数据的并发修改
- 长时间运行的CLI脚本中的资源竞争
我曾在一个物流系统中遇到过这样的案例:多个worker进程同时处理运单状态更新,由于没有加锁机制,导致最终状态出现混乱。后来通过引入Redis分布式锁,才彻底解决了这个问题。
2. 互斥锁的核心实现方案
2.1 文件锁(flock)方案
文件锁是最基础的单机解决方案,通过flock()函数实现。它的优势是零依赖,适合简单的单机应用场景。但要注意几个关键点:
php复制$fp = fopen("/tmp/lock.txt", "w+");
if (flock($fp, LOCK_EX)) { // 排他锁
// 临界区代码
flock($fp, LOCK_UN); // 释放锁
} else {
throw new RuntimeException("获取文件锁失败");
}
fclose($fp);
实际使用中我发现几个常见陷阱:
- 文件权限问题导致锁失效(确保/tmp目录可写)
- 未正确处理异常导致死锁(必须用try-finally确保锁释放)
- NFS等网络文件系统上的不可靠性(避免使用网络存储)
2.2 Redis分布式锁实现
对于分布式系统,Redis锁是最常用的方案。其核心是SETNX命令(SET if Not eXists):
php复制$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$lockKey = 'order:123:lock';
$token = uniqid(); // 唯一标识
// 获取锁(带超时)
if ($redis->set($lockKey, $token, ['NX', 'EX'=>30])) {
try {
// 业务逻辑
} finally {
// 确保只释放自己的锁
if ($redis->get($lockKey) == $token) {
$redis->del($lockKey);
}
}
}
在电商项目中,我们曾用这种方案处理订单创建,需要注意:
- 锁的过期时间要大于业务执行时间(但不宜过长)
- 必须使用唯一token防止误删其他请求的锁
- 考虑锁续期机制(对长时间任务)
2.3 数据库悲观锁方案
对于已有数据库的系统,可以直接利用数据库的行锁:
php复制// MySQL示例
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
"SELECT * FROM products WHERE id=? FOR UPDATE"
);
$stmt->execute([$productId]);
$product = $stmt->fetch();
// 更新库存等操作
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
这种方案的优点是无需额外组件,但要注意:
- 必须使用事务
- 高并发下可能导致大量连接等待
- 不同数据库语法有差异(如PostgreSQL使用FOR UPDATE NOWAIT)
3. 高级锁方案与性能优化
3.1 RedLock算法实现
当需要更高可靠性时,可以采用Redis作者提出的RedLock算法。它需要至少5个独立的Redis节点:
php复制$servers = [
['host'=>'redis1', 'port'=>6379],
['host'=>'redis2', 'port'=>6379],
// ...共5个节点
];
$quorum = (int)(count($servers)/2) + 1;
$locks = [];
try {
// 尝试在所有节点获取锁
foreach ($servers as $server) {
$redis = new Redis();
$redis->connect($server['host'], $server['port']);
if ($redis->set($lockKey, $token, ['NX','EX'=>$ttl])) {
$locks[] = $redis;
}
}
// 确认获得多数锁
if (count($locks) >= $quorum) {
// 执行业务逻辑
}
} finally {
// 释放所有锁
foreach ($locks as $redis) {
$redis->del($lockKey);
}
}
实际部署时要注意:
- 节点需要真正独立(不同物理机)
- 时钟同步问题(NTP配置)
- 网络延迟对算法的影响
3.2 Swoole协程锁
在使用Swoole时,可以利用协程特有的锁机制:
php复制$lock = new Swoole\Lock(SWOOLE_MUTEX);
Co\run(function() use ($lock) {
$lock->lock();
try {
// 协程安全代码
} finally {
$lock->unlock();
}
});
与普通锁的区别在于:
- 只在单个进程内有效
- 不会导致进程阻塞
- 性能开销极低
4. 实战:库存扣减系统设计
让我们设计一个完整的库存管理系统,包含以下功能:
- 库存查询(无需锁)
- 安全扣减(需要互斥)
- 库存回滚(取消订单时)
4.1 数据库设计
sql复制CREATE TABLE `inventory` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint NOT NULL,
`stock` int NOT NULL DEFAULT '0',
`locked_stock` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_product` (`product_id`)
) ENGINE=InnoDB;
4.2 带锁的扣减逻辑
php复制class InventoryService {
private Redis $redis;
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1');
}
public function deductStock($productId, $quantity) {
$lockKey = "inventory:{$productId}:lock";
$token = uniqid();
try {
// 获取分布式锁
if (!$this->redis->set($lockKey, $token, ['NX', 'EX'=>10])) {
throw new Exception('系统繁忙,请重试');
}
// 开启数据库事务
$pdo->beginTransaction();
// 使用SELECT FOR UPDATE获取行锁
$stmt = $pdo->prepare(
"SELECT * FROM inventory
WHERE product_id=? FOR UPDATE"
);
$stmt->execute([$productId]);
$inventory = $stmt->fetch();
if ($inventory['stock'] < $quantity) {
throw new Exception('库存不足');
}
// 扣减可用库存,增加锁定库存
$pdo->prepare(
"UPDATE inventory SET
stock=stock-?, locked_stock=locked_stock+?
WHERE product_id=?"
)->execute([$quantity, $quantity, $productId]);
$pdo->commit();
return true;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
} finally {
// 释放Redis锁
if ($this->redis->get($lockKey) == $token) {
$this->redis->del($lockKey);
}
}
}
}
4.3 性能优化技巧
在高并发场景下,我们采用了以下优化措施:
-
锁分段:将热点商品ID哈希到多个锁,减少竞争
php复制$segment = $productId % 16; // 分为16段 $lockKey = "inventory:{$segment}:lock"; -
本地缓存+异步持久化:
- 使用APCu缓存库存数据
- 变更先写内存,再异步刷到数据库
- 配合定时任务校对数据
-
乐观锁替代悲观锁:
sql复制UPDATE inventory SET stock=stock-? WHERE product_id=? AND stock>=?
5. 常见问题与解决方案
5.1 死锁预防
在项目中我们遇到过这样的死锁场景:
- 请求A获取锁1,尝试获取锁2
- 请求B获取锁2,尝试获取锁1
- 两者互相等待形成死锁
解决方案:
- 按固定顺序获取多个锁(如按锁名排序)
- 设置合理的锁超时时间
- 使用
tryLock而非阻塞式锁
5.2 锁续期机制
对于可能长时间执行的任务,需要实现锁续期:
php复制class LockRenewal {
private $redis;
private $renewalInterval;
private $shouldRenew = true;
public function startRenewal($lockKey, $token, $ttl) {
$this->shouldRenew = true;
Swoole\Timer::tick(($ttl/2)*1000, function()
use ($lockKey, $token, $ttl)
{
if (!$this->shouldRenew) return;
$this->redis->expire($lockKey, $ttl);
});
}
public function stopRenewal() {
$this->shouldRenew = false;
}
}
5.3 锁等待队列处理
当大量请求争抢同一个锁时,需要合理处理等待:
php复制// 实现简单的退避算法
$retries = 3;
$backoff = 100; // 毫秒
while ($retries-- > 0) {
if ($lock->tryLock()) {
try {
// 执行业务
break;
} finally {
$lock->unlock();
}
} else {
usleep($backoff * 1000);
$backoff = min($backoff * 2, 1000); // 最大1秒
}
}
6. 监控与调试
完善的锁系统需要监控:
-
锁等待时间(Prometheus指标)
php复制$start = microtime(true); if ($lock->lock()) { $waitTime = microtime(true) - $start; $metrics->observe('lock_wait_seconds', $waitTime); } -
死锁检测(通过超时日志)
-
Redis锁的TTL监控(防止永久锁)
调试技巧:
- 为每个锁请求添加唯一Trace ID
- 记录锁的获取/释放日志
- 使用
strace观察系统调用
7. 不同PHP版本的注意事项
随着PHP版本升级,锁相关特性有所变化:
PHP 7.4+:
- 改进的
pcntl扩展稳定性 - 更好的Swoole兼容性
PHP 8.0+:
- JIT对锁性能的提升
- 新增的
WeakMap可用于锁管理
PHP 8.2+:
- 新增
#[\SensitiveParameter]属性,适合保护锁密钥
特别提醒:在Docker环境中部署时,要注意文件锁在跨容器时的行为差异,建议使用Redis锁替代文件锁。
