ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL 8.0性能调优实战:从体检到参数优化的完整流程

MySQL 8.0性能调优实战:从体检到参数优化的完整流程

很多人拿到一台MySQL 8.0实例,第一反应就是找一份“优化清单”,把网上流传的几十个参数照着抄进去。我处理过不少性能问题,发现真正让数据库变慢的,往往不是参数没调到位,而是你上来就动了不该动的几项,结果把旧瓶颈压下去,新瓶颈又冒出来,而且更难定位。这篇文章不打算给你一份冷冰冰的参数大全,而是按一次完整的优化流程来写:先体检,再从容内存、日志、慢查询、并发几个方向逐个动手,每一步都用压测数据验证,最后留下回退路径。无论是刚接手业务的开发,还是有一定经验的DBA,都可以照着这个思路落地,至少不会再把线上实例改成事故现场。

1. 第一件事不是调参,而是把实例“体检”做完整

1.1 半小时内能拿全的配置与运行状态清单

我先说一个原则:在没搞清楚现状之前,不要改任何参数。改之前你需要三样东西:当前配置、当前运行状态、机器的系统层指标。

配置直接用下面这几条拿全:

SHOW VARIABLES LIKE 'innodb%'; SHOW VARIABLES LIKE 'max_connections'; SHOW VARIABLES LIKE 'binlog%'; SHOW VARIABLES LIKE 'long_query_time'; SHOW VARIABLES LIKE 'slow_query_log';

运行状态要看增量,不能只看累计值。比如Uptime是1000秒,Questions是10万,平均每秒100条,这没问题。可如果你只看那一瞬间的累计数字,根本不知道当前实例是在喘气还是已经趴窝。我习惯分两次取样,间隔60秒左右:

mysqladmin extended-status -r -i 60 -uroot -p

-r表示显示相对值,两次采样之间的差值才是这一分钟的真实变化。重点看这些状态值:

状态变量看什么
Threads_connected当前连接数,接近max_connections就有风险
Threads_running真正在执行语句的线程数,持续偏高说明并发堆积
Max_used_connections历史峰值连接数,判断连接压力
Questions每秒请求量,业务负载的风向标
Innodb_buffer_pool_read_requests / reads算buffer pool命中率
Created_tmp_disk_tables磁盘临时表数量,过多说明排序或关联有问题

系统层指标同样不能跳过。在MySQL机器上用一段时间的vmstat 3、iostat -x 3,看r(运行队列)、b(阻塞进程)、wa(IO等待)、磁盘%util和await。如果%util长期在80%以上、await明显高于设备标称值,那么调整日志参数或buffer pool可能比优化SQL更急迫。反过来,如果磁盘很闲但CPU跑满,那就是典型的SQL逻辑问题,先去查慢查询。

1.2 从系统指标反推MySQL的真实瓶颈

拿到这些数据之后,怎么判断瓶颈在哪?我的经验是看三组组合。

第一组:CPU高、磁盘不高、Threads_running高。说明数据库在大量计算或者大量扫描,绝大多数情况是索引缺失或者SQL写得太糙,先别动配置,去抓慢查询。

第二组:CPU不高、wa高、磁盘%util高。说明是IO瓶颈,优先看redo log容量、刷盘策略、buffer pool命中率,以及是不是有大量磁盘临时表。

第三组:内存吃紧、swap使用率上升、si和so频繁变化。这种情况最危险,多半是buffer pool或者每会话缓冲设置过大,实例处于OOM边缘。先解决内存,再谈什么性能调优。

顺带提醒一句,所有运行状态采样,尽量覆盖一个业务高峰周期,比如跑满15分钟到1小时。采样时间太短,很容易被偶发任务干扰,得出错误结论。之前某项目找我排查,头一天采样5分钟,Threads_running才个位数,第二天下午高峰直接飙到160多,差点把连接数打满。前半天的“体检报告”完全没有参考价值。

2. 内存配置不是越大越好:buffer pool与每会话缓冲的取舍

2.1 innodb_buffer_pool_size:按照数据量和命中率来定

内存类是MySQL调优的重头戏,首当其冲是innodb_buffer_pool_size,8.0里的默认值只有128MB,生产环境基本不够用。但“调大”不是拍脑袋调大,得按数据量来。

先看实例的整体数据量:

SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 2) AS total_gb FROM information_schema.tables WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys');

注意information_schema统计的是表和索引的“理论占用”,和实际文件大小有差距,但作为半定量参考足够了。再看当前命中率,用之前拿到的状态值算一下:

命中率 = Innodb_buffer_pool_read_requests / (Innodb_buffer_pool_read_requests + Innodb_buffer_pool_reads) * 100%

