0%

在阐述 SQL 的优化方案之前,我们需要先了解 SQL 的执行流程,如下:

  1. 客户端发送 SQL 语句给 MySQL 服务器。

  2. 如果查询缓存打开则会优先查询缓存,如果缓存中有对应的结果,直接返回给客户端。不过,MySQL 8.0 版本已经移除了查询缓存。

  3. 分析器对 SQL 语句进行语法分析,判断是否有语法错误。

  4. 明确 SQL 语句的目的后,MySQL 通过优化器生成执行计划。优化器通过成本计算预估出执行效率最高的方式,基本的预估维度为:

  5. IO 成本:

    1. 数据量越大,IO 成本越高,所以要避免 select *;尽量分页查询。
    2. 尽量通过索引加快查询。
  6. CPU 成本:

    1. 尽量避免复杂的查询条件,如有必要,考虑对子查询结果进行过滤。
    2. 尽量缩减计算成本,比如说为排序字段加上索引,提高排序效率;比如说使用 union all 替代 union,减少去重处理。
  7. 执行器调用存储引擎的接口,执行 SQL 语句。

SQL 性能分析工具

如果要查询某一类 SQL 语句的执行频率,比如查看当前数据库的增删改查操作的访问频次,我们可以使用如下命令:

1
SHOW [GLOBAL | SESSION] STATUS LIKE 'Com_%';

慢查询日志记录了执行时间超过指定参数(long_query_time,单位:秒,默认 10 秒)的所有 SQL 语句。

慢查询日志默认不开启,所以我们需要在配置文件 /etc/my.cnf 中配置如下信息来启动慢查询日志:

1
2
3
slow_query_log = 1

long_query_time = 2 # 执行时间超过 2s 的操作被记录到慢查询日志当中

profile 详情(默认关闭),该功能可以帮助了解 SQL 语句耗时的部分。比如:

  • 查看每一条 SQL 的耗时基本情况:SHOW PROFILES;
  • 查看指定 query_id 的 SQL 语句各个阶段的耗时情况(或CPU 使用情况):show profile [cpu] for query query_id;
  • 查看是否支持 profile 操作:SELECT @@have_profiling;

如果需要开启 profile 操作,我们可以设置:SET profiling = 1;

EXPLAIN SELECT SQL语句; 可以获取 MySQL 如何执行 SELECT 语句的信息,包括 SELECT 语句执行过程中表如何连接和连接的顺序,也就是该 SQL 语句的执行计划的细节。

img

上图展示了 explain 语句的结果中的各个字段,这些字段的含义如下表:

列名 说明
id 表示查询中执行 SELECT 子句或操作表的顺序。
id 相同:按顺序从上到下执行。
id 不同:数值越大越优先执行。
select_type 表示 SELECT 的类型:
SIMPLE: 简单查询,不使用 JOIN 或子查询。
PRIMARY: 主查询,即最外层查询。
UNION: UNION 中第二个或后面的查询。
SUBQUERY: 在 SELECT / WHERE 后包含子查询。
DERIVED: 派生表的 SELECTFROM 子句中的子查询)。
table 查询的表名。
type 表示连接类型,性能由好到差:
system: 表只有一行,通常是系统表,速度最快。
const, eq_ref, ref: 使用索引查找单个行,const 最优。
range: 检索给定范围的行。
index: 遍历索引树读取。
ALL: 全表扫描,效率最低。
possible_keys 显示该表可能会使用的索引(一个或多个),但不一定真的被使用。
key 实际使用的索引;如果为 NULL,则未使用索引。
key_len 索引中使用的字节数,是索引字段的最大可能长度,并非实际长度;值越短越好。
ref 用于与索引列比较的值来源:
const: 常量(如 WHERE column = 'value')。
– 列名称:通常在 JOIN 操作中,表示 JOIN 条件依赖的字段。
NULL: 未使用索引或全表扫描。
rows 估算为得到结果集需扫描的行数,越少越好。
filtered 表示返回结果行数占扫描行数的百分比,越大越好。
Extra 其他信息:
using index condition / NULL: 查找使用了索引,但需回表查询数据。
using where, using index: 查找使用了索引,且所需数据都在索引列中,无需回表。
using temporary: 使用临时表存储中间结果。

示例:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE user NULL range PRIMARY PRIMARY 4 NULL 6 100.00 Using where

通过 explain 命令,我们能分析出一些慢 SQL 的常见原因:

首先是索引使用问题,我们可通过 possible_keys(预计使用的索引) 和 key(实际使用的索引) 两个字段查看 InnoDB 有没有使用索引,优化器是否选择了错误索引,以及有没有实现覆盖索引。

接着是 I/O 开销问题,通过 rows(执行当前查询要遍历的行数) 和 filtered(有效行数/扫描行数比值) 字段来查看,是否扫描的行数过多,是否返回无用列且无用列有明显 I/O 性能开销(比如text、blob、json 等类型)。

optimizer_trace 可用于跟踪执行语句的解析、优化、执行的全过程。

使用步骤:

  • 查看系统变量信息:show variables like '%optimizer_trace%';
  • 打开 optimizer trace 开关:set optimizer_trace="enabled=on";
  • 执行 SQL 语句。
  • 查看 INFORMATION_SCHEMA.OPTIMIZER_TRACE 表中跟踪结果:select * from INFORMATION_SCHEMA.OPTIMIZER_TRACE;,并分析执行树:
    • join_preparation:准备阶段;
    • join_optimization:分析阶段;
    • join_execution:执行阶段。

关闭该功能:set optimizer_trace="enabled=off";

定位慢 SQL

定位和优化慢 SQL 是提升 MySQL 性能的关键环节。通常可通过以下四大步骤完成:

  1. 慢查询日志:记录所有执行时间超过阈值的 SQL,并借助 mysqldumpslow 汇总分析;
  2. 服务监控:在应用层面通过字节码插桩、连接池或 ORM 拦截对慢 SQL 进行实时监控与告警;
  3. SHOW PROCESSLIST:在数据库层面查看当前运行的会话及其执行时间,快速锁定长时间运行的语句;
  4. EXPLAIN:对疑似慢 SQL 执行 EXPLAIN,洞察查询执行计划,从而发现索引缺失、全表扫描等根本原因。

img

索引优化

覆盖索引指的是当查询所需字段全部存在于索引叶节点时,数据库可以仅依赖索引而无需回表读取,显著降低 I/O 开销。因此我们需要避免不必要的列,只查询需要的列。

创建联合索引能使多个查询字段同时被索引覆盖,从而避免回表和索引合并操作,且应遵循最左前缀规则以确保索引被有效利用。

为了保持索引的可用性,应避免使用 !=<> 等非等值算符以及在索引列上应用函数,因为这些写法会导致索引失效,全表扫描或全索引扫描,从而影响性能。

当对较长字符串字段建立前缀索引时,可节省存储空间,但由于前缀索引无法存储完整值,MySQL 无法利用其实现排序或分组操作,依然可能触发 filesort 或临时表。

此外,InnoDB 默认启用索引下推技术(ICP),能将部分过滤条件下放至存储引擎层,仅在满足索引列过滤的记录上执行回表,从而减少数据传输量与回表次数,进一步提升查询效率。

假设我要执行如下命令:

1
SELECT * FROM users WHERE age > 30 AND city = 'Los Angeles';

在未启用索引下推时,InnoDB 存储引擎仅依据 age > 30 的索引范围扫描收集所有符合该条件的行,并将它们一股脑儿地返回给 MySQL 服务器层,由服务器再对 city = 'Los Angeles' 条件逐行筛选;而启用索引下推后,存储引擎会在索引扫描阶段同时评估 age > 30city = 'Los Angeles' 两个条件,只有同时满足的行才会被送到服务器层,从而避免了无谓的行回表和服务器级过滤,显著减少了 I/O 操作并提升了查询性能。实际执行时,若在 EXPLAIN 的 Extra 列中看到 Using index condition 即表示已启用这一优化。

join 优化

在实际生产环境中,为了避免子查询带来的性能瓶颈,我们通常将其改写为等价的 JOIN 操作,并让行数较少的小表首先驱动行数庞大的大表,从而缩小中间结果集,减少随机 I/O;同时可以在业务表中适当增加冗余字段,将频繁关联的维度信息直接存储在事实表中,以降低 JOIN 次数;为控制单次查询的复杂度,通常不超过三张表联合查询,如若业务允许,还可将逻辑复杂的多表 JOIN 拆分为多个简单查询,再在应用层按需合并结果,以实现更稳定高效的查询性能。

insert 优化

在大规模数据写入场景下,将多条记录合并到单条 INSERT 语句中(例如一次提交 500–1000 条)能显著减少网络往返与语句解析开销,性能远超逐行插入;同时,关闭 AUTOCOMMIT 并使用 START TRANSACTION…COMMIT 手动提交事务,可以将多次写操作合并在一次事务中,避免频繁的事务开启与提交,从而进一步提升吞吐量;对于尤其庞大的数据集,采用 LOAD DATA LOCAL INFILE 命令从客户端文件批量导入,可利用服务器端的高速流式加载机制,其速度通常比批量 INSERT 快一个数量级以上;最后,确保插入数据按照主键单调递增的顺序写入 InnoDB 表,可减少聚簇索引中的页分裂与随机 I/O,获得优于乱序插入的最佳写入性能。

主键优化

选择简短且固定长度的整型主键:主键长度越短,聚簇索引和二级索引的存储和缓存开销越小;建议使用 INT 或 BIGINT 类型,并尽量避免使用 UUID 等无序且长度较长的值。

采用自增(AUTO_INCREMENT)主键:自增主键保证插入顺序与唯一性,能够最大化页的填充率并避免频繁的页分裂;只要满足业务需求,应优先使用自增主键而非自然主键(如身份证号)。

避免修改主键值:主键一旦更新,InnoDB 必须删除旧记录并插入新记录,等同于一次删除加一次插入,极易导致页分裂和 B+ 树重组,严重影响写入性能和存储布局。

显式定义主键:即使表中已有唯一索引,仍应显式声明主键列;若未定义主键,InnoDB 会隐式创建一个隐藏的聚簇索引,增加不确定性,且可能浪费空间和管理成本。

order by 优化

在 MySQL 中,任何无法直接利用索引有序性完成的 ORDER BY 操作都会触发 filesort:存储引擎先全表(或范围)扫描读取所有匹配行,将它们放入排序缓冲区(sort_buffer)中完成内存(或磁盘)排序后再返回结果;而如果能建立一个正好覆盖排序字段并且包含查询所需列的索引(Using index),MySQL 则可通过顺序扫描该索引直接输出有序结果,无需额外排序,效率更高。例如:

1
2
3
SELECT age, phone 
FROM table_name
ORDER BY age ASC, phone DESC;

若事先创建:

1
2
CREATE INDEX idx_user_age_pho_ad 
ON table_name(age ASC, phone DESC);

则该查询可通过索引顺序扫描直接返回,避免 filesort 和回表查询;否则,MySQL 在执行时仍需先回表取出 phone 字段,再对 age、phone 结果集进行 filesort,才能满足排序要求。对于多列排序,除了要遵循最左前缀原则以保证索引可用,还需在建索引时指定正确的 ASC/DESC 顺序;当无法避免 filesort 时,可通过增大 sort_buffer_size(默认为 256 KB)来提升大数据量场景下的排序性能。

group by 优化

在 MySQL 中,如果将用于 GROUP BY 的列定义在符合最左前缀规则的复合 B+Tree 索引上,服务器就可以直接沿着索引的有序叶节点执行分组操作,而无需使用临时表或 filesort,从而显著提高聚合查询性能;例如对于 GROUP BY(a, b) 的场景,只要存在 (a, b, …) 这样的复合索引,MySQL 就能利用该索引在扫描叶节点时即完成对 (a,b) 键值的分组,而不是先拉取所有行再在服务器层排序和分组。

limit 优化

在面对海量数据分页时,传统的 LIMIT … OFFSET 会因数据库必须扫描并丢弃前 OFFSET 行而导致深度分页速度骤降;为此,可采用延迟关联(Deferred Join)技术——先在子查询中仅基于主键索引完成分页,再与主表 JOIN 获取完整行,以大幅缩减扫描量并保持深度分页的稳定性能;另一种常见做法是书签或键集分页(Keyset Pagination),即在每页结果中记录最后一行的排序键,下次查询以 WHERE key > last_key LIMIT N 的方式继续,既避免了昂贵的 OFFSET 跳过开销,又能直接利用索引顺序扫描;总之,通过延迟关联减少笛卡尔积运算并使用游标式或书签式分页策略,可在保持简洁 SQL 的同时显著提升大数据量分页查询的执行效率。

count 优化

在 MySQL 中,COUNT(column) 最慢,因为引擎要逐行取出指定列的值并判断是否为 NULL,只有非空值才累加;COUNT(primary_key) 略快一些,因为主键列本身有 NOT NULL 约束,无需判断空值即可累加;COUNT(1)COUNT(*) 在现代 MySQL 中被优化为等价操作,它们都不实际读取任何列值,而是在服务层对每行隐式累加一个常量,性能十分接近,通常是最快的计数方式。

大多数权威测试与官方文档都表明,COUNT(1) 与 COUNT(*) 在 MySQL/InnoDB 上几乎没有性能差异,且二者优于其他形式的 COUNT(例如 COUNT(column))。

[!NOTE]

如果我们需要在大数据量下统计唯一值,同时对处理速度要求很高,但是允许出现小幅度误差,这个时候我们可以使用 HyperLogLog 算法。

HyperLogLog 是一种概率算法,通过统计哈希值中最长前导零长度来估算数据基数,误差一般在 1–2% 范围内。

具体论文,可参考:https://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf

update 优化

InnoDB 的行锁实际上是对索引记录(index record)加锁,而非对物理存储行本身加锁:当执行带有 WHERE 条件的 UPDATEDELETESELECT … FOR UPDATE 时,InnoDB 会在匹配条件的索引叶节点上设置记录锁和必要的间隙锁,从而实现行级并发控制;但如果查询条件无法利用任何合适的索引,InnoDB 就必须扫描整个表并在聚簇索引(或隐式主键索引)上对所有记录加锁,这时就会退化为表级锁,阻塞全表写操作;同样地,若使用的索引失效(例如对非索引列做范围扫描或函数运算),也会触发锁粒度升级为表锁,导致并发性能急剧下降。因此,为了保持细粒度的行锁并避免意外的表锁锁阻塞,务必为常用的查询条件列创建合适且选择性高的索引。

union 优化

在使用 UNION 语句时,为了让优化器更高效地执行查询,应将共同的过滤条件(如 WHERE)和分页限制(如 LIMIT)尽可能下推到各个子查询中,这样每个子查询只需处理满足自身子集条件的行,并在各自范围内完成截取与过滤,避免先将所有子查询结果合并后再做统一筛选,从而减少中间结果集的大小、降低 I/O 与内存开销,并加快整体查询响应速度。

MySQL 数据库 cpu 飙升的话,要怎么处理呢?

在 MySQL 出现 CPU 飙升时,首先可借助操作系统工具(如 top 或 htop)确认是 mysqld 进程占用过高,接着在数据库层面执行 SHOW PROCESSLIST 或查询 information_schema.processlist,快速定位执行时间或状态异常的会话,并对最耗资源的 SQL 做 EXPLAIN 分析,检查是否缺失索引或数据量过大;发现可疑线程后,可使用 KILL 语句终止它们,同时观察 CPU 是否回落,然后针对性地优化(如新增索引、重写慢查询、调整内存参数等)并重新执行这些 SQL;此外,如果是大量会话瞬间涌入导致 CPU 突增,则需与应用方协同排查连接激增的原因,并可考虑设置最大连接数或在业务层做限流,以防过多并发请求压垮数据库服务器。

有一个查询需求,MySQL 中有两个表,一个表 1000W 数据,另一个表只有几千数据,要做一个关联查询,如何优化?

如果 orders 表是大表(比如 1000 万条记录),而 users 表是相对较小的表(比如几千条记录)。

  1. 为关联字段建立索引,确保两个表中用于 JOIN 操作的字段都有索引。这是最基本的优化策略,避免数据库进行全表扫描,可以大幅度减少查找匹配行的时间。

    1
    2
    CREATE INDEX idx_user_id ON users(user_id);
    CREATE INDEX idx_user_id ON orders(user_id);
  2. 小表驱动大表,在执行 JOIN 操作时,先过滤小表中的数据,这样可以减少后续与大表进行 JOIN 时需要处理的数据量,从而提高查询效率。

    1
    2
    3
    4
    5
    6
    7
    8
    SELECT u.*, o.*
    FROM (
    SELECT user_id
    FROM users
    WHERE some_condition -- 这里是对小表进行过滤的条件
    ) AS filtered_users
    JOIN orders o ON filtered_users.user_id = o.user_id
    WHERE o.some_order_condition; -- 如果需要,可以进一步过滤大表

如何解决慢查询问题?

首先,开启慢查询日志,用于记录执行时间超过阈值的查询,帮助定位慢查询的 SQL 语句。

开启方法如下:

1
2
3
4
5
SET GLOBAL slow_query_log = 1;

SET GLOBAL long_query_time = 1; -- 设置慢查询的阈值为1秒

SHOW VARIABLES LIKE 'slow_query_log%'; -- 确认是否启用

之后检查慢查询日志文件,定位耗时较长的 SQL 语句,并使用 EXPLAIN 分析 SQL 的执行计划,判断是否使用了索引或是否存在全表扫描等问题。

比如说:EXPLAIN SELECT * FROM table_name WHERE column_name = 'value';,该命令中的关键字段解释如下:

  1. type:查询类型,优化目标是避免 ALL(全表扫描),优先选择 index、range。
  2. key:实际使用的索引。
  3. rows:扫描的行数,值越小越好。
  4. extra:留意 Using temporary 或 Using filesort,这些会影响性能。

如果缺少索引,那么我们需要为查询条件中的字段添加索引,特别是 WHERE、JOIN、GROUP BY、ORDER BY 中涉及的字段。
比如说:CREATE INDEX idx_column ON table_name (column_name);

如果大量数据出现回表操作,那么需要改成覆盖索引,减少回表操作。
比如说:CREATE INDEX idx_multi_column ON table_name (column1, column2);

同时,还要判断是否出现索引失效。对此,我们可以避免对索引列使用函数或表达式,避免隐式类型转换(如字符串与数字比较)。

然后,我们可以重构查询语句,比如说使用 LIMIT 分页来避免返回大量数据:SELECT * FROM table_name WHERE condition LIMIT 100;,或者可以明确查询字段:SELECT column1, column2 FROM table_name WHERE condition;,避免运行 SELECT *;,再者可以在 JOIN 字段上设置索引,并尽量减少复杂的嵌套查询。

最后,我们可以进行表结构优化。

比如说可以使用分区表,也就是如果查询条件中经常使用时间或地理区域,可以将表按这些字段分区,减少扫描范围:

1
2
3
4
5
6
7
CREATE TABLE table_name (
id BIGINT NOT NULL,
created_at DATE NOT NULL
) PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025)
);

或者如果单表数据量仍然过大,还可以按特定规则(如用户 ID)将表拆分为多个子表,降低单表数据量。

存储引擎是存储、更新或查询数据、以及建立索引等技术的实现方式。存储引擎是基于表的,而不是基于库的,因此存储引擎也可被称为表类型。

指定存储引擎:

1
2
3
CREATE TABLE 表名 (
...
) ENGINE = INNODB ...;

查看当前数据库支持的存储引擎:SHOW ENGINES;

存储引擎特点

img

InnoDB

它是一个兼顾高可靠性和高性能的通用存储引擎。

特点

  • DML 操作遵循 ACID 模型,支持事务。
  • 行级锁提高并发访问性能。
  • 支持外键 FOREIGN KEY 约束,保证数据的完整性和正确性。
  • 支持非锁定读,即默认读取操作不会产生锁。

InnoDB 引擎中有多个内存块,这些内存块组成了一个大的内存池,负责如下工作:

  1. 维护所有进程或线程需要访问的多个内部数据结构。
  2. 缓存磁盘上的数据,方便快速读取,同时再对磁盘文件的数据修改之前在这里缓存。
  3. Redo 日志缓冲。

img

上图中,后台线程的主要作用是刷新内存中的数据,保证缓冲池中缓存的是最新的数据,此外将已修改的数据文件刷新到磁盘,同时保证在数据库发生异常的情况下 InnoDB 能恢复到正常运行状态。

后台线程

InnoDB 存储引擎是多线程模型,因此有多个不同的后台线程,负责不同的任务:

  1. Master Thread

    Master Thread 是一个非常核心的后台线程,主要负责将缓冲池中的数据异步刷新到磁盘,保证一致性,包括脏页的刷新、合并更改缓冲区、回收 Undo 页等。

  2. IO Thread

    InnoDB 中大量使用了 AIO 来处理写 IO 请求,这样可以极大提高数据库的性能。而 IO Thread 主要负责这些 IO 请求的回调处理。

  3. Purge Thread

    事务被提交后,其所使用的 Undo 日志可能不再被需要,因此引入 Purge Thread 来回收已经使用并被分配的 Undo 页。

  4. Page Cleaner Thread

    作用是将之前版本中脏页的刷新操作都放入到单独的线程中来完成,用于减轻元 Master Thread 的工作以及对于用户查询线程的阻塞。

Checkpoint

缓冲池的设计目的是为了协调 CPU 速度和磁盘速度的鸿沟。但是如果每次一个页只要的发生变化,都要将脏页刷新到磁盘的话,那开销将非常大。而且如果从缓冲池刷新页到磁盘的磁盘过程中发生了宕机,那么数据无法恢复,因此 InnoDB 采用了 Write Ahead Log 策略,也就是当事务提交时,先写 Redo 日志再修改页。

如果 Redo 日志可以无限增大,并且缓冲池也足够大。那么我们无需将缓冲池中的脏页刷回磁盘。但是我们做不到,因为大容量内存很少见,而且维护人员需要时刻监测 Redo 日志的积累量是否超过磁盘空间阈值,此外宕机之后大量 Redo 日志的重放非常耗时,因此我们需要 Checkpoint 解决这些问题:

  1. 缩短数据库的恢复时间;
  2. 缓冲池不够用时,将脏页刷回磁盘;
  3. Redo 日志不可用时,刷新脏页。

因此,当数据库发⽣宕机时, 数据库不需要重做所有的⽇志,因为 Checkpoint 之前的页都已经刷新回磁盘。故数据库只需对 Checkpoint 后的重做⽇志进⾏恢复。这样就⼤⼤缩短了恢复的时间。

此外,当缓冲池不够⽤时,根据 LRU 算法会淘汰最近最少使⽤的页,若此页为脏页,那么需要强制执⾏ Checkpoint,将脏页刷回磁盘。

重做日志出现不可用的情况是因为当前事务数据库系统对重做日志的设计都是循环使用的,并不是让其无限增大的。重做日志可以被重用的部分是指这些重做日志已经不再需要,即当数据库发生宕机时,数据库恢复操作不需要这部分的重做日志,因此这部分就可以被覆盖重用。若此时重做日志还需要继续使用不可被覆盖的部分,那么必须强制产生 Checkpoint,将缓冲池中的页至少刷新到当前重做日志的位置。

InnoDB 是通过 LSN(Log Sequence Number)来标记版本的,而 LSN 是 8 字节的数字。每个页有 LSN,重做日志中也有 LSN,Checkpoint 也有 LSN。

InnoDB 存储引擎内部,有两种 Checkpoint:

  1. Sharp Checkpoint;
  2. Fuzzy Checkpoint。

