问题
数据库乐观锁的过程中,完全没有加任何锁吗?
答案
核心结论
不是! 乐观锁并非完全无锁,在UPDATE执行时仍然会加锁,只是锁的持有时间非常短,且读取阶段不加锁。准确地说,乐观锁是”读不加锁,写时短暂加锁”。
乐观锁的真实加锁过程
1. 读取阶段:不加锁(MVCC)
-- 步骤1:读取数据(不加锁)
SELECT id, stock, version FROM products WHERE id = 1;
-- 结果: stock=100, version=5
-- 此时使用MVCC(多版本并发控制)读取一致性快照
-- 不会阻塞其他事务的读写操作
原理:
- InnoDB通过MVCC实现非锁定读
- 每个事务看到的是数据的快照版本
- 不会对索引记录加任何锁
2. 更新阶段:加排他锁(X锁)
-- 步骤2:更新数据(加排他锁)
BEGIN;
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- 条件匹配时加锁
-- 实际执行过程:
-- 1. 扫描索引定位到id=1的记录
-- 2. 对该记录加排他锁(X Lock)
-- 3. 检查version是否为5
-- 4. 如果匹配,执行更新;否则返回affected_rows=0
-- 5. COMMIT时释放锁
COMMIT;
详细加锁分析
场景1:版本号匹配(更新成功)
-- 事务A
BEGIN;
SELECT id, stock, version FROM products WHERE id = 1; -- version=5, 无锁
-- 事务B(并发执行)
BEGIN;
SELECT id, stock, version FROM products WHERE id = 1; -- version=5, 无锁(MVCC)
-- 事务A先执行UPDATE
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 1. 对id=1加排他锁
-- 2. 检查version=5 ✓
-- 3. 更新成功,version变为6
-- 4. 持有X锁直到COMMIT
COMMIT; -- 释放X锁
-- 事务B随后执行UPDATE
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 1. 尝试对id=1加排他锁(等待事务A释放)
-- 2. 事务A提交后获取到锁
-- 3. 检查version是否为5,发现已经是6 ✗
-- 4. WHERE条件不匹配,affected_rows=0
-- 5. 更新失败,释放锁
COMMIT;
场景2:通过EXPLAIN分析加锁
-- 查看执行计划
EXPLAIN SELECT id, stock, version FROM products WHERE id = 1;
-- type: const (主键查询,无锁)
EXPLAIN UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- type: const (主键查询,但UPDATE会加X锁)
场景3:查看实际锁信息
-- 事务A:执行更新但不提交
BEGIN;
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 另一个会话:查看锁信息
SELECT
ENGINE_TRANSACTION_ID,
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'products';
-- 输出示例:
-- LOCK_TYPE: RECORD
-- LOCK_MODE: X,REC_NOT_GAP (排他记录锁,不含间隙锁)
-- LOCK_DATA: 1 (主键id=1)
与悲观锁的加锁对比
-- 悲观锁:读写都加锁,锁持有时间长
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- 加X锁,持有直到COMMIT
-- 执行业务逻辑(可能耗时5秒)...
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 总锁持有时间:5秒+
-- 乐观锁:只有写时加锁,锁持有时间短
BEGIN;
SELECT * FROM products WHERE id = 1; -- 无锁
-- 执行业务逻辑(可能耗时5秒)...
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = ?; -- 加X锁,持有直到COMMIT
COMMIT; -- 锁持有时间:仅UPDATE执行时间(毫秒级)
乐观锁的锁特点
1. 锁的粒度
-- 主键查询:只锁单行
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 锁定:id=1的记录锁(Record Lock)
-- 非唯一索引查询:可能锁多行
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE category = 'electronics' AND version = 5;
-- 锁定:所有category='electronics'的记录 + 间隙锁(RR级别)
2. 锁的持有时间
/**
* 悲观锁 vs 乐观锁的锁持有时间对比
*/
public class LockDurationComparison {
// 悲观锁:锁持有时间 = 整个事务时间
@Transactional
public void pessimisticLock() {
long start = System.currentTimeMillis();
// 1. 加锁(持有直到事务结束)
Product product = selectForUpdate(1L); // 加锁点
// 2. 业务逻辑(持有锁期间,其他事务等待)
doBusinessLogic(); // 耗时5000ms
// 3. 更新(仍然持有锁)
updateProduct(product);
// 4. 提交,释放锁
long duration = System.currentTimeMillis() - start;
// 锁持有时间: ~5000ms
}
// 乐观锁:锁持有时间 = UPDATE执行时间
@Transactional
public void optimisticLock() {
long start = System.currentTimeMillis();
// 1. 无锁读取
Product product = selectById(1L); // 无锁
// 2. 业务逻辑(不持有锁,其他事务可并发执行)
doBusinessLogic(); // 耗时5000ms
// 3. 更新(加锁)
long lockStart = System.currentTimeMillis();
updateWithVersion(product); // 加锁点
long lockDuration = System.currentTimeMillis() - lockStart;
// 锁持有时间: ~10ms (仅UPDATE执行时间)
long duration = System.currentTimeMillis() - start;
// 总时间: ~5010ms,但锁持有仅10ms
}
}
为什么说乐观锁”不加锁”
这是一种概念上的说法,相对于悲观锁而言:
- 读取阶段不加锁:这是乐观锁的核心特点
- 写入阶段虽然加锁,但时间极短:通常在毫秒级
- 应用层逻辑无锁竞争:业务逻辑执行期间不持有数据库锁
-- 乐观锁的"无锁"是相对的
-- 读:无锁(MVCC)
SELECT * FROM products WHERE id = 1; -- ✓ 真的无锁
-- 写:有锁(X锁),但持有时间短
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- ✗ 实际加锁,但仅持有毫秒级
乐观锁的MVCC机制
-- 多个事务并发读取
-- 事务A (开始时间: T1)
BEGIN; -- 创建Read View
SELECT * FROM products WHERE id = 1; -- 读取version=5的快照
-- 事务B (开始时间: T2)
BEGIN;
UPDATE products SET stock = 99, version = 6 WHERE id = 1; -- 加X锁更新
COMMIT; -- 释放锁
-- 事务A (T3时刻)
SELECT * FROM products WHERE id = 1;
-- RR隔离级别:仍然读取version=5的快照(一致性读)
-- RC隔离级别:读取最新的version=6
实际性能测试
/**
* 性能测试:乐观锁 vs 悲观锁
*/
@SpringBootTest
public class LockPerformanceTest {
@Test
public void testLockPerformance() throws InterruptedException {
int threadCount = 100;
int iterations = 10;
// 测试悲观锁:TPS ~200
long pessimisticStart = System.currentTimeMillis();
testPessimisticLock(threadCount, iterations);
long pessimisticDuration = System.currentTimeMillis() - pessimisticStart;
System.out.println("悲观锁总耗时: " + pessimisticDuration + "ms");
// 测试乐观锁:TPS ~1500
long optimisticStart = System.currentTimeMillis();
testOptimisticLock(threadCount, iterations);
long optimisticDuration = System.currentTimeMillis() - optimisticStart;
System.out.println("乐观锁总耗时: " + optimisticDuration + "ms");
// 结论:乐观锁性能约为悲观锁的7-8倍(低冲突场景)
}
}
答题总结
数据库乐观锁并非完全无锁,准确地说:
- 读取阶段:通过MVCC实现非锁定读,完全不加锁
- 更新阶段:UPDATE执行时会加排他锁(X Lock),但持有时间极短(毫秒级)
- 对比悲观锁:
- 悲观锁:读写全程持锁,锁持有时间长
- 乐观锁:只在UPDATE时短暂持锁,并发性能高
面试要点:
- 能说清楚乐观锁在UPDATE时仍然会加排他锁
- 理解”乐观锁不加锁”是相对概念,指的是读不加锁
- 知道锁的持有时间短是乐观锁高并发性能的关键
- 了解MVCC在乐观锁中的作用
关键误区澄清:
- ✗ 错误:乐观锁完全不用数据库锁
- ✓ 正确:乐观锁在写操作时仍然加锁,但持有时间极短,读操作通过MVCC实现无锁
这个细节考察对数据库锁机制和MVCC的深入理解,是高级面试中的常见追问点。