系列文章
基础篇——MySQL 的基础架构
基础篇——redo log 和 binlog
目录
- 系列文章
- 1. 事务隔离
- 1.1 隔离性与隔离级别
- 1.2 如何实现事务隔离
- 1.3 事务的启动方式
- 1.4 思考: 使用什么方案来避免长事务
1. 事务隔离
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。但并不是所有的存储引擎都支持事务,比如MySQL 原生的 MyISAM 引擎就不支持事务(这也是 MyISAM 被 InnoDB 取代的重要原因之一)。
1.1 隔离性与隔离级别
事务的隔离性关注并发访问的数据可见性。
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。
隔离级别越高,系统的效率就越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。
我们通过下面这个例子来理解这四个隔离级别:
- 若隔离级别是“读未提交”, 就是说A事务能够看到B事务未提交的值,所以则 V1 、V2、V3 都是 2。可能出现“脏读”
- 若隔离级别是“读提交”,就是说则事务 B 的更新在提交后才能被 A 看到。所以, V1 是 1,V2 、 V3 的值是 2。避免了“脏读”,但不能避免""幻读和不可重复读取”
- 若隔离级别是“可重复读”,那么要遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。因此A事务在提交前看到的数据都是一致的,所以, V1、V2 是 1,V3 是 2。避免了“脏读和不可重复读取“的情况,但不能避免“幻读”
- 若隔离级别是“串行化”,是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。因此在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。串行化的隔离级别最高,能够避免脏读、不可重复读、幻读,但性能损耗也最大。
Oracle数据库默认隔离级别是“读提交”,MySQL的认隔离级别是“可重复读”。相关的配置参数是transaction-isolation
在实现上,数据库里面会创建一个视图,这里的视图是指数据库为了实现特定的隔离级别而创建的逻辑上的数据快照。这个视图决定了事务可以看到哪些数据版本。 在可重复读(REPEATABLE READ)这个隔离级别下,事务在开始时创建一个视图,整个事务期间都使用这个视图。这意味着事务在执行期间看到的数据是一致的,即使其他事务对这些数据做了修改并提交,当前事务也不会看到这些更改。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
1.2 如何实现事务隔离
上面介绍了事务的隔离级别,下面展开说明“可重复读”这一隔离级别是如何实现的。
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。也就是说会有一个undo log记录回滚操作。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。(更具体的,务会根据自己的视图数组中的事务ID和版本号来判断所查询数据是否可见。如果查询时刻的事务启动时刻在数据版本提交之前,那么数据将不可见;如果在数据版本提交之后,数据将可见。)
对于回滚日志,肯定是不能一直保留的,当当系统里没有比这个回滚日志更早的 read-view 的时候,也就是没有事务再需要用到这些回滚日志时,回滚日志会被删除。
所以建议尽量不要使用长事务。因为长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。除此之外,长事务还占用锁资源,也可能拖垮整个库。
1.3 事务的启动方式
MySQL 的事务启动方式有以下几种:
- 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
- 隐式自动开启事务。
set autocommit=0
,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了(正常来说select是不会启动事务,但是加了这个set命令后,此后的所有的sql语句的执行都会包含在该事务中。),而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。因此,建议你总是使用 set autocommit=1,
通过显式语句的方式来启动事务。
在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit
则提交事务。如果执行 commit work and chain
,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。
你可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
1.4 思考: 使用什么方案来避免长事务
从业务方角度:
-
将
set autocommit
参数值设置为1。测试环境中检查项目是否使用了set autocommit=0
可以通过把 MySQL 的general_log
开起来,然后随便跑一个业务逻辑,通过general_log
的日志来确认。因为general_log
记录了所有执行的 SQL 语句,包括设置会话变量的语句(如SET autocommit=0
)。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。(需要注意的是,开启general_log
可能会对 MySQL 服务器的性能产生影响,因为它会记录所有的查询,所以建议只在测试环境中使用,并在确认问题后及时关闭。) -
去掉没必要的只读事务。因为即使只读事务不修改数据,它们在开始时也可能获取共享锁,这会阻止其他事务(包括写事务)获取排他锁。如果只读事务持续时间很长,它们持有的共享锁就会阻塞其他事务的进行。并且在MySQL里,即使是只读事务,也可能占用回滚段资源。
-
业务连接数据库的时候,根据业务本身的预估,通过
SET MAX_EXECUTION_TIME
命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间(关于MAX_EXECUTION_TIME
值设置为多少,通常建议的实践是先在测试环境中评估业务查询的平均执行时间,并根据这个评估来设置合理的MAX_EXECUTION_TIME
值。同时,也要考虑到系统的负载情况和性能要求)。
从数据库角度:
-
控
information_schema.Innodb_trx
表,设置长事务阈值,超过就报警 / 或者 kill; -
Percona 的 pt-kill 这个工具不错,推荐使用;
-
在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
-
如果使用的是 MySQL 5.6 或者更新版本,把
innodb_undo_tablespaces
设置成 2(或更大的值)。innodb_undo_tablespaces
是控制undo是否开启独立的表空间的参数 为0表示undo使用系统表空间,即回滚段保存在ibata
文件中 ;不为0表示使用独立的表空间,一般名称为 undo001 undo002。这样如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
不过这个参数在MySQL8.0之后会被撤销,作为替代的是可以在运行时使用
CREATE UNDO TABLESPACE
语法创建额外的撤消表空间(官方文档:[15.6.3.4 撤消表空间_MySQL 8.0 参考手册]):CREATE UNDO TABLESPACE tablespace_name ADD DATAFILE 'file_name.ibu';