Sharp Checkpoint 发生在数据库关闭时将所有的脏页都刷新回磁盘,这是默认的工作方式。

但是若数据库在运行时也使用 Sharp Checkpoint,那么数据库的可用性就会受到大大影响,故在 InnoDB 存储引擎内部常使用 Fuzzy Checkpoint 进行页的刷新,即只刷新一部分脏页,而不是刷新所有的脏页回磁盘。

InnoDB 存储引擎中可能会生成如下几种情况的 Fuzzy Checkpoint:

  • Master Thread Checkpoint;
  • FLUSH_LRU_LIST Checkpoint;
  • Async/Sync Flush Checkpoint;
  • Dirty Page too much Checkpoint。

Master Thread 中发生的 Checkpoint,差不多以每秒或每十秒的速度从缓冲池的脏页列表中刷新一定比例的页回磁盘。这个过程是异步的,即此时 InnoDB 存储引擎可以进行其他的操作,用户查询线程不会阻塞。

FLUSH_LRU_LIST Checkpoint 是因为 InnoDB 存储引擎要保证 LRU 列表中需要有不多于 100 个空闲页可供使用。倘若没有 100 个可用空闲页,那么 InnoDB 会将 LRU 列表尾端的页移除。如果这些页中有脏页,那么需要进行 Checkpoint,而这些页是来自 LRU 列表的,因此称为 FLUSH_LRU_LIST Checkpoint。之后,这些检查被放在了一个单独的 Page Cleaner 线程中进行。

Async/Sync Flush Checkpoint 指的是重做日志文件不可用的情况,这时需要强制将一些页刷新回磁盘,而此时脏页是从脏页列表中选取的。

若将已经写到重做日志的 LSN 记为 redo_lsn,将已经刷新回磁盘最新页的 LSN 记为 checkpoint_lsn,则可定义:

$$checkpoint_age = redo_lsn - checkpoint_lsn$$

再定义以下的变量:

$$async_water_mark = 75% * total_redo_log_file_size$$

$$sync_water_mark = 90% * total_redo_log_file_size$$

若每个重做日志文件的大小为 1GB,并且定义了两个重做日志文件,则重做日志文件的总大小为 2GB。那么 async_water_mark=1.5GBsync_water_mark=1.8GB,则:

  • checkpoint_age < async_water_mark 时,不需要刷新任何脏页到磁盘;
  • async_water_mark < checkpoint_age < sync_water_mark 时触发 Async Flush,从 Flush 列表中刷新足够的脏页回磁盘,使得刷新后满足 checkpoint_age < async_water_mark
  • checkpoint_age > sync_water_mark 这种情况一般很少发生,除非设置的重做日志文件大小小,并且在进行类似 LOAD DATA 或 BULK INSERT 操作。此时触发 Sync Flush 操作,从 Flush 列表中刷新足够的脏页回磁盘,使得刷新后满足 checkpoint_age < async_water_mark

之后,这部分刷新操作同样被放入到 Page Cleaner 线程中,所以再也不会阻塞用户查询线程。

最后一种 Checkpoint 的情况是 Dirty Page too much,即脏页的数量太多,导致 InnoDB 存储引擎强制进行 Checkpoint。其目的的总结来说是为了保证缓冲池中有足够可用的页。其可由参数 innodb_max_dirty_pages_pct 控制,innodb_max_dirty_pages_pct 值为 75 表示,当缓冲池中脏页的数量占比 75% 时,强制进行 Checkpoint,刷新一部分的脏页到磁盘。

Master Thread

InnoDB 存储引擎的主要工作都是在一个单独的后台线程 Master Thread 中完成的。具体的演化过程请参考 MySQL 技术内幕的 36-45 页。

关键底层特性

  • 插入缓冲
  • 两次写
  • 自适应哈希索引
  • 异步 IO
  • 刷新临接页

插入缓冲

InnoDB 中,主键是行唯一的标识符,通常应用程序中行记录的插入是按照主键递增的顺序来进行的(AUTO_INCREMENT),因此插入聚集索引一般是顺序的,无需磁盘随机读取。但是,一张表上可能存在多个非聚集切非唯一的二级索引,再进行插入操作的时候,数据页的存放还是按照主键顺序存放的,但是对于非聚集非唯一的索引而言,叶子节点的插入一般来说就不再是顺序的了,也就是会出现随机访问,除了日期之类的递增列的索引。

因此 InnoDB 中设计了插入缓冲:对于非狙击索引的插入和更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓冲池中,若在,则直接插入,若不在,则先放入到一个插入缓冲中,然后再以一定频率和情况进行插入缓冲和二级索引页的合并操作,这时通常能将多个插入操作合并到一个操作中,极大提高了对于非聚集索引插入的性能。

为什么插入缓冲不操作唯一索引?

因为如果要对唯一索引进行操作,不论是插入、删除还是更新,都需要到索引页中判断要操作的记录的唯一性,这又会导致随机访问的发生,从而导致插入缓冲失去意义。

插入缓冲是一棵 B+ 树,并且该树数据库全局中只有一棵,负责对所有表的二级索引进行插入操作的缓存。该树被放在共享表空间中,即 ibdata1 中。 因此,试图通过独⽴表空间 ibd ⽂件恢复表中数据时,往往会导致 CHECK TABLE 失败。这是因为表的辅助索引中的数据可能还在 InsertBuffer 中,也就是共享表空间中,所以通过 ibd ⽂件进⾏恢复后,还需要进⾏ REPAIR TABLE 操作来重建表上所有的辅助索引。

该树中的非叶子节点存放的是查询的 search key,也就是索引键(在 Insert Buffer B + 树中,二级索引页根据( space, offset )都已排序好),其结构如下:

img

searchkey ⼀共占⽤ 9 个字节,其中 space 表⽰待插⼈记录所在表的表空间 id,在 InnoDB 存储引擎中,每个表有⼀个唯⼀的 spaceid,可以通过 spaceid 查询得知是哪张表。space 占⽤ 4 字节。marker 占⽤ 1 字节,它是⽤来兼容⽼版本的 Insert Buffer。offset 表⽰页所在的偏移量,占⽤ 4 字节。

当⼀个辅助索引要插入到页( space, offset )时,如果这个页不在缓冲池中,那么 InnoDB ⾸先根据上述规则构造⼀个 searchkey,接下来查询 InsertBuffer 这棵 B+树,然后再将这条记录插⼊到 Insert Buffer 的叶⼦节点中。

对于插⼊到 Insert Buffer 叶⼦节点的记录,并不是直接将待插入的记录插入,⽽是需要根据如下规则进⾏构造:

img

上图是 Insert Buffer 叶子节点中的记录结构,其中 space、marker、offset 字段和之前的含义一致,一共 9 字节。metadata 占用 4 字节,其存储内容如下:

名称 字节
IBUF_REC_OFFSET_COUNT 2
IBUF_REC_OFFSET_TYPE 1
IBUF_REC_OFFSET_FLAGS 1

IBUF_REC_OFFSET_COUNT 记录进入 Insert Buffer 的顺序,并且通过这个顺序才能得到正确的值。

从第 5 个字段开始,就是实际插入记录的各个字段,因此较原插入记录,Insert Buffer 中的叶子节点中的记录需要额外 13 字节的开销。

当启用 Change Buffer(Insert Buffer 升级)后,InnoDB 不会立即将对非聚集索引页的修改(插入、删除、更新)同步到该页上,而是将修改记录缓存到一个 Insert Buffer 中。这种做法虽然减少了大量随机 I/O,但带来两个问题:

  1. 何时合并? 合并时要把哪些缓冲修改应用到目标页?
  2. 是否还有可用空间? 只有当目标页有足够剩余空间时,才允许新的修改进入缓冲,否则要避免膨胀页大小。

为此,引入了 bitmap 页面来快速回答这两类问题,而无需读取整个索引页。

每个 bitmap 页描述一组连续的二级索引页。在常见的 16 KB 页大小下,一个 bitmap 页能跟踪 16 384 个索引页(即 256 个区)。每个 Insert Buffer Bitmap 页是 16384 个页中的第⼆个页。每个被跟踪的索引页对应 4 位信息,如下:

名称 大小(bit) 说明
IBUF_BITMAP_FREE 2 表示该辅助索引页中的可用空间数量,可取值为:
• 0 表示无可用剩余空间
• 1 表示剩余空间大于 1/32 页(512 字节)
• 2 表示剩余空间大于 1/16 页
• 3 表示剩余空间大于 1/8 页
IBUF_BITMAP_BUFFERED 1 1 表示该辅助索引页有记录被缓存至 Insert Buffer B+ 树中
IBUF_BITMAP_IBUF 1 1 表示该页为 Insert Buffer B+ 树的索引页

Merge Insert Buffer 的操作可能发⽣在以下⼏种情况下:

  1. 当某个二级索引页被读取到缓冲池时,立即应用该页所有挂起的缓冲修改;
  2. 当 Insert Buffer Bitmap 检测到某页可用空间不足,必须先合并才能继续缓冲新的修改;
  3. Master Thread 循环检测并执行各种维护任务,包括 Change Buffer 的合并操作。

对第二点进行补充:若 Bitmap 显示该页剩余可用空间 ≥ 阈值(默认为页大小的 1/32),则直接将修改写入 Change Buffer;否则,视为可用空间不足,需要先合并该页在 Change Buffer 中的所有挂起修改,再重新计算空间后才能缓冲。

在 Master Thread 中,执行 merge 操作的不止是一个页,而是根据 srv_innodb_io_capacity 的百分比来决定真正要合并多少个辅助索引页。但 InnoDB 存储引擎又是根据怎样的算法来得知需要合并的辅助索引页?

解决方案是随机选页策略:

  • 随机起点:Master Thread 在 Change Buffer 树中随机挑选一个页(或一个树节点),然后从该页开始,按内部顺序(通常是树结构中后继页)依次读取所需数量的页来合并。
  • 保证覆盖全树:通过每次随机的起点,随着时间推移,整个 B+ 树中的所有页都会被均匀地触及,避免固定区段被长时间忽略。
  • 批量合并:从选中的起点开始,连续取出 N 页(N 由 I/O 预算决定)进行条目合并和写回。

在 merge 时,如果要进行 merge 的条目已经被删除,此时可以直接丢弃已被 Insert/Change Buffer 存储的对应数据记录。

为什么不按顺序选页?

(space, offset) 排序:理论上可以从最小的表空间 ID(space)和页号(offset)开始,按顺序扫描整个 B+ 树。

公平性问题:如果总是从头开始,前面页的挂起修改会被反复优先处理,而后面页可能长时间得不到合并。

更改缓冲

更改缓冲是插入缓冲的升级版,因为它可以对 DML 操作(增删改)进行缓冲。更改缓冲的适用对象仍然是非唯一的二级索引。

innodb_change_buffering 控制 Change Buffer 的行为,默认值为 all,表示所有类型的插入、更新和删除操作都会使用 Change Buffer。可选值包括:

  • none:禁用 Change Buffer。
  • inserts:仅缓存插入操作。
  • deletes:仅缓存删除操作。
  • changes:缓存插入和删除操作。
  • all:缓存所有支持的操作。

双写

双写为 InnoDB 带来了数据页写磁盘的可靠性。

当发生数据库宕机时,可能 InnoDB 存储引擎正在写入某个页到表中,而这个页只写了一部分,比如 16 KB 的页,只写了前 4 KB,之后就发生了宕机,这种情况被称为部分写失效(partial page write)。在 InnoDB 存储引擎未使用 doublewrite 技术前,曾经出现过因为部分写失效而导致数据丢失的情况。

有人会想,如果发生写失效,可以通过重做日志进行恢复。这是一个办法。但是必须清楚地认识到,重做日志中记录的是对页的物理操作,比如偏移量 800,写入 aaaa 记录。如果这个页本身已经发生了损坏,再对其进行重做是没有意义的。这就是说,在应用重做日志前,用户需要一个页的副本,当页写失效时,先通过页的副本来还原该页,再进行重做,这就是 doublewrite。

当 InnoDB 将脏页写回磁盘时,如果发生意外宕机(如断电、进程被杀),可能只写入了页面的一部分字节,这称为部分写失效(partial page write)。此时磁盘上的原始页已损坏,内部结构(例如记录边界、checksum、LSN 等)不再正确。InnoDB 的 Redo Log 仅记录对特定行或特定偏移的修改差分,并不包含整页的镜像。因此,如果缺少干净的起始页,应用这些差分时就无从下手——就像没有原图就没法正确贴修补片一样。

doublewrite 由两部分组成:一部分是内存中的 doublewrite buffer,大小为 2 MB;另一部分是物理磁盘上共享表空间中连续的 128 个页,即 2 个区(extent),大小同样为 2 MB。

在后台线程对缓冲池的脏页进行刷回磁盘的时候,并不是直接写入表空间文件,而是先通过 memcpy 将数据页复制到内存中的 doublewrite buffer,它被划分为两段,每段约 1 MB,InnoDB 按连续顺序调用 write() 将第一段写入 OS 页缓存,再调用 fsync() 强制落盘,然后对第二段重复相同操作。在这个过程中,由于 doublewrite 页面在磁盘上是连续存放的,因此写入是顺序 I/O,性能损耗也相对可控。在完成 doublewrite 页的安全落盘之后,再将磁盘的 doublewrite 中的页面按各自原始逻辑位置分散写入 .ibd 或共享表空间文件;这一步 I/O 是随机分布的。

img

fsync 是一个 POSIX 系统调用,用于强制操作系统将指定文件的所有已修改数据块及其元数据,从内核缓冲区刷写(flush)到物理存储设备,并在返回之前阻塞直到存储设备确认写入完成。

如果操作系统在将页写入磁盘的过程中发生了崩溃,在恢复过程中,InnoDB 可从共享表空间中的 doublewrite 中找到该页的一个副本,将其复制到表空间文件并应用 Redo 日志。

注意:有些文件系统如 AFS 提供了部分写失效的防范机制,因此在这种情况下,无需启动 doublewrite。

为什么数据页会被先写入双写缓冲,而不是直接被写入数据文件?

直接写入数据文件时,部分写失效问题更加严重,因为数据文件中的数据页分布是随机的,不同的数据页可能在磁盘的不同区域,而不是连续存放。因此,直接写入这些随机分布的页时,我们需要花费更多的时间,发生部分写失效的风险更高。而双写缓冲区是一个专门设计的区域,确保每次写入是连续的、顺序的,它使得内存中的数据能在最短的时间内被写入磁盘,从而大大减少了部分写失效的风险。

自适应哈希索引

哈希(hash)是一种非常快的查找方法,在一般情况下这种查找的时间复杂度为 O(1),即一般仅需一次查找就能定位数据。而 B+ 树的查找次数,取决于 B+ 树的高度,在生产环境中, B+ 树的高度一般为 3 ~ 4 层,故需要 3 ~ 4 次的查询。

InnoDB 存储引擎会监控对表上各索引页的查询。如果观察到建立哈希索引可以带来速度提升,则建立哈希索引,称之为自适应哈希索引(Adaptive Hash Index,AHI)。AHI 是通过缓冲池的 B+ 树页构造而来,因此建立的速度很快,而且不需要对整张表构建哈希索引。InnoDB 存储引擎会自动根据访问的频率和模式来自动地为某些热点页建立哈希索引。

AHI 有一个要求,即对这个页的连续访问模式必须是一样的。例如对于 (a, b) 这样的联合索引页,其访问模式可以是以下情况:

1
2
3
WHERE a=xxx
-- 或者
WHERE a=xxx and b=xxx

访问模式一样指的是查询的条件一样,若交替进行上述两种查询,那么 InnoDB 存储引擎不会对该页构造 AHI。此外 AHI 还有如下的要求:

  • 以该模式访问了 100 次;
  • 页通过该模式访问了 N 次,其中 N = 页中记录 * 1/16

仅当上述两项同时满足,且访问模式一致时,才会触发 AHI 的构建。同一模式访问次数 ≥ 100:保证该模式不是偶发的短时热点。页访问次数 ≥ R / 16:保证该模式命中足够多的行数,是真正的页级热点。

当你交替执行两种不同的访问模式(WHERE a=xxxWHERE a=xxx AND b=xxx),即使每种模式单独都达到了 100 次,也仍然不会触发 AHI 的构建。

异步 IO

同步 I/O:每次调用 read()/write() 后,调用线程必须阻塞等待 I/O 完成,才能继续下一步操作。

异步 I/O (AIO):调用线程可连续调用多次 io_submit(),将多个 I/O 请求并行提交给内核,而不必等待每次完成。内核或专门的 I/O 线程池负责实际读写,完成后通过回调或事件 (io_getevents) 通知应用。

AIO 的另一个优势是可以进行 IO Merge 操作,也就是将多个 IO 合并为 1 个 IO,这样可以提高 IOPS 的性能。例如用户需要访问页的 (space, page_no) 为:(8, 6), (8, 7), (8, 8)。每个页的大小为 16KB,那么同步 IO 需要进行 3 次 IO 操作。而 AIO 会判断到这三个页是连续的(显然可以通过 (space, page_no) 得知)。因此 AIO 底层会发送一个 IO 请求,从 (8, 6) 开始,读取 48KB 的页。

刷新邻接页

在刷新某个脏页时,同时检查并一起刷新该页所在区(extent)中的所有其它脏页,从而通过 AIO(异步 I/O)将多个小的写操作合并成一次较大的顺序 I/O。

为什么要刷新邻接页?

  • 顺序 I/O 合并:在刷新单页时,如果只发出一个 16 KB 的写请求,操作系统与硬盘可能会进行一次小随机写;若同时将同一区中其他脏页一起刷新,就能将多次 16 KB 的写请求合并为一次大块的顺序写,从而利用顺序带宽、减少磁盘的寻道开销
  • AIO 效果:配合异步 I/O(AIO)提交这些写请求后,硬件/内核会自动合并相邻请求为更大块,有效提升 IOPS 和吞吐。

如果禁用该功能,则每次仅写出单页,虽然避免了写入无关脏页,但会频繁发生随机写,导致 I/O 延迟和写入抖动增大。

示例:在同一个 extent(假设包含页号 0–63)内,页 10、页 12、页 14 已被修改,成为脏页。

禁用刷新邻接页:每次只写出目标脏页本身,不管同一 extent 内是否还有其他脏页。仅页 12 刷入磁盘;页 10 和页 14 保持在缓冲池中,等待下次单独刷新或达到阈值才写入。

启用刷新邻接页:在同一 extent(页 0–63)内,发现目标页 12 后,会顺带把所有脏页(页 10 和页 14)一并写入一次 I/O。单次 I/O 写出页 10、12、14(共 3 × 16 KB),仅一次寻道即可完成,减少 HDD (硬盘驱动器)随机写的开销。

MyISAM

它是 MySQL 早期默认的存储引擎。

特点

  • 不支持事务和外键。

  • 支持表锁,不支持行锁。

  • 访问速度快。

  • 缓冲池只缓存索引文件,而不缓存数据文件。

MyISAM 和 InnoDB 的比较如下表:

项目 InnoDB MyISAM
存储结构 .frm 存储表定义
.ibd 存储数据和索引
.frm 存储表定义
.MYD 存储数据
.MYI 存储索引
事务 支持(ACID 事务、提交/回滚) 不支持
最小锁粒度 行级锁 表级锁
索引类型 聚簇索引 非聚簇索引(指向 .MYD 的指针)
外键 支持 不支持
主键 可以没有主键 可以没有主键
表的具体行数 需扫描整个表才能返回 存储在表属性中,查询时可直接返回

Memory

该存储引擎的表数据存储在内存中,因此为避免受到硬件问题或断电问题的影响,我们只能将这些表作为临时表或缓存使用。

特点

  • 内存存储,访问速度快。

  • 默认使用 hash 索引。

以上三种存储引擎的对比如下图:

img

存储引擎选择

  • InnoDB:支持事务、外键、行级锁。如果应用对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,数据操作包含大量的增删改查,那么 InnoDB 存储引擎是比较合适的选择。
  • MyISAM:如果应用以读操作和插入操作为主,只有很少的更新和删除操作,并且对事务的完整性、并发性要求不是很高,那么 MyISAM 是非常合适的(更好的选择是使用 MongoDB)。
  • Memory:将所有数据保存在内存中,访问速度快,通常用于临时表及缓存。缺陷是对表的大小有限制,太大的表无法缓存在内存中,而且无法保障数据的安全性(更好的选择是使用 Redis)。

除了以上三种引擎,MySQL 还内置了 Archive 和 Federated 等存储引擎。

MySQL 的体系结构如下:

MySQL architecture diagram showing connectors, interfaces, pluggable storage engines, the file system with files and logs.

客户端连接器
系统管理和控制工具 连接池 连接层
SQL 接口 解析器 查询优化器 缓存 服务层
可插拔式存储引擎 引擎层
系统文件 文件和日志 存储层
  • 连接层:负责网络协议解析、用户认证及会话管理,为每个客户端分配线程或线程池。
  • 服务层:包含 SQL 解析、优化与执行模块,并实现跨引擎功能,如存储过程、触发器和视图等。
  • 引擎层:通过统一的 Handler API 与各存储引擎(如 InnoDB、MyISAM)通信,负责数据的存储、检索及索引管理。
  • 存储层:基于操作系统文件系统执行物理 I/O,管理表空间、日志文件并支持异步或直通 I/O 优化。

Amazon DynamoDB 将 Dynamo 的增量扩展能力和可预测的高性能与 SimpleDB 的易用表模型和强一致性相结合,既避免了自建大型数据库系统所带来的运维复杂性,又突破了 SimpleDB 在存储容量、请求吞吐和查询/写入延迟方面的局限;同时,DynamoDB 作为一款无服务器、全托管的 NoSQL 服务,内置自动扩缩容、安全加固和多区域复制,让开发者能够专注于业务逻辑,而无需管理底层基础设施。

架构

一个 DynamoDB 表是多个条目的集合,或者具体来说是 KV 存储,每个条目由多个属性组成并且通过主键唯一标识。主键的模式在创建表时指定,主键模式包含分区键,或者分区键和排序键一起(也就是复合主键)。分区键的值总是作为内部哈希函数的输入,该哈希函数的输出和排序键的值(如果存在)共同决定该条目的存储位置(分区)。在具有复合主键的表中,多个条目可以具有相同的分区键值,但这些项目必须具有不同的排序键值。

img

DynamoDB 支持二级索引,一个表可以拥有一个或多个二级索引。二级索引允许使用除主键之外的备用键来查询表中的数据,这个备用键说白了就是二级索引键。

假设我们有一个游戏得分表 GameScores,记录玩家在不同游戏中的最高得分,表结构定义如下:

主表的表名是 GameScores,主键的设置如下:

  • 分区键:UserId (String);
  • 排序键:GameId (String)。

在这种设计下,针对单个用户查询他们在某个游戏里的得分非常高效,但如果我们想要按 游戏名称GameTitle)或 得分排名TopScore)来查询所有玩家的成绩,就无法直接使用主键查询。

为满足上述查询需求,我们可以在表中添加一个全局二级索引 GameTitleIndex,其备用键(索引键)定义如下:

  • 索引名:GameTitleIndex
  • 分区键:GameTitle (String);
  • 排序键:TopScore (Number)。

img

上表列出了客户端在 DynamoDB 表中读取和写入项目时可用的主要操作。任何插入、更新或删除项目的操作都可以带有一个条件,只有在该条件满足时操作才会成功。该条件判断在高并发场景中可避免多个客户端对同一项条目进行冲突性写入,例如只在某属性值符合预期时才更新。

此外,DynamoDB 支持 ACID 事务,使应用程序能够在更新多个项目时保证原子性、一致性、隔离性和持久性(ACID),而不会影响 DynamoDB 表的可扩展性、可用性和性能特性。

