1.数据库突然断电会丢数据吗?

1.1 什么是事务?

根据《高性能 MySQL》第 3 版 1.3 事务一节中定义:

事务就是一组原子性的 SQL 查询,或者说一个独立的工作单元。如果数据库引擎能够成功地对数据库应用该组查询的全部语句,那么就执行该组查询。如果其中有任何一条语句因为崩溃或其他原因无法执行,那么所有的语句都不会执行。也就是说,事务内的语句,要么全部执行成功,要么全部执行失败。

一个良好的事务处理系统,必须具备 ACID 特性:

  • atomicity(原子性) :要么全部执行,要么全部都不执行。原子性是由undo log来保证的,因为undo log记录着数据修改前的信息。
  • consistency(一致性):在事务开始和完成时,数据都必须保持一致状态。一致性是事务的目的,一致性由应用程序来保证。
  • isolation(隔离性):事务处理过程中的中间状态对外部是不可见的。隔离性由数据库提供隔离级别供我们选择,分别有read uncommitted, read committed, repeatable read, serializable
  • durability(持久性):事务完成之后,它对于数据的修改是永久性的。持久性由redo log来保证。

Redo log

Redo log 称为重做日志,用于记录事务操作变化,记录的是数据被修改之后的值。

Redo log 由两部分组成:

  • 内存中的重做日志缓冲(redo log buffer)
  • 重做日志文件(redo log file)

每次数据更新会先更新 redo log buffer,然后根据innodb_flush_log_at_trx_commit 来控制 redo log buffer 更新到 redo log file 的时机。innodb_flush_log_at_trx_commit 有三个值可选:

  • 0:事务提交时,每秒触发一次 redo log buffer 写磁盘操作,并调用操作系统 fsync 刷新 IO 缓存。
  • 1:事务提交时,InnoDB 立即将缓存中的 redo 日志写到日志文件中,并调用操作系统 fsync 刷新 IO 缓存;
  • 2:事务提交时,InnoDB 立即将缓存中的 redo 日志写到日志文件中,但不是马上调用 fsync 刷新 IO 缓存,而是每秒只做一次刷新 IO 缓存操作。

innodb_flush_log_at_trx_commit 参数的默认值是 1,也就是每个事务提交的时候都会将 redo log buffer 中的写更新记录到日志文件,而且会刷新磁盘缓存,这完全满足事务持久化的要求,是最安全的,但是这样会有比较大的性能损失。将参数设置为 0 时,如果数据库崩溃,最后 1 秒钟的 redo log 可能会由于未及时写入磁盘文件而丢失,这种方式尽管效率最高,但是最不安全。将参数设置为 2 时,如果数据库崩溃,由于已经执行了重做日志写入磁盘的操作,只是没有做磁盘 IO 刷新操作,因此,只要不发生操作系统崩溃,数据就不会丢失,这种方式是对性能和安全的一种折中处理。

1.2 Binlog

二进制日志(binlog)记录了所有的 DDL(数据定义语句)和 DML(数据操纵语句),但是不包括 selectshow 这类操作。Binlog 有以下几个作用:

  • 恢复:数据恢复时可以使用二进制日志
  • 复制:通过传输二进制日志到从库,然后进行恢复,以实现主从同步
  • 审计:可以通过二进制日志进行审计数据的变更操作

可以通过参数 sync_binlog 来控制累积多少个事务后才将二进制日志 fsync 到磁盘。

  • sync_binlog=0,表示每次提交事务都只write,不 fsync
  • sync_binlog=1,表示每次提交事务都会执行 fsync
  • sync_binlog=N(N>1),表示每次提交事务都 write,累积 N 个事务后才 fsync

比如要加快写入数据的速度或者机器磁盘 IO 瓶颈时,可以将 sync_binlog 设置成大于 1 的值,但是如果设置为 N(N>1)时,如果数据库崩溃,可能会丢失最近 N 个事务的 binlog

怎样确保数据库突然断电时不丢数据?

通过上面的讲解,只要 innodb_flush_log_at_trx_commitsync_binlog 都为 1(通常称为:双一),就能确保 MySQL 机器断电重启后,数据不丢失。因此建议在比较重要的库,比如涉及到钱的库,设置为双一,而你的测试环境或者正式业务不那么重要的库(比如日志库)可以将 innodb_flush_log_at_trx_commit 设置为 0,sync_binlog 设置成大于 100 的数值,提高更新效率。

