Mysql 事务

Mysql 事务

不是所有的 Mysql 存储引擎都实现了事务处理。支持事务的存储引擎有:InnoDBNDB Cluster。不支持事务的存储引擎,代表有:MyISAM

用户可以根据业务是否需要事务处理(事务处理可以保证数据安全,但会增加系统开销),选择合适的存储引擎。

事务简介

事务概念

“事务”指的是满足 ACID 特性的一组操作。事务内的 SQL 语句,要么全执行成功,要么全执行失败。可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

ACID

ACID 是数据库事务正确执行的四个基本要素。

  • 原子性(Atomicity)
    • 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
  • 隔离性(Isolation)
    • 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。

  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对系统崩溃的情况。

事务操作

事务相关的语句如下:

  • BEGIN / START TRANSACTION - 用于标记事务的起始点
  • START TRANSACTION WITH CONSISTENT SNAPSHOT - 用于标记事务的起始点
  • SAVEPOINT - 用于创建保存点。方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
  • RELEASE SAVEPOINT - 删除某个保存点。
  • ROLLBACK TO - 用于回滚到指定的保存点。如果没有设置保存点,则回退到 START TRANSACTION 语句处。
  • COMMIT - 提交事务
  • SET TRANSACTION - 设置事务的隔离级别。

注意:

两种开启事务的命令,启动时机是不同的:

  • 执行了 BEGIN / START TRANSACTION 命令后,并不代表事务立刻启动,而是当执行了增删查操作时,才真正启动事务。
  • 执行了 START TRANSACTION WITH CONSISTENT SNAPSHOT 命令,会立刻启动事务。

事务处理示例:

(1)创建一张示例表

1
2
3
4
5
6
7
8
9
10
-- 撤销表 user
DROP TABLE IF EXISTS `user`;

-- 创建表 user
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL COMMENT 'Id',
`username` VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '用户名',
`password` VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '密码',
`email` VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '邮箱'
) COMMENT ='用户表';

(2)执行事务操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 开始事务
START TRANSACTION;

-- 插入操作 A
INSERT INTO `user`
VALUES (1, 'root1', 'root1', 'xxxx@163.com');

-- 创建保留点 updateA
SAVEPOINT `updateA`;

-- 插入操作 B
INSERT INTO `user`
VALUES (2, 'root2', 'root2', 'xxxx@163.com');

-- 回滚到保留点 updateA
ROLLBACK TO `updateA`;

-- 提交事务,只有操作 A 生效
COMMIT;

(3)查询结果

1
SELECT * FROM `user`;

结果:

1
2
3
4
5
6
7
mysql> SELECT * FROM user;
+----+----------+----------+--------------+
| id | username | password | email |
+----+----------+----------+--------------+
| 1 | root1 | root1 | xxxx@163.com |
+----+----------+----------+--------------+
1 row in set (0.02 sec)

AUTOCOMMIT

MySQL 默认采用隐式提交策略(autocommit。每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMITROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。

通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。

1
2
3
4
5
6
7
8
-- 查看 AUTOCOMMIT
SHOW VARIABLES LIKE 'AUTOCOMMIT';

-- 关闭 AUTOCOMMIT
SET autocommit = 0;

-- 开启 AUTOCOMMIT
SET autocommit = 1;

并发一致性问题

在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。

丢失修改

“丢失修改”是指一个事务的更新操作被另外一个事务的更新操作替换

如下图所示,T1 和 T2 两个事务对同一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。

脏读

“脏读(dirty read)”是指当前事务可以读取其他事务未提交的数据

如下图所示,T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。

不可重复读

“不可重复读(non-repeatable read)”是指一个事务内多次读取同一数据,过程中,该数据被其他事务所修改,导致当前事务多次读取的数据可能不一致

如下图所示,T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

幻读

“幻读(phantom read)”是指一个事务内多次读取同一范围的数据,过程中,其他事务在该数据范围新增了数据,导致当前事务未发现新增数据

事务 T1 读取某个范围内的记录时,事务 T2 在该范围内插入了新的记录,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。

事务隔离级别

事务隔离级别简介

为了解决以上提到的“并发一致性问题”,SQL 标准提出了四种“事务隔离级别”来应对这些问题。事务隔离级别等级越高,越能保证数据的一致性和完整性,但是执行效率也越低。因此,设置数据库的事务隔离级别时需要做一下权衡。

事务隔离级别从低到高分别是:

  • “读未提交(read uncommitted)” - 是指,事务中的修改,即使没有提交,对其它事务也是可见的
  • “读已提交(read committed)” ** - 是指,事务提交后,其他事务才能看到它的修改**。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
    • 读已提交解决了脏读的问题
    • 读已提交是大多数数据库的默认事务隔离级别,如 Oracle。
  • “可重复读(repeatable read)” - 是指:保证在同一个事务中多次读取同样数据的结果是一样的
    • 可重复读解决了不可重复读问题
    • 可重复读是 InnoDB 存储引擎的默认事务隔离级别
  • 串行化(serializable ) - 是指,强制事务串行执行,对于同一行记录,加读写锁,一旦出现锁冲突,必须等前面的事务释放锁。
    • 串行化解决了幻读问题。由于强制事务串行执行,自然避免了所有的并发问题。
    • 串行化策略会在读取的每一行数据上都加锁,这可能导致大量的超时和锁竞争。这对于高并发应用基本上是不可接受的,所以一般不会采用这个级别。

事务隔离级别对并发一致性问题的解决情况:

隔离级别 丢失修改 脏读 不可重复读 幻读
读未提交 ✔️️️
读已提交 ✔️️️ ✔️️️
可重复读 ✔️️️ ✔️️️ ✔️️️
可串行化 ✔️️️ ✔️️️ ✔️️️ ✔️️️

查看和设置事务隔离级别

可以通过 SHOW VARIABLES LIKE 'transaction_isolation' 语句查看事务隔离级别。

【示例】查看事务隔离示例

1
2
3
4
5
6
7
mysql> SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.03 sec)

