1. 从慢查询日志到执行计划:先把"病根"找出来
我做Java后端这十年,绝大部分性能问题最后都卡在数据库这一层。应用代码再花里胡哨,数据库扛不住一切都是白搭。前几年接手过一个老项目,线上某个列表页接口的P99延迟已经冲到了10秒开外,用户点一次查询得盯着转圈好半天,客服那边的工单都快堆成山了。
当时我做的第一件事不是急着改代码,而是先把慢查询日志打开。很多人拿到慢查询问题第一反应就是"加索引",但索引加在哪个字段、用什么样的索引结构、查询语句到底怎么走执行计划,这些不搞清楚,加索引就是瞎猫碰死耗子。
MySQL的慢查询日志默认是关闭的,需要手动开启。我习惯在MySQL配置文件的mysqld段落里加上这几行:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
long_query_time设置成1秒,意思是执行时间超过1秒的SQL都会被记录下来。log_queries_not_using_indexes这个参数必须开,它会把那些没走索引的全表扫描SQL也记录下来,这类SQL往往是慢查询里最隐蔽的一批。
日志打开之后,我一般会用mysqldumpslow这个自带工具做初步聚合分析,看看哪些SQL出现频率最高、平均耗时最长:
bash复制mysqldumpslow -t 10 -s at /var/log/mysql/mysql-slow.log
这个命令按平均耗时排序,输出Top 10最慢的SQL。拿到这些SQL之后,接下来的关键步骤是用EXPLAIN看执行计划。这一步是判断SQL慢在哪里的核心依据,重点看几个字段:type、key、rows、Extra。
type字段代表访问类型,从快到慢依次是system、const、eq_ref、ref、range、index、ALL。看到ALL就说明是全表扫描,这是最需要警惕的信号。rows字段是MySQL预估的需要扫描的行数,这个数字越大越危险。Extra字段里如果出现Using filesort或者Using temporary,说明排序或去重操作在临时表里进行,性能损耗很大。
举个我当时遇到的真实例子。有一条统计SQL执行了8秒多,EXPLAIN一看:
code复制type: ALL
key: NULL
rows: 1240000
Extra: Using where; Using temporary; Using filesort
1.24亿行?不对,是124万行的表,全表扫描加上临时表排序,不慢才怪。而且这条SQL在业务代码里每天要被调用几百次,等于每天几百次全表扫描。这种问题不定位到执行计划层面,靠肉眼是根本看不出来的。
提示:线上环境开启慢查询日志后,要注意磁盘占用情况。慢SQL多的系统,日志文件增长很快。可以通过pt-query-digest这类工具定期分析归档,不要放任日志无限增长。
慢查询日志只是入口,完整的优化思路应该是:先通过日志找到慢SQL,再用EXPLAIN分析执行路径,找出全表扫描、索引失效、临时表排序这些具体原因,最后才是针对性地做索引设计或SQL改写。这个流程,是后面所有优化动作的基础。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引设计:覆盖索引与联合索引怎么用才不踩坑
定位到问题之后,最直接的优化手段就是索引。但索引这玩意儿,用好了是利器,用不好就是负担。写索引之前必须明白一个道理:索引不是越多越好,每多一个索引,插入、更新、删除时都要多维护一棵B+树,写入性能会跟着下降。所以在设计索引时,我的原则是:能用联合索引覆盖更多查询场景的,绝不多建单列索引。
先看联合索引。联合索引遵循最左前缀原则,也就是说(a, b, c)这个联合索引,可以匹配a、a+b、a+b+c这三种查询条件组合,但直接用b或者c当查询条件时,这个索引是派不上用场的。
举个例子,假设业务里有一个订单表,最常见的查询是按user_id和时间范围查订单列表,那么应该建一个(user_id, create_time)的联合索引,而不是分别建user_id和create_time两个单列索引。这样一条索引就能同时支撑"按用户查"和"按用户+时间查"两类高频查询。
索引字段的顺序也很有讲究,需要根据区分度来排。区分度高的字段放前面,这样索引树能更快地缩小范围。比如user_id的取值可能有几十万种,而status的取值只有几种,那么把user_id放前面明显更合适。如果反过来把status放前面,索引树前几层几乎都在做无意义的比较,效率会差很多。
再看覆盖索引。覆盖索引的意思是,查询需要的所有字段都在索引树上,不需要回表查数据行。这个优化效果立竿见影,尤其是大表查询时,可以省掉大量的随机I/O。
我在优化那些列表查询SQL时,会尽量把SELECT的字段控制在索引范围内。比如当时的业务查询需要展示订单号、用户ID、金额、状态这几个字段,而联合索引覆盖了user_id、create_time、amount,那么再建一个包含order_no和status的索引,让查询走覆盖索引,回表次数就从每次查询都回表变成了零回表。
用EXPLAIN验证时,Extra字段出现Using index就代表走了覆盖索引,这是一个非常理想的信号。
还有一个经常踩坑的地方是索引失效。常见的失效场景有几种:
- 对索引字段使用了函数计算,比如WHERE DATE(create_time) = '2024-01-01',索引会失效,应该改成create_time >= '2024-01-01' AND create_time < '2024-01-02'这样的范围查询。
- 隐式类型转换,比如手机号字段是varchar类型,但查询时用了WHERE phone = 13800001111这种数字条件,MySQL会做隐式转换导致索引失效。
- 前导模糊查询,LIKE '%关键词'这种写法用不上索引,只有LIKE '关键词%'才能走索引。
- OR条件连接时,如果其中一个条件没有索引,整个查询就可能退化成全表扫描。
这些坑听着简单,但在真实项目里出现的频率极高。有一次我排查一个慢查询,EXPLAIN显示type为ALL,查了半天发现是Java代码里传参时把Long类型拼成了String,导致隐式类型转换把索引废掉了。改了一行代码,查询从3秒变成了20毫秒。这个案例给我留下的印象太深了,自那以后,我每次看到慢SQL,都会先确认一下参数类型和字段类型是否严格匹配。
索引优化到这里其实已经能解决相当一部分慢查询了,但对于那些查询条件特别多、变化特别频繁的场景,DBA手工建索引的效率根本跟不上。这个后面会讲到MySQL 8.0的直方图和不可见索引等进阶能力,先把基础打牢。
3. SQL改写:从SELECT *到深分页踩过的坑
索引设计好了,SQL本身如果写得"肥胖",再好的索引也经不起折腾。我接手过的项目里,SELECT *几乎是标配,这玩意儿看着省事,实际危害不小。
SELECT *会把表中所有列都查出来,哪怕业务只需要其中两三个字段。这意味着:
- 查询需要读取更多的数据页,I/O开销变大;
- 如果索引无法覆盖所有列,必然要回表;
- 网络传输的数据量变大,应用服务器和数据库之间带宽被白白消耗。
我把当时那个列表页的SQL从SELECT *改成了只查需要的字段,再去掉几个根本用不上的LEFT JOIN,效果立竿见影。MySQL执行JOIN时,每多关联一张表,都需要额外做一次索引查找和数据读取,关联的表越多,执行成本越高。很多业务场景里,那些JOIN其实完全可以通过冗余字段或者拆分成多次查询来解决。
有一次遇到一个统计报表SQL,一口气JOIN了6张表,里面还有两张表的数据量各在几百万行。当时第一反应是看看JOIN的字段有没有索引。检查后发现,驱动表(LEFT JOIN左边的表)的关联字段有索引,但被驱动表(右边的表)的关联字段没有索引,导致每一行都要全表扫描匹配,复杂度直接是O(n×m)级别。
这种问题有两种改法:一是给被驱动表的关联字段加上索引;二是反向思考,能不能在Java内存里做JOIN。后者在数据量可控的情况下非常有效——先把需要的数据用两次简单查询查回来,在应用层用Map做关联,IO次数变多但单次查询极快,整体响应时间反而更短。
列出来一个典型的改写对比:
sql复制-- 改写前
SELECT * FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.create_time >= '2024-01-01'
AND o.create_time < '2024-02-01'
ORDER BY o.create_time DESC
LIMIT 10000, 20;
-- 改写后
SELECT id, user_id, amount, status, create_time
FROM orders
WHERE create_time >= '2024-01-01'
AND create_time < '2024-02-01'
ORDER BY create_time DESC
LIMIT 10000, 20;
索引优先命中orders表,去掉不必要的JOIN后,查询路径大大缩短。但这还没完,LIMIT 10000, 20这种深分页写法本身就是个坑。
深分页问题的本质是:LIMIT 10000, 20并不是只查20条,而是先把前10020条全部查出来,然后丢弃前10000条才返回。数据量越大,偏移量越大,数据库要扫描的行数就越多,性能自然就崩了。哪怕前面所有优化都做了,深分页照样能把查询拖到秒级。
我当时处理深分页用的是延迟关联方案。思路是先利用覆盖索引快速定位到目标行的主键ID,然后用主键ID去反查完整数据行。因为覆盖索引不需要回表,所以前面的扫描过程非常快。
sql复制-- 延迟关联
SELECT o.id, o.user_id, o.amount, o.status, o.create_time
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE create_time >= '2024-01-01'
AND create_time < '2024-02-01'
ORDER BY create_time DESC
LIMIT 10000, 20
) tmp ON o.id = tmp.id
ORDER BY o.create_time DESC;
优化后实测,这一条SQL从原来的3.5秒降到了0.2秒以内。内层子查询只查主键ID,可以走覆盖索引快速跳过偏移量,外层再通过主键回表取详细数据,虽然回表次数不变,但内层扫描的代价大幅降低。
除了延迟关联,深分页还能用游标分页(Keyset Pagination)来根除。基于上次查询结果的最大ID或最大时间戳作为下一页查询的起点,不再使用LIMIT大偏移,而是用WHERE条件直接定位:
sql复制WHERE create_time < '2024-01-20 00:00:00'
ORDER BY create_time DESC
LIMIT 20
这种方案在数据无限增长时依然稳定,非常适合无限滚动场景。不过实现起来需要业务配合,把上一页的最后一个排序字段值传给下一页,整体改动量略大,适合在性能敏感的核心链路上用。
SQL改写的核心思想就是一句话:让数据库做最少的事,不要把大量数据拉回来再在应用层过滤。凡是能在WHERE条件里写完的逻辑,绝不用Java代码去循环判断。
4. 事务与锁:被低估的隐藏慢查询杀手
很多人在优化慢查询时,眼里只看得到SQL本身,忽略了事务和锁的问题。但实际上,一个事务长时间占用行锁,或者一个锁等待链路过长,都会让一条本来很快的SQL卡到怀疑人生。
我遇到过最典型的一个案例:一个Update语句本身执行只要10毫秒,但在线上却稳定地跑出5秒以上的延迟。看慢查询日志,这条SQL确实被记录下来了,但EXPLAIN分析执行计划完全正常,索引也走得很好。
问题出在哪儿?后来一步一步排查,发现是另一个事务在处理大批量数据时,开启了事务但没有及时提交。那个事务里更新了几万行数据,把一批行锁都握在手里,而我的Update语句要更新的那行恰好也在其中,所以只能阻塞等待锁释放。
排查这类问题,MySQL提供了几个很有用的视图:
sql复制-- 查看当前正在执行的线程
SHOW FULL PROCESSLIST;
-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看锁等待关系
SELECT * FROM sys.innodb_lock_waits;
sys.innodb_lock_waits这个视图直接能看出谁在等谁。通过waiting_pid和holding_pid,能迅速定位到是哪个会话持有锁导致阻塞,然后进一步查看那个会话正在执行什么SQL、开启了多少时间的事务。
定位到问题之后,处理方式有两种:一是让持有锁的会话尽快提交或回滚;二是在代码层面控制事务粒度,避免在一个事务里做太多事情。
事务粒度这个问题,新手特别容易忽略。我见过有人在Spring的@Transactional方法里写了循环,每个循环里做一次或多次数据库更新,整个事务要跑好几秒甚至十几秒。事务期间,所有被更新的行锁全部由这个线程持有,其他任何线程想动这些行都只能等着。并发一上来,系统立刻从"每秒处理几百个请求"退化到"几百个请求排队等锁"。
正确做法是:把大事务拆小。读操作不要放到事务里,写操作尽量控制在几百条以内,事务时间尽量压到百毫秒级别。Spring的@Transactional默认只对RuntimeException回滚,检查异常不会触发回滚,这个也容易出问题,建议在事务方法里做好异常处理,避免事务长时间挂着不提交。
还有一种情况是死锁。死锁发生的原因通常是两个事务以不同的顺序锁定了相同的数据行。MySQL的InnoDB引擎会自动检测死锁,并把其中一个事务回滚掉,让另一个继续执行。但被回滚的那个事务,代价就大了——它的所有操作都白干了,应用层如果没做好重试,用户就会看到一个应用报错。
死锁一旦发生,应用日志里会出现Deadlock found when trying to get lock; try restarting transaction的异常。解决死锁的思路,核心是让所有事务以相同的顺序访问数据行。比如涉及订单和支付单两个表时,统一先锁订单表再锁支付单表,不要一个事务先订单后支付、另一个事务先支付后订单,那迟早要出问题。
我还碰到过一个更隐蔽的锁问题:某个表没有主键,或者主键设计不合理(比如用随机字符串做UUID主键),InnoDB内部会用隐藏的rowid来组织索引。此时按非主键条件更新数据时,锁的粒度不可控,可能锁住比预期更多的数据行。这类问题排查起来最费时,到最后才发现是表结构设计埋下的坑。
注意:对线上大表做DDL变更(比如加索引、加字段)时,老版本的MySQL会锁表,导致所有读写请求被阻塞。MySQL 8.0虽然默认支持了在线DDL,但执行期间依然会产生额外的负载,建议在业务低峰期操作,并监控当时的活跃连接数。
事务和锁这块,我的经验是:先把"谁持锁、谁等待"查清楚,再动手改代码。没有定位到具体阻塞源之前,瞎重启应用或者乱杀线程,只是治标不治本,过一会儿问题还会复发。
5. 分页查询的"降维打击":延迟关联、流式查询与缓存方案
第3小节提过延迟关联和游标分页,但那更像是SQL层面的小修小补。这一节专门聊聊分页查询这个场景——因为它几乎是慢查询重灾区,值得单独说透。
分页查询慢,最常见的两种表现是:翻页越深越慢、并发一高就崩。排除掉前面讲的深分页原因,还有两个容易被忽略的因素:一是排序字段没有索引,每页都要做filesort;二是查询条件太宽泛,返回的结果集过大。
先说排序字段。ORDER BY create_time DESC时,如果create_time没有索引,MySQL就要先把符合条件的所有行找出来,然后在内存或磁盘上做排序。排序的数据量一大,就会用磁盘临时文件,这种排序的效率极低。给排序字段加上索引后,因为B+树本身是有序的,遍历索引就能按顺序取出结果,filesort直接消失了。
但这里有个矛盾:如果WHERE条件和ORDER BY条件分别用不同的索引,MySQL只能选择其中一个走,另一个条件就只能靠回表过滤。这时候联合索引就派上用场了,把WHERE条件和ORDER BY字段一起做成联合索引,既能过滤又能排序,一举两得。
再一个容易被忽视的场景是:分页接口里返回了总数COUNT。很多列表页需要在分页数据之外,额外返回一个总条数,用于渲染分页控件。这导致每次翻页都要执行一次COUNT(*)。大表上的COUNT很贵,因为它需要扫描全部符合条件的行。
解决COUNT性能的思路有几种。第一种是精确性要求不高的场景,直接用EXPLAIN里的rows估算值,或者缓存一个近似值,比如第一次查询时拿到总数后放进Redis,一段时间内复用。第二种是把COUNT从主查询链路里拆出去,用异步任务预计算,或者通过单独的统计表实时更新。
对于那种"只翻前几页、后面的分页几乎没人看"的业务,有一个更极简的方案:前10页走普通分页,超过10页就提示用户使用筛选条件缩小范围,或者改用"加载更多"的游标模式。产品层面做一点让步,技术层面的复杂度能下降一大截。
流式查询是另一种思路,或者说它根本不是分页方案,而是"不分页"的方案。有时候业务需要把大量数据导出来做报表或同步,这种场景如果用分页查,每页一条SQL,几百万条数据要执行几万次查询,数据库直接被打满。正确的做法是用MySQL的游标流式读取,让JDBC驱动一行一行地消费结果集,而不是一次性把全部数据加载到内存里。MyBatis的Cursor接口就是干这个的:
java复制@Mapper
public interface OrderMapper {
Cursor<OrderDO> scanAllOrders(@Param("startTime") LocalDateTime startTime);
}
用Cursor时需要注意,整个查询期间要一直持有数据库连接,不能中途断开,同时消费完要立刻关闭。这类操作要放到独立的线程池里执行,避免占用业务线程太久。
最后还要说说分页和缓存的配合。如果同一类分页请求的重复度很高,完全可以做几分钟粒度的缓存。我自己比较常用的做法是,对于热点数据列表页(比如首页推荐、热门排行),直接把第一页第二页的响应结果放到Redis里,过期时间设个30秒到2分钟。用户翻到这两页时直接命中缓存,数据库连一次都不用查。
实际上把延迟关联、排序索引和必要的缓存三者结合起来,分页查询的性能几乎能做到无感。当年那个10秒的列表页,做完这三个动作后,P99直接掉到了500毫秒左右,已经很理想了。
6. 从MySQL 8.0的新特性里"白嫖"性能:直方图、不可见索引、Hash Join
很多时候,慢查询不是因为SQL写得不好,也不是索引不够,而是MySQL的优化器没有选对执行路径。MySQL 8.0在这方面带来了一些新特性,用好它们,很多优化根本不需要改动业务代码。
先说直方图(Histogram)。MySQL的优化器在选择执行计划时,需要估算条件的过滤性,以此判断走哪个索引更划算。如果表上的数据分布很不均匀,比如status字段90%都是1、10%是0,优化器在没有统计信息的情况下,会认为status=0和status=1返回的行数差不多,从而选出糟糕的执行计划。直方图的作用就是给优化器提供更准确的数据分布信息,让它做出更合理的判断。
创建直方图的语法很简单:
sql复制ANALYZE TABLE orders UPDATE HISTOGRAM ON status;
创建完之后,再用EXPLAIN看执行计划,优化器估算的rows会明显变得更贴近实际。不过要注意,直方图对等值查询的优化效果最明显,对范围查询的帮助有限,不要指望它能解决所有问题。
再说不可见索引(Invisible Indexes)。这个特性特别的实用。以前想确认一个索引到底有没有被用上,只能删掉索引然后观察,风险很大——万一删掉之后查询直接退化了,DBA的内心是崩溃的。MySQL 8.0允许把索引设置为不可见,优化器在生成执行计划时完全忽略它,但索引本身和数据仍然保持同步维护。
sql复制ALTER TABLE orders ALTER INDEX idx_user_time INVISIBLE;
如果确认线上查询都不需要这个索引了,再真正DROPe掉;如果发现查询性能下降了,一条SQL就能改回VISIBLE。这个操作让索引的灰度下线变得非常安全,强烈建议在清理冗余索引时用这个特性。
然后是Hash Join。在MySQL 8.0之前,MySQL对关联查询只支持Nested Loop Join(嵌套循环连接),被驱动表如果没走索引,性能会非常差。8.0引入了Hash Join,在关联字段没有索引、且数据量较大时,它会先在内存里为一张表构建哈希表,然后遍历另一张表做匹配,效率比嵌套循环高得多。
当时我那个6张表JOIN的报表,升级到MySQL 8.0后,即使没优化索引,执行时间也从原来的6秒降到了2.8秒。虽然最终靠索引优化把耗时压到了300毫秒,但Hash Join确实是一条退路——当一个复杂的即席查询实在优化不动时,它至少能撑住场面。
最后提一下优化器提示(Optimizer Hints)。虽然我不推荐一上来就用hints干预优化器,但某些场景下,优化器的选择确实很离谱,比如明明有更合适的索引,它偏要选一个区分度很差的。MySQL 8.0支持通过hint强制指定索引:
sql复制SELECT /*+ INDEX(orders idx_user_time) */
id, user_id, amount
FROM orders
WHERE user_id = 12345
AND create_time >= '2024-01-01'
ORDER BY create_time DESC;
hint是最后的手段,因为它把SQL和具体索引绑定了,一旦索引名变更要同步改代码。用之前,先确认是不是统计信息过期导致优化器判断失误,用ANALYZE TABLE刷新统计信息才是治本。
MySQL 8.0还有一个值得提的功能是反连接优化(Anti Join)。对于NOT EXISTS和NOT IN这类查询,8.0也有了更好的执行策略,但前提是子查询和外层表之间的关联字段有索引,同时参数类型保持一致。
这些新特性用的好,确实能"白嫖"不少性能,尤其是直方图和不可见索引这两个,操作成本低、风险小,对DBA特别友好。建议在测试环境先验证一轮,确认无害之后再推到生产。
7. 缓存与架构兜底:Redis、多级缓存和查询链路优化
当SQL层面能优化的都优化完了,如果仍然有部分慢查询高频发生,就该考虑缓存了。这里说的缓存不是简单地往Redis里塞数据,而是要考虑缓存什么、缓存多久、缓存失效了怎么办,以及怎么避免缓存击穿、穿透、雪崩。
先说我通常的缓存分层思路:本地缓存(Caffeine)放在最前面,Redis放中间,数据库在最后。本地缓存是毫秒级访问,Redis是亚毫秒级,能拦掉大部分热点请求。
以订单列表为例,用户的请求来了以后:
- 先查Caffeine本地缓存,命中直接返回;
- 未命中则查Redis,命中就回填Caffeine并返回;
- Redis也没有,才走数据库查询,查完之后依次回填Redis和Caffeine。
这套多级缓存的核心价值在于:即使Redis扛不住了,本地缓存仍然能缓冲一层,不至于直接打到数据库。
缓存更新策略上,我倾向于Cache-Aside模式:读请求先读缓存,不命中再读库并回填;写请求先更新数据库,再删除缓存。这个模式看起来简单,但有一个经典的并发问题:线程A读缓存未命中,去数据库查到旧数据,还没回填缓存;线程B更新了数据库并删除了缓存;然后线程A把旧数据写回了缓存。这个问题用延迟双删能在一定程度上缓解:更新数据库后先删缓存,等几百毫秒再删一次。不过延迟双删并不是绝对可靠,更稳的方案是给缓存设置一个较短的过期时间,比如5分钟,即使短暂不一致,也不会持续太久。
缓存穿透是另一个高频问题。所谓穿透,就是查一个根本不存在的数据,缓存里没有,数据库里也没有,请求每次都直接打到数据库,这种情况只要有人恶意构造请求,数据库瞬间就会被打垮。解决方案很简单但有效:
- 布隆过滤器拦截:系统启动时把存在的ID列表预加载到布隆过滤器,请求的ID不在布隆过滤器里就根本不放行。布隆过滤器的特点是"一定不存在"和"可能存在",它有误判率但能挡住绝大部分非法请求。
- 空值缓存:即使数据库没有查到数据,也把"null"这个结果缓存到Redis,过期时间设短一点,比如60秒。这样同一个不存在的ID反复请求时,直接命中缓存的null,不会打到数据库。
缓存击穿和雪崩的区别也得说清楚。缓存击穿指某个热点的key在某一瞬间过期,大量请求同时涌向数据库。雪崩指大量key在同一时间段集体失效,数据库压力瞬间暴增。解决击穿用互斥锁——缓存未命中时只允许一个线程去数据库查询,其他线程等待锁。解决雪崩就简单了:设置过期时间时加上随机抖动,让key的过期时间均匀分布。
Redis本身虽然在内存里,但它也有性能边界。Redis慢查询日志可以排查那些执行时间长的命令。像KEYS *这种命令,在大key很多时会让Redis阻塞秒级,线上绝对禁止使用。大key(比如一个list里有几十万条数据)也尽量拆分成多个小key,降低单次操作成本。
能缓存解决的问题解决了之后,剩下的就是架构层面的分流。比如把报表查询、数据分析这类重IO的查询迁移到只读从库上,让主库专注处理写事务。读写分离的落地并不复杂,用ShardingSphere的读写分离功能,或者在数据源层面做简单封装,主库负责写,从库负责读,通过负载均衡把读流量分散到多台从库。需要留意的是主从复制的延迟,刚写完主库立刻去读从库可能读到旧数据,业务上要做一定的容忍,或者对强一致的场景走主库。
再往下就是分库分表了。但到了那一步,说明业务增长速度已经远超常规优化手段的承受范围,而且分库分表带来的是无穷无尽的复杂性。我是建议除非万不得已,先用好查询优化、索引、缓存、读写分离这四板斧,分库分表是最后一个选项。
以当时的订单查询系统为例,做过Redis缓存之后,重复的查询请求几乎不会再打到数据库。那些真正需要落到数据库的查询,因为前面做了大量SQL层优化,单次耗时也控制在了百毫秒级别。整个链路从用户点击到返回,P99从原来的10秒降到了0.1秒。
8. 压测、监控与常态化治理:防止慢查询"春风吹又生"
优化做完并不意味着大功告成。线上系统是动态变化的,数据量一天天涨,业务逻辑不断迭代,今天优化的SQL可能下周就变成新的瓶颈。如果没有一套常态化的监控和治理机制,慢查询很快会卷土重来。
先说压测。优化之后不能只凭感觉说"快了",要有数据支撑。我一般用JMeter或者wrk做一轮简单的压测,看看吞吐量(TPS)和响应时间(RT)的变化。压测时要关注两个关键指标:P99和错误率。P99代表99%的请求都在这个时间以内,能反映整体体验;错误率则是判断系统是否稳定的底线。如果P99压下来了,但错误率上升,说明系统在高并发下出现了其他问题,比如连接池不够用、GC频繁等,这也是性能优化的一部分。
压测除了验证优化效果,还能帮我们确定系统的容量上限。比如当前配置下能支持多少QPS,超过多少开始恶化,这对后续的容量评估和扩容计划非常有参考价值。
然后是监控。监控体系我习惯从三个维度搭建:
-
数据库监控:用Prometheus + mysqld_exporter监控MySQL的关键指标,包括QPS、TPS、慢查询数量、活跃连接数、InnoDB行锁等待时长等。慢查询数量可以直接接到告警规则里,比如连续5分钟慢查询数超过某个阈值就触发告警。
-
应用监控:用SkyWalking或者Micrometer来监控接口的响应时间、SQL执行时间、JVM的GC情况。接口慢不一定全是SQL的锅,GC停顿、远程调用超时都有可能。只有链路追踪能告诉我们时间到底花在了哪里。
-
业务监控:这个经常被忽略。同样的SQL,不同参数下执行时间差异巨大。监控那些关键业务接口的分位数耗时,比如订单列表接口的P50/P95/P99,能第一时间发现异常波动。
Prometheus告警规则简单示例:
yaml复制groups:
- name: mysql.rules
rules:
- alert: MySQLSlowQuery
expr: mysql_global_status_slow_queries > 100
for: 5m
labels:
severity: warning
annotations:
summary: "MySQL slow query count is high"
description: "Current slow queries: {{ $value }}"
有了监控和告警,慢查询问题就能在用户感知之前被发现和处理,而不是等客服反馈了才去查日志。
除了监控,日常的治理规范同样重要。我对团队的SQL开发流程立了几条规矩:
- 所有SQL上线前必须通过EXPLAIN审查,确认没有全表扫描和filesort。
- 新功能开发前,先评估涉及的表的数据量,超过百万行的表默认要求给出索引设计。
- 禁止在线上执行没有WHERE条件的UPDATE/DELETE,这类操作必须先备份数据。
- 线上慢查询日志定期复查,发现的慢SQL要在两个工作日内给出优化方案。
- 定期用pt-query-digest分析慢查询日志,建立慢查询基线,监控基线的变化趋势。
这些规矩看起来很基础,但坚持做下来,能让整个团队的数据库操作水平维持在一个稳定的水位线上。很多时候,系统性能的劣化不是因为某一次操作失误,而是长期无人维护、小问题不断积累的结果。
最后还有一点想说的:多关注MySQL本身的一些隐患。比如大事务长时间不提交,undo log膨胀,可能导致版本链过长,影响所有查询的可见性判断。还有MySQL 8.0默认的隔离级别是REPEATABLE READ,某些只读场景其实不需要这么高的隔离级别,改成READ COMMITTED能减少一部分锁竞争的开销。
优化是一个持续迭代的过程。我刚接手那个项目时,第一次优化后性能从10秒降到了1秒,后来通过索引和缓存进一步压到0.1秒,但这个结果不是终点。数据量继续增长、业务继续扩展,每隔一段时间还是要重新审视一遍这些优化是否依然有效。把压测、监控、规范审查做成固定流程,慢查询才不会成为反复发作的顽疾。
