那个周五下午的惊魂时刻

我至今还记得那个周五下午,线上服务突然全线报警,监控大屏上一片通红。作为当时负责基础架构的工程师,我接到电话时咖啡还没喝完,但已经知道——出大事了。

那家电商公司的下单系统在促销活动期间彻底崩溃,用户无法提交订单,客服热线被打爆。当我和团队连夜排查时,发现问题的根源并非大家最初猜测的“代码bug”或“硬件故障”,而是MySQL连接池配置不当在高并发场景下引发的连锁反应。

今天我想和你聊聊这个真实的案例,以及我们是如何从废墟中重建系统的。这不仅仅是关于一个配置项的修改,而是一次对数据库架构的深度反思。

案例背景:促销活动的“至暗时刻”

业务场景

那是一家中型电商平台,日常QPS(每秒查询率)大约在500左右,能够平稳运行。但每当促销活动期间,尤其是“秒杀”场景,瞬时流量可能飙升至平时的10-20倍。

这次事故发生在一次限时秒杀活动中,商品库存仅500件,但报名参与的用户超过了10万。系统架构如下:

  • 应用层:Java Spring Boot集群,约20台应用服务器
  • 数据库层:MySQL 8.0,主从架构(1主2从)
  • 中间件:HikariCP作为连接池,Redis作为缓存层

事故现象

活动开始后15分钟,监控显示:

  1. 应用服务器响应时间从平均50ms飙升至3秒以上
  2. 数据库连接数迅速达到上限
  3. 大量请求返回“Cannot get a connection, pool error”错误
  4. 最终MySQL主库CPU使用率100%,服务完全不可用

更糟糕的是,当我们尝试重启应用服务器时,发现数据库连接泄漏严重,即使重启后问题依然存在。

深入排查:连接池配置的秘密

问题一:连接池配置过于保守

我们首先检查了应用服务器的连接池配置。由于业务团队担心“连接数过多会压垮数据库”,特意将连接池设置得非常保守:

# HikariCP配置 - 事故前的错误配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 10          # 每个应用服务器最大只有10个连接!
      minimum-idle: 5
      connection-timeout: 30000      # 30秒超时
      idle-timeout: 600000           # 10分钟空闲超时
      max-lifetime: 1800000          # 30分钟最大生命周期

表面看,10个连接似乎“够用”,但让我们算一笔账:

  • 20台应用服务器 × 10个连接 = 200个并发连接
  • 但这200个连接要支撑每秒数千次的秒杀请求

问题在于,每个HTTP请求不仅需要数据库连接,还需要:

  1. 查询库存
  2. 扣减库存
  3. 创建订单
  4. 写入订单详情
  5. 更新用户积分

如果每个请求平均需要0.5秒完成,那么每个连接每秒只能处理2个请求。200个连接理论上最大吞吐量为400 QPS,但实际高并发下,由于连接等待、锁竞争等因素,实际吞吐量可能只有理论值的50%-60%。

问题二:连接泄漏

更严重的是,我们发现代码中存在连接泄漏问题。在某些异常路径下,数据库连接没有被正确释放:

// 存在连接泄漏风险的代码片段
public Order createOrder(Long userId, Long productId) {
    Connection conn = dataSource.getConnection();
    try {
        // 查询库存
        int stock = queryStock(conn, productId);
        if (stock <= 0) {
            throw new InsufficientStockException("库存不足");
        }
        
        // 扣减库存
        updateStock(conn, productId, -1);
        
        // 创建订单
        Long orderId = insertOrder(conn, userId, productId);
        
        // 其他业务逻辑...
        
        return new Order(orderId);
    } catch (Exception e) {
        // 问题在这里:异常时连接可能没有被关闭
        // 如果这里抛出的异常没有被正确捕获,连接就会泄漏
        log.error("创建订单失败", e);
        throw e;
    } finally {
        // 如果上面的代码在finally之前抛出异常,
        // 那么finally块可能不会执行,或者连接已经被关闭两次
        try {
            conn.close();
        } catch (SQLException e) {
            log.warn("关闭连接失败", e);
        }
    }
}

这段代码有两个致命问题:

  1. 在try块中抛出异常后,finally块虽然会执行,但如果conn.close()本身抛出异常,可能会掩盖原始异常
  2. 更严重的是,如果异常处理逻辑有问题,连接可能永远不会被释放

问题三:数据库连接数配置不合理

我们检查了MySQL服务器配置:

# MySQL配置文件 - my.cnf
[mysqld]
max_connections = 500              # 最大连接数500
wait_timeout = 28800              # 8小时超时
interactive_timeout = 28800
thread_cache_size = 50            # 线程缓存大小

