事务处理是保证数据安全的重要机制,事务有四个重要属性 ,根据它们的英文名称可以记为ACID:

  • 原子性(Atomic): 事务操作是不可分割的; 事务只存在已执行和未执行两种状态,不存在只执行了部分指令的情况
  • 一致性(Consistency): 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。比如各种约束、触发器和级联操作等。
  • 隔离性(Isolation): 同时执行的事务之间相互隔离,不会互相影响。
  • 持久性(Durability): 事务成功提交后, 其写入的数据直到被覆盖永久有效

我们以银行转账操作为例理解事务:

START TRANSACTION;
UPDATE account_balance SET balance = balance - 200.00 WHERE customer_id = 1;
UPDATE account_balance SET balance = balance + 200.00 WHERE customer_id = 2;
COMMIT;

上述事务执行前后数据库只可能有两种状态: 账户1、2的余额未变化, 账户1余额减少200元账户2余额增加200元。不可能存在账户1余额减少而账户2余额不变的状态。

减少账户1余额和增加账户2余额是一个连续的过程, 不允许在事务执行过程中对账户1、2余额进行其它操作。

事务的原子性体现在两方面:

  • 事务执行过程中不允许插入其它操作。 减少账户1余额和增加账户2余额是一个连续的过程, 不允许在事务执行过程中对账户1、2余额进行其它操作。
  • 事务中的所有更改要么都发生要么都不发生, 不存在部分完成的情况。 减少账户1余额和增加账户2余额要么都发生,要么都不发生

事务一致性体现在: 事务执行前后数据库总是维持在一致状态, 转账开始前到转账结束(无论转账成功或失败)的整个过程中, 账户1、2的总余额始终不变。

事务隔离性体现在: 在转账事务减少账户1余额后提交之前,另一个事务查询到的账户1余额仍是减少之前的。

事务隔离性

并发事务的潜在问题

  • 脏读: 事务A修改了一个数据,但未提交,事务B读到了事务A未提交的更新结果(即脏数据)。 如事务A在执行转账操作,从转出账户扣除了余额但未修改转入账户余额,此时事务B读取了转入账户的余额, 即发生了脏读。

  • 不可重复读: 在同一个事务中,对于同一条数据两次查询读到的结果不一致。比如,在事务A两次查询中间事务B修改了某条记录,那么事务A两次查询会读到不同的结果。

  • 幻读: 在同一个事务中,对于同一个查询返回的记录数不一致。造成这种现象的原因是在事务A的两次查询中间事务B添加或删除了记录,导致事务A两次查询读到不同的结果。

幻读和不可重复读的区别在于,不可重复读是对已存在记录的修改导致的只需要对某一条记录加锁即可,幻读增删记录导致的必须对全表加锁。

InnoDB 的事务隔离级别

InnoDB 存储引擎提供四级事务隔离级别:

  • Read Uncommitted: 禁止多个事务同时修改同一条记录,其它事务可以读取未提交的修改。 隔离级别最低,并发性能最高,会出现脏读,不可重复读和幻读。

  • Read Committed: 禁止多个事务同时修改同一条记录, 修改在提交前其它事务只能读取修改前的版本。不会出现脏读,但会出现不可重复读和幻读。

  • Repeated Read: 禁止多个事务同时修改同一条记录, 事务提交前会锁定所有读取到的行,禁止其它事务修改它正在读取的行。默认隔离级别,不会出现脏读和不可重复读。是否会出现幻读我们将在下文进行深入讨论。

  • Serializable: 串行化执行,会锁定所有涉及的数据表。可以解决脏读、不可重复读和幻读, 隔离级别最高,并发性能最低。

在实际应用中我们需要根据需要选择合适的事务隔离级别。

设置事务隔离级别

SET TRANSACTION语句可以设置事务隔离级别:

-- SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 设置所有新连接的事务隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置当前连接的事务隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- 设置下一个事务的隔离级别

当前读与快照读

InnoDB 存储的读取分为当前读与快照读两种。普通的 select 使用快照读,快照读不需要加锁但是可能读到过时的数据。

Update 和 Delete 语句不能根据过时的数据进行更新删除操作因此它们使用当前读,即使用加锁的方式读取最新的数据。

使用当前读的语句包括 UPDATE, DELETE 和 SELECT ... IN SHARE MODE, SELECT ... FOR UPDATE。

MVCC

InnoDB 的快照读通过多版本并发控制(Multi Version Concurrent Control, MVCC)机制实现。

InnoDB 在每条记录中都添加了两个隐藏列:

  • data_trx_id: 记录最新更新这条记录的 Transaction ID, 删除也会被视为更新
  • data_roll_ptr: 指向当前记录项的 rollback segment 的 undo log 记录,用于找回之前版本的数据

