2.1 隔离性与隔离级别
SQL 标准的事务隔离级别:
- 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化(serializable ):顾名思义是对于 同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
多事务同时执行执行可能出现的问题:
- 脏读(dirty read):指一个事务读取到了另外一个事务修改了但还未提交到数据库的数据。
- 不可重复读(non-repeatable read):指在一个事务内,多次读同一数据,但在多次读取的过程中间,另外一个事务修改了这个数据,导致多次读取到的数据不一致。
- 幻读(phantom read):指一个事务在前后多次查询同一个范围的过程中间,另外一个事务进行了数据的新增,后面的查询看到了前面的查询未看到的行。
不同隔离级别可能出现的多事务问题:
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。
- 在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。
- 在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的。
- 这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念。
- 而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
配置事务隔离级别的方法:修改启动参数 transaction-isolation。
2.2 长事务
在MySQL中,实际上每条记录在更新的时候都会在回滚日志记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
系统会判断,当没有事务需要用到这些回滚日志时,回滚日志会被删除,即当系统里没有比这个回滚日志更早的 read-view(视图)的时候。
为什么尽量不要使用长事务?
- 长事务意味这会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
- 在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。可能存在数据只有 20 GB,而回滚段有 200 GB 的库。这种最终只好为了清理回滚段,重建整个库。
- 占用锁资源,可能拖垮整个库。
如何避免长事务?
- 可以从应用开发端和数据库端来看。
- 应用开发端:
- 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成1。
- 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
- 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。
- 数据库端:
- 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警/或者 kill。
- Percona 的 pt-kill 这个工具不错,推荐使用。
- 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题。
- 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
如何查看长事务?
- 可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过60s的事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
2.3 “快照”在 MVCC 里是怎么工作的
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。
先了解以下多版本和 row trx_id 的概念。
- InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
- 每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
- 也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的 row trx_id。
- 上图中虚线框里是同一行数据的 4 个版本,当前最新版本是V4,k的值是22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。
- 图中的三个虚线箭头,就是 undo log。而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。
- 在可重复读的隔离级别中,事务只认自己更新数据产生的版本。
- 在实现上,InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间的正在启动了但还未提交的所有的事务 ID。
- 数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
- 而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。
- 对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的。
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分,那就包括两种情况
- 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
- 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
InnoDB 还要保证的一条规则:事务启动以前所有还没提交的事务,它都不可见。
所以事务启动的时候还要保存“现在正在执行的所有事物 ID 列表”,如果一个 row trx_id 在这列表中,也要不可见。
MySQL 的事务启动方式有以下几种:
- 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
- set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。