MySQL 提供了 SET TRANSACTION 语句,该语句可以改变单个会话或全局的事务隔离级别。语法格式如下:

1
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}

其中,SESSIONGLOBAL 关键字用来指定修改的事务隔离级别的范围:

  • SESSION - 表示修改的事务隔离级别,将应用于当前会话内的所有事务。
  • GLOBAL - 表示修改的事务隔离级别,将应用于所有会话内的所有事务(即全局修改),且当前已经存在的会话不受影响;
  • 如果省略 SESSIONGLOBAL,表示修改的事务隔离级别,将应用于当前会话内的下一个还未开始的事务。

【示例】设置事务隔离示例

1
2
3
4
5
6
7
8
9
10
11
-- 设置事务隔离级别为 READ UNCOMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 设置事务隔离级别为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 设置事务隔离级别为 REPEATABLE READ
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 设置事务隔离级别为 SERIALIZABLE
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

事务隔离级别实现方式

Mysql 中的事务功能是在存储引擎层实现的,并非所有存储引擎都支持事务功能。InnoDB 是 Mysql 的首先事务存储引擎。

四种隔离级别具体是如何实现的呢?

以 InnoDB 的事务实现来说明:

  • 对于“读未提交”隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;
  • 对于“串行化”隔离级别的事务来说,通过加读写锁的方式来避免并行访问;
  • 对于“读提交”和“可重复读”隔离级别的事务来说,它们都是通过 ReadView 来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。
    • “读提交”隔离级别是在“每个语句执行前”都会重新生成一个 ReadView
    • “可重复读”隔离级别是在“启动事务时”生成一个 ReadView,然后整个事务期间都在用这个 ReadView。

关于 ReadView 更多细节,将在 MVCC 章节中阐述。

MVCC

当前读和快照读

在高并发场景下,多事务同时执行,可能会出现种种并发一致性问题。最常见,也是最容易想到的解决问题思路就是:对访问的数据加锁,通过强制互斥来解决问题。但是,加锁就意味着阻塞,势必会增加响应时间,降低系统整体吞吐量。在大多数真实的业务场景中,读请求远大于写请求,由于读请求并不会修改数据,自然也不存在一致性问题,因此为占大多数的读请求加锁是一种不必要的开销。那么,我们很自然的会想到,如果只针对写操作加锁,不就能大大提升吞吐量了吗?没错,有一种名为“写时复制(Copy-On-Write,简称 COW)”的技术,正是基于这个想法而设计,并广泛应用于各种软件领域,例如:Java 中的 CopyOnWriteArrayList 等容器;Redis 中的 RDB 持久化过程。

Copy-On-Write 的核心思想是:假设有多个请求需要访问相同的数据,先为这份数据生成一个副本(也可以称为快照)。然后将读写分离,所有的读请求都直接访问原数据;所有的写请求都访问副本数据,为了实现并发一致性,写数据时需要通过加锁保证每次写操作只能由一个写请求完成。当写操作完成后,用副本数据替换原数据。

