想象一下这个场景:周五晚上八点,你的电商平台正在搞“限时秒杀”,流量瞬间飙升了五十倍。突然,监控大屏上的 CPU 使用率直接飙红,数据库连接数爆满,应用服务器开始疯狂报错 Too many connections 或者 Lock wait timeout exceeded。用户端看到的不是精美的商品页面,而是一片白屏或者转圈圈的加载动画。作为技术负责人,这时候你心里肯定在滴血。
别慌,这其实不是世界末日,而是很多系统从“玩具”变成“工业级产品”时必须经历的阵痛。今天,我不跟你讲那些枯燥的理论定义,咱们直接钻进一个真实的、血淋淋的线上故障案例里,看看我是如何一步步把即将崩盘的数据库救回来的。这个过程,涵盖了从最基础的索引优化,到中间件层面的读写分离,再到架构层面的最终解决方案。
第一关:急救室里的“慢查询”侦探
故事发生在一个中型电商项目的订单查询模块。起初,业务增长并不快,大家觉得 MySQL 原生单实例扛得住。直到有一次大促活动,运营搞了个“历史订单回溯”功能,允许用户筛选过去三年的订单。
1.1 现象:CPU 100% 与无响应
那天下午,运维同事发来警报:核心数据库 CPU 持续 100%,且平均响应时间从 50ms 变成了 3000ms+。应用层大量超时。
我登录服务器,打开 top 命令,发现 mysqld 进程吃掉了所有的 CPU 资源。紧接着,我登录 MySQL,执行了 SHOW PROCESSLIST;。映入眼帘的不是正常的查询,而是几十个状态为 Sending data 或 Sorting result 的长连接。
-- 这是当时抓到的典型慢查询
SELECT * FROM orders
WHERE user_id = 123456
AND order_status IN (1, 2, 3)
AND create_time BETWEEN '2021-01-01' AND '2023-12-31'
ORDER BY create_time DESC;
乍一看,这 SQL 挺正常的啊?有索引吗?
1.2 诊断:索引失效的陷阱
我检查了 orders 表的索引结构。当时表里只有一个主键索引 PRIMARY KEY (id),以及一个为了加速用户查询建立的普通索引 idx_user_id (user_id)。
为了验证猜想,我用了 EXPLAIN 关键字。
EXPLAIN SELECT * FROM orders
WHERE user_id = 123456
AND order_status IN (1, 2, 3)
AND create_time BETWEEN '2021-01-01' AND '2023-12-31'
ORDER BY create_time DESC;
输出结果令人绝望:
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ref | idx_user_id | idx_user_id | 4 | const | 150000 | Using where; Using filesort |
关键点来了:
- type: ref:虽然用到了
idx_user_id,但这只是第一步过滤。 - rows: 150,000:MySQL 估计要扫描 15 万行数据!而在生产环境,这个用户可能有几十万条历史订单。
- Extra: Using filesort:这是致命的。因为
order_status和create_time都不在索引中,MySQL 不得不把这 15 万条数据全部拉取到内存(甚至磁盘)中进行排序。这就是 CPU 100% 的元凶。
1.3 修复:联合索引的艺术
对于这种多条件查询 + 排序的场景,最经典的解决方案就是最左前缀原则下的联合索引。
我们需要一个能同时覆盖 user_id(等值查询)、order_status(范围/等值)、create_time(范围/排序)的复合索引。
优化方案:
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, order_status, create_time);
为什么这样建?
- user_id:放在最左边,用于快速定位特定用户的数据子集。
- order_status:放在中间。虽然它是
IN查询,但在联合索引中,它可以帮助进一步缩小范围。 - create_time:放在最后。因为它既是范围查询(BETWEEN),又是排序字段(ORDER BY)。将排序字段放在索引末尾,可以利用索引本身的有序性避免
filesort。
再次 EXPLAIN 验证:
EXPLAIN SELECT * FROM orders
WHERE user_id = 123456
AND order_status IN (1, 2, 3)
AND create_time BETWEEN '2021-01-01' AND '2023-12-31'
ORDER BY create_time DESC;
新的输出:
| id | select_type | table | type | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | range | idx_user_status_time | 9 | NULL | 500 | Using index condition |
看!rows 从 150,000 降到了 500,Extra 里没有了 Using filesort。CPU 使用率瞬间从 100% 降到了 20% 以下。
专家提示:这里有个细节,如果
order_status的选择性很低(比如只有 2 种状态),有时候把它放在最后反而更好,具体要看数据分布。但在高并发下,优先保证最核心的过滤条件(如 user_id)和排序条件(create_time)在索引中,通常是最稳妥的。
第二关:当写入成为瓶颈——读写分离的实战
索引优化解决了读的问题,但很快,新的问题出现了。随着用户量增加,下单(写)操作变得频繁。虽然查询快了,但每次下单都要更新库存、创建订单、扣减优惠券,这些写操作锁住了行,导致其他线程等待。更糟糕的是,我们的单节点 MySQL 磁盘 I/O 开始达到上限。
这时候,单纯靠加索引已经不够了,我们需要架构升级:引入读写分离。
2.1 为什么需要读写分离?
想象一下,你在一个图书馆借书。如果只有一个人负责借书和还书(单实例),那么当他在忙着登记新书入库(写)的时候,其他想查书的人(读)就得排队。如果我们将“借还书”和“查目录”分开,让专门的机器处理查目录,压力是不是就分散了?
在 MySQL 中,写操作需要强一致性,必须落在主库;而读操作对实时性要求没那么高,可以落在从库。
2.2 方案选型:中间件 vs 代码层
市面上有很多方案:
- ShardingSphere-JDBC / MyCat:透明化代理,对代码侵入小,但配置复杂,调试困难。
- 代码层动态路由:在 Service 层根据方法名或注解判断走主库还是从库。灵活,但代码耦合度高。
- Spring Cloud LoadBalancer + 自定义拦截器:适合微服务架构。
考虑到我们之前的项目是基于 Spring Boot 单体演进过来的,我决定采用代码层动态路由 + 主从复制的方案,这样可控性最强。
2.3 实施步骤详解
第一步:搭建主从复制(Master-Slave Replication)
首先,你需要至少两台 MySQL 服务器。
主库配置 (my.cnf):
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW # 强烈建议使用 ROW 模式,保证数据一致性
从库配置 (my.cnf):
[mysqld]
server-id=2
relay-log=mysql-relay-bin
在主库创建一个专门用于同步的用户:
CREATE USER 'repl_user'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl_user'@'%';
FLUSH PRIVILEGES;
然后在从库执行 CHANGE MASTER TO 指向主库,并启动同步:
CHANGE MASTER TO
MASTER_HOST='master_ip',
MASTER_USER='repl_user',
MASTER_PASSWORD='password',
MASTER_LOG_FILE='mysql-bin.000001', -- 通过 show master status; 获取
MASTER_LOG_POS=154; -- 通过 show master status; 获取
START SLAVE;
SHOW SLAVE STATUS\G;
确保 Slave_IO_Running 和 Slave_SQL_Running 都是 Yes。
第二步:Java 代码实现动态数据源切换
我们需要一个工具类来管理数据源的切换。核心是利用 ThreadLocal 来存储当前线程应该使用的数据源类型。
1. 定义枚举:
public enum DataSourceType {
MASTER, SLAVE
}
2. 数据源持有者(Holder):
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();
public static void setDataSourceType(DataSourceType dataSourceType) {
contextHolder.set(dataSourceType);
}
public static DataSourceType getDataSourceType() {
return contextHolder.get() != null ? contextHolder.get() : DataSourceType.SLAVE;
}
public static void clearDataSourceType() {
contextHolder.remove();
}
}
3. 动态数据源路由类:
继承 AbstractRoutingDataSource,这是 Spring 提供的钩子类,允许我们在运行时决定使用哪个数据源。
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceType();
}
}
4. AOP 切面自动切换:
这是最关键的一步。我们希望所有以 query, select, find, get 开头的方法自动走从库,而 insert, update, delete 走主库。或者更简单地,手动标注。
这里我们用一个简单的注解方式,配合 AOP:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {
}
@Aspect
@Component
public class DataSourceAop {
@Before("@annotation(master)")
public void setMasterDataSource(JoinPoint point, Master master) {
DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER);
}
@Around("@annotation(slave)") // 假设有一个 @Slave 注解,或者默认走 slave
public Object around(ProceedingJoinPoint point) throws Throwable {
try {
DataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE);
return point.proceed();
} finally {
DataSourceContextHolder.clearDataSourceType();
}
}
// 为了简化,通常我们会写一个拦截器,在请求开始时根据 URL 或方法名判断
// 这里展示核心逻辑:在执行前设置,执行后清除
}
更实用的做法:基于方法名的默认规则
很多时候,我们不想在每个 Service 方法上加注解。我们可以利用 Spring 的事务管理器特性。通常,@Transactional 默认绑定主库。如果没有事务,我们默认走从库。
// 在配置类中注册 DynamicDataSource
@Bean
@Primary
public DynamicDataSource dynamicDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slaveDataSource") DataSource slaveDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceType.MASTER, masterDataSource);
targetDataSources.put(DataSourceType.SLAVE, slaveDataSource);
DynamicDataSource dataSource = new DynamicDataSource();
dataSource.setTargetDataSources(targetDataSources);
dataSource.setDefaultTargetDataSource(slaveDataSource); // 默认走从库
return dataSource;
}
然后,在 Service 层,凡是涉及写操作的,加上 @Transactional 注解,Spring 的事务管理器会确保连接池返回的是主库连接(因为主从库连接 URL 不同,但通过上述 AOP 或事务策略可以控制)。
注意:真正的生产环境,推荐使用 ShardingSphere 或 MyCat,因为它们处理了连接池的健康检查、主从延迟感知等复杂问题。手写代码容易踩坑,比如主从延迟导致刚写完查不到。
2.4 解决主从延迟带来的“不一致”问题
这是读写分离最大的痛点。用户刚下单,立刻去查“我的订单”,结果查不到!因为从库还没同步过来。
解决方案:
- 强制读主:对于强一致性要求的场景(如支付结果查询、库存扣减后的确认),在代码中显式指定走主库。
- 缓存兜底:将刚刚写入的数据立即写入 Redis,后续读取先查 Redis。
- 业务妥协:在 UI 上提示“数据同步中,请稍后再试”。
在我们的案例中,我在 OrderService.createOrder() 方法上加了 @Transactional,并在返回成功后,立即将订单 ID 存入 Redis,并设置一个较短的过期时间。前端轮询时,如果发现 Redis 里有,就直接返回,否则再查数据库(此时从库可能已同步)。
第三关:终极防线——防止雪崩与缓存击穿
即使做了读写分离,如果并发量继续指数级增长,MySQL 依然可能被打挂。这时候,我们需要缓存作为第一道防线。
3.1 为什么必须加缓存?
数据库是硬盘 IO,缓存是内存 IO。速度相差成千上万倍。 对于高频访问的热点数据(如首页推荐商品、热门秒杀品),90% 的请求应该被拦截在缓存层。
3.2 实战:Redis 集成与缓存策略
我们使用 Spring Data Redis。
1. 依赖引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 核心代码:双重检查锁(DCL)防止缓存击穿
当缓存过期瞬间,大量请求同时打到数据库,会造成数据库压力骤增。
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProductById(Long id) {
String key = "product:" + id;
// 1. 第一次检查缓存
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存未命中,尝试获取分布式锁,防止并发穿透
// 这里使用 Redisson 的 RLock 更稳妥,简单起见用 Redis SETNX 模拟
String lockKey = "lock:" + key;
boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (isLock) {
try {
// 3. 第二次检查缓存(防止其他线程已经回填了缓存)
json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 4. 查询数据库
Product product = productMapper.selectById(id);
// 5. 写入缓存,设置过期时间(防止数据永久不过期)
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 竞争锁失败,短暂休眠后重试
try { Thread.sleep(50); } catch (InterruptedException e) {}
return getProductById(id); // 递归重试
}
return null;
}
}
3.3 缓存与数据库的双写一致性
这是一个经典难题。是先更新数据库还是先删缓存?
业界最佳实践:先更新数据库,再删除缓存。
为什么?
- 如果先删缓存,再更新数据库,在更新完成前,其他线程查缓存为空,去查数据库拿到旧数据,再写入缓存,导致脏数据。
- 如果先更新数据库,再删缓存,虽然存在极短时间的不一致窗口,但下次查询时会重新加载最新数据。
异步删除保证最终一致性: 由于删除缓存失败的情况很少见,但一旦发生会很麻烦。我们可以使用延时双删或者订阅 Binlog 异步删除。
这里介绍一种轻量级的Binlog 监听方案(使用 Canal 或 Otter):
- 业务代码只更新数据库。
- 部署一个 Canal Agent,监听 MySQL 的 Binlog。
- 当捕获到
UPDATE或DELETE事件时,发送消息到 MQ。 - 消费者监听 MQ,删除对应的 Redis 缓存。
这样实现了业务代码与缓存解耦,且保证了高可用。
第四关:压测与监控——没有度量就没有改进
做完以上所有优化,你以为万事大吉了吗?不,你必须知道系统的极限在哪里。
4.1 使用 JMeter 进行压力测试
不要猜,要测。
测试场景:
- 基准测试:单用户查询商品详情,RT 多少?
- 负载测试:逐步增加并发用户数(100 -> 500 -> 1000),观察 RT 和 QPS 的变化。
- 稳定性测试:保持 80% 最大并发,运行 24 小时,观察是否有内存泄漏或连接池耗尽。
JMeter 脚本要点:
- 使用 HTTP Request Sampler。
- 添加 CSV Data Set Config 模拟不同用户 ID。
- 添加 Listener:View Results Tree(调试用)、Summary Report(看总体指标)、Graph Results(看趋势)。
4.2 关键监控指标
在 Grafana + Prometheus 面板上,重点盯着这几个指标:
- QPS (Queries Per Second):每秒查询数。
- TPS (Transactions Per Second):每秒事务数。
- Active Connections:活跃连接数。如果接近
max_connections,必须报警。 - Slow Queries:慢查询数量。一旦上升,立即排查。
- Replication Lag:主从延迟秒数。如果超过 5 秒,说明从库压力过大或网络有问题。
- Cache Hit Rate:Redis 命中率。低于 80% 说明缓存设计有问题,或者热点数据太分散。
结语:没有银弹,只有权衡
回顾整个案例,我们从一个个具体的 SQL 问题出发,通过索引优化解决了单次查询的性能问题,通过读写分离解决了并发读的压力,通过缓存解决了极端高并发下的数据库保护,最后通过监控确保了系统的可观测性。
记住几个核心原则:
- 索引是基础:没有好的索引,一切架构优化都是空中楼阁。
- 缓存是利器:能不进数据库,就不进数据库。
- 读写分离是常态:只要读多于写,就应该考虑分离。
- 监控是眼睛:你不知道的问题,才是致命的问题。
在这个过程中,你可能会遇到各种各样的奇怪问题:比如主从数据不一致、缓存穿透、雪崩效应。不要害怕,每一个错误日志都是系统给你的反馈。保持冷静,善用 EXPLAIN,善用监控工具,你就能像这位专家一样,从容应对任何高并发挑战。
现在,深呼吸,去检查你的慢查询日志吧。那里藏着提升系统性能的金钥匙。