Skip to content

乐观锁与悲观锁及 MySQL 行锁机制详解

2026-05-30

一、悲观锁(Pessimistic Locking)

核心理念:认为并发冲突一定会发生,先加锁再操作。

synchronized 关键字

java
// 实例方法锁(锁的是 this)
public synchronized void deductBalance(BigDecimal amount) {
    this.balance = this.balance.subtract(amount);
}

// 同步代码块(细粒度控制)
public void deductBalance(BigDecimal amount) {
    synchronized (this) {
        this.balance = this.balance.subtract(amount);
    }
}

ReentrantLock(显式锁)

java
private final ReentrantLock lock = new ReentrantLock();

public void deductBalance(BigDecimal amount) {
    lock.lock();
    try {
        this.balance = this.balance.subtract(amount);
    } finally {
        lock.unlock();  // 务必在 finally 中释放
    }
}

// 尝试非阻塞获取锁
public boolean tryDeduct(BigDecimal amount, long timeout, TimeUnit unit)
        throws InterruptedException {
    if (lock.tryLock(timeout, unit)) {
        try {
            this.balance = this.balance.subtract(amount);
            return true;
        } finally {
            lock.unlock();
        }
    }
    return false;
}

数据库 select ... for update(MySQL InnoDB)

sql
-- 事务内使用,锁住该行,其他事务无法修改
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 业务计算 ...
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- 自动释放行锁

二、乐观锁(Optimistic Locking)

核心理念:认为冲突很少发生,操作时不加锁,提交时检查冲突。

方式一:版本号字段(推荐)

数据库表设计

sql
CREATE TABLE account (
    id        BIGINT PRIMARY KEY,
    balance   DECIMAL(18,2) NOT NULL,
    version   INT NOT NULL DEFAULT 0   -- 版本号
);

MyBatis 实现

xml
<update id="updateBalanceAndVersion">
    UPDATE account
    SET balance = #{newBalance},
        version = #{newVersion}        <!-- 新版本 = 旧版本 + 1 -->
    WHERE id = #{id}
      AND version = #{oldVersion}      <!-- 核心:条件带旧版本 -->
</update>

Java 代码

java
// 1. 读取数据(带版本号)
Account account = accountMapper.selectById(1L);
int version = account.getVersion();
BigDecimal newBalance = account.getBalance().subtract(amount);

// 2. 更新时校验版本号
int rows = accountMapper.updateBalanceAndVersion(1L, newBalance, version, version + 1);
if (rows == 0) {
    throw new OptimisticLockException("数据已被修改,请重试");
}

方式二:CAS(原子类,无锁并发)

java
private final AtomicLong stockCount = new AtomicLong(100);

public boolean deductStock(long quantity) {
    while (true) {
        long current = stockCount.get();
        long newValue = current - quantity;
        if (newValue < 0) return false;
        if (stockCount.compareAndSet(current, newValue)) {
            return true;   // CAS 成功
        }
        // CAS 失败 → 自旋重试
    }
}

ABA 问题:值从 A→B→A,CAS 认为没变。 解决AtomicStampedReference 带版本戳。

java
private final AtomicStampedReference<BigDecimal> balanceRef =
    new AtomicStampedReference<>(initialBalance, 0);

public boolean deduct(BigDecimal amount) {
    int[] stampHolder = new int[1];
    while (true) {
        BigDecimal current = balanceRef.get(stampHolder);
        int stamp = stampHolder[0];
        BigDecimal newVal = current.subtract(amount);
        if (balanceRef.compareAndSet(current, newVal, stamp, stamp + 1)) {
            return true;
        }
    }
}

三、select ... for update + MyBatis 完整实战

Mapper 层

DAO 接口

java
public interface AccountMapper {
    // 查询并锁定指定账户
    Account selectForUpdate(@Param("id") Long id);
    int updateBalance(@Param("id") Long id, @Param("newBalance") BigDecimal newBalance);
}

XML 实现

xml
<select id="selectForUpdate" resultType="com.example.entity.Account">
    SELECT id, user_id, balance, version
    FROM account
    WHERE id = #{id}
    FOR UPDATE
</select>

<update id="updateBalance">
    UPDATE account SET balance = #{newBalance} WHERE id = #{id}
</update>

Service 层

java
@Service
public class AccountService {

    @Transactional(rollbackFor = Exception.class)
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 1. 加锁读取 from 账户
        Account from = accountMapper.selectForUpdate(fromId);
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientBalanceException("余额不足");
        }

        // 2. 加锁读取 to 账户(防止死锁:按固定顺序加锁)
        Long firstLock = Math.min(fromId, toId);
        Long secondLock = Math.max(fromId, toId);
        // 但这里我们是两个独立的 selectForUpdate
        // 更安全的做法是在调用方统一排序
        Account from = accountMapper.selectForUpdate(fromId);
        Account to = accountMapper.selectForUpdate(toId);

        // 3. 执行更新
        accountMapper.updateBalance(fromId, from.getBalance().subtract(amount));
        accountMapper.updateBalance(toId, to.getBalance().add(amount));
        // 方法退出 → 事务提交 → 行锁释放
    }
}