乍看之下,500个连接似乎足够支撑200个应用连接。但问题在于:

  1. 连接数计算错误:我们只考虑了应用服务器的连接,忽略了其他用途(如运维工具、批处理任务等)
  2. 线程创建开销:MySQL为每个连接创建一个线程,线程创建和销毁开销很大
  3. 连接数限制:操作系统也有文件描述符限制,可能比MySQL配置更早成为瓶颈

索引优化:从根源提升查询性能

问题发现

在排查过程中,我们发现数据库慢查询日志中大量查询来自订单表和库存表。通过EXPLAIN分析,发现以下问题:

-- 慢查询示例1:查询库存
EXPLAIN SELECT * FROM product_stock WHERE product_id = 12345 AND store_id = 1001;

-- 慢查询示例2:插入订单
EXPLAIN INSERT INTO orders (user_id, product_id, quantity, total_price, status, create_time)
VALUES (10001, 12345, 1, 99.99, 'PENDING', NOW());

分析结果:

  • product_stock表缺少复合索引,导致全表扫描
  • orders表插入操作频繁,但查询订单时也需要优化

索引优化方案

我们进行了以下索引优化:

1. 创建复合索引

-- 为product_stock表添加复合索引
ALTER TABLE product_stock 
ADD INDEX idx_product_store (product_id, store_id);

-- 分析索引效果
EXPLAIN SELECT * FROM product_stock 
WHERE product_id = 12345 AND store_id = 1001;

执行EXPLAIN后,我们发现:

  • beforetype: ALLrows: 1000000(全表扫描)
  • aftertype: refrows: 1(索引查询)

2. 优化订单表索引

-- 为orders表添加常用查询索引
ALTER TABLE orders 
ADD INDEX idx_user_create_time (user_id, create_time),
ADD INDEX idx_status_create_time (status, create_time);

-- 对于高频查询,使用覆盖索引
ALTER TABLE orders 
ADD INDEX idx_order_query (user_id, product_id, status, create_time);

3. 使用适当的数据类型

我们还将product_iduser_id的数据类型从VARCHAR(64)改为BIGINT UNSIGNED,因为:

  • 整数比较比字符串比较快得多
  • 占用空间更小,索引效率更高
  • 减少内存和磁盘I/O
-- 修改表结构,使用更高效的数据类型
ALTER TABLE product_stock 
MODIFY COLUMN product_id BIGINT UNSIGNED NOT NULL,
MODIFY COLUMN store_id INT UNSIGNED NOT NULL;

ALTER TABLE orders 
MODIFY COLUMN user_id BIGINT UNSIGNED NOT NULL,
MODIFY COLUMN product_id BIGINT UNSIGNED NOT NULL;

索引优化效果

优化后,关键查询的性能显著提升:

查询类型 优化前平均响应时间 优化后平均响应时间 提升幅度
库存查询 150ms 2ms 75倍
订单插入 50ms 5ms 10倍
订单查询 200ms 10ms 20倍

更重要的是,由于查询速度提升,每个请求占用数据库连接的时间大幅减少,间接缓解了连接池压力。

读写分离:架构层面的解耦

问题认识

即使索引优化后,我们仍然面临一个根本问题:写操作和读操作的资源争用

在秒杀场景中:

  • 写操作:扣减库存、创建订单(高频)
  • 读操作:查询库存、查询商品信息(高频)

这些操作争用相同的数据库资源,导致锁竞争和性能下降。

读写分离方案设计

我们实施了读写分离架构:

                    ┌─────────────┐
                    │   应用层     │
                    └──────┬──────┘
                           │
           ┌───────────────┼───────────────┐
           │               │               │
    ┌──────▼──────┐ ┌─────▼─────┐ ┌──────▼──────┐
    │  主库(写)    │ │  从库1(读) │ │  从库2(读)   │
    │  MySQL A    │ │  MySQL B  │ │  MySQL C    │
    └──────┬──────┘ └─────┬─────┘ └──────┬──────┘
           │              │              │
           └──────────────┴──────────────┘
                          │
                   ┌──────▼──────┐
                   │   路由层     │
                   │ (读写分离)   │
                   └─────────────┘

1. 实现读写分离中间件

我们使用MyCat作为读写分离中间件,配置如下:

<!-- MyCat配置 - schema.xml -->
<schema name=" ecommerce_db" checkSQLschema="false" sqlMaxLimit="100">
    <!-- 表定义 -->
    <table name="orders" primaryKey="id" dataNode="dn1" rule="mod-long"/>
    <table name="product_stock" primaryKey="id" dataNode="dn1"/>
    <table name="users" primaryKey="id" dataNode="dn1"/>
</schema>

<!-- 数据节点定义 -->
<dataNode name="dn1" dataHost="localhost1" database="ecommerce_db"/>

