从双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:最大连接数

这个参数的设置,需要考虑三个因素:

  1. 应用服务器的数量:总连接数 = 单服务器连接数 × 服务器台数
  2. MySQL的 max_connections:不能超过MySQL能承受的上限
  3. 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,用监控数据指导你的优化方向。

如果你的系统还没有经历过高并发的考验,那趁现在,去做压测吧。等真的上线出问题了,再想优化就来不及了。

加油!