从双11秒杀宕机说起 聊聊MySQL高并发场景下的索引优化连接池调优读写分离方案实战
那天服务器炸了
我还记得2023年双11凌晨零点,我们团队盯着监控大屏,心跳跟着曲线一路飙升。
上午11点58分,订单服务还在正常运转。11点59分,QPS从平时的两千多,瞬间蹿到了三万八。12点00分03秒——DBA群里弹出一条消息:”主库CPU 100%,连接数爆了,开始抛异常了。”
紧接着,前端页面全部转圈,用户抱怨下单失败。技术群里炸开了锅。
事后复盘,我们做了整整一周的根因分析。问题出在哪里?不是代码逻辑错了,而是高并发场景下,我们之前一直忽视的三个底层优化点,同时踩了雷。
这篇文章,我就把这个案例掰开揉碎,聊聊MySQL在高并发场景下,索引怎么优化、连接池怎么调、读写分离怎么搭。全是实战踩坑换来的经验,希望能帮你避开我们踩过的雷。
先搞懂:高并发场景下MySQL到底在怕什么?
做优化之前,得先知道问题在哪。MySQL在高并发下,主要面临三个”杀手”:
第一,连接数被打满。
MySQL每一个连接都会占用内存(每个连接约256KB~几MB,取决于配置)。一个应用服务器可能有几百个连接,几十个应用服务器同时连过来,几万连接很容易就把MySQL扛不住了。连接数满了,新请求直接报错:Too many connections。
第二,慢查询拖垮主库。
高并发下,哪怕只有一个慢查询,也可能让CPU打满。想象一下,一秒钟进来一万个请求,其中十个是慢查询,每个要跑五秒钟——这 ten 个查询就能把CPU卡死,其他九千九百个请求全排队。
第三,单点扛不住,写入成为瓶颈。
很多系统的数据库都是单主库结构,所有写入操作都打到一台机器上。高并发秒杀场景,写入量是平时的几十倍甚至上百倍,单机MySQL根本扛不住。
我们的双11宕机,就是这三个问题叠加在一起爆发的。接下来,我们逐个击破。
一、索引优化:让查询快下来,是最高性价比的优化
1.1 先看看我们当时犯的错误
双11宕机后,DBA导出了一批慢查询日志,我随手看了一眼,发现一个让我脸红的SQL:
SELECT * FROM orders
WHERE user_id = 123456
AND status = 1
AND create_time > '2023-11-11 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
这张表叫 orders,记录了几亿条订单数据。问题来了:索引在哪里?
开发同学说,他们在 (user_id, status) 上建了联合索引,心想这应该够了。结果这个SQL跑起来,每次要扫描几十万行数据,才能过滤出几十条符合条件的记录。
1.2 索引优化的核心原则:最左前缀法则
联合索引 (user_id, status, create_time) 才能被这条SQL充分利用。为什么?因为MySQL的联合索引遵循最左前缀法则:
- 查询条件中必须包含索引的最左列,才能用到这个联合索引
- 如果跳过了中间列,后面的列就失效了
我们来看几种常见的错误写法:
-- ✅ 好的写法:覆盖了联合索引的所有列
SELECT * FROM orders
WHERE user_id = 123456
AND status = 1
AND create_time > '2023-11-11 00:00:00'
ORDER BY create_time DESC;
-- ❌ 坏的写法:跳过了 status,只用了 user_id 索引
SELECT * FROM orders
WHERE user_id = 123456
AND create_time > '2023-11-11 00:00:00'
ORDER BY create_time DESC;
第二种写法虽然能用上 (user_id) 的部分索引,但后面的条件就无法充分利用了,会导致大量的回表查询。
1.3 覆盖索引:避免回表,性能飙升
回表是索引优化里一个非常重要的概念。
当你的查询是 SELECT *,而索引只覆盖了部分列时,MySQL需要先通过索引找到主键,再去聚簇索引(主键索引)里把整行数据取出来——这个”再查一次”的过程就叫回表。
如果能把查询涉及的列都放进索引里,就不需要回表了,这叫覆盖索引。
-- 原来的查询,需要回表
SELECT * FROM orders
WHERE user_id = 123456 AND status = 1;
-- 优化后,使用覆盖索引,不需要回表
SELECT order_id, user_id, status, create_time
FROM orders
WHERE user_id = 123456 AND status = 1;
我们在双11之后,对订单表做了这样的改造:
-- 创建覆盖索引,包含查询所需的所有列
ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, create_time, order_id, amount);
改造后,这条查询的耗时从原来的平均800ms,降到了8ms——整整100倍的性能提升。
1.4 区分度高的列放前面
联合索引里,列的顺序很重要。区分度高(即唯一值多)的列,应该放在前面。
举个例子:
表:users
- 性别:只有"男""女"两个值,区分度极低
- 手机号:几乎唯一,区分度极高
- 注册时间:区分度高
索引 (性别, 手机号, 注册时间) ← 这样排列很差
索引 (手机号, 注册时间, 性别) ← 这样排列更好
因为手机号区分度高,放在前面可以快速缩小搜索范围;性别区分度低,放在最后,即使没用到也不影响性能。
1.5 用 EXPLAIN 诊断你的SQL
每次写完SQL,养成用 EXPLAIN 的习惯,能快速发现索引问题:
EXPLAIN SELECT * FROM orders
WHERE user_id = 123456 AND status = 1;
重点关注这几个字段:
| 字段 | 含义 | 好状态 | 坏状态 |
|---|---|---|---|
| type | 连接类型 | range / ref | ALL(全表扫描) |
| key | 实际使用的索引 | 有值 | NULL |
| rows | 扫描的行数 | 越小越好 | 几十万、上百万 |
| Extra | 额外信息 | Using index | Using filesort / Using temporary |
1.6 索引不是越多越好
很多开发同学觉得索引加越多越安全,这是一个误区。
索引的代价:
- 写入速度变慢(每次INSERT/UPDATE/DELETE都要更新索引)
- 占用更多存储空间
- 优化器选择索引时需要更多开销
建议:
- 每张表的索引控制在 3~5 个以内
- 高频查询的字段优先建索引
- 删除长期不被使用的索引
二、连接池调优:别让连接数拖垮你的MySQL
2.1 连接池是什么?
想象你去餐厅吃饭,每来一个客人,服务员都要去厨房重新生火、拿锅碗瓢盆——这得多麻烦?
连接池就是干这个的:提前准备好一批数据库连接,放在”池子”里,客人来了直接取用,用完归还,而不是每次临时建连接。
建连接的成本很高,TCP三次握手、SSL握手(如果有)、MySQL协议握手,每一步都有开销。频繁建连断连,数据库和客户端都会吃不消。
2.2 我们当时踩的坑
双11那次,我们的Java应用用的是默认的HikariCP配置:
spring:
datasource:
hikari:
# 没有显式配置,用的是默认值
maximum-pool-size: 10 # 默认就10个连接!
一个应用服务器只有10个连接,我们当时部署了50台应用服务器。理论上最多能撑 50 × 10 = 500 个并发连接。但实际上,因为连接泄漏、超时没释放等问题,有效连接数可能只有300多。
而秒杀场景下,瞬时并发请求达到了上万。连接池早就满了,新请求直接排队,等不到连接就超时抛异常了。
更糟糕的是,MySQL端的 max_connections 默认值是151,虽然能接受更多,但连接数太多会导致MySQL内存爆炸——每个连接都要分配内存缓冲区。
2.3 连接池参数的正确调法
2.3.1 maximum-pool-size:最大连接数
这个参数的设置,需要考虑三个因素:
- 应用服务器的数量:总连接数 = 单服务器连接数 × 服务器台数
- MySQL的 max_connections:不能超过MySQL能承受的上限
- MySQL的内存:每个连接约占 256KB~几MB 内存
计算公式:
单服务器最大连接数 = (MySQL总内存 / 每连接占用内存) / 应用服务器数量
实际推荐值 = 计算值 × 0.6(留40%缓冲)
举个例子:
MySQL可用内存:16GB
每连接占用:1MB(约数)
应用服务器数量:50台
理论最大连接数 = 16 × 1024 / 1 = 16384
单服务器推荐连接数 = 16384 / 50 × 0.6 ≈ 196
我们最终设置为:200
2.3.2 connection-timeout:获取连接超时时间
spring:
datasource:
hikari:
maximum-pool-size: 200
connection-timeout: 3000 # 3秒内获取不到连接就报错
idle-timeout: 600000 # 空闲连接10分钟后的回收时间
max-lifetime: 1800000 # 连接最大存活时间30分钟
关键参数解读:
| 参数 | 含义 | 推荐值 |
|---|---|---|
| connection-timeout | 获取连接的超时时间 | 3000~5000ms |
| idle-timeout | 空闲连接回收时间 | 600000ms(10分钟) |
| max-lifetime | 连接最大生命周期 | 1800000ms(30分钟) |
| leak-detection-threshold | 连接泄漏检测阈值 | 60000ms(60秒) |
2.3.3 连接泄漏检测
连接泄漏是指:应用获取了连接,但用完之后没有归还到池子里。长时间泄漏,连接池里的连接会越来越少,最终所有请求都卡死。
HikariCP 自带泄漏检测,开启非常简单:
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 超过60秒未归还,记录警告
2.4 用代码监控连接池状态
光靠配置不够,我们后来写了一个监控接口,定期采集连接池的健康数据:
@Component
public class DataSourceMonitor {
@Autowired
private DataSource dataSource;
/**
* 获取连接池健康指标
*/
public Map<String, Object> getPoolMetrics() {
HikariDataSource hikariDataSource = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDataSource.getHikariPoolMXBean();
Map<String, Object> metrics = new HashMap<>();
metrics.put("activeConnections", poolBean.getActiveConnections()); // 活跃连接数
metrics.put("idleConnections", poolBean.getIdleConnections()); // 空闲连接数
metrics.put("totalConnections", poolBean.getTotalConnections()); // 总连接数
metrics.put("maxPoolSize", poolBean.getMaximumPoolSize()); // 最大连接数上限
metrics.put("pendingThreads", poolBean.getThreadsAwaitingConnection()); // 等待连接的线程数
// 计算连接池使用率
int total = poolBean.getTotalConnections();
int max = poolBean.getMaximumPoolSize();
double usageRate = max > 0 ? (double) total / max * 100 : 0;
metrics.put("connectionPoolUsageRate", String.format("%.1f%%", usageRate));
// 告警判断
if (usageRate > 90) {
metrics.put("alertLevel", "CRITICAL");
metrics.put("alertMessage", "连接池使用率超过90%,请立即扩容或排查慢查询!");
} else if (usageRate > 70) {
metrics.put("alertLevel", "WARNING");
metrics.put("alertMessage", "连接池使用率超过70%,请关注。");
} else {
metrics.put("alertLevel", "NORMAL");
}
return metrics;
}
}
配合定时任务,把这些指标上报到监控平台,设置告警阈值。一旦连接池使用率超过80%,立刻通知运维介入。
2.5 MySQL端的 max_connections 设置
连接池调好了,MySQL端也要跟上。默认 max_connections = 151,在高并发场景下远远不够。
# my.cnf 配置
[mysqld]
# 最大连接数,根据实际内存调整
max_connections = 2000
# 每个连接的最大缓冲区
max_allowed_packet = 64M
# 连接等待超时(秒)
wait_timeout = 600
interactive_timeout = 600
# 表缓存,减少打开关闭表的开销
table_open_cache = 4096
# 线程缓存,避免频繁创建线程
thread_cache_size = 128
注意: 调大 max_connections 的同时,要确保MySQL服务器有足够的内存。经验值:每个连接占用1~2MB内存,2000个连接大概需要2~4GB内存专门用于连接。
三、读写分离:让数据库分分工
3.1 为什么需要读写分离?
想象一个场景:你的系统有100个用户同时读数据,1个用户在写数据。
如果全部打到一台MySQL上,这100个读请求和1个写请求要排队竞争资源。写操作会加锁,读操作可能会被阻塞——这就是读写竞争。
读写分离的思路很简单:写操作走主库,读操作走从库。主库负责写入,从库负责读取,各司其职,互不干扰。
3.2 主从复制的原理
MySQL的主从复制,本质上是把主库的写操作记录下来,从库复现一遍。
主库(Master)
↓ 写入操作
↓ 记录到 binlog(二进制日志)
↓
从库(Slave/Replica)
↓ I/O线程读取binlog
↓ 写到本地的relay log(中继日志)
↓ SQL线程重放relay log
↓ 从库数据与主库一致
这个过程是异步的,所以从库的数据可能会有几毫秒到几秒的延迟——这在秒杀场景下是一个需要特别注意的问题。
3.3 我们当时的读写分离架构
双11之后,我们搭建了如下的读写分离架构:
┌─────────────┐
│ 应用层 │
│ (Spring) │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌───▼──────┐
│ 写操作 │ │ 读操作 │ │ 读操作 │
│ → 主库 │ │ → 从库1 │ │ → 从库2 │
└─────┬─────┘ └───┬────┘ └───┬──────┘
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌───▼──────┐
│ MySQL │ │ MySQL │ │ MySQL │
│ Master │ │ Slave1 │ │ Slave2 │
└───────────┘ └────────┘ └──────────┘
3.4 代码实现:Spring Boot + MyBatis 的读写分离
我们用 Spring Boot 结合 MyBatis 来实现读写分离,核心思路是:根据方法名或注解,动态切换到主库或从库的数据源。
第一步:配置多个数据源
# application.yml
spring:
datasource:
master:
jdbc-url: jdbc:mysql://master-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 50
connection-timeout: 3000
slave1:
jdbc-url: jdbc:mysql://slave1-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 100
connection-timeout: 3000
slave2:
jdbc-url: jdbc:mysql://slave2-db:3306/shop?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 100
connection-timeout: 3000
第二步:定义数据源切换的上下文
/**
* 数据源切换上下文
* 使用 ThreadLocal 保证线程安全
*/
public class DataSourceContextHolder {
/**
* 数据源类型枚举
*/
public enum DbType {
MASTER, // 主库
SLAVE1, // 从库1
SLAVE2 // 从库2
}
private static final ThreadLocal<DbType> CONTEXT = new ThreadLocal<>();
public static void setMaster() {
CONTEXT.set(DbType.MASTER);
}
public static void setSlave() {
CONTEXT.set(DbType.SLAVE1); // 默认用从库1,可以扩展轮询逻辑
}
public static DbType getDbType() {
DbType dbType = CONTEXT.get();
return dbType == null ? DbType.SLAVE1 : dbType;
}
public static void clear() {
CONTEXT.remove();
}
}
第三步:动态数据源路由
/**
* 动态数据源,根据上下文路由到不同的数据源
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDbType();
}
}
第四步:配置多个数据源 bean
@Configuration
public class DataSourceConfig {
/**
* 主库数据源
*/
@Bean("masterDataSource")
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return HikariDataSourceBuilder.create().build();
}
/**
* 从库1数据源
*/
@Bean("slave1DataSource")
@ConfigurationProperties("spring.datasource.slave1")
public DataSource slave1DataSource() {
return HikariDataSourceBuilder.create().build();
}
/**
* 从库2数据源
*/
@Bean("slave2DataSource")
@ConfigurationProperties("spring.datasource.slave2")
public DataSource slave2DataSource() {
return HikariDataSourceBuilder.create().build();
}
/**
* 动态数据源,路由到主库或从库
*/
@Bean
@Primary
public DynamicDataSource dynamicDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slave1DataSource") DataSource slave1DataSource,
@Qualifier("slave2DataSource") DataSource slave2DataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceContextHolder.DbType.MASTER, masterDataSource);
targetDataSources.put(DataSourceContextHolder.DbType.SLAVE1, slave1DataSource);
targetDataSources.put(DataSourceContextHolder.DbType.SLAVE2, slave2DataSource);
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(targetDataSources);
// 默认数据源
dynamicDataSource.setDefaultTargetDataSource(slave1DataSource);
return dynamicDataSource;
}
/**
* MyBatis SqlSessionFactory
*/
@Bean
public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DynamicDataSource dynamicDataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dynamicDataSource);
factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
.getResources("classpath:mapper/*.xml"));
return factoryBean.getObject();
}
}
第五步:AOP 自动切换数据源
这是最关键的一步——我们用一个切面,根据方法名自动判断走主库还是从库:
/**
* 读写分离切面
* 根据方法名自动切换数据源
*/
@Aspect
@Component
public class DataSourceAspect {
private static final Pattern WRITE_METHOD_PATTERN = Pattern.compile(
"^(insert|update|delete|save|add|edit|remove|alter|change|create|drop|truncate|replace|merge)"
);
/**
* 拦截所有 Service 层的 public 方法
*/
@Around("execution(public * com.example.shop.service..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
String methodName = method.getName().toLowerCase();
// 判断是否为写操作方法
if (isWriteMethod(methodName)) {
DataSourceContextHolder.setMaster();
} else {
// 读操作,轮询从库
DataSourceContextHolder.setSlave();
}
try {
return point.proceed();
} finally {
// 务必清空,防止内存泄漏
DataSourceContextHolder.clear();
}
}
/**
* 判断是否为写操作方法
*/
private boolean isWriteMethod(String methodName) {
return WRITE_METHOD_PATTERN.matcher(methodName).matches();
}
}
第六步:增强版——注解方式精确控制
如果你需要对某些方法做更精确的控制,可以用自定义注解:
/**
* 指定使用主库
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {
}
/**
* 指定使用从库(通常不需要,默认就是读操作走从库)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Slave {
}
然后在切面中增加注解判断:
@Aspect
@Component
public class DataSourceAspect {
@Around("execution(public * com.example.shop.service..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
Method method = getMethod(point);
// 优先检查注解
if (method.isAnnotationPresent(Master.class)) {
DataSourceContextHolder.setMaster();
} else if (method.isAnnotationPresent(Slave.class)) {
DataSourceContextHolder.setSlave();
} else {
// 默认根据方法名判断
String methodName = method.getName().toLowerCase();
if (isWriteMethod(methodName)) {
DataSourceContextHolder.setMaster();
} else {
DataSourceContextHolder.setSlave();
}
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
使用示例:
@Service
public class OrderServiceImpl implements OrderService {
@Master // 强制走主库
@Override
public void createOrder(Order order) {
orderMapper.insert(order);
}
@Master // 查询订单详情前,必须走主库确保数据最新
@Override
public Order getOrderById(Long orderId) {
return orderMapper.selectById(orderId);
}
@Slave // 列表查询走从库
@Override
public PageResult<Order> listOrders(OrderQuery query) {
return orderMapper.selectList(query);
}
}
3.5 读写分离的一个关键问题:数据延迟
读写分离有一个绕不开的问题:主从复制延迟。
主库写完数据后,从库需要一定时间才能同步过来。如果在延迟期间查询从库,可能查到的是旧数据。
秒杀场景下的特殊情况:
用户下单后,立刻去查”我的订单”,如果走的是从库,可能查不到刚刚下单的记录——这体验太糟糕了。
解决方案:
/**
* 事务内强制走主库
* 确保刚写入的数据立即可读
*/
@Transactional
public Order createOrderAndQuery(Order order) {
// 写入
orderMapper.insert(order);
// 因为在同一事务中,这里的查询会自动路由到主库
// Spring的@Transactional会绑定主库连接
return orderMapper.selectById(order.getId());
}
在代码层面,我们还有一个策略:下单后的短时间内(比如5秒内),强制路由到主库查询:
/**
* 主库亲和性路由
* 写入操作后的 N 秒内,查询强制走主库
*/
@Component
public class MasterAffinityRouting {
/**
* 记录每个用户的最近主库写入时间
*/
private final Map<Long, Long> userLastWriteTime = new ConcurrentHashMap<>();
private static final long AFFINITY_DURATION_MS = 5000; // 5秒亲和期
/**
* 标记写入操作
*/
public void markWrite(Long userId) {
userLastWriteTime.put(userId, System.currentTimeMillis());
}
/**
* 判断是否应该走主库
*/
public boolean shouldUseMaster(Long userId) {
Long lastWriteTime = userLastWriteTime.get(userId);
if (lastWriteTime == null) {
return false;
}
return System.currentTimeMillis() - lastWriteTime < AFFINITY_DURATION_MS;
}
}
然后在切面中集成:
@Aspect
@Component
public class DataSourceAspect {
@Autowired
private MasterAffinityRouting affinityRouting;
@Around("execution(public * com.example.shop.service..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
// 提取用户ID(从方法参数中)
Long userId = extractUserId(point.getArgs());
// 优先检查主库亲和性
if (userId != null && affinityRouting.shouldUseMaster(userId)) {
DataSourceContextHolder.setMaster();
} else if (/* 写操作判断 */) {
DataSourceContextHolder.setMaster();
if (userId != null) {
affinityRouting.markWrite(userId);
}
} else {
DataSourceContextHolder.setSlave();
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
3.6 从库负载均衡
当有多个从库时,可以做简单的轮询负载均衡:
/**
* 从库选择器,轮询负载
*/
@Component
public class SlaveRouter {
private final List<String> slaves = Arrays.asList("slave1", "slave2");
private final AtomicInteger index = new AtomicInteger(0);
public String chooseSlave() {
String slave = slaves.get(Math.abs(index.getAndIncrement()) % slaves.size());
return slave;
}
}
四、双11复盘:我们到底改了什么?
回到最初的话题。双11宕机之后,我们团队做了一个月的优化,核心改动如下:
4.1 索引层面
-- 订单表:增加了覆盖索引
ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, create_time, order_id, amount);
-- 商品表:增加了覆盖索引
ALTER TABLE products
ADD INDEX idx_category_status_stock (category_id, status, stock, product_id, price);
-- 删除了长期不被使用的索引
DROP INDEX idx_create_time ON orders;
DROP INDEX idx_status ON products;
4.2 连接池层面
# 应用服务器连接池配置
spring:
datasource:
hikari:
maximum-pool-size: 200
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
MySQL端配置:
max_connections = 2000
table_open_cache = 4096
thread_cache_size = 128
4.3 读写分离层面
- 搭建了1主2从的MySQL集群
- 实现了基于AOP的读写分离,默认写操作走主库,读操作走从库
- 实现了主库亲和性机制,写入后5秒内查询强制走主库
- 增加了连接池监控告警
4.4 效果对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 订单查询平均响应时间 | 800ms | 8ms |
| MySQL最大连接数 | 151 | 2000 |
| 连接池使用率峰值 | 100%(瓶颈) | 65% |
| 慢查询数量/小时 | 1200+ | < 10 |
| 双11期间数据库可用性 | 宕机 | 99.99% |
五、给小伙伴们的几点建议
写到这里,我想分享几个我觉得最重要的心得,这些都不是书本上的理论,而是真金白银换来的教训。
第一,优化要抓主要矛盾。
高并发场景下,最影响性能的不是连接池,也不是读写分离,而是慢查询。一个没加索引的慢查询,足以让MySQL的CPU打满,其他优化都白搭。所以,先把索引优化好,再考虑其他。
第二,连接池不是越大越好。
很多团队一上来就把连接池开到500、1000,以为这样就能扛住高并发。实际上,连接数太多,MySQL的内存和CPU都会被连接管理本身消耗掉。要根据实际业务量和MySQL资源,找到一个平衡点。
第三,读写分离要注意数据一致性。
读写分离带来性能提升的同时,也带来了主从延迟的问题。在秒杀场景下,用户下单后立刻查订单,必须确保查到的是最新数据。我们用的是主库亲和性策略,你也可以根据业务特点选择其他方案,比如强制某些关键查询走主库。
第四,监控和告警必不可少。
再好的优化,也抵不过没有监控。一定要对连接池状态、慢查询、主从延迟等关键指标做实时监控和告警。出了问题,能第一时间发现和处理。
第五,压测!压测!压测!
不要等到双11才发现问题。平时的开发流程中,一定要做充分的压力测试,模拟高并发场景,提前发现瓶颈。
写在最后
双11那次宕机,对我们团队来说是一次惨痛的教训,也是一次宝贵的成长。
优化MySQL高并发性能,没有银弹。它需要你懂索引、懂连接池、懂主从复制、懂业务场景,还需要你有丰富的实战经验。这篇文章里讲的每一个点,都是我们踩过的坑。
希望这些经验能帮到你。如果你正在面临类似的高并发挑战,不妨从索引优化开始,一步一步来。记住,数据不会说谎——用 EXPLAIN 分析你的SQL,用监控数据指导你的优化方向。
如果你的系统还没有经历过高并发的考验,那趁现在,去做压测吧。等真的上线出问题了,再想优化就来不及了。
加油!