2. MVCC 如何实现的?

MVCCMulti-Version Concurrency Control,多版本并发控制)。

2.1 隐藏列

对于 InnoDB,每行记录除了我们创建的字段外,其实还包含 3 个隐藏的列:

  • ROW ID:隐藏的自增 ID,如果表没有主键,InnoDB 会自动以 ROW ID 产生一个聚簇索引树。
  • 事务 ID:记录最后一次修改该记录的事务 ID。
  • 回滚指针:指向这条记录的上一个版本。

2.2 Undo log

Undo log 就发挥了作用。Undo log 是逻辑日志,将数据库逻辑地恢复到原来的样子,所有修改都被逻辑的取消了。

  • 也就是如果是 insert 操作,其对应的回滚操作就是 delete
  • 如果是 delete,则对应的回滚操作是 insert
  • 如果是 update,则对应的回滚操作是一个反向的 update 操作。

Undo log 的作用除了回滚操作,Undo log 的另一个作用是 MVCCInnoDB 存储引擎中 MVCC 的实现是通过 Read View + Undo log 来完成的。当用户读取一行记录时,若该记录已经被其它事务占用,当前事务可以通过 Undo log 读取之前的行版本信息,因为没有事务需要对历史的数据进行修改操作,所以也不需要加锁,以此来实现非锁定读取。

2.3 Read View

Read View 是指事务进行快照读操作的那一刻,产生数据库系统当前活跃事务列表的一个快照。

Read View 中大致包含以下内容:

  • trx_ids:数据库系统当前活跃事务 ID 集合;
  • low_limit_id:活跃事务中最大的事务 ID +1;
  • up_limit_id:活跃事务中最小的事务 ID;
  • creator_trx_id:创建这个 Read View 的事务 ID。

比如某个事务,创建了 Read View,那么它的 creator_trx_id 就为这个事务的 ID,假如需要访问某一行,假设这一行记录的隐藏事务 ID 为 t_id,那么可能出现的情况如下:

  • 如果 t_id < up_limt_id,说明这行记录在这些活跃的事务创建之前就已经提交了,那么这一行记录对该事务是可见的。
  • 如果 t_id >= low_limt_id,说明这行记录在这些活跃的事务开始之后创建的,那么这一行记录对该事物是不可见的。
  • 如果 up_limit_id <= t_id < low_limit_id,说明这行记录可能是在这些活跃的事务中创建的,如果 t_id 也同时在 trx_ids 中,则说明 t_id 还未提交,那么这一行记录对该事物是不可见的;如果 t_id 不在 trx_ids 中,则说明事务 t_id 已经提交了,那么这一行记录对该事物是可见的。

对于不可见的记录,都是通过查询 Undo log 来查询老的记录。

了解了上面的原理,我们知道了,Read View 规则帮我们判断当前版本的数据是否可见。下面,我们分析下当查询一条记录时,大致的步骤:

  • 获取事务本身的事务 ID;
  • 获取 Read View
  • 查询得到的数据,然后与 Read View 中的事务版本号进行比较;
  • 如果能查询,则直接查询对应的记录;如果不能直接查询,则通过 Undo Log 获取历史快照;
  • 最终返回结果。

另外需要补充的一点就是,在 RRRC 隔离级别下,获取 Read View 的时机也是不一样的:

  • 在可重复读隔离级别(RR)下,同一个事务中,查询语句只是在第一个读请求发起时获取 Read View,而后面相同的查询语句都会使用这个 Read View
  • 在读已提交隔离级别(RC)下,同一个事务中,同样的查询语句在每次读请求发起时都会获得 Read View

2.4 什么是 MVCC?

在 session1 更新了 a=1 这行记录,但还没提交的情况下,在 session2 中,满足 a=1 这条记录,b 的值还是原始值 1,而不是 session 1 更新之后的 666,那么在数据库层面,这是怎么实现的呢?

其实 InnoDB 就是通过 MVCCUndo log 来实现的。

什么是 MVCC

MVCC, 即多版本并发控制。MVCC 的实现,是通过保存数据在某个时间点的快照来实现的,也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。也就是上面实验第 6 步中,为什么 session2 查询的结果还是 session1 修改之前的记录。

MVCC 的实现原理

我们拿上面的例子,对应解释下 MVCC 的实现原理,如下图:

img

