文章目录
-
- [1. 加锁规则](#1. 加锁规则)
-
- [1.1 案例一:等值查询间隙锁](#1.1 案例一:等值查询间隙锁)
- [1.2 案例二:非唯一索引等值锁](#1.2 案例二:非唯一索引等值锁)
- [1.3 案例三:主键索引范围锁](#1.3 案例三:主键索引范围锁)
- [1.4 案例四:非唯一索引范围锁](#1.4 案例四:非唯一索引范围锁)
- [1.5 案例五:唯一索引范围锁 bug](#1.5 案例五:唯一索引范围锁 bug)
- [1.6 案例六:非唯一索引上存在"等值"的例子](#1.6 案例六:非唯一索引上存在"等值"的例子)
- [1.7 案例七:limit 语句加锁](#1.7 案例七:limit 语句加锁)
- [1.8 案例八:一个死锁的例子](#1.8 案例八:一个死锁的例子)
- [2. "饮鸩止渴"提高性能的方法](#2. “饮鸩止渴”提高性能的方法)
-
- [2.1 短连接风暴](#2.1 短连接风暴)
- [2.2 短连接风暴处理方式](#2.2 短连接风暴处理方式)
- [2.2.1 第一种方法:先处理掉那些占着连接但是不工作的线程](#2.2.1 第一种方法:先处理掉那些占着连接但是不工作的线程)
- [2.2.1 第二种方法:减少连接过程的消耗](#2.2.1 第二种方法:减少连接过程的消耗)
- [2.3 慢查询性能问题](#2.3 慢查询性能问题)
-
- [2.3.1 索引没有设计好](#2.3.1 索引没有设计好)
- [2.3.2 语句没写好](#2.3.2 语句没写好)
- [2.3.3 MySQL 选错了索引](#2.3.3 MySQL 选错了索引)
- [2.3.4 优化方案](#2.3.4 优化方案)
- [2.4 QPS 突增问题](#2.4 QPS 突增问题)
- [3. MySQL如何保证数据不丢](#3. MySQL如何保证数据不丢)
-
- [3.1 binlog 的写入机制](#3.1 binlog 的写入机制)
- [3.2 redo log 的写入机制](#3.2 redo log 的写入机制)
-
- [3.2.1 组提交(group commit)机制](#3.2.1 组提交(group commit)机制)
1. 加锁规则
默认是可重复读隔离级别,默认是可重复读隔离级别
加锁规则
- 原则 1:加锁的基本单位是 next-key lock(next-key lock 是前开后闭区间)。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
- 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
- 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
表 t 的建表语句和初始化语句如下
sql
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
1.1 案例一:等值查询间隙锁
关于等值条件操作间隙:
图 1 等值查询的间隙锁
由于表 t 中没有 id=7 的记录,所以用上面提到的加锁规则判断一下的话:
- 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
- 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。
所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。
1.2 案例二:非唯一索引等值锁
关于覆盖索引上的锁
图 2 只加在非唯一索引上的锁
这里 session A 要给索引 c 上 c=5 的这一行加上读锁。
- 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5]加上 next-key lock。
- 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10]加 next-key lock。
- 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
- 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。
session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。
注意
在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。
执行 for update 时,系统会认为接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
这个例子说明,锁是加在索引上的;
如果要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。
比如,将 session A 的查询语句改成 select d from t where c=5 lock in share mode。
1.3 案例三:主键索引范围锁
关于范围查询
对于我们这个表 t,下面这两条查询语句,加锁范围是否相同?
sql
mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;
在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。
图 3 主键索引上范围查询的锁
- 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
- 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。
因此,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果就能理解了。
需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
1.4 案例四:非唯一索引范围锁
关于范围查询
图 4 非唯一索引范围锁
session A 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。
1.5 案例五:唯一索引范围锁 bug
图 5 唯一索引范围锁的 bug
session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15]这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。
但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20]这个 next-key lock 也会被锁上。
所以,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。
照理说,这里锁住 id=20 这一行的行为,其实是没有必要的。因为扫描到 id=15,就可以确定不用往后再找了
1.6 案例六:非唯一索引上存在"等值"的例子
给表 t 插入一条新记录
sql
mysql> insert into t values(30,10,30);
新插入的这一行 c=10,现在表里有两个 c=10 的行
图 6 非唯一索引等值的例子
虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。
用 delete 语句来验证。注意,delete 语句加锁的逻辑,其实跟 select ... for update 是类似的,也就是我在文章开始总结的两个"原则"、两个"优化"和一个"bug"。
图 7 delete 示例
session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。
然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。
这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。
图 8 delete 加锁效果示例
这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁
1.7 案例七:limit 语句加锁
图 9 limit 语句加锁
session A 的 delete 语句加了 limit 2。表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。
可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。
因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间,如下图所示:
图 10 带 limit 2 的加锁效果
可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。
指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
1.8 案例八:一个死锁的例子
图 11 案例八的操作序列
- session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock(5,10] 和间隙锁 (10,15);
- session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待;
- 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。
session B 的"加 next-key lock(5,10] "操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。
小结
读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把"不满足条件的行"上的行锁直接释放了,不需要等到事务提交。读提交隔离级别下,锁的范围更小,锁的时间更短
思考
文章开头初始化的表 t,里面有 6 条记录,上图的语句序列中,为什么 session B 的 insert 操作,会被锁住呢?
看看 session A 的 select 语句加了哪些锁:
- 由于是 order by c desc,第一个要定位的是索引 c 上"最右边的"c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]。
- 在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 session B 的 insert 语句的原因。
- 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。
因此,session A 的 select 语句锁的范围就是:
- 索引 c 上 (5, 25);
- 主键索引上 id=15、20 两个行锁。
锁就是加在索引上的,这是 InnoDB 的一个基础设定
2. "饮鸩止渴"提高性能的方法
情景:业务高峰期,生产环境的 MySQL 压力太大,没法正常响应,需要短期内、临时性地提升一些性能
2.1 短连接风暴
正常的短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。
如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。
短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨
max_connections 参数
控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示"Too many connections"。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用
调高 max_connections 的值,这样做是有风险的。
设计 max_connections 这个参数的目的是想保护 MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到 CPU 资源去执行业务的 SQL 请求。
2.2 短连接风暴处理方式
2.2.1 第一种方法:先处理掉那些占着连接但是不工作的线程
max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过 kill connection 主动踢掉
注意
在 show processlist 的结果里,踢掉显示为 sleep 的线程,可能是有损的
显示状态为Sleep的线程,可能正处于事务中,如果kill掉这种线程会引起事务回滚。因此,如果是连接数过多,可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。
kill connection + id 命令
从服务端断开连接使用的是 kill connection + id 的命令, 一个客户端处于 sleep 状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。直到客户端在发起下一个请求的时候,才会收到这样的报错"ERROR 2013 (HY000): Lost connection to MySQL server during query"。
从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,"MySQL 一直没恢复"。
2.2.1 第二种方法:减少连接过程的消耗
有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段
跳过权限验证的方法
重启数据库,并使用--skip-grant-tables 参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。此方法风险极高
在 MySQL 8.0 版本里,如果你启用--skip-grant-tables 参数,MySQL 会默认把 --skip-networking 参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL 官方对 skip-grant-tables 这个参数的安全问题也很重视。
2.3 慢查询性能问题
MySQL 中,会引发性能问题的慢查询,大体有以下三种可能:
- 索引没有设计好;
- SQL 语句没写好;
- MySQL 选错了索引。
2.3.1 索引没有设计好
这种场景一般就是通过紧急创建索引来解决
MySQL 5.6 版本以后,创建索引都支持 Online DDL 了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行 alter table 语句。
理想的是能够在备库先执行。
假设你现在的服务是一主一备,主库 A、备库 B,这个方案的大致流程是这样的:
- 在备库 B 上执行 set sql_log_bin=off,也就是不写 binlog,然后执行 alter table 语句加上索引;
- 执行主备切换;
- 这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。
在需要紧急处理时,上面这个方案的效率是最高的
2.3.2 语句没写好
通过改写 SQL 语句来处理
MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式。
比如,语句被错误地写成了 select * from t where id + 1 = 10000,可以通过下面的方式,增加一个语句改写规则。
sql
mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1");
call query_rewrite.flush_rewrite_rules();
call query_rewrite.flush_rewrite_rules() 这个存储过程,是让插入的新规则生效,也就是我们说的"查询重写"
2.3.3 MySQL 选错了索引
应急方案就是给这个语句加上 force index
2.3.4 优化方案
实际上出现最多的是前两种,即:索引没设计好和语句没写好。
而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,就可以预先发现问题。
- 上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志;
- 在测试表里插入模拟线上的数据,做一遍回归测试;
- 观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。
2.4 QPS 突增问题
由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。
从数据库端处理的话,对应于不同的背景,有不同的方法可用:
- 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
- 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
- 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成"select 1"返回。
方案 1 和 2 都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。由此可见,更多的准备,往往意味着更稳定的系统。
方案 3 是用于止血的,跟前面提到的去掉权限验证一样,应该是所有选项里优先级最低的一个方案
这个操作的风险很高,需要特别细致。它可能存在两个副作用:
- 如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
- 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。
3. MySQL如何保证数据不丢
3.1 binlog 的写入机制
事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题
系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。
事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。
可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。
- 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
- 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。
write 和 fsync 的时机,是由参数 sync_binlog 控制的:
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
3.2 redo log 的写入机制
事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。
redo log buffer 里面的内容,每次生成后不需要直接持久化到磁盘。但如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。
同时,事务还没提交的时候,redo log buffer 中的部分日志有可能被持久化到磁盘,这要从 redo log 可能存在的三种状态说起。
图 2 MySQL redo log 存储状态
这三种状态分别是:
- 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
- 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
- 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。
日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。
为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:
- 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
- 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
- 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。
InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。
注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。
除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。
- 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
- 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。
两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。
如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。
MySQL 的"双 1"配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。
也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog
3.2.1 组提交(group commit)机制
- LSN
日志逻辑序列号(log sequence number,LSN)
LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。
如下图 所示,是三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。
从图中可以看到,
- trx1 是第一个到达的,会被选为这组的 leader;
- 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
- trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
- 这时候 trx2 和 trx3 就可以直接返回了。
所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。
在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。
图 4 两阶段提交
图中,"写 binlog"当成一个动作。但实际上,写 binlog 是分成两步的:
- 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
- 调用 fsync 持久化。
MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:
这么一来,binlog 也可以组提交了。
在执行图上中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。
通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。
想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 来实现。
- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
这两个条件是或的关系,也就是说只要有一个满足条件就会调用 fsync。所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。
WAL 机制是减少磁盘写,可是每次提交事务都要写 redo log 和 binlog,WAL 机制主要得益于两个方面:
- redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?
针对这个问题,可以考虑以下三种方法:
- 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于"额外的故意等待"来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
- 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
- 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。
不建议把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。
而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。
问题 1:执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?
回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。
问题 2:为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
回答:MySQL 这么设计的主要原因是,binlog 是不能"被打断的"。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能"搭便车",其他事务提交的时候可以被一起写到磁盘中。
问题 3:事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?
回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。
问题 4:如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?
回答:不是。设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到"网络断开"的异常。这种也只能算是事务成功的,不能认为是 bug。
实际上数据库的 crash-safe 保证的是:
- 如果客户端收到事务成功的消息,事务就一定持久化了;
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
- 如果客户端收到"执行异常"的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。
来自 林晓斌 《MySql实战45讲》