MySQL 锁
MySQL 锁用于保证并发访问下的数据一致性。没有锁,两个事务同时修改同一份数据,就可能出现丢失更新、重复扣款、库存超卖等问题。
一句话理解:
MVCC 主要解决“读和写如何尽量不互相阻塞”,锁主要解决“写和写、当前读和写如何保证正确顺序”。
为什么需要锁
假设账户余额是 100,两个请求同时扣款。
sequenceDiagram
participant T1 as "事务 A"
participant DB as "MySQL"
participant T2 as "事务 B"
T1->>DB: "读取余额 100"
T2->>DB: "读取余额 100"
T1->>DB: "写回 90"
T2->>DB: "写回 80"如果没有并发控制,最后余额可能是 80 或 90,都不一定符合真实业务。锁的作用就是让冲突操作按可控顺序执行。
锁分类
flowchart TD
A["MySQL 锁"] --> B["全局锁"]
A --> C["表级锁"]
A --> D["行级锁"]
D --> E["记录锁 Record Lock"]
D --> F["间隙锁 Gap Lock"]
D --> G["临键锁 Next-Key Lock"]
C --> H["意向锁"]
C --> I["元数据锁 MDL"]| 锁 | 说明 | 场景 |
|---|---|---|
| 全局锁 | 整个实例只读 | 逻辑备份 |
| 表锁 | 锁住整张表 | 特殊场景、非 InnoDB 引擎 |
| 元数据锁 MDL | 保护表结构读写一致 | 查询和 DDL 互相影响 |
| 意向锁 | 表级标记,表示表中某些行将加锁 | 协调表锁和行锁 |
| 记录锁 | 锁住索引中的某条记录 | 主键等值更新 |
| 间隙锁 | 锁住两个索引值之间的空隙 | 防止幻读 |
| 临键锁 | 记录锁 + 间隙锁 | RR 下范围查询常见 |
InnoDB 行锁加在哪里
InnoDB 的行锁不是直接锁“表里的某一行物理数据”,而是锁索引记录。
flowchart TD
A["执行 update / delete / select for update"] --> B{"条件是否命中索引"}
B -- "命中索引" --> C["定位索引记录"]
C --> D["锁住相关索引记录或范围"]
B -- "没有命中索引" --> E["扫描更多记录"]
E --> F["锁范围扩大,并发下降"]这句话非常重要:行锁加在索引上。
准备表:
create table account (
id bigint primary key,
user_id bigint not null,
balance decimal(10, 2) not null,
key idx_user_id (user_id)
) engine = InnoDB;命中主键:
update account
set balance = balance - 10
where id = 1;锁主键索引上 id = 1 的记录。
命中普通索引:
update account
set balance = balance - 10
where user_id = 1001;会先锁普通索引 idx_user_id 的相关记录,同时也会锁对应的聚簇索引记录。
如果没有索引:
update account
set balance = balance - 10
where balance = 100.00;balance 没有索引时,需要扫描更多记录,可能造成大量锁等待。
快照读和当前读
| 类型 | 示例 | 是否加锁 | 读到什么 |
|---|---|---|---|
| 快照读 | 普通 select | 通常不加行锁 | 基于 Read View 的可见版本 |
| 当前读 | select ... for update | 加锁 | 最新已提交并可加锁的数据 |
| 当前读 | update、delete | 加锁 | 当前最新数据 |
示例:
-- 快照读,不阻塞普通 update
select *
from account
where id = 1;
-- 当前读,会尝试加锁
select *
from account
where id = 1
for update;为什么要区分:很多人以为“查询不会加锁”,但 select ... for update 是当前读,会锁住目标记录,常用于扣库存、抢任务等场景。
记录锁、间隙锁、临键锁
准备数据:
create table product_stock (
id bigint primary key,
sku varchar(50) not null,
stock int not null,
unique key uk_sku (sku)
) engine = InnoDB;
insert into product_stock(id, sku, stock)
values (10, 'A', 100), (20, 'B', 100), (30, 'C', 100);记录锁
begin;
select *
from product_stock
where id = 20
for update;命中唯一索引等值查询时,通常锁住 id = 20 这条记录。
间隙锁
begin;
select *
from product_stock
where id > 10 and id < 20
for update;这个范围里没有记录,但 RR 隔离级别下可能锁住 (10, 20) 这个间隙,防止其他事务插入 id = 15 造成幻读。
临键锁
临键锁是记录锁 + 间隙锁。范围查询时,InnoDB 可能锁住索引记录以及记录前后的范围。
flowchart TD
A["索引值:10"] --> B["间隙 (10,20)"]
B --> C["索引值:20"]
C --> D["间隙 (20,30)"]
D --> E["索引值:30"]如果事务锁住 (10,20],意味着:
- 锁住
id = 20这条记录。 - 锁住
10和20之间的间隙。 - 其他事务不能插入
id = 15。
SQL Demo:观察行锁等待
准备数据:
create table account_lock_demo (
id bigint primary key,
balance decimal(10, 2) not null
) engine = InnoDB;
insert into account_lock_demo(id, balance)
values (1, 100.00), (2, 200.00);事务 A:
begin;
update account_lock_demo
set balance = balance - 10
where id = 1;
-- 暂时不要 commit事务 B:
begin;
update account_lock_demo
set balance = balance - 20
where id = 1;
-- 会等待事务 A 释放 id=1 的锁查看等待:
select *
from performance_schema.data_lock_waits;查看正在执行的事务:
select *
from information_schema.innodb_trx;当事务 A 执行:
commit;事务 B 才能继续执行。
死锁是什么
死锁是两个或多个事务互相等待对方释放锁,导致谁也无法继续。
sequenceDiagram
participant A as "事务 A"
participant B as "事务 B"
A->>A: "锁住 id=1"
B->>B: "锁住 id=2"
A->>B: "等待 id=2"
B->>A: "等待 id=1"MySQL 检测到死锁后,会回滚其中一个事务,让另一个事务继续。
查看最近一次死锁:
show engine innodb status\G业务上要做两件事:
- 尽量降低死锁概率。
- 捕获死锁异常后做有限重试。
如何减少死锁
| 做法 | 原理 |
|---|---|
| 同类业务按固定顺序更新资源 | 避免 A 先锁 1 再锁 2,B 先锁 2 再锁 1 |
| 更新条件必须命中索引 | 减少扫描和加锁范围 |
| 缩短事务时间 | 锁持有时间越短,冲突概率越低 |
| 事务内不要调用远程接口 | 远程调用慢且不可控,会延长锁持有 |
| 批量操作分批提交 | 避免一次锁住大量记录 |
| 捕获死锁后重试 | 死锁不是逻辑必然错误,有限重试可恢复 |
错误示例:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.decrease(fromId, amount);
remoteRiskService.check(fromId, toId, amount); // 不建议在事务中远程调用
accountMapper.increase(toId, amount);
}更好的方式是先完成必要校验,再进入短事务:
public void transfer(Long fromId, Long toId, BigDecimal amount) {
remoteRiskService.check(fromId, toId, amount);
transferInTransaction(fromId, toId, amount);
}
@Transactional
public void transferInTransaction(Long fromId, Long toId, BigDecimal amount) {
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
accountMapper.lockById(first);
accountMapper.lockById(second);
accountMapper.decrease(fromId, amount);
accountMapper.increase(toId, amount);
}这里按 ID 从小到大加锁,可以降低两个转账请求互相反向等待的概率。
锁等待排查流程
flowchart TD
A["接口变慢或超时"] --> B["查看是否有锁等待"]
B --> C["查 data_lock_waits / innodb_trx"]
C --> D["找到阻塞事务"]
D --> E["查看事务执行 SQL 和持有时间"]
E --> F{"是否长事务或无索引更新"}
F -- "是" --> G["优化事务范围或补索引"]
F -- "否" --> H["分析业务并发顺序和死锁日志"]常用命令:
show processlist;
select *
from information_schema.innodb_trx;
select *
from performance_schema.data_locks;
select *
from performance_schema.data_lock_waits;常见误区
| 误区 | 正确理解 |
|---|---|
| 行锁就是锁某一行物理数据 | InnoDB 行锁加在索引记录上 |
| 普通 select 一定加锁 | 普通快照读通常不加行锁 |
| 加了索引只影响查询速度 | 加索引也会影响锁范围 |
| 死锁就是数据库坏了 | 死锁是并发系统的正常风险,要降低概率并重试 |
| 事务越大越安全 | 事务越大,锁持有越久,阻塞越严重 |
| 间隙锁锁的是已有行 | 间隙锁锁的是索引范围,可能锁住不存在的数据位置 |
小结
- 锁是为了保证并发写入和当前读的正确性。
- InnoDB 行锁加在索引上,SQL 不命中索引会扩大锁影响。
- 普通
select是快照读,select ... for update是当前读。 - RR 隔离级别下,范围当前读可能产生间隙锁和临键锁。
- 死锁不能完全避免,业务要固定加锁顺序、缩短事务、失败重试。
继续学习时建议阅读 MVCC:它解释了为什么很多普通读可以不加锁,也能看到一致的数据版本。
