大数跨境

mysql乐观锁与悲观锁:并发数据实战指南

mysql乐观锁与悲观锁:并发数据实战指南 wordpress知识
2026-09-11
8

后端开发中,数据库并发问题是无法回避的挑战。无论是秒杀扣库存、账户余额变动,还是订单状态更新,只要多用户同时操作同一数据,稍有不慎便会引发数据覆盖、超卖或金额错乱。

解决此类问题的核心方案,在于合理运用 MySQL 的两大锁机制:悲观锁与乐观锁。

为什么需要锁?

以一个经典的并发 Bug 为例:用户 A 和用户 B 同时查询某商品库存,均显示剩余 1 件。若两个请求同时执行“库存减 1"的逻辑,最终结果将是库存变为 0,却卖出了 2 单,导致严重超卖。

其根本原因在于:查询与更新是两条独立的 SQL 语句,中间存在时间窗口。若无冲突校验机制,多事务可并行修改数据,从而产生脏写。悲观锁与乐观锁正是为了杜绝此类并发问题、保障数据一致性而诞生的。

悲观锁:先上锁,再操作

悲观锁假设并发冲突必然发生。因此在操作数据前必须先加锁,强制其他事务排队等待,直至锁释放后方可继续操作。通俗类比如同公共卫生间:进门即反锁,他人只能在外等候。

两种实现方式

1. 排他锁(写锁)FOR UPDATE

这是业务写操作中最常用的方式。在事务内对查询结果加锁后,其他事务无法对该数据进行修改或删除。

begin;
-- 对 id=1 的数据加行排他锁
select * from user where id = 1 for update;
-- 执行数据更新
update user set balance = balance - 100 where id = 1;
commit; -- 事务提交,锁释放

锁规则:

  • 其他事务执行 UPDATE、DELETE 或 FOR UPDATE 时会被阻塞等待。
  • 普通 SELECT 采用快照读(MVCC 机制),不受锁影响。

2. 共享锁(读锁)LOCK IN SHARE MODE

适用于多读场景。多个事务可同时持有读锁,但只要存在读锁,任何事务都无法修改该数据。

begin;
select * from user where id = 1 lock in share mode;
commit;

悲观锁的致命陷阱

1. 无索引导致锁升级
使用 FOR UPDATE 时必须命中索引。若未命中,MySQL 无法定位具体行,将直接升级为表锁,彻底拖垮并发性能。

2. 长事务引发高阻塞
锁在事务开启时生效,仅在提交或回滚时释放。若事务中包含耗时逻辑,会导致大量请求阻塞甚至超时。

3. 死锁风险
在多资源竞争场景下,若加锁顺序不一致,极易引发死锁。

乐观锁:不上锁,提交时校验

乐观锁假设并发冲突极少发生,因此数据库层面不加任何锁,读写完全并行。仅在数据更新时校验数据是否被他人修改过。该机制无阻塞、无等待,性能极高,完全由业务代码实现(MySQL 无原生乐观锁语法)。

标准实现:版本号机制

在数据表中新增版本字段(如 version INT DEFAULT 0)。执行逻辑如下:

  1. 查询数据,获取当前版本号;
  2. 更新数据时,携带旧版本号进行原子校验;
  3. 若版本号匹配则更新成功并将版本号 +1;若不匹配则更新失败,表明数据已被修改。

正确的 SQL 写法(原子执行):

update user 
set balance = balance - 100, version = version + 1 
where id = 1 and version = 旧版本号;

判断规则:根据 SQL 受影响行数(affected_rows)判定:

  • 返回 1:更新成功,无冲突;
  • 返回 0:数据已被修改,发生并发冲突,需重试。

绝对错误的写法:

-- 错误!两条 SQL 存在时间窗口
select version from user where id=1;
update user set balance=balance-100 where id=1;

将查询与更新拆分为两条独立 SQL,中间可能被其他线程插入修改,导致数据覆盖,使乐观锁彻底失效。核心原则是:校验与更新必须在一条 SQL 中原子完成。

在高并发场景下,单次更新失败直接返回会影响用户体验。生产环境的最佳实践是搭配有限次数的自动重试机制。

<?php
use app\model\User;
/**
 * 乐观锁扣余额 + 自动重试
 * @param int $userId 用户 ID
 * @param int $amount 扣减金额
 * @param int $maxRetry 最大重试次数
 * @return array
 */
function deductBalance(int $userId, int $amount, int $maxRetry = 3): array
{
    // 有限次数重试,避免死循环
    for ($i = 0; $i < $maxRetry; $i++) {
        $user = User::where('id', $userId)->find();
        if (!$user) return [0, '用户不存在'];
        if ($user['balance'] < $amount) return [0, '余额不足'];
        
        // 原子更新:版本号校验 + 数据修改
        $affectRows = User::where('id', $userId)
            ->where('version', $user['version'])
            ->update([
                'balance' => $user['balance'] - $amount,
                'version' => $user['version'] + 1
            ]);
        
        // 更新成功直接返回
        if ($affectRows > 0) {
            return [1, '扣减成功'];
        }
        
        // 冲突休眠,降低 CPU 消耗
        usleep(10000);
    }
    
    // 重试耗尽,返回繁忙
    return [0, '操作繁忙,请稍后再试'];
}
?>

悲观锁 VS 乐观锁:核心对比

对比维度 悲观锁 乐观锁
实现方式 数据库行锁(FOR UPDATE) 业务版本号校验
并发假设 默认一定会冲突 默认很少冲突
阻塞情况 会阻塞,存在锁等待 无阻塞,冲突则重试
性能表现 写多场景性能差 读多写少性能极佳
死锁风险
适用场景 高写冲突、资金交易 读多写少、普通更新

生产环境最终选型方案

1. 首选乐观锁
适用于绝大多数业务场景,如用户资料修改、商品数据更新、普通订单操作等(读多写少)。其优势在于高性能、无阻塞、无死锁且运维成本低。

2. 选用悲观锁
适用于核心资金流转、高并发秒杀、库存扣减等写冲突极高的场景。使用时必须确保索引生效、事务极短,坚决杜绝长事务。

【声明】内容源于网络
0
0
wordpress知识
各类跨境出海行业相关资讯
内容 347
粉丝 0
wordpress知识 各类跨境出海行业相关资讯
总阅读9.4k
粉丝0
内容347