本文目录导读:

在 PHP 中(尤其是在处理高并发、秒杀、下单等场景时),选择悲观锁还是乐观锁,核心取决于业务场景的冲突频率、并发量以及对数据一致性的要求。
没有绝对最好的锁,只有最适合当前业务的锁。
以下是详细的选型指南和实战代码示例:
核心判断标准(怎么选?)
| 维度 | 悲观锁 | 乐观锁 | 怎么选) |
|---|---|---|---|
| 冲突频率 | 适合冲突多(写多读少)的场景 | 适合冲突少(读多写少)的场景 | 冲突多选悲观,冲突少选乐观 |
| 并发量 | 并发量较低(依赖数据库连接) | 并发量较高(无锁,轻量级) | 大流量秒杀优先乐观 |
| 响应速度 | 较慢(需要等待获取锁,可能阻塞) | 较快(无需等待,直接CAS) | 对延迟敏感选乐观 |
| 锁粒度 | 锁住整个行/表 | 锁住一个版本号字段 | 粒度越细越利于并发 |
| 死锁风险 | 有(必须统一加锁顺序) | 无(靠重试解决) | 避免复杂事务选乐观 |
| 实现复杂度 | 简单(SELECT FOR UPDATE) |
中等(需要加版本字段或状态字段) | 团队技术水平一般选悲观 |
一句话总结:
如果你不确定,先从“乐观锁”开始(因为成本低、无死锁),只有当系统出现严重的“同一条数据频繁被多人同时修改,导致重试率过高”时,再切换为悲观锁。
悲观锁(数据库层 FOR UPDATE)
原理:在事务中,使用 SELECT ... FOR UPDATE 将数据行锁定,其他事务必须等待该事务提交或回滚后才能操作。
适用场景:银行转账、库存扣减(但库存极低)、后台管理员修改关键配置。
实战代码(ThinkPHP/Laravel 框架风格):
use Illuminate\Support\Facades\DB;
public function deductStockWithPessimisticLock($productId, $quantity)
{
DB::beginTransaction();
try {
// 1. 锁定该行数据(关键:必须开启事务)
$product = DB::table('products')
->where('id', $productId)
->lockForUpdate() // 悲观锁
->first();
if (!$product || $product->stock < $quantity) {
DB::rollBack();
throw new \Exception('库存不足');
}
// 2. 模拟耗时操作(比如扣款接口调用)
sleep(1);
// 3. 扣减库存
DB::table('products')
->where('id', $productId)
->decrement('stock', $quantity);
DB::commit();
return true;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
致命缺点:
- 极度依赖数据库连接:高并发下,1000个请求只有100个在排队,其余900个都在等待数据库连接池耗尽报错。
- 死锁风险:如果两个事务加锁顺序不一致(A锁行1,B锁行2,A又等行2,B又等行1),会死锁。
乐观锁(版本号/状态字段)
原理:不锁数据库,而是记录一个版本号(version字段),更新时检查版本号是否一致,一致则更新并version+1,不一致则更新失败返回0行,然后重试。
适用场景:秒杀抢购(并发极高但只有少数人能抢到)、文章点赞、Feeds流热度更新。
实战代码(原生 SQL 模拟 CAS):
use Illuminate\Support\Facades\DB;
public function deductStockWithOptimisticLock($productId, $quantity)
{
$maxRetries = 3;
$attempt = 0;
while ($attempt < $maxRetries) {
// 1. 读取当前版本号(不需要锁)
$product = DB::table('products')->where('id', $productId)->first();
if (!$product || $product->stock < $quantity) {
return false; // 库存不足,无需重试
}
// 2. 尝试更新(核心:WHERE 条件加上 version)
$affected = DB::table('products')
->where('id', $productId)
->where('version', $product->version) // 关键:乐观锁条件
->update([
'stock' => $product->stock - $quantity,
'version' => $product->version + 1,
]);
// 3. 如果影响行数为1,说明更新成功
if ($affected) {
return true;
}
// 4. 如果影响行数为0,说明version被改了,重试
$attempt++;
usleep(100000); // 延迟100ms避免空转风暴
}
throw new \Exception('系统繁忙,请稍后重试');
}
性能陷阱:
- 如果冲突率太高,重试会浪费CPU,必须设置最大重试次数(通常3次)。
- 如果业务逻辑在更新后还要做其他操作(如写订单表),乐观锁必须配合事务,但锁粒度是基于版本的,不是基于行锁,所以死锁概率极低。
进阶建议(大厂生产环境策略)
混合使用(推荐)
- 入口用乐观锁:秒杀时,先用Redis或数据库乐观锁快速拦截绝大多数无效请求(库存=0直接返回)。
- 出口用悲观锁:只有真正抢到了“名额”的少数请求,进入支付/扣款阶段,对预扣库存行加悲观锁确保数据绝对一致。
Redis Lua 脚本(首选方案)
在超高并发场景下,数据库锁(无论悲观乐观)都扛不住,通常直接改用 Redis 原子操作:
-- KEYS[1] = 库存key
-- ARGV[1] = 扣减数量
local stock = tonumber(redis.call('get', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return -1
end
redis.call('decrby', KEYS[1], ARGV[1])
return 0
PHP 调用:
$result = Redis::eval($luaScript, 1, 'product_stock_'.$productId, 1);
if ($result === 0) {
// 扣减成功,然后异步用消息队列去更新数据库(最终一致)
}
如何选择加锁的字段?
- 悲观锁:不需要额外字段。
- 乐观锁:除了
version,还可以用库存量本身作为版本(WHERE stock = $old_stock AND stock >= $quantity)。
决策表)
| 场景实例 | 推荐方案 | 理由补充 |
|---|---|---|
| 后台管理员修改商品价格(100并发) | 悲观锁 | 冲突极高,重试成本大于等待成本 |
| 用户评论点赞(1万并发,同一个视频) | 乐观锁(Redis Incr) | 写入冲突极低,且要求毫秒级响应 |
| 电商秒杀(单SKU库存10件,1万并发) | Redis Lua + 数据库乐观锁兜底 | 数据库锁会直接打爆连接池 |
| 系统内部订单状态流转(如待支付->已支付) | 乐观锁(WHERE status = '待支付') |
防止ABA问题,状态本身就是锁 |
| 分布式任务调度(多台机器抢同一批任务) | 悲观锁(SELECT FOR UPDATE SKIP LOCKED) |
数据库提供的跳过锁机制,避免队列挤压 |
最后提醒:锁只是手段,一定要给读取数据的接口加缓存(如Redis),或者用消息队列削峰,否则无论用哪种锁,系统最终都会因为数据库IO瓶颈而崩溃。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播