你有没有遇到过这种时刻:业务跑得正欢,突然数据库CPU飙到100%,前端页面转圈转得让人心焦,后台日志里一堆Slow Query像雪片一样飞来?别慌,这几乎是每个后端开发、DBA或者架构师都会经历的“成长仪式”。今天咱们不聊那些干巴巴的理论,我就把自己这些年踩过的坑、改过的SQL、调过的参数,还有那些让深夜运维人员抓狂的瞬间,掰开了揉碎了讲给你听。咱们从最让人头疼的慢查询排查开始,一步步聊到读写分离的实战,最后再揭开索引设计里那些看似正确实则致命的陷阱。相信我,看完这篇,下次再面对高并发场景,你心里就有底了。
慢查询排查:不是看日志就完事了
很多人以为查慢查询就是打开slow_log,然后盯着那些执行时间长的SQL叹气。其实,这就像医生看病,你不能只说“病人发烧了”,你得知道烧多少度、什么时候烧的、有没有其他症状。
1. 开启慢查询日志,但要聪明地开
首先,确认你的MySQL是否开启了慢查询日志:
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
long_query_time默认是10秒,但在高并发场景下,10秒太宽限了。我见过很多线上系统,很多查询其实500毫秒就已经很慢了,但用户感觉得到卡顿,而MySQL还没把它记下来。我建议在生产环境把这个值调到0.5秒甚至更低,但要注意,设太低会导致日志爆炸,磁盘IO扛不住。
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log = 'ON';
另外,别只记录慢查询,建议开启log_queries_not_using_indexes,这样能帮你发现那些没走索引的“隐形杀手”。
2. 解读慢查询日志,找到真正的问题
慢查询日志通常长这样:
# Time: 2024-05-20T10:30:15.123456Z
# User@Host: app_user[app_user] @ localhost []
# Query_time: 3.141592 Lock_time: 0.000100 Rows_sent: 1 Rows_examined: 5000000
SET timestamp=1716201015;
SELECT * FROM orders WHERE user_id = 12345 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
你看,Rows_examined有500万,但Rows_sent只有10条。这意味着什么?MySQL为了找到这10条记录,扫描了500万行数据!这就是典型的“全表扫描+文件排序”。
这时候,别急着加索引,先看看这个查询的执行计划:
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
你可能会看到type: ALL,Extra: Using filesort。这说明查询没有用到索引,而是在做文件排序。
3. 善用EXPLAIN和trace
EXPLAIN是标配,但很多人只看了type和key就完事了。其实,rows、filtered、Extra这些字段同样重要。
type: ALL:全表扫描,必须优化。type: index:全索引扫描,虽然比全表扫描好点,但也不理想。type: ref:非唯一索引查找,这是我们可以接受的下限。type: range:索引范围扫描,通常性能不错。type: const或eq_ref:最好,基本是主键或唯一索引查找。
另外,MySQL 5.6+提供了EXPLAIN ANALYZE(注意不是EXPLAIN),它能告诉你实际执行时的行数、耗时,和理论值对比,看看哪里出了问题。
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 12345 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
对于更复杂的情况,你可以用trace工具来分析优化器为什么选择了一个你不满意的执行计划。这个稍微深入点,但真的很管用。
SET OPTIMIZER_TRACE="enabled=on";
SET optimizer_trace_max_mem_size=1000000;
-- 执行你的查询
SELECT * FROM orders WHERE user_id = 12345 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
SELECT * FROM information_schema.optimizer_trace;
optimizer_trace会告诉你优化器考虑了哪些索引、为什么放弃了某个索引、最终为什么选择了这个执行计划。很多时候,你会发现是因为统计信息不准确,或者索引选择性太差。
索引设计陷阱:你以为建了索引,其实没建对
索引是MySQL性能优化的核心,但也是最大的“坑”之一。很多开发者以为只要在列上加个索引就万事大吉,结果发现查询还是慢,或者写入性能暴跌。
陷阱1:选择性低的列建索引
什么叫选择性?就是某一列的不同值数量占总行数的比例。比如一个gender列,只有M和F两个值,选择性极低。如果你在gender上建索引,MySQL优化器大概率会放弃使用它,因为扫描整个索引还不如直接扫表。
-- 假设users表有100万行,gender只有男女
SELECT COUNT(DISTINCT gender) / COUNT(*) FROM users;
-- 结果接近 0.000002,选择性极低
判断索引选择性的小技巧:
SELECT COUNT(*) as total, COUNT(DISTINCT column_name) as distinct_vals
FROM table_name;
如果distinct_vals / total很小(比如小于0.1),那这个索引很可能没用,甚至有害。
陷阱2:不符合最左前缀原则的联合索引
这是最常见的错误之一。假设你有一个联合索引(a, b, c),然后你写了这样的查询:
SELECT * FROM table WHERE b = 1 AND c = 2;
这个查询能用到索引吗?不能!因为a没出现在WHERE条件里,索引的最左前缀被破坏了。MySQL只能从a开始匹配,既然a没有约束,整个索引就失效了。
正确的做法是确保查询条件符合最左前缀:
-- 能用到索引
SELECT * FROM table WHERE a = 1 AND b = 2 AND c = 3;
SELECT * FROM table WHERE a = 1 AND c = 3; -- b是范围查询或没有,c也能用到
SELECT * FROM table WHERE a = 1 AND b >= 2 AND b <= 3; -- b是范围,c用不到
记住:联合索引就像一本书的目录,你必须从第一章开始读,不能直接从第三章开始。
陷阱3:在索引列上做函数运算或类型转换
-- 假设created_at上有索引
SELECT * FROM orders WHERE DATE(created_at) = '2024-05-20';
这个查询虽然意图是查某一天的数据,但因为对created_at做了DATE()函数运算,索引失效了!MySQL会对每一行都计算DATE(created_at),然后比较,这等于全表扫描。
正确的写法:
SELECT * FROM orders
WHERE created_at >= '2024-05-20 00:00:00'
AND created_at < '2024-05-21 00:00:00';
这样就能利用索引的范围扫描了。
同样,如果user_id是VARCHAR类型,但你查的时候传了数字:
SELECT * FROM users WHERE user_id = 12345; -- user_id是VARCHAR
MySQL会隐式类型转换,导致索引失效。应该写成:
SELECT * FROM users WHERE user_id = '12345';
陷阱4:过度索引,尤其是写入频繁的表
有些开发者有“索引恐惧症”,觉得多建索引总没坏处。大错特错!每个索引都会增加写入时的开销,因为每次INSERT、UPDATE、DELETE都要维护所有索引。
举个例子,你的orders表每天有10万条写入,但有20个索引。每次写入都要更新20棵B+树,这性能损耗是不可忽视的。
如何判断索引是否过度?
SELECT
table_name,
index_name,
seq_in_index,
column_name,
CARDINALITY,
NULLABLE
FROM information_schema.STATISTICS
WHERE table_schema = 'your_database'
ORDER BY table_name, index_name, seq_in_index;
结合查询频率和写入频率,如果一个索引几乎不被查询使用,或者选择性很低,那就可以考虑删掉它。
DROP INDEX idx_unused ON orders;
陷阱5:覆盖索引和回表
有时候你明明建了索引,查询还是慢,可能是因为“回表”。
什么是回表?当你的查询需要返回的列不在索引中时,MySQL需要先通过索引找到主键,然后再回主键索引去查完整行数据。这个过程就叫回表。
-- 假设id是主键,user_id上有索引
SELECT * FROM orders WHERE user_id = 12345;
如果*包含了大量不在user_id索引中的列,那就会有很多回表操作。
解决方案:使用覆盖索引。
-- 只查询user_id索引中包含的列,或者包含所有需要的列
SELECT id, user_id, status FROM orders WHERE user_id = 12345;
如果这些列正好构成一个联合索引,MySQL就可以直接从索引树中获取数据,不需要回表,性能提升巨大。
你可以通过EXPLAIN中的Extra字段看到Using index,表示使用了覆盖索引。
高并发场景下的性能优化:不仅仅是索引
索引重要,但高并发场景下的性能优化是一个系统工程。光靠索引,有时候解决不了问题。
1. 分区表:把大问题拆成小问题
当单表数据量超过几千万,索引的效率也会下降。这时候可以考虑分区表。
比如按时间分区,每个月一个分区:
ALTER TABLE orders PARTITION BY RANGE (YEAR(create_time) * 100 + MONTH(create_time)) (
PARTITION p202401 VALUES LESS THAN (202402),
PARTITION p202402 VALUES LESS THAN (202403),
-- ...
PARTITION pmax VALUES LESS THAN MAXVALUE
);
分区后,查询时可以指定分区,避免扫描全表。但要注意,分区键的选择很关键,如果查询条件不包含分区键,分区就失效了。
2. 缓存层:减少数据库压力
在高并发场景下,读多写少的数据,缓存是神器。Redis、Memcached都可以用。
但缓存不是银弹,要注意一致性问题和缓存穿透、击穿、雪崩。
// 伪代码示例
public Order getOrder(Long orderId) {
String cacheKey = "order:" + orderId;
Order order = redis.get(cacheKey);
if (order == null) {
// 缓存穿透:查询数据库,结果为空
order = orderMapper.selectById(orderId);
if (order != null) {
redis.setex(cacheKey, 3600, order);
} else {
// 可以缓存一个空值,防止穿透
redis.setex(cacheKey, 60, "");
}
} else if (order.isEmpty()) {
return null;
}
return order;
}
3. 连接池优化:别让连接成为瓶颈
高并发下,数据库连接是稀缺资源。连接池配置不当,要么连接不够用,事务超时;要么连接太多,数据库扛不住。
合理配置连接池参数:
maxTotal:最大连接数,根据数据库承受能力和应用需求设置。maxIdle:最大空闲连接数,避免频繁创建和销毁连接。minIdle:最小空闲连接数,保证有一定数量的连接可用。testOnBorrow:借用连接时测试是否有效,适合高并发场景,但有一定开销。testWhileIdle:空闲时测试,适合长时间运行的应用。
<!-- Druid连接池配置示例 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="maxActive" value="50"/>
<property name="initialSize" value="10"/>
<property name="maxWait" value="3000"/>
<property name="testOnBorrow" value="true"/>
<property name="testWhileIdle" value="true"/>
<property name="timeBetweenEvictionRunsMillis" value="60000"/>
</bean>
4. 分库分表:终极方案
当单库单表已经无法承受时,分库分表是终极方案。但这是双刃剑,复杂度高,运维成本高。
常用方案:
- 垂直分库:按业务模块拆分,比如订单库、用户库、商品库。
- 水平分表:按某个字段哈希或范围拆分,比如按
user_id哈希分成16张表。 - 分库分表中间件:ShardingSphere、MyCat等,可以简化开发。
-- 逻辑表
SELECT * FROM orders WHERE user_id = 12345;
-- 实际路由到
SELECT * FROM orders_3 WHERE user_id = 12345; -- 假设user_id % 16 = 3
分库分表后,跨库查询、全局唯一ID、分布式事务等都是问题,需要仔细设计。
读写分离实战:架构层面的优化
读完多写少是高并发系统的典型特征。读写分离就是利用这个特点,把读请求分散到多个从库,减轻主库压力。
1. 原理与架构
主库(Master)负责写操作,从库(Slave)负责读操作。主库通过binlog异步复制到从库。
+--------+
| Client |
+---+----+
|
+-----v-----+ +--------+
| Proxy |---->| Master |
| (读写分离) | +--------+
+-----+-----+
|
+-----v-----+ +--------+
| Slave 1 |<----| binlog |
+-----------+ +--------+
+-----------+
| Slave 2 |
+-----------+
常见的读写分离方案:
- 应用层实现:在代码中手动区分读写,路由到不同数据源。
- 中间件实现:使用MyCat、ShardingSphere-Proxy等,对应用透明。
- Proxy层实现:如MySQL Proxy、MaxScale,拦截SQL并路由。
2. 应用层读写分离实战
假设你用的是Spring Boot + MyBatis,可以这样实现:
// 定义数据源路由键
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDataSource(String dataSource) {
contextHolder.set(dataSource);
}
public static String getDataSource() {
return contextHolder.get();
}
public static void clearDataSource() {
contextHolder.remove();
}
}
// 动态数据源
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
// AOP切面,自动路由
@Aspect
@Component
public class DataSourceAop {
@Before("@annotation(readOnly)")
public void setReadDataSource(JoinPoint point, ReadOnly readOnly) {
DataSourceContextHolder.setDataSource("slave");
}
@Around("@annotation(write)")
public Object setWriteDataSource(ProceedingJoinPoint point, Write write) throws Throwable {
DataSourceContextHolder.setDataSource("master");
try {
return point.proceed();
} finally {
DataSourceContextHolder.clearDataSource();
}
}
@Around("@annotation(write)")
public Object setWriteDataSource(ProceedingJoinPoint point, Write write) throws Throwable {
DataSourceContextHolder.setDataSource("master");
try {
return point.proceed();
} finally {
DataSourceContextHolder.clearDataSource();
}
}
}
使用时,在查询方法上加@ReadOnly注解,在写方法上加@Write注解。
3. 主从延迟问题:最大的坑
读写分离最大的问题就是主从延迟。主库写完,binlog复制到从库,从库执行,这期间可能有几十毫秒甚至几秒的延迟。如果用户刚写完数据就立刻去查,可能查不到,或者查到旧数据。
解决方案:
- 强制读主库:对于刚写完的数据,强制读主库。可以通过标记实现,比如写入后在Session中设置一个标志,后续查询如果标志存在,就读主库。
”`java public class DataSourceAop {
@Around("@annotation(write)")
public Object setWriteDataSource(ProceedingJoinPoint point, Write write) throws Throwable {
DataSourceContextHolder.setDataSource("master");
// 设置强制读主库标志
DataSourceContextHolder.setForceMaster(true);
try {
return point.proceed();
} finally {
DataSourceContextHolder.clearDataSource();
DataSourceContextHolder.clearForceMaster();
}
}
}
// 在查询时检查是否强制读主库 @Before(“@annotation(readOnly)”) public void setReadDataSource(JoinPoint point, ReadOnly readOnly