之前提到过,DynamoDB 表被拆分为多个分区,以满足表的吞吐量和存储需求。每个分区承载表键值范围中不重叠的一个连续区段,并在不同可用区分布多个副本,以实现高可用性和持久性。这些副本组成一个复制组,采用 Multi-Paxos 协议进行领导者选举与一致性达成。任一副本都可以发起选举,成为领导者后需定期续租,唯有领导者副本可处理写请求和强一致性读取。领导者在接收到写请求时,会生成预写日志并分发给其他副本,当多数副本将日志持久化后,才向客户端确认写入成功。DynamoDB 支持强一致性和最终一致性读取,其中任何副本都能提供最终一致性读取。若领导者被检测为失败或下线,其它副本可再次发起选举,新领导者在前领导者租约到期前不会处理写入或强一致性读取。复制组包含预写日志和以 B 树形式存储键值数据的存储副本。同时为了进一步提升可用性与持久性,复制组中还可包含仅持久化最近预写日志的日志副本,它们类似 Paxos 中的接受者,但不存储键值数据。也就是说,DynamoDB 的复制组中包含多个数据副本和多个日志副本。

img

img

DynamoDB 是由数十个微服务组成的,其中一些核心服务包括元数据服务、请求路由器服务、存储节点和自动管理服务。元数据服务存储有关表、索引以及给定表或索引的分区复制组的路由信息。请求路由器服务负责对每个请求进行授权、身份验证,并将其路由到相应的服务器。

所有的读取和更新请求都会被路由到承载客户数据的存储节点。请求路由器会从元数据服务中查询路由信息。所有资源创建、更新和数据定义请求则会被路由到自动管理服务。存储服务负责在一组存储节点上保存客户数据,每个存储节点会承载多个不同分区的副本。

img

在上图中,请求首先通过网络到达请求路由器(Request Router)服务,该服务依次调用认证系统进行 IAM 权限校验、查询分区元数据系统获取路由信息,并与全局准入控制(GAC)系统协作对表级吞吐进行限流,最后将请求转发至目标存储节点(Storage Node)进行数据读写操作。存储节点分布在多个可用区(AZ),采用 SSD 存储并在三个副本间使用 Paxos 算法选举主节点以提供写入一致性和读扩展能力,同时依靠副本复制实现高可用与持久性。认证系统通过 AWS IAM 服务简化身份验证与授权管理,分区元数据系统维护分区与存储节点的映射关系,而 GAC 则作为分布式令牌桶机制确保吞吐的可预测性与表级隔离。

这里需要强调的是,自动管理服务被构建为 DynamoDB 的中枢神经系统,它负责集群健康、分区健康、表的弹性扩缩以及所有控制平面请求的执行。该服务会持续监控所有分区的状态,并替换任何被判定为不健康(响应缓慢、无响应或运行在故障硬件上)的副本。它还会对 DynamoDB 的所有核心组件进行健康检查,并替换任何正在出现故障或已故障的硬件。例如,如果自动管理服务检测到某个存储节点不健康,它会启动恢复流程,把此前托管在该节点上的数据副本迁移或重建到其他健康的节点,以确保整个系统的复制组能够再次达到预期的副本数和健康状态。

[!NOTE]

普遍意义上,控制平面是系统或网络中的大脑或指挥中心,负责管理、配置和决策,决定资源如何被创建、更新、删除以及如何路由请求;而实际的数据转发、存储和处理则由数据平面执行。控制平面通过一系列管理 API 与底层组件通信,实现对系统状态的监控、调度和恢复,从而保证整体的可用性、一致性和可扩展性。

因此,在 DynamoDB 中,控制平面并不仅仅指自动管理服务,而是由多种后台管理组件和它们所提供的管理 API 共同构成的一套系统。

从预配置到弹性伸缩

在最初的 DynamoDB 版本中,开发者引入了分区的概念,以便能够动态地扩展表的容量和性能。系统最开始会将一张表切分为多个分区,使其内容能够分布到多台存储节点上,并且与这些节点的可用空间和性能相映射。当表的规模增大或访问负载上升时,系统可以进一步拆分分区并将其迁移,以实现弹性扩展。分区这一抽象证明了其极高的价值,并一直是 DynamoDB 设计的核心。

用户需要以读取吞吐量单位(RCU)和写入吞吐量单位(WCU)的形式,显式地指定一个表所需的吞吐量(预配置吞吐量)。对于不超过 4 KB 的条目,1 个 RCU 可每秒执行 1 次强一致性读取请求;对于不超过 1 KB 的条目,1 个 WCU 可每秒执行 1 次标准写入请求。

显然,早期版本将容量与性能的分配紧密耦合到各个分区,导致了若干挑战。DynamoDB 使用准入控制来确保存储节点不会过载,避免同机房中不同表的分区相互干扰,并强制执行客户所请求的吞吐量限制。

最初,一个表的所有存储节点共同承担准入控制的责任。每个存储节点会根据其本地所存放的分区的分配情况,独立地执行准入控制。由于一个节点通常会承载多个表的分区,系统便利用各分区的分配吞吐量来隔离不同表的工作负载。DynamoDB 会对单个分区可分配的最大吞吐量进行上限限制,同时确保某节点上所有分区的总吞吐量不超过由其存储介质物理特性所决定的该节点最大允许吞吐量。当表的整体吞吐量发生变化或分区被拆分时,系统会相应地调整各分区的分配吞吐量。若因表容量增长而拆分分区,子分区会从父分区继承并平均分配吞吐量;若因吞吐量需求增长而拆分分区,则新分区会按照表的预配置吞吐量进行分配。例如,假设某分区最大可承载 1000 WCU。创建一个具有 3200 WCU 的表时,DynamoDB 会生成 4 个分区,每个分区分配到 800 WCU;若将表的吞吐量提升至 3600 WCU,则每个分区可用吞吐量自动增至 900 WCU;若进一步提升至 6000 WCU,系统会拆分出 8 个子分区,每个分区分配到 750 WCU;若将吞吐量下调至 5000 WCU,则每个分区的吞吐量相应降至 675 WCU。

以上这种对各分区进行均匀吞吐量分配的做法基于如下假设:应用会均匀地访问表中各个键,且按容量拆分分区也会等比地拆分性能。但开发者发现,应用在不同时间和键范围上的访问模式常常并不均匀。当表内请求速率分布不均匀时,将分区拆分并按比例分配吞吐量,往往会导致热点区域拆分后可用性能反而低于拆分前的水平。由于吞吐量在分区层面上被静态分配和强制执行,这类非均匀工作负载偶尔会引发应用的读写请求被拒绝(即“限流”),即使整表的预配置吞吐量充足,也无法满足集中在少数键的高并发访问。

img

img

在这种配置下,最常遇到的两个挑战是:热点分区(hot partitions)和吞吐量稀释(throughput dilution)。热点分区指的是访问始终集中在某些表项上的场景,这些热点可能稳定地落在某几个分区内,也可能随着时间在不同分区间跳动。吞吐量稀释则常出现在因扩容而按大小拆分分区的场景:拆分后,父分区的吞吐量被平均分配到新分区,使得每个分区的可用吞吐量降低。

从用户角度看,这两种情况都会导致资源被限流,使其应用在某些时段出现不可用。遭遇限流的用户往往会通过人为过度提升表的预配置吞吐量来规避问题,但这导致资源浪费、成本上升,并且难以准确估算所需性能。

因此,DynamoDB 在发布后不久就推出了两项改进,即突发容量和自适应容量,以解决这些问题。

突发容量

当开发者注意到各分区的访问模式并不均匀后,又发现并非所有分区在同一时刻都会耗尽为其分配的吞吐量。因此,为了在分区层面应对短时的工作负载高峰,DynamoDB 引入了突发(bursting)机制,其核心思想是在分区级别让应用可以按尽最大努力使用未被实时消耗的容量,以吸收短暂的访问激增。DynamoDB 会保留每个分区多达 300 秒的未使用容量,用于后续的突发性吞吐需求,这部分未用容量即称为突发容量(burst capacity)。

img

此外,为了仍然保证工作负载的隔离性,DynamoDB 要求分区只有在所在节点整体存在未用吞吐量时才能突发。系统在存储节点层面通过多组令牌桶来管理容量:每个分区对应两组桶(一个分配桶 allocated、一个突发桶 burst),整个节点还有一组节点桶 node。它们共同构成了准入控制的机制。

每当读或写请求到达存储节点时,系统首先检查对应分区的“分配桶”是否有剩余令牌。若有,则请求被接纳,并同时从该分区与节点级令牌桶中各扣减相应令牌。当分区分配桶中的令牌耗尽后,系统仅在分区突发桶节点级桶均有可用令牌时,才允许继续处理(突发)请求。

而且,完全依据本地(本节点)令牌桶完成校验,无需跨副本通信。写请求除了检查本地突发桶和节点桶外,还需额外验证该分区其他副本所在节点的节点级令牌桶是否有余量,以保证跨副本一致性与可用性。为支持写请求的额外校验,分区的主副本会定期从各成员副本节点收集节点级令牌余量信息,并据此决定是否允许写请求突发。

img

自适应容量

DynamoDB 推出了自适应容量(Adaptive Capacity)机制,专门用来应对那些持续时间比较长、突发容量救急不了的高峰期。自适应容量会持续监控所有表的预配置吞吐量和实际消耗情况;当表级别发生节流但整张表的吞吐量仍在预配置范围内时,系统会自动按比例控制算法动态提升该表中热点分区的分配吞吐量。如果表的总消耗超过预配置容量,则会相应地降低刚刚那些已接受提升的分区容量,以避免资源过度使用。自动管理服务等控制平面确保获得加速的分区被迁移到具备足够剩余能力的合适节点上,以保证性能提升能够得到支撑。与突发机制一样,自适应容量也是尽力而为的,但它能消除因访问倾斜导致的 99.99% 以上因为某几个热键访问量太高而被限流的尴尬,从而让应用跑得更稳,也更省钱。

全局准入控制

虽然 DynamoDB 通过突发和自适应容量在很大程度上缓解了非均匀访问带来的吞吐量问题,但这两种方案各有局限。突发机制仅能应对短时的流量峰值,并且依赖于节点本身还有剩余容量;自适应容量则属于被动响应,仅在检测到限流发生后才会启动,这意味着应用在此之前已经经历了短暂的不可用。两者的关键问题是:我们将分区级的容量管理紧密地和准入控制耦合起来了,而准入控制是分散在各个分区中执行的。换句话说,当用户往某个分区发请求时,这个分区自己就决定放行还是拒绝该请求。这种分区粒度的准入控制无法处理非均匀访问模式导致的热点,往往在整体容量闲置的情况下依然出现局部节流,因为每个分区的准入控制只能感知自身的吞吐量和资源使用情况。因此如果能够将准入控制从分区中剥离出来,让分区始终保持可 burst,同时又能保证不同工作负载之间的隔离,将更为高效。

为此,DynamoDB 用全局准入控制(Global Admission Control,GAC)取代了原有的自适应容量。GAC 依然基于令牌桶思想运行:一个中央服务持续监控全表范围内的令牌消耗情况,每个请求路由器在本地维护一个令牌桶;当应用请求到达时,先尝试从本地令牌桶中扣除令牌;当本地令牌不足时,再向 GAC 请求新的令牌。GAC 根据各路由器提交的消耗信息,动态计算并分发下一时间窗口内可用的全局令牌份额,确保即便流量集中在表中某些键上,也不会超过单个分区的最大处理能力。

img

与此同时,为了多层防护,DynamoDB 保留了分区级的令牌桶,并对其容量进行了上限限制,以防某个应用独占节点资源或过度消耗其存储节点上的吞吐量。这样,GAC 实现了跨分区的全局流量调度,而分区级令牌桶则继续在最底层保障多租户隔离。

平衡消耗的容量

让分区始终保持突发(bursting)能力,就需要 DynamoDB 对突发容量进行有效管理。DynamoDB 在多种硬件实例类型上运行,这些实例在吞吐量和存储能力上各不相同。最新一代的存储节点上往往承载着数千个分区副本,这些分区可能完全无关联,属于不同表,甚至不同客户,而各表的访问模式也千差万别。要将这些副本安全地部署在同一节点上,又能保证可用性、稳定的性能、安全性和弹性,就必须设计出合理的分配方案。

如果只用预配置吞吐量,那很好办:分区数固定,按容量找机器,根本不用担心某个分区会多吃流量。但有了突发和自适应后,分区可能随时超出预设容量使用突发资源,这就意味着某些节点在短时内可能会超载,导致把多个数据分区放到同一台机器上变得棘手。

因此,为了在不牺牲可用性的前提下,提高节点利用率,DynamoDB 实现了一套主动均衡系统:每个存储节点独立监控其上所有分区副本的吞吐量和数据大小,一旦发现总吞吐量超过节点最大容量的阈值,就会向自动管理服务报告一批候选迁移分区。自动管理服务再为这些分区寻找新的存储节点(可在同可用区或跨可用区),确保新节点上尚未存在该分区的副本,从而将它们安全地搬迁出去,降低过度紧凑部署带来的可用性风险。

拆分消费

DynamoDB 在引入全局准入控制和始终可突发能力后,发现当流量高度集中在某些键上时,仍可能出现节流。为此,它会根据分区的实际吞吐量自动扩展:当某个分区的消耗超过阈值时,系统会根据该分区的访问分布(而不是简单地把键范围对半拆分)选择最佳拆分点,将其分成两个子分区。这样可以更精准地将热点区域隔离出来,不过对于只针对单个键或按顺序访问整个键范围的场景,此方法并无优势;对此,DynamoDB 会自动识别并避免执行拆分操作。

按需配置

DynamoDB 还推出了按需表(On-Demand Tables)模式,帮助之前在本地或自建数据库上运行、需要手动配置服务器的应用解放运维负担,是一种无需用户预先规划吞吐量即可弹性扩缩的无服务器模式。按需表通过读写容量单位(RCU/WCU)来自动弹性扩缩,系统会实时监控实际的读写请求量,并能瞬间承载到达表上的流量峰值的两倍。如果后续流量超过此前最大峰值的两倍,DynamoDB 会不断新增分区并按流量情况拆分,以保证应用不会因超出配额而被限流。全局准入控制则负责从整体上监控并保护系统,防止单个应用抢占所有资源;加上基于消耗量的智能分区调度,按需表能够高效利用节点资源,避免触及节点级别的容量上限,让应用在任何突发流量下都能平稳运行。

持久性和正确性

硬件失败

DynamoDB 将预写日志存储在一个分区的所有三个副本中。为了获得更高的持久性,这些预写日志会定期归档到 S3——一个设计上可提供 99.999999999% 持久性的对象存储服务。每个副本仍保留最接近归档时间的日志,这些未归档的日志通常只有几百兆字节大小。

在大型服务中,内存或磁盘等硬件故障时有发生。一旦某个节点发生故障,该节点上承载的所有复制组就会降至两份副本。修复存储副本的过程可能需要数分钟,因为此过程不仅要复制 B 树结构,还要复制预写日志。为快速恢复,当领导副本检测到某个存储副本不健康时,它会立即添加一个日志副本,以确保持久性不受影响。添加日志副本只需几秒钟,因为系统只需从健康副本拷贝最近的预写日志,而无需复制 B 树结构。通过这种仅复制日志的快速修复方式,DynamoDB 能够在绝大多数情况下,保证最近写入操作的高持久性和系统的持续可用性。

静默数据错误

静默数据错误是指在数据存储或传输过程中发生的错误,但并未被系统的常规检测机制(如硬盘固件、操作系统或内存 ECC)发现,从而导致数据不正确却依然被认为是正确的现象。

静默数据错误的来源多种多样,通常与硬件缺陷或环境因素有关:

  • 存储介质固件或驱动中的漏洞可能在擦写或读取过程中引入错误;
  • 内存中的软错误,如宇宙射线或电磁干扰引起的单比特反转,若未使用 ECC 内存,则无法检测和纠正;
  • 网络传输过程中,数据包在多层转换或路由中也可能被篡改而未被底层校验机制捕获;
  • CPU 自身的硬件缺陷或老化也可能导致运算结果错误,如计算 1+1=3,却不会被硬件纠错方案发现。

在 DynamoDB 的实践中,某些硬件故障可能导致存储介质、CPU 或内存中出现错误,进而引发数据不正确的情况。由于这些错误往往是隐蔽的、随机出现且难以检测,DynamoDB 广泛使用校验和机制来发现静默错误。在每一条日志记录、消息以及日志文件内部都维护有校验和,用于在节点间的每次数据传输时验证数据完整性。这些校验和如同护栏,能够在各个层面防止错误蔓延。例如,在节点或组件之间传递的每条消息都会计算并校验校验和,因为消息在到达目的地之前可能会经过多层转换,任何一层都可能引入隐性错误。

每个归档到 S3 的日志文件都配有一个清单,其中记录了该日志所属的表、分区,以及日志文件中数据的起止标记。负责将日志文件归档到 S3 的代理在上传数据前会执行多项校验,包括但不限于:验证每条日志记录是否属于正确的表和分区;校验每条记录的校验和以发现任何静默错误;检查日志文件的序列号是否连续无缺。只有在所有校验通过之后,日志文件及其清单才会被归档。归档代理在复制组的三个副本上均运行;如果某个代理发现日志文件已被归档,它会下载该文件并与本地的预写日志进行比对,以再次验证数据完整性。此外,每个日志文件和清单文件在上传到 S3 时都带有内容校验和,S3 在执行上传操作时会核对该校验和,从而防范数据在传输过程中的任何损坏。

连续性校验

DynamoDB 会持续对静态存储中的数据进行校验,以期发现任何未被预见的静默数据错误或比特衰变。

一种典型的连续校验机制是擦洗,该流程的目标有两点:

  • 一是验证复制组中三份副本的数据是否完全一致;
  • 二是将在线副本的数据与通过归档的预写日志条目离线重建的副本进行比对。

离线重建流程详见下一小节。验证时,系统会计算在线副本的校验和,并将其与从 S3 中归档日志条目生成的快照校验和进行比对。

擦洗机制作为多层防护的一环,用于检测在线存储副本与基于日志历史重建副本之间的任何不一致性。这些全面的校验手段极大地增强了对运行中系统的信心。同时,全局表副本也采用了类似的连续校验技术。开发者发现,对静态存储数据进行持续校验,是防范硬件故障、静默数据损坏乃至软件缺陷的最可靠方法。

备份和恢复

除了防范物理介质损坏之外,DynamoDB 还支持备份与恢复机制,以防止客户应用程序中的逻辑错误造成的数据损坏。备份和恢复操作不会影响表的性能或可用性,因为它们是基于已归档至 S3 的预写日志构建的。这些备份在多分区范围内可达最接近秒级的一致性,而且它们都是 DynamoDB 表的完整副本,存储于 Amazon S3 存储桶中,用户可随时将备份数据恢复到新的 DynamoDB 表中。

此外,DynamoDB 支持按时间点恢复(Point-in-Time Restore,PITR)。通过 PITR,用户可以将表在过去 35 天内任意时间点的内容恢复到同一区域内的另一张表中。对于启用了按时间点恢复功能的表,DynamoDB 会定期对该表所属的分区进行快照并上传至 S3,快照的生成频率由该分区累积的预写日志量决定。也就是说,DynamoDB 通过快照与预写日志的配合使用,来实现按时间点恢复。当用户发起恢复请求时,DynamoDB 会为表的各个分区选取最接近请求时间的快照,应用至该时间点的预写日志,生成恢复用的表快照,并完成恢复操作。

高可用性

为实现高可用性,DynamoDB 表在一个区域内跨多个可用区进行分布和复制,并定期通过模拟真实流量的断电测试来验证对节点、机架及可用区故障的容灾能力。测试过程中,调度器会随机切断部分节点电源,随后工具会检查数据库中的数据在逻辑上保持完整且无损坏,从而确保系统在经历多种故障场景后仍能持续提供高度可靠的存储服务。

写和一致性读的可用性

写入能不能继续,关键就在于有没有领导者副本(leader)在正常工作,以及能不能凑齐足够的投票副本(write quorum)来同意这次写操作。在 DynamoDB 里,三个可用区(AZ)里各有一份数据,只要其中任意两份还能正常响应,就能完成写入。要是某个副本突然挂了,leader 会立刻再拉来一个日志副本(log replica)顶替它,这样写操作就不会因为缺少投票而被卡住。

img

img

[!NOTE]

在 DynamoDB 的 Multi-Paxos 共识中,日志副本 与普通数据副本具有相同的投票权重:当 leader 发起写入时,它会在 Phase 1(Prepare)和 Phase 2(Accept)中向所有活跃副本,包括日志副本,发送 Paxos 消息,日志副本在本地持久化写前日志后,会对 Accept 请求投下赞成票,以满足多数要求,从而使写操作被确认并返回给客户端。日志副本由于只存储日志记录,无需同步完整数据结构,能快速上线并参与投票,大幅缩短恢复时间,确保写入可用性不被中断。

至于一致性读取(consistent read),只有 leader 能给用户最新、最靠谱的数据;而最终一致性读取(eventual consistent read)则可由任意副本响应,哪怕数据还没完全同步。要是 leader 出问题,其他副本会迅速发现,并自动推举出一个新的 leader,这样读写服务就能尽快恢复,不会大面积中断。

失败检测

新的 leader 当选后,必须等待旧 leader 的租约到期才能开始提供写入和强一致性读取服务,这一过程虽然只需数秒,却会在这段时间内阻断新写入和一致性读取,影响可用性。为了尽快发现 leader 故障并将此窗口期降至最低,DynamoDB 采用了快速且可靠的故障检测机制:当某个副本长时间未收到 leader 的心跳时,它会向同组的其他副本询问它们是否仍能与 leader 通信;若其他副本反馈 leader 健康,则放弃发起选举,从而避免因灰度网络故障,如节点与 leader 间的单向通信中断或路由故障,导致的误判与不必要的 leader 选举。该改进显著减少了系统中因误判而触发的冗余选举,提升了整体高可用性。

测量可用性

DynamoDB 针对全局表(Global Tables)和区域表(Regional Tables)分别设计了 99.999% 和 99.99% 的可用性目标;可用性按每 5 分钟区间内成功处理请求的比例来计算。为确保达标,DynamoDB 在服务级别和表级别持续监控可用性指标,实时跟踪并在错误率超过阈值时触发面向用户的告警,以便自动或人工干预;同时,每日汇总各用户的可用性数据并上传至 S3,供后续趋势分析。此外,DynamoDB 还通过两类客户端监测用户侧感知可用性:一是使用 DynamoDB 作为数据存储的内部亚马逊服务,二是部署在各可用区并通过所有公网端点访问的金丝雀应用(Canary Applications)。真实流量和灰度故障检测能帮助 DynamoDB 全面评估并优化客户实际体验到的可用性与延迟。

[!NOTE]

金丝雀应用是一种合成监控和灰度测试手段,通过在生产环境中运行专门的轻量级客户端或小规模流量,持续地向系统发起真实或模拟请求,以监测系统在不同地理区域、不同网络路径和不同服务端点下的可用性和性能表现。这些应用通常分布在各可用区内,通过定期及实时的请求和心跳检测,帮助团队提前发现灰度网络故障、API 错误或性能退化,及时触发告警并辅助故障排查,从而确保用户侧感知到的可用性和延迟符合服务级别目标。

部署