在 Mysql 中,也采用了 Copy-On-Write 设计思想,将读写分离。

  • 这里的“写”指的是当前读。“当前读”,顾名思义,指的是读取记录当前的数据。为了保证读取当前数据时,没有其他事务修改,因此需要对读取记录加锁。当前读的场景有下面几种:
    • INSERT - 插入操作
    • UPDATE - 更新操作
    • DELETE - 删除操作
    • SELECT ... LOCK IN SHARE MODE - 加共享锁(读锁)
    • SELECT ... FOR UPDATE - 加独享锁(写锁)
  • 这里的“读”指的是快照读。“快照读”,顾名思义,指的是读取记录的某个历史快照版本。不加锁的普通 SELECT 都属于快照读,例如:SELECT ... FROM。采用快照读的前提是,事务隔离级别不是串行化级别。串行化级别下的快照读会退化成当前读。快照读的实现是基于 MVCC。

什么是 MVCC

前文提到,快照读的实现是基于 MVCC。那么,什么是 MVCC 呢?

MVCC 是 Multi Version Concurrency Control 的缩写,即“多版本并发控制”。MVCC 的设计目标是提高数据库的并发性,采用非阻塞的方式去处理读/写并发冲突,可以将其看成一种乐观锁。

不仅是 Mysql,包括 Oracle、PostgreSQL 等其他关系型数据库都实现了各自的 MVCC,实现机制没有统一标准。MVCC 是 InnoDB 存储引擎实现事务隔离级别的一种具体方式。其主要用于实现读已提交和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

MVCC 实现原理

MVCC 的实现原理,主要基于隐式字段、UndoLog、ReadView 来实现。

隐式字段

InnoDB 存储引擎中,数据表的每行记录,除了用户显示定义的字段以外,还有几个数据库隐式定义的字段:

  • DB_ROW_ID - 隐藏的自增 ID,如果数据表没有指定主键,InnoDB 会自动基于 row_id 产生一个聚簇索引。
  • DB_TRX_ID - 最近修改的事务 ID。事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
  • DB_ROLL_PTR - 回滚指针,指向这条记录的上一个版本。

UndoLog

MVCC 的多版本指的是多个版本的快照,快照存储在 UndoLog 中。该日志通过回滚指针 roll_pointer 把一个数据行的所有快照链接起来,构成一个版本链

ReadView

ReadView 就是事务进行快照读时产生的读视图(快照)

ReadView 有四个重要的字段:

  • m_ids - 指的是在创建 ReadView 时,当前数据库中“活跃事务”的事务 ID 列表。注意:这是一个列表,“活跃事务”指的就是,启动了但还没提交的事务
  • min_trx_id - 指的是在创建 ReadView 时,当前数据库中“活跃事务”中事务 id 最小的事务,也就是 m_ids 的最小值。
  • max_trx_id - 这个并不是 m_ids 的最大值,而是指创建 ReadView 时当前数据库中应该给下一个事务分配的 ID 值,也就是全局事务中最大的事务 ID 值 + 1;
  • creator_trx_id - 指的是创建该 ReadView 的事务的事务 ID。

在创建 ReadView 后,我们可以将记录中的 trx_id 划分为三种情况:

  • 已提交事务
  • 已启动但未提交的事务
  • 未启动的事务

ReadView 如何判断版本链中哪个版本可见?

一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

  • trx_id == creator_trx_id - 表示 trx_id 版本记录由 ReadView 所代表的当前事务产生,当然可以访问。
  • trx_id < min_trx_id - 表示 trx_id 版本记录是在创建 ReadView 之前已提交的事务生成的,当前事务可以访问。
  • trx_id >= max_trx_id - 表示 trx_id 版本记录是在创建 ReadView 之后才启动的事务生成的,当前事务不可以访问。
  • min_trx_id <= trx_id < max_trx_id - 需要判断 trx_id 是否在 m_ids 列表中
    • 如果 trx_idm_ids 列表中,表示生成 trx_id 版本记录的事务依然活跃(未提交事务),当前事务不可以访问。
    • 如果 trx_id 不在 m_ids 列表中,表示生成 trx_id 版本记录的事务已提交,当前事务可以访问。

这种通过“版本链”来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。

MVCC 如何实现多种事务隔离级别

对于“读已提交”和“可重复读”隔离级别的事务来说,它们都是通过 MVCC 的 ReadView 机制来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。

  • “读已提交”隔离级别,会在“每个语句执行前”都会重新生成一个 ReadView。
  • “可重复读”隔离级别,会在“启动事务时”生成一个 ReadView,然后整个事务期间都在复用这个 ReadView。

