事务
为什么要有事务?
对于一个数据库系统来说可能会面临各种各样的问题,比如:数据库挂了、客户端挂了、网不通了,多个client同时访问……而出现这些情况都有可能会影响整个系统。为了保证数据库的可靠性,所以就出现了“事务”这种机制。从概念上讲,事务就是将多个操作视为一个操作,而整个事务要么执行成功commit,要么执行失败abort或者rollback,这样如果执行失败,客户端可以回滚整个事务,而不是再去找多个操作中哪个操作出现了问题。
事务的ACID提供了什么能力?
事务所提供的安全保证,可以用ACID来描述:
- Atomicity:原子性
- 数据库的属性
- 多个操作视为一个操作,只能全部成功或者全部失败
- 提供了在中间的某个操作中失败时,丢弃之前所有操作的能力
- Consistency:一致性
- 应用程序的属性
- 一个事务的前后从一个合法的状态变到另一个合法的状态,而这里的“合法”是应用程序自定义的,比如:转账系统的总和应该保持不变,事务开始前A有10元,B有5元,总和为15元,事务中A给B转5元,结果A有5元,B有10元,总和为15元,这样就是从一个合法的状态转移到另一个合法的状态。如果A给B转15元呢?这样A有-5元,B有20元,总和还是15元,这也是合法的状态,这是因为开始定义“合法”时,只要求了总和不变,并没有要求某一个人的余额不能小于0,所以说“合法”完全就是应用程序定义的
- 说白了事务的ACID中的AID是为了保证C的
- Isolation:隔离性
- 数据库的属性
- 多个客户端的操作应该是相互“隔离”的,相互不影响的,当然这个“隔离”也是有程度的,引出下面的隔离级别
- 提供了多个客户端同时操作时相互隔离的能力
- Durability:持久性
- 数据库的属性
- 提供了事务一旦执行成功,数据就写入磁盘中的能力
MySQL中事务是由存储引擎层实现的,所以支不支持事务取决于使用了什么存储引擎,MySQL原生的引擎MyISAM不支持,而InnoDB是支持的。MySQL5.5.5之后默认的存储引擎为InnoBD
多个客户端同时操作时会有哪些问题?
- 脏读
- 一个事务读到了其他事务还没有提交的数据
- 为什么脏读会有问题?举个例子:事务B修改了某一行的数据,事务A读到了这一行的数据,事务B由于某些原因失败并回滚了,事务A拿这一行读到的数据进行了某些操作并commit了。结果上来看事务A使用了有问题的行进行了操作。
- 不可重复读
- 一个事务内读都读某一行的数据,前后不一致
- 为什么不可重复读会有问题?举个例子【2】:
-- session A start transaction; insert table(id, value) values(1, 1); insert table(id, value) values(2, 2); commit; -- session B begin transation; select id, value from table where id = 1; -- session A start transaction; update table set value = 10 where id = 1; update table set value = 20 where id = 2; commit; -- session B select id, value from table where id = 2; commit; -- 数据库中这两行的数据只有:(1, 1)(2, 2)和(1, 10)(2, 20)这两个状态,但是sessionB却读到了(1, 1)(2, 20) -- 假如业务代码中写了: -- if (value1 * 2 != value2) { -- 执行某些操作 -- } -- 但是显然某些操作在这里不应该执行的,因为数据库中始终没有这个状态,这就是不可重复读的问题
- 幻读
- 一个事务内查询的结果数量前后不一致,比如另一个事务又插入了满足条件的数据。
- 幻读有什么问题?
- 更新丢失
如何解决上面的问题?
在标准的SQL规范中定义了四种事务隔离级别:
- 读未提交,可能出现脏读、不可重复读、幻读
- 读已提交,可能出现不可重复度、幻读
- 可重复读,可能出现幻读
- 串行化,没有任何问题
更新丢失问题...
但是规范始终只是规范,并没有说明四种隔离级别该如何实现,因此不同的数据库具体的实现方式可能也不相同。而SQL隔离级别的标准是依据基于锁的实现方式来制定的,首先看看只通过锁如何实现这四种隔离级别。【3】
如何通过锁实现四种隔离界别?
前置知识
- 锁分为共享锁(S锁)和排它锁(X锁),至于为什么要分为两种锁,可以类比Java的读写锁,允许多个线程同时读,但只允许一个线程写,既支持并发提高性能,又保证了并发安全。【4】
- 锁可能在commit的时候才会释放
具体实现:
- 读未提交:读操作不加锁,读读,读写,写读并行;写操作加X锁且直到事务提交后才释放。
- 读已提交:读操作加S锁,写操作加X锁且直到事务提交后才释放;读操作不会阻塞其他事务读或写,写操作会阻塞其他事务写和读,因此可以防止脏读问题。
- 可重复读:读操作加S锁且直到事务提交后才释放,写操作加X锁且直到事务提交后才释放;读操作不会阻塞其他事务读但会阻塞其他事务写,写操作会阻塞其他事务读和写,因此可以防止脏读、不可重复读。
- 串行化:读操作和写操作都加X锁且直到事务提交后才释放,粒度为表锁,也就是严格串行。
既然通过锁就可以实现四种隔离级别,为什么又会出现多版本并发控制?
讨论MVCC的前提:存储引擎使用的InnoDB
可以看到通过加锁的方式来实现读已提交和可重复读时,当一个session写某一行时,另个一个session读是会block的,影响了整个系统的并发性。所以向InnoDB这种存储引擎实现了MVCC这种无锁的方案,用以解决事务读-写并发的问题,能够极大提升读-写并发操作的性能。
InnoDB的MVCC是如何实现的?
讨论MVCC的前提:隔离级别是RC或者RR
MVCC的实现依赖于undo log和read view,一个一个解释。
当插入、修改、删除一条记录时,在修改对记录修改前,会将修改前的数据加入到undo log日志中,而对于每一条行记录都有两个隐藏列,一个是修改当前行的事务id(DB_TRX_ID),一个是回滚指针(DB_ROLL_PTR),通过回滚指针可以将一个一个版本连接起来。但undo log存的并不是当前的记录,这样说只是为了方便理解,实际上让我们执行insert操作时,undo log中存的是delete相关操作,也就是逆运算,delete和update同理(例如某一个事务的操作是将name从3改到4,当前值就为4,而undo log中记录的是将4改为3,这样就能知道上一个版本中name是3了)。总之从结果上来看是可以通过undo log和当前的记录回滚到之前的状态。前面提到事务有个特性是原子性,而实现这一机制就是undo log(InnoDB)。
那undo log和MVCC又有什么关系呢?他和read view是怎么配合的?
由上面的解释我们可以知道undo log存的是一行记录的多个版本,而通过限定我们能读到的版本范围就可以实现RC和RR隔离级别,read view就提供了这样的能力,read view将所有的事务分为了三类:
- 已经提交的事务
- 正在活跃的事务,即还没有commit的
- 未来的事务
RR隔离界别下read view生成的时间点是事务开启的时候,也就是说这个时间已经确定了已经提交的事务,说白了就是我能读到的事务,因此在我这事务执行期间“我能读到的事务”是不会改变的,这样就实现了“可重复读”。
而RC隔离级别下每一次快照读(我们一直使用的普通select)都会生成新的read view,也就是说“我能读到的事务”是会改变的,如果别事务在我快照读前commit了,“我能读到的事务”就会新增,即在我这个事务中前后读到的数据不一致,即“不可重复读”。
那undo log什么时候删除呢?
其实就是没有任何read view需要某一个版本的时候就可以删除了。分别以RC和RR隔离级别来举两个例子:
RC:假设当前有两个事务A和B,trx_id分别是9和10,事务A对name字段做了两次更新,原值为1,分别更新成了2和3,那么undo log中就是这样的:1 <-- 2 <-- 3,此时如果事务B进行读操作,对于该事务来说只有1是可见的,那么read view就是需要这三个版本,也就是都不能删除。而接下来事务A提交了,那么事务B再去读的话,3也是可见的,那么undo log中1和2的版本对于任何事务中的read view来说都是不需要的,那么也就是可以删除了。
RR:例子和RC一样,在事务A提交后,对于B来说再select还是只有1可见的,也就是说undo log还是不能删。这也是MySQL中使用InnoDB存储引擎的前提下RR相对于RC不好的地方,就是undo log可能会很大。而且需要尽量避免一个长事务,长事务可能需要的undo log就越长,比如一个事务A进行select,而其他的事务一直在更新并提交。
事务的持久性是如何实现的?
在前面中大量的讨论了事务隔离性的问题,且在MVCC中也说明了事务原子性是通过undo log来实现的,那么事务的持久性是如何实现的呢?【5】【6】
讨论持久化之前,首先先看一下buffer pool(InnoDB实现的),照例还是先看一下为什么要有buffer pool,假如没有buffer pool的话,那么每一次的增删改查都是需要与磁盘交互,这样性能就会有影响。
buffer pool中存储的是什么?
我们知道在InnoDb中最小的存储单元是page,以page作为磁盘和内存存储的基本单位,因此buffer pool中也是保存的一个个page。同时每一个page都有一个描述数据块来指向page,因此会看到buffer pool占用的内存是比page总和大的。疑问:下一次的查询怎么知道我需要的page已经在buffer pool中?
- 数据页
- 索引页
- 插入缓存页
- undo页
- 自适应哈希索引
- 锁的信息
buffer pool中页的类型
- free page,空闲页,即我们可以使用哪些page,为了避免找free page时遍历所有的page,InnoDB中使用一个free链表来管理所有的free page。(类比操作系统分配内存)
- dirty page,脏页,已经修改但是没有保存到磁盘的页,同理使用flush链表来管理
驱逐策略
- LRU
- 空间局部性,预读策略,young和old,污染问题,加时间
脏页刷盘时机
- redo log满了
- buffer pool空间满了,且驱逐的page是脏页,也会触发刷盘
- mysql认为空闲时
- mysql正常关闭前
redo log(InnoDB实现的)
buffer pool的出现是因为性能问题,但是毕竟是基于内存的,但内存的缺点是数据可能会丢失,为了解决这个问题就出现了redo log。所以更新page的流程是(这个流程是不完整的,下文中将列出更新一条语句的完成流程):
- 更新page
- 将修改记录到redo log中,redo log记录的是page中的修改,例:某个页的某个位置做了某某更新。这个记录还包括更新undo页的记录。
崩溃恢复
- 如果在事务中发生异常,可以通过undo log回到到事务之前的状态(原子性,事务内的所有操作同时失败或者同时成功)
- 如果是事务commit之后发生了异常,可以通过redo log恢复改事务中更新的内容(持久性)
这里需要理解redo log保证的“持久性”到底是什么意思,它的大前提是我们使用buffer pool来减少io的次数,但是buffer pool毕竟是基于内存,是存在丢失的风险的,所以在一次事务commit后,redo log中是已经记录了这次事务中所有的变更操作,假设这个时候MySQL挂了那么buffer pool的脏页还没来得及刷到磁盘,但是由于redo log中已经有记录了,所以数据还是不会丢失,只需要将redo log中read指针和write指针重新写到磁盘即可。所以redo log保证的“持久性”是buffer pool中数据,假如InnoDB中没有buffer pool,且事务中的变更操作最终写入磁盘后才算一次事务commit成功,那么这种方式也是可以保证持久性的,只不过性能不好罢了。
为什么要使用redo log?
顺序写 vs 随机写
redo log刷盘时机
redo log并不是直接写入磁盘,而是先写入到redo log buffer中,刷盘时机:
- mysql正常关闭
- redo log buffer中的记录占用了一般的空间
- 参数innodb_flush_log_at_trx_commit控制:
- 0:不主动触发写入磁盘的操作,每隔一秒刷盘
- 1:事务提交时触发,保证数据不丢失,真的写到磁盘
- 2:写到os的page cache中,每隔一秒刷盘,mysql崩溃时数据并不会丢失,只有os崩溃或者停电等情况才会丢失
redo log满了
默认有两个(可以配置)redo log文件ib_logfile0和ib_logfile1,通过循环写的方式,有一个写指针和读指针。当写指针最少读指针是说明redo log满了,这样mysql的更新操作就会被阻塞,需要等待redo log中的数据刷盘后才可以结束阻塞。
binlog
在上面通过事务引出了undo log和redo log,再来看一下binlog。
首先undo log和redo log都是InnoDB实现的,而binlog是由server实现的,作用是通过全量备份+重放binlog可以找到之前的数据,而且还可以通过binlog主从复制。
既然binlog中记录了所有变更操作(表结构+表数据),那么看起来也可以有crash-safe(即脏页还没有写入磁盘但是MySQL挂了,但还是可以恢复数据)的能力,为什么InnoDB还需要实现一个redo log呢?这是因为MySQL设计binlog的目的就是为了恢复和主从复制,在MySQL挂了的时候,虽然里面记录了所有变更的数据,但是我们是不知道哪些数据是还没有写入到磁盘中的,而redo log中读指针和写指针之间是还没有写入磁盘中的数据,因此重放这段即可。(不过通过全量备份+binlog重放貌似也可以?没查到可靠的说法)
binlog vs redo log
- 实现:server vs InnoDB
- 文件格式:
- 写入方式:追加写 vs 循环写
- 用途:恢复
binlog刷盘时机
写入binlog也是一样,并不会直接写入到磁盘中,而是先写到binlog buffer中,刷盘时机由参数sync_binlog控制
- 0:表示每次提交事务都只write,不fsync,后续交由操作系统决定何时将数据持久化到磁盘
- 1:每次都write和fsync
- 2~N:每次write,但是只有到了x次才会fsync
在一个事务中只有redo log和binlog都写入了事务(buffer or 刷盘)才能commit,而这里是采用两阶段提交的方式,如果不采用两阶段提交的话是会有问题的比如:
- 先redo log后binlog中间挂了,那么数据是可以恢复的,但是从库中恢复不了的,导致主从不一致
- 先binlog后redo log中间挂了,那么从库是可以恢复的,但是数据是恢复不了的,导致主从不一致
两阶段提交过程
- redo log prepare状态:写redo log,XID写入redo log(需要注意的是,redo log并不是最后commit时才写的,而是事务开始的过程中就已经开始写了,而binlog是最后commit的时候才写的)
- 写binlog,XID写入binlog
- redo log commit状态:事务提交
这其中可能存在问题的点:
- 1 -> 2挂了,回滚因为XID不在binlog中
- 2 -> 3挂了,正常提交
一条更新操作执行的过程
说明
===================================
仅作为校招时的《个人笔记》,详细内容请看【参考】部分
===================================

浙公网安备 33010602011771号