赞
踩
慢SQL是稳定性中重要的一部分,一般指查询时间超过1s或扫描行数百万的SQL操作,可以通过show variables like 'long_query_time'
查看。对于交易这类对稳定性要求很高的业务来说治理慢SQL格外重要。这里总结下不同业务存在的慢SQL问题,以及一些通用的解决方案
案例:
平时扫描16000行执行时间1s左右的慢sql,作为任务在一个时间点执行,日常并发400,但是由于一次的大流量并发进入,导致表的连接池被打满,其他依赖这个表的服务不可用时间长达半个多小时。表面看如果数据库有400个连接,16000的1s慢sql需要执行1分多钟而已,但是由于连接打满,数据库性能下降严重,平时只有几十个连接,1s的语句现在可能执行翻了几十倍。因此慢sql的并发突增有很大的安全隐患,可能导致一些冷门业务影响了线上核心业务
大部分的慢SQL通过加索引、分库分表的方式解决就可以了,这里介绍一些其他的优化方式。以下的测试效果根据没有缓存的第一次查询算平均(SQL_NO_CACHE
没法完全排除数据缓存优化,现在数据库都是默认不使用查询缓存)
很多接口都会提供分页查询的能力,但当对于一些遍历结果的定时任务来说可能会扫描到最后一页(深度分页)。这意味着当筛选出的结果很多的时候,可能出现SELECT ... OFFSET 49990 LIMIT 10
类似这样的SQL语句,它会扫描5w行记录然后抛弃前面49990行返回最后10行,5w行就算全部命中索引,有时候耗时都会高达2s
问题原因:之所以会出现耗时长,主要原因是抛弃前面49990行是数据库server层做的,而数据获取是在存储引擎层,两者是分开的无法感知;其次数据获取会先在辅助索引里按顺序拿到记录的主键id,再去主键索引中找到完整记录数据,但是这个操作不是顺序的是随机的,也就是说进行了5w次的随机IO。。
这种优化主要是针对任务类的分页查询。一些定时任务往往会遍历表里所有符合条件的记录,然后做一些操作,当过滤条件比较少的时候会筛选出大量的记录,随着任务推进产生深度分页,导致了慢SQL
SELECT ... WHERE ... ORDER BY id DESC LIMIT 50000,100
针对任务类可以:
- -- 方法1:2021-01-01执行定时任务时,对每条查询到的记录modify_tag修改为2021-01-02
- SELECT ... WHERE modify_tag = '2021-01-01' LIMIT 100
-
- -- 方法2:每次记录上次的id,在下次的查询时带入,由于要id排序没有索引全命中可能会负优化
- SELECT ... WHERE a="a" and b="b" and id > 1111 ORDER BY id LIMIT 100
实践
以分页查询的定时任务为例,命中索引(type,status),这类索引的选择性不高,且要比对where因此扫描行数很多,当深度分页后会扫描大量行记录
- SELECt *
- FROM xxxx
- WHERE type= 'ABC'
- AND status= 'VALID'
- LIMIT 10000,
- 100
优化后如下,每次记录上一次扫描结束的记录主键id作为下一次扫描的参数,这里还会产生索引合并(index merge),主键索引和二级索引的结果做交集,降低扫描的范围,当分页到很后面的时候优化效果明显。测试下有缓存的情况下优化90%左右(100:1000),尤其深度到后面
- SELECT *
- FROM xxxx
- WHERE id> 7506888
- AND debt_type= 'ABC'
- AND status= 'VALID'
- ORDER BY id
- LIMIT 100
注:第二个语句从执行计划上可以看到extra只有using where而没有using filesort,因为当辅助索引包含了主键,当索引值相等其主键是有序的,在查询时MySQL做了优化
Can MySQL use primary key values from a secondary index?
一个典型二级索引查询,例如:
- SELECT * FROM xxx
- WHERE code='ABC'
- LIMIT 30000,500
大致分三步:
这里可以发现由于要查询select很多列所以会把一级索引里所有的行记录取出来再使用limit筛选,这一步显的很重,因为我们会对很多不需要的行做读取IO,比如LIMIT 1000,50
那无用的行记录IO就是950。子查询的思路就是把limit的操作提前到二级索引筛选的时候,在到一级索引中搜索结果输出,相当于把查询拆成两步把limit筛选提前。这个过程中大致分为:
主要形式有:
- SELECT * FROM xxx
- WHERE id >= (SELECT id FROM xxx LIMIT 3000, 1)
- LIMIT 500
以上这种试用于没有where筛选条件的查询,一旦有条件子查询就不是连续的了,如果只是那种单纯罗列的列表就可以用,对于包含where条件的可以通过连接来做:
- SELECT *
- FROM xxx p1 join(
- SELECT id FROM xxx WHERE code= 'ABC'
- LIMIT 30000, 500) as p2
- ON p1.`id`= p2.`id`;
相较原始的语句测试下来大致能提升30%左右(90:130),当分页增加时提升更多。但当where条件中的属性没有全走索引的话,这种优化就没有优势,因为子查询最终也会回表查询,但是由于过滤中不需要全部的行记录因此效率也会有小的提升10%左右(1900:2200)。
Pagination Optimization - MariaDB Knowledge Base
这里给出了一个应用层面分页展示优化方案,跟边改边查类似。主要思想是不使用offset,而采用索引范围查询取代。主要实现方式如下:
- First page (latest 10 items):
- SELECT ... WHERE ... ORDER BY id DESC LIMIT 10
- Next page (second 10):
- SELECT ... WHERE ... AND id < $left_off ORDER BY id DESC LIMIT 10
由于基于索引做范围查询,可以很快定位到数据并扫描少量的记录,这个速度是很快的。同时这种方式有一些缺点:
11-20 Out of 12345
,但是可以维护一个大致的值这种分页方式跟传统分页不同,在UI上也有所改变,可以很好的避免慢SQL但是缺少了准确性,且这种涉及前端的改造成本比较高,因此在实际改造中没有应用
很多接口调用需要获取符合条件的记录总量,这就需要用到count(*)(count(*)和count(1)效率相同),计数
当遇到索引使用不到位、筛选条件不合理、大买/卖家数据过多会产生大量行扫描。
count的优化方式不多,一般要么取大概值要么离线算好专门维护一个数量值,要么分表,直接优化的方式最好的就是索引覆盖,避免回表查询后在server层进行筛选然后计数。
有的业务需要根据条件判断表中是否有符合条件的记录,这种不需要count计算全表记录数,只需要最快命中符合条件的记录即可,exist有短路机制,因此效率更高。对于这种业务需求可以直接将sql语句改为如下的形式:
- -- 1是有,0是无
- SELECT count(*) FROM(
- SELECT 1 FROM table1 WHERE ... LIMIT 1);
-
- -- 1是有,0是无
- SELECT EXISTS(
- SELECT * FROM table1 WHERE ...);
这种短路机制对于计数量高于3w或者where条件没有完全命中索引的情况提升比较多。尤其是未全命中索引的语句count会回表并到server层一个个比对计数,测试下来大概能提升90%(70:700)左右
sql - optimize mysql count query - Stack Overflow
这里提了两个方法针对对某个时间区间实时统计总数,其实平时也不怎么会用上,基本统计类的最好都走离线数据,这里只是提一下
- SELECT min(incr_id) min_id FROM record_updates WHERE date_updated BETWEEN '2009-10-11 15:33:22' AND '2009-10-12 23:59:59';
- SELECT max(incr_id) max_id FROM record_updates WHERE date_updated > DATE_SUB(NOW(), INTERVAL 2 DAY);
如果分页查询的页大小为10,但是返回的结果数量只有5,说明总数就是5不需要再回数据库count总数
- /**
- * 快速计算当前查询条件下的count
- * return 如果未知返回-1
- * resultSize 本次分页查询结果页的长度
- * start 本次分页查询的偏移起始位置
- * limit 本次分页查询的页长
- */
- public static int fastCalculateCount(int resultSize, int start, int limit){
- // 如果分页查询返回结果为0且查询的是第一页
- if(resultSize == 0 && start == 0 && limit > 0){
- return 0;
- }
- // 如果分页查询返回的数量小于当前分页大小,那么总count其实就等于 start + resultSize
- if(resultSize > 0 && resultSize < limit){
- return start + resultSize;
- }
- // 需要回源count
- return -1;
- }
通过explain查看数据访问类型type,SQL性能优化的目标:至少要达到 range 级别,要求是ref级别,如果可以是const最好。一般来说所有类型效率从高到低:(常见几种的是const > ref > range > index > all)
类型 | 解释 | 举例 |
system | 结果只有一行。是一种特殊的const | SELECT * FROM mysql.proxies_priv |
const | 使用主键或唯一索引的时候;被连接部分是以一个常量值。结果最多只有一行 | SELECT * FROM tbl_name WHERE primary_key_part1=1 AND primary_key_part2=2; |
eq_ref | 多表联合时使用了主键或唯一索引,且所有组成部分都被用上。每个表只会返回一行记录给上一个表 | SELECT * FROM ref_table,other_table WHERE ref_table.key_column_part1=other_table.column AND ref_table.key_column_part2=1; |
ref | 命中了索引最左原则或非主键/唯一的索引。索引全命中结果返回多个行 | -- 相较上面命中了非唯一索引返回了多行 -- SELECT * FROM ref_table,other_table WHERE ref_table.key_column_part1=other_table.column AND ref_table.key_column_part2=1; |
fulltext | 全文索引才会出现 | SELECT * FROM tbl_name WHERE match(text_col) AGAINST ("text" IN BOOLEAN MODE) |
ref_or_null | 跟ref类似,MySQL会优化is null的查询(某列等于某值或者为空) | SELECT * FROM ref_table WHERE key_column=expr OR key_column IS NULL; |
index_merge | 一个查询用到了多个索引可能触发index mege优化机制 | SELECT * FROM tbl_name WHERE key_column1=1 AND key_column2=2; |
unique_subquery | 类似eq_ref,不过是子查询中走了主键或唯一索引 | value IN (SELECT primary_key FROM single_table WHERE some_expr) |
index_subquery | 类似unique_subquery,不过是自查询走了非唯一索引 | value IN (SELECT key_column FROM single_table WHERE some_expr) |
range | 查询给定范围(between,in,> < >=)内的行,会命中索引 | SELECT * FROM tbl_name WHERE key_part1 = 10 AND key_part2 IN (10,20,30); |
index | 扫描索引树返回目标行,如果查询是覆盖索引extra会有using index,不会回表;按索引顺序全表扫描来查找行,extra不会有信息 | |
all | 全表扫描 |
注:以上效率结论是相对的,如果索引的选择性很低那么ref有时候性能也会很差
在慢SQL治理中可以适当修改或删除低效的索引,减少不必要的资源开销。
索引选择性:不重复的索引值(也称为基数,cardinality)和数据表的记录总数的比值,比值越高,代表索引的选择性越好,唯一索引的选择性是最好的,比值是 1。适用前缀索引、联合索引顺序等的选择
CREATE TABLE test (blob_col BLOB, INDEX(blob_col(10)));
(身份证之类的可以反转再取前缀),去除无用信息减少索引体量PERFORMANCE_SCHEMA.table_io_waits_summary_by_index_usage
没法查到,AliSQL和xdb也都没记录,好像是因为集团数据库版本原因没有记录【强制】业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。
说明:不要以为唯一索引影响了insert速度,这个速度损耗可以忽略,但提高查找速度是明显的;另外,即使在应用层做了非常完善的校验和控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。
开发手册鉴于数据的正确性建议业务唯一的字段建立唯一索引,还能很小程度(可忽略)提升查询效率,但是唯一索引会使change buffer作用无效而降低插入性能(由于唯一性约束,需要从磁盘/内存中获取索引页以判断是否重复,产生随机IO,而非唯一索引可以接受内存无相应数据页直接存入change buffer),对于频繁插入场景的数据库需要在业务一致性和性能间作出权衡。
索引在建立的时候可以参考三星索引的原则:
往往现实中复杂的查询情况难以做到三星索引,这就可以根据筛选能力决定选择哪几个星了
如果语句并发量不大且无法继续优化,限流降级是一种兜底的方案,以免数据量或并发量突增导致数据库资源耗尽。
数据库升级后具备内核级别的慢SQL隔离能力,会对慢请求进行限流处理而不影响正常的请求,通过SHOW VARIABLES like 'slow_query_blocker_enabled';
查看数据库是否具有慢SQL隔离的能力。
其具体流程如下:
对于有些存在慢SQL风险的接口,可以通过Sentinel进行接口级别慢调用的限流降级,防止突然的大并发查询导致数据库打满。
限流:保护服务提供方不被过于频繁的调用压垮(防止QPS或占用线程过高)。调用方有时候存在类似OLAP拼接参数的慢查询(如内部小二后台查询系统),这种一般用于工单排查调用量较少且可以接受失败,可以针对这些调用应用进行限流处理:拒绝频繁调用
降级:保护服务调用方不被不可用的服务拖死(RT过高或异常过多)。当有耗时很高的慢SQL进入,虽然qps不高但是对资源占用也很严重,这种需要通过识别RT占用较高的服务进行降级处理:不可用的服务快速失败。
对于一些定时任务,如果对时间实时性要求不高的/分析类的/订正数据类的,建议都走OLAP数据库,如通过ODPS拉取离线数据进行数据分析/订正,以减少线上OLTP数据库的压力。
一些历史的数据可能查询频率不高,可以定期将线上数据库的历史数据转移到一些离线的数据库产品,比如ODPS、Hbase。
如果加了索引且达到了最优效果,由于数据量过大还是会存在性能问题,这时候可以考虑分表、分库分表。水平拆分一般以买家id作为分表键数据散列相对均匀,比如订单表、保单库,再通过数据库中间件进行路由。数据库的水平拆分让容量不再成为瓶颈,但同时在部分性能方面作出了牺牲(分布式事务, 跨库关联等),交易一般数据库现状是:
MySQL在实际的业务场景,特别是核心交易场景下,我们都是通过弱化关系模型, 通过事务拆分,来尽量规避分布式的事务, 也基本放弃跨库join,也不建议使用view,存储过程, 子查询等等
有时候虽然根据买家拆分,但是由于一些特殊场景,导致大买家热点,比如交易大买家问题,可以达到一个买家日均几十万单。针对这类问题可以对热点的买家做进一步的hash散列,把数据分散到各个库,在查询的时候再进行聚合。难点在于需要对全链路进行改造
如果数据库有做分库分表,查询没有禁止全表查:<property name="allowFullTableScan" value="false"/>
,当条件中没有分表键的情况下会全表查询。这时候数据库中间件有可能会在分析阶段将查询拼接成一个大的SQL包含多个表的union,有可能导致慢查询。
对于这种扫描类的查询,涉及多个表可以通过Hint对语句进行定制,可以在Hint中指定分表键,它会影响数据库中间件路由规则而不影响SQL语句本身。
hint除了可以指定分库分表的表还可以指定查询主库还是备库、强制使用索引等,可以根据需要进行策略优化
如果存储没有对事务有很大的依赖或者有文本检索需求又或者存在没法解决的热点用户,可以考虑将MySQL替换为ElasticsSearch一类的存储以提升查询性能和多样性。
以ElasticsSearch为例
优势:高扩展不需要分库分表天然支持分布式、数据量大压缩能力好、数据结构灵活
劣势:不支持事务、没有外键之类的数据约束,成本高
为数据关系约束考虑,存储最好还是以MySQL等关系型数据库为主要存储,ElasticsSearch、Redis等作为辅助存储,将MySQL数据同步到辅助存储以满足高效查询、文本检索等能力
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。