如图,首先 insert 语句向表 t20 中插入了一条数据,a 字段为 1,b 字段为 1,ROW ID 也为 1,事务 ID 假设为 1,回滚指针假设为 null。当执行 update t20 set b=666 where a=1 时,大致步骤如下:

  • 数据库会先对满足 a=1 的行加排他锁;
  • 然后将原记录复制到 undo 表空间中;
  • 修改 b 字段的值为 666,修改事务 ID 为 2;
  • 并通过隐藏的回滚指针指向 Undo log 中的历史记录;
  • 事务提交,释放前面对满足 a=1 的行所加的排他锁。

在前面实验的第 6 步中,session2 查询的结果是 session1 修改之前的记录,也就是那个点的 Read View,根据上面将的 Read View 原理,被查询行的隐藏事务 ID 就在当前活跃事务 ID 集合中。因此,这一行记录对该事物(session2 中的事务)是不可见的,可以知道 session2 查询的 a=1 这行记录实际就是来自 Undo log 中。我们看到的现象就是同一条记录在系统中存在了多个版本,这就是 MySQL 的多版本并发控制(MVCC)。需要注意的是,MVCC 只在 RCRR 两个隔离级别下工作。因此在上面的实验中,改成 RR 隔离级别,第 6 步中,得到的结果还是 session1 修改之前的记录.

2.5 MVCC 的优势

MVCC 最大的好处是读不加锁,读写不冲突,极大地增加了 MySQL 的并发性。通过 MVCC,保证了事务 ACID 中的 I 特性。

3. 不同事物隔离级别有哪些区别?

3.1 通过基本定义认识事务隔离级别

MySQL 有四种隔离级别,这四种隔离级别的定义如下:

  • Read Uncommitted(读未提交,简称:RU): 在该隔离级别,所有事务都可以看到其它未提交的事务的执行结果。可能会出现脏读。
  • Read Committed(读已提交,简称:RC):一个事务只能看见已经提交事务所做的改变。因为同一事务的其它实例在该实例处理期间可能会有新的 commit,所以可能出现幻读。
  • Repeatable Read(可重复读,简称:RR):这是 MySQL 的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。消除了脏读、不可重复读,默认也不会出现幻读。
  • Serializable(串行化):这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。

3.2 通过生活中的例子认识事务隔离级别

Read uncommitted 的例子

拿零售业务场景来讲,在事务隔离级别 RU 下:比如顾客 A 在超市买单时,当收银员扫完顾客 A 的支付码后,因为网络原因,一直等待着(也就是整个支付过程的事务还没结束);这时收银员去后台数据查询,看到 A 的钱已经进入超市账户了,然后让顾客 A 离开。过了一会,整个支付过程回滚了,才发现 A 实际是支付失败。这样超市岂不是很亏。这就是 RU 隔离级别可能导致脏读的情况。

Read Committed 的例子

在 RC 隔离级别下:比如顾客 A 在超市购买了 90 元的东西,当收银系统查询到顾客 A 还剩 100 元,足够扣款,此时 A 的老婆在家网购,花掉了 A 账户里的这 100 块,这时收银系统在扣除 A 账户 90 元这一步操作时,就会出现报错的情况。这时顾客 A 肯定郁闷,不是明明钱够么?这就是 RC 隔离级别下的幻读现象。

Repeatable Read 的例子

还是拿上面的例子,顾客 A 在超市购买了 90 元的东西,当收银系统查询到顾客 A 还剩 100 元,足够扣款,此时 A 的老婆在家网购,能查询到 A 的账户里还有 100 元,但是想要用 A 账户里的 100 块,却发现并不能使用这 100 元。这样,A 最后的扣款步骤也能正常完成,最终顺利完成了整个付款过程。这就是可重复读的现象。

Serializable 的例子

顾客 A 在超市购买了 90 元的东西,当收银系统查询到顾客 A 还剩 100 元,足够扣款,此时 A 的老婆在家网购,想查询 A 账户里还有多少钱,却发现无法查看到,必须要等到 A 整个付款完成,其老婆才能去查询余额。这就是串行导致的。

如何选择合适的事务隔离级别

对于 RU 隔离级别,会导致脏读,从性能上看,也不会比其它隔离级别好太多,因此生产环境不建议使用。

对于 RC 隔离级别,相比 RU 隔离级别,不会出现脏读;但是会出现幻读,一个事务中的两次执行同样的查询,可能得到不一样的结果。

