场景引入
小李的公司电商系统突然出现大量订单超时,排查发现是多个事务同时修改同一条热门商品库存时互相等待,导致系统几乎"卡死"。DBA 赶到后看了一眼监控,说:"锁竞争了。" 锁是数据库并发控制的基石,理解锁机制才能写出既安全又高效的高并发 SQL。
一、为什么需要锁?
数据库是共享资源,多个会话(Session / 连接)可能同时读写同一行数据。没有锁,就会出现:
| 问题 | 说明 |
|---|---|
| 丢失更新 | 两个事务同时读到库存=10,各自减1后都写回9,实际应剩8 |
| 脏读 | 读到另一个事务未提交的数据(可被回滚) |
| 不可重复读 | 同一事务内两次读同一行,值变了 |
| 幻读 | 同一事务内两次范围查询,行数变了 |
锁的作用:协调并发访问,保证数据一致性与事务隔离。
二、MySQL InnoDB 锁类型
InnoDB 实现了行级锁(Row-Level Lock),同时支持表级锁作为后备。
1. 共享锁(Shared Lock,S Lock)
- 允许持有者读取该行
- 多个事务可同时持有同一行的 S 锁
- 阻止其他事务获取 X 锁(即阻止修改)
sql
-- 显式加共享锁(SELECT ... LOCK IN SHARE MODE)
-- MySQL 8.0 起推荐使用 FOR SHARE
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR SHARE;
-- 此时其他会话可以读,但不能修改 id=1 的行
COMMIT;
2. 排他锁(Exclusive Lock,X Lock)
- 允许持有者读取并修改该行
- 同一行上 X 锁与任何其他锁(S 或 X)互斥
UPDATE、DELETE、INSERT自动加 X 锁
sql
-- 显式加排他锁
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 此时其他会话无法读取或修改该行(FOR SHARE 或 FOR UPDATE 都会等待)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
3. 意向锁(Intention Lock)
- 表级别锁,表示"某个事务即将对表中的行加锁"
- 意向共享锁(IS):准备加 S 锁
- 意向排他锁(IX):准备加 X 锁
- MySQL 自动管理,开发者通常无需关心
意义:当要加表锁时,不用遍历所有行检查是否有行锁,直接看意向锁即可快速判断。
4. 行锁 vs 表锁
| 存储引擎 | 默认锁粒度 |
|---|---|
| InnoDB | 行级锁(并发高) |
| MyISAM | 表级锁(并发差,已过时) |
即使使用 InnoDB,不走索引的查询会导致行锁升级为表锁:
sql
-- ❌ name 字段无索引,全表扫描 → 锁住整表
UPDATE employees SET salary = 10000 WHERE name = '张三';
-- ✅ 走主键/唯一索引 → 只锁 index=5 这一行
UPDATE employees SET salary = 10000 WHERE id = 5;
三、加锁规则与 MVCC 配合
InnoDB 默认使用 READ COMMITTED 或 REPEATABLE READ 隔离级别,MVCC(多版本并发控制) 负责"读不阻塞写,写不阻塞读":
- 快照读(Snapshot Read) :普通
SELECT,不加锁,读取历史版本 - 当前读(Current Read) :
SELECT ... FOR UPDATE / FOR SHARE以及UPDATE/DELETE,加锁,读最新已提交版本
sql
-- 快照读 → 不加锁,读快照
SELECT * FROM orders WHERE id = 100;
-- 当前读 → 加行锁(X 锁)
UPDATE orders SET status = 'paid' WHERE id = 100;
四、死锁(Deadlock)
两个事务互相等待对方释放锁,形成循环等待。
sql
事务 A:UPDATE t SET x=1 WHERE id=1; -- 锁 id=1
事务 B:UPDATE t SET x=2 WHERE id=2; -- 锁 id=2
事务 A:UPDATE t SET x=3 WHERE id=2; -- 等待 B 释放 id=2
事务 B:UPDATE t SET x=4 WHERE id=1; -- 等待 A 释放 id=1
MySQL 处理 :InnoDB 自动检测死锁,回滚其中一个事务 (代价较小的那个),并抛出 ER_LOCK_DEADLOCK 错误(代码 1213)。
sql
-- 应用层需要重试
try:
cursor.execute("UPDATE t SET x=1 WHERE id=1")
cursor.execute("UPDATE t SET x=2 WHERE id=2") -- 可能收到死锁错误
except pymysql.err.OperationalError as e:
if e.args[0] == 1213: # Deadlock
connection.rollback()
time.sleep(0.1)
retry()
避免死锁的最佳实践
| 做法 | 说明 |
|---|---|
| 固定访问顺序 | 所有事务按相同顺序访问资源(如先 id=1 再 id=2) |
| 缩短事务 | 尽量快提交,减少锁持有时间 |
| 合理使用索引 | 避免行锁升级为表锁 |
| 降低隔离级别 | 不需要 SERIALIZABLE 时尽量用 READ COMMITTED |
| 限制单表更新量 | 大批量更新考虑分批处理 |
五、如何查看当前锁状态?
sql
-- 查看当前正在等待锁的事务
SELECT * FROM performance_schema.data_lock_waits\G
-- 查看所有锁
SELECT * FROM performance_schema.data_locks\G
-- 查看 InnoDB 状态(含锁信息)
SHOW ENGINE INNODB STATUS\G
实用排查命令:
sql
-- 找出阻塞其他事务的会话
SELECT
waiting.pid AS blocked_pid,
blocking.pid AS blocking_pid,
waiting.query AS blocked_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.processlist waiting ON w.requesting_thread_id = waiting.id
JOIN information_schema.processlist blocking ON w.blocking_thread_id = blocking.id;
六、锁与隔离级别的关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✅ 可能 | ✅ 可能 | ✅ 可能 | 写加 X 锁,读不加锁 |
| READ COMMITTED | ❌ 不会 | ✅ 可能 | ✅ 可能 | 写加 X 锁,读不加锁(语句级快照) |
| REPEATABLE READ (默认) | ❌ | ❌ | ❌ 可能 | 写加 X 锁 + Gap Lock / Next-Key Lock |
| SERIALIZABLE | ❌ | ❌ | ❌ | 所有 SELECT 隐式转 FOR SHARE |
MySQL 的 RR 级别通过 Next-Key Lock(行锁 + Gap Lock) 可以一定程度上防止幻读,但并非 100%。如需完全防幻读,用
SERIALIZABLE。
注意事项
- 索引是行锁的前提:不走索引 = 表锁,并发暴跌
- 事务务必提交:未提交的事务会一直持有锁,阻塞其他会话
- SELECT 默认不加锁 :需要加锁时显式使用
FOR UPDATE或FOR SHARE - 死锁不是 bug,是正常现象:应用层做好重试机制即可
- Gap Lock:RR 级别下,范围条件会锁住不存在的记录间隙,防止幻读,但也可能降低并发
总结
锁机制是数据库并发控制的基石。记住三条核心原则:
- 行锁靠索引 --- 没索引就是表锁
- 阅读过锁 --- 用
FOR UPDATE / FOR SHARE显式加锁 - 快提交、定顺序 --- 避免死锁和锁等待