事务处理是保证数据安全的重要机制,事务有四个重要属性 ,根据它们的英文名称可以记为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两次查询读到不同的结果。
幻读和不可重复读的区别在于,不可重复读是对已存在记录的修改导致的只需要对某一条记录加锁即可,幻读增删记录导致的必须对全表加锁。
事务隔离级别
MySQL提供四级事务隔离级别:
-
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; -- 设置下一个事务的隔离级别
事务并发控制原理
我们通常有3种思路进行并发控制:
-
悲观锁: 在事务进行过程中数据总是处于被锁定状态。
悲观锁对数据被其它事务修改的可能性持悲观态度(倾向于可能发生), 常用于数据争用激烈的情景。我们通常使用的锁即是悲观锁。 -
乐观锁: 在事务执行过程中数据不被锁定, 在事务提交时会对是否发生数据争用进行判断,若未发生冲突则完成提交, 否则回滚事务。
乐观锁认为数据争用发生的可能性较小, 常用于数据争用比较少的情景。CAS原语是乐观锁的一个典型示例。 -
快照: 所有对数据的修改都是在原有数据上产生了一个新的版本, 对数据的读取是在快照(历史版本)上进行的。写操作产生新的版本不会影响在旧版本执行的读操作。
MySQL默认使用的InnoDB存储引擎使用悲观锁和快照(多版本并发控制, Multi Version Concurrent Control, MVCC)来实现事务的并发控制。
InnoDB采用两阶段锁协议, 即事务分为扩张阶段和收缩阶段, 扩张阶段只允许加锁不能释放锁, 收缩阶段只能释放锁不能加锁。
MVCC
InnoDB提供了全局唯一且有序的事务序列号, 以修改数据的事务序列号作为数据版本号。并为每条记录维护两个版本号: 最近修改版本号, 删除事务号。
以 REPEATABLE READ 隔离级别下 MVCC 机制为例:
-
SELECT: 被检索的行必须同时满足两个条件:
-
行的修改版本号必须小于或等于当前事务序列号
-
行的删除版本号为空或者大于当前事务序列号
当前事务读取到的数据总是事务开始前的版本或事务进行中修改的版本, 更晚开始的事务的修改不会被读取。这种读取方式称为快照读。
-
-
INSERT: 将当前事务序列号作为修改版本号
-
UPDATE: 插入一行新的记录并使用当前事务序列号作为修改版本号, 并将当前事务序列号作为旧记录的删除事务号(标记为已删除)。
-
DELETE: 将当前事务序列号作为记录的删除事务号(标记为已删除)。
因为快照读不会读取到更晚开始事务的修改, 因此不会产生不可重复读和幻读的问题。
在不同事务隔离级别下,快照读的一致性是不同的:
- READ COMMITTED: 每次SELECT时生成快照。SELECT 可以看到其它已提交事务的修改
- REPEATABLE READ: 事务开始时生成快照,事务内的更改会修改快照,SELECT 语句看不到其它事务的修改。
GAP 锁
快照读的缺陷在于只能读取事务开始前的版本, 而对于修改操作而言必须读取最新版本。
读取最新版本的需求被InnoDB称为当前读(Locking Read), 使用当前读的语句包括 UPDATE, DELETE 和 SELECT ... IN SHARE MODE, SELECT ... FOR UPDATE。
InnoDB使用锁来解决当前读的问题, InnoDB 中存在三种行级锁:
- Record Lock: 单条行记录上的锁
- Gap Lock:间隙锁,锁定一个范围,但不包括记录本身
- Next-Key Lock: Record Lock + Next-Key Lock
用一个示例来说明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 的行也无法修改。
InnoDB 锁定索引而非锁定数据行, BTREE索引是有序的。GAP LOCK 锁定了索引树中 age < 18 的空间(即索引间的空隙), 被锁定的区间不能插入记录也不能修改已有记录。
在 REPEATABLE READ 隔离级别下不出现幻读是 InnoDB 存储引擎的特性不是 MySQL 的要求, 在使用其它存储引擎时仍可能出现幻读问题。
更多关于读一致性的内容可以参考 InnoDB 官方文档: innodb-consistent-read