MySQL InnoDB 引擎的默认隔离级别虽然是“可重复读”,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:

  • 针对快照读(普通 select 语句),通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
  • 针对当前读(select … for update 等语句),通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

MVCC 实现可重复读

可重复读隔离级别只有在启动事务时才会创建 ReadView,然后整个事务期间都使用这个 ReadView。这样就保证了在事务期间读到的数据都是事务启动前的记录。

举个例子,假设有两个事务依次执行以下操作:

  • 初始,表中 id = 1 的 value 列值为 100。
  • 事务 2 读取数据,value 为 100;
  • 事务 1 将 value 设为 200;
  • 事务 2 读取数据,value 为 100;
  • 事务 1 提交事务;
  • 事务 2 读取数据,value 依旧为 100;

以上操作,如下图所示。T2 事务在事务过程中,是否可以看到 T1 事务的修改,可以根据 ReadView 中描述的规则去判断。

从图中不难看出:

  • 对于 trx_id = 100 的版本记录,比对 T2 事务 ReadView ,trx_id < min_trx_id,因此在 T2 事务中的任意时刻都可见;
  • 对于 trx_id = 101 的版本记录,比对 T2 事务 ReadView ,可以看出 min_trx_id <= trx_id < max_trx_id ,且 trx_idm_ids 中,因此 T2 事务中不可见。

综上所述,在 T2 事务中,自始至终只能看到 trx_id = 100 的版本记录。

MVCC 实现读已提交

读已提交隔离级别每次读取数据时都会创建一个 ReadView。这意味着,事务期间的多次读取同一条数据,前后读取的数据可能会出现不一致——因为,这期间可能有另外一个事务修改了该记录,并提交了事务。

举个例子,假设有两个事务依次执行以下操作:

  • 初始,表中 id = 1 的 value 列值为 100。
  • 事务 2 读取数据(创建 ReadView),value 为 0;
  • 事务 1 将 value 设为 100;
  • 事务 2 读取数据(创建 ReadView),value 为 0;
  • 事务 1 提交事务;
  • 事务 2 读取数据(创建 ReadView),value 为 100;

以上操作,如下图所示,T2 事务在事务过程中,是否可以看到其他事务的修改,可以根据 ReadView 中描述的规则去判断。

从图中不难看出:

  • 对于 trx_id = 100 的版本记录,比对 T2 事务 ReadView ,trx_id < min_trx_id,因此在 T2 事务中的任意时刻都可见;
  • 对于 trx_id = 101 的版本记录,比对 T2 事务 ReadView ,可以看出第二次查询时(T1 更新未提交),min_trx_id <= trx_id < max_trx_id ,且 trx_idm_ids 中,因此 T2 事务中不可见;而第三次查询时(T1 更新已提交),trx_id < min_trx_id,因此在 T2 事务中可见;

综上所述,在 T2 事务中,当 T1 事务提交前,可读取到的是 trx_id = 100 的版本记录;当 T1 事务提交后,可读取到的是 trx_id = 101 的版本记录。

MVCC + Next-Key Lock 解决幻读

MySQL InnoDB 引擎的默认隔离级别虽然是“可重复读”,但是它很大程度上避免幻读现象(并不是完全解决了)。针对快照读和当前读,InnoDB 的处理方式各不相同。

快照读是如何避免幻读的?

针对快照读(普通 SELECT 语句),通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。

当前读是如何避免幻读的?

针对当前读SELECT ... FOR UPDATE 等语句),通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读,因为当执行 SELECT ... FOR UPDATE 语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好的避免了幻读问题。

幻读被完全解决了吗?

可重复读隔离级别下虽然很大程度上避免了幻读,但是还是没有能完全解决幻读

【示例】幻读案例一

环境:存储引擎为 InnoDB;事务隔离级别为可重复读

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
44
45
46
47
48
49
50
51
52
-- --------------------------------------------------------------------------------------
-- 实验说明:以下 SQL 脚本必须严格按照顺序执行,并且事务 A 和事务 B 必须在不同的 Client 中执行。
-- ----------------------------------------------------------------------------------------

-- --------------------------------------------------------------------- (1)数据初始化

-- 创建表 test
CREATE TABLE `test` (
`id` INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`value` INT(10) NOT NULL
);

