做MySQL迁移和性能优化这几年,我踩过的坑比官方文档里的注释还多。最近一次是帮某业务团队把一套运行了六年的核心订单库从5.7版本迁到8.0,整个过程从兼容性评估、备份方案选型,到数据追平、上线切换,再到上线后的参数调优和慢查询治理,前后折腾了两周。这篇文章就把这次迁移的完整思路、我在选型时纠结过的点、以及上线后遇到的那些怪问题一次性交代清楚,给准备动手做MySQL版本升级、或者正在被慢查询和锁等待折磨的同学一份可以直接复用的操作参考。
先说背景。那套订单库单实例有接近400GB数据,业务对写入延迟敏感,每天高峰期QPS能到几千。原本跑在5.7.44,硬件是16核64G的物理机,磁盘是PCIE SSD,整体负载看着还能扛,但有两个隐患:一是5.7已经过了社区维护期,安全更新断了,漏洞只能自己盯着;二是业务那边计划上线新报表模块,要用窗口函数和公共表表达式,这些在5.7里能写但不舒服,部分查询需要靠临时表和多次自连接硬凑。所以迁移到8.0不是单纯追新,而是有具体业务驱动力。迁移版本这件事,如果只为了“用新版本”三个字就去动生产库,我一般劝退,但如果有明确的功能需求、性能瓶颈或者安全合规压力,那就值得认真规划。
1. 迁移前评估:版本差异与兼容性排查
1.1 为什么要从5.7迁到8.0
MySQL 5.7的维护截止期早就过了,社区版不再有新的补丁。对于还在上面跑核心业务的人来说,这是一颗不知道什么时候会爆的雷。安全漏洞这东西,平时看不见,等攻击者利用上了再去应急,代价远大于一次有计划、有演练的版本升级。8.0这边,除了常规性能增强,几个点在实际业务里非常能打:窗口函数、公共表表达式、不可见索引、降序索引、直方图、原子DDL、以及redo log容量自动调整。这些都是可以直接改变SQL写法和运维方式的特性。
窗口函数解决的是“分组内排名”和“相邻行比较”这类问题。以前写“查每个用户最近一笔订单”这类需求,要么用临时表自连接,要么用变量模拟row_number,写法别扭而且性能不稳定。8.0里一行ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY create_time DESC)直接搞定,语义清晰。公共表表达式让复杂报表SQL的可读性上了一个台阶,尤其那种嵌套四五层的子查询,改成WITH之后后面接手的人少掉不少头发。不可见索引和降序索引对DBA来说更是实用,旧索引不想删又怕影响执行计划,先设为不可见观察一段时间,比直接删安全得多。
但版本升级不是无脑切。如果业务代码里用了大量旧语法、老驱动,或者有依赖5.7特定行为和SQL MODE的查询,升级后很可能冒出各种兼容性问题。所以迁移前必须把“版本差异清单”过一遍,不能直接备份恢复完就放业务流量。
1.2 兼容性检查清单:一张表把差异点列全
我在正式动手前做了一张兼容性风险表,把可能影响线上行为的关键差异都列了出来,逐条对照检查。这里直接分享给你:
| 检查项 | 5.7行为 | 8.0行为 | 影响等级 |
|---|---|---|---|
| 默认认证插件 | mysql_native_password | caching_sha2_password | 高:旧客户端可能无法认证 |
| 默认字符集排序规则 | utf8mb4_general_ci | utf8mb4_0900_ai_ci | 高:影响排序结果和索引匹配 |
| SQL MODE默认值 | 不含ONLY_FULL_GROUP_BY | 含ONLY_FULL_GROUP_BY | 高:部分GROUP BY查询报错 |
| 系统表结构 | information_schema较简单 | 系统表视图重写,统计信息更准确 | 中:监控脚本可能失效 |
| 查询优化器 | 成本模型相对粗糙 | 新成本模型,直方图支持 | 中:执行计划可能变化 |
| DDL行为 | 大多时候拷贝表 | 多数支持INSTANT/INPLACE | 低:反而减少锁表时间 |
| 整数类型显示宽度 | int(11)等显示宽度生效 | int(11)不推荐,不影响存储 | 低:迁移后显示宽度丢失 |
这里面最容易被忽略的是SQL MODE。很多老系统迁到8.0之后,突然开始报“Expression #X of SELECT list is not in GROUP BY clause”,就是ONLY_FULL_GROUP_BY被默认开启的结果。5.7里如果当时没有显式设置SQL_MODE,默认其实也是ONLY_FULL_GROUP_BY,但如果运维之前改过全局配置把它关掉了,迁移后就会踩坑。我在迁移前的连接层测试里特意跑了一轮全量慢查询日志里出现过的高频SQL,专门筛选GROUP BY和排序相关的,拿它们在8.0上先跑一遍看是否报错。这一步非常推荐,比上线后让业务同学来找你报错省心得多。
1.3 字符集与排序规则的坑
8.0默认排序规则从utf8mb4_general_ci换成了utf8mb4_0900_ai_ci。这个变化会让排序结果和唯一键判断产生差异。utf8mb4_0900_ai_ci来自Unicode 9.0的UCA算法,“ai”指accent-insensitive,在处理带重音字符时比general_ci更符合语言习惯,但它和general_ci完全不兼容。如果你数据库中在5.7时期创建的表用的是utf8mb4_general_ci,迁移后新库默认建表可能是utf8mb4_0900_ai_ci,两张表做JOIN时排序规则不一致,MySQL干脆报“Illegal mix of collations”,或者更隐蔽的是索引失效、查询结果排序顺序跟老环境不一样。
我这次的做法是:建新实例时显式把character_set_server和collation_server都设成utf8mb4_general_ci,和旧实例保持一致。8.0完全支持继续使用general_ci,只是不是默认值而已。这样可以最大限度避免迁移阶段的隐性差异。等业务稳定后,再找低峰期分批把表和列改成0900_ai_ci。如果你是要做跨地域的多活部署,字符集和排序规则不一致会引发数据对比偏差,所以从一开始就统一是最好的。
判断当前库的字符集和排序规则,用下面两条命令:
SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'your_db'; SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db';1.4 认证插件更换的影响
8.0默认认证插件caching_sha2_password比mysql_native_password更安全,密码不会以哈希形式走明文通道,但老版本客户端和连接驱动不一定支持。Java业务那边如果还在用mysql-connector-java 5.1.x,直接会连不上,报Public Key Retrieval is not allowed。PHP的旧版mysqli扩展、Python的旧PyMySQL在小版本上也可能出问题。
解决办法有两个方向。一是升级客户端驱动,现在官方和主流语言驱动都支持caching_sha2_password,这是正路。二是临时给对应账号改成mysql_native_password,作为过渡。我这次选了折中方案:先在8.0实例上把业务账号保留为mysql_native_password,确保应用启动不受影响,等所有驱动的TLS和密码认证适配完成之后,再分批次切到caching_sha2_password。具体命令:
ALTER USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';这里有个经验:驱动升级这件事,测试环境提前验证,别等到生产切换当天才升级。8.0对TLS连接也默认支持,如果业务网络里有防火墙或者代理做了协议解析,需要注意。
2. 备份与迁移方案选型:逻辑备份还是物理备份
2.1 备份工具的对比与选择
迁移一个400GB的库,备份方式直接决定了你的停机窗口和失败概率。我最开始想过直接用mysqldump导出再导入,但按照每秒30MB的平均吞吐来算,导出加导入少说也要七八个小时,还不算索引重建的时间。8.0导入的时候如果目标端没有把参数调好,可能更慢。所以对几十GB以上的实例,我更推荐物理备份工具。
mysqldump和Percona XtraBackup的定位不同。mysqldump是逻辑备份,导出的是SQL语句,结构清晰、跨版本迁移方便,但耗时长、恢复时需要重放SQL,适合小库或者需要做数据抽取的临时任务。Percona XtraBackup是物理备份,直接拷贝InnoDB数据文件,利用redo log实现一致性,备份和恢复速度都远快于逻辑备份,适合单实例几百GB甚至TB级别的大库。当然它的要求也高:需要和目标版本兼容,8.0对应的是Percona XtraBackup 8.x,不能用旧版去备8.0实例。
我这次的选型结果是:全量物理备份加binlog增量追平,切换时短暂停写。整个过程预留的停机窗口只有30分钟,实际用了大概15分钟。
2.2 XtraBackup实操步骤
安装Percona XtraBackup时注意选和MySQL版本匹配的release。我这里用8.0.33对应的xtrabackup 8.0.x版本。全量备份命令:
xtrabackup --backup \ --target-dir=/data/backup/full_$(date +%F) \ --datadir=/var/lib/mysql \ --user=backup_user \ --password=your_password \ --parallel=4 \ --compress=lz4解释一下几个容易被忽略的参数。--parallel是并行拷贝线程数,一般按CPU核数或者磁盘能力设,不要盲目设太高,否则IO被打满反而降低整体速度。--compress=lz4是压缩备份集,我这边磁盘空间够,实际没有压缩,而是在拷贝完成后直接做了一次校验。压缩解压要额外花时间,空间够的话建议不要为了省不到一半的空间额外引入延迟。
备份完成后的准备阶段:
xtrabackup --prepare --target-dir=/data/backup/full_2025XXXXprepare这步会把备份时尚未落盘的redo log应用到数据文件,让备份集处于一致可恢复状态。一定要做,直接把备份目录丢给MySQL启动是起不来的。
恢复到新实例时,把备份目录解开到目标datadir后,注意目录属主要改成mysql用户,而且my.cnf里datadir路径要一致。启动前先用--validate检查一下备份完整性更稳妥。
2.3 迁移过程中的数据一致性保障
物理备份只是拿到了某个时间点的一致性快照。业务不停写的情况下,从备份时间点到切换之间产生的增量数据,需要靠binlog追平。我的操作流程是这样的:
- 在旧库开启binlog并确保格式为ROW,expire_logs_days保持足够大,避免追增量时日志被清理。
- 记录全量备份开始时对应的binlog文件名和位点。XtraBackup会在备份生成的xtrabackup_binlog_info文件里自动记录这个位置,后续追增量就从这个位置开始。
- 先在目标8.0实例把全量数据恢复并启动到正常状态。
- 在目标库上配置主从复制,把8.0实例作为从库挂在旧5.7主库下,开始追增量。
- 观察复制延迟直到Seconds_Behind_Master归零并稳定一段时间。
- 业务停机维护,确认旧库没有新写入,停止复制,把应用连接切到新实例,完成切换。
整个过程看起来简单,但有几个细节容易翻车。第一个是binlog的位置记录,如果用了表级DDL或者并行复制,位置判断可能出偏差,稳妥做法是在备份前做一次FLUSH TABLES WITH READ LOCK,短暂把只读开启,确保binlog位点干净。第二个是复制账号权限,需要REPLICATION SLAVE权限,别只给个SELECT账号。第三是GTID问题,如果旧库开启了GTID,新库也建议开启,这样复制源可以用GTID自动定位位置,不用手工填file和pos,省去很多麻烦。
2.4 追增量时的binlog补偿细节
追增量本质上就是做一次临时主从复制。5.7主库到8.0从库这种跨版本复制,官方支持从低版本到高版本,但要注意binlog格式和MySQL 8.0对binlog默认格式要求ROW,而5.7可能还是STATEMENT或者MIXED。STATEMENT格式在跨版本复制时容易因函数、自增列、临时表出现不一致,最稳妥是提前把旧库binlog格式改成ROW。
复制配置以GTID模式为例:
CHANGE MASTER TO MASTER_HOST='old-host', MASTER_PORT=3306, MASTER_USER='repl_user', MASTER_PASSWORD='xxx', MASTER_AUTO_POSITION=1; START REPLICA;启动之后用SHOW REPLICA STATUS\G的Seconds_Behind_Master看延迟。如果延迟一直回不到零,优先检查从库是否有慢查询在拖累回放速度,或者是否因为DDL导致复制线程卡住。我当时遇到一个情况:旧库有一个大表在线加字段,5.7是拷贝表方式做ALGORITHM=INPLACE还是COPY,耗时较长,这一条DDL在binlog里是一个大事务,导致从库在回放时也卡了十几分钟。这种属于正常现象,等DDL回放完就恢复了,不需要焦虑。
追平之后不要急着切,先跑一遍数据校验。我习惯用pt-table-checksum对比主从关键表的checksum,确保没有因为备份恢复或者复制类型差异造成数据不一致。这一步虽然费时间,但能让你在切换前发现问题,而不是等到业务用起来才发现某张表数据对不上。
3. 8.0上线后的参数调优与性能优化
3.1 核心参数调整:先把地基打牢
版本迁移完成不等于任务结束,Downgrade后的性能调优才是决定业务感知的关键。8.0很多默认参数比5.7更合理,但离“适合你的业务”还差得远。最核心的依然是InnoDB缓冲池大小。我这次给目标是64G物理机,配置是:
[mysqld] innodb_buffer_pool_size = 46G innodb_buffer_pool_instances = 8 innodb_log_file_size = 4G innodb_flush_log_at_trx_commit = 1 innodb_flush_method = O_DIRECT max_connections = 800 table_open_cache = 8000 tmp_table_size = 64M max_heap_table_size = 64Minnodb_buffer_pool_size我设为物理内存的70%左右,剩余留给系统、连接线程、排序缓冲、临时表。如果你机器上还跑着监控、日志采集这类进程,占比要稍微降一点,不然内存压力会传染到数据库。8.0提供了一个innodb_dedicated_server变量,会自动根据内存算buffer pool和log file大小,但别完全依赖它,尤其容器环境或共享机器上自动分配可能不符合预期。
innodb_log_file_size从默认的48M改成4G,这个参数直接影响写入吞吐。日志文件太小会导致日志切换频繁,脏页刷新压力大,大量小的checkpoint反而拖慢写入。但log file也不是越大越好,过大会让崩溃恢复变慢。我这边是结合写入量,按“高峰期每秒钟产生的日志量大概几十MB,冗余到3到5分钟的量”估算出来的。
还有一个参数容易被漏掉:transaction_isolation。旧库如果用的是REPEATABLE READ,8.0默认也是REPEATABLE READ,但如果你业务里有大量读多的报表查询,可以考虑把部分非核心库改为READ COMMITTED,减少gap lock带来的锁竞争。不过这个改动会影响业务对一致性的预期,不能拍脑袋改,要和业务同学确认。
3.2 慢查询排查与索引优化实操
上线后第一个周末,监控发告警说某核心接口P95延迟从30ms飙到800ms。我打开慢查询日志一看,罪魁祸首是一条关联查询:
SELECT o.order_id, o.amount, u.user_name FROM orders o JOIN users u ON o.user_id = u.user_id WHERE o.status = 1 ORDER BY o.create_time DESC LIMIT 200;orders表已经有两百多万行,status字段有单列索引,create_time也有单列索引,但优化器在这个查询里走的是orders.status索引,之后对users表逐行嵌套循环查询,最后用filesort排序取200行。问题在于status=1的数据占到全表80%,单列索引选择性太低,优化器认为回表成本低,实际上大量的回表操作把IO吃满了。
解决办法是建联合索引覆盖这条查询路径:
ALTER TABLE orders ADD INDEX idx_status_createtime (status, create_time DESC);这里有两个要点。第一,把create_time降序加入索引后,排序可以直接走索引逆向扫描,消除filesort。第二,覆盖全部SELECT字段才是最优,但orders表字段很多,覆盖整行会让索引体积过大,所以折中方案是只加到能消除排序的字段,回表留给users关联部分。
建完索引后我用EXPLAIN重新看执行计划:
EXPLAIN ANALYZE SELECT o.order_id, o.amount, u.user_name FROM orders o JOIN users u ON o.user_id = u.user_id WHERE o.status = 1 ORDER BY o.create_time DESC LIMIT 200;8.0的EXPLAIN ANALYZE非常香,会真实执行SQL并输出每个算子的耗时和扫描行数。能看到orders表走了idx_status_createtime,实际扫描行数从一百多万行降到了几千行,P95延迟直接回到50ms以内。
3.3 优化器行为变化带来的SQL改写
8.0的优化器比5.7聪明不少,但对某些老式写法反而会给出让人意外的计划。我遇到一个典型例子:旧SQL用了IN + 子查询,5.7里会物化内层结果集,而8.0可能将其转换为半连接或者反连接,执行计划变化后,原本能走索引的查询变成了先扫描驱动表再去关联。这不一定是坏事,但如果统计信息刚更新完,可能选出一个不是最优的计划。
遇到这种情况,先看information_schema里表的统计信息是否准确,8.0默认会持久化统计信息和直方图。再考虑加hint或者改写SQL。比如把IN改成EXISTS有时能让优化器走更容易预估的路径。但我不建议一上来就上hint,hint是最后的武器,等统计信息更新、索引重建都没效果再用。
另外,8.0优化器对LIMIT分页场景做了改进,但深分页OFFSET很大的问题依旧存在。业务报表里那种LIMIT 1000000, 20的写法,成本一样高。改写思路是:用上一页返回的last_id作为查询条件,即“WHERE id < ? ORDER BY id DESC LIMIT 20”,这就是基于游标的分页,能大幅降低扫描行数。8.0支持窗口函数,也可以在某些场景下配合ROW_NUMBER做查询。
3.4 监控体系:不盯着关键指标等于白调
参数调没调好,索引加得对不对,不能靠猜。我迁移完第一件事就是把监控粒度从分钟级提升到10秒级。MySQL 5.7和8.0里performance_schema默认开着,8.0还提供了更细致的等待事件和锁等待记录。最常用的查询是:
SELECT * FROM sys.statement_analysis LIMIT 10;sys库是performance_schema之上的视图封装,直接能看到Top SQL、平均耗时、扫描行比例、锁等待时间。这是运维排查时最有价值的入口。配合Prometheus的mysqld_exporter,把QPS、Threads_connected、InnoDB_buffer_pool_read_requests、InnoDB_row_lock_waits这些指标拉成曲线图,能快速定位故障时间点和瓶颈方向。
我这边还要特别强调一个指标:当前活跃事务数。通常看information_schema.innodb_trx,如果活跃事务长期挂着,会积累巨大的undo日志、制造大量purge压力,导致数据库空间和性能双双失控。监控里加一张表专门记录innotop或者sys.detail_latest_queries能看到的信息,比事后翻日志高效得多。
4. 常见问题与排查技巧实录
4.1 连接数被打满的排查
上线后第二天下午,业务反馈系统突然大量报“Too many connections”。我登录进去第一件事不是直接改max_connections,而是先看连接在干什么:
SHOW PROCESSLIST; SELECT state, COUNT(*) FROM performance_schema.threads WHERE PROCESSLIST_ID IS NOT NULL GROUP BY state;问题很快浮出水面:大量连接处于Sleep状态,占比超过60%。这些是应用侧连接池泄漏的典型症状。应用用的连接池最大连接数设了200,但池里的连接长期不被回收,加上每个连接可能还有未提交的事务,数据库端看到的连接数越积越多。临时处理先kill掉超过空闲阈值的连接,然后调整应用连接池的idleTimeout和maxLifetime参数,让它主动回收空闲连接。这是我踩过很多次的一个经典坑:数据库参数调得再大,也比不上应用侧的池化配置合理来得重要。
连接数调优还有一层:8.0默认最大连接数151,很多业务上来就报满。但直接拉到几千并不一定是好事,因为每一条连接都要占用内存,放大了内存压力。更稳的做法是排查慢查询、减少连接建立频率,把连接池控制在合理范围。
4.2 主从延迟突然飙升
迁移时临时搭的主从复制没有马上撤,我把它当作过渡期的高可用。有一次看监控发现Seconds_Behind_Master从0飙到3000多秒。排查步骤:先看SHOW REPLICA STATUS里有没有报错,再查从库上的大查询或者大事务。
出现延迟的原因很典型:从库上有一个新增报表在跑聚合查询,把CPU打满,复制回放线程抢不到资源。临时方案是把报表查询改到只读从库专用的时间窗口,同时把这个大查询加上了索引。如果是大事务造成的延迟,要考虑拆分事务批量提交,避免一条事务修改几十万行。8.0默认复制是单线程的,遇到大事务只能等它回放完,没有捷径,思路是让业务侧减少超大事务。
4.3 内存占用过高问题
有台从库16G内存,长期占用95%以上。我们第一反应是buffer pool太大,后来看进程发现mysqld占了12G,但buffer pool只设了8G。多出来的内存来自performance_schema。8.0里performance_schema默认会占用一定比例的全局内存,如果监控了海量事件,内存消耗非常可观。解决方法是把不需要的consumer关掉,保留对业务排查有用的配置:
performance_schema_max_events_instruments = 8000 performance_schema = ON另外一个容易被忽略的内存大户是表缓存。table_open_cache如果设置得过高,MySQL会为每一个打开的表分配缓存结构。表数量几千张以上的实例,table cache占用的内存也不小。观察open_tables和opened_tables两个状态变量,如果opened_tables增长很快,说明缓存命中率低,再去调整table_open_cache才有意义。
4.4 常用排查命令速查表
分享一张我日常排查MySQL问题时的命令速查表,基本覆盖了80%的救火场景:
| 场景 | 命令/查询 | 说明 |
|---|---|---|
| 连接打满 | SHOW PROCESSLIST / performance_schema.threads | 看连接状态分布 |
| 锁等待 | SELECT * FROM sys.innodb_lock_waits | 查看锁等待树 |
| 活跃事务 | SELECT * FROM information_schema.innodb_trx | 查长时间未提交事务 |
| 慢SQL分析 | SHOW VARIABLES LIKE 'long_query_time'; 配合mysqldumpslow | 定位慢查询 |
| InnoDB状态 | SHOW ENGINE INNODB STATUS\G | 检查死锁、锁等待、redo日志 |
| 执行计划 | EXPLAIN ANALYZE SELECT ... | 真实执行并输出各算子耗时 |
| 复制状态 | SHOW REPLICA STATUS\G | 查看主从延迟和错误 |
| 缓存命中率 | SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%' | 计算缓冲池命中率 |
| 等待事件 | SELECT * FROM sys.waits_global_by_latency | 查看全局等待事件热点 |
| 磁盘IO压力 | SHOW GLOBAL STATUS LIKE 'Innodb_data_fsyncs' / iostat | 结合系统工具看IO |
这条表是我自己整理打印出来贴在工位上的,遇到告警不用翻文档,直接对着跑一遍基本能定位问题方向。加一个写死了的心得:任何一次线上操作前,先把pt-query-digest或者performance_schema里的快照存一份,出了问题能对比,省得两手空空靠猜。
迁移做完之后,我最深的体会是:MySQL版本升级本身并不是最难的,难的是升级前把业务SQL和配置差异摸透,升级后把监控和运维手段补齐。8.0变更点不少,但真正影响线上业务的关键就那么几个,只要做好兼容性测试、备份方案、索引优化和监控,整个切换过程可以做得非常平滑。最后分享一个小技巧:迁移后保留旧实例3天再销毁,期间如果有什么数据对不上的历史数据,还能从旧库找线索,这是我每次迁移都会给自己留的退路。