<!-- 数据主机定义 -->
<dataHost name="localhost1" maxCon="1000" minCon="10" balance="3"
          writeType="0" dbType="mysql" dbDriver="native" switchType="1">
    <!-- 写库 -->
    <writeHost host="hostM1" url="192.168.1.100:3306" user="root" password="password"/>
    <!-- 读库 -->
    <readHost host="hostS1" url="192.168.1.101:3306" user="root" password="password"/>
    <readHost host="hostS2" url="192.168.1.102:3306" user="root" password="password"/>
</dataHost>

配置说明:

  • balance="3":所有读请求都负载均衡到读库
  • writeType="0":所有写请求都发送到主库
  • switchType="1":主库故障时自动切换

2. 应用层配置读写分离

在Spring Boot中,我们配置了两个数据源:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingDataSource(DataSource masterDataSource, 
                                       DataSource slaveDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DatabaseType.MASTER, masterDataSource);
        targetDataSources.put(DatabaseType.SLAVE, slaveDataSource);
        
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(masterDataSource);
        
        return routingDataSource;
    }
}

// 数据库类型枚举
public enum DatabaseType {
    MASTER, SLAVE
}

// 线程安全的数据库类型持有者
public class DatabaseContextHolder {
    private static final ThreadLocal<DatabaseType> CONTEXT = new ThreadLocal<>();
    
    public static void setMaster() {
        CONTEXT.set(DatabaseType.MASTER);
    }
    
    public static void setSlave() {
        CONTEXT.set(DatabaseType.SLAVE);
    }
    
    public static DatabaseType get() {
        return CONTEXT.get();
    }
    
    public static void clear() {
        CONTEXT.remove();
    }
}

// 路由数据源实现
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DatabaseContextHolder.get();
    }
}

// AOP切面,自动路由读写操作
@Aspect
@Component
public class DataSourceAspect {

    @Pointcut("@annotation(com.example.annotation.Master)")
    public void masterPointCut() {}

    @Pointcut("@annotation(com.example.annotation.Slave)")
    public void slavePointCut() {}

    @Before("masterPointCut()")
    public void masterBefore() {
        DatabaseContextHolder.setMaster();
    }

    @Before("slavePointCut()")
    public void slaveBefore() {
        DatabaseContextHolder.setSlave();
    }

    @After("masterPointCut()")
    @After("slavePointCut()")
    public void clear() {
        DatabaseContextHolder.clear();
    }
}

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Slave {}

使用示例:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private ProductStockMapper productStockMapper;

    @Master  // 写操作,强制使用主库
    public Order createOrder(Long userId, Long productId) {
        // 扣减库存
        productStockMapper.deductStock(productId, 1);
        // 创建订单
        return orderMapper.insert(Order.builder()
                .userId(userId)
                .productId(productId)
                .createTime(new Date())
                .build());
    }

    @Slave  // 读操作,使用从库
    public Order queryOrder(Long orderId) {
        return orderMapper.selectById(orderId);
    }

    @Slave
    public int queryStock(Long productId) {
        return productStockMapper.queryStock(productId);
    }
}

3. 解决主从延迟问题

读写分离最大的挑战是主从延迟。在秒杀场景中,用户刚提交订单,紧接着查询订单状态,可能因为延迟而查不到最新数据。

我们采用以下策略解决:

@Service
public class OrderQueryService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 查询订单时使用主库,确保数据一致性
     * 适用于关键业务场景,如查询订单状态、支付结果等
     */
    public Order queryOrderMaster(Long orderId) {
        DatabaseContextHolder.setMaster();
        try {
            return orderMapper.selectById(orderId);
        } finally {
            DatabaseContextHolder.clear();
        }
    }

    /**
     * 查询订单信息时使用从库
     * 适用于订单列表、订单详情等对实时性要求不高的场景
     */
    @Slave
    public List<Order> queryOrderList(Long userId) {
        return orderMapper.selectByUserId(userId);
    }

    /**
     * 强制读主库的方法
     * 适用于事务内的读操作
     */
    @Transactional
    public Order queryOrderInTransaction(Long orderId) {
        // 在事务中,默认使用主库
        return orderMapper.selectById(orderId);
    }
}

4. 监控主从延迟

”`java @Component public class ReplicationMonitor {

@Autowired
private DataSource dataSource;

@Scheduled(fixedDelay = 5000)  // 每5秒检查一次
public void checkReplicationDelay() {
    try (Connection conn = dataSource.getConnection()) {
        // 查询主库当前时间
        Timestamp masterTime = queryMasterTime(conn);

        // 查询从库复制延迟
        Long slaveDelay = querySlaveDelay(conn);

        if (slaveDelay > 5000) {  // 延迟超过5秒
            log.warn("主从延迟过大: {}ms", slaveDelay);
            // 触发告警
            alertService.sendAlert("主从延迟过大: " + slaveDelay + "ms");
        }

    } catch (SQLException e) {
        log.error("检查主从延迟失败", e);
    }
}

private Timestamp queryMasterTime(Connection