如果命中率低于99%,说明大部分数据读请求落到了磁盘,buffer pool可能偏小。我之前遇到过一台实例,数据总量120GB,热数据大约60GB,物理内存256GB,innodb_buffer_pool_size只配了8GB,命中率在95%附近晃荡。后来把pool扩到64GB,命中率稳定在99.8%以上,同样的业务SQL,响应时间肉眼可见地降下来。

但这里有个坑:不是所有机器都能把60%、70%的内存给buffer pool。如果是云主机,还要考虑同机其他进程、page cache、文件系统缓存。如果是主机上还跑着应用,更要留足余量。我通常建议:MySQL独占机器时,buffer pool可以按物理内存的50%~70%配;混部场景,先减掉其他进程的固定占用,再按剩余内存的60%左右配。

2.2 sort_buffer_size这类“每会话”缓冲为什么最坑

很多人知道sort_buffer_size、join_buffer_size、read_buffer_size这类参数,但没意识到它们和buffer pool完全是两回事。buffer pool是全局共享的内存池,这些参数是每个会话、每次操作都可能分配的私有缓冲。

sort_buffer_size默认只有256KB,看着不起眼。问题是如果有500个连接同时在排序,每个连接分配4MB到16MB,内存直接膨胀几个GB。而且MySQL对排序缓冲可以“超额分配”,一次排序用到上限甚至超过上限都很常见,根本不受你心里那个“4MB不大”的预期控制。

所以我的建议很简单:第一,保持小,不要盲目调到32MB、64MB。第二,如果你确实有大的排序需求,优先在SQL层面解决,比如优化order by、合理使用索引避免filesort,或者减少排序字段数量。调大排序缓冲只是掩盖问题,不是解决问题。

max_heap_table_size和tmp_table_size也有类似问题。它们的坑在于,如果SQL生成了大临时表,内存临时表会自动转为磁盘临时表,此时Created_tmp_disk_tables会飙升。这本身是个保护机制,但如果你为了“提升性能”盲目调大这两个值,内存压力会变得不可控。

2.3 一台实例总内存怎么粗算,防止被OOM

内存优化的底线是不被OOM killer盯上。我见过不止一次因为buffer pool调太大,或者奇葩的per-session缓冲配置,直接把实例整崩溃的。

给你一个粗算公式:

实例预估内存 ≈ innodb_buffer_pool_size + (read_buffer_size + sort_buffer_size + join_buffer_size) × 活跃连接数 + redo log容量 + binlog cache和临时表占用的均值 + performance_schema等系统层开销

举个例子:buffer pool 64GB,连接数1200,每个连接各缓冲平均加起来按1MB算,那么这部分就是1.2GB左右,redo log按4GB算,再加上performance_schema和系统缓冲,整体在70GB附近。如果物理内存只有96GB,这还能活;如果物理内存只有64GB,那铁定OOM。

粗算之后,再看实际使用。Linux下用free -g观察available和free的变化趋势,如果available持续走低、swap开始有进有出,先考虑调小buffer pool或者限制连接数,再去谈性能调优。实话说,一个频繁触发swap的MySQL实例,任何参数优化都是打水漂,因为内存页换进换出的代价远大于你省下的那点CPU。

3. 日志与磁盘刷盘:redo log、binlog和事务提交的参数组合

3.1 8.0的redo log容量变化与checkpoint的关系

MySQL 8.0在redo log配置上和5.7差别不小。8.0.30之前,innodb_log_file_size决定了单个redo日志文件大小,配合innodb_log_files_in_group控制总容量。8.0.30引入innodb_redo_log_capacity,统一控制redo log总容量,默认值并不大,写密集业务建议起步往2GB到4GB规划。

为什么redo log容量这么重要?你可以把redo log理解成一个环形缓冲区。事务提交前要先把redo日志写到磁盘,InnoDB后台再异步把脏页刷到数据文件。如果redo log太小,日志写满的时候就必须强制推进checkpoint、加速刷脏页,这时候磁盘IO会被瞬间打满,吞吐直接掉下来。典型表现是:高峰期间磁盘%util很高,但仔细看读写分布,很多是在刷脏页而不是服务业务请求。

检查redo log压力,除了看系统层IO,还可以看:

SHOW ENGINE INNODB STATUS;

关注LOG段落里的几个序号:Log sequence number和Log flushed up to。两者差距长期偏大,说明日志推进跟不上写入速度,redo log容量或者磁盘fsync能力是短板。