InnoDB 每次进行快照读的时候会将当前系统中的活跃事务(即未提交事务)列表创建一个副本 ReadView,其中包含:

  • up_limit_id: 该SQL启动时当前系统中创建最早但还未提交的事务
  • low_limit_id: 该SQL启动时当前系统中最近创建的除自身以外最大事务编号
  • m_ids: 当前活跃事务ID列表

事务的创建顺序和提交顺序不是一致的 id >= up_limit_id && id < up_limit_id 的事务可能已经提交也可能没有提交,此时我们需要 m_ids 来帮助判断记录修改和 ReadView 创建的先后顺序。具体规则如下:

  1. 若 data_trx_id < up_limit_id 表示记录的最后修改在ReadView创建之前,因此这条记录可以被看见。
  2. 若 data_trx_id > low_limit_id 表示记录的最后修改在ReadView创建之后,因此这条记录一定不可以被看见。
  3. 若 data_trx_id 在 up_limit_id 和 low_limit_id 之间,则判断 db_trx_id 是否在 m_ids 中:
    1. 若存在则表示这条记录的最后修改是在 ReadView 创建之时,被另外一个活跃事务所修改,所以这条记录也不可以被看见
    2. 如果不存在,则表示这条记录的最后修改在 ReadView 创建之前所以可以看到

ReadView 与 SQL 绑定不与事务绑定,在不同隔离级别下 ReadView 的创建时机是不同的:

  • Read Commited: 在每次 SQL 执行的过程中根据当前的全局事务状态重新创建 ReadView, 因此可以看到所有在执行 SQL 时已提交的事务。同一个事务中 ReadView 可见区间会发生变化, 可能出现不可重复读和幻读。
  • Repeatable Read: 在创建事务的时候生成事务内共享的 global_read_view 一直维持到事务结束. 整个事务中 ReadView 可见区间不变, 不会出现不可重复读, 也不会出现幻读。

间隙锁

InnoDB使用锁来实现当前读, InnoDB 中存在三种行级锁:

  • 行锁(Record Lock): 单条行记录上的锁
  • 间隙锁(Gap Lock): 锁加在不存在的空闲空间,可以是两个索引记录之间,也可能是第一个索引记录之前或者最后一个索引之后的空间,两边都是开区间。
  • Next-Key Lock: 行锁与间隙锁组合起来用就叫做 Next-Key Lock,是一个前开后闭区间。

InnoDB 对 Next-Key Lock定义为: 当 SQL 语句按照范围查询非唯一索引列,并且有数据命中的时候会给索引加锁,锁住命中索引项的前一个索引到命中索引项的后一个索引之间的一个左开右闭区间

用一个示例来说明GAP锁:

1> START TRANSACTION;
1> DELETE FROM user WHERE age < 18;

在执行 DELETE 语句时 GAP LOCK 锁定了所有 age < 18 的行。我们在另一个会话中开始另一个事务, 此时事务1尚未提交:

2> START TRANSACTION;
2> INSERT INTO user (age) VALUES (17);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

可以看到事务2等待锁超时, 在事务1释放 GAP LOCK 之前不能插入 age < 18 的行, 原有的 age < 18 的行也无法修改。

GAP LOCK 锁定索引而非锁定数据行。 GAP LOCK 锁定了索引树中 age < 18 的空间(即索引间的空隙), 被锁定的区间不能插入记录也不能修改已有记录。

在这个示例中也可以看出 GAP LOCK 锁定粒度相当大,很可能导致吞吐量下降甚至死锁,务必谨慎使用。

手动加锁

上文提到 SELECT 使用快照读, SELECT ... IN SHARE MODE, SELECT ... FOR UPDATE 使用当前读。 现在我们来了解一下这两种特殊的 SELECT 语句:

SELECT ... LOCK IN SHARE MODE 是意向共享锁,即在符合条件的记录上都加了共享锁,其它线程可以在记录上继续添加共享锁。

SELECT ... FOR UPDATE 是意向排它锁,即在符合条件的记录上都加了排它锁,其它线程无法在记录上继续添加共享锁或排它锁。

总结

至此我们可以回答上文提出的经典问题: Repeated Read 隔离级别下会出现幻读吗?

在使用快照读时,整个 RR 事务使用相同的 ReadView 不会出现幻读;在使用当前读时,间隙锁机制保证其它事务无法修改查询区间内的记录,因此不会出现幻读;

在 RR 事务中除非一条语句使用当前读、另一条语句使用快照读否则不会出现幻读,遇到这种情况时只需要将使用快照读 SELECT 语句改为 SELECT ... LOCK IN SHARE MODE 或 SELECT ... FOR UPDATE 手动加锁即可。

更多关于读一致性的内容可以参考 InnoDB 官方文档: innodb-consistent-read

相关文章: