MySQL慢查询日志实战:配置、分析与慢SQL优化 1. 先把慢这个词定义清楚阈值背后的取舍逻辑线上告警响起来第一反应往往是哪个SQL慢了。但真打开慢查询日志你会发现日志里躺着的语句可能几十万条随便翻一眼都是慢SQL反而不知道该动谁。问题不在日志在于一开始就没搞清楚这份日志的采集口径是怎么定义的——什么算慢、什么会被记下来、什么根本进不来。口径不清后面所有分析都是瞎猜。1.1 long_query_time 不是拍脑袋定的数long_query_time是慢查询日志的第一道门槛只统计执行时间超过这个值的语句。它默认是 10 秒这个默认值在今天的业务里基本没有参考价值——10 秒的语句早就把连接池打满了等它进日志故障已经发生了。所以我一般会把它压到 1 秒甚至 0.5 秒。但往下压不是没有代价的。这个参数支持微秒级精度你可以写成long_query_time 0.1日志量会成倍上涨。我见过一个日均两千万次查询的订单库阈值从 1 秒调到 0.1 秒之后慢日志从每天 30MB 涨到 2.4GB单是日志落盘就吃掉了不少 IO 带宽。比较务实的做法是分层场景建议阈值理由核心交易链路0.1 ~ 0.3 秒用户可感知且这类接口对延迟最敏感一般业务查询0.5 ~ 1 秒兼顾排查覆盖面和日志体积报表/批处理库2 ~ 5 秒本身就是长查询阈值太低会淹没信号临时排查某个接口会话级临时设置不影响全局最后一行是很多人不知道的用法long_query_time是global session 双作用域的变量。排查某个特定接口时可以在那个连接上单独设SET SESSION long_query_time 0.05; -- 跑一轮压测或复现操作 -- 用完自动失效不影响其他连接这招在复现偶发慢的时候特别好使不用为了抓一条语句去动全局配置。1.2 还有哪些语句会被额外记进来只靠耗时阈值筛选有个致命盲点扫了一千万行只返回一行、耗时 0.4 秒的语句永远进不了日志但它在高并发下把 buffer pool 冲刷得一团糟是典型的隐形杀手。所以 MySQL 还提供了几个补充开关log_queries_not_using_indexes ON没走索引的语句一律记录。这个开关是把双刃剑小表全表扫描也记、SHOW类语句也记很容易把日志冲爆。真要用的话一定配log_throttle_queries_not_using_indexes它限制每分钟最多记录多少条同类语句默认 0 表示不限制我一般设成 60。min_examined_row_limit N扫描行数少于 N 的语句不记录。它是用来减噪的比如设成 100那些扫几十行就返回的语句就不占日志了。log_slow_admin_statements ON把ALTER TABLE、ANALYZE TABLE这类管理语句也纳入统计。DDL 慢起来能锁表几十分钟值得记录。log_replica_statements老版本叫log_slow_slave_statements主从架构下从库回放执行的语句默认不记录需要单独打开。这几个参数组合起来才构成一份有分析价值的日志。只调一个long_query_time你拿到的只是冰山露出水面的那一角。2. 日志往哪写FILE 和 TABLE 的性能账要算明白参数调完下一步是决定日志落到哪里。log_output有三个取值FILE、TABLE、NONE默认是FILE。很多人看到TABLE能直接SELECT查就改成了它图省事结果在高并发下把库拖得更慢了——这个坑值得单独说清楚。2.1 两种落盘的机制差异log_output FILE是把记录追加写到slow_query_log_file指定的文件里走的是顺序追加写开销主要在磁盘 IO而且是顺序 IO对数据库本身干扰很小。log_output TABLE是把记录写进mysql.slow_log这张表默认引擎是 CSV。CSV 引擎不支持索引、不支持事务、每次写入都要重新打开文件并发写会互相抢锁。我实测过一个 QPS 三千左右的库切到TABLE之后慢日志自身产生的锁等待时间一度占到整体响应的 5% 以上。所以我的建议很明确生产环境一律用FILE。需要查询分析用离线工具解析文件别让数据库自己扛这个活。TABLE唯一的合理场景是临时排查——比如你手边没有服务器文件权限想快速看两眼那就开个十分钟看完立刻切回FILE。2.2 生产环境开关日志的正确姿势慢查询日志的开关和路径改动建议不要用SET GLOBAL一改了之。因为SET GLOBAL只在运行期生效重启实例后全部还原下一次故障排查时你会发现日志又没了。正确的做法是写进配置文件让它持久化[mysqld] slow_query_log 1 slow_query_log_file /var/log/mysql/slow.log long_query_time 0.5 log_queries_not_using_indexes 1 log_throttle_queries_not_using_indexes 60 min_examined_row_limit 100 log_slow_admin_statements 1 log_output FILE改完配置后有两种生效方式重启实例或者用SET GLOBAL在线把同样的值设一遍重启后配置文件的值会接管。我通常选后者避免重启带来的业务抖动。这里有个容易忽略的细节日志文件所在目录必须对 MySQL 进程的属主可写而且在部分发行版上还会受安全模块的文件路径限制。如果开完日志发现文件压根没生成先去看错误日志大概率是权限或路径策略的问题而不是参数没生效。2.3 别忽视日志本身的资源占用慢查询日志的写盘是语句执行完之后发生的也就是说它不会让语句变慢但它会持续消耗 IO 和磁盘。上面提到的那类高流量库日志一天几个 GB如果没人清理一个月就能把/var撑爆。磁盘写满之后MySQL 可能直接进入只读状态这个后果比慢SQL本身严重得多。我的固定做法是三步日志放到独立分区、配logrotate每天切割并保留 7 到 14 天、切割后用mysqladmin flush-logs或 MySQL 8.0 的FLUSH SLOW LOGS让进程重新打开新文件。这三步做完日志才算是可持续的。一个判断日志是否失控的简易标准如果slow.log每小时增长超过 200MB说明阈值或log_queries_not_using_indexes设置过宽先收紧再分析。3. 逐字段读懂一条慢日志记录打开文件看到的第一屏通常长这样# Time: 2024-06-11T09:22:41.318521Z # UserHost: app_rw[app_rw] 10.0.3.17 [] Id: 882134 # Query_time: 2.417890 Lock_time: 0.000132 Rows_sent: 20 Rows_examined: 1834620 SET timestamp1718097761; SELECT id, order_no, amount FROM t_order WHERE merchant_id 9012 AND status 3 ORDER BY create_time DESC LIMIT 0, 20;大部分人只会看Query_time其实这一小段里藏的信息量远超想象每一项都能指向不同的根因。3.1 头部三个字段的解读顺序# UserHost加上Id给出了执行者身份和线程号。这点在做归因时非常关键同一条SQL来自定时任务连接和来自线上接口连接处理优先级完全不同。如果一条慢SQL几乎全部来自某个报表账号那你优化它的收益就很有限——它不占用户请求的链路。反过来如果集中在应用连接池那就是实打实的用户体验问题。Query_time是语句从开始执行到结束的总耗时包含等待锁的时间。Lock_time是其中花在等锁上的部分。这两个值的关系能直接分流排查方向Lock_time接近Query_time比如 Query 2.4 秒、Lock 2.3 秒那这条语句本身不慢是它要改的行被别人占着。方向应该是去找持锁的事务看是不是有大事务、有没有FOR UPDATE长时间不提交。Lock_time接近 0 而Query_time很高才是真正的语句本身有毛病往索引和写法上查。Rows_sent和Rows_examined是一对组合。前者是最终返回给客户端的行数后者是存储引擎实际扫描的行数。上面那条语句扫了 183 万行只返回 20 行这个比例就是典型的索引没吃到正确的过滤条件。3.2 判断效率的一个经验比值我给团队定过一条简单的筛选线Rows_examined / Rows_sent 1000且扫描行数超过 10 万就优先看。上面例子是 9 万多倍必然有问题。但要注意反例聚合查询天然就是扫很多行返回少数行。SELECT COUNT(*)、GROUP BY这类语句比值天然很高不能一刀切。判断标准要结合语句语义看它理论上最少需要扫多少行再和实际扫描行数对比。3.3 打开 log_slow_extra 拿到更细的现场MySQL 8.0.14 之后多了一个log_slow_extra参数注意它只对log_output FILE生效。打开之后每条记录会追加一大批统计其中最值钱的是这几项字段含义排查用途Read_rnd_next全表/全索引扫描读到的下一行次数数值巨大基本等于全表扫Read_key通过索引读行的次数衡量索引回表量Sort_rows排序处理的行数判断 filesort 规模Sort_merge_passes排序归并趟数大于 0 说明超出排序缓冲区Created_tmp_disk_tables落到磁盘的临时表数量大于 0 说明内存临时表不够用Bytes_sent返回给客户端的数据量揪出返回巨量数据的语句Sort_merge_passes这个指标我特别看重。它是 0说明排序完全在内存里完成一旦大于 0就意味着排序过程在内存和磁盘之间来回倒数据性能会断崖式下跌。调大sort_buffer_size能缓解但更根本的解法是让ORDER BY走索引顺序避免排序发生。4. 从几十万条记录里挑出真正该改的那几条日志有了、字段会看了接下来是最考功力的一步排序。默认按文件顺序看等于随机抽奖。4.1 为什么不能按单次耗时排序这是最常见的思维误区。一条耗时 30 秒的报表SQL每天跑一次总共占用数据库 30 秒而一条耗时 0.6 秒的语句每分钟被调用 8000 次一天累计占用 80 分钟。谁在拖累系统答案很明确。所以正确的排序维度不是单次耗时而是累计耗时 平均耗时 × 执行次数。慢查询日志本身是按事件记录的不带聚合所以你必须借助工具做摘要。4.2 pt-query-digest聚合画像的首选Percona Toolkit 里的pt-query-digest是这类工具里最成熟的。一条命令就能拿到按总耗时排序的TOP列表pt-query-digest --limit 100% --order-by Query_time:sum \ --report-format profile \ /var/log/mysql/slow.log /tmp/slow_report.txt输出分三块整体概况、按总耗时排序的语句画像、每条语句的详细样本。看第二块的时候我会关注三个数Response time 占比这条语句占总慢查询时间的百分比。占比超过 10% 的基本都要动手。Calls调用次数。次数极高说明它在下游被反复触发减少调用次数可能比优化语句本身收益更大——比如加缓存。pct 95 / pct 99 / max分布尾部。如果平均值不高但 pct 99 很高说明慢是偶发的通常和锁、IO 抖动、连接池排队有关而不是语句写法问题。平均值高、尾部也高才是稳定的写法问题。--order-by这个参数非常好用除了Query_time:sum还可以换成Query_time:cnt按次数、Rows_examined:sum按扫描量。我一般会跑三遍三份TOP列表交叉看。4.3 官方工具的轻量替代如果不想装额外组件MySQL 自带的mysqldumpslow也能用只是分析维度糙一些# 按总耗时排序取前 10 mysqldumpslow -s t -t 10 /var/log/mysql/slow.log # 按平均耗时排序只过滤 SELECT mysqldumpslow -s at -t 10 -g SELECT /var/log/mysql/slow.log # 按扫描行数排序 mysqldumpslow -s r -t 10 /var/log/mysql/slow.log它的短板很明显数字和字符串字面量会被统一成N和S某些情况下会把语义不同的语句合并到一起也没有 pct 分位数。作为快速摸底够用深度分析还是得上pt-query-digest。4.4 无索引语句和已聚合视图的互补视角慢日志是按执行事件记录的看不到当前库上最贵的语句模式这种全局视图。这时候sys库可以补位它的数据来自performance_schema是聚合后的结果-- 总延迟最高的 10 个语句模式 SELECT query, exec_count, total_latency, avg_latency, rows_examined_avg, rows_sent_avg, full_scan FROM sys.statement_analysis ORDER BY total_latency DESC LIMIT 10; -- 全表扫描的语句 SELECT * FROM sys.statements_with_full_table_scans LIMIT 10; -- 冗余索引和从未被使用的索引 SELECT * FROM sys.schema_redundant_indexes; SELECT * FROM sys.schema_unused_indexes;sys.statement_analysis里的full_scan字段特别有价值——它直接告诉你这条语句模式是否走了全表扫描不用自己去EXPLAIN验证。而schema_unused_indexes是反向优化的入口索引不是越多越好只写不读的索引会拖慢写操作该删就得删。5. 把根因钉死EXPLAIN、统计信息与优化器决策从日志里挑出候选语句之后别急着改SQL。先让数据库自己把执行计划吐出来看清它到底是怎么执行的。很多优化失误都是因为跳过这一步凭直觉加索引结果加了没用甚至更慢。5.1 EXPLAIN 里我先看哪几列EXPLAIN FORMATTRADITIONAL SELECT id, order_no, amount FROM t_order WHERE merchant_id 9012 AND status 3 ORDER BY create_time DESC LIMIT 0, 20;输出列里我的扫视顺序是固定的type访问类型。ALL是全表扫描index是全索引扫描这两个是红线range、ref、eq_ref、const依次变好。看到ALL就要往为什么没用索引上问。key实际选择的索引。如果是NULL说明优化器判定不划算或者没有可用索引。possible_keys候选索引。如果这一列有值而key是 NULL那是有索引但没选问题在优化器成本估算如果possible_keys也是空那就是根本没建对的索引问题在索引设计。这两种情况后续处理完全不同。rows预估要扫描的行数。和慢日志里的Rows_examined对比差距巨大说明统计信息不准。filtered按条件过滤后剩多少比例。低值配合大rows说明过滤条件没走到索引上。Extra信息最密集的一列。Using filesort需要额外排序、Using temporary用了临时表、Using index conditionICP通常是好事、Using join bufferJOIN 没走索引、Using MRR。MySQL 8.0.18 起还有个EXPLAIN ANALYZE它会真正执行语句并给出各阶段的实际耗时和实际行数比预估版的rows靠谱得多。排查复杂 JOIN 时我几乎只用它。但要注意它会真实执行语句别拿它去跑没加限定的UPDATE。5.2 统计信息不准会让优化器犯傻rows这一列的估算依据是索引的基数统计而这个统计是抽样得来的会随时间漂移。典型的症状是语句本身没问题索引也建了但EXPLAIN显示type ALL因为它算错了。处理手段从轻到重-- 重新采样索引统计 ANALYZE TABLE t_order; -- 8.0 支持给特定列建直方图对倾斜严重的列效果显著 ANALYZE TABLE t_order UPDATE HISTOGRAM ON status, merchant_id WITH 128 BUCKETS;直方图这个功能知道的人不多但它解决了一类老问题数据分布严重倾斜时等值查询的基数估算失准。比如status字段 99% 的行都是 1只有 1% 是 3优化器按平均数估就会走错路。建直方图之后它对 3 这种低频值的估算会准很多。还有个相关参数eq_range_index_dive_limit默认 200。等值条件的组合数小于这个值时优化器会用索引下探逐个取值精确计算更准但更慢超过之后退回抽样估算更快但更粗。大表上跑大批量IN查询时这个值会显著影响计划选择值得关注。5.3 优化器为什么不选那个看起来更优的索引这是我被问得最多的问题。答案是唯一的优化器比较的是成本不是你想要什么。成本模型基于 IO 次数和 CPU 代价的加权估算而估算依赖统计信息。要看清它的推理过程打开optimizer_traceSET optimizer_trace enabledon; SET optimizer_trace_max_mem_size 1048576; -- 在这里执行待分析的语句 SELECT id FROM t_order WHERE merchant_id 9012 AND status 3; SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace enabledoff;返回的 JSON 里rows_estimation段会列出每个候选索引的估算行数和评估代价considered_execution_plans段会给出最终选择及原因。我见过的最常见情形是走 A 索引回表次数多回表被认为很贵于是优化器宁可全表扫——这在merchant_id过滤性差、create_time过滤性好的场景下特别常见。解法不是加更多索引而是建覆盖索引减少回表把回表成本降下来。一个实操提醒optimizer_trace有内存上限追踪复杂语句时容易截断。排查单条语句时建议先把它单独跑一遍不要夹在一批查询里。6. 几类高频慢SQL的真实改法与原理前面讲的是怎么找到问题这一节讲找到之后怎么改。下面这几类是我在实际项目里遇到频率最高的每一类的改法背后都有明确的原理理解了原理才不至于照抄。6.1 隐式类型转换索引在但用不上这是最隐蔽的一类。表里order_no是varchar(32)应用传参时拼成了数字-- 索引失效 SELECT * FROM t_order WHERE order_no 20240611000123;原因是比较双方类型不一致时MySQL 会做类型转换。字符串和数字比较规则是把字符串转成数字再比。这就意味着列上套了一层隐式函数B 树的有序性用不上索引直接失效退化成全表扫描。同一类问题还有字符集不一致。两个字符集不同的列做 JOIN或者在WHERE里混用不同排序规则的字面量同样会导致转换、索引失效。这类问题在日志上的特征是Rows_examined接近表总行数但EXPLAIN里possible_keys明明有索引。看到这个组合第一反应就该去查字段类型和字符集。6.2 函数包列与前缀通配符-- 索引失效列上做了运算 SELECT * FROM t_order WHERE DATE(create_time) 2024-06-11; -- 改成可走索引的范围查询 SELECT * FROM t_order WHERE create_time 2024-06-11 00:00:00 AND create_time 2024-06-12 00:00:00;原理一样DATE(create_time)是行级函数每行都要算一次索引顺序无法利用改写成常量区间的范围查询优化器就能用 B 树定位到起点顺序扫。这个改写是等价的不会漏数据比BETWEEN更稳妥的是左闭右开——BETWEEN两端都闭遇到带小数秒的时间戳边界容易出偏差。前缀通配符LIKE %abc同理左模糊必然全扫。这类需求通常要靠搜索引擎或者倒排索引方案解决不要指望在 MySQL 里靠 SQL 技巧绕过去。6.3 深分页越翻越慢的老问题-- 翻到第 50 万页慢得离谱 SELECT * FROM t_order ORDER BY id LIMIT 10000000, 20;LIMIT的语义是先取出前 1000 万零 20 行再丢掉前面 1000 万行。所以页码越深扫描量线性增长。可行有两种-- 方案一延迟关联先在索引上分页拿主键再回表取数据 SELECT o.* FROM t_order o INNER JOIN ( SELECT id FROM t_order ORDER BY id LIMIT 10000000, 20 ) AS t ON o.id t.id; -- 方案二书签式翻页记住上一页最后一条的 id SELECT * FROM t_order WHERE id 10000000 ORDER BY id LIMIT 20;方案一的原理是让分页动作只在聚簇索引或者覆盖索引上完成避免了大量无谓回表方案二是从根上避开深偏移代价是不能随机跳页。实际业务里如果前端是下一页的交互方案二最优如果需要跳页方案一更合适。6.4 JOIN 的驱动顺序和连接字段JOIN 慢通常有两个来源。一是连接字段没索引导致被驱动表每次都要全扫EXPLAIN的 Extra 会显示Using join buffer。给被驱动表的连接列加上索引这个提示就消失了。二是驱动表选错。MySQL 的 Nested Loop Join 里驱动表越小、进内层循环的次数越少、总代价越低。理论上优化器会自己选但统计信息偏差时可能选错。这时候可以用STRAIGHT_JOIN强制顺序或者用JOIN的书写顺序配合ANALYZE TABLE修正统计。需要提醒的是JOIN 的字段类型和排序规则必须完全一致否则连接条件里会发生隐式转换索引直接失效——这一条和 6.1 是同一个根因只是发生在两表之间更难察觉。6.5 子查询与批量更新的写法陷阱一个经典问题是更新一张表条件是它自己在某个子查询里的结果-- 会报 ERROR 1093不能在子查询里引用正在更新的表 UPDATE t_order SET status 9 WHERE id IN (SELECT id FROM t_order WHERE create_time 2024-01-01);MySQL 的限制是不能在UPDATE/DELETE的子查询里直接引用目标表。绕过的方法是把子查询包成派生表让优化器把它物化成临时结果集UPDATE t_order SET status 9 WHERE id IN ( SELECT id FROM ( SELECT id FROM t_order WHERE create_time 2024-01-01 ) AS tmp );另一种更值得推荐的做法是用JOIN改写UPDATE t_order o INNER JOIN ( SELECT id FROM t_order WHERE create_time 2024-01-01 LIMIT 5000 ) AS tmp ON o.id tmp.id SET o.status 9;这里多加的LIMIT是关键——大事务是慢SQL之外的另一大杀手。一次性更新几十万行会持有大量行锁、生成巨大的 undo、主从延迟飙升。拆成多批每批几千行业务上完全无感但对数据库的冲击天差地别。这个习惯我是吃过亏之后才养成。7. 把慢日志变成可持续运转的分析资产前面讲的都是一次性的排查动作。但慢SQL不是一次性的问题——只要业务在迭代新的慢SQL就会源源不断冒出来。所以真正有价值的做法是把它做成一套常态化机制而不是等出事再翻日志。7.1 日志切割、保留与归档第一步是让日志不失控。我惯用的logrotate配置大概是这个结构/var/log/mysql/slow.log { daily rotate 14 missingok notifempty compress delaycompress create 640 mysql mysql postrotate mysqladmin --defaults-file/etc/mysql/rotate.cnf flush-logs endscript }几个细节值得说。delaycompress让最近一份归档不压缩方便快速查看postrotate里的flush-logs必须执行否则 MySQL 会继续往已经改名的文件句柄里写新文件永远是空的——这个坑我踩过一次当时盯着空白的slow.log怀疑了半天参数配置。归档目录放到独立分区保留 14 天够回溯一个迭代周期的问题。7.2 用 Python 把日志汇总成统计口径pt-query-digest是命令行工具不方便做长期趋势对比。想每天看一眼今天TOP10慢SQL和昨天比变化如何自己写个解析脚本更灵活。核心逻辑就是把日志按块切分抽出头部字段然后按归一化后的语句模板聚合。import re from collections import defaultdict BLOCK re.compile( r# Time: (?Ptime[^\n])\n r# UserHost: (?Puser[^\[])\[[^\]]*\] (?Phost[^\s])[^\n]*\n r# Query_time: (?Pqt[\d.])\sLock_time: (?Plt[\d.])\s rRows_sent: (?Prs\d)\sRows_examined: (?Pre\d)\n r(?:#\s.*\n)* rSET timestamp\d;\n(?Psql.*?);\n, re.S, ) # 把字面量统一成占位符避免同一条语句被拆成成百上千个不同语句 LITERAL [ (re.compile(r(?:[^\\]|\\.)*), ?), (re.compile(r\b\d\b), N), (re.compile(r\s), ), ] def normalize(sql: str) - str: for pat, rep in LITERAL: sql pat.sub(rep, sql) return sql.strip().lower() def parse(path): text open(path, encodingutf-8, errorsignore).read() stats defaultdict(lambda: {cnt: 0, sum_qt: 0.0, max_qt: 0.0, sum_re: 0, sum_rs: 0, sample: }) for m in BLOCK.finditer(text): d m.groupdict() key normalize(d[sql]) s stats[key] qt, re_, rs float(d[qt]), int(d[re]), int(d[rs]) s[cnt] 1 s[sum_qt] qt s[max_qt] max(s[max_qt], qt) s[sum_re] re_ s[sum_rs] rs if not s[sample]: s[sample] d[sql].strip()[:200] return stats if __name__ __main__: stats parse(/var/log/mysql/slow.log) rows sorted(stats.items(), keylambda kv: kv[1][sum_qt], reverseTrue) for key, s in rows[:10]: print(f次数{s[cnt]:6} 总耗时{s[sum_qt]:.1f}s f均耗时{s[sum_qt]/s[cnt]:.3f}s f扫描/返回{s[sum_re]//max(s[sum_rs],1)}) print(f {s[sample]}\n)这段脚本的关键设计在normalize函数。如果不对字面量做归一化每条语句都会因为参数不同而成为独立的key聚合就失去了意义——这正是mysqldumpslow内部做的事。数字替换成N、字符串替换成?之后同一类语句才会归到一组。把这套脚本挂到定时任务上每天生成一份统计再写进一张记录表就能画出趋势曲线某条语句的总耗时是不是在涨、扫描行数有没有恶化。这比出事了再翻日志要主动得多。7.3 每天五分钟的例行巡检工具之外习惯更重要。我固定每天花几分钟看几个数形成肌肉记忆昨天慢查询总条数和前 7 天均值比涨了 30% 以上就要查原因。按总耗时排序的前 5 条有没有新面孔。有没有Lock_time / Query_time 0.5的记录有就去看当前长事务。mysql.slow_log或者日志文件体积有没有异常增长。这套动作花不了几分钟但能在故障发生前一两天就发现问题苗头。慢查询治理最大的敌人不是技术难度而是没人看。8. 几个我实际踩过的坑供你少走弯路理论说完了最后把几个真实的失败经验摊开讲这些在文档里基本找不到。第一个坑开完日志第二天磁盘满了。原因是我把阈值调到 0.1 秒又同时打开了log_queries_not_using_indexes却没设节流。没走索引的小语句疯狂刷日志一天 40GB。教训是这两个参数永远不要同时无节制地开log_throttle_queries_not_using_indexes是必须配的保险丝。第二个坑慢日志里找不到那条明明很慢的语句。排查了很久才发现那条语句是从库上回放的而log_replica_statements默认是关的。主从架构下一定要记得单独打开这个开关否则你会以为慢SQL消失了。第三个坑加完索引反而更慢了。现象是写接口的延迟涨了。原因是新加的索引让这条语句有了两个可选路径优化器在某些参数下选错了而且多出来的索引还增加了写放大。后来用optimizer_trace确认了选择逻辑改成覆盖索引并删掉冗余索引才稳定。这件事让我记住一条加索引之前先看possible_keys评估会不会改变现有语句的计划选择。第四个坑改名日志文件后新文件一直是空的。就是前面提到的flush没做。手动mv文件之后必须让 MySQL 重新打开文件句柄否则它还在往老文件写。第五个坑把long_query_time设得太低导致缓冲区溢出。有个很反直觉的点——在极高并发的实例上把long_query_time设成 0.01 秒这样的极端值会让海量语句进入慢日志记录流程虽然单条开销很小但累加起来会挤占正常的查询处理资源。极端阈值只在短时间排查时用不要长期挂着。说到底慢查询日志是个事后工具它只能告诉你哪儿慢过。想更进一步得配合performance_schema的实时统计、配合代码层的埋点和链路追踪才能把慢精确定位到某行代码、某个接口。但无论上多少高级工具先把慢查询日志吃透——知道它记录什么、不记录什么、每个字段什么意思、日志怎么聚合怎么比对——这些都是后面一切分析的地基。地基不牢再花哨的看板也只是把一堆没归类的数据画成图而已。