这里有个小经验:扩redo log并不能解决所有写入慢的问题,它只解决“日志空间不足导致的刷脏页阻塞”。如果磁盘本身是普通机械盘,先把容量调大,再结合下一节的刷盘参数一起看。SSD环境下,redo log容量的收益会更明显。

3.2 binlog参数怎么设才既保安全又不拖吞吐

binlog默认就是ROW格式,这点8.0做得很好,不用为了某些旧工具去改回STATEMENT。重点要关注两个东西:保留时长和刷盘频率。

binlog_expire_logs_seconds = 2592000 max_binlog_size = 512M sync_binlog = 1

binlog_expire_logs_seconds按秒控制binlog保留时间,比5.7的expire_logs_days更精细。max_binlog_size不是越越大越好,设得太大,单文件坏了损失范围大,恢复也不方便,512MB到1GB是常用区间。

sync_binlog默认是1,意思是每次事务提交都把binlog刷到磁盘。这保证了主从同步和崩溃恢复不丢binlog,但代价是每次提交多一次fsync。如果业务能容忍极端情况下丢失最近一小段binlog,可以考虑sync_binlog=0或者配成N,让MySQL攒几个事务再刷盘。但我要强调,这是安全性和性能的取舍,不是“调大就能变快”的白嫖选项。

做这类取舍之前,建议先查一下当前磁盘的fsync能力。如果SSD本身很快,sync_binlog=1带来的吞吐损失可能没那么明显,那就没必要冒险降安全级别。

3.3 innodb_flush_log_at_trx_commit和group commit的取舍

innodb_flush_log_at_trx_commit这个参数值得单独拎出来说。它有三个值:

取值行为安全级别
1每次事务提交都刷redo log到磁盘最高,不丢已提交事务
2每次事务提交只写入OS缓存,每秒刷盘中等,实例崩溃可能丢1秒数据
0每秒才把redo log刷入磁盘最低,性能最好,丢失窗口更大

默认值是1,这也是“双1”配置里的一半:innodb_flush_log_at_trx_commit=1和sync_binlog=1组合,是数据安全性的黄金标准。如果业务对丢失容忍度低,建议就保持双1,别为了那点吞吐去动它。

在双1前提下还想提升吞吐,可以走group commit。MySQL 8.0里有两个参数:binlog_group_commit_sync_delay和binlog_group_commit_sync_no_delay_count。简单理解,就是让多个事务凑一波再一起刷盘,用一点点延迟换更高的吞吐。线上经验是,把binlog_group_commit_sync_delay设在几百微秒到几毫秒级别,特别适合大量短事务的写入场景。但注意,这会让单个事务的提交延迟变大,如果业务对延迟敏感,需要压测后再决定。

我在实际调优中有一个习惯:写密集场景先看redo log容量是否够,再看刷盘组合是否需要动。大多数情况下,容量补足后,双1配置反而没那么难扛。

4. 慢查询优化的完整路径:从慢日志打开到EXPLAIN ANALYZE执行计划

4.1 慢日志配置与聚合分析的正确姿势

配置慢日志不算难,难的是分析。

slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = ON min_examined_row_limit = 100

long_query_time设成1秒起步,对于大多数在线业务已经够用。如果还想抓更细,可以调到0.5。这里有个反直觉的点:log_queries_not_using_indexes在生产分析阶段可以打开,但别长期开,因为很多正常的全表扫描查询会刷爆慢日志,反而干扰判断。

日志存储方式也有讲究。如果存成文件,方便mysqldumpslow这种工具做聚合;如果存成表,可以这样分析:

SET GLOBAL log_output = 'TABLE';

然后直接查mysql.slow_log表,按业务和时间维度聚合。更省事的方式是直接用performance_schema的语句汇总表,因为8.0里performance_schema默认开启,不需要额外配置:

SELECT SCHEMA_NAME, DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12, 3) AS total_sec, ROUND(AVG_TIMER_WAIT/1e9, 3) AS avg_ms FROM performance_schema.events_statements_summary_by_digest ORDER BY total_sec DESC LIMIT 20;

这个结果按“总耗时”排序,能一眼看出最拖系统的SQL是哪些。注意,DIGEST_TEXT里的参数是归一化之后的,所以同一条SQL的不同参数会聚合到一行,非常方便。

4.2 EXPLAIN ANALYZE:别再只盯着type和rows

慢日志定位到SQL之后,就该看执行计划了。很多人还在用传统EXPLAIN,看type是不是ref、rows大不大。这些信息足够,但不够直接。MySQL 8.0.18起提供了EXPLAIN ANALYZE,它可以真实执行语句,并给出每一步的实际耗时、实际行数和循环次数。

