Skip to content

MySQL 锁 ​

MySQL 锁用于保证并发访问下的数据一致性。没有锁,两个事务同时修改同一份数据,就可能出现丢失更新、重复扣款、库存超卖等问题。

一句话理解:

MVCC 主要解决“读和写如何尽量不互相阻塞”,锁主要解决“写和写、当前读和写如何保证正确顺序”。

为什么需要锁 ​

假设账户余额是 100,两个请求同时扣款。

mermaid
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,都不一定符合真实业务。锁的作用就是让冲突操作按可控顺序执行。

锁分类 ​

mermaid
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 的行锁不是直接锁“表里的某一行物理数据”,而是锁索引记录。

mermaid
flowchart TD
    A["执行 update / delete / select for update"] --> B{"条件是否命中索引"}
    B -- "命中索引" --> C["定位索引记录"]
    C --> D["锁住相关索引记录或范围"]
    B -- "没有命中索引" --> E["扫描更多记录"]
    E --> F["锁范围扩大,并发下降"]

这句话非常重要:行锁加在索引上。

准备表:

sql
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;

命中主键:

sql
update account
set balance = balance - 10
where id = 1;

锁主键索引上 id = 1 的记录。

命中普通索引:

sql
update account
set balance = balance - 10
where user_id = 1001;

会先锁普通索引 idx_user_id 的相关记录,同时也会锁对应的聚簇索引记录。

如果没有索引:

sql
update account
set balance = balance - 10
where balance = 100.00;

balance 没有索引时,需要扫描更多记录,可能造成大量锁等待。

快照读和当前读 ​

类型示例是否加锁读到什么
快照读普通 select通常不加行锁基于 Read View 的可见版本
当前读select ... for update加锁最新已提交并可加锁的数据
当前读update、delete加锁当前最新数据

示例:

sql
-- 快照读,不阻塞普通 update
select *
from account
where id = 1;

-- 当前读,会尝试加锁
select *
from account
where id = 1
for update;

为什么要区分:很多人以为“查询不会加锁”,但 select ... for update 是当前读,会锁住目标记录,常用于扣库存、抢任务等场景。

记录锁、间隙锁、临键锁 ​

准备数据:

sql
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);

记录锁 ​

sql
begin;
select *
from product_stock
where id = 20
for update;

命中唯一索引等值查询时,通常锁住 id = 20 这条记录。

间隙锁 ​

sql
begin;
select *
from product_stock
where id > 10 and id < 20
for update;

这个范围里没有记录,但 RR 隔离级别下可能锁住 (10, 20) 这个间隙,防止其他事务插入 id = 15 造成幻读。

临键锁 ​

临键锁是记录锁 + 间隙锁。范围查询时,InnoDB 可能锁住索引记录以及记录前后的范围。

mermaid
flowchart TD
    A["索引值:10"] --> B["间隙 (10,20)"]
    B --> C["索引值:20"]
    C --> D["间隙 (20,30)"]
    D --> E["索引值:30"]

如果事务锁住 (10,20],意味着:

  1. 锁住 id = 20 这条记录。
  2. 锁住 10 和 20 之间的间隙。
  3. 其他事务不能插入 id = 15。

SQL Demo:观察行锁等待 ​

准备数据:

sql
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:

sql
begin;
update account_lock_demo
set balance = balance - 10
where id = 1;
-- 暂时不要 commit

事务 B:

sql
begin;
update account_lock_demo
set balance = balance - 20
where id = 1;
-- 会等待事务 A 释放 id=1 的锁

查看等待:

sql
select *
from performance_schema.data_lock_waits;

查看正在执行的事务:

sql
select *
from information_schema.innodb_trx;

当事务 A 执行:

sql
commit;

事务 B 才能继续执行。

死锁是什么 ​

死锁是两个或多个事务互相等待对方释放锁,导致谁也无法继续。

mermaid
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 检测到死锁后,会回滚其中一个事务,让另一个事务继续。

查看最近一次死锁:

sql
show engine innodb status\G

业务上要做两件事:

  1. 尽量降低死锁概率。
  2. 捕获死锁异常后做有限重试。

如何减少死锁 ​

做法原理
同类业务按固定顺序更新资源避免 A 先锁 1 再锁 2,B 先锁 2 再锁 1
更新条件必须命中索引减少扫描和加锁范围
缩短事务时间锁持有时间越短,冲突概率越低
事务内不要调用远程接口远程调用慢且不可控,会延长锁持有
批量操作分批提交避免一次锁住大量记录
捕获死锁后重试死锁不是逻辑必然错误,有限重试可恢复

错误示例:

java
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountMapper.decrease(fromId, amount);
    remoteRiskService.check(fromId, toId, amount); // 不建议在事务中远程调用
    accountMapper.increase(toId, amount);
}

更好的方式是先完成必要校验,再进入短事务:

java
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 从小到大加锁,可以降低两个转账请求互相反向等待的概率。

锁等待排查流程 ​

mermaid
flowchart TD
    A["接口变慢或超时"] --> B["查看是否有锁等待"]
    B --> C["查 data_lock_waits / innodb_trx"]
    C --> D["找到阻塞事务"]
    D --> E["查看事务执行 SQL 和持有时间"]
    E --> F{"是否长事务或无索引更新"}
    F -- "是" --> G["优化事务范围或补索引"]
    F -- "否" --> H["分析业务并发顺序和死锁日志"]

常用命令:

sql
show processlist;

select *
from information_schema.innodb_trx;

select *
from performance_schema.data_locks;

select *
from performance_schema.data_lock_waits;

常见误区 ​

误区正确理解
行锁就是锁某一行物理数据InnoDB 行锁加在索引记录上
普通 select 一定加锁普通快照读通常不加行锁
加了索引只影响查询速度加索引也会影响锁范围
死锁就是数据库坏了死锁是并发系统的正常风险,要降低概率并重试
事务越大越安全事务越大,锁持有越久,阻塞越严重
间隙锁锁的是已有行间隙锁锁的是索引范围,可能锁住不存在的数据位置

小结 ​

  1. 锁是为了保证并发写入和当前读的正确性。
  2. InnoDB 行锁加在索引上,SQL 不命中索引会扩大锁影响。
  3. 普通 select 是快照读,select ... for update 是当前读。
  4. RR 隔离级别下,范围当前读可能产生间隙锁和临键锁。
  5. 死锁不能完全避免,业务要固定加锁顺序、缩短事务、失败重试。

继续学习时建议阅读 MVCC:它解释了为什么很多普通读可以不加锁,也能看到一致的数据版本。