数据库进阶:索引机制、事务与锁管理
索引机制
在数据库基础里,我们先确定一行代表什么、表之间怎样联系;SQL 基础则让我们能够表达查询需求。数据量变大以后,同一条 SQL 可能需要检查大量记录,返回很少的结果却花很长时间。索引为这类查询提供了更合适的访问路径。这里暂时以 MySQL 为例,讲解索引机制如何设计,如何工作。
索引基础
先理解索引用来解决什么问题
假设我们要从大量订单中找出订单 1001,或者找出客户 42 的所有订单。数据库需要先确定哪些记录符合条件,再读取查询要求的字段。如果没有合适的查找结构,就可能需要逐行检查整张表。
索引是一种帮助数据库查找记录的数据结构。 它把用于检索的列值按一定规则组织起来,让数据库能够定位某个值或一段范围。建立索引的列或列组合称为索引键;例如,为 customer_id 建索引时,数据库会根据客户 ID 的值组织和查找索引记录。
MySQL 的存储引擎负责数据与索引的存放、读取和维护。下面使用 InnoDB 存储引擎,先把 B+ 树的结构与查找过程讲清楚,再看不同类型的索引怎样保存和读取数据。
B+ 树怎样组织索引记录
InnoDB 中,这里讨论的常规索引使用 B+ 树结构。B+ 树是一种平衡的多路查找树:“多路”表示一个节点可以指向多个子节点;“平衡”表示所有叶子节点位于同一层,查找不同键时不会因为树的一侧特别深而多走很多层。
先认识几个位置:
- 根节点是整棵树的查找入口。
- 内部节点是保存导航信息的节点,包含用于判断查找范围的键,以及对应子节点的位置。非叶子的根节点也属于内部节点。
- 叶子节点位于树的最底层,保存索引记录。这些记录中具体有哪些字段,后面再结合不同类型的索引说明。
在 InnoDB 中,节点对应数据页,默认索引页大小为 16KB。一个页可以保存多条记录或多条导航信息,因此访问一个节点并不等于只检查一个键。InnoDB 索引页的结构
下面用一棵只画出少量键的 B+ 树说明查找过程。内部节点用分界值表示子页的查找范围,叶子页暂时只展示索引键:
graph TD
R["根节点:分界键 50"]
A["内部节点:分界键 30"]
B["内部节点:分界键 70"]
L1["叶子页 A:10、20"]
L2["叶子页 B:30、40"]
L3["叶子页 C:50、60"]
L4["叶子页 D:70、80"]
R -->|小于 50| A
R -->|不小于 50| B
A -->|小于 30| L1
A -->|不小于 30| L2
B -->|小于 70| L3
B -->|不小于 70| L4
图中省略了叶子页之间的连接。它们按键值顺序连接成 A ↔ B ↔ C ↔ D,每个叶子页内的记录也按键值有序组织。内部节点负责找到页,叶子层的连接则让我们能够继续读取相邻的索引记录。
B+ 树怎样完成等值与范围查找
先查一个确定的键值 60。从根节点开始,60 不小于分界值 50,进入右侧内部节点;接着,60 小于分界值 70,进入叶子页 C;最后在该页中找到键值 60 对应的索引记录。
这条路径可以写成:
1 | 查找 60:根节点 → 右侧内部节点 → 叶子页 C → 对应的索引记录 |
查找时,内部节点中的键用于导航,最终仍需要到叶子节点取得对应记录。即使目标键恰好也出现在内部节点中,它在那里承担的也是划分范围的作用。
再查 30 ≤ 键值 ≤ 60 这个范围。先按同样的方式定位起点 30,找到叶子页 B;读取该页中的 30、40 后,沿叶子页连接进入 C,继续读取 50、60,到达范围终点后结束。这些记录位于相邻的叶子页中,可以沿叶子层继续扫描。
这里还有两个层次的定位:B+ 树的目录负责找到哪一个页,页内的结构负责找到哪一条记录。InnoDB 的页目录保存记录组的位置,能够先定位记录组,再在组内查找;不需要把页内每一个键都当成独立的磁盘读取。从数据页理解页内查找
为什么选择 B+ 树,以及它的读取成本
我们希望索引能快速找到一个值,也能高效取得一个范围。B+ 树的优势可以结合前面的路径理解:
| 结构特点 | 对查找的影响 |
|---|---|
| 一个内部节点包含多个键和子页位置 | 分支较多,同样的数据量通常可以用较少的层级组织。 |
| 内部节点主要保存导航信息,记录集中在叶子层 | 相同大小的内部页可以容纳更多导航信息。 |
| 叶子页按键值顺序连接 | 定位范围起点以后,可以继续扫描相邻的索引记录。 |
内部节点仍保存导航键和子节点位置,只是不保存用于返回查询结果的整行记录。增删也有维护成本:插入可能触发页分裂,删除后可能合并利用率较低的页,相关调整有时会传递到父节点。维护成本取决于写入和页状态,不能仅根据导航键存在冗余,就推断增删一定更便宜。
二叉搜索树的每个节点最多只有两个子节点,数据量变大时通常需要更多层。传统 B 树的内部节点也可以保存数据项;B+ 树把用于取得结果的记录放在叶子层,内部节点更适合集中做导航。Hash 则通过散列函数组织查找,主要适合等值查询,缺少按键值排列的结构,不能直接提供相同的范围扫描和排序能力。这些差异也可以结合查找与排序算法理解。
但“访问了几个节点”和“发生几次磁盘读取”需要分开看。InnoDB 用 Buffer Pool 缓存数据页和索引页:页已经在这个内存区域中,就可以直接访问;不在缓存中,才需要读取相关页。因此,较低的树高可以减少定位过程中需要访问的页,但并不是一定会更快。实际查询成本还包括定位之后扫描了多少索引记录,后面讨论二级索引与回表时,会继续沿着这条路径分析。
到这里,我们已经知道索引怎样按键查找、怎样扫描一个范围,以及增删可能带来的结构调整。下面再看 B+ 树与表的数据怎样对应,就能区分聚簇索引、二级索引,以及后面的回表和覆盖索引。
索引的分类分别在描述什么
B+ 树解释了索引键怎样组织和查找。接下来,还要区分索引保存什么内容、承担什么约束,以及由几列组成。同一个索引可以同时符合多种分类:
| 分类角度 | 常见名称 | 名称的含义 |
|---|---|---|
| 按数据结构 | B+ 树、Hash | B+ 树按键值组织有序的树形结构;Hash 通过散列函数组织等值查找。 |
| 按 InnoDB 的数据组织方式 | 聚簇索引、二级索引 | 聚簇索引的叶子记录保存整行数据;二级索引的叶子记录保存索引列值和主键,为其他字段提供查找入口。 |
| 按约束 | 主键索引、唯一索引、普通索引 | 主键要求唯一且非空;唯一索引限制重复键值;普通索引允许键值重复。 |
| 按键的列数 | 单列索引、联合索引 | 单列索引以一列为键;联合索引以多列的组合作为键。 |
| 按索引内容 | 完整列值、字符串前缀、表达式结果 | 可以索引整列的值,也可以只取字符串开头的一部分,或者索引表达式的计算结果。 |
例如,按 customer_id 建立的非唯一索引,可以同时是 B+ 树索引、二级索引、普通索引和单列索引。
聚簇索引怎样保存整行数据
InnoDB 把整行记录按索引键组织在 B+ 树的叶子节点中,这种直接组织数据行的索引叫作聚簇索引。 内部节点负责导航,叶子节点保存记录本身,所以沿这棵树找到叶子记录,就能取得该行的数据。
如果表声明了主键,InnoDB 就使用主键作为聚簇索引的键。主键规定一行记录的唯一身份,聚簇索引描述数据的存储组织方式;在 InnoDB 中,这两者因此建立了对应关系。
先准备一张独立的订单练习表:
1 | CREATE TABLE orders_index_demo ( |
PRIMARY KEY 声明订单 ID 为主键,INDEX idx_customer (customer_id) 声明一个按客户 ID 查找的索引。下面的示例用于说明索引中的内容与查询路径。
假设表中有四笔订单,聚簇索引叶子记录中的用户字段可以简化为:
| order_id | customer_id | status | ordered_at | total_amount |
|---|---|---|---|---|
| 1001 | 42 | paid | 2026-10-01 09:00:00 | 100.00 |
| 1002 | 43 | paid | 2026-10-01 08:00:00 | 120.00 |
| 1003 | 42 | pending | 2026-10-01 11:00:00 | 80.00 |
| 1004 | 42 | paid | 2026-10-02 10:00:00 | 90.00 |
这里用 order_id 组织记录,所以按订单 ID 查询时,数据库可以根据这个键逐层定位到相应的叶子记录,下面这条查找入口是主键值 1001,找到叶子记录后,客户、状态、时间和金额都在该记录中,可以直接读取:
1 | SELECT * FROM orders_index_demo WHERE order_id = 1001; |
InnoDB 每张表只有一个聚簇索引,因为整行数据以这一套键顺序组织。有显式主键时使用主键;没有主键时,选择第一个所有键列都非空的唯一索引;如果仍没有合适的索引,再生成隐藏的行 ID 来组织记录。聚簇索引的选择规则
二级索引怎样支持其他字段的查询
聚簇索引按订单 ID 组织数据,但我们也经常按客户 ID 查订单。同一客户的订单 ID 可能分散在不同位置,因此需要另一条按客户 ID 查找的路径。
二级索引是在聚簇索引之外建立的索引。 对这里的 idx_customer,索引键是 customer_id,叶子记录保存客户 ID 和对应的主键 order_id。上面的四笔订单,在这棵索引中可以简化为:
| customer_id | order_id |
|---|---|
| 42 | 1001 |
| 42 | 1003 |
| 42 | 1004 |
| 43 | 1002 |
因此,查客户 42 的订单时,可以先在二级索引中找到这个客户对应的范围,再取得该范围内的订单 ID。二级索引为同一批数据提供了另一种查找顺序。
对比两棵树:聚簇索引用订单 ID 导航,叶子记录保存整行;客户二级索引用客户 ID 导航,叶子记录保存客户 ID 和主键。这些主键值用于继续查找聚簇索引中的数据行。
从二级索引理解回表与覆盖索引
如果查询需要客户 42 的完整订单数据:
1 | SELECT * FROM orders_index_demo WHERE customer_id = 42; |
在使用 idx_customer 的情况下,数据库先取得订单 ID 1001、1003、1004。但金额、状态和时间没有保存在这个二级索引中,还要拿每个订单 ID 去聚簇索引中查找整行记录。通过二级索引取得主键,再访问聚簇索引获取所需数据,这一步叫作回表。
1 | customer_id = 42 |
如果查询只需要订单 ID,情况就不同了:
1 | SELECT order_id FROM orders_index_demo WHERE customer_id = 42; |
customer_id 用于过滤,order_id 用于返回,这两列都在客户二级索引中。当一个索引能够提供查询所需的列时,这条查询就可以使用覆盖索引,减少读取完整数据行的需要。 同一个 idx_customer,对只返回订单 ID 的查询可以覆盖,对需要金额的查询则不能覆盖。
至此,两种查询成本的区别就比较明确了:找到多少条索引记录,与取得这些记录之后还要不要回表,是两个需要分别观察的问题。后面的索引优化会继续使用这个区别。
联合索引怎样排列多个字段
前面的客户索引只用了一列。当查询同时关心客户、状态和时间时,可以把多个列的组合作为索引键,这就是联合索引。
一个 (customer_id, status, ordered_at) 联合索引,先按客户排序,同一个客户内再按状态排序,同一个客户和状态内再按时间排序。前面的订单数据按这组键排列,可以简化为:
1 | (42, paid, 2026-10-01 09:00:00) |
先给定客户 ID,就能找到这个客户对应的连续范围;再给定状态,就能在其中定位更小的一段;这两个值确定以后,该段记录按时间排列。
最左前缀原则正是由这种排序方式产生的:从索引最左边的列开始给定条件,才能按通常的方式逐步定位对应范围。只给定 status 时,相同状态分散在不同客户的范围里,通常无法直接使用同样的前缀定位方式。
什么时候需要索引
先看常用查询怎样访问数据
索引应当围绕查询和约束设计。只看到某一列出现在 WHERE 中,还不足以决定是否给它单独建索引。更有用的判断,是数据库能不能借助候选索引减少需要检查的记录、避免额外排序,或者少读取一些完整的数据行。
| 使用场景 | 值得考虑的索引 | 判断时需要同时观察什么 |
|---|---|---|
| 按订单 ID、业务编号查找 | 主键或唯一索引 | 是否真的要求唯一,是否经常按这个键查找? |
| 查询某个客户的订单 | 以客户 ID 开头的索引 | 每个客户通常有多少订单,查询还筛选什么? |
| 按客户、状态查询最近的订单 | 匹配过滤和排序的联合索引 | 能否缩小范围,能否按需要的时间顺序读取? |
| 表连接 | 被查找一侧连接字段上的索引 | 连接顺序、字段类型是否匹配,每次连接会命中多少行? |
| 分组、排序、最小值或最大值查询 | 与实际访问顺序匹配的索引 | 查询形态是否满足索引优化条件,是否仍需额外处理? |
| 只返回少量字段的高频查询 | 能覆盖这些字段的索引 | 减少读取整行的收益,是否足以抵消新增索引的成本? |
例如,“某个客户最近的已支付订单”同时涉及客户、状态、时间顺序和返回数量。单独给这几个字段各建一个索引,通常不能提供一个联合索引所维护的组合顺序。候选索引需要针对完整 SQL 来评估。
唯一索引还有另一类价值:阻止违反业务规则的重复写入。即使某个唯一键并不经常被查询,只要数据库需要维护这项约束,它仍然有存在的理由。这和纯粹为了加速查询建立的普通索引,要分别判断。
区分度需要结合数据分布理解
字段的区分度通常用不同值的数量与总记录数的比值来描述。客户 ID 往往比订单状态具有更多不同值,但真正影响一次查询成本的,是这条条件预计命中多少记录。
假设大部分订单已经完成,待处理订单只有很少一部分,后台又经常查待处理订单。即使 status 只有几个可能值,针对待处理状态的查询仍可能从索引中获益。反过来,一个有很多不同值的时间字段,如果查询覆盖绝大多数历史记录,也未必适合逐条通过二级索引回表。所以“低区分度字段不要建索引”“匹配超过某个固定比例就不走索引”都不适合作为通用规则。还要考虑返回字段、排序、LIMIT、缓存状态及优化器估算的成本。
把读取收益与维护成本放在一起
索引需要占用磁盘空间,也会占用缓存。插入和删除记录时,需要维护相应索引;更新索引列时,也可能改变索引记录的位置。
小表、需要处理大部分记录的报表查询、很少使用的过滤条件,以及已有索引能够满足的查询,新增普通索引的收益可能有限。频繁变化的字段也要比较读写成本:订单状态虽然会更新,但后台查询可能非常依赖它,仍然可能值得索引。决定保留多少索引,应看实际查询负载,而不是给每列都准备一个。
索引应该如何优化
按查询组织联合索引
继续看这个订单列表查询:
1 | SELECT order_id, ordered_at, total_amount |
可以评估下面这个候选索引:
1 | CREATE INDEX idx_customer_status_time |
客户和状态是等值条件,它们确定一段记录;在这段记录内部,时间有序,可以继续限制时间范围,并从较新的记录向较旧的记录读取。与只按客户定位后再筛状态、再排序相比,这个索引提供了更贴合查询的路径。“等值条件在前,范围或排序字段随后”是这类查询的一个设计起点,还要结合其他 SQL 判断顺序。
分清减少扫描与减少回表
上面的列表还需要 total_amount,它不在候选二级索引中,匹配记录仍需要回表。如果列表只返回订单 ID 和时间,可以改成:
1 | SELECT order_id, ordered_at |
这里的候选联合索引能够覆盖这条查询:客户、状态和时间列可用于过滤,时间列和主键可用于返回,所需字段都能由索引提供。二级索引已经保存主键,因此仅为返回 order_id,不必再把它重复加入索引定义。覆盖可以减少读取完整记录的需要,不过在某些事务可见性检查场景中,InnoDB 仍需要访问聚簇索引。
另一个优化是索引条件下推(Index Condition Pushdown,ICP)。假设候选索引换成 (customer_id, ordered_at, status),下面讨论的是这一排列下的查询路径:
1 | SELECT order_id, total_amount |
在这组键的顺序下,客户等值条件和时间范围可以用于定位扫描区间;后面的状态通常不能继续把这个时间区间缩成一个更小的连续区间。但状态仍在索引记录里,如果查询满足 ICP 的适用条件,存储引擎可以先检查状态,再为通过检查的记录读取整行,从而减少回表。覆盖索引解决的是所需字段能否直接由索引提供;ICP 解决的是需要读取整行时,能否先在索引中排除一些记录。它们都可能减少读取成本,却不一定减少需要遍历的索引记录数。
用 EXPLAIN 检查实际访问路径
前面的索引只是候选方案,优化器最终采用什么路径,需要看执行计划:数据库准备用哪个索引、预计检查多少记录,以及是否需要额外排序。可以在订单查询前加上 EXPLAIN,这里指定传统表格格式:
1 | EXPLAIN FORMAT=TRADITIONAL |
先观察三个字段即可:
| 字段 | 主要看什么 |
|---|---|
key |
优化器实际选择的索引,是否与候选方案一致。 |
rows |
预计需要检查的记录数,是否仍要经过很大的范围。 |
Extra |
查询还使用了哪些处理方式,例如覆盖索引、索引条件下推或额外排序。 |
Extra 中的 Using index 表示索引覆盖了所需列,Using index condition 表示使用 ICP,Using filesort 表示需要额外排序,它们分别对应前面讨论的不同成本。
rows 是估算值,不能当作实际扫描行数或查询耗时。练习表数据很少时,优化器也可能选择全表扫描;评估索引效果应使用接近实际规模和分布的数据,而不是只看索引是否存在。EXPLAIN 的字段说明
分页应该如何设计
分页查询需要同时考虑列表的使用方式和数据库的访问路径。每页只返回 20 条,并不意味着数据库只处理了 20 条。页面是否允许任意跳转、是否显示精确总数,也会影响后端应该怎样查询。
先确定排序与接口约定
继续使用订单练习表,列表按下单时间从新到旧排列。如果只写 ORDER BY ordered_at DESC,时间相同的订单之间没有确定的顺序,换一个执行计划就可能改变它们的先后。可以补上唯一的订单 ID,写成 ORDER BY ordered_at DESC, order_id DESC,让每笔订单都有明确的位置。前面的 (customer_id, status, ordered_at) 索引可以继续作为候选。在 InnoDB 中,二级索引还携带主键 order_id,优化器能够利用这个索引扩展处理查找与排序;客户和状态固定后,可以按时间、订单 ID 的顺序读取。
接口层也要确定翻页约定。需要输入页码直接跳到某一页的后台列表,可以使用页码和页大小;连续向下浏览的订单流,可以使用上一页留下的游标。服务端应限制页大小,并与前端约定返回哪些字段、是否提供总数。若页面只需要“还有下一页”,通常不必每次都计算精确总数。
OFFSET 分页为什么越往后越贵
传统页码分页使用 LIMIT offset, page_size。页码从 1 开始时,offset = (page_no - 1) × page_size。假设客户 42 已有足够多的已支付订单,下面取跳过 50000 条后的 20 条:
1 | SELECT order_id, ordered_at, total_amount |
OFFSET 表示跳过多少条匹配结果,不是一个可以直接定位的索引键。 B+ 树能按键值找到起点,却不能直接把“第 50001 条匹配结果”转换成一个键。在常见的有序扫描路径下,这条查询要经过约 50020 条匹配记录,再丢弃前 50000 条。差距主要体现在这些被跳过的记录上:需要遍历更多索引项、访问更多页;当返回字段不在二级索引中时,还可能为最后不会返回的记录读取整行。即使页已经在缓存中,扫描与处理也需要 CPU 时间。
用覆盖索引延后读取整行
必须保留任意页码跳转时,可以先通过索引取出这一页的主键,再读取需要展示的字段。这种做法通常称为延迟关联,也可以理解为延后回表:
1 | SELECT o.order_id, o.ordered_at, o.total_amount |
内层所需的客户、状态、时间和主键,都能由候选索引提供,可以先扫描较窄的索引记录;外层再按这一页的主键读取金额等字段。内层的 LIMIT 使它不能直接与外层合并为同一个查询块,外层也必须显式声明排序,不能依赖内层结果的返回顺序。
这里主要减少的是读取完整记录的成本。内层仍然要跨过前面的 50000 条匹配结果,因此偏移量很大时,扫描成本依然存在。若原查询已经由索引覆盖,或者偏移量很小,增加一次关联未必更划算。
用游标从上一页末尾继续查询
连续翻页可以改用游标分页(Keyset Pagination):保存上一页最后一条记录的排序键,下一页从这个键之后继续查找。这里的游标是应用保存的分页位置。
假设上一页最后一条订单的时间是 2026-10-02 10:00:00,订单 ID 是 1004。按时间、订单 ID 都降序的规则,下一页需要时间更早的订单,或者时间相同、ID 更小的订单:
1 | SELECT order_id, ordered_at, total_amount |
时间与 ID 共同构成翻页边界。只记住时间,会漏掉同一时间下尚未返回的订单;只写 order_id < 1004,又不能表达这个按时间优先排列的列表。使用严格的小于条件,才能排除上一页已经返回的末尾记录。这样的复合边界需要与索引顺序一起检查,确认优化器确实形成了合适的范围访问。
每页展示 20 条时,可以多取一条:取得 21 条,就返回前 20 条,并设置 has_more = true;不足 21 条,就没有更多记录。有下一页时,next_cursor 应取实际返回的最后一条,也就是第 20 条记录的时间与 ID,不能取那条只用于探测的第 21 条,否则会跳过它。第一页省略游标边界,后续请求携带游标和相同的筛选条件;筛选或排序改变后,应重新开始分页。在合适的索引和执行计划下,游标条件让数据库从边界附近继续扫描,不必反复遍历之前的所有页。
如果每页都在独立事务中查询,游标不会自动把整个列表固定在同一份快照中。例如,上一页已经返回订单 1004,游标保存它的时间 2026-10-02 10:00:00;如果这笔订单的下单时间随后被改成同一天的 09:00:00,下一页仍使用旧游标,它就可能再次落入查询范围,造成重复。未返回的订单若被移到游标之前,也可能在后续翻页中被遗漏。
因此,排序字段应尽量保持稳定,接口也要约定列表是随数据实时变化,还是要求读取固定的一组结果。游标减少的是从头跳过记录的成本,不会自动提供跨多次请求的一致快照。
| 分页方式 | 主要读取成本 | 更适合的需求 |
|---|---|---|
| OFFSET 分页 | 偏移量越大,需要经过的记录通常越多。 | 浅页查询、任意页码跳转。 |
| 延迟关联 | 仍扫描偏移量对应的索引记录,主要减少提前读取整行。 | 需要跳页,且完整记录读取成本较高。 |
| 游标分页 | 用排序键定位边界,再继续读取本页,额外过滤仍可能扩大扫描。 | 连续翻页、批量遍历。 |
事务
索引帮助数据库找到记录,事务则要解决另一组问题:几次修改怎样一起完成,多个事务同时读写时,又分别能看到哪些数据。下面以 InnoDB 为例,从转账开始,再逐步理解隔离级别、历史版本与幻读。
从转账理解事务与 ACID
为什么要把两次修改放在一起
假设 accounts_tx_demo 是一张账户表,account_id 是主键,balance 保存余额。账户 1 有 1000 元,账户 2 有 500 元,现在要从账户 1 向账户 2 转账 100 元。
数据库需要先让账户 1 减少 100,再让账户 2 增加 100。如果两次修改分别提交,第一步完成后程序中断,就可能留下“已经扣款,但没有入账”的结果。我们希望把它们作为一个整体:全部确认成功后提交;决定放弃时,撤销这组尚未提交的修改。
事务(Transaction)就是这样一个共同提交或回滚的工作单元。 账户存在、余额充足且两次更新都成功时,转账的基本形式可以写成:
1 | START TRANSACTION; |
START TRANSACTION 开始事务,COMMIT 提交其中的修改,成功后的余额为 900 和 600。若在提交前决定放弃,可以执行 ROLLBACK,撤销本轮尚未提交的修改。下面的并发案例彼此独立,讨论余额时都重新从 1000 开始。
ACID 分别描述什么
事务的四项性质简称为 ACID,继续用转账来理解:
| 性质 | 含义 | 对应转账中的要求 |
|---|---|---|
| 原子性 Atomicity | 事务中的修改作为整体提交或回滚。 | 扣款、入账共同完成,放弃时一起撤销。 |
| 一致性 Consistency | 操作前后符合数据约束与业务规则。 | 无手续费时,总余额仍为 1500,两边变动金额相同。 |
| 隔离性 Isolation | 规定并发事务之间的可见性与相互影响。 | 其他请求同时查询或修改余额时,遵循确定的读取和冲突处理规则。 |
| 持久性 Durability | 已成功提交的结果在故障恢复后能够保留。 | 数据库重启后,已经确认的转账结果仍然存在。 |
一致性需要正确的业务逻辑。例如,把“扣 100、加 80”放进事务并提交,仍会得到错误的转账结果,事务不能替程序判断金额是否合理。隔离性要处理的是多个事务交错读写时的相互影响。持久性强调故障恢复后能够保留已提交的结果。实现时可以先可靠持久化必要的日志,即使相关数据页尚未全部写回数据文件,也能通过日志恢复;下一节再介绍这两者的关系。
undo log 与 redo log 怎样支撑事务
undo log:撤销修改与重建旧版本
undo log(回滚日志) 保存撤销修改所需的信息。例如,把余额从 1000 改成 800 时,需要保留恢复旧值所需的内容;事务回滚时,InnoDB 可以据此撤销修改。
它还有一个与隔离有关的用途:其他事务可能需要读取修改前的余额,InnoDB 可以通过 undo 信息重建历史版本。因此,undo 不仅服务于回滚,也为后面介绍的多版本读取提供基础。
redo log:记录修改以便故障恢复
前面提到,数据页会缓存在 Buffer Pool 中。修改先发生在内存页里,数据文件可能尚未写入最新内容;如果此时断电,仅保存在内存中的修改就可能丢失。
redo log(重做日志)记录数据页的修改信息。将必要的日志可靠持久化后,数据库可以在重启时重放这些修改,恢复尚未完整写入数据文件的内容。数据页随后逐步写回,不必每次提交都把涉及的页全部刷完,这种先持久化日志、再写回数据页的做法叫作 WAL(Write-Ahead Logging,预写式日志)。
两种日志分工不同:undo 支持撤销与历史版本,redo 支持恢复数据修改。
并发读取的问题与四种隔离级别
脏读、不可重复读与幻读
一个事务正在修改数据时,另一个事务可能也在读取。先区分三种现象:
| 现象 | 关注的问题 | 例子 |
|---|---|---|
| 脏读 Dirty Read | 读到了其他事务尚未提交的修改。 | B 将余额改成 800,尚未提交,A 已经读到 800;B 随后回滚。 |
| 不可重复读 Non-repeatable Read | 同一事务重复读取同一记录,值因其他事务的提交而改变。 | A 先读到 1000;B 改成 800 并提交;A 再读到 800。 |
| 幻读 Phantom Read | 同一事务重复按相同条件查询,匹配的记录集合发生变化。 | A 查询 account_id >= 3 时为空;B 插入账户 5 并提交;A 再查出现账户 5。 |
脏读关注修改有没有提交,不可重复读关注同一条记录的值,幻读关注哪些记录满足条件。幻读常表现为条数变化,但要理解的是匹配记录集合的变化,而不只是一个数量。
四种隔离级别限制了什么
InnoDB 支持四种隔离级别,默认是可重复读。这里先把快照理解为读取所依据的一份可见数据状态,后面再解释它怎样选择历史版本。
| 隔离级别 | 主要读取规则 |
|---|---|
| 读未提交 READ UNCOMMITTED | 普通查询可能读到其他事务尚未提交的修改,因此允许脏读。 |
| 读已提交 READ COMMITTED,简称 RC | 每条普通查询使用新的读取快照,读取已提交版本;前后查询可能得到不同值或记录集合。 |
| 可重复读 REPEATABLE READ,简称 RR | 同一事务的普通查询复用首次此类查询建立的快照,其他事务后续的提交不会刷新这份快照;自身写入另有可见性规则。 |
| 串行化 SERIALIZABLE | 施加更强的并发约束,使执行结果符合某种先后次序;显式事务中的普通读取也会加锁,冲突操作可能等待。 |
按 SQL 标准的异常划分,RC 避免脏读,RR 进一步避免不可重复读,SERIALIZABLE 还约束幻读。标准中的 RR 允许幻读,但 InnoDB 的 RR 对普通查询和锁定查询分别有处理办法,最后一节再具体讨论。
隔离规则越强,通常需要更多并发约束,但不能把串行化理解成整个数据库只许一个事务运行。接下来先看 RC 和 RR 怎样通过多版本读取,使普通查询可以在其他事务修改记录时继续获得合适的内容。
MVCC 怎样通过版本链与 Read View 选择数据
版本链保存了什么
MVCC(Multi-Version Concurrency Control,多版本并发控制) 通过记录的不同版本和可见性规则处理并发读取。在 RC、RR 下,普通查询能够读取需要的历史内容,而不必仅因为另一个事务正在更新,就等待它结束。
聚簇索引的叶子记录保存整行数据,其中还有两项与事务有关的隐藏信息:
DB_TRX_ID:标识最后插入或修改这条记录的事务。DB_ROLL_PTR:指向 undo 记录的回滚指针,可以据此重建修改前的版本。
例如,余额由不同事务先后从 1000 改成 800,再改成 750。当前记录与重建出的历史版本,可以用下面的逻辑链条表示:
graph LR
V108["当前记录:余额 750,写事务 ID 108"]
V103["历史版本:余额 800,写事务 ID 103"]
V90["历史版本:余额 1000,写事务 ID 90"]
V108 -->|通过 undo 重建| V103
V103 -->|通过 undo 重建| V90
图中画的是版本之间的关系,undo 并不一定保存一份完整行副本,而是保存重建旧内容所需的信息。
Read View 怎样判断可见性
Read View(读视图) 保存建立视图时的事务状态,用来判断哪些写入版本对这次读取可见。它不是把整张表复制一份。
下面用四个名字表示视图中的事务信息和可见性边界:
| 信息 | 含义 |
|---|---|
creator_trx_id |
视图所属事务的 ID,用于识别自身修改。 |
m_ids |
建立视图时,其他仍活跃、尚未提交的读写事务 ID 集合。 |
min_trx_id |
活跃集合的最小 ID;集合为空时可与下面的上界相同。 |
max_trx_id |
建立视图时,下一个待分配的事务 ID,并非活跃集合的最大值。 |
检查某个版本的写事务 ID 时,按下面的规则判断:
- 当前事务自己的修改:对自身可见。
- 小于
min_trx_id:对应修改在视图建立前已提交,可见。 - 大于或等于
max_trx_id:这个 ID 在建立视图后才分配,对此视图不可见。 - 位于两条边界之间:在
m_ids中,表示建立视图时尚未提交,不可见;不在集合中,表示当时已提交,可见。
用具体版本走一遍判断过程
假设视图所属事务已经取得 ID 100,其他活跃写事务为 103、106,下一个待分配 ID 为 108:
1 | creator_trx_id = 100 |
读取前面的余额版本链时:
| 版本的写事务 ID | 余额 | 判断结果 |
|---|---|---|
| 108 | 750 | 达到视图上界,不可见。 |
| 103 | 800 | 在建立视图时的活跃集合中,不可见。 |
| 90 | 1000 | 小于视图下界,可见。 |
因此,这份视图读到 1000。即使事务 103 稍后提交,只要仍使用这份 Read View,它也仍然属于“建立视图时尚未提交”的事务,视图不会跟着别人的提交自动变化。
边界之间也不全是不可见版本。例如,ID 104 不在这个活跃集合中,由它写出的版本就可以被该视图读取。判断依据是建立视图时的事务状态,不是只看 ID 大小。
RC 与 RR 为什么会读到不同结果
区别在于 Read View 的创建时机
RC 和 RR 都可以沿版本链读取历史数据。这里的普通一致性查询是指它们按照可见性快照读取的普通 SELECT,通常也称为快照读。两种级别的主要区别是是否复用同一份 Read View:
- RC:每条普通一致性查询建立新的 Read View。
- RR:同一事务的普通一致性查询复用首次此类查询建立的 Read View。
START TRANSACTION 已经开始 SQL 事务,但 RR 的 Read View 通常要到首次普通一致性查询时才建立。如果其他事务在这次查询之前提交,第一次查询就可以看到它的结果。
从提交前后看两种读取结果
重新让账户 1 从余额 1000 开始。A、B 表示两个事务,A 分别在 RC、RR 下各做一轮,查询都是普通 SELECT:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| 1 | 开始事务,第一次查询余额,读到 1000。 | — |
| 2 | — | 开始事务,将余额改成 800,暂不提交。 |
| 3 | 再次查询:RC、RR 都读到 1000。 | 修改尚未提交。 |
| 4 | — | 提交事务。 |
| 5 | 再次查询:RC 读到 800,RR 仍读到 1000。 | — |
| 6 | 提交事务;新的事务再查询,可以读到 800。 | — |
第三步,B 尚未提交,RC 和 RR 都不能读取它的新版本,需要找到可见的旧内容。第五步,RC 建立新视图,B 已经提交,800 变得可见;RR 复用旧视图,仍把 B 的修改视为“建立视图时尚未提交”,所以继续读到 1000。
这就把隔离级别与前面的实现连起来了:版本链提供可读取的历史内容,Read View 判断可见性,视图的创建时机决定是否保持同一份读取依据。
快照读、当前读与幻读的边界
这一节先分开两个问题:A 能看到哪些记录,B 能不能插入新的记录。 前者涉及读取版本,后者涉及是否已经锁住相应范围。下面都按 InnoDB 的可重复读(RR)讨论,A、B 表示两个事务,各个示例分别从所说明的初始数据开始。
先用同一个账户区分快照读与当前读
先看账户 1,初始余额为 1000。A 开始事务,第一次普通查询读到 1000,建立了 Read View;随后 B 把余额改成 800 并提交。此时 A 的事务仍未结束。
A 可以使用两种查询方式:普通 SELECT 按已有 Read View 选择版本,叫作快照读;加上 FOR UPDATE 的查询读取当前可用的记录并取得相应锁,属于当前读。继续在 A 中执行:
1 | -- 快照读 |
| A 的查询 | 读到的余额 | 为什么 |
|---|---|---|
普通 SELECT |
1000 | 旧 Read View 不包含 B 后来提交的修改,沿版本链读取旧值。 |
SELECT ... FOR UPDATE |
800 | 按当前读取规则取得 B 已提交的记录,并锁住它。 |
再次普通 SELECT,A 尚未修改该行 |
1000 | 锁定读不会刷新原来的 Read View。 |
两种查询只是读取依据不同。快照读关注这份视图允许读哪个版本;当前读关注现在可用的记录,并保护后续依赖它的操作。如果其他事务还在修改该记录,当前读可能需要先等待那个事务结束。
快照读看不到新行,但不会阻止别人插入
余额变化是同一条记录的值变化。幻读关注的是结果里多出了此前没有的匹配记录:同一事务前后按相同条件查询,另一个事务插入并提交了符合条件的新行,后一次查询把它读出来了。 现在重新开始一轮,表中只有账户 1、2,A 想查询编号不小于 3 的账户:
1 | SELECT account_id |
按下面的顺序执行:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| 1 | 开始事务,执行普通查询,结果为空,建立 Read View。 | — |
| 2 | — | 插入账户 5,并提交成功。 |
| 3 | 再做相同的普通查询,结果仍为空。 | — |
| 4 | 把查询改为 SELECT ... FOR UPDATE,可以看到账户 5。 |
— |
第三步时,账户 5 已经存在,只是 A 的旧快照看不到它。普通查询并没有禁止 B 插入;它通过保持读取依据,避免 B 的新增记录改变本轮快照读的结果。
第四步切换成了当前读,账户 5 已经提交,因此可以被读取。这里切换了查询方式,不是相同读取方式突然丢掉了可重复读的保证。更关键的是,锁在 B 插入之后才取得,不能阻止一件已经完成的插入。
要阻止并发插入,需要提前锁住查询范围
如果 A 的要求是“检查完这个范围以后,到操作结束前,不允许别人在其中插入新账户”,就要改变上一轮的执行顺序。
重新恢复到只有账户 1、2 的状态,A 一开始就在事务内做范围锁定查询:
1 | START TRANSACTION; |
此时查询虽然为空,相关插入区间仍会受到保护。B 再尝试插入账户 5,过程是:
sequenceDiagram
participant A as 事务 A
participant DB as InnoDB
participant B as 事务 B
A->>DB: 开始事务,查询编号不小于 3 的账户(FOR UPDATE)
DB-->>A: 返回空结果,保留相应范围锁
B->>DB: 开始事务,尝试插入账户 5
Note over DB,B: 插入等待,账户 5 尚未插入成功
A->>DB: 再次按相同条件查询(FOR UPDATE)
DB-->>A: 结果仍为空
A->>DB: COMMIT,释放锁
DB-->>B: 插入继续执行并成功返回
B->>DB: COMMIT
这次 A 前后两次当前读都为空,是因为 B 的插入还没有完成。A 结束事务、释放锁后,B 才能继续插入并提交;新的事务随后就可以看到账户 5。
为什么没有查到记录也能加锁?因为需要保护的不只是已有记录,还有索引中可供新记录插入的位置。间隙锁限制这些位置上的插入,记录锁保护已有索引记录,next-key lock(临键锁) 结合记录与其前面的间隙来保护范围。
对照前一轮:快照读允许 B 插入,但 A 继续看旧快照;提前取得范围锁,则让 B 的插入等待。两种办法都能让本轮读取结果保持稳定,做到这一点的方式却不同。
自己修改后为什么又能看见这条记录
重新从只有账户 1、2 的状态开始。A 开始 RR 事务,第一次普通查询 account_id >= 3,结果为空,并建立 Read View。随后 B 插入账户 5,余额为 1000,并提交;A 尚未结束事务,再做相同的普通查询,仍然看不到账户 5。
现在 A 没有结束事务,而是执行:
1 | UPDATE accounts_tx_demo |
更新可以找到当前已经存在的账户 5;更新后的普通查询,也能返回账户 5,余额为 900。看起来是“原先看不到的记录出现了”,但这里要按两个动作分别理解:
UPDATE处理当前记录,所以能够修改旧快照看不到、但已经由 B 提交的账户 5。- 自己的修改对自己可见,所以后面的普通查询能够读取 A 自己改出的版本。
原来的 Read View 没有刷新,其他事务后来的提交也不会全部进入旧快照;只是自己的修改对自己可见。理解 InnoDB 的 RR 时,要区分三个前提:普通快照读复用既有视图;提前锁住范围的当前读限制该范围内的并发插入;切换读取方式或读取自身修改时,结果可能与先前的快照读不同。
锁管理
前面用 MVCC 解释了事务能看到哪些版本,也提到范围锁能够让插入等待。这里继续看锁本身:多个事务同时访问数据时,哪些操作可以一起进行,哪些操作必须等别人完成。
本章行级锁以 InnoDB 的可重复读(RR)为例;A、B 是不同会话中的事务,各个例子独立。
锁怎样协调并发访问
A 修改账户 1 后尚未提交,B 也要修改同一条记录。InnoDB 会用锁协调这两次修改:B 先等待,A 提交或回滚、释放锁后,B 再继续处理。
锁用来限制冲突操作能否同时进行。 对同一条索引记录,先区分两种加锁方式:
| A 已持有的锁 | B 申请共享锁 S | B 申请独占锁 X |
|---|---|---|
| 共享锁 S(Shared) | 可以同时持有。 | 需要等待。 |
| 独占锁 X(Exclusive,也称排他锁) | 需要等待。 | 需要等待。 |
共享锁允许多个事务以加锁方式读取同一记录,独占锁保护修改。这里的“读写互斥”指需要相应锁的操作,普通快照读仍可读取符合 Read View 的历史版本。
查询时,SELECT ... FOR SHARE 申请共享锁,SELECT ... FOR UPDATE 申请独占锁,UPDATE、DELETE 会自动为处理的数据取得独占锁。
例如,继续使用前面的账户表,A 执行到锁定查询后,先保持事务未结束:
1 | START TRANSACTION; |
B 的更新可能停在等锁这一步。在这个显式事务中,查询执行完并不等于锁已经释放;锁定读取得的锁会保留到事务提交或回滚。
按作用范围,锁又分为全局锁、表级锁和行级锁。S、X 描述兼容关系,后面这组名称描述作用范围,它们是不同的分类维度。
全局锁与四类表级锁
全局锁:让整个实例暂时停止写入
全局读锁可以通过 FLUSH TABLES WITH READ LOCK 取得,释放时使用 UNLOCK TABLES,持有锁的会话断开也会释放。它作用于整个 MySQL 实例,而不只是当前选择的某个数据库:普通查询仍可进行,数据修改和表结构变更会受到阻塞。
全库逻辑备份需要让各张表的数据对应同一份状态。假设账户表中,用户余额为 1000 元;库存表中,商品还有 10 件,每件 100 元。购买一件商品的事务会同时扣除 100 元、减少一件库存,提交后余额为 900 元,库存为 9 件。
如果备份没有使用一致性快照,也没有暂停写入,就可能出现下面的顺序:
| 顺序 | 发生的操作 | 本步结果 |
|---|---|---|
| 1 | 先备份账户表,此时购买尚未发生。 | 用户余额为 1000 元。 |
| 2 | 购买事务完成扣款、扣库存并提交。 | 数据库中的余额变为 900 元,库存变为 9 件。 |
| 3 | 再备份库存表。 | 商品库存为 9 件。 |
这份备份恢复后,会出现余额仍为 1000 元、库存却只剩 9 件的结果:商品已经减少,购买对应的扣款却不在备份里。购买事务本身一起提交了两次修改,问题出在备份先后读取了提交前、提交后的数据。
在备份期间持有全局读锁,可以阻止这些修改,让账户表和库存表保持在同一数据状态。代价是购买等业务的写入也需要等待备份释放锁。
表锁:限制整张表的访问
显式表锁通过 LOCK TABLES 取得,常见形式是:
| 形式 | 持锁会话 | 其他会话 |
|---|---|---|
LOCK TABLES accounts_tx_demo READ |
可以读这张表,不能写。 | 可以读,写入需要等待。 |
LOCK TABLES accounts_tx_demo WRITE |
可以读写这张表。 | 对这张表的读写需要等待。 |
持锁会话也不能随意访问未声明锁定的其他表。可以用 UNLOCK TABLES 显式释放这类表锁,会话断开时也会释放。LOCK TABLES 在尝试加锁前会隐式提交当前事务,因此不能把它当成普通行锁语句直接嵌入前面的转账事务。
表锁可能阻塞与目标记录无关的请求,InnoDB 的普通事务读写主要使用更细的行级锁。显式表锁的行为
元数据锁:保护表结构
元数据锁(Metadata Lock,MDL) 保护字段、索引等结构定义,避免一个查询还在读取,另一个会话却删掉了相关字段。访问表时会自动取得相应 MDL,普通数据读写通常兼容,与之冲突的结构变更需要等待。
例如,A 在显式事务中查询账户表后迟迟不结束事务,B 执行 ALTER TABLE 就可能等待。查询已经返回,MDL 通常仍保留到事务结束;结构变更排队后,后续查询也可能跟着等待。因此,前面说“普通 SELECT 不加行锁”,指的是它不锁住数据记录,并不表示数据库完全没有为这次查询加锁。
意向锁:说明表内存在怎样的行锁需求
申请整表锁时,需要知道表里是否有其他事务锁定记录,逐条检查的成本很高。
InnoDB 会在取得行锁前自动取得表级意向锁:意向共享锁 IS 表示共享行锁需求,意向独占锁 IX 表示独占行锁需求。比如 A 用 FOR UPDATE 锁定账户 1,会先取得表级 IX,申请整表锁的 B 可以据此判断冲突。
意向锁本身不会把整张表的数据都锁住。 A、B 都可以持有 IX,再分别修改不同的账户;是否等待,还要看它们申请的具体行锁是否冲突。
AUTO-INC 锁:协调自增编号的分配
字段声明为 AUTO_INCREMENT 后,并发插入需要协调自增值的分配。AUTO-INC 锁是服务于这一过程的特殊表级锁,它通常持有到当前插入语句结束,不必等到整个事务提交。
InnoDB 也提供更轻量的分配方式,由 innodb_autoinc_lock_mode 控制:
| 模式 | 基本处理方式 |
|---|---|
| 0,传统模式 | 插入使用表级 AUTO-INC 锁,持有到语句结束。 |
| 1,连续模式 | 能预先确定行数的普通插入通常只在分配编号时短暂协调;INSERT ... SELECT 等批量插入仍使用表级 AUTO-INC 锁。 |
| 2,交错模式 | 不使用表级 AUTO-INC 锁,允许多个插入语句交错分配编号。 |
这些模式在编号分配的可预测性和插入并发之间取舍。例如,在模式 2 下,自增编号从 1 开始分配,A 用 INSERT ... SELECT 批量插入两条记录,B 同时插入一条记录。可能出现这样的顺序:A 的第一条记录取得 1,B 取得 2,A 的下一条记录取得 3。
如果备库只重放两条 SQL,并先完整执行 A,再执行 B,A 的两条记录就可能获得 1、2,B 再取得后续编号,与原先的分配结果不同。这就是基于语句的复制不适合交错模式的原因。按行复制传递实际行内容,其中包含已经分配好的编号,因此不需要重现同样的分配时序。
行级锁怎样保护记录与间隙
InnoDB 的行级锁作用于索引记录及其间隙。没有显式主键时,也会选择或生成聚簇索引来加锁,不能只按最终返回几行理解锁的范围。
下面重新准备独立数据:账户编号为 1、3、7,account_id 是主键。
记录锁:保护已有的索引记录
记录锁(Record Lock) 只锁定某条索引记录。比如,账户 7 上的 X 型记录锁会让其他事务对它的更新、删除或锁定读取等待;但这个记录锁本身,不会禁止插入账户 4、5。
仅锁住已有记录,还不能限制往记录之间插入新账户。
间隙锁:限制记录之间的插入
间隙锁(Gap Lock) 保护索引记录之间的空隙,也可以保护第一条记录之前或最后一条记录之后的空隙。例如,锁住 (3, 7),就会阻止其他事务插入账户 4、5、6。
这里的圆括号表示不包含两端已有记录。间隙锁本身限制的是向这个位置插入新记录,不能把它理解为同时锁住账户 3、7。
不同事务可以同时持有同一间隙上的间隙锁,它们共同限制插入,彼此并不互斥。
临键锁:同时保护一条记录与它前面的间隙
临键锁(Next-Key Lock) 是记录锁与前方间隙锁的组合。例如,账户 7 上对应 (3, 7] 的 X 型临键锁,既保护账户 7,也限制往它前面的间隙插入新账户。
| 锁的形式 | 能否更新、删除已有账户 7 | 能否插入新账户 5 |
|---|---|---|
| 账户 7 的 X 型记录锁 | 需要等待。 | 这个锁本身不阻止。 |
间隙锁 (3, 7) |
这个锁本身不阻止。 | 需要等待。 |
X 型临键锁 (3, 7] |
需要等待。 | 需要等待。 |
临键锁用记录部分限制修改、删除,用间隙部分限制新增。普通快照读能看到哪些记录,则仍由 MVCC 决定。
SQL 与索引怎样决定加锁范围
唯一查找与范围扫描需要不同保护
仍使用账户 1、3、7。以下都是已开启事务中的锁定查询;另假设 balance 上建立了普通二级索引 idx_balance(balance)。根据查找方式和记录是否存在,可以区分几种常见情况:
| 查询方式 | 基本加锁思路 |
|---|---|
| 主键或唯一索引等值查询,目标存在 | 能唯一定位已有记录,通常只需记录锁。例如查询 account_id = 3。 |
| 主键或唯一索引等值查询,目标不存在 | 保护目标可能插入的间隙。例如查询 account_id = 4 为空,仍会保护 (3, 7)。 |
| 非唯一索引等值查询 | 要继续扫描同值记录,并保护相关间隙,避免后来再插入同值记录;没有匹配记录时,也需要保护相应插入位置。 |
| 唯一索引或非唯一索引范围查询 | 保护扫描涉及的索引记录和间隙,通常会用到临键锁;边界处可能作相应调整。 |
唯一索引找到完整唯一键后,就知道没有第二条同键记录。普通索引没有这个保证:找到一个余额为 1000 的账户后,还要扫描其他同值记录并限制新增。联合唯一索引只筛选部分列,也未必能唯一定位。
二级索引上的独占锁定,还会保护对应的聚簇索引记录。二级索引按“索引列值、主键值”组织,两个账户余额相同、主键不同,仍有先后位置,插入位置不能只看余额。核心原因是保护扫描涉及的记录与插入位置。
没有合适的索引,为什么可能影响整张表
假设 balance 没有索引,A 在 RR 下执行下面的更新,并保持事务未结束:
1 | START TRANSACTION; |
无法按余额定位目标时,只能扫描聚簇索引。即使最终只修改少数记录,扫描过程中仍可能锁住大量记录和间隙,阻塞整张表上的其他修改与插入。大量记录和间隙被锁住,会产生近似整张表都无法修改的效果,索引既影响查询成本,也影响加锁范围,要检查实际执行计划是否采用了合适索引。
INSERT 与插入意向锁
插入一条新记录时,需要保护新记录,也要确认插入位置能否使用。插入意向锁(Insert Intention Lock) 是为插入操作使用的一种特殊间隙锁,它表示事务准备在某个索引位置插入。
没有其他冲突时,A 插入账户 4,B 插入账户 5,位置不同,不会仅因处于同一间隙而阻塞;若另一事务已锁住整个 (3, 7) 间隙,两次插入都需要等待。
插入意向锁与表级 IS、IX 不同:前者用于具体插入位置,后者协调整表锁与行锁。
成功插入的记录需要独占保护。假设 A 插入账户 4,尚未提交;即使没有立即为这条记录创建显式锁结构,InnoDB 也能通过记录的事务信息识别它仍由 A 保护,这种方式称为隐式锁。
如果 B 随后尝试修改账户 4,数据库可以把这种保护转换为显式锁,让 B 等待 A 的事务结束。因此,查看锁信息时没有看到新增记录的锁结构,并不意味着其他事务可以随意修改它。主键、唯一索引的重复检查也可能让插入等待:冲突记录由未提交事务写入时,需要先等它结束,再判断本次插入能否成立。插入语句的锁与重复检查
从锁等待理解死锁
死锁是相互等待形成了循环
A 持有账户 1 的锁,B 等 A 释放,这只是一次锁等待。如果 A 可以正常提交,B 就能继续。
死锁则是另一种状态。比如,两个事务都要修改账户 1、3,却按相反顺序操作:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| 1 | 更新账户 1,持有它的独占锁。 | 更新账户 3,持有它的独占锁。 |
| 2 | 尝试更新账户 3,等待 B。 | 尝试更新账户 1,等待 A。 |
graph LR
A["事务 A:持有账户 1 的锁"]
B["事务 B:持有账户 3 的锁"]
A -->|等待账户 3 的锁| B
B -->|等待账户 1 的锁| A
A、B 都持有对方需要的锁,又都无法走到提交,这就是死锁(Deadlock)。这里满足四个条件:互斥、占有且等待、不可抢占、循环等待。
“先查不存在,再插入”为什么也会死锁
重新从账户 1、3、7 开始,account_id 是主键。A 想新增账户 4,B 想新增账户 5,它们都先用 FOR UPDATE 检查目标是否存在,查不到才插入。A、B 分别在自己的会话中执行 START TRANSACTION,使用 RR,并按下面的顺序操作:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| 1 | 执行 SELECT account_id FROM accounts_tx_demo WHERE account_id = 4 FOR UPDATE,结果为空。 |
— |
| 2 | 保持事务未结束。 | 执行 SELECT account_id FROM accounts_tx_demo WHERE account_id = 5 FOR UPDATE,结果为空。 |
| 3 | 执行 INSERT INTO accounts_tx_demo (account_id, balance) VALUES (4, 1000.00),等待 B 释放间隙锁。 |
保持事务未结束。 |
| 4 | 仍在等待 B。 | 尝试执行 INSERT INTO accounts_tx_demo (account_id, balance) VALUES (5, 1000.00),也需要 A 释放间隙锁。 |
第一步查询账户 4 时,记录不存在,A 会保护它可能插入的 (3, 7) 间隙。第二步查询账户 5 时,B 也会保护同一个间隙。两个间隙锁彼此兼容,所以两次查询都能成功返回,但这时 A、B 都持有尚未释放的间隙锁。
第三步,A 自己的间隙锁不会阻止自己的插入,但 B 也锁住了这个位置,因此 A 的插入请求要等 B。第四步同理:B 想插入账户 5,却要等 A 释放间隙锁。两个事务都不能先完成自己的插入、再提交释放锁,等待关系形成了循环。
开启死锁检测时,InnoDB 会选择其中一个事务回滚,另一方才能继续。这个例子说明,查询为空仍可能保护插入位置;间隙锁彼此兼容,插入请求却会与另一个事务的间隙锁冲突。
- 标题: 数据库进阶:索引机制、事务与锁管理
- 作者: Hyacehila
- 创建于 : 2026-10-07 00:00:00
- 链接: https://hyacehila.github.io//blog/2026/10/07/mysql-indexes-transactions-locking/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。