Appearance
乐观锁与悲观锁及 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 N | MySQL 8.0+ 支持 FOR UPDATE NOWAIT 或 WAIT 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) | ❌ 不阻塞 | 删除已在锁范围内的记录不涉及插入 |
怎么避免
- 用主键 ID 做 for update,不要用普通索引做范围锁定
- 业务允许时降级为 READ COMMITTED(禁用 Gap Lock)
- 高并发场景用乐观锁替代悲观锁
六、对比总结
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心机制 | 先锁后操作 | 提交时检查冲突 |
| 适用场景 | 写多读少、冲突频繁 | 读多写少、冲突很少 |
| 实现代表 | synchronized, ReentrantLock, select ... for update | AtomicInteger, version, CAS |
| 并发度 | 低(串行化执行) | 高(无阻塞并发读) |
| 代价 | 锁竞争 → 上下文切换 | CAS 自旋 → CPU 空转 |
| 死锁风险 | 有(需注意锁顺序) | 无 |
| 重试机制 | 不需要(阻塞等待) | 需要(自旋或重试) |
| 数据库实现 | for update(行锁 + 间隙锁) | version 乐观锁(Where 条件校验) |
选型经验
- 数据库更新:高并发写(秒杀扣库存)→ 乐观锁+版本号;事务性强(转账)→
for update - 内存变量:单变量 →
AtomicLong/LongAdder;复合操作 →synchronized/ReentrantLock - JPA/Hibernate:
@Version注解自动管理乐观锁