多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

慢SQL治理实战:从慢日志分析到索引与分片键设计

慢SQL治理实战:从慢日志分析到索引与分片键设计 做数据库维护时间长了你会发现一个规律线上大多数非致命性的性能故障最后都能顺藤摸瓜找到几条慢SQL。慢SQL治理这个词听起来很大但拆开无非三件事把慢日志捞出来分析清楚把索引设计得刚好把数据分片键选得合理。这篇文章就从这三个方向展开讲一讲我用mysqldumpslow做慢日志分析、用执行计划指导索引优化、以及在分库分表场景下选择分片键的实践思路适合数据库DBA、后端开发和对性能优化感兴趣的同学参考。1. 慢SQL治理的总体思路与核心价值1.1 一个慢SQL是怎么拖垮整个实例的很多人以为慢SQL只是“查询时间长了点”实际上它的破坏力远不止于此。我遇到过一个订单系统的线上事故某天下午高峰期数据库连接数突然打满应用层不断报连接超时后台面板几乎瘫痪。排查到最后根源是某个统计接口的一个聚合SQL单次执行要12秒但它被调用得很频繁。每次执行期间都占着一个连接不释放积少成多几百个连接全被这种慢SQL占满了正常业务请求反而排队等不到连接。慢SQL还会引发连锁反应。锁等待、主从延迟、临时文件落盘、缓冲池被大查询污染这些都是一条慢SQL可以带出的问题。更麻烦的是慢SQL经常藏在大流量的业务代码里表面看是接口慢其实是数据库在扛不住。所以治理慢SQL本质上是防止数据库故障从“单点慢性病”升级为“全站急症”。这也是为什么每个上规模的团队都应该把慢日志采集、SQL质量审核和索引评审当成常态化工作而不是出了问题再救人。1.2 治理闭环发现、分析、优化、验证慢SQL治理不能是零散的打补丁要形成闭环。我自己在团队里推的流程是六步采集、分析、定位、优化、验证、回归。采集是指把慢日志、监控指标接到统一平台先保证“有问题能看得见”。分析是用mysqldumpslow这类工具把慢日志里的SQL按模板聚合排序找出哪几类语句占用了绝大多数时间。定位是拿EXPLAIN看执行计划判断有没有全表扫描、回表太多、排序没走索引、甚至锁等待。优化则是针对原因动手可能是加索引、调联合索引顺序、改写SQL或者干脆调整分片键和表结构。验证不是光看执行计划变好看要用压测或影子流量确认线上体感延迟真的降下来了。回归是把这条SQL加进监控白名单和代码评审的检查项防止它换个写法又回来。这套流程看着基础但坚持做下来绝大多数慢SQL都能被提前消化掉。后面几个部分我会把每个环节里最实操的东西展开说。2. 慢日志分析先学会用mysqldumpslow捞问题2.1 开启慢日志的推荐配置慢日志是治理的起点。我见过不少环境慢日志要么没开要么开了但门槛设得离谱最后完全没人看。先给一套比较稳的配置可以直接用在MySQL实例上[mysqld] slow_query_log ON slow_query_log_file /var/log/mysql/mysql-slow.log long_query_time 1 log_queries_not_using_indexes ON min_examined_row_limit 100简单解释一下这几个参数背后的考虑。slow_query_log是总开关slow_query_log_file指定日志路径。long_query_time设为1秒是多数团队公认的“仪表盘”太短会产生海量日志太长会漏掉一些中慢查询。log_queries_not_using_indexes负责把没走索引的查询也记下来哪怕它执行不到1秒——这类SQL往往是潜在炸弹。min_examined_row_limit设为100是让优化器只记录扫描行数超过100的行为避免把只扫几行的简单查询也灌进日志。需要注意的是long_query_time支持动态修改比如SET GLOBAL long_query_time 1;但当前已建立的会话可能还需要SET SESSION long_query_time 1;才能生效。改完可以用SHOW VARIABLES LIKE long_query_time;确认。另外日志文件要配合轮转我习惯让系统按天切分慢日志保留30天防止单文件膨胀到几个G之后连mysqldumpslow读起来都吃力。2.2 mysqldumpslow核心用法与输出解读mysqldumpslow是MySQL自带的分析工具不需要额外安装第一次上手就能用。它的核心价值是把慢日志里成千上万条SQL按照“去掉具体值之后的模板”聚合成有限几类然后按总耗时或者次数排序。这样你看到的不是一条条零散的SQL而是“影响最大的几种坏味道”。我最常用的有三个参数组合# 按总执行时间排序取前20条 mysqldumpslow -s t -t 20 /var/log/mysql/mysql-slow.log # 按平均执行时间排序主要为了找单条特别慢的SQL mysqldumpslow -s at -t 20 /var/log/mysql/mysql-slow.log # 按执行次数排序筛出最频繁的SQL mysqldumpslow -s c -t 20 /var/log/mysql/mysql-slow.log-s后面的排序字段里c是次数t是总时间at是平均时间l是锁时间r是返回行数。实战中我基本只看三种先看t找出“总耗时最高的模板”再看c找“高频访问的模板”最后用at排除“虽然次数少但单次特别慢”的隐患。还可以用-g加正则过滤比如只分析某张订单表相关的SQLmysqldumpslow -s t -g t_order -t 20。实际输出长这个样子Count: 1286 Time2.48s (3190s) Lock0.01s (12s) Rows8205.2 (10551k), appuser[appuser]hostname SELECT * FROM t_order WHERE user_id S AND status S ORDER BY create_time DESC LIMIT N这行输出信息量很大Count1286说明有1286次相似查询Time2.48s是平均耗时括号里的3190s是这一类SQL的总耗时Rows8205.2表示平均每次要处理八千多行括号里是总量。最后一行SQL里的字符串和数字已经被替换成S和N这也是mysqldumpslow的默认行为——把同类模板归并起来。如果想看原始常量可以加 -a 参数。按我的习惯拿到TopN列表会先看两件事排名靠前的SQL是否集中在某几张核心表上以及它的Rows普遍很大还是量级很小。Rows很大说明扫描行数远超返回行数大概率索引选择有问题这类往往是优化收益最大的。2.3 慢日志的辅助分析手段mysqldumpslow够用但信息密度有限有时候还是需要更细的分析。如果你装了pt-query-digest可以直接生成一份报告pt-query-digest /var/log/mysql/mysql-slow.log它会输出Profile、每个查询指纹的执行次数、平均耗时、响应时间占比等还会帮你估算每条SQL的“排名”哪个需要优先处理一目了然。不过这个工具不一定在所有环境都有所以我会再推荐一个不依赖外部工具的方案开启MySQL的performance_schema之后可以直接查sys库或者events_statements_summary_by_digest表按DIGEST_TEXT聚合执行次数和总耗时。这种表比文件日志更结构化适合接进监控平台做自动告警。还有个小经验拿到慢日志不要只分析一次最好每天自动跑一遍mysqldumpslow把TopN结果写入一个历史表。长期积累以后能明显看出哪些SQL的耗时在逐渐上升——比如某张表数据量增长导致索引选择性下降这种趋势比一次性的突击优化更有价值。3. 索引优化从执行计划里找到真因3.1 一次订单列表慢查询的优化全程说一个很典型的案例。某个订单中心有一张订单表t_order线上慢日志显示有一条SQL频繁上榜SELECT * FROM t_order WHERE user_id 1001 AND status 1 ORDER BY create_time DESC LIMIT 20;表结构大致是这样的CREATE TABLE t_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, status TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, KEY idx_user (user_id) ) ENGINEInnoDB;这条SQL语义很简单查某个用户某种状态下的最近订单。但慢。EXPLAIN一看问题出在idx_user这个单列索引上typeref没问题但rows估算有12.8万行Extra里还有明显的Using where和Using filesort。原因也好解释。idx_user只能定位到user_id这条SQL还要在12.8万行订单里过滤status1然后在内存里做排序取出最新20条。等于每个符合user_id条件的记录都要走一遍最后还把一大块临时结果排个序浪费很严重。改造思路是做一个“等值在前、排序在后”的联合索引ALTER TABLE t_order ADD INDEX idx_user_status_time (user_id, status, create_time);这个索引的逻辑是先用user_id精确过滤用status精确过滤然后用create_time天然的有序性来满足ORDER BY create_time DESC。优化之后EXPLAIN的rows降到了36行Extra里不再出现Using filesort。单次执行时间从平均2.4秒降到几十毫秒。这里还能顺手提一个更进一步的优化如果业务只需要订单ID、状态和金额可以考虑把SELECT *改成只查询必要字段并把amount列加进索引形成覆盖索引Extra会变成Using index连回表都省了。但覆盖索引不是越多越好后面会讲它的代价。3.2 联合索引设计最容易踩的三个坑很多人知道“联合索引有最左前缀原则”但实际设计时还是容易踩坑。我总结三个最常见的问题。第一字段顺序只按等值条件不考虑排序和范围。联合索引不是把所有查询字段堆一起就行要按“等值字段在前范围字段居中排序字段放最后”的顺序排。比如上面这条SQL如果把索引建成(user_id, create_time, status)create_time用范围字段把后续字段隔断了status的过滤条件就没法在索引里有效完成。第二忽略排序方向和排序字段。MySQL 8.0支持索引的ASC和DESC混合排序如果SQL经常按create_time倒序可以建(user_id, status, create_time DESC)让B树的物理顺序和查询要求完全一致。否则优化器仍然可能额外做一次filesort。排序字段尽量放在联合索引的末尾但也别把多个排序字段堆在一起一个查询通常只需要用索引满足一个排序需求。第三留着冗余的旧索引不清理。加完新的联合索引之后旧的idx_user从语义上已经被完全覆盖了。如果线上同时留着两个索引写入时每个索引都要维护等于白白多付了写放大成本。尤其在订单这种高写入表上冗余索引的代价会随着数据量放大。我建议定期用information_schema.statistics去看重叠索引清理掉value明显覆盖在别索引里的旧索引。3.3 常见索引失效场景与检查思路慢SQL排查时最怕的不是没索引而是索引明明存在却不生效。我把高频踩雷的场景整理成一个参考表场景原因处理思路WHERE LEFT(create_time, 7) 2024-06索引列参与函数运算B树无法定位改为create_time BETWEEN 2024-06-01 AND 2024-06-30 23:59:59WHERE user_id 1001user_id是BIGINT隐式类型转换导致索引失效参数按字段类型传或统一规范WHERE order_no LIKE %ABC前导通配符最左匹配失效改为右匹配或引入全文/外部索引WHERE status 1 OR remark xOR两段条件难同时利用索引拆成UNION ALL避免全表合并索引字段为NULL且统计严重优化器认为走索引也捞不回足够数据考虑默认值并重新ANALYZE TABLE这里有一个很实用的排查习惯线上看到一个查询变慢别急着猜原因先EXPLAIN再看type、key、rows、Extra四个字段。type是ALL说明全表扫描key为NULL说明没命中任何索引rows远大于实际返回行数说明过滤性差Extra出现Using filesort或Using temporary说明排序或分组阶段有问题。只要这四项对照着看大多数索引问题都能定位到具体环节。4. 分片键设计从架构层消除慢SQL4.1 分片键选型的三条铁律当单表数据量到了几千万甚至上亿行即便索引设计合理也会开始出现瓶颈B树更深、缓冲池命中率下降、写放大明显、热点行锁竞争加剧。这时候就需要水平拆分成多个分片。分片方案里最灵魂的决定是分片键怎么选。我总结成三条铁律缺一不可。第一值域要宽且分布均匀。分片的本质是让数据均匀撒到各个分片上。如果用一个只有几种取值的字段比如status、类型字段那不管怎么分都只会有极少数分片有数据。常用的好分片键是用户ID、买家ID这类天然打散的数值或字符串。第二高频查询必须能带分片键。分片之后应用层要能根据一个值准确定位到目标分片。比如订单表按buyer_id分片那么所有查订单的入口都尽量带着buyer_id参数。如果查询条件里没有分片键中间件就只能把请求广播到所有分片再合并结果这种fan-out查询不但慢还会放大很多倍压力。第三选一个几乎不会变化的业务属性。分片键一旦确定数据在哪一片就固定了改动意味着跨片迁移。手机号、账号这类可能变更的字段不建议直接当分片键。订单号、会员ID、设备ID这类一旦生成就基本不变的值才值得作为分片键。4.2 典型分片反模式与代价我见过不少分片键选错的案例代价都非常直接。第一个反模式是拿低基数字段做分片键。曾经有一套系统按订单类型分片类型一共就三种。结果三片中一片存了80%的数据剩下两片几乎没有读写。热点分片的连接池先被打满而且type是会被业务更新的字段一旦类型变化数据就要跨片搬复杂度直接爆表。第二个反模式是按时间范围分片。时间分片看起来很好理解每个月一片写起来也方便。但它的致命问题是写入全部集中到当前月份的那一片上其他片完全闲着。尤其对于订单、日志这类“只写最近”的场景等于把一个巨大的热点堆在一个分片上性能还不如不分。而且业务查询经常是跨日、跨月、跨季度的范围查询跨片扫描避不开。第三个反模式是分片键和真实查询条件脱节。比如一张订单表选了主键id做分片键但线上所有高频SQL都是按user_id查订单。每次查询都要把请求广播到所有分片再合并结果效率奇差。分片键的价值在于路由脱节的键等于白选。选分片键还有一个常见争论用哈希还是用取模。理论上直接从业务ID取模也可以很均匀但如果后续分片数要扩容一次性迁移量会很大。我更推荐在最上层引入一层“虚拟分片”的概念比如先计算hash(buyer_id) % 1024得到一个虚拟分片号再把这个分片号映射到物理分片。未来扩物理节点时只要调整映射关系迁移成本可控得多。def pick_shard(buyer_id: str, virtual_shards: int) - int: # 用稳定的哈希算法映射到虚拟分片避免后续扩容时全量重算 return binascii.crc32(buyer_id.encode(utf-8)) % virtual_shards4.3 分片键与索引的配合方案很多人分片之后以为索引就不重要了这是误解。分片解决了单表体量问题但每个分片内部依然要依赖索引来定位记录。关键是分片键需要和分片内索引配合起来设计。最常见的设计是“分片键高频过滤字段”的联合索引。比如订单表按buyer_id分片分片内经常要查某个买家某个店铺的订单那索引可以设计成(buyer_id, shop_id)。查询同时带了buyer_id和shop_id时既能精确路由到分片又能在片内走索引非常高效。麻烦的场景是按买家分片但业务高频入口是按店铺查询订单。这种情况下应用层不可能在每个分片上遍历。一个可行的方案是维护一张轻量的映射表记录shop_id和buyer_id的关系。应用先查映射表得到买家ID集合再带上buyer_id去查订单分片。为了接受这些查询映射表自身也按shop_id分片形成“用一次额外查换来精准路由”的平衡。另一条路线是数据冗余把订单在“买家维度和店铺维度”各存一份通过异步同步保证一致性。这会让存储成本翻倍但查询体验最好。这类取舍没有绝对答案核心还是看业务的高频场景是什么分片键设计本质上是把架构选择放在读写模型上做权衡。5. 一个完整的治理复盘和避坑清单5.1 一次真实慢SQL事故的完整复盘拿一套交易中台的订单中心来说。某天监控告警订单查询接口P95从200ms涨到5秒数据库活跃会话数接近上限。第一反应就是去看慢日志mysqldumpslow按总耗时排序Top1是一条带有DATE(create_time)的统计SQLSELECT DATE(create_time) AS day, COUNT(*) FROM t_order WHERE create_time DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY day;这SQL本来不长但数据量有8000万行。EXPLAIN一看typeALL整表扫描。原因很典型在create_time列上套了DATE函数索引完全失效。这个统计逻辑还会在后台定时任务里高频调用每次扫描全表直接把IO和CPU拉爆。治理过程分三步走。先是应急和业务方对齐把定时任务频率降一半同时在SQL上临时加条件先保证系统恢复稳定。然后是根治把DATE(create_time)改写为create_time xxx AND create_time xxx的范围条件同时确认已有索引能覆盖范围查询。成本最低的代价只是把“按天分组”的意图用区间表达出来。最后是长效把这类函数包索引列的写法加入代码评审检查项从源头卡住。优化之后这条SQL从平均8秒降到120毫秒接口P95回到200ms以内。复盘结论也很有趣不是缺少索引而是索引被函数运算屏蔽了。这也解释了为什么我坚持要求团队在定位慢SQL时先EXPLAIN再判断要不要加索引而不是埋头加索引。5.2 慢SQL问题速查表把平时排查慢SQL遇到的现象和对应思路整理成一个速查表可以直接存下来当工具用现象可能原因排查建议慢日志中typeALL表无合适索引或索引失效EXPLAIN看key优先补等值过滤字段Extra出现Using filesort排序字段不在索引内让排序字段成为联合索引末尾列Extra出现Using temporaryGROUP BY/DISTINCT/UNION未优化改写SQL或建立支持去重/排序的索引单条慢但Count小深分页、大关联、大事务检查LIMIT offset是否过大改key pagination分片后某片读写集中分片键值域窄或范围分片优先换成高基数业务ID做哈希分片EXPLAIN正常但实际还是慢锁等待、IO瓶颈、资源争用查memory/disk状态排除锁和IO因素加索引后写入明显变慢索引粒度过细、冗余索引多用SELECT DISTINCT评估索引唯一性清理低价值索引排查时记住一个原则执行计划是“优化器认为怎么跑”实际耗时还受数据分布、缓冲池、锁、磁盘IO影响两者不一致时要继续往深处挖。但绝大多数情况先从执行计划看索引方向不会错。5.3 我建议形成的好习惯最后分享几个靠时间积累下来的习惯它们比任何一次救火都要有价值。第一个习惯是“建表即预演”。建表时顺手把业务方最常跑的几条查询用EXPLAIN跑一遍看看有没有全表扫描或filesort。很多团队等线上慢日志报出来才处理其实建表阶段就能解决大半问题。第二个习惯是“慢日志要看趋势”。不要只在告警时才打开慢日志分析每天都让定时任务把TopN慢SQL归档到一张结果表。数据积累一个月你会看到某张表的查询耗时随数据量上升的曲线提前发现问题。第三个习惯是“索引变更要走灰度”。大表加索引不一定是小事尤其是高写入的表。加索引期间可能造成长时间锁表或者主从延迟尽量用在线DDL工具并在低峰期执行。我曾经见过半夜加索引把复制延迟拉到几千秒的事故从那以后任何DDL变更都会先确认表大小和当前锁情况。慢SQL治理没有终点因为表会增长数据分布会变化业务查询模式也会演进。但只要你把“慢日志分析、索引设计、分片键规划”这三个习惯嵌进日常工作再多的突发性数据库质量问题都只是流程里的一个普通问题而已。
返回列表