MySQL优化这事儿,说实话已经被聊烂了。网上随便一搜,全是“索引失效十大场景”、“SQL优化黄金法则”、“分库分表避坑指南”这类文章。但真正在线上折腾过的人都知道,文章写得再花哨,落不了地就是废话。你照着文章去建索引,结果发现写请求变慢了;你辛辛苦苦把SQL从3秒优化到0.3秒,结果业务一上量又打回原形;你费劲千辛万苦把表拆了,最后发现join做不了、事务也没法保证——这些都是真实发生过的。
这篇帖子我不打算给你讲什么高深的理论,就从一个实际干过几年MySQL运维和架构的人的角度,把索引、SQL和分库分表这三件事从头到尾掰开揉碎了讲。核心思路只有一个:先搞清楚问题在哪一层,再决定用哪把刀。很多人的毛病是想一步到位直接分库分表,结果发现根本没到那一步;还有些人天天在研究SQL写法,却忽略了索引本身就是错的。
这篇文章适合谁看?适合那些公司业务正在增长,MySQL开始出现性能瓶颈,但你还没到非上分布式中间件不可阶段的开发者。也适合那些已经分了库分了表,但发现为什么还是慢、为什么数据对不上的兄弟。我会把每个关键决策背后的理由讲清楚,附上可以直接抄作业的排查思路和参数,最后再分享几个线上踩坑的复盘记录。
1. 全貌梳理:索引、SQL与分库分表的分工与边界
在动手优化之前,先把这三件事的边界和关系理清楚。搞不清楚边界,很容易陷入“拿着锤子看什么都像钉子”的陷阱。
1.1 三层优化的定位到底是什么
索引、SQL、分库分表,本质上是在处理不同层的成本问题。
索引和SQL优化解决的是单机单库内部的计算成本问题。说白了就是,同样在一张表里查数据,怎么让存储引擎少干活。索引决定了数据怎么被快速定位,SQL决定了优化器拿到你写的语句之后能不能走上去。这两者其实是配套的:索引建得好,但SQL写法让优化器不走索引,等于白建;SQL写得再规范,但表上压根没有合适的索引,优化器也只能硬着头皮全表扫。
分库分表解决的则是单机物理上限的问题。当你的数据量或者写入并发已经超出了单个MySQL实例的承载能力,不管索引多完美、SQL多高效,都无济于事。这时候不是优化的问题,是架构的问题。
很多人经常犯的错误是混淆这两个层面。比如表里已经有几亿行数据,还在费劲地调SQL,企图让它跑进100毫秒。这往往徒劳无功。反过来,才几百万行数据的表,三天两头有人提分库分表方案,这也是典型的过度设计。
1.2 优化顺序的决策逻辑
我个人的习惯,永远遵循一个顺序:先SQL,后索引,再架构。
这个顺序看起来跟标题的顺序不一样,但实际工作上必须颠倒过来。理由很简单:SQL和索引的调整成本低、周期短、风险可控,而分库分表的改造成本极高,一旦上了这条船,意味着你的代码要全面重构,事务模型要变,join要拆,扩容要规划。能通过SQL和索引解决的问题,绝不轻易动架构。
具体到实操,我的判断流程是这样的:
- 打开慢查询日志,看拿到的是哪些SQL。
- 对每个慢SQL执行explain,看是不是走了索引。
- 如果该走索引没走,先改SQL写法;改了还不行,再看索引设计是否合理。
- 如果每一条慢SQL都走到了索引上,但延迟依然高,数据库负载依然高,这时候再考虑参数调优和架构改造。
这套流程我用了很多年,帮我在很多项目里避免了“业务没到那一步,但提前背上了分库分表的债”的尴尬局面。
2. 索引优化:从数据结构到落地的完整拆解
索引是MySQL优化里性价比最高的一环。很多时候一条SQL从几秒降到几十毫秒,就只是加了一个索引这么简单。但索引也是出错最多的环节,建多了会拖垮写入,建错了毫无意义,建歪了甚至导致优化器选错执行计划。
2.1 为什么B+树能成为InnoDB索引标配
先说索引的数据结构。InnoDB引擎使用的是B+树,而不是其他所谓“更高级”的结构。理解了这个选择,后面看什么“最左前缀”、“回表”、“覆盖索引”才能顺着逻辑走通。
B+树的几个特性跟磁盘IO是高度相关的。每一层节点的数据量是有严格上限的,树高度一般控制在2到4层。一次索引查找意味着2到4次磁盘IO,这基本上就是MySQL能快速响应的物理基础。
那B+树和B树最大的区别在哪?B树的叶子节点和非叶子节点都存数据,而B+树只有叶子节点存数据,非叶子节点只存索引键值。这意味着同样一个4KB的磁盘页,B+树能放更多的索引项,树更矮,查询路径更短。另一个关键点是B+树的叶子节点之间通过双向链表连在一起,这对范围查询极其友好。你要查一条范围内所有记录,只需要找到起点,然后顺着链表一路往后扫,不需要像B树那样反复回溯父节点。
所以你在MySQL里做范围查询、排序、分组,本质都是在利用B+树的物理结构特性。了解了这一点,你就能理解为什么联合索引有“最左前缀”的规则了——因为联合索引在B+树里是按照索引定义字段的顺序先排序第一列,再排序第二列,再排序第三列。如果你跳过第一个字段直接查第二个字段,那B+树的全局有序性就失效了,优化器只能放弃这个索引,或者退化成索引扫描。
2.2 索引设计的几个核心步骤
我建索引从来不是拍脑袋。每一张表的索引设计,我都会走一遍下面这个流程。
先盘点这张表的全部查询维度。查询维度不等于表字段。比如订单表,用户查订单是一个维度,商家查订单是一个维度,平台运营查订单又是一个维度。不同维度的查询频率、实时性要求、数据量级都不同,不可能一个索引通吃。
然后看每个维度的选择性。选择性高不高,取决于字段的重复程度。比如状态字段,无非是待支付、已支付、已发货、已完成几类,选择性极低。这种字段单独建索引意义不大,因为索引一旦扫出来还是大量数据,回表成本不比全表扫描低多少。相反,用户ID、订单号这类字段,选择性极高,放在联合索引第一位几乎总能让查询走索引。
接着才是设计联合索引。联合索引的设计里有个关键思路:范围条件放后面,等值条件放前面。为什么?还是B+树的排序规则。等值条件在树里定位就是直接落到一个节点,而范围条件一旦触发,后续字段的排序就已经失效了。所以如果你有个查询是“用户ID=?且创建时间>?”,那联合索引应该是(user_id, created_at),而不是反过来。反过来之后,每个用户的时间段数据在索引里并不是连续存放的,查询效率大打折扣。
最后还要看回表成本。InnoDB的主键索引也就是聚簇索引,其叶子节点存的是整行数据。二级索引的叶子节点存的是主键值,你要查的字段不在索引里,就得拿主键回表找数据。如果一条查询命中了几万行,哪怕走索引了,回表几万次,速度也快不了。此时可以考虑把查询需要的字段全部放进索引里——这就是覆盖索引。比如SELECT user_id, order_status FROM order_table WHERE user_id = ?,建一个(user_id, order_status)的联合索引就能让查询完全走索引,无需回表。
注意:覆盖索引不是越多越好。索引占空间,每次写入都要更新所有相关索引,写入性能会下降。一个表二级索引数量我个人建议控制在5个以内,索引字段总数控制在10个以内。超出这个范围,优化收益会被写放大抵消。
2.3 索引失效场景与隐式转换的危害
索引建到位了,不等于SQL就一定能走索引。有一类特别容易中招的问题,就是索引字段上的隐式类型转换。
最经典的场景:user_id字段在表里是varchar类型,但你在SQL里写了WHERE user_id = 123456,那么这个条件查询时MySQL会把varchar字段隐式转换成数字类型再做比较。一旦字段加了函数或隐式转换,索引就失效了。你排查半天,发现explain走的是全表扫描,但看字段又有索引,其实就是这么个不起眼的坑。
类似的还有在索引字段上做计算、函数操作,比如WHERE DATE(created_at) = '2024-01-01'、WHERE amount + 10 > 100这些写法。只要索引字段本身就是函数的参数,优化器就拿索引没辙。
另一个实操里常见的服务端编码问题——字符集不一致。如果两张表的关联字段一个用utf8,一个用utf8mb4,即使内容完全相同,在做join时也会发生隐式转换,导致关联查询不走索引。
排查这类问题的标准动作就是explain。重点关注type列,从好到差大致顺序是const、eq_ref、ref、range、index、ALL。看到ALL,基本就说明全表扫了。看到index也要警惕,它是全索引扫描,虽然遍历的是索引树,但对大表而言同样很慢。
2.4 索引维护与线上监控
很多人把索引建完就抛之脑后,这是不对的。业务的查询模式是会变化的,几个月前的核心查询可能已经变成了边缘功能,而新功能新查询连索引都没有。
索引监控这块,我常用的方法很简单:打开慢查询日志,周期性地分析Top N慢SQL,发现字段上有查询但没索引,就评估增加索引。然后定期去看performance_schema里的统计数据,找出那些建了但从来没被使用过的索引,评估删除。
删除无用索引这件事要特别小心。生产环境删除索引之前,先在测试环境用真实查询模式跑一遍,确认没有依赖。我自己曾经吃过一次亏,删掉了一个当时认为无用的索引,结果第二周某个边缘报表开始慢,排查半天才发现是那个索引支撑的。
索引碎片也值得关注。频繁的更新和删除会在B+树里留下空洞。用OPTIMIZE TABLE可以重建表,但注意锁表时间可能很长,线上操作务必放在低峰期。数据量大的表,我更推荐通过Online DDL配合插件或者直接在维护窗口做,千万别白天在业务高峰期跑。
3. SQL优化:慢查询定位与改写实战
索引聊完,来说SQL优化。这里我给一个比较反直觉的建议:优先改业务逻辑,其次改SQL语法,再次才是改SQL细节。很多慢SQL之所以慢,是因为业务设计上就要求数据库干了一件不必在数据库里干的事。
3.1 慢查询日志的开启与解读
慢查询日志是定位SQL问题的入口。线上环境一般建议开启,但要控制阈值。很多公司的慢查询阈值设的是1秒,我见过某些核心系统把阈值设到100毫秒甚至更低的。阈值越高,越容易漏掉那些“看起来不慢但量极大”的SQL。一条500毫秒的查询不慢,但如果每秒被调用了几百次,那堆积起来就是数据库的灾难。
配置文件里加上以下几行,然后重启生效:
slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 0.5 log_queries_not_using_indexes = 1log_queries_not_using_indexes这个参数,会把所有没用索引的查询都记进日志。在业务低峰期开一段时间,你会惊讶地发现,多少你以为是正常的情况其实根本没走索引。
拿到慢日志后,用mysqldumpslow或者pt-query-digest这类工具聚合分析,按总耗时排序,优先处理累计时间最长的那些SQL。优化完一批,隔一周再看慢日志,会有非常明显的曲线变化。
3.2 explain执行计划的正确阅读姿势
关于explain,我只看几个关键的列:type、key、rows、Extra。很多人执着于type是不是有system、const这种惊人级别,其实在真实OLTP场景,大部分正常查询做到ref、range就已经很不错了。关键是key列,看看优化器实际用的索引跟你预期的是不是同一个。如果key是NULL就是没走索引,立刻返回去查索引和SQL写法。
Extra列是宝藏,也是最容易被忽略的。看到Using filesort,说明排序没走索引,要看看排序字段是不是在索引里。看到Using temporary,说明用了临时表,group by或者distinct出了问题。看到Using index,恭喜你,这就是前面说的覆盖索引,效率最高。Using index condition则是索引条件下推,在InnoDB里把一部分WHERE条件在存储引擎层过滤掉了,也不差。但如果你看到Using where; Using index,别高兴太早,通常意味着虽然走了覆盖索引但WHERE条件不是全索引覆盖,可能还有回表层级的损耗。
实操心得:调参前先看慢日志,看慢日志前先看业务曲线。凌晨三四点不慢的SQL,不代表业务高峰不慢,因为锁等待和IO竞争在不同负载下的表现完全不同。优化后的SQL一定要在压测环境里回放真实流量,别只测单条。
3.3 深分页优化:延迟关联与游标方案
MySQL深分页问题,几乎是所有做To B系统的团队都会遇到的。你写个LIMIT 100000, 20,感觉就是取20条记录,数据库却要扫描前面10万行,然后丢掉。越往后翻页越慢,最后直接超时。
这里我推荐两种方案。
第一种是延迟关联。思路是先利用覆盖索引快速定位到主键ID,再用主键去关联获取全行数据。举个例子:
-- 优化前(深分页慢) SELECT * FROM orders WHERE status = 1 ORDER BY id LIMIT 100000, 20; -- 优化后(延迟关联) SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY id LIMIT 100000, 20 ) t ON o.id = t.id;优化前的SQL,即使是全表顺序扫描加limit,也得先扫到10万行。优化后,最内层用覆盖索引先扫主键,拿到20个id之后,再回表拿整行。如果status字段有索引,内层查询全程在索引树里走,扫描成本大幅降低。
第二种是游标式分页。适用于翻页很深的场景,比如用户翻了几百页。直接用WHERE id > 上一页的最大id来定位,而不是OFFSET。这种方案在To B里的工单列表、会话记录里很常见,配合order by id或者order by创建时间字段,性能是很稳定的。前提是业务能接受“不能跳页”这种交互模式,只允许上一页下一页。
这两种方案我都在生产环境里用过。延迟关联适合改造成本低的场景,游标式适合产品需求本身支持顺序浏览的场景。如果产品非要支持几千页跳页,说句实话,单靠MySQL已经很难扛了,要么限制最大翻页深度,要么上搜索引擎类组件。
3.4 关联查询、子查询与聚合的取舍
关联查询这块,我踩过的坑比较多。MySQL的优化器对子查询的处理历史上是有过很多问题的,特别是IN子查询,很多时候会把子查询物化再跟外层做关联。在数据量不确定、统计信息不准的时候,执行计划经常不是最优的。
我现在的习惯是:
- join能写就尽量用join,而不是IN或EXISTS嵌套子查询。
- 做join之前,明确驱动表是哪张。驱动表最好是过滤后结果集最小的那张,小表驱动大表是基本准则。
- join字段两侧都要求有索引,否则用不上索引关联,算法直接退化成Block Nested Loop,慢得你想骂人。
- 尽量避免三层以上的深join。如果业务上有这种需求,考虑在应用层分多次查询,用内存组装结果。
聚合查询是另一个容易中招的场景。COUNT()在InnoDB里是没法像MyISAM那样走计数的,必须一条条数。大表的COUNT()就别做了,日常统计用定时任务跑一个汇总表。SUM、AVG这类聚合,排查一下是不是把不该聚合的数据也拉进来了。一个常见的错误是:先用子查询把明细拉出来,然后外层做聚合,这样就会让临时表膨胀。能用聚合函数解决的问题,不要先查明细再聚合。
4. 分库分表:别轻易碰,但要懂怎么碰
分库分表这个话题,我得泼一盆冷水:大部分业务根本不配(不需要)分库分表。这句话可能有点刺耳,但我见过的真实场景里,很多“不得不分库”的案例,其实是慢SQL加烂索引导致的假性瓶颈,硬拆了之后问题更多。
但话又说回来,业务量真的上去了,不分库分表就是死路一条。这时候你对这个技术领域的理解深度,直接决定了拆完之后的稳定性。
4.1 分库分表的触发信号
什么样的业务才需要分库分表?我倾向于用数据量和写入并发两个维度做判断。
数据量维度:单表数据量超过千万级别时,我们就开始评估。这不是死标准,表结构简单、查询走索引且无大聚合的,千万级照样跑得好好的。表结构复杂、字段多、查询模式多样,可能500万就已经吃力了。
写入并发维度:看磁盘IO和主从延迟。如果主库写入的IOPS已经接近磁盘上限,binlog同步延迟越来越明显,说明单实例写入能力已经逼近物理极限。这时候不是优化能解决的,得从架构上分摊写入压力。
还有一个容易被忽视的信号:单表体积已经大到影响日常运维。比如ALTER TABLE加字段要跑很长时间,备份恢复要几个小时,这时候哪怕业务还能撑,你也该考虑拆分或者分区了。
4.2 分片键选择与路由策略
分库分表第一步就是选分片键。这一选择直接决定后面所有查询能不能路由到正确的库表。选错了,整个架构就是灾难。
我的原则是:选业务里最高频、最强制、最稳定的那个查询条件。比如订单业务,按用户ID分就是最常见的。用户查自己的订单,天然就能定位到一个分片。但用户ID分片意味着运营后台想查某时间段全平台订单,就变成全分片广播查询了,那怎么办?要么再维护一个索引表,要么运营查询走离线数仓。这是分库分表必须接受的代价,不存在完美分片键。
重点聊一下分片键能不能改的问题。分片键一旦定了,后期修改的成本高到可怕。所以决策时一要想到未来三年,二要看业务主流程的查询范式。如果业务里主查询同时存在两个维度且频率相当,比如C端用户查单和B端商家查单都很高频,那就得考虑双分片或者引入映射表方案。
路由策略上,业界常用的有三种。Range分片,按ID区间切分,后续扩展方便,但容易造成热点库,新的数据都往最后一个分片挤。Hash分片,按分片键哈希取模,数据分布均匀,这是目前用得最多的方式。还有一致性哈希,为了扩容时最小化数据迁移。简单业务用取模;预判未来会频繁扩容的业务,我建议一开始就设计好虚拟桶方案,避免后期全量迁移。
4.3 常见的分库分表中间件选型
市面上主流的方案其实就两大类。
一类是客户端中间件,典型代表就是ShardingSphere-JDBC。它是以jar包的形式作用于应用层,SQL解析、路由、改写都在客户端完成,对DBA来说是透明的,不需要额外部署Proxy层。优点是性能好,因为不需要经过代理转发层,直连数据库。缺点是多语言支持比较麻烦,每个应用都得接入SDK,而且分布式事务、分布式主键这些都得自己适配。
另一类是中间代理层,比如ShardingSphere-Proxy,以及各种公司自研的proxy层。这类方案对应用是透明的,业务代码基本不动,只需把连接串指向proxy就行。优点是接入成本低、多语言友好、集中管理分片规则。缺点是多一跳网络开销,性能上限受限于proxy层本身的处理能力,代理集群本身又成了新的高可用关注点。
选择上,我的建议是:中小团队、改造时间紧、业务比较简单的,优先客户端方案,它简单直接,少一套组件少一套故障。如果公司有专职的DBA团队,多系统都依赖同一套分库分表能力,那就上代理层,集中管控。
还有个不能回避的问题是分布式主键。分库之后数据库自增主键就不可用了,因为每个分片各自自增会重复。业界常用的有Snowflake算法、Leaf,或者你用UUID转数字。Snowflake类的方案,要点在于保证workerId唯一和时钟回拨问题。时钟回拨会导致生成重复ID,这个问题在生产环境真实发生过,一定要在方案里处理时钟同步的回退策略。
4.4 分片后的数据迁移与平滑扩容
分库分表不只是“拆”一下就完事了,数据迁移和扩容才是真正让无数团队头疼的事。
初次从单库拆到多库,最稳妥的方式是双写迁移。解释一下流程:旧库继续承载读写,同时新库开启同步,把增量数据往新库写入。同时写一个数据同步任务,把全量历史数据按分片规则写入新库。跑完一轮之后,对比新旧数据,做校验和修补,最后把读流量灰度切到新库,观察一段时间后,再把写流量切过来,下线旧库。
这里有个细节容易被忽略:迁移脚本里面要控制写入速率。你如果傻乎乎地开一堆线程并行写,很容易把新库的IO打爆,导致新库延迟飙升,又反过来影响双写阶段。我一般会把全量任务限速,观察目标库的磁盘IO和主从延迟,动态调整并发数。
扩容更麻烦。如果用的是普通的hash取模,一旦分片数变了模数就变了,几乎全量数据都要迁移。所以现在很多团队在初期设计时就引入了虚拟桶或者一致性哈希。虚拟桶方案的思路是,先在哈希环或者桶空间上维护一个大的虚拟节点集合,每个虚拟节点再映射到物理库表。扩容时,只需迁移部分虚拟节点的数据,迁移总量可控。
这次教训我印象很深:一上来就图省事用ID取模4,结果一年后业务翻了4倍,扩容到8个库,数据搬了整整一个周末。如果当时预留虚拟桶,可能一个晚上就搞定了。
5. 实践中的典型问题与排查实录
最后一章,我把过去几年在线上环境里遇到的一些有代表性的问题整理出来。这些问题是面试题里永远碰不到的,但都是生产环境实实在在的坑。
5.1 案例一:一个不该走的全表扫描
某次线上告警,某个核心接口的P99延迟从80ms飙升到3秒。查慢日志发现一条SQL占了一半的慢查询次数:
SELECT * FROM user_info WHERE mobile = '139xxxx1234';表里明明在mobile字段上有唯一索引,但explain显示走的是全表扫描。仔细一看字段定义,mobile是varchar(20),但表字符集是utf8,而连接参数里character_set_client是utf8mb4。这一层隐式转换就让索引彻底失效了。
排查步骤其实不复杂:show create table看字符集,show variables检查连接字符集,确认不一致后,把表和连接统一到utf8mb4。索引立刻生效。
5.2 案例二:分页越翻越慢,用户直接弃用
某个管理后台的列表页,数据量大概是160万行。当用户翻到第1800页时,SQL执行时间已经超过5秒。我检查后发现,这条SQL不但有深分页问题,还连着三张表join,每张表的过滤字段都建了索引,但因为深分页导致扫描的数据量太大。优化方案就是前面说的延迟关联,把LIMIT条件从主查询挪到子查询内层,让内层先在索引树里快速定位主键ID。改造后,翻到第3000页的耗时也在80ms左右。
5.3 案例三:分库分表之后,事务和join怎么搞
某订单系统按用户ID分成了16个库,拆完之后发现两个问题。第一,一个用户的一个订单流程需要更新订单表、扣库存表、写流水表,但订单表和流水表按用户分片没问题,库存表按商品维度分片,跨在两个不同的分片上,单库事务就保证不了了。第二,运营后台想按店铺查近30天订单,结果分片键是用户ID,查询要广播到16个库,再在应用层合并。
这两个问题的最终解法,一个是用柔性事务方案,通过消息队列做最终一致性,引入本地消息表;另一个是把运营后台的查询迁到分析型数据库,通过binlog同步过去,从源头杜绝大查询打在线库。这条路走下来给我最大的感受是:分库分表后最大的成本从来不是迁移本身,而是业务代码里所有“理所当然”的单库能力全部需要重新设计。
5.4 一些容易忽略的参数配置
优化不只是表结构和SQL的事,几个关键MySQL参数往往被漏掉。
innodb_buffer_pool_size是最重要的一个。建议设置为可用内存的60%-75%,如果服务器内存是64G,那么设置40G-48G是合理的。这个值决定了InnoDB能把多少数据页和索引页缓存在内存里。设太小,磁盘IO直线上升;设太大,留给OS和其他进程的内存不够,可能导致swap。
innodb_flush_log_at_trx_commit在数据安全性和写入性能之间有个取舍。为1是最安全的,但每次事务提交都要刷盘,写入TPS会明显下降。对转账这类资金敏感业务,必须用1;对非关键业务,比如日志类、流水类,可以调到2,这样每秒才做一次刷盘,性能提升相当可观。
max_connections和thread_cache_size也要配合调整。连接数开得太大不是好事,每个连接都会占用内存和CPU,反而引发资源争抢。正确思路是限制连接数,配合应用层连接池合理设置。连接池上线一般设为20到50就够,具体看业务IO时长。
写在最后
这次先聊到这儿吧。回头看看,我做MySQL优化这几年最大的感受是:技术方案的取舍永远是在可维护性、扩展性和当前业务规模之间做平衡。索引、SQL、分库分表这三板斧,前两板是日常,第三板是重武器。不到万不得已不要轻易上重武器,但上了之后就得把路由、迁移、事务、运维体系的账都算清楚。
实操里我还有个习惯:每次优化完,都会顺手在文档里记录当时的业务瓶颈指标、优化手段、改动前后的SQL耗时对比,以及三个月后的效果跟踪。这套记录办法帮我避开了无数“过几天就忘记当初为什么这么改”的坑。建议你也在自己的项目里养成这个习惯,比看任何方法论都管用。后面如果大家有兴趣,我可以再写一篇关于线上故障应急处理和数据库高可用切换的实操复盘。