比如:

EXPLAIN ANALYZE SELECT user_id, status FROM t_order WHERE order_no = 'SO123456';

输出类似:

-> Index lookup on t_order using idx_order_no (order_no='SO123456') (actual time=0.129..0.246 rows=1 loops=1)

注意输出里的actual time和rows,是执行过程中的真实测量值,而不是优化器的估算值。这两者差距很大时,多半是统计信息过期,或者优化器估算模型失准。

用EXPLAIN ANALYZE有个大前提:它会真正执行语句。所以千万不能在慢日志分析完就直接跑到大表上跑EXPLAIN ANALYZE,尤其不能对DML语句直接执行,建议在只读从库或者事先准备好的事务里操作。我习惯先以EXPLAIN看框架,再用EXPLAIN ANALYZE验证关键SQL的实际开销,避免线上被一条全表扫描语句拖垮。

4.3 一个varchar隐式转换导致的索引失效案例

分享一个我印象很深的案例。某张订单表里order_no是varchar(20),索引建得好好的,但某天突然出现大量慢查询,语句长这样:

SELECT * FROM t_order WHERE order_no = 123456;

用EXPLAIN看,type是ALL,走了全表扫描。原因很典型:order_no列是字符串,等号右边是数字字面量,MySQL会先把列值隐式转换成数字再比较,索引也就失效了。

修复很简单,把参数改成字符串:

SELECT * FROM t_order WHERE order_no = '123456';

再跑一次EXPLAIN,type变成ref,扫描行数从几十万降到几条。这一类问题在开发联调阶段很难暴露,因为数据量小,全表扫描也很快。上线后数据一多,就是事故。

排查这类问题,我现在的固定动作是:拿到一条慢SQL,先看执行计划里有没有type=ALL或rows暴涨,再检查等号两边的类型是否一致。不要小看隐式转换,它和函数套列、前导通配符%xxx并列,是索引失效最常见的三大元凶。

5. 并发连接是最容易翻车的一环:max_connections别盲目调大

5.1 max_connections与Threads_running的区别

一提到连接数不够,很多人第一反应是把max_connections从默认的151调到1000、3000。这个思路要不得。

max_connections只是连接数的上限,它不等于能同时高效运行的线程数。更值得关注的是Threads_running,它代表此刻真正在CPU上执行语句的线程数。一台机器就算连接数3000,如果真正在跑的只有20,那这3000个连接只是占着内存睡觉。反过来,哪怕连接数只有200,如果Threads_running长期在80以上,你的CPU已经严重过载了。

每增加一个连接,MySQL至少要为它分配线程栈和会话级别的内存,还要承担上下文切换的代价。把max_connections盲目调到3000,往往结果不是业务跑得更快,而是内存先报警,接着连接风暴、锁等待连环爆发。

我建议观察两个比值:Max_used_connections / max_connections和Threads_running / CPU核数。前者接近1的时候,先做应用侧的连接收敛,再考虑调整上限;后者长期超过核数的1.5到2倍,就要先从SQL和索引入手降低单语句开销。

5.2 应用侧连接池才是并发控制的真正入口

很多连接数打满的问题,根源根本不在数据库,而在应用侧连接池配得太大,或者连接获取和归还的逻辑有问题。数据库侧调max_connections是治标,应用侧管好连接池才是治本。

连接池的核心就三个值:最大连接数、最小空闲数、获取连接超时时间。我见过某项目组为了提升吞吐,把一个服务实例的连接池最大连接数配到300,又部署了多个副本,总量直接超过数据库上限。结果业务高峰期,数据库Threads_connected打满,新连接超时,旧连接还在跑,最后整个服务雪崩。

在数据库侧排查时,可以用一条命令快速看清连接状态:

SHOW PROCESSLIST;

如果发现大量Sleep连接堆积,很可能就是连接池空闲连接没回收,或者连接生命周期配置过长。这时候优先调整应用侧参数,把最大连接数降到一个合理范围,比如单个实例50到80,再设置一个较短的连接超时时间。数据库侧再配合wait_timeout控制空闲连接回收,通常能腾出大量资源。

5.3 一次连接风暴触发的锁等待排查记录

那次连接风暴我印象很深。现象是业务侧大量报错,数据库日志里出现“Too many connections”,同时SHOW PROCESSLIST看到几十个线程卡在Waiting for table metadata lock和行锁等待上。

排查顺序是这样的:

  1. 先看SHOW ENGINE INNODB STATUS的TRANSACTIONS段落,找到长时间未提交的事务。
  2. 查information_schema.innodb_trx,按trx_started排序,找到最早开启的事务线程。
  3. 用sys.innodb_lock_waits视图看阻塞链,锁定是谁堵住了谁。

最后定位到的原因很有代表性:一个批量任务开启事务后,某一步查询走了全表扫描,跑了很久没提交;后面一大片正常业务SQL都要访问同一批行,全部被这个长事务卡住。再加上连接数配得高,越来越多请求挤进来,连接池也不断重试,数据库负载彻底失控。

处理办法分两步:先把长事务kill掉恢复业务,再把应用侧连接池上限收紧,同时给批量任务加上分批提交逻辑。数据库本身没改任何参数,问题就解决了。这个案例给我的教训是:高连接数会放大任何一处的短板,不管短板是慢SQL、锁竞争还是磁盘抖动。所以处理连接压力,千万别只盯着max_connections这个数字。

6. 每次改动都压测:用数据说话,也留好回退路径

6.1 压测前必须统一的三件事

调优是否有效,不能靠“感觉变快了”。同一套改动,必须用可复现的压测来验证。压测前有三件事必须统一。

第一,数据集不能变。同一张表、同样的数据量、同样的索引结构。如果在两次压测之间往表里插了几百万行,结果对比就没有任何意义。

第二,参数基线要固定。每次只改一个变量,其他参数保持一致。比如这次只动innodb_buffer_pool_size,那么sort_buffer_size、max_connections这些都别碰。这样改完出了效果或者出了问题,你知道该归因到谁。

第三,压测要错峰。不要在压测期间再跑批量任务、备份或者其他分析查询。尽量挑一个空闲窗口,连续跑三轮以上,取中位数结果,避免单次压测被系统偶发抖动污染。

工具方面,mysqlslap是官方自带的小工具,适合做快速冒烟测试。更复杂、更接近真实业务的场景,可以把SQL脚本交给通用基准工具去跑。你要做的只是保证脚本里包含线上典型的读、写、排序、关联语句。

6.2 除了TPS/QPS,还要盯住哪些指标

压测结果不能只看TPS、QPS涨了多少。这两个数字很直观,但容易掩盖其他隐患。

我每次压测都会额外记录一份清单:

  • 延迟分布,特别是p99。TPS可能涨了,但p99从10毫秒飙到300毫秒,那就是拿长尾延迟换吞吐,不可取。
  • Threads_running是否稳定。并发压测下这个值如果持续攀高,说明系统已经逼近处理上限。
  • Innodb_row_lock_wait次数和等待时间。这个值飙升,说明写冲突和锁等待在增加。
  • Created_tmp_disk_tables。这个值大量出现,说明排序或关联语句需要优化,而不是继续压参数。
  • Slow_queries计数。如果优化以后慢查询反而增多,那基本可以判定改动有问题。

给个我常用的对比模板:

指标优化前优化后结论
QPS52007800提升
p99延迟42ms28ms提升
Threads_running峰值9658改善
Created_tmp_disk_tables41035改善

只要有一项恶化,就要停下来分析原因。只挑好看的指标写在报告里,迟早会还债。

6.3 改错参数后的定位与快速回退

调优不可能永远一次到位,我也改错过参数。有一回为了压内存,我把sort_buffer_size从2MB降到了256KB,结果线上一个常用排序SQL从200毫秒变成2秒,慢日志直接被打爆。幸好改动前我把SHOW VARIABLES完整存了一份,压测结果又明显异常,马上动态改回去,业务就恢复了。

所以每次改参数之前,我习惯先跑一次备份:

mysql -uroot -p -e "SHOW VARIABLES" > /backup/variables_before_$(date +%F).txt

改完以后再存一份:

mysql -uroot -p -e "SHOW VARIABLES" > /backup/variables_after_$(date +%F).txt

执行对比:

diff /backup/variables_before_$(date +%F).txt /backup/variables_after_$(date +%F).txt

如果改动用的是SET GLOBAL,生效是动态的,但实例重启后会丢;所以一定要在配置文件里同步持久化。线上出问题时,先动态改回原值缓解,再找业务窗口重启。我见过有人只改配置文件没执行SET GLOBAL,然后重启前参数根本没生效,排查半天才发现是乌龙。

调优记录也别偷懒。文件名、日期、改前值、改后值、改动原因、压测结果,每行都写清楚。三个月后你再翻这份记录,会感激当时的自己。

最后分享一个我的习惯:调优这件事,永远不要同时动超过一个参数。每改一个,就压测一轮,记录一轮。最好的优化状态,不是把参数调成某个网传“标准值”,而是针对你的业务负载,找到一组参数组合,并用压测反复确认它确实稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进