数据库面试题——ACID靠什么来保证
原子性是由undo log日志保证的,它记录了需要回滚的日志信息,也就是说我们的事务还没提交需要回滚,那么事务回滚就是根据undo log日志来撤销已经执行成功的SQL。说白了,undo log其实就是SQL的反向执行,它记录了反向执行的SQL语句,把正向语句回滚回去
数据库事务的四大特性:原子性、一致性、隔离性、持久性。
要回答标题的问题得先了解清楚MySQL的日志体系
MySQL日志体系
MySQL在InnoDB存储引擎级别有两种日志:undo log日志和redo log日志,那在MySQL Server级别又有一个binlog日志
undo Log
Undo日志顾名思义是用来做回滚的,其实Undo的作用不止于此,MVCC这种重要的概念也是基于Undo实现的。
为了阐述MVCC,这里需要引入一个比较重要的概念——read view。我们知道,一个事务读取到的数据实际上是一个快照,这是MVCC的基本功能,只有这样,才能保证并发能力,即一个事务拿到记录的X锁之后,并不会阻塞其他事务读取数据,即便X锁和其他的锁是互斥的。
每一次的数据更新,都会生成一个read view,比如下面的图:
undo里面有按照顺序排列的read view,一个事务开始的时候,系统就会分给它一个read view,这样也就实现了MVCC。
那么谁去读当前值呢?看起来似乎每个读请求都是平行宇宙一样互不干扰,这就又引出了两个新的名词:
- 当前读
- 快照读
快照读读取到的就是read view,一般都会写的select一定是快照读了,但是如果你加上了for update,那么就一定是当前读了。
根据undo的生成机制,每次修改数据都会生成一个read view,那么在一个很多写操作的系统上,或者进行过大量批量修改数据的系统上,undo表空间就会变得非常大。在MySQL5.5版本以前,undo是放在共享表空间里的,而且这个共享表空间文件很有意思,一旦变大了就再也不会变小,虽然undo里的日志会被系统自动择机删除,但是其申请的空间就再也不会回缩了。
这是一个不好的设计,因此在后来的版本中,undo已经可以独立表空间文件了,而且这个文件也是可以回收空间的。
redo Log
熟悉MySQL InnoDB引擎的人都知道,InnoDB有一个最重要的概念就是缓冲池,这是在内存中分配的一个区域,InnoDB会将数据首先缓存在此,请求首先去命中缓冲池,无法命中缓冲池的才会在磁盘上进行检索,被检索到的数据还是会缓存在缓冲池中。
但是缓冲池依赖的内存是一种易失性的存储介质,掉电以后所有的数据都会被抹掉,为了数据的持久性,任何在缓冲池中做出的变更操作,都要持久化到磁盘上,只有这样,数据库才能实现持久性,用户也才能放心的将数据放在数据库上。
现在来看看这样一条SQL语句是如何执行的:
update table set col1 = 1 where col2 = 2;
用一个比较直观的流程图表示其过程如下:
我这里选择性的忽略了一步,就是有一个线程,采用异步的方式,慢慢的将脏数据页从内存中刷入磁盘的数据文件中。这种commit之后首先写redo,然后异步写数据文件的方式,叫做WAL,即预写日志方式。
有了WAL方式,我们的数据只要commit成功就绝对不会丢失了。InnoDB的REDO LOG有两个可以控制的参数,规定了日志文件最大的大小和一共有多少日志文件。
但是不管有多少个文件,都可以看做是一个文件,redo文件是循环利用的,即文件写满了,就会回收空间。在很多资料上,Redo Log文件都会被画成一个环,实际上也确实如此。
我们知道,Redo的存在保证了持久性,所谓持久性,一般都可以理解为只要提交的事务就一定会持久化。那么Redo是如何保证持久化的呢?设想这样一种情况,在某一时刻,数据库崩溃了,mysqld进程异常退出,此时DBA将数据库重启,会发生什么事情?
这里就要引入一个叫做LSN的概念,即Log Sequence Number,可以理解唯一个序列号或者坐标。标记了日志或者数据文件中最新的位置。一般来说,磁盘文件中最新的LSN都会小于Redo中最新的LSN,那么这两个LSN之间的数据块,就是没有刷入磁盘数据文件的数据块了。
在崩溃重启后,数据库只需要将redo中这部分没有刷入磁盘的数据块刷到数据文件中就可以了,这样崩溃恢复的速度就会大大提高。
上图是redo文件的逻辑示意图,这里又引入了一个概念叫checkpoint,实际上是一个动作,即每次读取最老的脏页,确保这个脏页对应的LSN之前的LSN都已经写入了数据文件,这个脏页的LSN作为checkpoint点记录到日志文件中。write pos指写入的位置,也是一个LSN值,这两个LSN值之间的部分是可写部分,如果一旦write pos要赶上checkpoint了,就再做一次checkpoint。
至此我们已经讨论了redo log的一般套路,但是实际使用MySQL过程中还会遇到两个参数:
- innodb_flush_method
- innodb_flush_log_at_trx_commit
这两个参数是控制事务提交时的刷磁盘策略的,通常第一个参数在Linux系统上我们都会设置成O_DIRECT,至于这个O_DIRECT的实现方式,可以参考任何一本Linux内核编程的书,这里只需要知道这个O_DIRECT代表了不走OS cache,直接将缓存中的数据块刷到磁盘中。
下图说明了实际情况:
而第二个参数,则是控制了commit时redo刷盘的时机:
- 1:每次commit,都会将redo buffer中的数据块写入redo并立刻刷磁盘;
- 2:每隔一秒,将redo buffer中的数据块写入redo并刷盘;
- 3:每次commit都会将redo buffer中的数据块写入redo log,但是每隔1s才会刷盘
binlog
Redo其实是InnoDB特有的一种日志,这也是和Oracle学来的技术,因此学完了InnoDB以后再去学Oracle会感觉很轻松,因为基本原理都是一样的。
Binlog又是一种重要的日志,只不过这种日志是MySQL提供的,什么引擎都可以用。Binlog记录了数据的实际变更(当然如果binlog格式是mixed或者statement,那么大部分情况下记录的是SQL语句),当然有了这种日志,就能够实现复制功能了。而事实上大部分人用binlog都会去做复制,以及备份,当然也有很多人基于binlog开发了类似Oracle的闪回工具。
既然有了binlog,那么什么时候写binlog又是一个值得探究的事情,这就可以引出一个参数,sync_binlog,这个参数控制了binlog和事务提交的关系,取值范围是0-N:
- 0:不去强制要求,由系统自行判断何时写入;
- 1:每次commit的时候都要写binlog;
- N:每N个事务,才会去写binlog
如此看来,最安全的选择还是1,加上之前的innodb_flush_log_at_trx_commit参数,如果选择最安全的方式也是1,在很多材料中都会写MySQL配置为双1,就是指的这两个参数的配置。
既然有两种日志,就会有一个写入的策略问题,这个问题也就引出了另一个概念——两阶段提交。所谓两阶段提交,其实就是将redo的提交拆分成了prepare和commit两个阶段,注意这里的commit不是commit语句,是一种状态。
当事务发起commit的时候,根据上面的描述,首先会将脏数据块写入redo log,但是此时还没有写binlog,因此阶段处于prepare阶段,只有当binlog完成了写操作之后,才会将redo log标记为commit,这个事务才算是真的完结了。
这时,我们来思考一个问题,如果写binlog的时候crash了,怎么办?因为redo log还是处于prepare状态,实际上事务没有真的commit,因此是会回滚的。
Binlog有一个比较好玩的参数:binlog_format,这个参数有三个选项,分别是ROW,STATEMENT和MIXED。
最开始学习MySQL的时候看到这个MIXED便被他的名字迷惑了,这个参数一定是智能的,一定是最好的。但是事实证明我还是想错了,这个参数其实是为了兼容老旧的STATEMENT参数设计的,大部分情况下,binlog里记录都是STATEMENT格式。这里就要说一下STATEMENT格式记录了什么了。
如果是我来设计怎么将主库的事件发送到从库,那么我在设计时一定会首先想到将主库上执行过的所有的SQL都发给从库,这样就可以了。
这样的确可以,而且还很简单,但是确实有隐患。举一个简单的例子来说,如果主库上的SQL语句里有讲sysdate()插入表的语句,那么在从库上执行的时候,这个sysdate()获取到的实际上是从库的时间,这就存在主库和从库不一致的情况了,谁也不能保证语句立刻就能发送到从库并立刻执行,这一切操作保证在1s之内是很难的,存在网络问题,存在单线程复制回放问题的限制。
因此这种方式后来的MySQL开发者也觉得不好,所以设计了一种新的binlog格式,即ROW格式。这种格式记录了实际的数据变更,这样就解决了上面说的问题。不过,为了兼容旧版,MySQL的设计者设计了一个颇具迷惑性的MIXED选项,这个选项大部分情况下,都会把SQL语句记录在binlog里,而且这个选项也没有办法支持最新的GTID特性。
综上所述,一个系统要做复制,一定要使用ROW格式。
不过STATEMENT格式也不是没有其好处,至少binlog很好读,里面都是明文记录的SQL语句,要追查什么很方便,而ROW都是二进制加密的,可读性非常差,这里提供一个一般性的语句:
mysqlbinlog --base64-output=decode-rows -vv binlogXXXX
binlog是实现复制的重要日志,Master负责将事件记录在binlog中,并且通知slave将日志取走,Slave的IO线程将数据取走之后,将binlog中的事件保存在本地的中继日志中,由一个叫做sql线程的线程开始依次将中继日志中的事件回放在本地。
这个过程就是复制的基本原理,虽然现在有异步复制,半同步复制,增强型半同步复制,但是复制的基本流程和原理却始终没有变。
在此需要引入一个重要的概念——GTID,即全局事务ID。当然本文并不是论述GTID及其运维的,因此只是简单提要。
GTID的出现大大简化了基于binlog的复制配置和运维难度,配置复制的时候不再需要直接指定pos等信息,而是可以自动化的进行。
每一个事务之前都会有一个set GTID的语句,因此从库在回放的时候,首先会执行该语句,那么这个GTID就会被从库维护起来,表示这个GTID已经执行过了。
假如我需要将slave节点挂载在主备上,那么基于GTID的复制会简化我的操作,因为slave上记录了所有已经执行过的GTID,此时是不需要手动干预去指定pos之类的值的,slave自己就可以判断要从哪里开始继续复制。
其它日志
其他日志包括文本格式的error log,慢查日志和general log,还有中继日志。
其中中继日志顾名思义,就是将主库发送来的binlog先保存在本地,然后按循序进行回放。
文本格式的日志里,error log记录了MySQL运行过程中打印出来的日志,包括warning,error或者info,这些都是排查问题时的参考。
慢查日志记录了符合条件(慢查时间阈值,是否使用索引)的SQL,这是很重要的性能优化和排查依据。
general log一般用来调试的时候使用,记录了所有的数据库操作明细,开启以后会大量降低数据库系统性能,不建议在生产环境上开启。
ACID靠什么来保证
原子性
原子性是由undo log日志保证的,它记录了需要回滚的日志信息,也就是说我们的事务还没提交需要回滚,那么事务回滚就是根据undo log日志来撤销已经执行成功的SQL。
说白了,undo log其实就是SQL的反向执行,它记录了反向执行的SQL语句,把正向语句回滚回去。
一致性
如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态;如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。
一致性是ACID的目的,也就是说,只需要保证原子性、隔离性、持久性,自然也就保证了数据的一致性。比如说,我们的ID在数据库中是唯一的,此时插入了一个唯一ID,数据库会给我们做一个检查,告诉咱们是否发生了主键冲突,如果主键冲突数据就无法插入。
另一部分是业务数据的一致性,这需要程序代码来保证。比如说转账这个场景,假设我要转账100元出去,实际上数据库中只有90元,那这时候就不应该转账成功,这种情况通过数据库是无法保证的,只能由程序来保证。
因此一致性是由其它三大特性来保证,而业务一致性由程序代码保证
隔离性
在MySQL中隔离性是通过MVCC多版本并发控制机制来保证的,它是在事务隔离级别中最最重要的一个概念,那它是怎么实现的呢?
多版本并发控制:读取数据时通过一种类似快照的方式将数据保存下来,这样读锁和写锁就不冲突了,不同事务的session会看到自己特定版本的数据,也就是版本链,通过版本链的概念来达到读和写能够并发进行。
MVCC只在READ COMMITTED(已提交读)和REPEATABLE READ(可重复读)两个隔离级别下工作,其他两个隔离级别和MVCC不兼容,这是因为READ UNCOMMITTED(读未提交)总是读取最新的数据行,而不是符合当前事务版本的数据行,而ZERIALIZABLE(串行化)则会对所有读取的行都加锁,MVCC就没有意义了。
在MySQL的InnoDB下,聚簇索引记录中有两个必要的隐藏列:
- trx_id:它用来存储每次对某条聚簇索引记录进行修改时的事务ID,这个事务ID由MySQL分配。
- roll_pointer:每次对哪条聚簇索引记录有修改的时候,都会把老版本写入undo日志中,这个roll_pointer就是存了一个指针,它指向这条聚簇索引记录的上一个版本的位置,通过它来获得上一个版本的记录信息(注意插入操作的undo日志没有这个属性,因为它没有老版本)。
理解了这些概念,咱们再来看看MVCC。已提交读和可重复读的区别就在于它们生成ReadView的策略不同。MVCC就是版本链+ReadView所组成的这么一种概念,当我们掌握了版本链和ReadView这两个概念,也就明白了MVCC,我们接着来看看这个ReadView。
我们在开启事务时创建ReadView,ReadView维护了当前活动的事务ID,即未提交的正在进行中的事务ID,排序生成一个数组访问数据,获取需要修改的记录中的事务ID(获取的是事务ID最大的记录),然后去对比ReadView:
- 如果获取的事务ID在ReadView的左边(比ReadView都小),表示可以访问(在左边意味着该事务已经提交)。
- 如果获取的事务ID在ReadView的右边(比ReadView都大),或者就在ReadView中,表示不可以访问,获取roll_pointer,取上一版本重新对比(在右边意味着,该事务在ReadView生成之后出现,在ReadView中意味着该事务还未提交)。
已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,也就是说我每次select查出来的ReadView都会重新生成,所以ReadView可能会不一样,就是说读到的数据就会不一样;
而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView,每次select查询都是一样的。
在这里咱们发现了这两者的性能是有差别的,MySQL为了提高查询性能,默认使用了可重复读这种隔离级别(原因之一)。
这就是MySQL的MVCC,通过版本链,实现多版本可并发读-写、写-读,通过ReadView生成策略的不同实现不同的隔离级别。
持久性
持久性意味着事务操作最终要持久化到数据库中,持久性是由 内存+redo log来保证的,InnoDB在修改数据的时候,同时在内存和redo log记录这次操作,宕机的时候可以从redo log中恢复数据。
同时,我们都知道MySQL Server的主从同步就是通过binlog来实现的,从服务器通过binlog文件的SQL拿过去执行一遍,保证跟主服务器的数据一致,而binlog和redo log都存储了表中的数据,都可以用来做数据恢复的,那怎么保证binlog和redo log的数据一致呢?
下面是InnoDB下redo log的过程:
- 对redo log进行写盘,写完后事务进入prepare状态。
- 如果前面prepare成功,马上就会进行binlog写盘,再继续将事务日志持久化到binlog。
- 如果binlog持久化成功,那么事务则进入commit状态(在redo log里面写一条commit记录)。
这意味着一个事务到底有没有成功,由两方面来保证:第一是redo log里面有没有commit记录,如果有commit记录,那么binlog一定是持久化成功了,也就是说事务成功了。再者就是redo log最终还会进行刷盘,它的刷盘会在系统空闲时进行,并不是写到redo log时马上进行刷盘。
更多推荐
所有评论(0)