-- 数据初始化
INSERT INTO `test` (`id`, `value`) VALUES (1, 1);
INSERT INTO `test` (`id`, `value`) VALUES (2, 2);
INSERT INTO `test` (`id`, `value`) VALUES (3, 3);

-- --------------------------------------------------------------------- (2)事务 A

BEGIN;

-- 查询 id = 4 的记录
SELECT * FROM `test` WHERE `id` = 4;
-- 结果为空

-- --------------------------------------------------------------------- (3)事务 B

BEGIN;

INSERT INTO `test` (`id`, `value`) VALUES (4, 4);

COMMIT;

-- --------------------------------------------------------------------- (4)事务 A

-- 查询 id = 4 的记录
SELECT * FROM `test` WHERE `id` = 4;
-- 结果依然为空

-- 成功更新本应看不到的记录 id = 4
UPDATE `test` SET `value` = 0 WHERE `id` = 4;

-- 再一次查询 id = 4 的记录
SELECT * FROM `test` WHERE `id` = 4;
-- 结果为:
-- +----+-------+
-- | id | value |
-- +----+-------+
-- | 4 | 0 |
-- +----+-------+

COMMIT;

以上示例代码的时序图如下:

【示例】幻读案例二

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
44
45
46
-- --------------------------------------------------------------------- (1)数据初始化

-- 创建表 test
CREATE TABLE `test` (
`id` INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`value` INT(10) NOT NULL
);

-- 数据初始化
INSERT INTO `test` (`id`, `value`) VALUES (1, 1);
INSERT INTO `test` (`id`, `value`) VALUES (2, 2);
INSERT INTO `test` (`id`, `value`) VALUES (3, 3);

-- --------------------------------------------------------------------- (2)事务 A

BEGIN;

-- 查询 id > 2 的记录数
SELECT COUNT(*) FROM `test` WHERE `id` > 2;
-- 结果为:
-- +----------+
-- | count(*) |
-- +----------+
-- | 1 |
-- +----------+

-- --------------------------------------------------------------------- (3)事务 B

BEGIN;

INSERT INTO `test` (`id`, `value`) VALUES (4, 4);

COMMIT;

-- --------------------------------------------------------------------- (4)事务 A

-- 查询 id > 2 的记录数
SELECT COUNT(*) FROM `test` WHERE `id` > 2 FOR UPDATE;
-- 结果为:
-- +----------+
-- | count(*) |
-- +----------+
-- | 2 |
-- +----------+

COMMIT;

要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 Next-Key Lock,从而避免其他事务插入一条新记录。

分布式事务

在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为 本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。

分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。

分布式事务的常见方案如下:

  • 两阶段提交(2PC) - 将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
  • 三阶段提交(3PC) - 与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
  • 补偿事务(TCC)
    • Try - 操作作为一阶段,负责资源的检查和预留。
    • Confirm - 操作作为二阶段提交操作,执行真正的业务。
    • Cancel - 是预留资源的取消。
  • 本地消息表 - 在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
  • MQ 事务 - 基于 MQ 的分布式事务方案其实是对本地消息表的封装。
  • SAGA - Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

分布式事务方案分析:

  • 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
  • TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
  • 本地消息表/MQ 事务 都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
  • Saga 事务 由于 Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。 Saga 相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga 事务较适用于补偿动作容易处理的场景。

分布式事务详细说明、分析请参考:分布式事务基本原理

事务最佳实践

高并发场景下的事务到底该如何调优?

尽量使用低级别事务隔离

结合业务场景,尽量使用低级别事务隔离

避免行锁升级表锁

在 InnoDB 中,行锁是通过索引实现的,如果不通过索引条件检索数据,行锁将会升级到表锁。我们知道,表锁是会严重影响到整张表的操作性能的,所以应该尽力避免。

缩小事务范围

有时候,数据库并发访问量太大,会出现以下异常:

1
MySQLQueryInterruptedException: Query execution was interrupted

高并发时对一条记录进行更新的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当有大量请求进入时,就可能导致一些请求同时进入到事务中。

又因为锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超时被系统打断踢出。

img

如上图中的操作,虽然都是在一个事务中,但锁的申请在不同时间,只有当其他操作都执行完,才会释放所有锁。因为扣除库存是更新操作,属于行锁,这将会影响到其他操作该数据的事务,所以我们应该尽量避免长时间地持有该锁,尽快释放该锁。又因为先新建订单和先扣除库存都不会影响业务,所以我们可以将扣除库存操作放到最后,也就是使用执行顺序 1,以此尽量减小锁的持有时间。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

参考资料