你有没有遇到过这种绝望的时刻:监控大屏上的QPS(每秒查询率)曲线像心电图一样飙升,用户反馈页面卡成PPT,你盯着数据库CPU占用率100%报警,脑子里一片空白,不知道是该重启、加机器,还是直接甩锅给网络?别慌,这种场景我经历过太多次了。今天咱们不整那些虚头巴脑的理论,就实打实地,像剥洋葱一样,把高并发下数据库瓶颈这个难题,从根儿上给它刨出来,一步一步教你如何诊断、优化,直到让页面重新丝滑起来。
第一步:先别急着动刀,找准“凶手”在哪里
很多新手一看到数据库慢,第一反应就是:“哦,肯定是SQL写得烂,改一下。” 停!先别动手。盲目优化就像盲人摸象,可能摸到的是大象腿,以为那是根柱子。你得先搞清楚,到底哪里卡了?是SQL本身慢?是查询次数太多?还是缓存失效导致请求全打到库上?
1.1 开启慢查询日志,让数据库“自曝家丑”
MySQL最直接的证据就是慢查询日志(Slow Query Log)。这是诊断数据库性能问题的金标准。你需要确认两件事:
首先,确认慢查询日志是否开启,以及阈值设得合不合理。
你可以登录MySQL,执行以下命令查看当前状态:
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';
slow_query_log:值为ON表示已开启。long_query_time:记录执行时间超过这个值(单位:秒)的SQL。默认通常是10秒,在高并发场景下,10秒太宽容了,可能导致很多该优化的慢查询被漏掉。建议先临时调整为0.5秒甚至0.1秒,把潜在问题都捞出来。slow_query_log_file:日志文件的路径,方便你后续查看。
如果日志没开,或者阈值太高,先调整:
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 调整阈值,比如0.5秒
SET GLOBAL long_query_time = 0.5;
-- 注意:SET GLOBAL 设置的参数在MySQL重启后会失效。
-- 如果要永久生效,需要修改 my.cnf 配置文件:
-- [mysqld]
-- slow_query_log = 1
-- slow_query_log_file = /var/log/mysql/mysql-slow.log
-- long_query_time = 0.5
其次,分析慢查询日志。
日志文件通常很大,手动看会看吐。推荐使用 mysqldumpslow 工具,它能帮你汇总和排序。
# 按查询次数排序,看最多的慢查询
mysqldumpslow -s c -t 10 /var/log/mysql/mysql-slow.log
# 按平均执行时间排序,看最耗时的慢查询
mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
# 按锁定时间排序,看哪些查询阻塞了其他请求
mysqldumpslow -s l -t 10 /var/log/mysql/mysql-slow.log
-s 是排序方式(c:次数, t:时间, l:锁定时间, r:返回记录数),-t 是显示前几条。
通过这一步,你通常会拿到一个“十大慢查询排行榜”。这时候,你的问题缩小到了一小撮SQL上。 别急,接着往下看。
1.2 用 EXPLAIN 剖析每一行慢SQL
找到慢SQL后,千万不要直接开始改索引、改代码。你得先“看穿”它的执行计划。
对每一条慢SQL,执行 EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND status = 'paid' ORDER BY create_time DESC LIMIT 10;
EXPLAIN 的输出里有几个关键字段,你要重点盯着看:
| 字段 | 含义 | 你需要关注的点 |
|---|---|---|
id |
查询的ID | 如果有多个,id 越大优先级越高(子查询情况)。 |
table |
涉及的表 | 是否有太多表关联? |
type |
访问类型 | 这是最重要的! 从好到差大致是:system > const > eq_ref > ref > range > index > ALL。ALL 是全表扫描,必须优化! range 是索引范围扫描,通常可接受。 |
key |
实际使用的索引 | 是否为 NULL?如果是,说明没用上索引。 |
key_len |
使用的索引长度 | 越短越好,但也要看是否用全了。 |
rows |
估算扫描的行数 | 这个数字直接反映查询效率。如果表很大但 rows 也很大,说明索引效果差。 |
Extra |
额外信息 | 最忌讳看到 Using filesort 和 Using temporary。Using filesort 意味着要在内存或磁盘里排序;Using temporary 意味着用了临时表,通常是GROUP BY或DISTINCT没用好索引导致的。Using index 是好事,说明是覆盖索引。 |
举个例子:
假设 EXPLAIN 结果里 type 是 ALL,key 是 NULL,rows 是100万,Extra 是 Using where。这说明什么?这条SQL在对100万行的表做全表扫描,然后在结果里过滤。 这就是典型的“坏SQL”,必须加索引或者改查询逻辑。
如果 type 是 ref,key 是 idx_user_id,但 rows 还是很大(比如5万),说明索引选择性太差,可能需要考虑复合索引或者优化查询条件。
记住:EXPLAIN 是你的X光机,能看清SQL的“骨骼”结构。没看懂 EXPLAIN 就动手优化,等于蒙着眼修表。
第二步:对症下药,优化慢查询本身
通过 EXPLAIN,你已经知道了慢SQL的病因。现在,针对性地开方。
2.1 索引优化:让查询“抄近道”
原则:小表不大查,大表不细查。
创建合适的索引
- 最左前缀原则:复合索引
(a, b, c),查询条件必须包含a才能用上索引,(a, b)可以,a=1 AND c=2只能用a的索引部分。 - 索引列尽量细:把区分度高的列放在前面。比如
status只有几种值,区分度低,放后面;user_id区分度高,放前面。 - 避免在索引列上做计算或函数操作:
WHERE YEAR(create_time) = 2023会导致索引失效,因为对列做了函数运算。应该写成WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01'。
- 最左前缀原则:复合索引
覆盖索引(Covering Index)
- 如果查询的字段都在索引里,MySQL可以直接从索引中返回结果,不需要回表查数据行,极大提升性能。
- 比如:
SELECT user_id, status FROM orders WHERE user_id = 123;如果索引是(user_id, status),这就是覆盖索引,Extra会显示Using index。
避免
SELECT *- 只查需要的字段。
SELECT *会取回所有列,可能包含大字段(如TEXT、BLOB),浪费I/O和内存,也更容易导致覆盖索引失效。
- 只查需要的字段。
限制返回行数
- 分页查询时,
LIMIT offset, size在offset很大时性能极差,因为MySQL要扫描并丢弃前offset行。可以用“延迟关联”优化:-- 慢查询 SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10000, 10; -- 优化后:先查主键,再关联回原表 SELECT o.* FROM orders o INNER JOIN (SELECT id FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10000, 10) AS tmp ON o.id = tmp.id;
- 分页查询时,
2.2 改写SQL逻辑:减少数据库负担
有时候,索引加得再好,也救不了逻辑上的愚蠢。
避免大事务
- 事务锁定的资源会阻塞其他请求。尽量缩小事务范围,只把真正需要原子性的操作包在事务里。
避免长连接持有
- 不要在一个长连接上连续发慢查询,这会长时间占用连接池资源。用完后尽快释放。
拆分复杂查询
- 如果一条SQL关联了5张以上的大表,或者包含复杂的子查询和聚合,考虑在应用层拆分。比如,先查出ID列表,再分批查详情,或者用Elasticsearch等专门做复杂查询和聚合的引擎。
批量操作代替循环单条
- 在应用代码里,避免
for循环里一条条INSERT或UPDATE。用INSERT INTO ... VALUES (...), (...), (...)批量插入,或者用UPDATE ... WHERE id IN (...)批量更新。这能显著减少网络往返和数据库解析开销。
- 在应用代码里,避免
2.3 表结构优化
选择合适的数据类型
- 越小越好。能用
TINYINT就别用INT,能用VARCHAR(20)就别用VARCHAR(255)。节省存储空间,也提高索引效率。 - 避免使用
NULL,尽量给字段默认值。NULL会让索引统计和查询计划变得复杂。
- 越小越好。能用
垂直分表
- 把大字段(如长文本、图片URL)拆分到扩展表,主表只留核心字段。这样主表更“瘦”,查询主表时I/O更少。
水平分表(分库分表)
- 当单表数据量超过千万级,且业务允许时,考虑按某个字段(如
user_id)进行分片。这是终极大招,架构改动大,但能彻底解决单表性能瓶颈。注意,分表后,跨分片的查询会变得复杂,需要提前做好设计。
- 当单表数据量超过千万级,且业务允许时,考虑按某个字段(如
第三步:引入缓存,给数据库“减负”
优化完SQL和索引,数据库性能提升了,但如果并发量继续涨,或者热点数据非常集中,单靠数据库还是扛不住。这时候,缓存就是救命稻草。
3.1 为什么需要缓存?
数据库的瓶颈在于磁盘I/O和计算。缓存(通常是内存数据库如Redis)将热点数据放在内存里,读写速度比磁盘快几个数量级。当请求命中缓存时,根本不用去查数据库,直接返回数据,极大地减轻了数据库压力。
3.2 缓存架构选型
最常用的组合:Redis + MySQL。
Redis支持多种数据结构(String, Hash, List, Set, ZSet),灵活性高,性能极强(单Key读写可达10万+/秒)。
3.3 缓存的使用模式
模式一:Cache-Aside(旁路缓存)—— 最常用
读:
- 先读缓存。
- 缓存命中,直接返回。
- 缓存未命中,读数据库,将结果写入缓存,再返回。
写:
- 先更新数据库。
- 再删除缓存(注意:是删除,不是更新!)。
为什么写的时候删除缓存而不是更新? 假设你更新缓存,可能会出现并发问题:线程A读DB得到旧值,线程B更新DB为新值,线程A将旧值写回缓存,导致缓存数据与DB不一致。而删除缓存后,下次读时会重新从DB加载最新数据,保证了最终一致性。
// 伪代码示例
public Order getOrder(long orderId) {
// 1. 查缓存
String cacheKey = "order_" + orderId;
String orderJson = redis.get(cacheKey);
if (orderJson != null) {
return JSON.parseObject(orderJson, Order.class);
}
// 2. 缓存未命中,查数据库
Order order = orderMapper.selectById(orderId);
if (order != null) {
// 3. 写入缓存,设置过期时间(防止缓存雪崩)
redis.setex(cacheKey, 3600, JSON.toJSONString(order));
}
return order;
}
public void updateOrder(Order order) {
// 1. 先更新数据库
orderMapper.updateById(order);
// 2. 再删除缓存
String cacheKey = "order_" + order.getId();
redis.del(cacheKey);
}
模式二:Read/Write Through(读写穿透)
应用层只和缓存打交道,缓存负责与数据库同步。这通常由缓存中间件(如Memcached的某些客户端)提供支持,或者自研。开发复杂度较高,但应用代码更简洁。
模式三:Write Behind(写回缓存)
应用层写缓存,缓存异步批量写入数据库。性能最高,但数据丢失风险大(缓存挂了,数据就丢了)。适用于对一致性要求不高、但追求极致写性能的场景(如日志、计数器)。
对于大多数业务系统,Cache-Aside 是首选。
3.4 缓存的致命问题及解决方案
缓存不是银弹,用不好会引入新问题。
问题一:缓存穿透
现象: 查询根本不存在的数据,缓存和数据库都查不到,每次请求都打到数据库。攻击者可能故意用大量不存在的ID攻击,导致数据库崩溃。
解决方案:
- 缓存空值: 如果数据库查不到,也将一个空对象(或特殊标记)缓存起来,设置一个较短的过期时间(如5分钟)。下次查询直接命中空缓存,不再查DB。
- 布隆过滤器: 在缓存层之前加一个布隆过滤器,把所有可能存在的key都放进去。查询前先过布隆过滤器,过滤掉肯定不存在的key。
// 缓存空值示例
if (order == null) {
// 缓存一个空对象,过期时间设短一点
redis.setex(cacheKey, 300, "NULL");
return null;
}
if ("NULL".equals(orderJson)) {
return null;
}
问题二:缓存击穿
现象: 某个热点key(如爆款商品)在过期瞬间,大量请求同时打到数据库,导致数据库压力剧增。这不同于穿透,key是存在的,只是刚好过期了。
解决方案:
- 永不过期: 从逻辑上让key不过期(但需要定期更新)。
- 互斥锁(Mutex Key): 当缓存失效时,不直接查DB,而是先加一把分布式锁(如Redis SETNX),只有一个线程去查DB并重建缓存,其他线程等待或重试。
// 互斥锁示例
public Order getOrderWithMutex(long orderId) {
String cacheKey = "order_" + orderId;
String orderJson = redis.get(cacheKey);
if (orderJson != null) {
return JSON.parseObject(orderJson, Order.class);
}
// 缓存未命中,尝试获取锁
String lockKey = "lock_order_" + orderId;
boolean locked = redis.setnx(lockKey, "1", 10); // 锁10秒
if (locked) {
try {
// 双重检查,防止并发重建
orderJson = redis.get(cacheKey);
if (orderJson != null) {
return JSON.parseObject(orderJson, Order.class);
}
// 查数据库
Order order = orderMapper.selectById(orderId);
if (order != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(order));
} else {
redis.setex(cacheKey, 300, "NULL");
}
return order;
} finally {
// 释放锁
redis.del(lockKey);
}
} else {
// 没抢到锁,稍后重试
Thread.sleep(50);
return getOrderWithMutex(orderId);
}
}
问题三:缓存雪崩
现象: 大量key同时过期,或者Redis实例宕机,导致所有请求瞬间打到数据库,数据库崩溃。
解决方案:
- 随机过期时间: 给缓存key的过期时间加上一个随机值(如1-5分钟),避免大量key同时过期。
- 高可用架构: Redis集群(Sentinel或Cluster),避免单点故障。
- 限流降级: 在应用层做限流,超出阈值的请求直接返回默认值或错误页,保护数据库。
// 随机过期时间示例
int expireTime = 3600 + new Random().nextInt(300); // 1小时 ± 5分钟
redis.setex(cacheKey, expireTime, JSON.toJSONString(order));
3.5 缓存一致性如何保证?
缓存和数据库最终一致即可,强一致会严重损害性能。
- 先更新DB,再删缓存: 这是最推荐的方式。
- 延时删缓存: 如果担心删缓存失败,可以延时再删一次。或者用MQ消息重试。
- 监听Binlog: