1. 优化这件事,得先把问题找对
干了这么多年后端,我发现大多数团队聊MySQL性能优化,第一反应就是“加索引”、“改参数”,但真正上手一测,往往发现加了索引也没快多少,参数改了半天反而把稳定性改坏了。问题出在哪?没把瓶颈定位准,就在那儿盲目下药。
这篇文章我打算换个思路来聊。不从“索引怎么建”这种零散知识点切入,而是先给你一套完整的优化框架:监控定位 → SQL与索引优化 → 事务与锁优化 → 参数调优 → 架构升级。这五个层次,从单条SQL的微调,一路做到读写分离、分库分表这种架构级改造,每一步都有清晰的适用场景和判断依据。
这套东西适合谁看?不管你是刚接触MySQL的初级开发,还是已经带团队的后端负责人,甚至是专职DBA,都能从中找到对应的优化思路。初级开发可以照着“SQL改写”和“索引设计”的章节直接落地;负责人和DBA则可以把“监控指标体系”和“架构演进决策”当作一份排查手册来用。
我尽量用真实业务里遇到的案例来讲,不带虚的。你学完不仅能知道“怎么做”,更能明白“为什么这么做”,下次遇到慢查询,至少知道先查哪张表、先看哪个指标,而不是打开配置文件一通乱改。
2. 从数据操作开始:先搞清楚MySQL是怎么把数据搞慢的
2.1 一条SQL的完整旅程,慢在哪个环节?
很多人以为SQL慢就是“表太大”或者“没走索引”,其实这一步的认知太粗了。一条SQL从客户端发出来,到最终拿到结果,中间经过的每一个环节都可能成为瓶颈。
我习惯把这条链路拆成五段:
- 连接层:客户端与MySQL建立连接需要时间,连接数满了更要排队等待。这个环节最容易被忽略,因为连接超时往往报的是“Too many connections”,很多人第一反应是“加大max_connections”,其实真正原因是连接被长事务或者慢查询占着不释放。
- 解析层:SQL语法解析、权限校验的CPU开销。如果你写的SQL本身就有问题,比如多表JOIN没条件、子查询嵌套太深,解析器再聪明也救不了你。
- 优化器:这里是最容易出幺蛾子的环节。优化器会根据统计信息选择执行计划,但如果统计信息不准,或者SQL写得太复杂导致优化器选错索引,就会产生“明明有索引却不走”的怪现象。
- 执行层:真正去存储引擎里扫描数据的地方。InnoDB要判断走哪个索引、扫描多少行、回表多少次,每一步都有成本。
- 存储引擎层:内存里的缓冲池命中率、磁盘IO、redo log刷盘,这些都是最底层的物理开销。
我让你记住这条链路,是因为排查思路必须跟着链路走。比如同样是“查询慢”,如果是因为连接排队导致的,你改SQL怎么改都没用,得先处理连接问题;如果是优化器选错索引,你光加索引也没用,得用force index或者改写SQL来干预。
注意:排查SQL性能问题的第一件事,不是看SQL长什么样,而是先确认瓶颈到底在哪一层。用慢查询日志定位SQL,用show processlist看连接状态,用explain看执行计划,三步走完才能动手改。
2.2 实战案例:一个“不太慢”但极其频繁的查询
我先分享一个我实际处理过的案例。某个交易系统里有张订单表,数据量大约300万行,不算大。但线上就是经常偶发延迟,排查下来发现有一条查询订单状态的SQL,单次执行只要80毫秒,看似没什么问题。
但问题是,这条SQL的调用频率极高,每秒要执行近千次。80毫秒乘1000次,意味着这条SQL每秒要占用80秒的数据库执行时间。你不算这个账的时候觉得“MySQL真快”,一算账就会发现CPU都快被打满了。
这条SQL长这样:
SELECT id, order_no, user_id, status FROM orders WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 10;看起来很简单对吧?但执行计划一出来,问题就暴露了:虽然user_id上有索引,但排序字段create_time没在同一个索引里,MySQL需要先根据user_id把数据捞出来,再在临时表里做一次文件排序。
优化方案也简单,把索引改成联合索引,让排序直接走索引:
ALTER TABLE orders ADD INDEX idx_user_create (user_id, create_time);改动之后,这条SQL的执行时间从80毫秒降到2毫秒左右,数据库CPU使用率直接降了40%。这个案例告诉我一个道理:优化SQL之前,先看执行频率。低频但很慢的SQL,再慢也影响有限;高频但“没那么慢”的SQL,才是真正的性能杀手。
2.3 别小看基础规范,写法和数据类型的隐性成本
除了执行计划层面的问题,很多慢SQL的根源在写法和表结构设计阶段就埋下了。
一个常见的坑是函数包裹索引列。比如:
SELECT * FROM users WHERE DATE(create_time) = '2025-01-01';这种写法乍一看没问题,但你只要在索引列上用了函数,索引就失效了,MySQL会对全表每一行都执行一次DATE()计算,再进行比对。数据量一大,必然慢。
正确的姿势是:
SELECT * FROM users WHERE create_time >= '2025-01-01' AND create_time < '2025-01-02';同样的结果,一个走不了索引,一个可以走索引,效率差出好几个数量级。
另一个隐性成本是隐式类型转换。比如user_id是varchar类型,你查询时却传了数字:
SELECT * FROM users WHERE user_id = 12345;MySQL会先把字符串列隐式转换成数字再比较,这个转换过程在索引列上执行,索引又失效了。所以建表的时候类型定义要严谨,查询的时候参数类型也要对得上,这种细节写代码的时候不注意,上线之后DBA来“擦屁股”才痛苦。
还有字符集的影响,如果表本身是utf8mb4,查询条件里传的值是从另一个utf8的表带过来的,连接时MySQL也会做字符集转换,可能导致索引失效。多表关联的时候,特别要检查关联字段的字符集是否一致,不一致就老老实实统一掉。
3. 索引优化:建索引不是越多越好,而是越准越好
3.1 从B+树结构反推索引设计思路
聊索引之前,先把底层数据结构搞清楚。InnoDB的索引用的是B+树,叶子节点存数据,非叶子节点只存索引键值和指针。
B+树有个特点:数据量增长时树的层数增长极慢。3层B+树大概能存2000万条数据(假设每条记录1KB,每个叶子节点16KB能存16条,每层索引节点能存约1000个键值),4层就能上亿。这就是为什么千万级数据的单表查询,走主键查询依然很快的原因。
但索引快不快,取决于你减少了多少次磁盘IO。走索引的本质,是把“全表扫描的N次IO”缩减为“沿树查找的3到4次IO”。所以索引设计的好坏,直接决定了扫描行数的多少。
理解了B+树,你就明白几个原则:
- 索引列要选择区分度高的列。性别这种列区分度只有2,建了索引也大概率被优化器放弃。
- 联合索引要考虑列的顺序。最左前缀原则意味着,查询条件里没有第一个列,索引就走不了。但反过来,如果你查询时最常用的是(a,b,c)中的所有列,那索引设计为(a,b,c)就比(b,c,a)高效。
- 索引要覆盖高频查询。能用覆盖索引的查询,连回表都不用了,直接在索引树上拿结果,速度还能再上一个台阶。
3.2 联合索引的列顺序该怎么排?
这是索引优化里最容易翻车的地方。我见过太多人建联合索引完全不考虑顺序,以为把所有的列都塞进一个索引里就万事大吉。事实恰恰相反,顺序排不好,索引的利用率直接对半砍。
判断列顺序有三条经验法则:
- 第一优先:等值查询的列放在最前面。比如
WHERE user_id = ? AND status = ?,user_id和status都是等值匹配,放在前面的列要有最高的区分度。 - 第二优先:区分度高的列放在区分度低的列前面。比如user_id(唯一)和status(只有几个枚举值),如果两个都是等值查询,user_id必须在前。
- 第三优先:用于排序、分组的列尽量利用索引顺序。把ORDER BY的列加进索引,让排序能直接走索引有序性,避免filesort。
我举个例子。某个用户消息表,高频查询是“查某个用户最近一个月的未读消息”,SQL条件有三个:user_id=等值、status=等值、create_time范围+排序。这时候索引应该建(user_id, status, create_time),而不是(create_time, user_id, status)。前者的范围条件放最后,等值条件先定位,能大幅缩小扫描范围;后者第一个条件就是范围,后面的列在索引里基本用不上。
提示:遇到范围查询(>、<、BETWEEN)的时候,这个列之后的索引列都失效。所以范围条件必须放在联合索引的末尾。这算是一个经典的“索引失效”场景,实际开发中几乎天天遇到。
3.3 回表、覆盖索引和最左前缀,一次讲透
这三个概念是索引优化的核心,但很多人一直模模糊糊。我用大白话讲一遍。
回表:InnoDB的主键索引(聚簇索引)的叶子节点存的是整行数据;二级索引(非主键索引)的叶子节点存的是主键值。你用二级索引查数据,会先在二级索引树上找到主键值,再用主键值到主键索引树上找整行数据。第二次查找就叫“回表”。回表次数越多,IO消耗越大。
覆盖索引:如果你查询的字段全部都在二级索引的键值里,那MySQL压根不用回表,直接在二级索引的叶子节点就把结果取完了。这种索引就叫覆盖索引,是查询优化里最理想的状态。所以有时候建索引要“冗余”一两个并不参与查询条件的返回列,目的就是为了凑成覆盖索引。
举个例子。用户表高频技能查询是:
SELECT user_id, nickname FROM users WHERE mobile = '13800138000';假设mobile有普通索引,那MySQL需要先从mobile索引找到主键id,再回表查nickname。如果你把索引建为(mobile, nickname),那这条查询就能完全走覆盖索引,回表这一步直接省掉。别小看这一步,千万级数据下,回表一万行和直接取一万行,耗时的差距能到好几倍。
最左前缀:联合索引(a,b,c),可以被以下查询用到:
- WHERE a = 1
- WHERE a = 1 AND b = 2
- WHERE a = 1 AND b = 2 AND c = 3
但以下查询用不到:
- WHERE b = 2
- WHERE c = 3
- WHERE b = 2 AND c = 3
你只要记住“从最左边的列开始连续匹配,中间的列不能断”就行。这个特性也决定了,如果你想用联合索引优化两种不同条件的查询,必须慎重设计顺序,做好权衡。
3.4 查看执行计划的手把手教程
索引建没建对,不看执行计划光靠猜,纯属浪费时间。我最常用的工具就是explain,它会把MySQL优化器的执行计划展示给你看。
重点看这么几个字段:
| 字段 | 含义 | 重点关注 |
|---|---|---|
| type | 访问类型 | 至少要到range,最好到ref或eq_ref,出现ALL就是全表扫描 |
| key | 实际用的索引 | 是NULL就说明没走索引 |
| rows | 预估扫描行数 | 这个数字越接近实际数据量越危险 |
| Extra | 额外的执行信息 | 出现Using filesort、Using temporary都是要优化的信号 |
我实地演示一个场景。一张订单表有单列索引idx_user_id,执行以下SQL:
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY create_time DESC LIMIT 10;执行计划里大概率会出现Using filesort。这表示MySQL先用idx_user_id找出了该用户的所有订单,再放到临时内存里去排序。如果这个用户的订单有几千单,排序开销就不小。
这时候,把索引调整为联合索引(user_id, create_time)再来explain,可以看到Extra变成了Using index condition,Using filesort消失了。数据从“先查再排”变成“边查边排”,整体效率高一个量级。
我一直建议团队里的开发:写完一条SQL,顺手跑一遍explain。这习惯一旦养成,线上慢查询至少能消灭一半。
实操心得:如果explain里出现type=ALL(全表扫描),先别急着加索引。先确认这张表的数据量到底多大、查询频率多高。小表全表扫描可能比走索引更快,因为全表扫描的顺序IO比索引的随机IO顺序好,优化器不傻。数据量上来了,或者查询频率上来了,再考虑加索引。
4. 事务与锁:性能问题的隐形炸弹
4.1 锁的粒度与事务隔离级别的取舍
很多时候MySQL性能上不去,不是SQL或索引的问题,而是事务锁竞争导致的。理解锁,要先从InnoDB的锁机制说起。
InnoDB默认的行锁,锁的是索引记录。根据隔离级别的不同,加锁范围差别很大:
- 读未提交:读不加锁,写加锁。会有脏读问题,基本不用。
- 读已提交:MVCC快照读不加锁,但当前读(select for update、update、delete)只对命中的记录加锁。存在幻读问题。
- 可重复读:MVCC快照读不加锁,但当前读除了对命中的记录加锁,还会对扫描范围内的间隙加“间隙锁”,从而防止幻读。这是InnoDB默认隔离级别。
- 串行化:所有读都加锁,并发能力最差。
之所以说锁是性能的隐形炸弹,是因为间隙锁的存在。可重复读级别下,一个范围条件WHERE id BETWEEN 10 AND 20,不仅锁住符合条件的三行,还会把10到20之间的整个间隙锁住。如果这个区间里频繁有插入操作,插入就会被阻塞,表现为“明明只是几行update,却让一堆insert排队”。
很多业务场景其实完全可以降级到读已提交,既避免间隙锁带来的并发阻塞,又能通过MVCC保持读写不互相干扰。MySQL从5.7开始支持在配置里按需切换:
transaction_isolation = READ-COMMITTED切到读已提交之后,间隙锁不再使用,插入操作不再被无谓阻塞。但代价是可能出现幻读,业务上如果对幻读容忍度低,就不能盲目切换。
注意:Binlog格式如果是STATEMENT,且隔离级别降为读已提交,会导致主从数据不一致的风险。这种情况下binlog必须设为ROW格式。改隔离级别前,先检查你的binlog格式配置。
4.2 从死锁日志反推业务逻辑的问题
死锁是行锁竞争发展到极端的表现。每个后端工程师职业生涯里都至少被死锁折磨过一次。但死锁问题往往不是MySQL本身配置不对,而是业务代码里的加锁顺序不一致。
举个真实场景。某个积分系统里,一个事务里需要同时更新用户表和积分明细表。代码是这样写的:
事务A: 1. UPDATE users SET points = points + 10 WHERE id = 100; 2. INSERT INTO points_log (user_id, points) VALUES (100, 10); 事务B: 1. UPDATE users SET points = points - 5 WHERE id = 200; 2. UPDATE users SET points = points - 5 WHERE id = 100;事务A先锁用户100,事务B先锁用户200。当事务A执行完第1步想去锁用户200(如果它有后续操作)时,发现被事务B持有;事务B执行完第2步时需要锁用户100,又被事务A持有。经典的双向等待,死锁发生。
MySQL检测到死锁后,会回滚其中代价较小的事务,然后抛出一个错误码1213。应用层如果不处理这个错误,整个请求就直接失败。
解决死锁有三个方向:
- 统一加锁顺序。不管事务有多大、涉及多少对象,都按照同一个物理顺序去加锁。比如所有涉及用户和积分明细的操作,都先锁用户表再锁明细表,死锁就消失。
- 缩小事务范围。事务里只放必要的操作,无关的查询和计算放到事务外。事务越小,持有锁的时间越短,死锁概率越低。
- 冗余重试机制。捕获死锁错误码后,对整个事务做一次重试。这种方式适合电商下单这类高频场景,简单粗暴但有效。
4.3 长事务的危害,比你想的更严重
很多人没有意识到,一个长事务会把缓冲池里的undo log持续撑大,导致purge线程迟迟清理不掉旧版本数据,最终让表和索引变得臃肿,查询性能持续下滑。这不只是锁阻塞的问题,而是物理空间上的“慢性病”。
我排查过一个线上案例。有个订单处理程序,每处理一批订单会开一个事务,但在事务里做了大量的外部接口调用,一次事务运行超过30秒。这个事务在高峰期运行了十几分钟,结果该表的undo log膨胀到几个GB,同时间段所有针对这张表的查询都明显变慢,连主键查询都从1毫秒退化到50毫秒。
后来改造方案是,把外部接口调用移出事务,事务内部只保留数据库更新操作。事务耗时从30秒缩短到200毫秒,表性能马上恢复。
这里给你一条红线:事务里不要做远程调用,不要等用户输入,不要执行大批量循环更新。事务开启之前就要明确,里面的每一步都得是瞬间能完成的操作。
-- 不好的示例:事务内做远程调用 START TRANSACTION; UPDATE orders SET status = 'paid' WHERE id = ?; -- 调用外部支付结果通知接口(耗时2秒) UPDATE orders SET notify_flag = 1 WHERE id = ?; COMMIT; -- 推荐的示例:远程调用放事务外 -- 先查状态,再调用外部接口,接口返回后单独开短事务更新 START TRANSACTION; UPDATE orders SET status = 'paid' WHERE id = ?; COMMIT; -- 远程通知在这里5. 参数调优的艺术:改配置不只是看文档
5.1 InnoDB缓冲池:最核心的“内存加速器”
说到MySQL参数调优,第一个要动的一定是innodb_buffer_pool_size。这个参数决定了InnoDB在内存里能够缓存多少数据页和索引页。它直接关系到你读写磁盘的频次,也是所有MySQL线上调优里性价比最高的参数。
行业里有一条粗略的经验值:把这个参数设为物理内存的50%到70%。比如一台64GB内存的数据库服务器,建议设置成32GB到40GB左右。但要注意,这只是经验值,不是绝对值。如果机器上还跑了其他服务,比如监控Agent、日志采集器、定时任务,那要留足内存余量,别把机器打到内存交换,那样比缓存不够更可怕。
检查缓冲池够不够用的指标是缓冲池命中率。可以通过show global status来查看:
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';命中率 = (read_requests - reads) / read_requests。一般情况下,业务稳定的系统命中率应该在95%以上,命中率低于90%,要警惕是不是缓冲池太小,或者SQL扫描了大量用不上的数据页。
改这个参数之后怎么验证效果?最简单的方式是跑一个之前明显慢的报表查询,观察耗时是否缩短。我做过一次对比:某报表系统,缓冲池从8GB调到32GB后,一个原本要跑25秒的统计查询,直接降到9秒。这就是内存换性能的直观感受。
注意:MySQL 8.0及以上版本支持动态调整缓冲池大小,不用重启实例。命令是
SET GLOBAL innodb_buffer_pool_size = 34359738368;。建议让缓冲池大小是innodb_buffer_pool_chunk_size的整数倍,避免内存浪费。
5.2 日志刷盘策略:安全与性能的经典博弈
redo log是InnoDB保证数据不丢失的关键机制。每次事务提交,redo log都要刷到磁盘才算安全。但这个“刷盘”动作如果太频繁,会严重影响性能。
innodb_flush_log_at_trx_commit这个参数,控制的就是这个时机:
- 值为1:每次事务提交都强制刷盘。最安全,但性能最差。这是默认值,也是绝对不能随便改的值,除非你能接受丢数据的风险。
- 值为2:事务提交时只写入操作系统缓存,由系统在下一秒左右统一刷入磁盘。性能好一些,但如果MySQL进程崩溃且操作系统没刷盘,可能丢失最近一次写入的数据。
- 值为0:事务提交时完全不主动刷盘,完全依赖后台线程周期性刷盘。性能最高,但崩溃时可能丢失更多数据。
对绝大多数线上业务系统,我建议保持值为1。只有那些数据丢失容忍度高的场景(比如日志、浏览记录、非关键统计),才考虑改成2,换来吞吐量的提升。
另一个和刷盘强相关的参数是sync_binlog:
- sync_binlog = 1:每个事务提交都同步写binlog到磁盘。
- sync_binlog = 0:由系统决定何时落盘。
这两个参数如果同时为1,性能压力最大,但数据安全性最高;同时为0,吞吐量高但丢数据的边界更宽。行业中还有一种折中组合:innodb_flush_log_at_trx_commit=1配合sync_binlog=1跑核心交易;非核心库可以放宽。
5.3 连接数与线程池:什么时候该调?
max_connections是一个看起来很有存在感,但实际上容易被误调的参数。默认值151,很多人一看到连接数不够就往上加,但实际上,连接数加大的同时,MySQL需要为每个连接分配线程和内存,连接数越多,单个连接能分到的CPU和内存就越少,慢请求反而更多。
判断连接数是否够用的依据,不是看max_connections的大小,而是看实际活跃连接数和线程运行状态。
SHOW STATUS LIKE 'Threads_connected'; SHOW STATUS LIKE 'Threads_running';Threads_connected表示当前建立的连接数,Threads_running表示正在执行SQL的线程数。如果Threads_connected接近上限,而Threads_running只有个位数,说明大部分连接都处于睡眠状态(sleep),这时候应该优化应用层的连接池配置,而不是盲目调大max_connections。
反过来说,如果Threads_running很高,比如持续在几十甚至上百,那说明SQL本身很慢,调大连接数只会让SQL排队更严重,甚至拖垮整个实例。
正确的调优顺序是:先用慢查询日志和explain解决SQL问题,再考虑连接数问题。连接参数调优永远是最后一步,而不是第一步。
6. 架构级调优:数据量大了,单机扛不住怎么办?
6.1 读写分离:缓存与一致性该如何权衡?
当单实例的读压力成为瓶颈,最典型的表现是CPU的IO等待上升、主库的读并发打高,这时候第一个可以考虑的架构升级方案就是读写分离。
读写分离的核心思路很简单:主库负责写,从库负责读。通过主从复制把主库的数据变更同步到从库,应用层把读流量分发到从库。一组一主两从的架构,读能力能扩展两倍以上。
但这里有个谁也绕不开的问题:主从复制延迟。MySQL主从复制默认是异步的,从库同步数据有一定延迟,在极端情况下可能达到几百毫秒甚至几秒。如果你的业务刚刚写入一条数据,马上又去读,读到了从库但数据还没同步过来,就会出现刚写入就读不到的情况。
解决这个问题的办法通常是“强制主库读”:比如用户刚下完单,跳转到订单详情页,这个读请求强制走主库,保证读到最新数据;而用户查历史订单这类允许延迟的读,才走从库。
业务上对实时性要求不高的读接口,才适合放到从库。实时性要求高的接口,一律走主库,或者加一层本地缓存做到秒级一致。
还有一个大家经常踩的坑:读写分离后的连接管理。如果应用层的连接池没有区分主从连接,写请求发到了从库,会直接报错“read-only”。所以数据源配置上要拆成两个独立数据源,一个指向主库,一个指向从库,并且在代码层做读写路由。
6.2 分库分表:拆之前要想明白这四件事
分库分表是我最不想看到团队“冲上去就干”的方案,因为一旦拆分下去,JOIN没法用了,事务边界变了,查询也要跟着改,运维复杂度成倍上升。但只要数据量真到了单表几千万、上亿的时候,拆是必然的。
决定分库分表之前,先回答四个问题:
- 数据量真的到了瓶颈吗?单表2000万行以内,只要索引设计合理、SQL没有明显问题,InnoDB一般来说还是能扛的。先做SQL和索引优化,别急着拆。
- 拆分键怎么选?绝大多数业务适合按用户ID或业务主键拆分。拆分键一旦确定,后续所有查询都要尽量带上它。不带拆分键的查询就得走广播路由,性能会退化。
- 数据怎么路由?是取模还是按范围?取模路由数据分布均匀,但扩缩容要迁移数据;范围路由扩容方便,但可能产生热点。没有完美的方案,只有适合当前业务的方案。
- 拆分后的查询怎么做?之前一条JOIN能搞定的关联查询,拆了之后要么冗余字段,要么两次查询在应用层组装。如果一大堆SQL都依赖JOIN,拆分的成本会高到让你怀疑人生。
我遇到过最典型的失败案例是这样:某团队为了“未雨绸缪”,把一个其实只有800万行的表分成了64个库表。结果业务查询普遍不带拆分键,每次查询都要广播到64个分片,每台分片再去扫描一遍自己的800万分之一的数据。最终性能不仅没有提升,反而因为网络开销和分片汇总,变成了灾难现场。
所以我的建议很明确:拆分是最后的手段,拆之前把SQL优化、索引优化、读写分离都试过一遍,确认这些招都使完了还不够,再动分库分表。
6.3 缓存层:MySQL的前置“挡箭牌”
架构层面还有一张低调但极其好用的牌:缓存层。在数据库前面加一层本地缓存或分布式缓存,能挡住绝大多数重复的读请求。
我见过太多系统,同样的热点数据(比如商品详情、用户信息),每秒被同一个查询打上千次,数据库忙得不可开交。其实这些数据完全可以缓存起来,数据变更时再主动失效缓存,剩下的读请求直接命中缓存,压根不用碰MySQL。
缓存层的设计有几个关键点:
- 缓存key要有明确的业务含义,比如user:info:12345、product:detail:678。方便排查问题,也方便人工清理。
- 必须设置过期时间。即使业务上觉得数据可以长期缓存,也建议设一个兜底的过期时间(比如24小时),防止缓存无限膨胀或数据长期不一致。
- 写操作时要主动更新或失效缓存。先写数据库还是先失效缓存,是个经典的竞态问题。我实践下来比较稳的顺序是:先更新数据库,再删除缓存。删除比更新更安全,因为更新缓存时容易产生并发覆盖问题。
- 热点数据要防击穿。一旦某个key过期,瞬间涌进来大量请求全打到数据库,数据库容易被打爆。解决办法是加互斥锁,或者用逻辑过期时间(缓存里存一个逻辑过期时间,后台异步续期)。
缓存的命中率也要关注。命中率太低说明缓存设计有问题,可能在缓存一些根本没人查的数据;命中率太高,也别太得意,得看是不是缓存了太多无用数据,白白占据内存。
7. 综合案例:从慢SQL到全链路优化的完整复盘
7.1 问题现象:一次高峰期服务雪崩的排查过程
前阵子我接手了一个线上服务优化项目,业务场景是一个面向大量C端用户的资讯类应用。每天早高峰时段(7点到9点)用户集中访问,服务偶发超时,严重时直接触发雪崩。
当时观察到的现象有三个:应用层报错增多,错误信息集中在数据库超时和连接池满;MySQL监控中Threads_running持续在40以上,正常时段只有个位数;慢日志里出现大量对同一个内容表的查询。
这个内容表当时的数据量大约5000万行,MySQL实例是16核32G的配置。理论上这个规格扛住这种压力是够的,问题大概率出在设计上。
7.2 定位过程:三层排查法,一步步逼近真相
我采取的是从现象到本质的三层排查法。
第一层:看慢日志,锁定元凶SQL。把慢查询日志阈值临时调到1秒,抓了一个小时,发现高频慢SQL集中在三条查询上。它们都查同一张内容推荐表,where条件不同,但都有一个共同点:在类型字段上用了一个范围查询,排序字段却没有被任何索引覆盖。
第二层:explain执行计划。对这几条慢SQL逐一跑explain,发现type都是ALL,也就是根本没走索引,全部是全表扫描。原因很简单:查询条件里的类型字段区分度太低,优化器判断走索引还不如全表扫描。这就是典型的“逻辑上该走索引,实际上被优化器嫌弃”的情况。
第三层:确认索引设计缺陷。检查表上的索引之后确认,这张表只有一个主键索引和一个针对状态的低效索引。查询条件里的其他字段完全没有可用索引。也就是说,这表已经5000万行了,等于每天都在裸奔。
7.3 方案落地:索引调整、SQL改写与缓存引入三步走
整改分三步,每步之间都有先后依赖关系。
第一步先解决索引问题。分析全部高频查询之后,把查询里的等值条件、排序条件都列出来,设计了两组联合索引。设计原则就是前面讲过的那套:等值条件放前面,范围条件放后面,排序字段尽量进索引。上线之后,原本3到5秒的查询直接掉到50毫秒以内。
第二步改SQL,把一些无效的条件挪走。其中有条SQL原本对内容状态字段做了IN查询,里面包含了一些已经废弃的状态值,优化器计算基数时会因此误判,走了全表扫描。清掉这些无效状态值,加上新索引,执行计划就恢复正常了。
第三步是上缓存层。内容详情接口的读频率极高,一天里同一个内容被反复获取几百次。针对这个特征做了本地缓存加分布式缓存两级缓存,热点内容全部命中缓存,数据库读压力直接下降了70%。
改造完成后的高峰期表现:Threads_running峰值从40降到6,接口P99耗时从2.3秒降到180毫秒。后续连续跑了两周的监控,没有再出现连接池满或者超时报警。
7.4 复盘心得:这个案例教给我的三件事
第一,查问题要按链路来,别跳步。如果你不看连接状态、不看慢日志、不看执行计划,上来就先调参数,很容易把方向带偏。这个案例里最核心的问题就是索引缺失,调任何参数都救不了。
第二,索引设计必须和数据分布匹配。只知道“给查询字段加索引”是不够的,还要知道哪些字段区分度高、哪些字段优化器会嫌弃。区分度太低的字段,要么不单独建索引,要么放到联合索引的靠后位置。
第三,缓存永远比加硬件便宜。这个项目的数据库服务器从16核32G加一路,也可以扛住高峰期,但费用高很多。缓存层把绝大多数重复读挡在了数据库外面,才是性价比最高的方案。
8. 常规优化手段之外,这些细节帮你再多榨20%性能
8.1 用直方图、统计信息辅助优化器做决策
MySQL 8.0开始支持在列上创建直方图。对于数据分布不均匀的列,直方图能给优化器提供更准确的分布信息,帮助它选择更优的执行计划。某些场景下,这比建索引更管用。
直方图的适用场景是那些有索引但优化器依然选错的情况。比如某列上90%的值是0,10%的值是其他数字,你查询等值条件为0的时候,走全表扫描可能更快,但如果优化器对分布一无所知,可能还是会选择走索引,导致大量回表反而更慢。
创建直方图的方法很简单:
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;删除直方图:
ANALYZE TABLE orders DROP HISTOGRAM ON status;直方图的数据不会自动更新,每次大批量数据变更之后需要重新分析。所以我一般只在数据分布相对稳定的列上使用,比如订单状态、会员等级这类字段。
8.2 分批操作,别一把梭更新全表
曾经有一个经典的生产事故:某个运营需要把一张表里几百万行的状态字段统一刷新,开发直接执行了一条UPDATE,结果更新了900万行。那么这个更新操作即便索引正确,执行了20多分钟,期间对该表的其他操作被锁阻塞,线上直接瘫痪。
正确的做法是分批更新。比如每批只更新1000行,做完一批等几毫秒,再做下一批:
UPDATE orders SET status = 2 WHERE status = 1 AND id <= (SELECT max_id FROM ... ) LIMIT 1000;批次之间的间隔能让其他事务有机会插进来执行,不至于长期霸占锁和undo空间。批量操作必须拆批,这是DBA和开发之间的默契。
8.3 定期巡检,把问题解决在爆发之前
最后一条建议,与其说是技术,不如说是习惯。我每次接手一个团队的数据库,都会先建立一套基础的巡检机制。每周跑一遍以下检查项,很多小问题能在变成大事故之前就被发现。
需要巡检的指标可以概括为这么几项:
- 慢查询扫描:每周抓一次慢日志,分析有没有新增的高频慢SQL。
- 索引效率检查:通过performance_schema或者sys库的统计,看看有没有未被使用过的冗余索引,以及被高频使用但字段可以合并的索引。
- 连接数与活跃线程:观察高峰期Threads_running是否持续偏高。
- 表空间增长:检查大表的增长趋势,如果单表即将突破千万行,就要提前设计冷数据归档或者拆分方案。
- 主从延迟:主从复制秒级延迟是正常的,但如果持续延迟超过10秒,就要关注大事务或大DDL操作。
- 缓冲池命中率:如果命中率连续一周低于90%,优先考虑调大缓冲池或优化扫描逻辑。
这些巡检项不一定每次都要花费大量时间,写成脚本定时跑,出结果邮件或者看板推送,周度、月度各看一次就行。一个合格的DBA或者后端负责人,应该是“让问题不过夜”,而不是“等问题爆发再来救火”。
我之前也提过,监控体系的建立是优化工作的前置条件。没有数据支撑的优化,跟闭着眼睛开车没区别。所以如果你问第一步做什么,我会说,先搭监控,再谈优化。