[MySQL]常用知识

本文深入解析MySQL索引原理,包括B+树结构及其在磁盘IO优化中的作用,探讨索引的最左匹配原则及复合索引的使用技巧。通过实例演示慢查询优化流程,展示如何通过优化SQL语句和建立有效索引显著提升查询性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

参考文章:MySQL索引原理及慢查询优化

MySQL索引原理

索引的目的在于提高查询效率。数据库的查询不仅面临着等值查询,还有范围查询(<,>,between,in)、模糊查询(like)、并集查询(or)...数据库应该如何应对这些问题呢?试想,能不能将数据分段,然后分段查询呢?最简单的如果有1000条数据,1~100为第1段,101~200为第2段,201~300为第3段......如此,查询第250条数据,直接找第3段就可以了,一下子就去除了90%的无效数据。但如果是1千万条记录呢,分成几段比较好?有人可能会想到搜索树,其平均复杂度是lgN,具有不错的查询性能。但这里忽略了一个关键问题,复杂度模型是基于每次相同的操作成本来考虑的,数据库实现比较复杂,数据保存在磁盘上,而为了提高性能,每次又可以把部分数据读入内存来计算,因为访问磁盘(disk seek大概10,000,000ns)的成本大概是访问内存(main memory reference大概100ns)的10万倍左右,所以简单的搜索树难以满足复杂的应用场景。

索引的数据结构

传统的机械磁盘读取数据靠机械运动,每次读取所花时间可分为寻道、旋转延迟、传输3部分,寻道时间指磁臂移动到指定磁道所需时间,主流磁盘一般不高于5ms;旋转延迟即磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,即1秒能转120次,旋转延迟就是1/120/2 = 4.17ms;传输时间指将数据写入或读出磁盘的时间,一般在零点几毫秒。综上,一次磁盘IO的时间约9ms,听起来还好,但要知道一台500MIPS(Million Instructions Per Second)的机器每秒可执行5亿条指令,因为指令依靠的是电的性质,即执行一次IO的时间可以执行约450万条指令,数据库数据量动辄百万乃至千万级,每次9ms,显然是个灾难。

考虑到磁盘IO操作代价高昂,OS做了些优化,当一次IO时,不只将当前磁盘地址的数据,还将相邻的数据也都读取到内存缓冲区,因为局部预读性原理告诉我们,当计算机访问一个地址的数据时,与其相邻的数据也会很快被访问到。每次IO读取的数据我们称之为一页(page)。具体一页有多大跟OS有关,一般为4k或8k,即每读取一页,发生一次IO。

那么我们需要索引的数据结构能够做些什么呢?由以上可知,我们需要在每次查找数据时,将磁盘IO次数控制在一个很小的数量级,最好是常量级。那么如果有一个高度可控的多路搜索树,是否能满足需求呢?就这样,b+树应运而生。

b+树

b+树

上图为一颗b+树,浅蓝色的块我们称之为一个磁盘块,可以看到每个磁盘块包含几个数据项(深蓝色所示)和指针(黄色所示),如磁盘块1包含数据项17和35,包含指针P1、P2、P3,P1表示小于17的磁盘块,P2表示在17和35之间的磁盘块,P3表示大于35的磁盘块。真实的数据存在于叶子节点即上图最下面一层。非叶子节点不存真实数据,只存指引搜索方向的数据项,如17、35并不真实存在于数据表。

如图所示,如果要查找数据项29,那么首先会将磁盘块1加载到内存(一次IO),在内存中用二分查找确定29在17和35之间,锁定指针P2,通过P2的磁盘地址将磁盘块3加载到内存(二次IO),29在26和30之间,锁定磁盘块3的指针P2,通过P2加载磁盘块8到内存(三次IO),在内存中二分查找找到29,查询结束。真实情况是,3层b+树可表示上百万条数据,如果上百万的数据查找只需三次IO,性能提升将是巨大的,如果没有索引,每个数据项都要发生一次IO,那么总共需百万次IO,显然成本太高。

通过上面的分析,我们知道IO次数取决于b+数的h,假设当前数据表的数据为N,每个磁盘块的数据项的数量是m,则有h=\log_{m+1}N,N一定,m越大,h越小,而m=磁盘块size/数据项size,磁盘块大小即一个数据页的大小,是固定的,如果数据项占的空间越小,数据项的数量越多,树的高度越低。这就是为什么每个数据项,即索引字段要尽量小。这也是为什么b+树要求把真实的数据放到叶子节点

当b+树的数据项是复合的数据结构(比如name,age,sex)时,b+数按从左到右的顺序建立搜索树,比如当(张三,20,F)这样的数据来检索时,b+树会优先比较name,确定下一步搜索方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据时,b+树就不知道下一步该查哪个节点,因为建立搜索树时name是第一个比较因子,必须要先根据name来搜索才能知道下一步去哪里。比如当(张三,F)这样的数据来检索时,b+树可用name来指定搜索方向,但因下一字段age的缺失,所以只能把名字等于张三的数据都找到,然后再匹配性别是F的数据了,这个是非常重要的性质,即索引的最左匹配特性。

慢查询优化

我们先来看看索引的几大基本原则:

  • 最左匹配原则,mysql会一直向右匹配,直到遇到范围查询,停止匹配,比如a=1 and b=2 and c>3 and d=4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可任意调整。
  • =和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可任意顺序,mysql的查询优化器会自动优化成可以识别的形式。
  • 尽量选择区分度高的列作为索引,区分度的公式是count(distinct col)/count(1),表示字段不重复的比例,比例越大扫描的记录数越少。那么,在实际使用中,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段要求不低于0.1。
  • 索引列不能参与计算,比如from_unixtime(create_time) = ’2018-12-20’就不能使用到索引,因为b+树中存的都是数据表中的字段值,但进行检索时,需把所有元素都应用函数才能比较,显然成本太大。所以语句应写成create_time = unix_timestamp(’2018-12-20’)。
  • 尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的即可。

查询优化辅助命令——explain命令,这里需要强调,rows是核心指标,绝大部分rows小的语句执行一定很快(但也有例外)。所以优化语句基本上都是在优化rows。

慢查询优化的基本步骤:

  1. 先运行看看是否真的很慢,注意设置SQL_NO_CACHE
  2. where条件单表查,锁定最小返回记录表。将查询语句的where都应用到表中返回的记录数最小的表开始查起,单表每个字段分别查询,看哪个字段的区分度最高
  3. explain查看执行计划,是否与上步的预期一致
  4. order by limit 形式的sql语句让排序的表优先查
  5. 了解业务方使用场景
  6. 加索引时参照建索引的几大原则
  7. 观察结果,不符合预期的继续从1开始分析

很多情况下,我们写SQL只是为了实现功能,这只是第一步,不同的语句书写方式对效率往往有本质差别,这要求我们对mysql的执行计划和索引原则有非常清楚的认识,请看下面的语句:

select
   distinct cert.emp_id 
from
   cm_log cl 
inner join
   (
      select
         emp.id as emp_id,
         emp_cert.id as cert_id 
      from
         employee emp 
      left join
         emp_certificate emp_cert 
            on emp.id = emp_cert.emp_id 
      where
         emp.is_deleted=0
   ) cert 
      on (
         cl.ref_table='Employee' 
         and cl.ref_oid= cert.emp_id
      ) 
      or (
         cl.ref_table='EmpCertificate' 
         and cl.ref_oid= cert.cert_id
      ) 
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00';

1.先运行一下,53条记录 1.87s,又没有用聚合语句,比较慢

53 rows in set (1.87 sec)

2.explain

+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+
| id | select_type | table      | type  | possible_keys                   | key                   | key_len | ref               | rows  | Extra                          |
+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+
|  1 | PRIMARY     | cl         | range | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date     | 8       | NULL              |   379 | Using where; Using temporary   |
|  1 | PRIMARY     | <derived2> | ALL   | NULL                            | NULL                  | NULL    | NULL              | 63727 | Using where; Using join buffer |
|  2 | DERIVED     | emp        | ALL   | NULL                            | NULL                  | NULL    | NULL              | 13317 | Using where                    |
|  2 | DERIVED     | emp_cert   | ref   | emp_certificate_empid           | emp_certificate_empid | 4       | meituanorg.emp.id |     1 | Using index                    |
+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+

简述一下执行计划,首先mysql根据idx_last_upd_date索引扫描cl表获得379条记录;然后查表扫描了63727条记录,分为两部分,derived表示构造表,也就是不存在的表,可简单理解成是一个语句形成的结果集,后面的数字表示语句的ID。derived2表示的是ID=2的查询构造了虚拟表,并且返回了63727条记录。我们再来看看ID=2的语句究竟做了写什么返回了这么大量的数据,首先全表扫描emp表13317条记录,然后根据索引emp_certificate_empid关联emp_cert表,rows=1表示,每个关联都只锁定了一条记录,效率比较高。获得后,再和cl的379条记录根据规则关联。从执行过程上可以看出返回了太多的数据,返回的数据绝大部分cl都用不到,因为cl只锁定了379条记录。

如何优化呢?可以看到我们在运行完后还是要和cl做join,那么我们能不能之前和cl做join呢?仔细分析语句不难发现,其基本思想是如果cl的ref_table是EmpCertificate就关联emp_certificate表,如果ref_table是Employee就关联employee表,我们完全可以拆成两部分,并用union连接起来,注意这里用union,而不是union all,因为原语句有“distinct”来得到唯一记录,而union恰好具备了这种功能。如果原语句中没有distinct不需要去重,我们就可以直接使用union all了,因为使用union需要去重的动作,会影响性能。优化过的语句如下:

select
   emp.id 
from
   cm_log cl 
inner join
   employee emp 
      on cl.ref_table = 'Employee' 
      and cl.ref_oid = emp.id  
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00' 
   and emp.is_deleted = 0  
union
select
   emp.id 
from
   cm_log cl 
inner join
   emp_certificate ec 
      on cl.ref_table = 'EmpCertificate' 
      and cl.ref_oid = ec.id  
inner join
   employee emp 
      on emp.id = ec.emp_id  
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00' 
   and emp.is_deleted = 0

5.不需要了解业务场景,只需改造的语句和改造之前的语句保持结果一致

6.现有索引可以满足,不需建索引

7.用改造后的语句实验一下,只需10ms,降低了近200倍

+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
| id | select_type  | table      | type   | possible_keys                   | key               | key_len | ref                   | rows | Extra       |
+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
|  1 | PRIMARY      | cl         | range  | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8       | NULL                  |  379 | Using where |
|  1 | PRIMARY      | emp        | eq_ref | PRIMARY                         | PRIMARY           | 4       | meituanorg.cl.ref_oid |    1 | Using where |
|  2 | UNION        | cl         | range  | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8       | NULL                  |  379 | Using where |
|  2 | UNION        | ec         | eq_ref | PRIMARY,emp_certificate_empid   | PRIMARY           | 4       | meituanorg.cl.ref_oid |    1 |             |
|  2 | UNION        | emp        | eq_ref | PRIMARY                         | PRIMARY           | 4       | meituanorg.ec.emp_id  |    1 | Using where |
| NULL | UNION RESULT | <union1,2> | ALL    | NULL                            | NULL              | NULL    | NULL                  | NULL |             |
+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
53 rows in set (0.01 sec)

再举个例子,举这个例子的目的在于颠覆我们对列的区分度的认知,一般我们认为区分度越高的列,越容易锁定更少的记录,但在一些特殊情况下,这种理论是有局限性的。

select
   * 
