缓存并发与 MySQL 核心机制随记

从双重检锁、JOIN 语义、联合索引到 InnoDB 锁模型的整理

Posted by Ekko on June 30, 2024

这篇笔记把几个在面试和日常开发里很容易混在一起的知识点集中梳理一遍,包括缓存击穿场景下的双重检锁、JOIN 的逻辑语义、联合索引与 ICP/MRR 优化、InnoDB 的锁模型、聚簇索引以及 InnoDBMyISAM 的差异。

重点不是只记结论,而是把“它为什么成立”“在哪些前提下成立”“什么场景下容易误用”说清楚。这样再回头看 EXPLAIN、死锁、慢查询和锁等待时,判断会更稳。

参考资料:

MySQL 8.4 Reference Manual - InnoDB Locking

MySQL 8.4 Reference Manual - Locking Reads

MySQL 8.4 Reference Manual - Index Condition Pushdown Optimization

MySQL 8.4 Reference Manual - Multi-Range Read Optimization

MySQL 8.4 Reference Manual - Clustered and Secondary Indexes

MySQL 8.4 Reference Manual - The MyISAM Storage Engine

[TOC]


1. 双重检锁

双重检锁的核心不是“加两次锁”,而是“加锁前后各做一次判空”。它主要用于缓存击穿场景,而不是缓存穿透。

  • 缓存击穿:某个热点 key 失效后,大量并发请求同时打到数据库
  • 缓存穿透:请求的数据本来就不存在,缓存和数据库里都查不到

因此,双重检锁解决的是“热点 key 失效瞬间的并发回源”问题;如果要处理缓存穿透,通常还需要配合空值缓存布隆过滤器等手段。

典型写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public String getData(String key) {
    String value = stringRedisTemplate.opsForValue().get(CACHE_KEY + key);
    if (value != null) {
        return value;
    }

    RLock lock = redissonClient.getLock(LOCK_KEY + key);
    lock.lock(10, TimeUnit.SECONDS);
    try {
        // 第二次判空,避免并发线程重复回源
        value = stringRedisTemplate.opsForValue().get(CACHE_KEY + key);
        if (value != null) {
            return value;
        }

        value = loadDataFromDatabase(key);
        stringRedisTemplate.opsForValue().set(CACHE_KEY + key, value, 10, TimeUnit.MINUTES);
        return value;
    } finally {
        lock.unlock();
    }
}

这里真正起作用的是第二次判空。因为在高并发下,很多线程都可能通过第一次判空,但最终只有拿到锁并再次确认缓存仍为空的线程,才应该真正访问数据库。

这类方案还需要注意几个细节:

  1. 锁的粒度最好落到具体业务 key,而不是整个缓存前缀。
  2. 锁一定要放在 finally 中释放,避免异常导致死锁。
  3. 如果数据库可能返回空结果,最好把空结果也缓存一个较短 TTL,否则不存在的数据会持续穿透。
  4. 分布式场景里要关注锁超时、续约和线程误解锁问题,直接使用成熟组件通常比手写更稳。

2. SQL 执行顺序与 JOIN 语义

很多资料都会写一条“执行顺序”口诀:

1
FROM -> ON -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT

这个顺序更适合用来理解 SQL 的逻辑处理顺序,而不是数据库一定逐句、逐步机械执行的物理顺序。真正执行时,优化器会重排访问路径、连接顺序和索引选择。

2.1 先分清逻辑语义和物理执行

  • 逻辑语义:帮助理解一条 SQL 最终会得到什么结果
  • 物理执行:优化器决定先扫哪张表、走什么索引、是否重排序

因此,JOIN 里的“驱动表”“被驱动表”是执行计划层面的概念,不能直接拿来替代 ONWHERE 的语义判断。

2.2 ONWHERE 的差别

原始笔记里最容易误解的一点,是把 ON 后面的条件简单理解成“只会筛选非驱动表”。这个说法并不准确。

更稳妥的理解方式是:

  • ON:定义两张表如何匹配
  • WHERE:对 JOIN 之后的结果再做过滤

对于 INNER JOIN,很多情况下把条件放在 ON 还是 WHERE,结果可能一致;但到了 LEFT JOIN,语义差别就会明显出现。

例如:

1
2
3
4
5
6
SELECT *
FROM user u
LEFT JOIN orders o
  ON u.id = o.user_id
 AND o.status = 'PAID'
WHERE u.deleted = 0;

这条 SQL 的语义是:

  1. 先保留 user 表中满足 u.deleted = 0 的用户;
  2. 再去关联这些用户的订单;
  3. 只有 status = 'PAID' 的订单会被匹配出来;
  4. 没有匹配订单的用户仍然会保留,只是订单列为 NULL

如果把 o.status = 'PAID' 挪到 WHERE

1
2
3
4
5
6
SELECT *
FROM user u
LEFT JOIN orders o
  ON u.id = o.user_id
WHERE u.deleted = 0
  AND o.status = 'PAID';

那么 o.status = 'PAID' 会在结果集上继续过滤,原本那些没有订单的用户,因为 o.statusNULL,会被过滤掉,这条 SQL 的效果就更接近 INNER JOIN

所以,关于 JOIN 最值得记住的不是“驱动表条件生不生效”,而是:

  • 对外连接来说,ON 影响匹配过程
  • WHERE 影响最终结果集
  • 把右表条件从 ON 挪到 WHERE,很可能改变结果语义

3. 最左匹配原则、ICP 与 MRR

3.1 最左匹配原则

最左匹配原则主要针对联合索引。假设有索引 (a, b, c),那么索引中的记录顺序可以理解为:

  1. 先按 a 排序
  2. a 相同的前提下再按 b 排序
  3. ab 都相同的前提下再按 c 排序

因此,查询条件如果想高效利用这个联合索引,通常要从最左边的列开始连续使用。

例如:

  • a = 1 AND b = 2 AND c = 3:可以很好地利用 (a, b, c) 联合索引
  • a = 1 AND b = 2:同样符合最左前缀
  • a = 1 AND c = 3a 可以用于定位,c 无法跳过 b 直接构成有序查找边界
  • a > 1 AND b = 2a 的范围条件会截断后续列继续用于缩小查找区间

这里常见的误区是把最后一种情况概括成“b 完全不走索引”。更准确的说法应该是:

  • b 往往不能继续参与索引区间定位
  • 但它仍然可能出现在 Using index condition 这类优化里,被用于索引层过滤
  • 如果查询本身被覆盖索引满足,后续列也可能仍然有价值

也就是说,范围条件之后,后续列通常失去的是“继续确定有序范围”的能力,而不是“彻底没用”。

最左匹配b+树.png

3.2 索引下推(Index Condition Pushdown)

ICP 的目标,是让一部分本来要在 MySQL Server 层判断的 WHERE 条件,提前下推到存储引擎层,并且尽量在只读取索引项时就先做过滤。

如果没有 ICP,执行流程更像这样:

  1. 先根据索引找到候选记录
  2. 回表读取完整行
  3. 再在 Server 层判断 WHERE

有了 ICP 之后,流程会变成:

  1. 先扫描索引项
  2. 把能够只依赖索引列判断的条件提前过滤
  3. 只有通过过滤的记录才回表

这样做的直接收益,是减少不必要的回表次数。

几个要点值得单独记住:

  1. ICP 适用于 rangerefeq_refref_or_null 等访问方式。
  2. InnoDBMyISAM 都支持 ICP
  3. InnoDB 来说,ICP 主要用于二级索引,因为聚簇索引读取时整行数据本来就已经拿到了。
  4. EXPLAINExtra 列出现 Using index condition,通常就说明使用了 ICP

3.3 MRR(Multi-Range Read)

MRR 可以理解成“把原本零散的回表,尽量改造成更顺序的回表”。

典型场景是:先扫描二级索引拿到一批主键,再按主键顺序去访问聚簇索引对应的数据页。这样做的目的,是减少随机 I/O,提升大表范围扫描或某些连接场景下的读取效率。

它的核心收益主要有三个:

  1. 把随机回表尽量改造成更顺序的数据访问;
  2. 批量处理一组主键访问请求;
  3. 在 I/O 压力较大时,减少不必要的磁盘随机读。

需要注意的是,MRR 不是“开启就一定更快”:

  • 如果查询本身已经被覆盖索引满足,不需要回表,MRR 就没有明显收益;
  • 优化器默认会做成本判断;
  • EXPLAINExtra 中出现 Using MRR,通常说明该优化被启用。

可以通过下面的方式查看或调整开关:

1
2
SHOW VARIABLES LIKE '%optimizer_switch%';
SET @@optimizer_switch='mrr=on,mrr_cost_based=off';

