事务的隔离级别
事务并发执行遇到的问题
当数据库上多个事务并发执行的时候,就可能出现脏写(Dirty Write)、脏读(Dirty Read)、不可重复读(Non-Repeatable Read)、幻读(Phantom Read)的问题。
问题按照严重性来排序:
脏写 > 脏读 > 不可重复读 > 幻读
脏写
“脏写"是指,一个事务修改了另一个未提交事务修改过的数据。
脏读
“脏读"是指,一个事务读到了另一个未提交事务修改过的数据。
不可重复读
如果一个事务能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读。
幻读
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。
不可重复读的重点是修改,幻读的重点在于新增或者删除。
四种隔离级别
为了解决事务并发执行遇到的问题,就有了隔离级别的概念。SQL 标准中设立了 4 个隔离级别:
READ UNCOMMITTED
:读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。可能发生脏读、不可重复读和幻读问题。READ COMMITTED
:读已提交是指,一个事务提交之后,它做的变更才会被其他事务看到。可能发生不可重复读和幻读问题,但是不可以发生脏读问题。REPEATABLE READ
:可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。SERIALIZABLE
:可串行化是指,对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
MySQL 的默认隔离级别为 REPEATABLE READ
。
隔离级别越高事务隔离性越好,但性能就越低;隔离级别越低,越严重的问题就越可能发生:
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 (read uncommitted) | 可能 | 可能 | 可能 |
读已提交 (read committed) | 不可能 | 可能 | 可能 |
可重复读 (repeatable read) | 不可能 | 不可能 | 可能 |
串行化 (serializable) | 不可能 | 不可能 | 不可能 |
MySQL 在 REPEATABLE READ
隔离级别下,是可以禁止幻读问题的发生的(需要配合间隙锁):
- MVCC (多版本并发控制):处理快照读。
- 间隙锁 (Gap Lock):处理当前读。
设置事务的隔离级别
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
level
可选值有 4 个:REPEATABLE READ
,READ COMMITTED
,READ UNCOMMITTED
,SERIALIZABLE
。
启动参数 transaction-isolation
可以设置事务的默认隔离级别。
MVCC
MVCC(Multi-Version Concurrency Control)多版本并发控制,是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。
InnoDB 存储引擎实现了 MVCC,用来解决不可重复读和幻读的问题。
版本链
InnoDB 存储引擎它的聚簇索引记录中都包含两个必要的隐藏列:
trx_id
:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给trx_id
隐藏列。roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo log 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
假设插入一条记录,事务 id 为 80,之后另外两个事务 id 分别为 100、200 的事务对这条记录进行更新:
顺序 | trx 100 | trx 200 |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update hero set name = '关羽' where number = 1; | |
4 | update hero set name = '张飞' where number = 1; | |
5 | commit; | |
6 | update hero set name = '赵云' where number = 1; | |
7 | update hero set name = '诸葛' where number = 1; | |
8 | commit; |
每次对记录进行改动,都会记录一条 undo log,每条 undo log 也都有一个 roll_pointer
属性,可以将这些 undo log 都连起来,串成一个链表。对该记录每次更新后,都会将旧值放到一条 undo log 中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer
属性连接成一个链表,这个链表称之为版本链。
版本链的头节点就是当前记录最新的值。
ReadView
对于使用 READ UNCOMMITTED
隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
对于使用 SERIALIZABLE
隔离级别的事务来说,使用加锁的方式来访问记录。
对于使用 READ COMMITTED
和 REPEATABLE READ
隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断版本链中的哪个版本对于当前事务可见的。
ReadView 中主要包含 4 个比较重要的内容:
m_ids
:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。min_trx_id
:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是m_ids
中的最小值。max_trx_id
:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。creator_trx_id
:表示生成该 ReadView 的事务的事务 id。
INSERT
、DELETE
、UPDATE
这些语句或加排它锁操作比如 select...for update
语句时),才会被分配一个单独的事务 id,否则在一个只读事务中的事务 id 值都默认为 0。- 如果被访问版本的
trx_id
与 ReadView 中的creator_trx_id
相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
小于 ReadView 中的min_trx_id
,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
大于或等于 ReadView 中的max_trx_id
值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
trx_id
在 ReadView 的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
READ COMMITTED
和 REPEATABLE READ
隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。
READ COMMITTD
在每一次进行普通SELECT
操作前都会生成一个 ReadView。REPEATABLE READ
只在第一次进行普通SELECT
操作前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView 就好了。
大事务的影响
- 并发情况下,数据库连接池容易被撑爆。
- 定太多的数据,造成大量的阻塞和锁超时。
- 执行时间长,容易造成主从延迟。
- 回滚所需要的时间比较长,如果大事务中有大量的
update
操作,回滚时也需要逐个去找 undo log 进行回滚。 - undo log 膨胀,事务未提交,undo log 会一直存在。
- 容易导致死锁。
事务优化实践原则
- 将查询等数据准备操作放到事务外,对于 RC 隔离级别,可以将查询放到事务之外,对于 RR 隔离级别,将查询放到事务里面,如果放到外面就无法保证 RR。
- 事务中避免远程调用,远程调用要设置超时,防止事务等待时间太久。
- 事务中避免一次性处理太多数据,可以拆分成多个事务分次处理。
- 更新等涉及加锁的操作尽可能放在事务靠后的位置。更新操作应该在插入操作之后,因为更新操作会对已存在的记录加锁,其他事务可能会使用该记录,会造成不必要的等待。而插入操作虽然也会加锁,但是其他事务不会使用该记录,因为还不存在。
- 能异步处理的尽量异步处理。
- 应用侧(业务代码)保证数据一致性,非事务执行。如果对性能要求非常高,可以考虑不适用事务。
事务问题定位
-- 查询执行时间超过 1 秒的事务
SELECT * FROM information_schema.innodb_trx
WHERE TIME_TO_SEC( timediff( now( ), trx_started ) ) > 1;
-- 强制结束事务
kill 线程 id (就是上面语句查出结果里的 trx_mysql_thread_id 字段的值)
查询需不需要加事务
对于 RC 隔离级别来说,查询操作是不需要加事务的,因为在不管是在事务内还是在事务外查询,没有什么区别,读到的都是已提交的数据。
对于 RR 隔离级别来说,如果查询操作是不是在事务内执行的话,可能会出现幻读的问题。对于一些生成报表的业务场景来说,需要保证数据是在同一时间维度,那就需要加事务。