与传统关系型数据库不同,DynamoDB 在部署过程中无需停机维护窗口,也不会影响客户体验到的性能和可用性。软件部署通常用于引入新功能、修复缺陷和优化性能,往往涉及对多个服务的更新。DynamoDB 以固定节奏推送软件更新,将系统从一种状态切换到另一种状态。新版本的软件在部署前会经历完整的开发和测试流程,以确保代码的正确性。多年来,在多次部署实践中,开发者认识到不仅要关注初始状态和目标状态,还需考虑在某些情况下新版本可能无法正常工作,需回滚至先前版本,而回滚后的状态往往与最初的版本并不完全相同。回滚流程如果在测试中被忽略,可能会对客户造成影响。为此,DynamoDB 在每次部署前都会在组件级别运行一整套升级和降级测试,主动执行回滚操作并运行功能测试,以便及早发现那些仅在回滚时才会显现的问题。

在分布式系统中,将软件部署到单个节点与部署到多个节点存在本质差异。部署操作并非原子性的;在部署过程中,集群中始终会有部分节点运行旧版本代码,另一些节点运行新版本代码。更复杂之处在于,新版本可能引入新的消息格式或修改协议,使得旧版本节点无法解析。DynamoDB 通过先读后写(read-write)部署模式来应对这类变更。该流程分为多个步骤:首先部署能够识别新消息格式或协议的版本,确保所有节点都能读取新消息;然后再启用新消息的发送功能。通过此种方式,新旧消息可在系统中并存,即使在回滚的情况下,系统仍能识别两种消息格式。

所有更新都会先在少量节点上进行灰度发布,以降低因部署故障带来的潜在风险。同时,DynamoDB 会对可用性指标设定告警阈值:若部署过程中错误率或延迟超出阈值,则自动触发回滚,将系统迅速恢复到上一个稳定版本。针对存储节点的软件部署,系统还会触发领导者副本切换:旧领导者主动放弃领导权,新领导者在无需等待旧领导者租约过期的情况下立即接手,从而保证可用性不受影响。

依赖外部服务

为了确保高可用性,DynamoDB 在请求路径中所依赖的所有服务,其可用性都应优于 DynamoDB;或者在这些依赖服务性能受损时,DynamoDB 仍能继续运行。DynamoDB 在请求路径中所依赖的服务示例包括用于身份验证的 AWS 身份与访问管理服务(IAM),以及对使用客户主密钥加密的表进行加密/解密的 AWS 密钥管理服务(KMS)。DynamoDB 利用 IAM 和 AWS KMS 对每一次客户请求进行身份验证。尽管这些服务本身具有很高的可用性,DynamoDB 的设计目标是:即使它们暂时不可用,也不影响 DynamoDB 的正常运行,并保持相同的安全保障。

对于 IAM 和 AWS KMS,DynamoDB 采用了一种静态稳定(statically stable)设计,即当某个依赖出现故障时,系统仍能保持运行。虽然可能无法获取该依赖在故障后才会更新的信息,但在故障发生之前已经获得的信息仍可继续使用,确保系统功能不受影响。具体而言,DynamoDB 会在执行请求认证的路由器上缓存来自 IAM 和 AWS KMS 的验证结果,并以异步方式定期刷新这些缓存。如果 IAM 或 KMS 服务出现不可用情况,路由器仍能在预定的延长时间内继续使用缓存结果。只有当客户端的请求被路由到那些尚未缓存相关结果的路由器时,才会受到影响;但在实际运行中,即便 IAM 或 KMS 性能受损,对整体服务的影响也极为有限。此外,通过本地缓存验证结果,还能在系统高负载时减少对外部调用次数,从而加快响应速度并提升系统吞吐性能。

元数据可用性

请求路由器所需的最重要的元数据之一是表的主键与存储节点之间的映射关系。DynamoDB 最初将这些路由信息存储在 DynamoDB 表自身中,该路由信息包括表的所有分区、每个分区的键范围,以及托管该分区的存储节点。当路由器接收到一个之前未见过的表的请求时,它会下载该表的全部路由信息并本地缓存。由于分区副本的配置信息很少变化,缓存命中率约为 99.75%。然而,这种缓存机制也带来了双峰性能表现的问题:在冷启动(缓存为空)时,路由器对每个请求都需要进行一次元数据查询,导致元数据服务的流量骤增,最高曾占到路由器总请求的 75%,从而影响系统性能并可能导致不稳定。此外,当缓存失效时,过多的直接查询压力还可能引发级联故障。

为了解决这一问题,DynamoDB 构建了一个名为 MemDS 的分布式内存存储系统,用以存放所有元数据并在 MemDS 集群中复制。MemDS 支持水平扩展,可处理 DynamoDB 的全部入站请求流量;其内部采用 Perkle 数据结构(Patricia 树与 Merkle 树的混合体),既可通过完整键或键前缀进行查找,也支持小于、大于和区间等范围查询,以及特殊的 floor(不大于指定键的最大键)和 ceiling(不小于指定键的最小键)两种操作。

在每台请求路由器上部署了新的分区映射缓存,替代原有的冷启动式本地缓存。新的缓存策略是:无论命中与否,都会异步向 MemDS 发起刷新请求,以确保 MemDS 集群持续承接稳定的流量。虽然这会增大元数据集群的负载,但可防止当缓存失效时对系统其他部分造成级联压力。

值得注意的是,DynamoDB 存储节点才是分区成员信息的权威来源。存储节点会将分区成员的更新推送至 MemDS,并同步到所有 MemDS 节点。如果路由器从 MemDS 获取到的成员信息已过期,则它会尝试联系该分区所在的存储节点:存储节点要么返回最新成员信息,要么返回错误码,触发路由器再次向 MemDS 查询,从而保证请求始终能被正确路由。

地理区域失败

AWS 在全球设立了多个大型地理区域(如 us-east-1、ap-southeast-1),每个区域由多个物理数据中心组成。那现在假设某个应用部署在 us-east-1 读写 DynamoDB 表,但某天该 Region 遭遇网络中断、数据中心宕机或 KMS 密钥失效,导致服务不可用。此时,所有对该表的读/写请求将失败,影响业务可用性。所以,开发者需要保障即使某 Region 宕机,也能无缝提供服务,同时保证不同地区的用户可以就近访问数据。那么这就是 DynamoDB 全局表的用武之地。

img

为 DynamoDB 表启用全局表,会在多个 Region 如 us-east-1, eu-west-1, ap-northeast-1 等生成完全对等的副本,并实现数据自动同步。所有副本都支持读写请求,允许客户端按需选择 Region,你同样可以选择“任一区域写入”模式,根据数据幂等性控制冲突。更新是异步的,多个 Region 可能产生延迟;若同一个条目(数据)在不同 Region 同时写入,DynamoDB 内置机制根据时间戳采用 “Last‑Writer‑Wins” 规则解决冲突。

img

上图中,每个区域既可本地读写,又会异步将写操作通过 DynamoDB Streams 复制到其它区域;当同一条记录在不同区域近乎同时发生更新时,Global Tables 会基于写入时间戳择优保留“最新”版本,确保最后全局一致。

如果某个 Region 宕机,全局表继续在其他 Region 上读写,不受影响。宕机区域恢复后,会自动同步补全所有挂起写入数据,无需人工干预。

假设有两个区域 A 和 B,那么两者的服务都可用时的流程如下:

img

客户端(App A)向 Region A 的 DynamoDB 表写入数据,写操作一旦在 Region A 达到持久化,立即返回成功给 App A,保证写入延迟最低(最终一致性或强一致性均可选)。DynamoDB 利用 Global Tables 机制,通过内部的 DynamoDB Streams 将写操作异步推送到 Region B 的副本表,在平时可让 Region B 的应用(App B)直接从 Region A 读取最新数据。副本表会在通常 1 秒内同步到最新写入项,Get 操作返回数据给 App B。

如果完成上图的第五步之后,Region A 的数据存储宕机,那么 DynamoDB 基于全局表的故障回复和切换如下:

img

通过 DNS 或客户端配置,将写流量切换到 Region B。切换后,App B 向 Region B 写入新数据,Region B 本地确认写入成功。当 Region A 恢复后,全局表会将 Region B 的变更异步推回 Region A。

复制

Region 内复制

多个可用区(AZ1、AZ2、AZ3)通过高速专有网络互联,数据同步延迟通常在毫秒级。每个 AZ 内都有一份完整数据副本,读写请求可路由至任一 AZ,以提升性能和可用性。

在同一区域内部署的分布式存储通常通过同步复制协议(如 Paxos、Raft)保证写操作在所有 AZ 同步完成后再返回成功,从而确保读写顺序严格一致。

单个 AZ 故障时,流量可自动切换到其他 AZ,减少服务中断,满足 99.99% 甚至 99.999% 的 SLA 要求。每份数据至少存储在三个物理位置,具备抗硬件损坏和局部灾难的能力,典型耐久性可达 11 个 9(99.999999999%)。

跨 Region 复制

数据从主区域(Region A)异步复制到一个或多个目标区域(Region B、Region C)。跨区域复制常用于灾备、合规和全球用户读写场景。

主要关注点

  • 写入性能:跨区域的往返时延通常在数十至数百毫秒之间,远高于区域内几十毫秒的延迟,影响写操作的吞吐量和响应时间。为了避免同步复制带来的高延迟,多数场景采取异步复制,但这会牺牲一致性和可能造成数据丢失。
  • 故障爆炸半径:一旦主区域出现故障,异步复制可能无法及时到达目标区域,导致大量未复制的数据面临丢失风险,同时故障影响范围横跨多个地理区域。因此需设计分片或多活策略,将流量和数据分散,以尽量缩小单点故障影响范围。
  • 算法超时:跨区域通信更易遭遇丢包和波动,可能导致复制协议中同步或心跳消息超时。需要在复制算法中引入指数退避、幂等操作和批量确认等机制,以避免超时重试带来的性能降级。

DynamoDB Streams 是 DynamoDB 提供的变更数据捕获机制,它记录表中每次写操作的顺序日志并保留 24 小时,供下游应用实时消费。

跨区域的复制流程具体如下:源区域(Region A)通过 DynamoDB Streams 将写操作的变更记录捕获到一个持久化日志中(Stream),然后由一组可横向扩展的 RepOut 进程并行地消费这些变更,打包成包含主键、属性和时间戳的写入请求(Put(k, v, ts)),并异步发送到目标区域(Region B)。目标区域由一组 RepIn 进程并行接收这些请求,并在写入本地表前,基于附带的时间戳与本地存储的版本进行比较,仅当本地版本更旧时才执行写入,以实现幂等性和冲突解决。通过在 RepOut/RepIn 层面使用多进程或无服务器函数(如 Lambda)对不同 Stream 分片或写请求进行分片处理,并结合批量提交与并发控制,就能在线性扩展吞吐的同时保证同分区写入的顺序性和最终一致性。

img

Recovery Point Objective(RPO)是灾难恢复和业务连续性规划中的关键指标,定义了在发生故障或灾难时,允许丢失数据的最长时间窗口。它决定了需要多频繁地进行备份或数据复制,以保证在灾难发生后仍能接受的数据损失范围。

若主站在此窗口内发生故障,流式日志中未传输的更新就会丢失,造成 “Lost Updates” 问题。具体流程如下:

  1. 应用在区域 A 执行 Put(k, v1),本地存储和流式层记录 k, v1, ts1,立即返回成功。
  2. 在 RepOut 将 k, v1, ts1 发送到区域 B 前,区域 A 突发故障(网络中断或宕机),流式层中尚未传输的 v1 列入 RPO 窗口,随故障一起丢失。
  3. 故障切换后,应用在区域 B 执行 Get(k) 得到旧值 v0,再执行 Put(k, v2 = v0 + 1)
  4. 新写入 v2 覆盖了原本应该是 v1 的内容,导致先前区域 A 的更新完全丢失,即 “Lost Update”。

为有效防范 “Lost Updates” 在故障切换时的发生,首先应尽量缩短 RPO 窗口,即将异步复制升级为半同步或全同步复制,确保主库在确认写入时也等待至少一个备库的持久化 ACK,从而将未复制数据丢失的概率降至最低;与此同时,可为敏感或高价值的写操作采用混合复制策略,仅对关键数据启用同步复制,其余业务继续使用异步复制以兼顾性能与成本。其次,优化网络与资源配置,提升 RepOut/RepIn 通道的带宽和优先级,减少复制积压,并结合近连续数据保护或实时流式复制技术,将数据写入与传输延迟控制在秒级以内,以实现接近零 RPO。最后,强化应用层幂等与冲突检测,通过在写操作中引入版本号、幂等键或乐观并发控制策略,在 RepIn 应用更新时比对版本号而非仅凭时间戳判断新旧,或采用 CRDT 等机制进行多副本冲突合并,以保证即便出现复制延迟也不会因时间戳“后写胜出”而丢掉任何有效更新。

编程接口

读写操作

由于 DynamoDB 是一个键值存储,应用程序最常用的操作包括:

  • 读取项(GetItem)
  • 插入项(PutItem)
  • 更新项(UpdateItem)
  • 删除项(DeleteItem)

这其中,插入、更新和删除操作统称为写操作(writes)。写操作可以可选地指定一个条件,只有当该条件得到满足时,操作才会成功执行。

事务操作

DynamoDB 提供了两种事务操作:

  • TransactGetItems(读取事务)
  • TransactWriteItems(写入事务)

这些操作是以单次请求提交的,要么全部成功,要么立即失败,不会阻塞。TransactGetItemsTransactWriteItems 相对于其他 DynamoDB 操作按可串行化顺序执行。

TransactGetItems 会从一个或多个表中检索最新版本的项,并在同一时间点读取它们,返回的是一致快照。如果有其他冲突操作正在修改任一读取项,请求将被拒绝。

TransactWriteItems 是同步且幂等的写入事务操作,可原子性地在一个或多个表中执行多个项的创建、更新或删除操作。它使用客户端请求令牌来保证幂等性,并可附带对当前项值的预先条件校验。只要任一个预条件不成立,请求即被拒绝。

假设我们有一个在线市场应用,涉及客户(Customers)、产品(Products)和订单(Orders)三张表:

  • Customers 表以客户 ID 为主键,存储客户信息及地址;
  • Products 表以产品 ID 为主键,存储产品价格和库存状态;
  • Orders 表以订单 ID 为主键,存储完整订单记录。

在处理一次订单时,需要确保:

  1. 客户账户已通过验证;
  2. 产品处于可用状态并更新为已售出;
  3. 订单条目已创建。

这些操作应当作为一个事务执行,否则可能造成数据不一致。

例如,在购买一本书的事务中:

  • 使用 ConditionCheck:在 Customers 表验证客户存在,但不修改任何数据;
  • 使用 UpdateItem:在 Products 表确认书籍库存并将其标记为已售;
  • 使用 PutItem:在 Orders 表创建订单记录。

整个过程在一个 TransactWriteItems 请求中完成,确保步骤要么全部成功,要么全部失败,保持数据一致。

该事务写入的具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Check if customer exists
Check checkItem = new Check()
.withTableName("Customers")
.withKey(Collections.singletonMap("CustomerId", new AttributeValue("CustomerUniqueId")))
.withConditionExpression("attribute_exists(CustomerId)");

// Update status of the item in Products
Update updateItem = new Update()
.withTableName("Products")
.withKey(Collections.singletonMap("ProductId", new AttributeValue("BookUniqueId")))
.withConditionExpression("expected_status = :expected")
.withUpdateExpression("SET ProductStatus = :newStatus")
.withExpressionAttributeValues(Map.of(
":expected", new AttributeValue("IN_STOCK"),
":newStatus", new AttributeValue("SOLD")
));

// Insert the order item in the Orders table
Put putItem = new Put()
.withTableName("Orders")
.withItem(Map.of(
"OrderId", new AttributeValue("OrderUniqueId"),
"ProductId", new AttributeValue("BookUniqueId"),
"CustomerId", new AttributeValue("CustomerUniqueId"),
"OrderStatus", new AttributeValue("CONFIRMED"),
"OrderCost", new AttributeValue().withN("100")
))
.withConditionExpression("attribute_not_exists(OrderId)");

// Assemble the transaction request
TransactWriteItemsRequest twiReq = new TransactWriteItemsRequest()
.withTransactItems(
new TransactWriteItem().withCheck(checkItem),
new TransactWriteItem().withUpdate(updateItem),
new TransactWriteItem().withPut(putItem)
);

// Single transaction call to DynamoDB
AmazonDynamoDB client = AmazonDynamoDBClientBuilder.defaultClient();
client.transactWriteItems(twiReq);

事务执行

img

事务路由

所有发送到 DynamoDB 的操作都会先到达一组称为请求路由器的前端主机。请求路由器负责对每个请求进行认证并根据访问的键,将请求路由到相应的存储节点。键范围(key‑range)与存储节点的映射关系由元数据子系统管理。

与非事务请求类似,每个事务操作也首先由请求路由器接收。路由器会进行必要的请求身份验证和权限授权,然后将请求转发给一组事务协调器。事务协调器集群中的任意一个实例都可以接管任意事务。

事务协调器将事务分解为针对各个条目的子操作,并启动一套分布式协议,由这些数据项所在的存储节点参与执行。上图展示了执行一笔事务时,所涉及的各组件的高层架构图。

时间戳顺序

DynamoDB 使用时间戳排序(Timestamp Ordering)机制来定义事务的逻辑执行顺序。当收到事务请求后,事务协调器会使用其当前时钟的值为该事务分配一个时间戳。

为了应对大规模的事务负载,系统中运行着大量并行工作的事务协调器,不同的协调器会为不同的事务分配时间戳。只要事务的执行顺序与其分配的时间戳一致,就可以确保可串行性。

一旦时间戳分配完成并通过了预检查,参与该事务的存储节点就可以独立执行各自负责的操作,无需进一步协调。每个存储节点各自负责保证涉及其数据项的请求按正确顺序执行,并拒绝那些无法正确排序的冲突事务。

即便事务协调器之间的时钟未严格同步,只要维持事务在时间戳上的一致性,也可以保证可串行性。但若协调器的时钟越精确,成功事务的比例就越高,而且得到的事务序列越贴近真实时间顺序。

DynamoDB 的事务协调器从 AWS 提供的时间同步服务(AWS Time Sync Service)获取时间,因此多个协调器之间的时钟能维持在微秒级别的同步。

然而,即使所有时钟完全同步,事务在传输过程中仍可能因为网络延迟、事务协调器的故障与恢复等问题,导致事务在存储节点上的到达顺序不一致。为了解决这一问题,存储节点会使用存储的时间戳信息来处理任意顺序到达的事务请求。

每个事务在进入存储节点前,都会带上事务协调器分配的时间戳 T。存储节点为每个条目维护了一个记录该项最后执行事务的最大时间戳lastCommittedTimestamp)。当节点收到一个事务请求时,它会:

  1. 检查 T 是否 ≥ lastCommittedTimestamp
  2. 如果满足,则执行这个事务,并将 lastCommittedTimestamp 更新为 T;
  3. 否则(即 T 比已有时间戳小),说明该事务时间在线谱中落在历史里,节点会拒绝该请求,避免生成不符合时间戳顺序的写入。

写事务的二阶段提交协议

img

两阶段协议确保事务中的所有写入操作都能原子性执行并保持正确顺序。为实现原子性,事务协调器首先在第一阶段对所有待写入项执行准备(prepare)。如果所有存储节点都接受该事务,则第二阶段协调器发出提交(commit)指令以执行写操作;若有任一节点无法接受,则协调器会取消该事务。以下代码展示了 TransactWriteItem 协议的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def TransactWriteItem(transact_write_items):
# Prepare all items
TransactionState = "PREPARING"
for operation in transact_write_items:
sendPrepareAsyncToSN(operation)

waitForAllPreparesToComplete()

# Evaluate whether to commit or cancel the transaction
if all_prepares_succeeded():
TransactionState = "COMMITTING"
for operation in transact_write_items:
sendCommitAsyncToSN(operation)
waitForAllCommitsToComplete()
TransactionState = "COMPLETED"
return "SUCCESS"
else:
TransactionState = "CANCELLING"
for operation in transact_write_items:
sendCancellationAsyncToSN(operation)
waitForAllCancellationsToComplete()
TransactionState = "COMPLETED"
return getReasonForCancellation()

为了实现写事务的时间戳排序机制,DynamoDB 在每次写操作(无论是单项写入还是事务写入)后都会将该事务的时间戳记录在对应项上。此外,存储节点还会为每个正在进行的事务持久化事务元数据,包括事务 ID 和时间戳。这些元数据附带在事务涉及的项上,并在分区重分裂等事件中随项迁移,从而确保事务执行不被结构变更干扰,可并行进行。一旦事务结束,这些元数据即被清除。

在协议的准备阶段,事务协调器向每个主存储节点发送一条 prepare 消息,其中包含时间戳、事务 ID 和针对该项的拟执行操作。存储节点仅当以下所有条件都满足时,才接受该事务中的该项写入请求:

  • 该项满足所有预条件;
  • 该写操作未违反系统限制(如条目大小上限);
  • 事务的时间戳大于该项上次写入时记录的时间戳;
  • 当前没有其他已接受但尚未提交的事务尝试写入同一项。

以下代码展示了这一阶段的伪代码。需要指出的是,上述最后两个条件虽然正确,但较为严格,后续内容会讨论它们的松弛方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def processPrepare(input: PrepareInput):
item = readItem(input)

if item is not None:
if (
evaluateConditionsOnItem(item, input.conditions) and
evaluateSystemRestrictions(item, input) and
item.timestamp < input.timestamp and
item.ongoingTransactions is None
):
item.ongoingTransaction = input.transactionId
return "SUCCESS"
else:
return "FAILED"
else:
# item does not exist
item = Item(input.item)
if (
evaluateConditionsOnItem(input.item, input.conditions) and
evaluateSystemRestrictions(input) and
partition.maxDeleteTimestamp < input.timestamp
):
item.ongoingTransaction = input.transactionId
return "SUCCESS"

return "FAILED"

若所有参与存储节点都接受,则事务协调器进入提交阶段;若任一节点拒绝,则协调器发出事务取消指令。提交/取消过程的伪代码如下。在提交阶段,参与节点执行本地项的写入,并将项的最后写入时间戳更新为该事务的时间戳;对于只带预条件检查但未修改的项,其时间戳也同步更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def processCommit(input: CommitInput):
item = readItem(input)

if item is None or item.ongoingTransaction != input.transactionId:
return "COMMIT_FAILED"

applyChangeForCommit(item, input.writeOperation)
item.ongoingTransaction = None
item.timestamp = input.timestamp
return "SUCCESS"


def processCancel(input: CancellationInput):
item = readItem(input)

if item is None or item.ongoingTransaction != input.transactionId:
return "CANCELLATION_FAILED"

item.ongoingTransaction = None

# item was only created as part of this transaction
if item.wasCreatedDuringPrepare:
deleteItem(item)

return "SUCCESS"

当所有参与节点完成提交或取消操作后,事务协调器向请求路由器发送“事务完成”响应,指明事务是否成功提交。请求路由器再将结果返回给客户端。

对于已删除的项,由于无法继续维护最后写入时间戳,因此 DynamoDB 不使用 tombstone(永久删除标记),以避免高额存储成本及垃圾回收开销。取而代之的是,每个分区维护一个最大删除时间戳。当事务删除某项时,如果其时间戳高于当前分区的最大删除时间戳,就更新该时间戳。后续若准备写入一个在当前分区中不存在的项,存储节点会将新事务的时间戳与此分区的最大删除时间戳进行比较,以决定是否接受该事务。这种按分区划分的时间戳设计既正确又高效。

在当前机制下,部分事务可能因其时间戳低于分区最大删除时间戳而被取消,而如果采用 tombstone 机制,这类事务本可提交。但实践中,此类取消事务占比极低,不会影响系统性能与一致性。