4. MySQL 锁:先看全景,再看细节

mysql锁.png

锁这一块最容易记乱,所以更适合从三个层面看:

  1. 表级锁:例如 LOCK TABLES
  2. 元数据锁:也就是 MDL
  3. 行级锁:记录锁、间隙锁、临键锁等,主要由 InnoDB 提供

4.1 表锁与 MDL

显式表锁比较直接,例如:

1
2
LOCK TABLES t READ;
LOCK TABLES t WRITE;

这类锁会直接限制整张表的并发访问。

MDL 则不同。它不是手动加的,而是 MySQL 为了保护表结构一致性自动加上的。只要访问表,通常就会涉及元数据锁;执行 ALTER TABLECREATE INDEX 之类 DDL 时,也需要更强的 MDL

MDL 最麻烦的点在于:

  • 很多业务代码根本感知不到它的存在;
  • 一个长事务如果一直不提交,相关 MDL 可能会持续占着;
  • 后续 DDL 就可能长时间等待,最终表现成“改表卡死”。

因此,线上做 DDL 时,除了关注语句本身,也要关注有没有长事务占着相关对象。

4.2 行锁、当前读与意向锁

只有 InnoDB 支持真正意义上的行级锁。这里还要顺手补一个常见但经常被漏掉的知识点:当前读

  • 普通 SELECT:通常是快照读,依赖 MVCC
  • SELECT ... FOR UPDATE
  • SELECT ... FOR SHARE
  • UPDATE / DELETE

这些属于当前读,会读取最新版本,并在需要时加锁。

InnoDB 的行锁本质上锁的是索引记录,不是抽象意义上的“行对象”。因此:

  • 命中主键索引时,锁的是主键索引记录;
  • 命中二级索引时,先锁二级索引记录,再根据主键回到聚簇索引;
  • 如果没有合适索引,可能会扫描大量记录并加锁,效果上接近“把整张表都锁住了”,但底层实现仍然是沿着索引记录逐步加锁,而不是简单退化成 MyISAM 式表锁。

意向锁也值得一起记住:

  • IS:意向共享锁
  • IX:意向排他锁

它们是表级锁,用来表达“我接下来准备对某些行加什么类型的锁”,主要服务于多粒度锁协调。FOR SHARE 会涉及 ISFOR UPDATE 会涉及 IX

4.3 共享锁与排他锁

共享锁(S 锁)允许事务读取数据,但不允许别的事务对这些记录加排他锁修改。

排他锁(X 锁)则更强:拿到它的事务既可以读,也可以改,其他事务不能再对同一记录加共享锁或排他锁。

可以把它们简单记成:

  • S 锁:大家能一起读,但不能有人改
  • X 锁:只有我能动,别人读写都得等

4.4 间隙锁

间隙锁(Gap Lock)锁的不是现有记录本身,而是索引记录之间的空隙。它的目标不是防止别人改已有行,而是防止别人往这个范围里插入新值。

这也是它和记录锁最大的区别。

关于间隙锁,有几个关键前提:

  1. 它主要出现在 InnoDB 的锁定读、UPDATEDELETE 等当前读场景。
  2. REPEATABLE READ 下更常见,因为它要配合防止幻读。
  3. READ COMMITTED 下,间隙锁大幅减少,但在外键检查和唯一键冲突检查等场景仍可能出现。

如果是唯一索引上的等值查询,并且目标记录存在,通常只需要记录锁,不需要额外的间隙锁。

例如:

1
SELECT * FROM t WHERE id = 10 FOR UPDATE;

如果 id 是唯一索引且值 10 存在,通常只会锁住这条索引记录本身。

再看一个典型的范围效果:

1
SELECT * FROM t WHERE number = 6 FOR UPDATE;

如果 number 是普通索引,InnoDB 很可能不仅锁住满足条件的记录,还锁住相关间隙,目的是阻止其他事务把新的记录插入到这个范围里,或者把别的记录修改成会落入这个范围的值,从而避免同一事务里两次读取结果集不一致。

间隙锁.png

4.5 临键锁

临键锁(Next-Key Lock)可以看成:

1
记录锁 + 记录前面的间隙锁

它是 InnoDB 在可重复读下防止幻读的重要手段。常见描述里会把它理解成左开右闭区间,这个记忆方式在很多例子里是成立的。