from
   stage_poi sp 
where
   sp.accurate_result=1 
   and (
      sp.sync_status=0 
      or sp.sync_status=2 
      or sp.sync_status=4
   );

1.先看看运行多长时间,951条数据6.22s,真的很慢。

951 rows in set (6.22 sec)

2.先explain,rows达到了361万,type=ALL表明是全表扫描。

+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows    | Extra       |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
|  1 | SIMPLE      | sp    | ALL  | NULL          | NULL | NULL    | NULL | 3613155 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+

3.所有字段都应用查询返回记录数,因为是单表查询,1已经做过了951条。

4.让explain的rows尽量逼近951。

看一下accurate_result=1的记录数:

select count(*),accurate_result from stage_poi  group by accurate_result;
+----------+-----------------+
| count(*) | accurate_result |
+----------+-----------------+
|     1023 |              -1 |
|  2114655 |               0 |
|   972815 |               1 |
+----------+-----------------+

我们看到accurate_result这个字段的区分度非常低,整个表只有-1,0,1三个值,加上索引也无法锁定特别少量的数据。

再看一下sync_status字段的情况:

select count(*),sync_status from stage_poi  group by sync_status;
+----------+-------------+
| count(*) | sync_status |
+----------+-------------+
|     3080 |           0 |
|  3085413 |           3 |
+----------+-------------+

同样的区分度也很低,根据理论,也不适合建立索引。

问题分析到这,好像得出了这个表无法优化的结论,两个列的区分度都很低,即便加上索引也只能适应这种情况,很难做普遍性的优化,比如当sync_status 0、3分布的很平均,那么锁定记录也是百万级别的。

5.找业务方沟通,看看使用场景。业务方是这么来使用这个SQL语句的,每隔五分钟会扫描符合条件的数据,处理完成后把sync_status这个字段变成1,五分钟符合条件的记录数并不会太多,1000个左右。了解了业务方的使用场景后,优化这个SQL就变得简单了,因为业务方保证了数据的不平衡,如果加上索引可以过滤掉绝大部分不需要的数据。

6.根据建立索引规则,使用如下语句建立索引

alter table stage_poi add index idx_acc_status(accurate_result,sync_status);

7.观察预期结果,发现只需要200ms,快了30多倍。

952 rows in set (0.20 sec)

我们再来回顾一下分析问题的过程,单表查询相对来说比较好优化,大部分时候只需把where条件里面的字段依照规则加上索引就好,如果只是这种“无脑”优化的话,显然一些区分度非常低的列,不应该加索引的列也会被加上索引,这样会对插入、更新性能造成严重影响,同时也有可能影响其它的查询语句。所以我们第5步调差SQL的使用场景非常关键,我们只有知道这个业务场景,才能更好地辅助我们更好的分析和优化查询语句。

DATETIME和TIMESTAMP 区别

  • 时间范围不一样,TIMESTAMP 要小很多 ,且最大为2038-01-19 03:14:07.999999
  • 对于TIMESTAMP,它把客户端插入的时间从当前时区转化为UTC(世界标准时间)进行存储。查询时,将其又转化为客户端当前时区进行返回。而DATETIME不做任何改变,基本上是原样输入和输出
  • TIMESTAMP 在mysql 5.6.5之后,TIMESTAMP(fraction)中的fraction代表的是小数位数,即默认秒,以秒为单位的小数点位数。 up to microseconds (6 digits) precision,最大为6.超过6则报错(旧版TIMESTAMP格式是14位,但也可以用更短的显示尺寸,最常见的有6、8、12、和14。在创建表时可以指定一个任意的显示尺寸,但是定义列长为0或比14大均会被强制定义为列长14。列长在从1~13范围的奇数值尺寸均被强制为下一个更大的偶数):
    ERROR 1426 (42000): Too-big precision 7 specified for 'hiredate'. Maximum is 6.

常用命令

1. mysql8不支持if exists、if not exists
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值