学习自jdh莫老师
MySQL逻辑架构图
redo log
为什么要redo log?
如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者采用了WAL技术(Write-Ahead Logging),关键点就是先写日志,再写磁盘,这个日志就是redo log。redo log是InnoDB引擎特有的日志。
binLog
为什么会有两份日志?
redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。那为什么会有两份日志呢?因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。
两种日志的区别
redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。 redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。 redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
简单update语句执行流程
update T set c=c+1 where ID=2
两阶段提交
由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。假设不用两阶段提交,会出现如下情况
- 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。
- 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log
还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。
事务隔离
什么是事务?
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
隔离性与隔离级别
隔离性是事务ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)中的I,当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable ) 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
Oracle 数据库的默认隔离级别是“读提交”,MySQL则是“可重复读”
事务隔离的实现?
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)
索引
InnoDB的索引模型
在 MySQL 中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。每一个索引在 InnoDB 里面对应一棵 B+ 树。 create table T(id int primary key, k int not null, name varchar(16),index (k))engine=InnoDB;表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。
覆盖索引
在一个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。例如如果有一个高频请求,要根据市民的身份证号查询他的姓名,建立一个(身份证号、姓名)的联合索引就有意义了,可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。
最左前缀原则
B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。比如说已经有了(name,age)这个联合索引,SQL 语句的条件是"where name like ‘张 %’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。已经有了 (a,b) 这个联合索引后,一般就不需要单独在 a 上建立索引了
索引下推
MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
锁
锁的类型有哪些?
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。
全局锁
全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是,做全库逻辑备份。一般只有不支持事务的数据库引擎才会用到这种锁,如MyISAM
表级锁
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。表锁的语法是 lock tables … read/write。在 MySQL 5.5 版本中引入了 MDL锁,每执行一条DML、DDL语句时都会申请MDL锁,DML操作需要MDL读锁,DDL操作需要MDL写锁(MDL加锁过程是系统自动控制,无法直接干预,读读共享,读写互斥,写写互斥)。MDL读锁之间不互斥,因此可以有多个线程同时对一张表增删改查。MDL读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
行锁
MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。 在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。针对死锁,InnoDB有两种解决策略 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用这个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。但是超时时间设置太短的话,会出现很多误伤。 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
普通索引和唯一索引,怎么选?
这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。对唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入 (4,400) 这个记录,就要先判断现在表中是否已经存在 k=4 的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用 change buffer 了。因此,唯一索引的更新就不能使用 change buffer,实际上也只有普通索引可以使用。将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。 综上所述,建议尽量选择普通索引,如果所有的更新后面,都马上伴随着对这个记录的查询,应该关闭 change buffer。而在其他情况下,change buffer 都能提升更新性能。在实际使用中,普通索引和 change buffer 的配合使用,对于数据量大的表的更新优化还是很明显的。
MySQL为什么有时候会选错索引?
随着表数据的不断增删,可能会出现MySQL 选错索引,根本原因是MySQL没能准确地判断出扫描行数或者错误地计算了执行成本,这种是低概率事件,但是某些场景下有可能触发MySQL的这个bug,解决方法一般有以下几种 由于索引统计信息不准确导致的问题,可以用 analyze table 来解决 采用 force index 强行选择一个索引,不过这个弊端也很明显,一来这么写不优雅,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。 修改语句,引导 MySQL 使用我们期望的索引。 如把select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1改成select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b,a limit 1 新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。
怎么给字符串字段加索引?
主要看该字段的区分度是不是足够大,建索引是否有必要。如果有必要,建索引有两种方法,一种是整个字段索引,如果字段值普遍较长,则可能会消耗较大的存储空间,另一种是使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。如:alter table SUser add index index2(email(7))。那问题来了,当要给字符串创建前缀索引时,有什么方法能够确定应该使用多长的前缀呢? select count(distinct email) as L from SUser; select count(distinct left(email,4))as L4, count(distinct left(email,5))as L5, count(distinct left(email,6))as L6, count(distinct left(email,7))as L7,from SUser;
但是使用前缀索引,就用不上覆盖索引对查询性能的优化了
小技巧:倒序存储和使用 hash 字段
SQL语句突然“变慢”?
InnoDB 在处理更新语句的时候,只做了写日志这一个磁盘操作,这个日志叫作 redo log(重做日志)。平时执行很快的更新操作,其实就是在写内存和日志,而 MySQL 偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。MySQL在下面这些情境下会做flush操作 InnoDB 的 redo log 写满了。这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。这种情况是 InnoDB 要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了 系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。这种情况其实是常态。但是出现以下这两种情况,都是会明显影响性能的 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长; 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的。 要用到 innodb_io_capacity 这个参数了,它会告诉 InnoDB 数据库主机的磁盘能力。这个值建议设置成磁盘的 IOPS。磁盘的 IOPS 可以通过 fio 这个工具来测试。并且平时要多关注脏页比例(innodb_max_dirty_pages_pct),不要让它经常接近 75%。如果是SSD盘,innodb_flush_neighbors参数设置成0 MySQL 认为系统“空闲”的时候。 MySQL 正常关闭
为什么表数据删掉一半,表文件大小不变?
delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。不止是删除数据会造成空洞,插入数据也会。也就是说,经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。而重建表,就可以达到这样的目的。 MySQL 5.6 版本开始引入的 Online DDL,对表重建操作流程做了优化,可以通过执行alter table t engine=InnoDB对表进行重建。通过日志文件记录和重放操作,在重建表的过程中,允许对表做增删改操作。重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,需要很小心地控制操作时间。
为什么count(*)这么慢?
为什么 InnoDB 不跟 MyISAM 一样,也把记录总数存起来呢,查询的时候直接返回?这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历,但是如果表的数据十分巨大,因为是整棵树遍历,所以性能会非常差。如果有高频需要获取大表记录总数的场景,需要自己进行计数。 比如可以在数据库里面建一张表,把高频需要获取记录总数的大表的记录数自己进行维护,获取总数时候直接从该表获取。
不同的count用法
对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。 对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。 对于 count(字段) 来说,如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。 所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(),所以建议尽量使用 count()。
“order by”是怎么工作的?
按字段排序
select city,name,age from t where city='杭州' order by name limit 1000 ;
图中“按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数sort_buffer_size。sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。
rowid排序
刚说的按字段排序算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。所以rowid排序的思想是减少查询字段的数量,节省排序内存,但是缺点是有可能需要回到原表去取数据。对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。
查询响应慢排查
MDL锁
使用 show processlist 命令查看 Waiting for table metadata lock 的示意图。MySQL 启动时需要设置 performance_schema=on,通过查询 sys.schema_table_lock_waits 这张表,就可以直接找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。
flush
出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了select 语句。
等行锁
select * from t where id=1 lock in share mode;由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,select 语句就会被堵住。这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果是 MySQL 5.7 版本,可以通过 sys.innodb_lock_waits 表查到。查询语句:select * from t sys.innodb_lock_waits where locked_table=‘test .t ’\G
一致性读原因导致
session A 先用 start transaction with consistent snapshot 命令启动了一个事务,之后 session B 才开始执行 update 语句。session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)。 带 lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果,所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。所以后者的执行时间大概是前者的4000倍
什么是幻读?
在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
幻读有什么问题?
session A 在 T1 时刻就声明了,“我要把所有 d=5 的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。
- 数据一致性问题
如何解决幻读?
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。间隙锁的引入,虽然解决了幻读的问题,但是可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。间隙锁是在可重复读隔离级别下才会生效的。所以,如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,要解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row。这,也是现在不少公司使用的配置组合。 next-key lock:间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。表 t(下图上方数字是主键值) 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
MySQL加锁规则解读
MySQL加锁原则
原则 1:加锁的基本单位是 next-key lock。 原则 2:查找过程中访问到的对象才会加锁。 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
案例一:等值查询间隙锁
由于表 t 中没有 id=7 的记录,加锁单位是 next-key lock(),session A 加锁范围就是 (5,10];这是一个等值查询 (id=7),而 id=10 不满足查询条件,根据优化2,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。
案例二:非唯一索引等值锁
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 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化。
案例三:主键索引范围锁
开始执行的时候,要找到第一个 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 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。
案例五:唯一索引范围锁bug
session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15]这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20]这个 next-key lock 也会被锁上。
案例六:非唯一索引上存在“等值”的例子
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 上的加锁范围,就是下图中蓝色区域覆盖的部分。
案例七:非唯一索引上存在“等值”的例子
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) 这个前开后闭区间,如下图所示: 这个例子的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
案例八:死锁例子
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 不是还没申请成功吗? 其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。
索引失效反面案例
条件字段函数操作
select count(*) from tradelog where month(t_modified)=7; 对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能而遍历整个索引的所有值。需要注意的是,即使是对于不改变有序性的函数,也不会考虑使用索引。比如,对于 select * from tradelog where id + 1 = 10000 这个 SQL 语句,这个加 1 操作并不会改变有序性,但是 MySQL 优化器还是不能用 id 索引快速定位到 9999 这一行。所以,需要在写 SQL 语句的时候,手动改写成 where id = 10000 -1 才可以。
隐式类型转换
select * from tradelog where tradeid=110717; tradeid 的字段类型是 varchar(32),而输入的参数却是整型,所以需要做类型转换,导致走了全表扫描。MySQL默认是将字符串转换成数字进行比较,所以上面的语句等价于select * from tradelog where CAST(tradid AS signed int) = 110717;触发了上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。
隐式字符编码转换
select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; 由于tradelog表的tradeid字符集编码是utf8mb4,而trade_detail表的tradeid是utf8,utf8mb4是utf8的超集,所以发生了隐式转换,该语句等价于select d.* from tradelog l, trade_detail d where d.tradeid=CONVERT(d.traideid USING utf8mb4) and l.id=2; 触发了上面说到的规则:对被驱动表索引字段做函数操作,优化器会放弃走树搜索功能。 思考题:不调整字段编码的情况下怎么改才能走对索引?
慢查询处理技巧
慢查询导致性能问题的三种可能情况如下
索引没有设计好
这种场景一般就是通过紧急创建索引来解决。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 语句加上索引。
语句没写好
这种就要通过分析explain命令分析语句的执行计划,并进行优化,典型反面案例参考上一页的索引失效反面案例。MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式,当出现线上业务bug导致QPS暴涨,数据库也面临宕机风险的时候,可以用这个把压力最大的SQL直接重写为“select 1”返回,以解燃眉之急
MySQL选错了索引
这种是低概率事件,具体解决方案参考上面的“MySQL为什么有时候会选错索引”这节
慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,一般在测试环境可以通过以下手段尽量规避 上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志; 在测试表里插入模拟线上的数据,做一遍回归测试;观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。
|