几个常见结论如下:

  1. 加锁的基本单位通常是临键锁,而不是单纯的记录锁。
  2. 唯一索引等值命中且记录存在时,临键锁可能退化为记录锁。
  3. 唯一索引范围查询,会一直锁到第一个不满足条件的值。
  4. 普通索引的等值或范围查询,往往更容易带出间隙锁。

如果查询条件没有可用索引,InnoDB 可能不得不扫描并锁住大量记录,虽然最终只保留满足条件记录上的锁,但扫描过程中的加锁与释放本身也会带来明显开销。


5. 聚簇索引

这里需要先纠正一个常见写法:标准叫法是聚簇索引,不是“聚族索引”。

聚簇索引不是一种独立的索引类别,而是一种数据和索引的组织方式。在 InnoDB 里:

  • 主键索引通常就是聚簇索引;
  • 聚簇索引的叶子节点直接保存整行数据;
  • 二级索引的叶子节点保存的是二级索引列值和主键值。

所以,很多“回表”本质上就是:

  1. 先走二级索引找到主键值
  2. 再拿主键值去聚簇索引里找到完整数据行

InnoDB 对聚簇索引的选择顺序也很固定:

  1. 如果定义了主键,就用主键做聚簇索引;
  2. 如果没有主键,就选择第一个所有列都为 NOT NULL 的唯一索引;
  3. 如果还没有,就生成一个隐藏的 6 字节行 ID。

聚簇索引的优点主要在于:

  1. 主键查找通常很快,因为索引叶子页就是数据页;
  2. 按主键范围访问时,数据局部性更好;
  3. 某些场景下可以减少一次额外 I/O。

代价同样很明显:

  1. 一张表只能有一个聚簇索引;
  2. 主键过长,会让所有二级索引都变胖,因为二级索引叶子要存主键值;
  3. 插入顺序如果和主键顺序严重不一致,页分裂和碎片问题会更明显。

因此,给 InnoDB 设计主键时,通常会优先考虑:

  • 稳定
  • 单调递增或整体有序

6. InnoDB 与 MyISAM

InnoDB 是当前 MySQL 默认、也是最主流的事务型存储引擎;MyISAM 更像是历史包袱里仍然保留的一种非事务引擎。

从版本历史上看,MyISAM 在早期 MySQL 中长期扮演默认引擎角色,而从 MySQL 5.5 起,默认引擎已经切换为 InnoDB。放到今天的大多数业务系统里,优先级几乎总是 InnoDB 在前。

6.1 事务与并发控制

  • InnoDB:支持事务、崩溃恢复、MVCC、行级锁
  • MyISAM:不支持事务,不支持 MVCC,锁粒度是表级

这意味着在并发读写场景下,InnoDB 的整体能力通常明显更强。

6.2 锁模型

  • InnoDB:以索引记录锁为核心,支持记录锁、间隙锁、临键锁等
  • MyISAM:主要是表锁,读写互斥更明显

所以在写入频繁的业务里,MyISAM 很容易成为瓶颈。

6.3 缓存与存储组织

  • InnoDB:缓冲池既缓存数据页,也缓存索引页
  • MyISAMkey_buffer 主要缓存索引,数据页缓存主要依赖操作系统

在物理组织上:

  • InnoDB 的主键索引就是聚簇索引,数据和主键索引组织在一起
  • MyISAM 没有聚簇索引,索引和数据分离

6.4 外键、全文索引与行数统计

  • 外键:InnoDB 支持,MyISAM 不支持
  • 全文索引:MyISAM 很早就支持,InnoDB 从 MySQL 5.6 起支持
  • COUNT(*)
    • MyISAM 对无 WHERE 的整表行数统计通常更直接
    • InnoDB 不维护精确总行数,整表 COUNT(*) 往往需要扫描

6.5 磁盘文件与使用建议

MyISAM 在磁盘上主要使用 .MYD 数据文件和 .MYI 索引文件;而 InnoDB 则通过表空间管理数据和索引,可能是共享表空间,也可能是独立表空间。

如果只从今天的工程实践出发,可以直接给出一个偏实用的结论:

  1. 绝大多数 OLTP 业务优先选择 InnoDB
  2. 只有在少量历史系统、只读或近似只读场景、兼容特殊工具链时,才会继续碰到 MyISAM
  3. 不要再把“查询多就用 MyISAM,写入多就用 InnoDB”当成通用结论;这个说法在当前版本和现代业务负载下已经过于粗糙。