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加锁最新已提交并可加锁的数据
当前读updatedelete加锁当前最新数据

示例:

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. 锁住 1020 之间的间隙。
  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:它解释了为什么很多普通读可以不加锁,也能看到一致的数据版本。