本文目录导读:

- 目录导读
- 什么是悲观锁?为什么PHP项目需要它?
- 悲观锁 vs 乐观锁:核心区别与适用场景
- PHP中实现悲观锁的3种主流方案
- 实战:基于MySQL行锁的悲观锁实现代码
- 基于Redis分布式悲观锁的实现
- 常见陷阱与性能优化建议
- FAQ:开发者最常问的5个问题
PHP项目如何实现悲观锁?从原理到实战的完整指南
目录导读
- 什么是悲观锁?为什么PHP项目需要它?
- 悲观锁 vs 乐观锁:核心区别与适用场景
- PHP中实现悲观锁的3种主流方案
- 实战:基于MySQL行锁的悲观锁实现代码
- 基于Redis分布式悲观锁的实现
- 常见陷阱与性能优化建议
- FAQ:开发者最常问的5个问题
什么是悲观锁?为什么PHP项目需要它?
悲观锁(Pessimistic Lock)是一种“先锁再说”的并发控制策略,假设数据被修改的可能性很高,因此在整个操作过程中,先对数据加锁,直到操作完成才释放锁,其他请求在此期间只能等待。
在PHP项目中,当多个用户同时操作同一资源(如商品库存、账户余额)时,如果不加锁,就会出现“超卖”、“死锁”等严重问题,悲观锁能确保数据的一致性,尤其在写多读少、高冲突的业务场景下(例如秒杀系统、银行转账),它是第一道防线。
问答1:问:悲观锁是不是会让性能变差?
答:是的,但这是为了数据一致性付出的代价,在并发量不超过系统瓶颈、冲突频率高的情况下,悲观锁反而比乐观锁更稳定。
悲观锁 vs 乐观锁:核心区别与适用场景
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 原理 | 操作前加锁,其他请求排队 | 操作时不加锁,更新时检查版本号 |
| 适用场景 | 写操作频繁、冲突概率高 | 读多写少、冲突概率低 |
| 性能 | 阻塞等待,吞吐量下降 | 无阻塞,但失败重试开销大 |
如果你的PHP项目处理的是热点数据(比如热门商品库存),悲观锁是更安全的选择,而像用户个人资料编辑这类低频操作,乐观锁更高效。
PHP中实现悲观锁的3种主流方案
在PHP中实现悲观锁,主要有以下3种常见方法:
- 基于数据库(MySQL/PostgreSQL)的行级锁:利用
SELECT ... FOR UPDATE或LOCK TABLES。 - 基于Redis的分布式锁:通过
SETNX、RedLock算法实现,适合多实例PHP应用。 - 基于进程间锁(文件锁/共享内存):使用PHP的
flock()函数,适合单机脚本。
本文重点讲解方案1和方案2,因为它们最贴近现代Web开发场景。
实战:基于MySQL行锁的悲观锁实现代码
假设我们有一个inventory表,字段包括product_id和stock,要扣减库存,必须确保同一时刻只有一个请求能修改记录。
// 1. 开启事务
$pdo->beginTransaction();
try {
// 2. 获取悲观锁(行级锁)
$sql = "SELECT stock FROM inventory WHERE product_id = :pid FOR UPDATE";
$stmt = $pdo->prepare($sql);
$stmt->execute([':pid' => $productId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row['stock'] <= 0) {
throw new \Exception('库存不足');
}
// 3. 更新库存
$updateSql = "UPDATE inventory SET stock = stock - 1 WHERE product_id = :pid";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute([':pid' => $productId]);
// 4. 提交事务(锁自动释放)
$pdo->commit();
return true;
} catch (\Exception $e) {
// 5. 回滚事务
$pdo->rollBack();
// 记录日志或返回错误
throw $e;
}
关键点:
- 必须使用
FOR UPDATE,否则SELECT不会加锁。 - 事务必须提交或回滚,锁才会释放。
- 锁的范围由WHERE条件决定;如果
product_id不是索引,可能会导致全表锁。
问答2:问:如果事务长时间不提交,会不会锁死?
答:会,MySQL有innodb_lock_wait_timeout配置(默认50秒),超时后自动回滚并释放锁,建议在应用层设置合理的超时机制。
基于Redis分布式悲观锁的实现
当你的PHP项目部署在多台服务器上,MySQL行锁就失效了(因为每台机器连接的是不同的数据库连接池),这时需要借助Redis实现分布式锁。
推荐使用RedLock算法,但为简化说明,这里展示基于单Redis实例的基础版(适用于大多数场景):
// 生成唯一锁标识(防误删)
$lockKey = 'lock:inventory:' . $productId;
$lockValue = uniqid('', true);
$ttl = 3000; // 毫秒,锁的过期时间
// 1. 尝试获取锁
$result = $redis->set($lockKey, $lockValue, ['NX', 'PX' => $ttl]);
if ($result) {
try {
// 2. 执行业务逻辑(扣库存等)
// ...
// 3. 释放锁:使用Lua脚本保证原子性
$lua = <<<SCRIPT
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
SCRIPT;
$redis->eval($lua, [$lockKey, $lockValue]);
} catch (\Exception $e) {
// 异常时也要释放锁
$redis->eval($lua, [$lockKey, $lockValue]);
throw $e;
}
} else {
// 获取锁失败,等待重试或返回失败
usleep(100); // 100微秒后重试
// 或者直接抛出异常
}
注意事项:
- 设置锁的过期时间(
ttl)必须 > 业务执行时间,否则锁会提前释放,造成并发问题。 - 释放锁时一定要比较value,防止误删其他线程的锁(例如业务执行时间过长导致锁过期,新线程获取锁后,旧线程释放了新的锁)。
常见陷阱与性能优化建议
陷阱1:死锁
当两个事务相互等待对方的锁时,会发生死锁,解决方案:
- 所有事务按相同顺序访问资源。
- 设置合理的
innodb_deadlock_detect,及时回滚其中一个事务。
陷阱2:粒度过粗
如果使用LOCK TABLES锁定整个表,并发能力会急剧下降,尽量使用行级锁(FOR UPDATE)。
优化建议
- 减少锁持有时间:在锁内只做必要的操作,复杂的计算移到锁外。
- 使用读写分离:对读多写少的场景,读操作不加锁,写操作用悲观锁。
- 数据库连接池:悲观锁会占用数据库连接,连接池能减少连接开销。
问答3:问:悲观锁和事务一定是绑定关系吗?
答:是的,MySQL的行锁依赖于事务,Redis的分布式锁虽然不需要事务,但为了防止业务异常导致锁不释放,也需要结合try-catch-finally。
FAQ:开发者最常问的5个问题
Q1:使用悲观锁后,PHP脚本超时怎么办?
A:设置PHP脚本最大执行时间大于锁等待时间(例如set_time_limit(60)),同时捕获异常,确保锁最终被释放。
Q2:Redis锁过期前业务没执行完怎么办?
A:可以采用“续期机制”——后台线程(或PHP的register_shutdown_function)每隔1/3的ttl时间检查并续期锁,更成熟的方案是使用Redisson或go-zero等框架。
Q3:MySQL的SELECT ... FOR UPDATE对非索引字段会全表锁?
A:是的,如果WHERE条件使用的列不是索引(或索引失效),InnoDB会退化为表级锁,务必确保相关字段有索引。
Q4:高并发下,Redis分布式锁可能导致大量失败重试?
A:可以引入“自旋等待”,配合指数退避算法(如第一次等待10ms,第二次20ms),避免大脑的“惊群效应”。
Q5:悲观锁和数据库乐观锁(版本号)可以混用吗?
A:可以,先使用悲观锁获取当前版本号,然后使用乐观锁更新(检查版本号),这被称为“混合锁”,在特定场景下能平衡性能与一致性。
要在PHP项目中正确实现悲观锁,核心在于选择合适的锁方案:单机用MySQL行锁,分布式用Redis锁,记住两个原则:锁定后尽快释放、设置合理的超时时间,在实际开发中,建议先评估业务冲突频率,再决定是否必须使用悲观锁——许多场景下,结合队列、消息中间件(如RabbitMQ)也是可行的替代方案。
锁不是银弹,但它是数据一致性的基石。