实战:扣库存(秒杀)

java
@Service
public class StockService {

    @Transactional(rollbackFor = Exception.class)
    public boolean deductStock(Long productId, int quantity) {
        ProductStock stock = stockMapper.selectStockForUpdate(productId);
        if (stock == null || stock.getStock() < quantity) {
            return false;
        }
        // SQL 层还有 stock >= quantity 兜底(双重保障)
        int rows = stockMapper.deductStock(productId, quantity);
        return rows > 0;
    }
}
xml
<update id="deductStock">
    UPDATE product_stock
    SET stock = stock - #{quantity}
    WHERE product_id = #{productId}
      AND stock >= #{quantity}   <!-- 前置条件防止超卖 -->
</update>

注意事项

注意点说明
必须走索引否则行锁退化为表锁。用 EXPLAIN 确认
事务要短锁内不要做 RPC 调用、长时间计算
死锁预防多个锁按固定顺序获取(如按 id 排序)
超时兜底innodb_lock_wait_timeout 默认 50s,业务可调整
NOWAIT / WAIT NMySQL 8.0+ 支持 FOR UPDATE NOWAITWAIT 3

四、普通索引 + for update — 间隙锁(Next-Key Lock)

三种索引加锁情况对比

索引类型SQL锁范围
主键/唯一索引(精准匹配单条)WHERE id = 5 FOR UPDATE只锁该行
普通索引(非唯一,如 status)WHERE status = '2' FOR UPDATE锁匹配行 + 前后间隙(Next-Key Lock)
无索引WHERE status = '2' FOR UPDATE全表锁(逐行加锁)

Next-Key Lock 的组成

  • Record Lock(行锁):锁定匹配的索引记录
  • Gap Lock(间隙锁):锁定匹配值前后的开区间间隙
  • Next-Key Lock(临键锁)= Record Lock + Gap Lock

具体例子

假设 status 索引有这些记录(按 status, id 排序):

(1,1)  (1,3) | (2,2)  (2,5)  (2,7)  (2,8)  (2,10) | (3,4)  (3,6)  (3,9)

执行 SELECT * FROM order WHERE status = '2' FOR UPDATE

锁类型锁定范围
Record Lock(2,2) (2,5) (2,7) (2,8) (2,10) 这 5 个索引行
Gap Lock(1, 2) 间隙 — status=1 和 status=2 之间的空隙
Gap Lock(2, 3) 间隙 — status=2 和 status=3 之间的空隙

实际影响

  • 其他事务可以读/写 status='1' 和 status='3' 的记录
  • 其他事务不能 INSERT 新的 status='2' 记录(间隙锁阻止插入)
  • 其他事务不能 UPDATE 现有的 status='2' 记录(行锁阻止修改)

五、UPDATE 改索引列时的阻塞情况

核心原理

UPDATE 修改索引列时,InnoDB 执行:删除旧索引条目 + 插入新索引条目

是否被间隙锁阻塞?

假设事务 A 持有 status='2' 的 Next-Key Lock:

操作是否阻塞原因
UPDATE status=5→2 where id=11✅ 阻塞需要插入新索引 (2,11) 到被锁间隙
UPDATE status=2→3 where id=5✅ 阻塞需要从 locked 区间删除 (2,5),再插入 (3,5)
UPDATE status=2→2 where id=5❌ 不阻塞status 值不变,索引记录不动
UPDATE balance=100 where id=11❌ 不阻塞不修改索引列
INSERT status=2✅ 阻塞新索引条目插入被锁间隙
DELETE where id=11(status=5)❌ 不阻塞只删除旧记录,不插入
DELETE where id=2(status=2)❌ 不阻塞删除已在锁范围内的记录不涉及插入

怎么避免

  1. 用主键 ID 做 for update,不要用普通索引做范围锁定
  2. 业务允许时降级为 READ COMMITTED(禁用 Gap Lock)
  3. 高并发场景用乐观锁替代悲观锁

六、对比总结

维度悲观锁乐观锁
核心机制先锁后操作提交时检查冲突
适用场景写多读少、冲突频繁读多写少、冲突很少
实现代表synchronized, ReentrantLock, select ... for updateAtomicInteger, version, CAS
并发度低(串行化执行)高(无阻塞并发读)
代价锁竞争 → 上下文切换CAS 自旋 → CPU 空转
死锁风险有(需注意锁顺序)
重试机制不需要(阻塞等待)需要(自旋或重试)
数据库实现for update(行锁 + 间隙锁)version 乐观锁(Where 条件校验)

选型经验

  • 数据库更新:高并发写(秒杀扣库存)→ 乐观锁+版本号;事务性强(转账)→ for update
  • 内存变量:单变量 → AtomicLong/LongAdder;复合操作 → synchronized/ReentrantLock
  • JPA/Hibernate@Version 注解自动管理乐观锁