读事务协议

DynamoDB 采用了一种两阶段无写入协议来处理只读事务,与写事务及其他系统有显著不同。为了避免在每次读操作中维护持久化且复制的数据的读取时间戳,引入了额外的延迟和成本,DynamoDB 发明了这种高效方案来执行读取事务。

协议的第一阶段:事务协调器会读取事务读取集合(read‑set)中的所有项。如果有任一项正被其他写事务处理,读取事务立即失败;否则进入第二阶段。存储节点在返回每个项的值时,还会附带该项的当前已提交的日志序列号(LSN),它代表该存储节点上最后一次写操作的序列编号,且是单调递增的。

协议的第二阶段:重新读取这些项,并对比第一阶段返回的 LSN。如果所有项的 LSN 均未改变,说明在两次读取期间未被修改,读取事务成功返回所读值;如果有任一项的 LSN 已变,则读取事务失败 。

无论事务成功或失败,存储节点都会返回 LSN,这让事务协调器能够在出现冲突时,仅针对变化的项重新执行读取,而无需回到事务开始阶段,从而避免完全重启。如果某项正处于写事务的准备阶段,存储节点会直接拒绝该读取请求。

回复与容错

DynamoDB 支持自动从存储节点故障中恢复,因此存储节点故障并不会影响事务协议的执行。如果负责某个数据项的主存储节点发生故障,该分区会自动将主节点角色切换到复制组中的其他存储节点。以前主节点已接受的事务元数据被持久化并在复制组内同步,因此新主节点可以继续处理这些事务而不会丢失信息。事务协调器也不需要感知主节点发生了变更。

然而,事务协调器失败的问题更为关键。协调器会因硬件或软件故障而崩溃,为了确保事务的原子性和最终能够完成,协调器将每笔事务及其当前状态记录在一个事务账本(ledger)中 。系统中运行有多个恢复管理器,它们会定期扫描账本,查找已接收但在合理时间内未完成的事务,并将这些事务重新分配给新的事务协调器继续执行协议。如果协调器被误判为失败而启动了操作的重复执行,并不会带来问题,因为存储节点对重复提交的写操作会进行幂等处理,识别出已执行操作并忽略它们。

事务完成后,协调器会向账本中写入“已完成”记录,表明该事务不再需要处理。相关信息可在日志中保留,用于监控与调试,也可以在后续清理。账本一般设计为 DynamoDB 表,使用事务 ID 作为主键。此外,恢复管理器会并行地扫描账本,从随机起始键开始,处理上千条未完成事务。

存储节点自身也可触发恢复流程:当某项被挂起一段时间(如长期处于 prepare 状态),其他请求读写该项时,节点会向恢复管理器发送提示,包括事务 ID 与项键,后者再依据账本状态决定是否继续处理该事务。

如果账本中显示该事务尚未完成,恢复管理器会重新分配事务给协调器,继续执行事务的后续阶段(commit 或 cancel)。

事务账本中已标记完成或不在账本中,表示该事务可能已经成功提交或被取消。此时恢复管理器不会触发新的执行流程,存储节点也因此可以安全清理该事务的挂起状态。

Questions

Why does DynmoDB not use the two-phase locking protocol?

While two-phase locking is used traditionally to prevent concurrent transactions from reading and writing the same data items, it has drawbacks. Locking restricts concurrency and can lead to deadlocks. Moreover, it requires a recovery mechanism to release locks when an application fails after acquiring locks as part of a transaction but before that transaction commits. To simplify the design and take advantage of low-contention workloads, DynamoDB uses an optimistic concurrency control scheme that avoids locking altogether.

With DynamoDB, what is the role of a transaction coordinator?

The Transaction Coordinator plays a central role in handling transactions that span multiple items or storage nodes. The TC is responsible for:

  • breaking down the transaction into individual operations and coordinating these operations across the necessary storage nodes.
  • ensuring that the operations follow two-phase commit and all parts of the transaction are either completed successfully or rolled back.
  • assigning timestamps to ensure the correct ordering of operations and managing any potential conflicts between concurrent transactions.

Is DynamoDB a relational database management system?

No, DynamoDB is not a relational database management system. It is a NoSQL database, specifically a key-value and document store. Here’s how it differs from an RDBMS:

  1. Data Model: DynamoDB does not use tables with fixed schemas like relational databases. Instead, it stores data as key-value pairs or documents (JSON-like structure). Each item can have different attributes, and there’s no need for predefined schemas.
  2. Relationships: Relational databases focus on managing relationships between data (using joins, foreign keys, etc.), while DynamoDB is optimized for storing large amounts of data without complex relationships between the data items.
  3. Querying: RDBMSs typically use SQL for querying data, which allows for complex joins and aggregations. DynamoDB uses its own API for querying and does not support SQL natively. While it allows querying by primary key and secondary indexes, it doesn’t support joins.
  4. Consistency and Transactions: DynamoDB supports eventual consistency or strong consistency for reads, while traditional relational databases typically ensure strong consistency through ACID transactions. DynamoDB has introduced transactions, but they work differently compared to those in relational databases.
  5. Scalability: DynamoDB is designed for horizontal scalability across distributed systems, allowing it to handle very large amounts of traffic and data by automatically partitioning data. In contrast, RDBMSs are typically vertically scaled and are not as naturally distributed.

How is DynamoDB’s transaction coordinator different than Gamma’s scheduler?

  • DynamoDB’s transaction coordinator uses Optimistic Concurrency Control (OCC) to manage distributed transactions, ensuring atomicity without 2PC, focusing on scalability and performance in a globally distributed system.
  • Gamma’s scheduler, on the other hand, uses the traditional Two-Phase Locking (2PL) protocol to guarantee strong consistency in a distributed environment, prioritizing strict coordination across nodes.

Name one difference between FoundationDB and DynamoDB?

FoundationDB: FoundationDB is a multi-model database that offers a core key-value store as its foundation, but it allows you to build other data models (such as documents, graphs, or relational) on top of this key-value layer. It’s highly flexible and provides transactional support for different types of data models via layers.

DynamoDB: DynamoDB is a NoSQL key-value and document store with a fixed data model designed specifically for highly scalable, distributed environments. It does not offer the flexibility of building different models on top of its architecture and is focused on high-performance operations with automatic scaling.

What partitioning strategy does FoundationDB use to distribute key-value pairs across its StorageServers?

FoundationDB uses a range-based partitioning strategy to distribute key-value pairs across its StorageServers.

Here’s how it works:

  1. Key Ranges: FoundationDB partitions the key-value pairs by dividing the key space into contiguous ranges. Each range of keys is assigned to a specific StorageServer.
  2. Dynamic Splitting: The key ranges are dynamically split and adjusted based on data distribution and load. If a particular range grows too large or becomes a hotspot due to frequent access, FoundationDB will automatically split that range into smaller sub-ranges and distribute them across multiple StorageServers to balance the load.
  3. Data Movement: When a key range is split or needs to be rebalanced, the corresponding data is migrated from one StorageServer to another without manual intervention, ensuring even distribution of data and load across the system.

Why do systems such as Nova-LSM separate storage of data from its processing?

  • Independent Scaling: Storage and processing resources can scale independently to meet varying load demands.
  • Resource Optimization: Storage nodes focus on data persistence and I/O performance, while processing nodes handle computation, improving overall resource efficiency.
  • Fault Tolerance: Data remains safe in storage even if processing nodes fail, ensuring high availability.

Reference:

一组 SQL 操作的集合,不可分割的工作单位。也就是说,事务会把所有操作作为一个整体向系统进行提交或撤销,即这些操作要么同时成功,要么同时失败。

MYSQL 的事务默认是自动提交的,即当执行一条 DML 语句时,MYSQL 会立即隐式地提交事务。

查看/设置事务提交方式:

  • 查看自动提交状态:SELECT @@autocommit; (1 为自动提交,0 为手动提交)
  • 设置手动提交:SET @@autocommit = 0;

事务的回滚以转账为例:

开启事务
查询发起人账户余额
发起人账户余额 -1000
如果抛异常,回滚事务
接收人账户余额 +1000
结束事务

事务四大特性

  • 原子性(Atomicity):事务是不可分割的最小操作单元,全部成功或全部失败。
  • 一致性(Consistency):事务中必须保持所有数据处于一致状态,即从一个合法状态转向另一合法状态。
  • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行,即并发执行的事务是彼此隔离的。
  • 持久性(Durability):事务一旦提交或回滚,对数据库中的数据的改变是永久的(通过数据库的恢复和日志机制来实现)。

事务分类

从事务理论的角度来说,可以把事务分为以下几种类型:

  • 扁平事务 (Flat Transactions)
  • 带有保存点的扁平事务 (Flat Transactions with Savepoints)
  • 链事务 (Chained Transactions)
  • 嵌套事务 (Nested Transactions)
  • 分布式事务 (Distributed Transactions)

扁平事务是事务类型中最简单的一种,但在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由 BEGIN 开始,由 COMMIT 或 ROLLBACK 结束,其间的操作是原子的,要么都执行,要么都回滚。因此扁平事务是应用程序成为原子操作的基本组成模块。下图显示了扁平事务的三种不同结果。

img

扁平事务的主要限制是不能提交或者回滚事务的某一部分,或分几个步骤提交。

例如用户在旅行网站上进行自己的旅行度假计划。用户想从南昌到洛杉矶的圣莫妮卡,这两个城市之间没有直达的班机,需要用户预订并转乘航班,或者需要搭火车等待。用户预订旅行度假的事务为:

1
2
3
4
5
6
7
8
9
BEGIN 

S1:预订南昌到上海的高铁。

S2:上海浦东国际机场坐飞机,预订去洛杉矶的航班。

S3:在洛杉矶打 Uber 前往圣莫妮卡。

COMMIT

但是当用户执行到 S3 时,发现由于飞机到达洛杉矶的时间太晚,打不到 Uber 了。这时用户希望在 LAX 附近住一晚,第二天出发去圣莫妮卡。这时如果事务为扁平事务,则需要回滚之前 S1、S2、S3 的三个操作,这个代价就显得有点大。因为当再次进行该事务时,S1、S2 的执行计划是不变的。也就是说,如果支持有计划的回滚操作,那么就不需要终止整个事务。因此就出现了带有保存点的扁平事务。

带有保存点的扁平事务除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎平衡,也开销太大。保存点(Savepoint)用来通知系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。

对于扁平的事务来说,其隐式地设置了一个保存点。然而在整个事务中,只有这一个保存点,因此,回滚只能回滚到事务开始时的状态。保存点用 SAVE WORK 函数来建立,通知系统记录当前的处理状态。当出现问题时,保存点能用作内部的重启动点,根据应用逻辑,决定是回到最近一个保存点还是其他更早的保存点。下图显示了在事务中使用保存点。

img

上图中,灰色背景部分的操作表示由 ROLLBACK WORK 而导致部分回滚,实际上并没有执行的操作。当用 BEGIN WORK 开启一个事务时,隐式地包含了一个保存点;当事务通过 ROLLBACK WORK : 2 发出部分回滚命令时,事务回滚到保存点 2,接着依次执行,并再次执行到 ROLLBACK WORK : 7,直到最后的 COMMIT WORK 操作,这时表示事务结束,除灰色阴影部分的操作外,其余操作都已经执行,并且提交。

另一点需要注意的是,保存点在事务内部是递增的,这从上图中也能看出。有人可能会想,返回保存点 2 以后,下一个保存点可以为 3,因为之前的工作终止了。然而新的保存点编号为 5,这意味着 ROLLBACK 不影响保存点的计数,并且单调递增的编号能保持事务执行的整个历史过程,包括在执行过程中想法的改变。

此外,当事务通过 ROLLBACK WORK : 2 命令发出部分回滚命令时,要记住事务并没有完全被回滚,只是回滚到了保存点 2 而已。这代表当前事务还是活跃的,如果想要完全回滚事务,还需要再执行命令 ROLLBACK WORK。

链事务可视为保存点模式的一种变种。带有保存点的扁平事务,当发生系统崩溃时,所有的保存点都将消失,因为其保存点是易失的 (volatile),而非持久的 (persistent)。这意味着当进行恢复时,事务需要从头开始重新执行,而不能从最近的一个保存点继续执行。

链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的一样。下图显示了链事务的工作方式:

img

上图中,第一个事务提交触发第二个事务的开始。

链事务与带有保存点的扁平事务不同的是,带有保存点的扁平事务能回滚到任意正确的保存点。而链事务中的回滚仅限于当前事务,即只能恢复到最近一个的保存点,也就是说,每个子事务刚开始时,系统会“隐式地”在那个点打一个恢复点,但你没有机会在同一个子事务里再自己开第二个、第三个保存点。

对于锁的处理,两者也不相同。带保存点的扁平事务开始后,一直占有它所申请的所有行锁、表锁(或者更高级别的锁),即便你中途用 ROLLBACK TO SAVEPOINT 回滚到某个保存点,也只是撤销了回滚点之后的逻辑修改,但不会释放任何锁。而链事务中,每当一个子事务执行 COMMIT,它所持有的锁就立即释放,数据库中其它会话或下一个子事务都可以并发访问那些之前被锁定的资源。

嵌套事务将一个大事务组织成“事务树”——顶层事务(parent)控制若干子事务(child),子事务内又可有更深层的子事务,直到叶子节点。叶子节点的事务是真正向数据库发起操作的“扁平事务”,上层事务只负责逻辑控制与协调。

提交与生效

  • 子事务提交:仅把该子事务状态标记为“准备就绪”,真正对外生效要等到其所有祖先(尤其是顶层事务)都提交后才算完成。
  • 父事务提交:向下统一提交整棵事务树,所有子事务的修改才真正持久化。

回滚某一事务节点,会递归地回滚其所有子孙事务。因此,只有顶层事务具备全局回滚能力,子事务无法独立保留已提交的修改。

嵌套事务可以选择性地继承父事务的锁,也可以通过“反向继承”让父事务获得子事务锁。不同子事务可对同一资源持有不同级别的锁;上层事务在其自身提交前,锁不会被释放。

保存点可模拟嵌套回滚的灵活性(任意回到某个保存点),但无法在锁继承、并行执行等方面还原嵌套事务的精细控制。若需要子事务并行执行或精确的锁传递,就必须由系统原生支持嵌套事务。

分布式事务通常是一个在分布式环境下运行的扁平事务,因此需要根据数据库所在位置访问网络中的不同节点。

假设一个用户在 ATM 机进行银行的转账操作,例如持卡人从招商银行的储蓄卡转账 10 000 元到工商银行的储蓄卡。在这种情况下,可以将 ATM 机视为节点 A,招商银行的后台数据库视为节点 B,工商银行的后台数据库视为节点 C,这个转账的操作可分解为以下步骤:

  1. 节点 A 发出转账命令。
  2. 节点 B 执行储蓄卡中的余额值减去 10 000。
  3. 节点 C 执行储蓄卡中的余额值加上 10 000。
  4. 节点 A 通知用户操作完成或者通知用户操作失败。

这里需要使用分布式事务,因为节点 A 不能通过调用一台数据库就完成任务,需要访问网络中多个节点的数据库,而在每个节点的数据库上执行的事务操作又都是扁平事务。分布式事务同样需要满足 ACID 特性,要么都发生,要么都失效。对于上述例子,如果步骤 2 或 3 中的任何一个操作失败,都必须回滚整个分布式事务,否则结果将非常不可控。

对于 InnoDB 存储引擎来说,支持扁平事务、带有保存点的事务、链事务和分布式事务。但原生并不支持嵌套事务,因此对于有并行事务需求的用户来说,MySQL 或 InnoDB 在这方面能力有限。不过,用户仍可通过带有保存点的事务来模拟串行的嵌套事务。

并发场景下的事务问题

脏读:一个事务读到另一个事务未提交的数据,这些数据可能在之后被回滚,从而导致读取到的数据无效。数据库系统在较低的隔离级别下允许其他事务读取尚未提交的数据,可以减少数据锁定的时间,从而提升系统的吞吐量和响应时间。

不可重复读:一个事务先后读取同一条事务,但两次读取的数据不同。具体来说,事务 A 在读取数据后,事务 B 更新了该数据并提交,事务 A 再次读取时,数据已经被改变,导致了前后读取的数据不一致。

幻读:一个事务按照条件查询数据时,没有对应的数据行,但之后又查询数据时,又发现这行数据已存在,可能是别的事务在这两次查询之间插入了新的数据。

事务的隔离级别

读未提交:这是最低的隔离级别,对普通一致性读不加锁,允许读取其他事务未提交的更改,因此可能出现脏读、不可重复读和幻读。InnoDB 在该级别下仍使用 MVCC 快照(Read View)进行一致性读,但允许直接读取缓冲区中的最新行版本,无需检查事务是否已提交。

读已提交:在该级别下,每条普通 SELECT 都会生成一个新的 Read View,只读取已提交的行版本,从而避免脏读,但仍可能出现不可重复读和幻读。一致性读不加行锁,只有在显式锁定读(SELECT … FOR UPDATE/SHARE)或 DML 操作时才会加锁。该级别只能工作在二进制日志为 ROW 的格式下。但即使不使用 READ COMMITTED 的事务隔离级别,也应该考虑将二进制日志的格式更换成 ROW,因为这个格式记录的是行的变更,而不是简单的 SQL 语句,所以可以避免一些不同步现象的产生,进一步保证数据的同步。

可重复读:这是 InnoDB 的默认隔离级别,事务在第一次读取时创建一个 Read View,后续所有普通读都使用相同快照,保证事务内多次读取相同记录的结果一致,从而避免脏读和不可重复读。同时,InnoDB 在该隔离级别下启用 Next-Key 锁(记录锁 + 间隙锁)阻止其他事务在范围内插入新行,以防止幻读。

串行化:这是最高隔离级别,所有普通 SELECT 都作为共享锁定读执行(LOCK IN SHARE MODE),对每条检索到的记录及其所在间隙加 Shared Next-Key 锁,彻底消除脏读、不可重复读和幻读。写操作继续使用 Exclusive Next-Key 锁,锁粒度保持行级;同时在表层加意向锁(IS/IX)以协调多粒度锁,但并不加表级锁。

隔离级别 脏读 不可重复读 幻读
Read uncommitted
Read committed
Repeatable read(默认)
Serializable

✅:会发生

❌:不会发生

查看事务隔离级别:SELECT @@TRANSACTION_ISOLATION;

设置事务隔离级别:SET [SESSION | GLOBAL] TRANSACTION_ISOLATION LEVEL [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE];

隔离级别越高,数据越安全,但是性能越低。

长事务

长事务就是执行时间较长的事务。

长事务的典型场景如下:

  • 开发错误:开启事务后忘记提交或回滚。
  • 业务逻辑复杂:一些业务逻辑需要执行多个步骤或设计的数据量大,耗时较长,可能导致事务一直未提交。

例如,对于银行系统的数据库,每过一个阶段可能需要更新对应账户的利息。如果对应账户的数量非常大,例如对有 1 亿用户的表 account,需要执行下列语句:

1
UPDATE account SET account_total = account_total + (1 + interest_rate);

这时这个事务可能需要非常长的时间来完成。可能需要 1 个小时,也可能需要 4、5 个小时,这取决于数据库的硬件配置。同时由于事务 ACID 的特性,这个操作被封装在一个事务中完成。这就产生了一个问题:在执行过程中,当数据库或操作系统、硬件等发生故障时,重新开始事务的代价变得不可接受。数据库需要回滚所有已经发生的变化,而这个过程可能比产生这些变化的时间还要长。

因此,对于长事务的问题,有时可以通过转化为小批量(mini batch)的事务来进行处理。事务发生错误时,只需要回滚一部分数据,然后接着上次已完成的小事务继续进行。例如,对于前面讨论的银行利息计算问题,我们可以将一个需要处理 1 亿用户的大事务,分解为每次处理 10 万用户的小事务。既可以在应用层通过循环和分页来完成,也可以写成存储过程。将大事务拆分成小事务后:

  • 每一次只更新一小批用户,某一批出错时只需回滚该批,前边已成功的批次不受影响;
  • 事务时间大幅缩短,锁竞争和系统压力随之下降;
  • 用户或监控系统能够更清晰地看到进度,例如已更新到第几批。

长事务的危害

  • 长事务会长时间占用行锁或表锁,导致其他事务无法访问相关资源,可能引发大量阻塞甚至死锁;
  • 事务未提交前,MySQL 需要保留 Undo 日志以支持事务的回滚操作,这会增加存储空间和内存压力,并且还会造成长时间回滚;
  • 长时间未提交的事务可能会影响数据库备份、复制和其他管理操作;
  • 并发情况下,数据库连接池中的可用连接会被耗尽。

长事务的优化

  • 将查询等数据准备操作放到事务外;
  • 事务中避免远程调用,如果有的话,要设置超时时间,防止事务等待时间太久;
  • 事务中避免一次性处理太多数据,可拆分成多个事务分次处理;
  • 更新等涉及加锁的操作尽量放在事务靠后的位置;
  • 尽量使用异步处理;
  • 极端情况下,可在应用侧(业务代码)保证数据一致性,放弃事务。

事务控制语句

在 MySQL 命令行的默认设置下,事务都是自动提交(auto commit)的,即执行 SQL 语句后就会马上执行 COMMIT 操作。因此要显式地开启一个事务需使用命令 BEGINSTART TRANSACTION,或者执行命令 SET AUTOCOMMIT=0,禁用当前会话的自动提交。在具体介绍其含义之前,先来看看用户可以使用哪些事务控制语句。

  • START TRANSACTION | BEGIN:显式地开启一个事务。
  • COMMIT:要想使用这个语句的最简形式,只需发出 COMMIT。也可以更详细一些,写为 COMMIT WORK,不过二者几乎是等价的。COMMIT 会提交事务,并使得已对数据库做的所有修改成为永久性的。
  • ROLLBACK:要想使用这个语句的最简形式,只需发出 ROLLBACK。同样地,也可以写为 ROLLBACK WORK,但是二者几乎是等价的。回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。
  • SAVEPOINT identifier:SAVEPOINT 允许在事务中创建一个保存点,一个事务中可以有多个 SAVEPOINT。
  • RELEASE SAVEPOINT identifier:删除一个事务的保存点,当没有一个保存点时执行这句话,会抛出一个异常。
  • ROLLBACK TO [SAVEPOINT] identifier:这个语句与 SAVEPOINT 命令一起使用。可以把事务回滚到指定的保存点,而不回滚此保存点之前的任何工作。例如可以发出两条 UPDATE 语句,后面跟一个 SAVEPOINT,然后又是两条 DELETE 语句。如果执行 DELETE 语句期间出现了某种异常情况,并且捕获到这个异常,同时发出了 ROLLBACK TO SAVEPOINT 命令,事务就会回滚到指定的 SAVEPOINT,撤销 DELETE 完成的所有工作,而 UPDATE 语句完成的工作不受影响。
  • SET TRANSACTION:这个语句用来设置事务的隔离级别。InnoDB 存储引擎提供的事务隔离级别有:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。

START TRANSACTIONBEGIN 语句都可以在 MySQL 命令行下显式地开启一个事务。但是在存储过程中,MySQL 数据库的解析器会自动将 BEGIN 识别为 BEGIN … END,因此在存储过程中只能使用 START TRANSACTION 语句来开启一个事务。

COMMITCOMMIT WORK 语句基本一致,都是用来提交事务。不同之处在于 COMMIT WORK 用来控制事务结束后的行为是 CHAIN 还是 RELEASE。如果是 CHAIN 方式,那么事务就变成了链事务。

