MySQL锁机制详解
文章目录
- 一、锁的基本概念
- 1.1 什么是锁
- 1.2 锁的必要性
- 二、锁的分类
- 2.1 按锁粒度分类
- 锁粒度对比
- 2.2 全局锁(Global Lock)
- 加锁命令
- 使用场景
- 2.3 表级锁
- 类型1:表锁(Table Lock)
- 类型2:元数据锁(MDL - Meta Data Lock)
- 类型3:意向锁(Intention Lock)
- 2.4 按锁模式分类
- 共享锁(S Lock / 读锁)
- 排他锁(X Lock / 写锁)
- 三、InnoDB行锁详解
- 3.1 行锁的类型
- 3.2 记录锁(Record Lock)
- 3.3 间隙锁(Gap Lock)
- 3.4 临键锁(Next-Key Lock)
- 3.5 行锁的加锁规则
- 规则1:基本加锁原则
- 规则2:索引失效时的加锁
- 3.6 实战示例:理解加锁范围
- 四、死锁问题
- 4.1 什么是死锁
- 4.2 死锁演示
- 4.3 死锁检测
- 4.4 死锁的产生条件
- 4.5 避免死锁的方法
- 方法1:按固定顺序访问资源
- 方法2:一次性获取所有锁
- 方法3:设置锁超时时间
- 方法4:使用乐观锁
- 方法5:降低事务隔离级别
- 方法6:缩小事务范围
- 五、锁等待和超时
- 5.1 查看锁等待
- 5.2 查看锁信息
- 5.3 杀掉阻塞进程
- 六、乐观锁与悲观锁
- 6.1 悲观锁(Pessimistic Lock)
- 6.2 乐观锁(Optimistic Lock)
- 6.3 乐观锁 vs 悲观锁
- 七、实战案例
- 7.1 案例1:电商秒杀
- 7.2 案例2:分布式锁
- 八、锁优化建议
- 8.1 减少锁冲突
- 8.2 选择合适的隔离级别
- 8.3 使用合适的索引
- 8.4 监控锁等待
- 总结
- 核心要点
一、锁的基本概念
1.1 什么是锁
锁(Lock) 是数据库用来管理并发访问的机制,防止多个事务同时修改同一数据导致数据不一致。
核心作用:
- 保证数据一致性
- 实现事务隔离
- 控制并发访问
1.2 锁的必要性
没有锁的问题:
时间线 事务A 事务B
T1 读取:库存=10
T2 读取:库存=10
T3 扣减:库存=9
T4 扣减:库存=9
T5 写入:库存=9
T6 写入:库存=9结果:两个订单,但库存只扣减了1(超卖!)
有锁的保护:
时间线 事务A 事务B
T1 加锁 + 读取:库存=10
T2 等待锁...
T3 扣减:库存=9
T4 等待锁...
T5 写入 + 释放锁
T6 加锁 + 读取:库存=9
T7 扣减:库存=8
T8 写入 + 释放锁结果:正确扣减库存
二、锁的分类
2.1 按锁粒度分类
全局锁(Global Lock)↓
表级锁(Table Lock)↓
页级锁(Page Lock)- BDB引擎↓
行级锁(Row Lock)- InnoDB
锁粒度对比
锁类型 | 粒度 | 开销 | 并发度 | 死锁 | 使用引擎 |
---|---|---|---|---|---|
全局锁 | 整个数据库 | 最小 | 最低 | 无 | 所有 |
表锁 | 整张表 | 小 | 低 | 无 | MyISAM、InnoDB |
行锁 | 单行记录 | 大 | 高 | 可能 | InnoDB |
2.2 全局锁(Global Lock)
定义:锁定整个数据库实例,使数据库变为只读状态。
加锁命令
-- 加全局读锁(FTWRL:Flush Tables With Read Lock)
FLUSH TABLES WITH READ LOCK;-- 此时:
-- ✅ 允许:SELECT查询
-- ❌ 禁止:UPDATE、INSERT、DELETE、DDL-- 释放锁
UNLOCK TABLES;
使用场景
场景1:全库逻辑备份
# 1. 加全局锁
mysql> FLUSH TABLES WITH READ LOCK;# 2. 备份数据
mysqldump -uroot -p --all-databases > backup.sql# 3. 释放锁
mysql> UNLOCK TABLES;
问题:整个数据库不可写,业务停摆!
更好的方案:
# 使用--single-transaction参数(InnoDB)
mysqldump -uroot -p --single-transaction --all-databases > backup.sql# 原理:在可重复读隔离级别下,通过MVCC获取一致性视图
# 优势:不阻塞写操作
2.3 表级锁
类型1:表锁(Table Lock)
读锁(共享锁):
-- 加读锁
LOCK TABLES users READ;-- 当前会话:
-- ✅ 可以读users表
-- ❌ 不能写users表
-- ❌ 不能访问其他表-- 其他会话:
-- ✅ 可以读users表
-- ❌ 不能写users表(阻塞,等待锁释放)-- 释放锁
UNLOCK TABLES;
写锁(排他锁):
-- 加写锁
LOCK TABLES users WRITE;-- 当前会话:
-- ✅ 可以读写users表
-- ❌ 不能访问其他表-- 其他会话:
-- ❌ 不能读users表(阻塞)
-- ❌ 不能写users表(阻塞)-- 释放锁
UNLOCK TABLES;
类型2:元数据锁(MDL - Meta Data Lock)
自动加锁(MySQL 5.5+):
-- 事务开始时自动加MDL
START TRANSACTION;
SELECT * FROM users; -- 自动加MDL读锁-- 其他会话尝试修改表结构
ALTER TABLE users ADD COLUMN age INT; -- 阻塞!等待MDL写锁COMMIT; -- 释放MDL锁
MDL锁的作用:
- 保护表结构不被并发修改
- 防止读取到不一致的数据
查看MDL锁:
-- MySQL 5.7+
SELECT * FROM performance_schema.metadata_locks;-- MySQL 8.0+
SELECT OBJECT_TYPE,OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_STATUS,OWNER_THREAD_ID
FROM performance_schema.metadata_locks;
类型3:意向锁(Intention Lock)
定义:表级锁,表示事务准备在某行上加共享锁或排他锁。
意向共享锁(IS Lock):事务准备加行级共享锁
意向排他锁(IX Lock):事务准备加行级排他锁
作用:提升加表锁的效率。
示例:
-- 事务A
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 自动加:IX锁(表级)+ X锁(行级)-- 事务B
LOCK TABLES users READ;
-- 检查意向锁,发现IX锁冲突,直接阻塞
-- 无需逐行检查是否有行锁
兼容性矩阵:
IS | IX | S | X | |
---|---|---|---|---|
IS | ✅ | ✅ | ✅ | ❌ |
IX | ✅ | ✅ | ❌ | ❌ |
S | ✅ | ❌ | ✅ | ❌ |
X | ❌ | ❌ | ❌ | ❌ |
2.4 按锁模式分类
共享锁(S Lock / 读锁)
定义:多个事务可以同时持有共享锁,但不能修改数据。
-- 加共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 或(MySQL 8.0.1+)
SELECT * FROM users WHERE id = 1 FOR SHARE;-- 效果:
-- ✅ 其他事务可以读
-- ✅ 其他事务可以加共享锁
-- ❌ 其他事务不能加排他锁(阻塞)
排他锁(X Lock / 写锁)
定义:只有一个事务可以持有排他锁,其他事务不能读写。
-- 加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;-- 效果:
-- ❌ 其他事务不能加共享锁(阻塞)
-- ❌ 其他事务不能加排他锁(阻塞)
-- ✅ 普通SELECT可以读(快照读,不加锁)
兼容性矩阵:
共享锁(S) | 排他锁(X) | |
---|---|---|
共享锁(S) | ✅ 兼容 | ❌ 冲突 |
排他锁(X) | ❌ 冲突 | ❌ 冲突 |
三、InnoDB行锁详解
3.1 行锁的类型
InnoDB支持3种行锁:
1. 记录锁(Record Lock)- 锁定单行记录2. 间隙锁(Gap Lock)- 锁定索引记录之间的间隙3. 临键锁(Next-Key Lock)- 记录锁 + 间隙锁- 锁定记录及其前面的间隙
3.2 记录锁(Record Lock)
定义:锁定索引记录本身。
示例:
-- 准备数据
CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(50),age INT,KEY idx_age (age)
);INSERT INTO users VALUES
(1, '张三', 20),
(5, '李四', 25),
(10, '王五', 30);-- 事务A:锁定id=5的记录
START TRANSACTION;
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 加记录锁:锁定id=5这一行-- 事务B
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- ✅ 不冲突,可以锁定
SELECT * FROM users WHERE id = 5 FOR UPDATE; -- ❌ 冲突,阻塞
SELECT * FROM users WHERE id = 10 FOR UPDATE; -- ✅ 不冲突,可以锁定
查看锁信息:
-- 查看当前锁
SELECT * FROM performance_schema.data_locks;-- 查看锁等待
SELECT * FROM performance_schema.data_lock_waits;
3.3 间隙锁(Gap Lock)
定义:锁定索引记录之间的间隙,防止其他事务在间隙中插入数据。
作用:解决幻读问题(可重复读隔离级别)。
示例:
-- 数据:id = 1, 5, 10-- 事务A
START TRANSACTION;
SELECT * FROM users WHERE id > 5 AND id < 10 FOR UPDATE;
-- 加间隙锁:锁定(5, 10)这个间隙-- 事务B
INSERT INTO users VALUES (6, '赵六', 28); -- ❌ 阻塞(在间隙中)
INSERT INTO users VALUES (7, '陈七', 29); -- ❌ 阻塞(在间隙中)
INSERT INTO users VALUES (4, '孙八', 22); -- ✅ 成功(不在间隙中)
INSERT INTO users VALUES (11, '周九', 35); -- ✅ 成功(不在间隙中)
间隙示意图:
索引值: 1 5 10
间隙: (-∞,1) (1,5) (5,10) (10,+∞)锁定 id > 5 AND id < 10:
锁定间隙 (5, 10)
注意:
- 间隙锁只在可重复读隔离级别下生效
- 读已提交隔离级别下没有间隙锁
3.4 临键锁(Next-Key Lock)
定义:记录锁 + 间隙锁的组合,锁定记录及其前面的间隙。
默认行为:InnoDB默认使用临键锁。
示例:
-- 数据:id = 1, 5, 10-- 事务A
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 加临键锁:锁定 (1, 5]
-- 即:间隙(1, 5) + 记录5-- 事务B
INSERT INTO users VALUES (2, '李四', 22); -- ❌ 阻塞(在间隙中)
INSERT INTO users VALUES (4, '王五', 24); -- ❌ 阻塞(在间隙中)
UPDATE users SET name = '李四二' WHERE id = 5; -- ❌ 阻塞(锁定记录)
INSERT INTO users VALUES (6, '赵六', 26); -- ✅ 成功(不在锁定范围)
临键锁范围:
索引值: 1 5 10SELECT ... WHERE id = 5 FOR UPDATE
锁定:(1, 5]SELECT ... WHERE id <= 5 FOR UPDATE
锁定:(-∞, 1] + (1, 5]SELECT ... WHERE id > 5 FOR UPDATE
锁定:(5, 10] + (10, +∞)
3.5 行锁的加锁规则
规则1:基本加锁原则
-- 1. 唯一索引等值查询
-- - 记录存在:加记录锁
-- - 记录不存在:加间隙锁SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- id存在:记录锁(5)
-- id不存在:间隙锁-- 2. 唯一索引范围查询
-- - 加临键锁SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 临键锁:(5, 10], (10, +∞)-- 3. 非唯一索引等值查询
-- - 加临键锁 + 间隙锁SELECT * FROM users WHERE age = 25 FOR UPDATE;
-- 假设age=25的记录id=5
-- 临键锁:age索引上的(20, 25]
-- 间隙锁:age索引上的(25, 30)
-- 记录锁:主键id=5-- 4. 非唯一索引范围查询
-- - 加临键锁SELECT * FROM users WHERE age > 25 FOR UPDATE;
-- 临键锁:(25, 30], (30, +∞)
规则2:索引失效时的加锁
-- 没有索引或索引失效:全表扫描 + 表锁
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
-- name没有索引
-- 结果:锁定整个表(所有行)
危险:索引失效导致锁升级为表锁,严重影响并发!
3.6 实战示例:理解加锁范围
-- 准备数据
CREATE TABLE t (id INT PRIMARY KEY,c INT,d INT,KEY idx_c (c)
);INSERT INTO t VALUES
(0, 0, 0),
(5, 5, 5),
(10, 10, 10),
(15, 15, 15),
(20, 20, 20),
(25, 25, 25);
场景1:主键等值查询
-- 事务A
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 加锁:记录锁(id=10)-- 事务B
UPDATE t SET d = d + 1 WHERE id = 5; -- ✅ 成功
UPDATE t SET d = d + 1 WHERE id = 10; -- ❌ 阻塞
UPDATE t SET d = d + 1 WHERE id = 15; -- ✅ 成功
场景2:主键范围查询
-- 事务A
SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
-- 加锁:
-- - 临键锁:(5, 10]
-- - 临键锁:(10, 15]
-- - 间隙锁:(15, 20) - 但15不包含,实际是(10, 15)-- 事务B
INSERT INTO t VALUES (8, 8, 8); -- ❌ 阻塞(在(5,10]中)
INSERT INTO t VALUES (12, 12, 12); -- ❌ 阻塞(在(10,15)中)
INSERT INTO t VALUES (16, 16, 16); -- ✅ 成功
场景3:非唯一索引查询
-- 事务A
SELECT * FROM t WHERE c = 10 FOR UPDATE;
-- 加锁:
-- - c索引上:临键锁(5, 10],间隙锁(10, 15)
-- - 主键上:记录锁(id=10)-- 事务B
INSERT INTO t VALUES (8, 8, 8); -- ❌ 阻塞(c=8在间隙中)
INSERT INTO t VALUES (12, 12, 12); -- ❌ 阻塞(c=12在间隙中)
UPDATE t SET d = d + 1 WHERE id = 10; -- ❌ 阻塞(主键锁)
UPDATE t SET d = d + 1 WHERE c = 5; -- ✅ 成功
四、死锁问题
4.1 什么是死锁
定义:两个或多个事务相互等待对方持有的锁,形成循环等待。
经典示例:
时间线 事务A 事务B
T1 START TRANSACTION;
T2 START TRANSACTION;
T3 UPDATE t SET c=1 WHERE id=1;-- 持有id=1的锁
T4 UPDATE t SET c=2 WHERE id=2;-- 持有id=2的锁
T5 UPDATE t SET c=3 WHERE id=2;-- 等待id=2的锁 ←
T6 UPDATE t SET c=4 WHERE id=1;-- 等待id=1的锁 ←死锁!循环等待
4.2 死锁演示
-- 准备数据
CREATE TABLE accounts (id INT PRIMARY KEY,balance DECIMAL(10,2)
);INSERT INTO accounts VALUES (1, 1000), (2, 500);-- 终端1(事务A)
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 持有id=1的锁-- 终端2(事务B)
START TRANSACTION;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 持有id=2的锁-- 终端1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 等待id=2的锁...-- 终端2
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 等待id=1的锁...-- MySQL检测到死锁,自动回滚其中一个事务
-- 错误:ERROR 1213 (40001): Deadlock found when trying to get lock
4.3 死锁检测
-- 查看死锁信息
SHOW ENGINE INNODB STATUS\G-- 输出示例:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 10:30:45*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140123456, query id 100 localhost root updating
UPDATE accounts SET balance = balance + 100 WHERE id = 2*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 12345 lock_mode X locks rec but not gap waiting*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 11, OS thread handle 140123457, query id 101 localhost root updating
UPDATE accounts SET balance = balance - 100 WHERE id = 1*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 12346 lock_mode X locks rec but not gap*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 12346 lock_mode X locks rec but not gap waiting*** WE ROLL BACK TRANSACTION (2)
4.4 死锁的产生条件
四个必要条件:
- 互斥条件:资源不能被共享
- 持有并等待:持有资源的同时等待其他资源
- 不可剥夺:资源不能被强制释放
- 循环等待:形成资源请求的环路
打破任一条件即可避免死锁。
4.5 避免死锁的方法
方法1:按固定顺序访问资源
-- ❌ 错误:不同顺序访问
-- 事务A:先锁id=1,再锁id=2
-- 事务B:先锁id=2,再锁id=1 ← 死锁!-- ✅ 正确:统一顺序访问
-- 所有事务都按id升序访问
-- 事务A:先锁id=1,再锁id=2
-- 事务B:先锁id=1,再锁id=2 ← 不会死锁,B等待A释放id=1
代码实现:
public void transfer(Long fromId, Long toId, BigDecimal amount) {// 按ID大小排序,统一访问顺序Long firstId = Math.min(fromId, toId);Long secondId = Math.max(fromId, toId);// 先锁较小的IDAccount first = accountMapper.selectByIdForUpdate(firstId);// 再锁较大的IDAccount second = accountMapper.selectByIdForUpdate(secondId);// 执行转账逻辑if (fromId.equals(firstId)) {first.setBalance(first.getBalance().subtract(amount));second.setBalance(second.getBalance().add(amount));} else {first.setBalance(first.getBalance().add(amount));second.setBalance(second.getBalance().subtract(amount));}accountMapper.update(first);accountMapper.update(second);
}
方法2:一次性获取所有锁
-- 一次性锁定所有需要的资源
START TRANSACTION;
SELECT * FROM accounts WHERE id IN (1, 2) FOR UPDATE;
-- 同时锁定id=1和id=2-- 执行业务逻辑
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;COMMIT;
方法3:设置锁超时时间
-- 查看锁等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认50秒-- 设置超时时间
SET SESSION innodb_lock_wait_timeout = 5; -- 5秒超时-- 超时后自动回滚,抛出异常
-- ERROR 1205 (HY000): Lock wait timeout exceeded
方法4:使用乐观锁
-- 不使用悲观锁,改用版本号机制
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = #{currentVersion};-- 更新失败则重试
方法5:降低事务隔离级别
-- 从可重复读降低到读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;-- 减少间隙锁,降低死锁概率
方法6:缩小事务范围
// ❌ 不好:事务范围过大
@Transactional
public void complexOperation() {// 大量业务逻辑// ...// 数据库操作accountMapper.update(...);// 更多业务逻辑// ...
}// ✅ 更好:缩小事务范围
public void complexOperation() {// 业务逻辑// ...// 只在必要时开启事务transactionTemplate.execute(status -> {accountMapper.update(...);return null;});// 更多业务逻辑// ...
}
五、锁等待和超时
5.1 查看锁等待
-- 方法1:SHOW PROCESSLIST
SHOW FULL PROCESSLIST;-- 方法2:information_schema
SELECT r.trx_id AS waiting_trx_id,r.trx_mysql_thread_id AS waiting_thread,r.trx_query AS waiting_query,b.trx_id AS blocking_trx_id,b.trx_mysql_thread_id AS blocking_thread,b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;-- 方法3:sys库(推荐)
SELECT * FROM sys.innodb_lock_waits;
5.2 查看锁信息
-- MySQL 8.0+
SELECT ENGINE_LOCK_ID,ENGINE_TRANSACTION_ID,OBJECT_SCHEMA,OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA
FROM performance_schema.data_locks;-- 查看锁等待
SELECT REQUESTING_ENGINE_LOCK_ID,BLOCKING_ENGINE_LOCK_ID,REQUESTING_THREAD_ID,BLOCKING_THREAD_ID
FROM performance_schema.data_lock_waits;
5.3 杀掉阻塞进程
-- 1. 找到阻塞的进程ID
SELECT * FROM sys.innodb_lock_waits;-- 2. 杀掉进程
KILL 12345; -- 12345是进程ID-- 或者杀掉查询
KILL QUERY 12345;
六、乐观锁与悲观锁
6.1 悲观锁(Pessimistic Lock)
思想:假设会发生并发冲突,每次操作都加锁。
实现:数据库锁机制(FOR UPDATE)
-- 悲观锁示例:库存扣减
START TRANSACTION;-- 1. 查询并锁定
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 其他事务无法修改,必须等待-- 2. 检查库存
IF stock > 0 THEN-- 3. 扣减库存UPDATE products SET stock = stock - 1 WHERE id = 1;
END IF;COMMIT;
优点:
- ✅ 保证数据强一致性
- ✅ 适合写多读少的场景
- ✅ 避免了重试
缺点:
- ❌ 并发性能低(阻塞)
- ❌ 可能产生死锁
- ❌ 锁持有时间长
6.2 乐观锁(Optimistic Lock)
思想:假设不会发生并发冲突,只在更新时检查数据是否被修改。
实现方式1:版本号
-- 1. 添加版本号字段
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;-- 2. 查询商品
SELECT id, stock, version FROM products WHERE id = 1;
-- 假设:stock=10, version=5-- 3. 更新时检查版本号
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;-- 4. 检查影响行数
-- 如果为0,说明版本号已变化,更新失败
-- 如果为1,说明更新成功
实现方式2:时间戳
-- 1. 添加时间戳字段
ALTER TABLE products ADD COLUMN update_time TIMESTAMP;-- 2. 查询商品
SELECT id, stock, update_time FROM products WHERE id = 1;-- 3. 更新时检查时间戳
UPDATE products
SET stock = stock - 1, update_time = NOW()
WHERE id = 1 AND update_time = #{previousUpdateTime};
Java实现:
@Transactional(rollbackFor = Exception.class)
public boolean deductStock(Long productId, Integer quantity) {// 最大重试次数int maxRetry = 3;for (int i = 0; i < maxRetry; i++) {// 1. 查询商品Product product = productMapper.selectById(productId);if (product.getStock() < quantity) {throw new BusinessException("库存不足");}// 2. 乐观锁更新int updated = productMapper.updateStockWithVersion(productId,quantity,product.getVersion());if (updated > 0) {return true; // 成功}// 失败,重试if (i < maxRetry - 1) {try {Thread.sleep(50); // 短暂等待} catch (InterruptedException e) {Thread.currentThread().interrupt();}}}throw new BusinessException("系统繁忙,请稍后重试");
}
优点:
- ✅ 并发性能高(无锁)
- ✅ 不会死锁
- ✅ 适合读多写少的场景
缺点:
- ❌ 可能需要重试
- ❌ 不保证实时一致性
- ❌ 高并发下重试次数多
6.3 乐观锁 vs 悲观锁
对比项 | 悲观锁 | 乐观锁 |
---|---|---|
加锁时机 | 读取时加锁 | 更新时检查 |
实现方式 | 数据库锁(FOR UPDATE) | 版本号/时间戳 |
并发性能 | 低(阻塞) | 高(无锁) |
适用场景 | 写多读少 | 读多写少 |
一致性 | 强一致性 | 最终一致性 |
死锁 | 可能 | 不会 |
重试 | 不需要 | 可能需要 |
选择建议:
悲观锁:
- 写操作频繁
- 对一致性要求极高
- 冲突率高乐观锁:
- 读操作频繁
- 冲突率低
- 允许重试
七、实战案例
7.1 案例1:电商秒杀
需求:100个商品,10000人秒杀。
方案对比:
方案1:悲观锁(不推荐)
@Transactional
public boolean seckill(Long productId, Long userId) {// 锁定商品Product product = productMapper.selectByIdForUpdate(productId);if (product.getStock() <= 0) {return false;}// 扣减库存productMapper.updateStock(productId, 1);// 创建订单orderMapper.insert(new Order(productId, userId));return true;
}
问题:数据库压力大,并发低(10000个请求排队)
方案2:乐观锁(改进)
@Transactional
public boolean seckill(Long productId, Long userId) {Product product = productMapper.selectById(productId);if (product.getStock() <= 0) {return false;}// 乐观锁扣减int updated = productMapper.updateStockWithVersion(productId, 1, product.getVersion());if (updated == 0) {return false; // 失败,不重试}// 创建订单orderMapper.insert(new Order(productId, userId));return true;
}
优势:并发性能提升,但仍有数据库压力
方案3:Redis预扣库存(推荐)
public boolean seckill(Long productId, Long userId) {String stockKey = "seckill:stock:" + productId;// 1. Redis原子扣减Long stock = redisTemplate.opsForValue().decrement(stockKey);if (stock < 0) {// 库存不足,回滚redisTemplate.opsForValue().increment(stockKey);return false;}// 2. 异步创建订单(MQ)rabbitTemplate.convertAndSend("seckill.order",new OrderMessage(productId, userId));return true;
}// 消费者
@RabbitListener(queues = "seckill.order")
public void processOrder(OrderMessage message) {// 创建订单orderService.createOrder(message.getProductId(), message.getUserId());
}
7.2 案例2:分布式锁
场景:多个服务实例,保证同一时间只有一个实例执行任务。
Redis分布式锁:
public void executeTask() {String lockKey = "task:lock";String lockValue = UUID.randomUUID().toString();// 1. 获取锁(SETNX + 过期时间)Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);if (!locked) {return; // 获取锁失败}try {// 2. 执行任务doTask();} finally {// 3. 释放锁(Lua脚本保证原子性)String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +" return redis.call('del', KEYS[1]) " +"else " +" return 0 " +"end";redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),Collections.singletonList(lockKey),lockValue);}
}
Redisson分布式锁(推荐):
@Autowired
private RedissonClient redisson;public void executeTask() {RLock lock = redisson.getLock("task:lock");try {// 尝试获取锁,最多等待10秒,锁自动过期时间30秒boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);if (locked) {// 执行任务doTask();}} catch (InterruptedException e) {Thread.currentThread().interrupt();} finally {// 释放锁if (lock.isHeldByCurrentThread()) {lock.unlock();}}
}
八、锁优化建议
8.1 减少锁冲突
1. 缩小锁范围
// ❌ 锁范围过大
@Transactional
public void process() {// 大量业务逻辑doSomething();// 数据库操作update();// 更多业务逻辑doOtherThing();
}// ✅ 缩小锁范围
public void process() {doSomething();// 只在必要时加锁transactionTemplate.execute(status -> {update();return null;});doOtherThing();
}
2. 减少锁持有时间
-- ❌ 锁持有时间长
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 执行大量业务逻辑...
-- 调用外部API...
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;-- ✅ 减少锁持有时间
-- 先完成业务逻辑
-- 再开启事务
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
3. 按顺序访问资源
// 统一访问顺序,避免死锁
public void transfer(Long from, Long to, BigDecimal amount) {Long firstId = Math.min(from, to);Long secondId = Math.max(from, to);lockAndUpdate(firstId);lockAndUpdate(secondId);
}
8.2 选择合适的隔离级别
-- 如果不需要可重复读,降低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;-- 减少间隙锁,提升并发性能
8.3 使用合适的索引
-- ❌ 没有索引:全表锁
UPDATE products SET stock = stock - 1 WHERE name = 'iPhone';-- ✅ 有索引:行锁
CREATE INDEX idx_name ON products(name);
UPDATE products SET stock = stock - 1 WHERE name = 'iPhone';
8.4 监控锁等待
-- 定期检查锁等待
SELECT * FROM sys.innodb_lock_waits;-- 配置告警
-- 锁等待超过5秒,发送告警
总结
核心要点
1. 锁的分类:- 粒度:全局锁、表锁、行锁- 模式:共享锁、排他锁- 类型:记录锁、间隙锁、临键锁2. InnoDB行锁:- 记录锁:锁定记录- 间隙锁:锁定间隙(防幻读)- 临键锁:记录锁+间隙锁3. 死锁:- 四个条件:互斥、持有等待、不可剥夺、循环等待- 避免方法:统一顺序、一次获取、超时、乐观锁4. 乐观锁 vs 悲观锁:- 悲观锁:写多读少,强一致性- 乐观锁:读多写少,高并发5. 优化建议:- 缩小锁范围- 减少锁持有时间- 使用合适的索引- 选择合适的隔离级别