对于 RR 隔离级别,相比 RC 隔离级别,解决了部分幻读(这个在第 17 节详细讲了,RR 隔离级别通过Next-key Lock解决了快照读情况下的幻读),但是相对于 RC,锁的范围可能更大了。

对于 Serializable 隔离级别,因为它强制事务串行执行,会在读取的每一行数据上都加锁,因此可能会导致大量的超时和锁争用的问题。生成环境很少使用。

因此总的来说,建议在 RCRR 两个隔离级别中选一种,如果能接受幻读,需要并发高点,就可以配置成 RC,如果不能接受幻读的情况,就设置成 RR 隔离级别。

4. 养成好的事物习惯

4.1 不好的事物习惯

  1. 在循环中提交

在类似这种循环写入的情况,如果循环次数不是太多,建议在循环前开启一个事务,循环结束后统一提交。这样只写了 1 次重做日志。

  1. 不关注同一个事物中的语句顺序

根据两阶段锁,整个事务里面涉及的锁,需要等到事务提交时才会释放。因此我们在同一个事务中,可以把没锁或者锁范围小的语句放在事务前面执行,而锁定范围大的语句放在后面执行。

  1. 不关注不同事物访问资源的顺序
  2. 不关注事物隔离级别
  3. 在事物中混合使用存储引擎

4.2 总结一下好的事物习惯

  1. 循环写入的情况,如果循环次数不是太多,建议在循环前开启一个事务,循环结束后统一提交。
  2. 优化事务里的语句顺序,减少锁时间。
  3. 关注不同事务访问资源的顺序。
  4. 创建事务之前,关注事务隔离级别。
  5. 不在事务中混合使用存储引擎。

5. 分布式事务

5.1 认识分布式事务

分布式事务是指一个大的事务由很多小操作组成,小操作分布在不同的服务器上或者不同的应用程序上。分布式事务需要保证这些小操作要么全部成功,要么全部失败。MySQL 从 5.0.3 开始支持分布式事务。

分布式事务使用两阶段提交协议:

  • 第一阶段:所有分支事务都开始准备,告诉事务管理器自己已经准备好了;
  • 第二阶段:确定是 rollback 还是 commit,如果有一个节点不能提交,则所有节点都要回滚。

与本地事务不同点在于:分布式事务需要多一次 prepare 操作,等收到所有节点的确定信息后,再进行 commit 或者 rollback

MySQL 中分布式事务按实现方式分为两种:MySQL 自带的分布式事务和结合中间件实现的分布式事务。

5.2 MySQL 自带的分布式事务

但是在 MySQL 5.7 之前的版本,自带的分布式事务存在以下问题:

比如某个分支事务到达 prepare 状态时,此时数据库断电,重启后,可以继续对分支事务进行提交或者回滚,但是提交的事务不会写 binlog,如果有从库,会导致主从数据不一致的情况。

如果分支事务的客户端连接异常中止,那么数据库会自动回滚当前分支未完成的事务,如果此时分支事务已经到 prepare 状态,那么这个分布式事务的其他分支可能已经成功提交,如果这个分支回滚,可能导致分布式事务的不完整,丢失部分分支事务的内容。

还有一种情况,如果分支事务在执行到 prepare 状态时,数据库出现故障,并且无法启动,需要使用全备和 binlog 来恢复数据,那么这些在 prepare 状态的分支事务因为没有记录到 binlog,所以也不能通过 binlog 进行恢复,在数据库恢复后,将丢失这部分数据。所以,MySQL 5.7 之前的版本自带的分布式事务还存在比较严重的缺陷,在有些场景下,会导致数据丢失。如果业务对数据完整性要求不高,可以考虑使用,如果对数据完整性要求比较高,需要考虑先升级到 5.7 版本。

5.3 结合中间件实现分布式事务

具体实现方式可以拿上面网上购书的例子来说:

订单业务程序处理完增加订单的操作后,将减库存操作发送到消息队列中间件中(比如:RocketMQ),订单业务程序完成提交。然后库存业务程序检查到消息队列有减对应商品库存的信息,就开始执行减库存操作。库存业务执行完减库存操作,再发送一条消息给消息队列中间件:内容是已经减掉库存。具体步骤如下:

img

当然,为了确定最终已经完成减库存操作,还可以加一步对数据库中该商品库存的判断。