用户可以通过参数 completion_type 来进行控制,该参数默认为 0,表示没有任何操作。在这种设置下 COMMITCOMMIT WORK 是完全等价的。当参数 completion_type 的值为 1 时,COMMIT WORK 等同于 COMMIT AND CHAIN,表示马上自动开启一个相同隔离级别的事务,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
mysql> CREATE TABLE t ( a INT, PRIMARY KEY (a) ) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@autocommit\G;
*************************** 1. row ***************************
@@autocommit: 1
1 row in set (0.00 sec)

mysql> SET @@completion_type=1;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> COMMIT WORK;
Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO t SELECT 2;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> INSERT INTO t SELECT 2;
ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'

mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

# 注意回滚之后只有 1 这一个记录,而没有 2 这两个记录

mysql> SELECT * FROM t\G;
*************************** 1. row ***************************
a: 1
1 row in set (0.00 sec)

在这个示例中我们设置 completion_type 为 1,第一次通过 COMMIT WORK 来插入 1 这一个记录。之后插入记录 2 时我们并没有用 BEGIN(或者 START TRANSACTION)来显式地开启一个事务,后续再插入一条重复的记录 2 就会抛出异常。接着执行 ROLLBACK 操作,最后发现只有 1 这一个记录,2 并没有被插入。因为 completion_type 为 1 时,COMMIT WORK 会自动开启一个链事务,第二条 INSERT INTO t SELECT 2 语句是在同一个事务内的,因此回滚后 2 这条记录并没有被插入表 t 中。

参数 completion_type 为 2 时,COMMIT WORK 等同于 COMMIT AND RELEASE。在事务提交后会自动断开与服务器的连接,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mysql> SET @@completion_type=2;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 3;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> COMMIT WORK;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT @@version\G;
ERROR 2006 (HY000): MySQL server has gone away
No connection. Trying to reconnect...
Connection id: 54
Current database: test
*************************** 1. row ***************************
@@version: 5.1.45-log
1 row in set (0.00 sec)

在这个例子中,设置了 completion_type=2 后,执行 COMMIT WORK 不仅提交了事务,而且释放(关闭)了当前连接。重新执行任何查询时,客户端都会发现与服务器的连接已断开,并尝试重新连接。

SAVEPOINT 记录了一个保存点,可以通过 ROLLBACK TO SAVEPOINT 来回滚到某个保存点,但是如果回滚到一个不存在的保存点,会抛出异常:

1
2
3
4
5
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> ROLLBACK TO SAVEPOINT t1;
ERROR 1305 (42000): SAVEPOINT t1 does not exist

InnoDB 存储引擎中的事务都是原子的,这说明下述两种情况:构成事务的每条语句要么都提交(持久化),要么所有语句都回滚。这种保护还延伸到单个语句——一条语句要么完全成功,要么完全回滚(注意,这里说的是语句级回滚)。因此当一条语句失败并抛出异常时,并不会导致先前已经执行的语句自动回滚。所有已执行的操作都将保留,必须由用户自己决定是否对其进行提交或回滚。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> CREATE TABLE t (a INT, PRIMARY KEY(a)) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> INSERT INTO t SELECT 1;
ERROR 1062 (23000): Duplicate entry '1' for key 'PRIMARY'

mysql> SELECT * FROM t\G;
*************************** 1. row ***************************
a: 1
1 row in set (0.00 sec)

可以看到,当插入第二条记录 1 时,由于主键重复抛出了 1062 错误,但数据库并没有自动回滚事务;此时事务仍处于活动状态,必须由用户显式地执行 COMMITROLLBACK 命令来结束事务。

另一个容易混淆的地方是 ROLLBACK TO SAVEPOINT——虽然它包含 “ROLLBACK”,但并不能真正结束整个事务。因此,即使执行了 ROLLBACK TO SAVEPOINT,之后仍需显式地运行 COMMITROLLBACK 才能完成事务。具体示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
mysql> CREATE TABLE t ( a INT, PRIMARY KEY(a) ) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SAVEPOINT t1;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 2;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SAVEPOINT t2;
Query OK, 0 rows affected (0.00 sec)

mysql> RELEASE SAVEPOINT t1;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 2;
ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'

mysql> ROLLBACK TO SAVEPOINT t2;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t;
+---+
| a |
+---+
| 1 |
| 2 |
+---+
2 rows in set (0.00 sec)

mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t;
Empty set (0.00 sec)

可以看到,在上面的例子中,虽然在发生重复错误后用户通过 ROLLBACK TO SAVEPOINT t2 命令回滚到了保存点 t2,但是事务此时并没有结束。再运行命令 ROLLBACK 后,事务才会完整地回滚。这里再次提醒,ROLLBACK TO SAVEPOINT 命令并不真正地结束事务。

隐式提交的 SQL 语句

以下这些 SQL 语句会产生一个隐式的提交操作,即执行完这些语句后,会有一个隐式的 COMMIT 操作。

  • DDL 语句:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    ALTER DATABASE … UPGRADE DATA DIRECTORY NAME
    ALTER EVENT
    ALTER PROCEDURE
    ALTER TABLE
    ALTER VIEW
    CREATE DATABASE
    CREATE EVENT
    CREATE INDEX
    CREATE PROCEDURE
    CREATE TABLE
    CREATE TRIGGER
    CREATE VIEW
    DROP DATABASE
    DROP EVENT
    DROP INDEX
    DROP PROCEDURE
    DROP TABLE
    DROP TRIGGER
    DROP VIEW
    RENAME TABLE
    TRUNCATE TABLE
  • 用来隐式地修改 MySQL 架构的操作:

    1
    2
    3
    4
    5
    6
    CREATE USER
    DROP USER
    GRANT
    RENAME USER
    REVOKE
    SET PASSWORD
  • 管理语句:

    1
    2
    3
    4
    5
    6
    ANALYZE TABLE
    CACHE INDEX
    CHECK TABLE
    LOAD INDEX INTO CACHE
    OPTIMIZE TABLE
    REPAIR TABLE

需要注意的是,TRUNCATE TABLE 语句是 DDL,因此虽然和对整张表执行 DELETE 的结果是一样的,但它是不能被回滚的。

隐式提交

隐式提交(implicit commit)并不是某个存储引擎偷偷在后台提交,而是 MySQL 服务器在执行这些语句时,在语法分析/执行阶段主动调用了事务提交。其原理可以概括为以下几点:

元数据一致性要求

DDL(Data Definition Language,定义性语言)操作例如 CREATE TABLEALTER TABLE 等,会修改数据库的元数据(数据字典)——表结构、索引信息、权限信息……这些修改必须在干净的事务边界上进行,否则如果与正在进行的事务混合,元数据和事务日志就可能出现不一致。因此,服务器在处理 DDL 之前,会先隐式地提交(COMMIT)当前事务,确保所有已做的数据修改都已持久化且可见;DDL 执行完毕后,又会隐式提交,以将新的元数据生效并释放所有表级锁。

存储引擎锁与全局锁

DDL 通常需要获取表级甚至元数据级的全局锁(例如 metadata lock),而 InnoDB 行事务的 MVCC、锁粒度等是在行和页级别管理的。为了避免长事务与元数据锁冲突,MySQL 设计为:

  • DDL 前自动提交,这样旧事务内的锁都释放掉,DDL 能顺利抢到元数据锁;
  • DDL 后自动提交,这样新事务再去访问时就能看到最新结构。

隐式提交的实现机制

在 MySQL 源码里,SQL 解析器(SQL layer)会为特定的语句类型(DDL、某些管理语句)打上 autocommit 标志或直接在执行函数里调用 thd->commit():

1
2
3
4
5
6
7
8
9
// 伪代码,执行 DDL 时 

if (statement_is_ddl(stmt)) {

thd->commit(); // 隐式提交前一个事务
execute_ddl(stmt); // 改变元数据
thd->commit(); // 隐式提交以生效并释放锁

}

对于 TRUNCATE TABLE 这类内部实现成 DROP + CREATE 的操作,同样也在前后各一次 commit(),所以即使在显式事务中执行,也马上结束当前事务。

与普通 DML 的区别

普通的 DML 如 INSERT/UPDATE/DELETE,在显式事务中只会被存入 redo/undo 日志缓冲,真正刷盘并提交则取决于 innodb_flush_log_at_trx_commit、COMMIT 语句或自动提交设置。它们不会在执行时强制触发存储引擎层面的全局提交。

对于事务操作的统计

由于 InnoDB 存储引擎是支持事务的,因此 InnoDB 存储引擎的应用需要在考虑每秒请求数(Questions Per Second, QPS)的同时,也应该关注每秒事务处理的能力(Transactions Per Second, TPS)。计算 TPS 的方法是:(com_commit + com_rollback) / 时间。

但要使用这种方法,前提是所有的事务都必须是显式提交的;如果存在隐式提交或隐式回滚(默认 autocommit=1),这些操作不会计入 com_commitcom_rollback 这两个状态变量中。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
mysql> SHOW GLOBAL STATUS LIKE 'com_commit'\G

*************************** 1. row ***************************
Variable_name: Com_commit
Value: 5
1 row in set (0.00 sec)

mysql> INSERT INTO t SELECT 3;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM t\G
*************************** 1. row ***************************
a: 1
*************************** 2. row ***************************
a: 2
*************************** 3. row ***************************
a: 3
3 rows in set (0.00 sec)

mysql> SHOW GLOBAL STATUS LIKE 'com_commit'\G
*************************** 1. row ***************************
Variable_name: Com_commit
Value: 5
1 row in set (0.00 sec)

可以看到,尽管插入了第三条记录,但 com_commit 的值仍然保持 5,没有增加。这是因为默认情况下每条 INSERT 都在隐式事务中执行(autocommit=1),而隐式提交不计入 com_commit

事务原理

事务隔离性由锁来实现。原子性、持久性通过数据库的 redo log 和 undo log 来完成,一致性则由其他三种特性的共同实现来保证。redo log 称为重做日志,用来保证事务的原子性和持久性。undo log 用来保证事务的一致性。

有人或许会认为 undo 是 redo 的逆过程,其实不然。redo 和 undo 的作用都可以视为是一种恢复操作;redo 恢复提交事务修改的页操作,而 undo 回滚行记录到某个特定版本。因此两者记录的内容不同,redo 通常是物理日志,记录的是页的物理修改操作。undo 是逻辑日志,根据行记录进行记录。

redo log => 持久性

重做日志用来实现事务的持久性,即事务 ACID 中的D。其由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),其是持久的。InnoDB 是事务的存储引擎,其通过 Force Log at Commit 机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的 COMMIT 操作完成才算完成。这里的日志是指重做日志,在 InnoDB 存储引擎中,由 redo log 和 undo log 两部分组成。redo log 用来保证事务的持久性,undo log 用来帮助事务回滚及MVCC的功能。redo log 基本上都是顺序写的,在数据库运行时不需要对 redo log 文件进行读取操作;undo log 是需要进行随机读写的。

为了确保每次日志都写入重做日志文件,在每次将重做日志缓冲写入重做日志文件后,InnoDB 存储引擎都需要调用一次 fsync 操作。由于重做日志文件打开并没有使用 O_DIRECT 选项,因此重做日志先写入文件系统缓存;为了确保重做日志写入磁盘,必须进行一次 fsync 操作。由于 fsync 的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。

InnoDB 存储引擎允许用户手工设置非持久化的情况发生,以此提高数据库的性能。即当事务提交时,日志不写入重做日志文件,而是等待一个时间周期后再执行 fsync 操作。由于并非强制在事务提交时进行一次 fsync 操作,这可以显著提高数据库的性能,但是当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。

参数 innodb_flush_log_at_trx_commit 用来控制重做日志刷新到磁盘的策略。该参数的默认值为 1,表示事务提交时必须调用一次 fsync 操作。还可以设置该参数的值为 0 和 2。0 表示事务提交时不进行写入重做日志操作,这个操作仅在 master thread 中完成,而 master thread 中每 1 秒会进行一次重做日志文件的 fsync 操作;2 表示事务提交时将重做日志写入文件系统缓存,不进行 fsync 操作。在这个设置下,当 MySQL 数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失;而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件的那部分事务。

img

在 MySQL 数据库中还有一种二进制日志(binlog),其用来进行 POINT-IN-TIME(PIT)的恢复及主从复制(Replication)环境的建立。从表面上看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。首先,重做日志是在 InnoDB 存储引擎层产生,而二进制日志是在 MySQL 数据库的上层产生的,并且二进制日志不仅仅针对 InnoDB 存储引擎,MySQL 数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。

其次,两种日志记录的内容形式不同。MySQL 数据库上层的二进制日志是一种逻辑日志,其记录的是对应的 SQL 语句。而 InnoDB 存储引擎层面的重做日志是物理格式日志,其记录的是对于每个页的修改。

此外,两种日志记录写入磁盘的时间点不同,如下图所示。二进制日志只在事务提交完成后进行一次写入。而 InnoDB 存储引擎的重做日志在事务进行中不断地被写入,这表现为日志并不是随事务提交的顺序进行写入的。

img

从上图中可以看到,二进制日志仅在事务提交时记录,并且对于每一个事务,仅包含对应事务的一个日志。而对于 InnoDB 存储引擎的重做日志,由于其记录的是物理操作日志,因此每个事务对应多个日志条目,并且事务的重做日志写入是并发的,并非在事务提交时写入,故其在文件中记录的顺序并非事务开始的顺序。*T1、*T2、*T3 表示的是事务提交时的日志。

log block

在 InnoDB 存储引擎中,重做日志都是以 512 字节进行存储的。这意味着重做日志缓冲、重做日志文件都是以块的方式进行保存的,称之为重做日志块,每块的大小为 512 字节。

若一个页中产生的重做日志数量大于 512 字节,那么需要分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是 512 字节,因此重做日志的写入可以保证原子性,不需要 doublewrite 技术。

重做日志除了日志本身之外,还由日志块头及日志块尾两部分组成。重做日志块头一共占用 12 字节,重做日志块尾占用 8 字节。故每个重做日志块实际可以存储的大小为 492 字节(512 – 12 – 8)。下图显示了重做日志块缓冲的结构。

img

log block header 由 4 部分组成,如下表所示:

名称 占用字节
LOG_BLOCK_HDR_NO 4
LOG_BLOCK_HDR_DATA_LEN 2
LOG_BLOCK_FIRST_REC_GROUP 2
LOG_BLOCK_CHECKPOINT_NO 4

log buffer 是由 log block 组成,在内部 log buffer 就好似一个数组,因此 LOG_BLOCK_HDR_NO 用来标记这个数组中的位置。其是递增并且循环使用的,占用 4 个字节,但是由于第一位用来判断是否是 flush bit,所以最大值为 2 G。

LOG_BLOCK_HDR_DATA_LEN(2 字节)表示 log block 所占用的大小。当 log block 被写满时,该值为 0x200,表示使用全部 log block 空间,即占用 512 字节。

LOG_BLOCK_FIRST_REC_GROUP(2 字节)表示 log block 中第一个日志所在的偏移量。如果该值的大小和 LOG_BLOCK_HDR_DATA_LEN 相同,则表示当前 log block 不包含新的日志。

例如:事务 T1 的重做日志 占用 762 字节,事务 T2 的重做日志占用 100 字节。由于每个 log block 实际只能保存 492 字节(512 − 12 − 8),其在 log buffer 中的情况如下图所示。
img

可以观察到,由于事务 T1 的重做日志占用 792 字节,因此需要占用两个 log block。

在第一个 log block 中,LOG_BLOCK_FIRST_REC_GROUP 为 12,即此 block 中第一个日志的起始位置。

在第二个 log block 中,由于它包含了之前事务 T1 的剩余日志,事务 T2 的日志才是该 block 中第一个日志,因此该 log block 的 LOG_BLOCK_FIRST_REC_GROUP 为 282(270 + 12)。

LOG_BLOCK_CHECKPOINT_NO(4 字节)表示该 log block 最后被写入时的检查点(checkpoint)编号。

log block tailer 只由 1 个字段组成(如下表所示),其值与 LOG_BLOCK_HDR_NO 相同,并在函数 log_block_init 中被初始化。

名称 大小(字节)
LOG_BLOCK_TRL_NO 4

log group

log group 为重做日志组,其中有多个重做日志文件。虽然源码里支持镜像多组 log group 的功能,但官方屏蔽了镜像功能,实际上只有一组文件集合。

log group 是一个逻辑上的概念,并没有一个实际存储的物理文件来表示 log group 信息。log group 由多个重做日志文件组成,每个 log group 中的日志文件大小是相同的,且在 InnoDB 1.2 版本之前,重做日志文件的总大小要小于 4 GB(不能等于 4 GB)。从 InnoDB 1.2 版本开始,重做日志文件总大小的限制提高为了 512 GB。

重做日志文件中存储的就是之前在 log buffer 中保存的 log block,因此其也是根据块的方式进行物理存储管理,每个块的大小与 log block 一样,同样为 512 字节。在 InnoDB 存储引擎运行过程中,log buffer 会根据一定的规则将内存中的 log block 刷新到磁盘。这个规则具体是:

  • 事务提交时
  • 当 log buffer 中有一半的内存空间已经被使用时
  • log checkpoint 时

对于 log block 的写入追加(append)在 redo log file 的最后部分,当一个 redo log file 被写满时,会接着写入下一个 redo log file,其使用方式为 round-robin。

虽然 log block 总是在 redo log file 的最后部分进行写入,有的读者可能认为对 redo log file 的写入都是顺序的。其实不然,因为 redo log file 除了保存 log buffer 刷新到磁盘的 log block,还保存了一些其他的信息,这些信息一共占用 2 KB 大小,即每个 redo log file 的前 2 KB 部分不保存 log block 的信息。对于 log group 中的第一个 redo log file,其前 2 KB 的部分保存 4 个 512 字节大小的块,其中存放的内容如下表所示。

名称 大小(字节)
log file header 512
checkpoint1 512
512
checkpoint2 512

需要特别注意的是,上述信息仅在每个 log group 的第一个 redo log file 中进行存储。

log group 中的其余 redo log file 仅保留这些空间,但不保存上述信息。正因为保存了这些信息,就意味着对 redo log file 的写入并不是完全顺序的。因为其除了 log block 的写入操作,还需要更新前 2 KB 部分的信息,这些信息对于 InnoDB 存储引擎的恢复操作来说非常关键和重要。

故 log group 与 redo log file 之间的关系如下图所示。

img

在 log file header 后面的部分为 InnoDB 存储引擎保存的 checkpoint(检查点)值,其设计是交替写入,这样的设计避免了因介质失败导致无法找到可用的 checkpoint 的情况。

具象一点就是,第一次写 checkpoint 时,写入 Slot A;下一次写 checkpoint 时,写入 Slot B;再下一次,又回到写 Slot A……如此循环。

为什么要这样做?

  • 避免写一半后丢失:如果只有一个槽,刚写入还没刷完,突然停电或磁盘故障,这个槽可能只写了一半,里面的值就坏了。
  • 保证总有一个有效副本:交替写入意味着每次只有一个槽被覆盖,另一个槽保存的是上一次写入的、完整且可靠的检查点值。即便一个槽在写入时被破坏,另外一个槽里还有上一次的有效值。

恢复时的处理
在数据库启动恢复阶段,InnoDB 会读取这两个槽的内容,选择最新且完整的那个作为真正的 checkpoint,从那里开始重做日志的回放,保证不会因为写损坏而找不到有效的检查点。

重做日志格式

不同的数据库操作会有对应的重做日志格式。此外,由于 InnoDB 存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的。虽然有着不同的重做日志格式,但它们具有通用的头部格式,如下图所示:

img

通用头部格式由以下 3 部分组成:

  1. redo_log_type:重做日志的类型。
  2. space:表空间的 ID。
  3. page_no:页的偏移量。

之后的 redo log body 的部分,根据重做日志类型的不同,会有不同的存储内容。例如,对于页上记录的插入和删除操作,分别对应下图所示的格式:

img

LSN

LSN 是 Log Sequence Number 的缩写,代表日志序列号。在 InnoDB 存储引擎中,LSN 占用 8 字节,并且单调递增。LSN 的含义包括:

  • 重做日志已写入的总字节数;
  • checkpoint 所在位置;
  • 页的版本。

LSN 记录了事务写入重做日志的累计字节数。例如:

  • 如果当前 LSN 为 1000,事务 T1 又写入了 100 字节的重做日志,则 LSN 更新为 1100;
  • 接着事务 T2 写入 200 字节,则 LSN 更新为 1300。

除了记录在重做日志文件中,每个数据页的页头也保存了一个 FIL_PAGE_LSN 值,它表示该页最后一次刷新的 LSN。恢复时,InnoDB 会比较重做日志中记录的 LSN 与页头中的 FIL_PAGE_LSN

  • 如果页头 LSN 小于重做日志中的 LSN,就需要应用重做日志;
  • 否则说明该页已经被刷新到最新,无需重做。

查看当前 LSN:

1
2
3
4
5
6
7
8
9
10
mysql> SHOW ENGINE INNODB STATUS\G

--- LOG ---
Log sequence number 113047174608
Log flushed up to 113047174608
Last checkpoint at 113047174608
0 pending log writes, 0 pending chkp writes
142 log i/o's done, 0.00 log i/o's/second

1 row in set (0.00 sec)

以上结果的参数如下:

Log sequence number:当前的 LSN

Log flushed up to:已刷新到重做日志文件的 LSN

Last checkpoint at:已同步到磁盘的 LSN

虽然在上面的例子中,Log sequence number 和 Log flushed up to 的值是相同的,但是在实际生产环境中,该值有可能是不同的。因为在一个事务中从日志缓冲刷新到重做日志文件并不只是在事务提交时发生,每秒都会有从日志缓冲刷新到重做日志文件的动作。下面是在生产环境下重做日志的信息的示例。

1
2
3
4
5
6
7
8
9
10
mysql> SHOW ENGINE INNODB STATUS\G

--- LOG ---
Log sequence number 203318213447
Log flushed up to 203318213326
Last checkpoint at 203252831194
1 pending log writes, 0 pending chkp writes
103447 log i/o's done, 7.00 log i/o's/second

1 row in set (0.00 sec)

所以,redo log 的刷盘时机有以下 4 种:

  1. 提交时:保证事务持久性;
  2. 超时周期:(默认 1s)定时写入,防止长时间积压;
  3. 缓冲区半满:防止 buffer 溢出;
  4. 检查点:保证恢复一致性。

恢复

InnoDB 存储引擎在启动时,不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志(如二进制日志)要快很多。与此同时,InnoDB 存储引擎自身也对恢复进行了优化,例如顺序读取和并行应用重做日志,这样可以进一步提高数据库恢复的速度。

由于 checkpoint 表示已刷新到磁盘页上的 LSN,因此在恢复过程中仅需应用从 checkpoint 开始的日志部分。

假设数据库崩溃时,checkpoint 的 LSN 为 10000;重做日志当前已写到 LSN 13000;那么恢复时只需重做 LSN 10000 到 13000 范围内的日志。

img

InnoDB 存储引擎的重做日志是物理日志,因此其恢复速度较之二进制日志恢复快得多。例如对于 INSERT 操作,其记录的是每个页上的变化。对于下面的表:

1
2
3
4
5
6
CREATE TABLE t (
a INT,
b INT,
PRIMARY KEY(a),
KEY(b)
);

若执行 SQL 语句:

1
INSERT INTO t SELECT 1, 2;

由于需要对聚集索引页和辅助索引页进行操作,其记录的重做日志大致为:

1
2
3
page(2,3), offset 32, value 1,2  # 聚集索引

page(2,4), offset 64, value 2 # 辅助索引

上图中的 page(x,y) 代表表空间 x 中的页 y。

可以看到记录的是页的物理修改操作,若插入涉及 B+ 树的 split,可能会有更多的页需要记录日志。此外,由于重做日志是物理日志,因此其是幂等的。幂等的概念如下:

$$f(f(x)) = f(x)$$

有的人错误地认为只要将二进制日志的格式设置为 ROW,那么二进制日志也是幂等的。这显然是错误的。举个简单的例子,INSERT 操作在二进制日志中就不是幂等的:重复执行可能会插入多条重复的记录。而上述 INSERT 操作的重做日志是幂等的。

undo log => 原子性

重做日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要 undo。因此在对数据库进行修改时,InnoDB 存储引擎不但会产生 redo,还会产生一定量的 undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条 ROLLBACK 语句请求回滚,就可以利用这些 undo 信息将数据回滚到修改之前的样子。

redo 存放在重做日志文件中,与 redo 不同,undo 存放在数据库内部的一个特殊段中,这个段称为 undo 段(undo segment)。undo 段位于共享表空间内。也就是说,一个共享表空间 ibdata1 里可以包含多个 undo segment,每个 segment 又由一系列 undo log page 组成。

很多人以为 undo 是把数据页整个地回滚到某个时刻的样子,就像把一整张纸翻到以前的版本。实际上并不是这样!

undo 记录的是对每条记录所做修改的逆操作:比如把一个 INSERT 反做成 DELETE,把一个 UPDATE 反做成把旧值写回去。它并不保存整页的“图片”或快照,而是保存每个操作的前后值,对操作本身做逆向处理。

为什么不能物理地整页回滚?

在真正的数据库中,常常有 大量并发事务 同时在操作:

  • 事务 A 在这一页上修改了第 3 行和第 7 行;

  • 同时事务 B 也在这一页上修改了第 10 行和第 15 行;如果把整页恢复到事务 A 开始时的版本,那么事务 B 对第 10、15 行的修改就一并被移除了,破坏了 B 的工作。

因此,InnoDB 在回滚时,不会把页面还原成某个历史时刻的完全镜像,而是针对事务 A 做的那些修改,逐条执行逆操作,只撤销 A 做的改动,保留其他事务(如 B)在同一页上的修改。

此外,假设用户执行了一个 INSERT 10W 条记录的事务,这个事务会导致分配一个新的段,即表空间会增大。在用户执行 ROLLBACK 时,会将插入的事务进行回滚,但是表空间的大小并不会因此而收缩。在内部把这些插入的行逻辑删除。回滚不会缩小表空间文件,它只是把数据逻辑地撤销,保留了原先为这些数据分配的物理页,用于后续的存储需求。这样避免了频繁的文件尺寸变动,也利于性能和空间重用。

因此,当 InnoDB 存储引擎回滚时,它实际上做的是与之前相反的工作。对于每个 INSERT,InnoDB 存储引擎会完成一个 DELETE;对于每个 DELETE,InnoDB 存储引擎会执行一个 INSERT;对于每个 UPDATE,InnoDB 存储引擎会执行一个相反的 UPDATE,将修改前的行放回去。

除了回滚操作,undo 的另一个作用是 MVCC,即在 InnoDB 存储引擎中 MVCC 的实现是通过 undo 来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过 undo 读取之前的行版本信息,以此实现非锁定读取。

最为重要的一点是,undo log 会产生 redo log,也就是 undo log 的产生会伴随着 redo log 的产生,这是因为 undo log 也需要持久性的保护。

undo 日志存储管理

InnoDB 存储引擎对 undo 的管理同样采用段的方式。但是这个段和之前介绍的段有所不同。首先 InnoDB 存储引擎有 rollback segment,每个 rollback segment 记录了 1024 个 undo log segment,而在每个 undo log segment 段中进行 undo 页的申请。共享表空间偏移量为 5 的页 (0, 5) 记录了所有 rollback segment header 所在的页,这个页的类型为 FIL_PAGE_TYPE_SYS

在 InnoDB1.1 版本之前(不包括 1.1 版本),只有一个 rollback segment,因此支持同时在线的事务限制为 1024。虽然对绝大多数的应用来说都已经够用,但不管怎么说这是一个瓶颈。从 1.1 版本开始 InnoDB 支持最大 128 个 rollback segment,故其支持同时在线的事务限制提高到了 128 × 1024。

虽然 InnoDB1.1 版本支持了 128 个 rollback segment,但是这些 rollback segment 都存储于共享表空间中。从 InnoDB1.2 版本开始,可通过参数对 rollback segment 做进一步的设置。这些参数包括:

  1. innodb_undo_directory
  2. innodb_undo_logs
  3. innodb_undo_tablespaces

参数 innodb_undo_directory 用于设置 rollback segment 文件所在的路径。这意味着 rollback segment 可以存放在共享表空间以外的位置,即可以设置为独立表空间。该参数的默认值为 “.”,表示当前 InnoDB 存储引擎的目录。

参数 innodb_undo_logs 用来设置 rollback segment 的个数,默认值为 128。在 InnoDB1.2 版本中,该参数用来替换之前版本的参数 innodb_rollback_segments

参数 innodb_undo_tablespaces 用来设置构成 rollback segment 文件的数量,这样 rollback segment 可以较为平均地分布在多个文件中。设置该参数后,会在路径 innodb_undo_directory 看到以 undo 为前缀的文件,该文件就代表一个 rollback segment。下图显示了由 3 个文件组成的 rollback segment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mysql> SHOW VARIABLES LIKE 'innodb_undo%';
+-------------------------+----------------+
| Variable_name | Value |
+-------------------------+----------------+
| innodb_undo_directory | . |
| innodb_undo_logs | 128 |
| innodb_undo_tablespaces | 3 |
+-------------------------+----------------+
3 rows in set (0.00 sec)

mysql> SHOW VARIABLES LIKE 'datadir';
+---------------+---------------------------------+
| Variable_name | Value |
+---------------+---------------------------------+
| datadir | /Users/david/mysql_data/data/ |
+---------------+---------------------------------+
1 row in set (0.00 sec)

mysql> system ls -lh /Users/david/mysql_data/data/undo*
-rw-rw---- 1 david staff 10M 11 22 16:55 /Users/david/mysql_data/data/undo001
-rw-rw---- 1 david staff 10M 11 22 16:51 /Users/david/mysql_data/data/undo002
-rw-rw---- 1 david staff 10M 11 22 16:51 /Users/david/mysql_data/data/undo003

需要特别注意的是,事务在 undo log segment 分配页并写入 undo log 的这个过程同样需要写入重做日志。当事务提交时,InnoDB 存储引擎会做以下两件事情:

  • 将 undo log 放入列表中,以供之后的 purge 操作;
  • 判断 undo log 所在的页是否可以重用,若可以则分配给下个事务使用。

事务提交后并不能马上删除 undo log 及 undo log 所在的页。这是因为可能还有其他事务需要通过 undo log 来得到行记录之前的版本。故事务提交时将 undo log 放入一个链表中,是否可以最终删除 undo log 及其所在页由 purge 线程来判断。

此外,若为每一个事务分配一个单独的 undo 页会非常浪费存储空间,特别是对于 OLTP 的应用类型。因为在事务提交时,可能并不能马上释放页。假设某应用的删除和更新操作的 TPS(transactions per second)为 1000,为每个事务分配一个 undo 页,那么一分钟就需要 1000*60 页,大约需要的存储空间为 1 GB。若每秒的 purge 页的数量为 20,这样的设计对磁盘空间有着相当高的要求。

因此,在 InnoDB 存储引擎的设计中对 undo 页可以进行重用。具体来说,当事务提交时,首先将 undo log 放入链表中,然后判断该 undo 页的使用空间是否小于 3/4,若是则表示该 undo 页可以被重用,之后新的 undo log 记录就在该页的后面。由于存放 undo log 的列表是以记录组织的,而 undo 页可能存放着不同事务的 undo log,因此 purge 操作需要涉及磁盘的离散读取,是一个相对较慢的过程。

具象一点就是,InnoDB 里维护了一条 undo log 链表,链表里的每个节点对应一条 undo 记录(即某次对某行的前镜像)。这些节点并不是按事务或者按页顺序排列,而是按操作顺序串成一串——也就是一条操作一条记录地组织。因此,一个物理的 undo page(4KB 或 16KB)上,可能混杂了多个事务在不同时间写入的 undo 记录。

当后台 purge 线程要清理某个事务的 undo 记录(即判断哪些 undo 可以安全丢弃),它会按链表顺序走:

  1. 找到这条事务的第一个 undo 记录在链表里的位置。
  2. 沿着链表往下走,一条一条检查:这条记录是不是该事务的?如果是,就删掉;如果不是,就跳过。
  3. 对应的,每读一条 undo 记录,就要去读它所在的那页(undo page)——而这些记录分散在不同的页面上。

undo log 格式

在 InnoDB 存储引擎中,undo log 分为:

  1. insert undo log;
  2. update undo log。

insert undo log 是指在 insert 操作中产生的 undo log。因为 insert 操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作。

下图显示了 insert undo log 的格式,其中 * 表示对存储的字段进行了压缩。insert undo log 开始的前两个字节 next 记录的是下一个 undo log 的位置,通过该 next 字节可以知道一个 undo log 所占的空间字节数。类似地,尾部的两个字节记录的是 undo log 的开始位置。type_cmpl 占用一个字节,记录的是 undo 的类型,也就是在回滚或 MVCC 回查时应该执行怎样的逆向操作。对于 insert undo log,该值总是为 11。undo_no 记录事务的 ID,table_id 记录 undo log 所对应的表对象。这两个值都是在压缩后保存的。接着的部分记录了所有主键的列和值。在进行 rollback 操作时,根据这些值可以定位到具体的记录,然后进行删除即可。

img

update undo log 记录的是对 delete 和 update 操作产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge 线程进行最后的删除。update undo log 的结构如下图所示。

img

update undo log 相对于之前介绍的 insert undo log,记录的内容更多,所需占用的空间也更大。next、start、undo_no、table_id 与之前介绍的 insert undo log 部分相同。这里的 type_cmpl,由于 update undo log 本身还有分类,故其可能的值如下:

  • 12 对应 TRX_UNDO_UPD_EXIST_REC,更新 non-delete-mark 的记录,用于 UPDATE 操作的时候生成该类型 undo 日志;
  • 13 对应 TRX_UNDO_UPD_DEL_REC,将 delete 的记录标记为 not delete,用于回滚 DELETE 操作的时候生成该类型 undo 日志;
  • 14 对应 TRX_UNDO_DEL_MARK_REC,将记录标记为 delete,在 DELETE 操作的时候生成该类型 undo 日志。

这里梳理一下:

  • 初次执行 DELETE时,InnoDB 会打上删除标记,这一步会生成 type_cmpl = 14 (TRX_UNDO_DEL_MARK_REC) 的 undo 记录,用于在回滚时撤销“打标记”操作。
  • 当真正执行回滚(或构建 MVCC 快照读需要“回到删除前”)时,InnoDB 必须将该行的删除标记“更新”回可见状态,这时才会写入 type_cmpl = 13 (TRX_UNDO_UPD_DEL_REC) 的 undo 记录,用于撤销之前的删除标记 。

为什么需要 type_cmpl = 13 这个状态?

当执行 ROLLBACK 或需要 MVCC 快照读回到删除前状态时,InnoDB 必须把 delete‐mark 更新回“未删除”,这一步在引擎层面是一次新的 UPDATE 操作。

接着的部分记录 update_vector 信息,update_vector 表示 update 操作导致发生改变的列。每个修改的列信息都要记录到 undo log 中。对于不同的 undo log 类型,可能还需要记录对索引列所做的修改。

如果回滚操作也失败了,怎么办?

在 MySQL(尤其是 InnoDB)中,ROLLBACK 失败主要出现在两种情况:一是客户端或连接异常导致回滚命令未能到达服务器,二是服务器在执行回滚期间遇到严重内部错误(例如表空间或日志损坏),无法完成回滚动作。针对这两类问题,通常需要先检查错误日志、确认失败原因;若是客户端侧问题,可重连重试或在应用代码中捕获并安全终止事务;若是服务器侧问题,则可能需要使用 innodb_force_recovery 模式跳过故障步骤以启动实例,甚至导出数据、重建表空间或从备份+二进制日志恢复。

查看 undo 日志

InnoSQL 对 information_schema 进行了扩展,添加了两张数据字典表,这样用户可以非常方便和快捷地查看 undo 的信息。

首先增加的数据字典表为 INNODB_TRX_ROLLBACK_SEGMENT。顾名思义,这个数据字典表用来查看 rollback segment,其表结构如下表所示。

Field Type Null Key Default Extra
Segment_id bigint(21) unsigned NO 0
space bigint(21) unsigned NO 0
page_no bigint(21) unsigned NO 0
last_page_no bigint(21) unsigned YES NULL
last_offset bigint(21) unsigned NO 0
last_trx_no varchar(18) NO 0
update_undo_list bigint(21) unsigned NO 0
update_undo_cached bigint(21) unsigned NO 0
insert_undo_list bigint(21) unsigned NO 0
insert_undo_cached bigint(21) unsigned NO 0

例如,可以通过如下命令来查看 rollback segment 所在的页:

1
2
3
4
5
6
7
8
9
mysql> SELECT segment_id, space, page_no
-> FROM INNODB_TRX_ROLLBACK_SEGMENT;
| segment_id | space | page_no |
|------------|-------|---------|
| 0 | 0 | 6 |
| 1 | 0 | 45 |
| 2 | 0 | 46 |
| ... | ... | ... |
128 rows in set (0.00 sec)

另一张数据字典表为 INNODB_TRX_UNDO,用来记录事务对应的 undo log,方便开发人员详细了解每个事务产生的 undo 量。下面将演示如何使用 INNODB_TRX_UNDO 表。首先根据如下代码创建测试表。

1
2
3
4
5
6
CREATE TABLE t (
a INT,
b VARCHAR(32),
PRIMARY KEY(a),
KEY(b)
) ENGINE=InnoDB;

接着插入一条记录,并尝试通过 INNODB_TRX_UNDO 观察该事务的 undo log 情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1,'1';
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3001
rseg_id: 2
undo_rec_no: 0
undo_rec_type: TRX_UNDO_INSERT_REC
size: 12
space: 0
page_no: 334
offset: 272
1 row in set (0.00 sec)

通过数据字典表可以看到,事务 ID 为 3001,rollback segment 的 ID 为 2,因为是该条事务的第一个操作,故 undo_rec_no 为 0。之后可以看到插入的类型为 TRX_UNDO_INSERT_REC,表示 insert undo log 的大小,占用 12 字节。最后的 space, page_no, offset 表示 undo log 开始的位置。打开文件 ibdata1,定位到页(334,272),并读取 12 字节,可得到如下内容:

1
01 1c 0b 00 16 04 80 00 01 01 10

上述就是 undo log 实际的内容,根据上一小节对 undo log 格式的介绍,可以整理得到:

1
2
3
4
5
6
7
8
9
10
11
01 1c   # 下一个 undo log 的偏移 272+12=0x011c 

0b # undo log 的类型,TRX_UNDO_INSERT_REC 为 11

00 # undo log 的记录,等同于 undo_rec_no

16 04 # 表的 ID

80 00 00 01 # 主键的长度

01 01 10 # 主键的内容

此外,由于知道该 undo log 所在的 rollback segment 的 ID 为 2,用户还可以通过数据字典表 INNODB_TRX_ROLLBACK_SEGMENT 来查看当前 rollback segment 的信息,如:

1
2
3
4
5
6
7
8
mysql> SELECT segment_id, insert_undo_list, insert_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
insert_undo_list: 1
insert_undo_cached: 0
1 row in set (0.00 sec)

可以看到 insert_undo_list 为 1。若这时进行事务的 COMMIT 操作,再查看该数据字典表:

1
2
3
4
5
6
7
8
9
10
11
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT segment_id, insert_undo_list, insert_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
insert_undo_list: 0
insert_undo_cached: 1
1 row in set (0.00 sec)

可以发现,insert_undo_list 变为 0,而 insert_undo_cached 增加为 1。这就是前面所介绍的 undo 页重用。下次再有事务需要向该 rollback segment 申请 undo 页时,可以直接使用该页。

接着再来观察 delete 操作产生的 undo log。进行如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> DELETE FROM t WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3201
rseg_id: 2
undo_rec_no: 0
undo_rec_type: TRX_UNDO_DEL_MARK_REC
size: 37
space: 0
page_no: 326
offset: 620
1 row in set (0.00 sec)

用上述同样的方法定位到页 326,偏移量为 620 的位置,得到如下结果:

1
2
3
4
5
6
7
0518260 00 00 00 00 00 00 00 00 00 00 00 02 91 0e 00 

0518270 16 00 00 00 30 01 e0 82 00 00 01 4e 01 10 04

0518280 80 00 00 01 0c 31 01 02 01 03 01 31 02 6c 00

0518290 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

接着开始整理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
02 91   # 下一个 undo log 开始位置的偏移量 

0e # undo log 类型,TRX_UNDO_DEL_MARK_REC 为 14

16 00 # undo no

00 # table id

00 00 30 01 # info bits

e0 # rec 事务 id

82 00 00 01 # rec 回滚指针

04 # 主键长度

80 00 00 01 # 主键值

0b # 之后部分的长度

04 # 列的位置

80 00 00 01 # 列的值

03 # 列的位置,前 00~02 为系统列

01 # 列的长度

31 # 列 b 插入的字符 ‘1’ 的十六进制

02 6c # 开始位置的偏移量

观察 rollback segment 信息,可以看到:

1
2
3
4
5
6
7
8
mysql> SELECT segment_id, update_undo_list, update_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
update_undo_list: 1
update_undo_cached: 0
1 row in set (0.00 sec)

同样的,在事务提交后,undo 页会放入 cache 列表以供下次重用:

1
2
3
4
5
6
7
8
9
10
11
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT segment_id, update_undo_list, update_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
update_undo_list: 0
update_undo_cached: 1
1 row in set (0.00 sec)

通过上面的例子可以看到,delete 操作并不直接删除记录,只是将记录标记为已删除,也就是将记录的 delete flag 设置为 1,而记录最终的删除是在 purge 操作中完成的。

最后来看 update 操作产生的 undo log 情况。首先再次插入记录 (1, '1'),然后进行 update 操作,同时通过数据字典表 INNODB_TRX_UNDO 观察 undo log 的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mysql> INSERT INTO t SELECT 1,'1';
Query OK, 1 row affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE t SET b='2' WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3205
rseg_id: 5
undo_rec_no: 0
undo_rec_type: TRX_UNDO_UPD_EXIST_REC
size: 41
space: 0
page_no: 318
offset: 724
1 row in set (0.00 sec)

用上述同样的方法定位到页 318,偏移量为 724 的位置,得到如下结果:

1
2
3
4
5
04f82d0 00 00 00 00 02 fd 0c 00 16 00 00 00 00 32 04 e0 

04f82e0 84 00 00 01 48 01 10 04 80 00 00 01 01 03 01 31

04f82f0 00 0b 00 04 80 00 00 01 03 01 31 02 d4 00 00 00

整理后得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
02 fd   # 下一个 undo log 开始位置 

0c # undo log 类型,TRX_UNDO_UPD_EXIST_REC 为 12

00 # undo no

16 00 # table id

00 00 00 32 # info bits

04 e0 # rec trx id

84 00 00 01 # rec 回滚指针

48 01 10 00 # 主键长度及主键值

01 03 01 31 # update_vector 的数目及各列编号和值

0b # 接下去部分占用的字节

00 … # 后续其他列的 old values(略)

上述的例子是更新一个非主键值,若更新的对象是一个主键值,那么其产生的 undo log 完全不同,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mysql> ROLLBACK;
Query OK, 1 row affected (0.00 sec)

mysql> UPDATE t SET a=2 WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO
-> ORDER BY undo_rec_no\G
*************************** 1. row ***************************
trx_id: 320F
rseg_id: 11
undo_rec_no: 0
undo_rec_type: TRX_UNDO_DEL_MARK_REC
size: 37
space: 0
page_no: 324
offset: 492
*************************** 2. row ***************************
trx_id: 320F
rseg_id: 11
undo_rec_no: 1
undo_rec_type: TRX_UNDO_INSERT_REC
size: 12
space: 0
page_no: 336
offset: 272
2 rows in set (0.00 sec)

可以看到,update 主键的操作其实分两步完成。首先将原主键记录标记为已删除,因此需要产生一个类型为 TRX_UNDO_DEL_MARK_REC 的 undo log,之后插入一条新的记录,因此需要产生一个类型为 TRX_UNDO_INSERT_REC 的 undo log。undo_rec_no 显示了产生日志的步数。对 undo log 不再详细进行分析,相关内容和之前介绍并无不同。

为什么主键值更新需要两阶段操作?

在 InnoDB 中,主键不仅是逻辑上的唯一标识,还决定了行在聚簇索引中的物理位置。当主键值发生改变时,MySQL 无法就地修改这一物理位置,也无法仅更新聚簇索引条目的关键字而保留其余数据和二级索引结构。因此,InnoDB 对主键的更新会被分成两阶段:先对旧记录打删除标记,再插入一条带新主键的记录,这样既能保证数据一致性,又能正确维护所有关联的索引。

但是,辅助索引的更新,虽然在逻辑上也等同于删除旧的索引条目+插入新的索引条目,但 InnoDB 在内部并不会把它拆成两个分离的 undo/redo 阶段,而是当作一次“更新”来处理,并由同一条 update undo logTRX_UNDO_UPD_EXIST_REC)和对应的 redo 记录一次性完成。

purge

delete 和 update 操作可能并不直接删除原有的数据。例如,对之前的表 t 执行如下的 SQL 语句:

1
DELETE FROM t WHERE a=1;

表 t 上列 a 有聚簇索引,列 b 上有辅助索引。对于上述的 delete 操作,通过前面关于 undo log 的介绍已经知道仅是将主键列等于 1 的记录 delete flag 设置为 1,记录并没有被删除,即记录还是存在于 B+ 树中。其次,对辅助索引上 a 等于 1、b 等于 1 的记录同样没有做任何处理,甚至没有产生 undo log。而真正删除这行记录的操作其实被延时了,最终在 purge 操作中完成。

purge 用于最终完成 delete 和 update 操作。这样设计是因为 InnoDB 存储引擎支持 MVCC,所以记录不能在事务提交时立即进行处理。这时其他事务可能正在引用该行,故 InnoDB 存储引擎需要保存记录之前的版本,而是否可以删除该记录通过 purge 来进行判断。若该记录已不被任何其他事务引用,那么就可以进行真正的 delete 操作。可见,purge 操作是清理之前的 delete 和 update 操作,将上述操作最终完成。而实际上执行的操作为 delete 操作,清理之前记录的版本。

之前已经介绍过,为了节省存储空间,InnoDB 存储引擎的 undo log 设计是这样的:一个页上允许多个事务的 undo log 存在。虽然这不代表事务在全局过程中提交的顺序,但是后面的事务产生的 undo log 总在最后。此外,InnoDB 存储引擎还有一个 history 列表,它根据事务提交的顺序,将 undo log 进行链接。

下图中,history list 表示按照事务提交的顺序将 undo log 进行组织。在 InnoDB 存储引擎的设计中,先提交的事务总在尾端。undo page 存放了 undo log,由于可以重用,因此一个 undo page 中可能存放了多个不同事务的 undo log。trx5 的灰色阴影表示该 undo log 还被其他事务引用。

img

在执行 purge 的过程中,InnoDB 存储引擎首先从 history list 中找到第一个需要被清理的记录,这里为 trx1,清理之后 InnoDB 存储引擎会在 trx1 的 undo log 所在的页中继续寻找是否存在可以被清理的记录,这里会找到事务 trx3,接着找到 trx5,但是发现 trx5 被其他事务引用而不能清理,故会再次去 history list 中查找,发现此时最尾端的记录为 trx2,接着找到 trx2 所在的页,然后依次再把事务 trx6、trx4 的记录进行清理。

由于 undo page2 中所有的页都被清理了,因此该 undo page 可以被重用。

InnoDB 存储引擎这种先从 history list 中找 undo log,然后再从 undo page 中找 undo log 的设计模式是为了避免大量的随机读取操作,从而提高 purge 的效率。

全局动态参数 innodb_purge_batch_size 用来设置每次 purge 操作需要清理的 undo page 数量。在 InnoDB 1.2 之前,该参数的默认值为 20。而从 1.2 版本开始,该参数的默认值为 300。通常来说,该参数设置得越大,每次回收的 undo page 也就越多,这样可供重用的 undo page 就越多,减少了磁盘存储空间与分配的开销。不过,若该参数设置得太大,则每次需要 purge 处理更多的 undo page,从而导致 CPU 和磁盘 I/O 过于集中于对 undo log 的处理,使性能下降。因此对该参数的调整需要由有经验的 DBA 来操作,并且需要长期观察数据库的运行状态。正如官方的 MySQL 数据库手册所说的,普通用户不需要调整该参数。

当 InnoDB 存储引擎的压力非常大时,并不能高效地进行 purge 操作。那么 history list 的长度会变得越来越长。全局动态参数 innodb_max_purge_lag 用来控制 history list 的长度,若长度大于该参数时,其会延缓 DML 的操作。该参数默认值为 0,表示不对 history list 做任何限制。当大于 0 时,就会延缓 DML 的操作,其延缓的算法为:

$$delay = ((length(history_list) – innodb_max_purge_lag) * 10) – 5$$

delay 的单位是毫秒。此外,需要特别注意的是,delay 的对象是行,而不是一个 DML 操作。例如当一个 update 操作需要更新 5 行数据时,每行数据的操作都会被 delay,故总的延时时间为 5 * delay。而 delay 的统计会在每一次 purge 操作完成后,重新进行计算。

InnoDB 1.2 版本引入了新的全局动态参数 innodb_max_purge_lag_delay,其用来控制 delay 的最大毫秒数。也就是说当上述计算得到的 delay 值大于该参数时,将 delay 设置为 innodb_max_purge_lag_delay,避免由于 purge 操作缓慢导致其他 SQL 线程出现无限制的等待。

group commit

若事务为非只读事务,则每次事务提交时需要进行一次 fsync 操作,以此保证重做日志都已写入磁盘。当数据库发生宕机时,可以通过重做日志进行恢复。虽然固态硬盘的出现提高了磁盘的性能,然而磁盘的 fsync 性能是有限的。为了提高磁盘 fsync 的效率,目前数据库都提供了 group commit 的功能,即一次 fsync 可以刷新确保多个事务日志被写入文件。对于 InnoDB 存储引擎来说,事务提交时会进行两个阶段的操作:

  1. 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
  2. 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。

步骤 2)相对于步骤 1)是一个较慢的过程,这是因为存储引擎需要与磁盘打交道。但当有事务进行这个过程时,其他事务可以进行步骤 1)的操作,正在提交的事务完成提交操作后,再次进行步骤 2)时,可以将多个事务的重做日志通过一次 fsync 刷新到磁盘,这样就大大地减少了磁盘的压力,从而提高了数据库的整体性能。对于写入或更新较为频繁的操作,group commit 的效果尤为明显。

然而在 InnoDB 1.2 版本之前,在开启二进制日志后,InnoDB 存储引擎的 group commit 功能会失效,从而导致性能的下降。并且在在线环境多使用 replication 环境,因此二进制日志的选项基本都为开启状态,因此这个问题尤为显著。

导致这个问题的原因是在开启二进制日志后,为了保证存储引擎层中的事务和二进制日志的一致性,二者之间使用了两阶段事务,其步骤如下:

  1. 当事务提交时 InnoDB 会将事务的 prepare 记录写入 redo 日志缓冲并持久化到磁盘。
  2. MySQL 数据库上层写入二进制日志。
  3. InnoDB 存储引擎层将日志写入重做日志文件。
    1. 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
    2. 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。

一旦步骤 2)中的操作完成,就确保了事务的提交,即使在执行步骤 3)时数据库发生了宕机。此外需要注意的是,每个步骤都需要进行一次 fsync 操作才能保证上下两层数据的一致性。步骤 2)的 fsync 由参数 sync_binlog 控制,步骤 3)的 fsync 由参数 innodb_flush_log_at_trx_commit 控制。因此上述整个过程如下图所示。

img

为了保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致,MySQL 数据库内部使用了 prepare_commit_mutex 这个锁。但是在启用这个锁之后,步骤 3)中的步骤 a)不可在其他事务执行步骤 b)时进行,从而导致了 group commit 失效。

然而,为什么需要保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致?

  1. 崩溃恢复一致性

    如果 Binlog 中记录的提交顺序与 InnoDB 实际提交顺序不一致:

    • 在异常崩溃后,InnoDB 会回滚那些 prepare 但未 commit 的事务;
    • 但 Binlog 里可能已记录这些事务,重放时会尝试执行已回滚的事务,导致副本或恢复数据与源实例不吻合。
  2. 物理备份兼容性

    工具如 Percona XtraBackup、InnoDB Hot Backup 在 prepare 阶段会汇总所有未完成事务,并依赖 Binlog 决定哪些事务在恢复时执行提交:

    • 若写日志与提交顺序错乱,就无法正确匹配,可能把原本应回滚的事务提交,或把应提交的事务丢弃。
  3. 主从复制一致性

    MySQL 单线程或多线程复制都遵循“在主库上的提交顺序即 Binlog 写入顺序”,备库严格按此顺序重放事务:

    • 顺序错乱会让备库先执行后提交的事务,再执行前提交的事务,破坏数据因果关系;
    • 按顺序保证了“复制是严格顺序一致”的模型。

这里以备份及恢复为例,例如通过工具 xtrabackup 或者 ibbackup 进行备份,并用来建立 replication。如下图所示:

img

因此通过锁 prepare_commit_mutex 以串行的方式来保证顺序性,然而这会使 group commit 无法生效,如下图所示。

img

上图中启用了 prepare_commit_mutex,因此只有当上一个事务 commit 后释放锁,下一个事务才可以进行 prepare 操作,并且在每个事务过程中 bin log 没有 fsync() 的调用。因此,事务的启动顺序对应写入 binlog 中的顺序。此外,由于内存数据写入磁盘的开销很大,如果频繁 fsync() 把日志数据永久写入磁盘,数据库的性能将会急剧下降。此时 MySQL 提供 sync_binlog 参数来设置在产生多少个 binlog 日志后调用一次 fsync(),将二进制日志刷新到磁盘,以提高整体性能。

这个问题最早在 2010 年的 MySQL 数据库大会中提出,Facebook MySQL 技术组、Percona 公司都提出过解决方案。最后由 MariaDB 数据库的开发人员 Kristian Nielsen 完成了最终的“完美”解决方案。在这种情况下,不但 MySQL 数据库上层的二进制日志写入是 group commit 的,InnoDB 存储引擎层也是 group commit 的。此外还移除了原先的锁 prepare_commit_mutex,从而大大提高了数据库的整体性。MySQL 5.6 采用了类似的实现方式,并将其称为 Binary Log Group Commit(BLGC)。

MySQL 5.6 BLGC 的实现方式是将事务提交的过程分为几个步骤来完成,如下图所示。

img

三阶段提交与队列模型

BLGC 将单次事务提交拆分为三阶段,每阶段各自维护一个队列和对应的处理流程:

  1. Flush 阶段(Flush queue):事务先将各自的 Binlog 缓存(内存 I/O cache)写入到文件页缓存中,不立即调用 fsync,仅做写操作;
  2. Sync 阶段(Sync queue):队列领导者统一调用一次 fsync,根据 sync_binlog 设置决定是否持久化到存储介质;如果 sync_binlog=1,每批次都强制同步;
  3. Commit 阶段(Commit queue):在 InnoDB 层真正执行 group commit,写入 Redo Log 的 commit 标记,并通知各事务完成提交。

Leader / Follower 模式:队列中的第一个事务称为 leader,其他事务称为 follower,leader 控制着 follower 的行为。

具体流程

  1. Flush 阶段

    • 每个事务将自己的 Binlog 事件追加到线程本地的缓存区;

    • Leader 在此阶段结束时,将所有 follower 的缓存统一写入到 Binlog 文件的页缓存(内存)中,仅触发一次文件页写操作,无 fsync。

  2. Sync 阶段

    • 所有线程进入 Sync 队列,并由 Leader 负责后续操作;

    • Leader 按 sync_binlog 配置调用 fsync。若 sync_binlog=1,保证每批次提交后强制持久化;若设置大于 1,则每隔 N 次才 fsync;

    • 同步完成后,Leader 释放文件锁并唤醒所有 Follower,确保它们能看到持久化的数据。

  3. Commit 阶段

    • InnoDB Group Commit:Leader 调用 InnoDB 的 group commit 接口,将事务在存储引擎层的提交日志(commit 重做日志)一次性刷入 Redo Log 文件;

    • 释放事务锁;

    • 所有参与事务的线程被唤醒,返回提交成功状态。

为什么 Binary Log Group Commit 能保证 Binlog 写入顺序与 InnoDB 提交顺序严格一致?

inary Log Group Commit 通过在 Flush、Sync、Commit 三个阶段均使用 FIFO 队列,并由队首的 Leader 顺序处理,保证了先进入队列的事务先被 Flush、先被 Sync、先被 Commit 以及在当前阶段完成后按顺序先被传给下一阶段,从而使 Binlog 的写入顺序与 InnoDB 的提交顺序严格一致。

当有一组事务在进行 Commit 阶段时,其他新事务可以进行 Flush 阶段,从而使 group commit 不断生效。当然 group commit 的效果由队列中事务的数量决定,若每次队列中仅有一个事务,那么可能效果和之前差不多,甚至会更差。但当提交的事务越多时,group commit 的效果越明显,数据库性能的提升也就越大。

参数 binlog_max_flush_queue_time 用来控制 Flush 阶段中等待的时间,即使之前的一组事务完成提交,当前一组的事务也不马上进入 Sync 阶段,而是至少需要等待一段时间。这样做的好处是 group commit 的事务数量更多,然而这也可能会导致事务的响应时间变慢。该参数的默认值为 0,且推荐设置依然为 0。除非用户的 MySQL 数据库系统中有着大量的连接(如 100 个连接),并且不断地在进行事务的写入或更新操作。

锁 和 MVCC => 隔离性

锁的内容在其他部分会提到,这里不赘述。

读操作的类型

当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

select ... lock in share mode, select ... for update, insert, delete 都是一种当前读。

快照读:读取的是记录数据的可见版本(可能是任一历史版本),不加锁,非阻塞读。简单的 select(不加锁)就是快照读。

  • read committed:每次 select 都生成一个快照读。
  • repeatable read:开启事务后执行第一次 select 语句时快照读(产生快照),之后的查询都是读取之前产生的快照。
  • serializable:通过快照隔离的读视图被放弃,取而代之的是对最新提交数据的锁定式读取。

MVCC

多版本并发控制:通过一定机制生成一个数据请求时间点的一致性数据快照,并用这个快照来提供一定级别(语句级或事务级)的一致性读取。记录的是某个时间点上的数据快照,用来实现不同事务之间数据的隔离性。

它维护一个数据的多个版本,使得读写操作没有冲突,快照读为 MySQL 实现 MVCC 提供了一个非阻塞读功能。

MVCC 的实现依赖于数据库的每条记录中的三个隐式字段:

  1. DB_TRX_ID:记录最后一次插入或修改该记录的事务 ID。
  2. DB_ROLL_PTR(回滚指针):指向 undo log 中这条记录的上一个版本。
  3. DB_ROW_ID(隐藏主键):如果表结构未指定主键,将会生成该隐藏字段。

img

undo log(回滚日志):记录未提交事务,用于事务回滚。

  • insert 时产生的回滚日志只在回滚时需要,可在事务提交后被立即删除
  • update 或 delete 时产生的回滚日志不仅在回滚时需要,在快照读时也需要,所以不会被立即删除。

**undo log 版本链:**不同事务或相同事务对同一条记录进行修改,会导致该记录 undo log 生成一条记录版本的链表,链表头部是最新的旧记录,链表尾部是最早的旧记录。

undo log 确实会被删除,但只有在所有可能依赖它的事务都结束之后才会删除。因此,在事务活跃期间,版本链仍然存在,不会立即消失。

img

ReadView 是快照读 SQL 执行时 MVCC 提取数据的依据,用于确定在特定事务中哪些版本的行记录是可见的,它记录并维护系统当前活跃的未提交事务的 id。ReadView 主要用来处理隔离级别为可重复读和读已提交的情况。因为在这两个隔离级别下,事务在读取数据时,需要保证读取到的数据是一致的,即读取到的数据是在事务开始时的一个快照。包含四个核心字段

字段 含义
m_ids 当前活跃的事务 ID 集合
min_trx_id 最小活跃事务 ID
max_trx_id 预分配事务 ID(当前最大事务 ID + 1)
creator_trx_id ReadView 创建者的事务 ID

版本链访问机制

img

trx_id:特定事务 ID

  1. trx_id == creator_trx_id:可以访问该版本。
  2. trx_id < min_trx_id:可以访问该版本。
  3. trx_id > max_trx_id:不可以访问该版本。
  4. min_trx_id <= trx_id <= max_trx_id:如果 trx_id 不在 m_ids 中则可以访问该版本。

假设读事务开启了一个 ReadView,这个 ReadView 里面记录了当前活跃事务的 ID 列表(444、555、665),以及最小事务 ID(444)和最大事务 ID(666)。当然还有自己的事务 ID 520,也就是 creator_trx_id。它要读的这行数据的写事务 ID 是 x,也就是 DB_TRX_ID

  • 如果 x = 110,显然在 ReadView 生成之前就提交了,所以这行数据是可见的。
  • 如果 x = 667,显然是未知世界,所以这行数据对读操作是不可见的。
  • 如果 x = 519,虽然 519 大于 444 小于 666,但是 519 不在活跃事务列表里,所以这行数据是可见的。因为 519 是在 520 生成 ReadView 之前就提交了。
  • 如果 x = 555,虽然 555 大于 444 小于 666,但是 555 在活跃事务列表里,所以这行数据是不可见的。因为 555 不确定有没有提交。

需要注意的是,不同隔离级别生成 ReadView 的时机不同:

  1. 可重复读:在第一次读取数据时生成一个 ReadView,这个 ReadView 会一直保持到事务结束,这样可以保证在事务中多次读取同一行数据时,读取到的数据是一致的。
  2. 读已提交:每次读取数据前都生成一个 ReadView,这样就能保证每次读取的数据都是最新的。

多个事务同时操作同一行会发生什么?

多个事务确实可同时操作同一行,但 MVCC 提供了以下机制来处理并发情况,确保数据一致性:

  • 读写并发:当一个事务在对某一行数据进行读取时(读操作),即使其他事务正在对该行数据进行写操作(更新或删除),MVCC 仍然允许该事务读取该行的历史版本数据,而不会与写操作发生冲突。读操作不会阻塞写操作,写操作也不会阻塞读操作,这正是 MVCC 并发控制的核心优势之一。
  • 写写并发:当多个事务同时对同一行数据进行写操作时,InnoDB 使用行级锁来控制并发写操作。这意味着,虽然多个事务可以并发读取同一行数据的不同版本,但同一时刻只能有一个事务对该行进行写操作。当一个事务对某一行数据加了排他锁(X 锁),其他事务尝试写入同一行时,会被阻塞,直到该排他锁释放。
  • 事务冲突与回滚:如果多个事务在同一行上发生冲突(例如,事务 A 在修改一行数据时,事务 B 也试图修改同一行数据),事务 B 会等待事务 A 完成。当事务 A 提交后,事务 B 才能继续进行。如果事务 A 回滚,那么事务 B 仍然可以继续修改该行,因为回滚后数据恢复到了事务 A 之前的状态。

如果其他三个特性都能够得到保证,那一致性也就能得到保证了。例如:

  • 如果一个事务回滚,原子性确保数据库回到之前的状态;
  • 隔离性确保事务之间的干扰最小化,避免不一致;
  • 持久性确保事务提交后修改不会丢失。

分布式事务

在 MySQL 数据库中,InnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来实现分布式事务。分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求其中的所有参与事务要么都提交,要么都回滚,这对事务原有的 ACID 要求提出了更高的要求。此外,在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE

XA 事务允许在不同数据库之间进行分布式事务。比如,一台服务器上运行 MySQL 数据库,另一台服务器上运行 Oracle 数据库,甚至还可能有一台运行 SQL Server 数据库,只要所有参与全局事务的节点都支持 XA 事务即可。分布式事务在银行系统的转账场景中比较常见,例如用户 David 需要从上海的账户转账 10 000 元到北京用户 Mariah 的银行卡中:

1
2
3
4
5
# Bank@Shanghai:
UPDATE account SET money = money - 10000 WHERE user='David';

# Bank@Beijing:
UPDATE account SET money = money + 10000 WHERE user='Mariah';

在这种情况下,一定需要使用分布式事务来保证数据的安全性。如果操作不能全部提交或全部回滚,那么任何一个节点出现问题都会导致严重后果:要么 David 的账户被扣款但 Mariah 没收到钱,要么 David 的账户没有扣款但 Mariah 收到钱。

XA 事务由以下三部分组成:

  • 资源管理器(Resource Manager):提供访问事务资源的方法。通常一个数据库就是一个资源管理器。
  • 事务管理器(Transaction Manager):协调参与全局事务的各个资源管理器,需要与所有参与的资源管理器通信。
  • 应用程序(Application Program):定义事务的边界,指定全局事务中的操作。

在 MySQL 的分布式事务中,资源管理器就是 MySQL 数据库,事务管理器通常是连接 MySQL 服务器的客户端。下图展示了一个分布式事务的模型。

img

分布式事务采用两段式提交(two-phase commit)方法:

  1. **准备阶段(PREPARE):**所有参与全局事务的节点开始准备,告诉事务管理器它们已经准备好提交。
  2. **提交/回滚阶段(COMMIT or ROLLBACK):**事务管理器根据各节点的准备情况,通知资源管理器执行 COMMIT 还是 ROLLBACK。如果任何一个节点无法提交,则所有节点都被告知回滚。

与本地事务相比,分布式事务多了一次 PREPARE 操作:只有在收到所有节点同意准备的信息后,才执行最终的 COMMIT 或 ROLLBACK。

MySQL 数据库中 XA 事务的 SQL 语法如下:

1
2
3
4
5
6
7
8
9
10
11
XA {START|BEGIN} xid [JOIN|RESUME]

XA END xid [SUSPEND [FOR MIGRATE]]

XA PREPARE xid

XA COMMIT xid [ONE PHASE]

XA ROLLBACK xid

XA RECOVER

在单个节点上运行分布式事务意义不大;通常需要通过编程语言来驱动多个节点的分布式事务操作。

内部 XA 事务

之前讨论的分布式事务是外部事务,即资源管理器是 MySQL 数据库本身。在 MySQL 数据库中还存在另外一种分布式事务,其在存储引擎与插件之间,又或者在存储引擎与存储引擎之间,称之为内部 XA 事务。

最常见的内部 XA 事务存在于 binlog 与 InnoDB 存储引擎之间。由于复制的需要,因此目前绝大多数的数据库都开启了 binlog 功能。在事务提交时,先写二进制日志,再写 InnoDB 存储引擎的重做日志。对上述两个操作的要求也是原子的,即二进制日志和重做日志必须同时写入。若二进制日志先写了,而在写入 InnoDB 存储引擎时发生了宕机,那么 slave 可能会接收到 master 传过去的二进制日志并执行,最终导致主从不一致的情况。如下图所示。

img

在上图中,如果执行完 ①、② 后在步骤 ③ 之前 MySQL 数据库发生了宕机,则会发生主从不一致的情况。为了解决这个问题,MySQL 数据库在 binlog 与 InnoDB 存储引擎之间采用 XA 事务。当事务提交时,InnoDB 存储引擎会先做一个 PREPARE 操作,将事务的 xid 写入,接着进行二进制日志的写入,如下图所示。如果在 InnoDB 存储引擎提交前,MySQL 数据库宕机了,那么 MySQL 数据库在重启后会先检查准备的 UXID 事务是否已经提交,若没有,则在存储引擎层再进行一次提交操作。

img

不好的事务习惯

在循环中提交

开发人员可能会在循环中进行事务的提交,如下(可想象成 Java 中的某个方法):

1
2
3
4
5
6
7
8
9
10
CREATE PROCEDURE load1(count INT UNSIGNED)
BEGIN
DECLARE s INT UNSIGNED DEFAULT 1;
DECLARE c CHAR(80) DEFAULT REPEAT('a',80);
WHILE s <= count DO
INSERT INTO t1 SELECT NULL, c;
COMMIT;
SET s = s + 1;
END WHILE;
END;

其实,在上述的例子中,是否加上提交命令 COMMIT 并不关键。因为 InnoDB 存储引擎默认是自动提交,所以在上述的存储过程中去掉 COMMIT,结果其实是完全一样的。

上面的存储过程存在一个问题,当发生错误时,数据库会停留在一个未知的位置。例如,用户需要插入 10000 条记录,但是在插入 5000 条时,发生了错误,此时前 5000 条记录已经存放在数据库中,那应该怎么处理呢?另一个问题是性能问题,上面两个存储过程都不会比下面的存储过程 load2 快,因为下面的存储过程将所有的 INSERT 都放在一个事务中:

1
2
3
4
5
6
7
8
9
10
11
CREATE PROCEDURE load3(count INT UNSIGNED)
BEGIN
DECLARE s INT UNSIGNED DEFAULT 1;
DECLARE c CHAR(80) DEFAULT REPEAT('a',80);
START TRANSACTION;
WHILE s <= count DO
INSERT INTO t1 SELECT NULL, c;
SET s = s + 1;
END WHILE;
COMMIT;
END;

也就是说,因为每一次提交都要写一次重做日志,存储过程 load1 和 load2 实际写了 10000 次重做日志文件,而对于存储过程 load3 来说,实际上只写了 1 次。

使用自动提交

MySQL 中默认启用 autocommit 模式,意味着每条 DML 语句会被视作单独的事务并在执行后立即提交。这虽然降低了使用门槛,但也容易在批量操作或存储过程中导致不可控的中间状态,并引发性能和一致性问题。通过关闭 autocommit(SET autocommit=0)或显式使用 START TRANSACTION/BEGIN 来管理事务,可以将多条操作放入同一个事务,从而在发生错误时统一回滚,并显著减少重做日志的写入次数。不同客户端 API(如 C API、Python API)对 autocommit 的默认行为各不相同,应用开发时需特别留意并在程序端明确控制事务边界。

使用自动回滚

在存储过程中,通过 DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK; 定义了一个针对任意 SQL 异常的退出型处理器,一旦发生错误便自动执行 ROLLBACK,无需显式再调用回滚语句。虽然自动回滚保证了数据一致性,但存储过程本身并不会向调用者返回错误信息。所以我们应将事务控制下放到应用程序一侧,既能保证回滚,也能捕获数据库抛出的错误编码与描述。