「笔记」MySQL 实战 45 讲 - 实践篇(二)

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 锁

      tu1

      • 现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住
    • 查获加表锁的线程 id

      tu2

  • 等 FLUSH

    • Waiting for table flush 状态示意图

      tu3

      • MySQL 里面对表做 flush 操作的用法
         # 只关闭表 t
         flush tables t with read lock;
         # 关闭 MySQL 里所有打开的表
         flush tables with read lock
        
    • 等行锁

      • 加锁读方式: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 id=1;(扫描行数 1 ,执行时长 800 毫秒
        • select * from t where id=1 lock in share mode;(扫描行数 1,执行时长 0.2 毫秒
      • id=1 的数据状态

        tu1

        • session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)
        • 一致性读需要从 1000001 开始依次执行 undo log,执行了 100 万次后,才将结果返回

幻读

  • 特别说明

    • 幻读在 “当前读” 下才会出现(普通查询是快照读,看不到其他事物插入的数据
      • 当前读的规则,就是要能读到所有已经提交的记录的最新值
    • 幻读仅专指 “新插入的行”(辩证观点看待
  • 锁的设计是为了保证数据的一致性

    • 不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性
  • 为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)

    • 间隙锁,锁的就是两个值之间的空隙

      tu1

    • 间隙锁和行锁合称 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 就会顺便给主键索引上满足条件的行加上行锁
  • 主键索引范围锁

    tu1

    • session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock (10,15]
    • 查找 id=10 行时是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断
  • 唯一索引范围锁 bug

    tu1

    • InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20
      • 由于这是个范围扫描,因此索引 id 上的 (15,20]
  • limit 语句加锁

    • 栗子:delete from t where c = 10 / delete from t where c = 10 limit 2
      • 前者加锁范围:(5,15)后置加锁范围:(5,10)
    • 在删除数据时尽量加 limit,不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围
  • 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 的写入机制
    • 事务执行过程中,先把日志写到 binlog cache,事务提交时再把 binlog cache 写到 binlog 文件中

      • 一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入
    • 每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小

      • 超过了这个参数规定的大小,就要暂存到磁盘
    • binlog 写盘状态

      tu1

      • 每个线程有自己 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 日志
  • redo log 的写入机制
    • 事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的,不需要直接持久化到磁盘

      • 若异常重启,这部分日志就丢了,但由于事务并没有提交,丢了也不会有损失
    • MySQL redo log 存储状态

      tu1

      • 存在 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

      • 栗子

        tu1

        • trx1 是第一个到达的,会被选为这组的 leader

        • 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务, LSN 变成了 160

        • trx1 去写盘的时候,带的就是 LSN=160(小于等于 160 的 redo log 均持久化

        • trx2 和 trx3 直接返回

        • 一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好

      • 利用组提交的 MySQL 优化:拖时间使 binlog 也可以组提交

        tu1

        • 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
  • 日志相关问题
    • 为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
      • 主要原因是 binlog 是不能 “被打断的”,一个事务的 binlog 必须连续写(等事务提交完写入
      • redo log 并没有这个要求,中间生成的日志可以写到 redo log buffer,还可以搭便车持久化
    • 事务执行期间还没到提交阶段时发生 crash 的话,redo log 丢失,这会不会导致主备不一致呢?
      • 不会; binlog 还在 binlog cache 中,未发给备库(crash 后从业务角度看事业也未提交
    • 数据库的 crash-safe 保证的是
      • 如果客户端收到事务成功的消息,事务就一定持久化了
      • 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了
      • 如果客户端收到 “执行异常” 的消息,应用需要重连后通过查询当前状态来继续后续的逻辑
        • 此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章