Sql Bad Case
- 条件字段函数操作
- 对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能
- 栗子:month () 函数、where id + 1 = 10000 等
- 隐式类型转换
- 在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字
- 栗子:select “10” > 9(返回 1 代表做数字比较
- 隐式字符编码转换
- utf8mb4 是 utf8 的超集
- 栗子
- select * from trade_detail where tradeid=$L2.tradeid.value; (原SQL
- select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;
- CONVERT ( ) 函数:把输入的字符串转成 utf8mb4 字符集
- 连接过程中要求在被驱动表的索引字段上加函数操作(导致对被驱动表做全表扫描的原因
- 破局之道
- 统一字符集(若数据量较大且业务上暂时不允许做 DDL
- 修改SQL:…. where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;
Slow Situation
-
查询长时间不返回
-
等 MDL 锁
- 现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住
-
查获加表锁的线程 id
-
-
等 FLUSH
-
Waiting for table flush 状态示意图
- MySQL 里面对表做 flush 操作的用法
# 只关闭表 t flush tables t with read lock; # 关闭 MySQL 里所有打开的表 flush tables with read lock
- MySQL 里面对表做 flush 操作的用法
-
等行锁
- 加锁读方式:select * from t where id=1 lock in share mode(for update
- 查看锁等待信息:select * from t sys.innodb_lock_waits where locked_table=
xxx
-
-
查询慢
- 扫描行数多
- 栗子:select * from t where c=50000 limit 1;
- 字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 万行
- 数据量与执行时间呈线性增涨
- 栗子:select * from t where c=50000 limit 1;
- 一致性读
-
栗子
- select * from t where id=1;(扫描行数 1 ,执行时长 800 毫秒
- select * from t where id=1 lock in share mode;(扫描行数 1,执行时长 0.2 毫秒
-
id=1 的数据状态
- session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)
- 一致性读需要从 1000001 开始依次执行 undo log,执行了 100 万次后,才将结果返回
-
- 扫描行数多
幻读
-
特别说明
- 幻读在 “当前读” 下才会出现(普通查询是快照读,看不到其他事物插入的数据
- 当前读的规则,就是要能读到所有已经提交的记录的最新值
- 幻读仅专指 “新插入的行”(辩证观点看待
- 幻读在 “当前读” 下才会出现(普通查询是快照读,看不到其他事物插入的数据
-
锁的设计是为了保证数据的一致性
- 不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性
-
为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)
-
间隙锁,锁的就是两个值之间的空隙
-
间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间
-
-
间隙锁和 next-key lock 的引入带来了一些 “困扰”
-
间隙锁导致的死锁问题(间隙锁与间隙锁兼容、间隙锁与插入意向锁冲突
-
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的
-
锁规则
-
我总结的加锁规则里面,包含了两个 “原则”、两个 “优化” 和一个 “bug”
- 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁
- 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时,next-key lock 退化为间隙锁
- 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止
-
关于覆盖索引上的锁
- 栗子:select id from t where c = 5 lock in share mode;
- lock in share mode 只锁覆盖索引, for update 就会顺便给主键索引上满足条件的行加上行锁
-
主键索引范围锁
- session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock (10,15]
- 查找 id=10 行时是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断
-
唯一索引范围锁 bug
- InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20
- 由于这是个范围扫描,因此索引 id 上的 (15,20]
- InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20
-
limit 语句加锁
- 栗子:delete from t where c = 10 / delete from t where c = 10 limit 2
- 前者加锁范围:(5,15)后置加锁范围:(5,10)
- 在删除数据时尽量加 limit,不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围
- 栗子:delete from t where c = 10 / delete from t where c = 10 limit 2
-
next-key lock 加锁时,要分成间隙锁和行锁两段来执行的
-
读提交隔离级别下的一个优化:语句执行过程中加上的行锁,在语句执行完成后,就要把 “不满足条件的行” 上的行锁直接释放了,不需要等到事务提交
show time
-
背景:业务高峰期生产环境的 MySQL 压力太大,没法正常响应,需要短期内、临时性地提升一些性能
- 这就是为什么这章叫「show time」的原因 ( it’s your show time
- 这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法
- 既有临时的高危方案,也有未雨绸缪的、相对安全的预案
- 连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制
-
短连接风暴
-
如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况
- 在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长
-
MySQL 建立连接的过程,成本是很高的
- 除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限
-
max_connections 参数,用来控制一个 MySQL 实例同时存在的连接数的上限
- 系统就会拒绝接下来的连接请求,并报错提示 “Too many connections”
- 设计 max_connections 这个参数的目的是想保护 MySQL(不要无脑调大数值
-
破局之道
-
先处理掉那些占着连接但是不工作的线程
- max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置
- 对于那些不需要保持的连接,我们可以通过 kill connection + id 主动踢掉
- 优先断开事务外空闲太久的连接,如果还不够,再考虑断开事务内空闲太久的连接
- 服务端主动断开后,客户端会在发起下一个请求时收到「失去 MySQL 连接」报错
-
减少连接过程的消耗,让数据库跳过权限验证阶段
-
跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables 参数启动
- 跳过所有的权限验证阶段,包括连接过程和语句执行过程在内
-
不建议使用此方案,尤其你的库外网可访问的场景下
-
在 MySQL 8.0 版本里启用–skip-grant-tables 参数后,默认把 --skip-networking 参数打开
- 表示这时候数据库只能被本地的客户端连接
-
-
-
-
慢查询性能问题
- 引发性能问题的慢查询,大体有以下三种可能
- 索引没有设计好
- 通过紧急创建索引来解决(Online DDL、gh-ost
- SQL 语句没写好
- MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式
- MySQL 选错了索引
- force index + query_rewrite
- 索引没有设计好
- 实际上出现最多的是前两种,通过提前做好预防措施远好于紧急救火
- 上线前回归测试(通过 slow log 、Rows_examined 等
- 引发性能问题的慢查询,大体有以下三种可能
-
QPS 突增问题
- 业务突然出现高峰或应用程序 bug,导致某个语句的 QPS 突然暴涨,MySQL 压力过大影响服务
- 最理想的情况是让业务把这个功能下掉,服务自然就会恢复
- 如果从数据库端处理的话,针对不同的场景有对应的方法可以用
- 一种是由全新业务的 bug 导致的(可以从数据库端直接把白名单去掉
- 这个新功能使用的是单独的数据库用户(用管理员账号把这个用户删掉,然后断开现有连接
- 如果以上都不能则通过处理语句来限制(查询重写功能,将压力最大的SQL改写为 select 1
- 风险极高,可能造成误伤,而且会导致后面的业务逻辑一起失败(优先级最低
- 其实方案 1 和 2 都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离
- 由此可见,更多的准备,往往意味着更稳定的系统
日志完整性
- 前景概要:只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复
- 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,但主机掉电的时候会丢数据
- 不建议设置 0 ,因为 MySQL 异常重启就会丢数据 并且 写到到 page cache 速度本来就很快
- 组提交(参数 binlog_group_commit_sync_delay、binlog_group_commit_sync_no_delay_count
- binlog 的写入机制
-
事务执行过程中,先把日志写到 binlog cache,事务提交时再把 binlog cache 写到 binlog 文件中
- 一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入
-
每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小
- 超过了这个参数规定的大小,就要暂存到磁盘
-
binlog 写盘状态
-
每个线程有自己 binlog cache,但是共用同一份 binlog 文件
-
write:把日志写入到文件系统的 page cache(速度快
-
fsync:将数据持久化到磁盘的操作(占磁盘的 IOPS
-
sync_binlog 控制 write 和 fsync 的时机
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync
- 考虑到丢失日志量的可控性,一般不建议将这个参数设成 0
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync
- sync_binlog=N (N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync
- 如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync
-
-
- redo log 的写入机制
-
事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的,不需要直接持久化到磁盘
- 若异常重启,这部分日志就丢了,但由于事务并没有提交,丢了也不会有损失
-
MySQL redo log 存储状态
-
存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分(快
-
写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里(快
-
持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分(慢,同样占磁盘的 IOPS
-
innodb_flush_log_at_trx_commit 参数控制 redo log 的写入策略
- 0:事务提交时都只是把 redo log 留在 redo log buffer 中
- 1:事务提交时都将 redo log 直接持久化到磁盘
- 2:每次事务提交时都只是把 redo log 写到 page cache
-
可能将一个「没有提交」的事务的 redo log 写入到磁盘中的场景
- 后台线程每秒一次的轮询(把 redo log buffer 中的日志写入 page cache 并 fsync 持久化
- redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半时后台线程会主动写盘
- 只 write,不 fsync
- 并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘
-
“双 1” 配置:sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1
- 一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog
-
-
组提交(group commit)机制
-
日志逻辑序列号(log sequence number,LSN):
- LSN 是单调递增的,用来对应 redo log 的一个个写入点
- 每次写入长度为 length 的 redo log, LSN 的值就会加上 length
-
LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log
-
栗子
-
trx1 是第一个到达的,会被选为这组的 leader
-
等 trx1 要开始写盘的时候,这个组里面已经有了三个事务, LSN 变成了 160
-
trx1 去写盘的时候,带的就是 LSN=160(小于等于 160 的 redo log 均持久化
-
trx2 和 trx3 直接返回
-
一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好
-
-
利用组提交的 MySQL 优化:拖时间使 binlog 也可以组提交
- MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到 binlog write 之后
- binlog write:binlog 从 binlog cache 中写到磁盘上的 binlog 文件
- binlog 的组提交的效果通常不如 redo log 组提交效果好(redo log fsync 执行很快
- 提升 binlog 组提交的效果的参数(两者为 或 关系,满足一个则调用 fsync
- binlog_group_commit_sync_delay 参数:延迟多少微秒后才调用 fsync
- binlog_group_commit_sync_no_delay_count 参数:累积多少次以后才调用 fsync
- MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到 binlog write 之后
-
-
- 日志相关问题
- 为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
- 主要原因是 binlog 是不能 “被打断的”,一个事务的 binlog 必须连续写(等事务提交完写入
- redo log 并没有这个要求,中间生成的日志可以写到 redo log buffer,还可以搭便车持久化
- 事务执行期间还没到提交阶段时发生 crash 的话,redo log 丢失,这会不会导致主备不一致呢?
- 不会; binlog 还在 binlog cache 中,未发给备库(crash 后从业务角度看事业也未提交
- 数据库的 crash-safe 保证的是
- 如果客户端收到事务成功的消息,事务就一定持久化了
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了
- 如果客户端收到 “执行异常” 的消息,应用需要重连后通过查询当前状态来继续后续的逻辑
- 此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了
- 为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?