想象一下,这是“双11”零点,秒杀活动刚刚开始,系统访问量瞬间涨了五百倍。后台监控大屏上一片警报红,数据库连接数直接顶满,应用服务器抛出一堆 Too many connections 的错误,用户端页面卡得转圈圈,客服电话被打爆。这时候,作为技术负责人,你必须在几分钟内做出决断。别慌,这种情况在电商大促中非常典型,核心问题往往不是数据库真的“坏”了,而是资源管理失控。今天,我们就把连接池爆满、慢查询优化、读写分离以及分库分表这四大终极武器,掰开了、揉碎了讲清楚,让你在大促时也能稳如泰山。

为什么连接池会瞬间爆满?先读懂“瓶颈”在哪里

首先,我们要搞清楚一个常识:数据库不是无限的,它像是一个餐厅,连接数就是桌椅数量。顾客(请求)进来,需要坐下来点菜(建立连接),吃饭(执行SQL),然后离开(释放连接)。如果后厨(SQL执行)出餐极慢,或者顾客坐在那里不吃饭光聊天(连接泄漏),桌椅很快就会被占满,新进来的顾客只能站在门口排队,直到餐厅崩溃。

在电商场景下,连接池爆满通常有三个罪魁祸首:

  1. 慢查询占用连接不释放:一个复杂的关联查询可能需要几秒甚至几十秒才能执行完,这段时间内,这个连接一直被占用。如果高并发下大量慢查询堆积,连接数会迅速打满。
  2. 连接泄漏:代码中获取了连接但没有正确关闭,或者异常处理逻辑不完善,导致连接“有去无回”。
  3. 突发流量超出预估:大促流量往往有不可预测的峰值,尤其是秒杀场景,瞬间并发可能达到平时的几十倍。

所以,解决的第一步,不是盲目加机器,而是诊断

实战诊断:如何快速定位“占着茅坑不拉屎”的连接?

当监控报警时,先不要急着重启服务(重启会导致服务中断,影响用户体验)。登录数据库,执行以下SQL查看当前活跃连接:

-- 查看当前连接总数
SHOW STATUS LIKE 'Threads_connected';

-- 查看每秒创建的连接数,判断是否有大量新建连接
SHOW STATUS LIKE 'Threads_created';

-- 查看当前正在执行的查询,重点关注 Time 字段(执行时间)
SHOW FULL PROCESSLIST;

SHOW FULL PROCESSLIST 的结果中,你需要特别关注:

  • State 为 Sending dataSorting resultTime 很大 的查询,这些就是慢查询,它们死死占住了连接。
  • State 为 SleepTime 很大 的连接,这很可能是连接泄漏或者应用层获取连接后没有及时使用。

如果发现有大量长时间运行的查询,可以考虑手动 KILL 掉那些最“碍事”的线程,释放资源。但这只是应急,根本解决还需要后续的优化。

连接池管理:给“餐厅”设定合理的规则

连接池(Connection Pool)是应用服务器和数据库之间的缓冲带。配置不当,要么连接不够用,要么连接太多占用内存。常见的连接池有 HikariCP、Druid、C3P0 等,其中 HikariCP 性能优异,是目前很多Java应用的首选。

核心参数调优实战

以 HikariCP 为例,几个关键参数需要精心调整:

  1. maximumPoolSize:最大连接数。这是最容易出错的参数。很多开发者直觉地把它设得很大,比如100、200,认为这样能扛住高并发。这是一个误区!连接数不是越大越好,因为每个连接都会占用数据库端的内存和CPU。对于MySQL,建议的最大连接数公式可以参考:

    \[ maximumPoolSize = (CPU核数 \times 2) + 有效磁盘数 \]

    更实际的电商场景下,可以根据压测结果来定。比如,你的数据库服务器是16核,那么连接池设为20-40可能比100效果更好,因为太多的连接会导致上下文切换开销剧增。

    # application.yml 配置示例
    spring:
      datasource:
        hikari:
          maximum-pool-size: 30  # 根据压测调整,不要盲目追求大
          minimum-idle: 10       # 最小空闲连接,保持一定热身
          idle-timeout: 30000    # 空闲连接超时时间(毫秒)
          max-lifetime: 1800000  # 连接最大生命周期(毫秒),防止数据库端连接过期
          connection-timeout: 30000 # 获取连接超时时间,避免线程无限等待
          leak-detection-threshold: 60000 # 连接泄漏检测阈值,超过60秒未归还视为泄漏
    
  2. Connection Timeout(获取连接超时时间):这个参数非常重要。如果连接池满了,新的请求获取连接时会阻塞。设置一个合理的超时时间(如30秒),让请求快速失败,而不是无限等待拖垮整个应用线程池。配合熔断降级策略,可以在极端情况下保护系统。

  3. Leak Detection(泄漏检测):在生产环境中务必开启泄漏检测。虽然会有一定的性能损耗,但它能帮你及时发现代码中的连接泄漏问题。一旦检测到泄漏,日志中会有明确提示,指向具体的代码位置。

应用层优化:避免在事务中做网络请求

很多慢查询问题源于代码逻辑。比如,在一个数据库事务中,发起了HTTP调用、RPC调用或者文件IO操作。这些操作可能耗时很长,导致数据库连接长时间占用。

// 错误示例:在事务中做耗时的RPC调用
@Transactional
public void placeOrder(Long userId, Long itemId) {
    // 1. 扣减库存
    inventoryService.deductStock(itemId);
    
    // 2. 调用第三方风控服务(可能耗时数百毫秒甚至秒级)
    // 这会导致数据库连接被长时间占用!
    RiskCheckResult result = riskService.check(userId);
    
    // 3. 创建订单
    orderService.createOrder(userId, itemId);
}

优化方案:将非数据库操作移到事务之外,或者使用异步方式处理。

// 正确示例:事务只包裹数据库操作
public void placeOrder(Long userId, Long itemId) {
    // 1. 先做风控检查(非事务)
    RiskCheckResult result = riskService.check(userId);
    
    // 2. 事务只包含数据库操作,快速提交
    transactionTemplate.execute(status -> {
        inventoryService.deductStock(itemId);
        orderService.createOrder(userId, itemId);
        return null;
    });
}

这样,数据库连接占用时间大大缩短,同一连接可以服务更多请求。

慢查询优化:根治“出餐慢”的难题

连接池爆满的根本原因往往是慢查询。优化慢查询是数据库性能调优的核心。

第一步:开启慢查询日志

MySQL提供了慢查询日志功能,可以记录执行时间超过阈值的SQL。

-- 查看慢查询日志是否开启
SHOW VARIABLES LIKE 'slow_query_log%';

-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';

-- 设置慢查询阈值,比如超过1秒的记录
SET GLOBAL long_query_time = 1;

-- 指定日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

第二步:使用EXPLAIN分析SQL

拿到慢查询日志中的SQL后,使用 EXPLAIN 命令分析其执行计划。

EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 1 ORDER BY create_time DESC LIMIT 10;

重点关注 EXPLAIN 结果中的几个字段:

  • type:访问类型。从优到劣依次是:system > const > eq_ref > ref > range > index > ALLALL 表示全表扫描,是最需要优化的。
  • key:实际使用的索引。如果是 NULL,说明没有使用索引。
  • rows:预计扫描的行数。行数越多,性能越差。
  • Extra:额外信息。Using filesort 表示需要额外的排序操作,Using temporary 表示使用了临时表,这些都是性能瓶颈。

第三步:索引优化实战

假设有一个订单表 orders,经常根据 user_idstatus 查询。

-- 原始表结构
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    create_time DATETIME NOT NULL,
    amount DECIMAL(10, 2)
);

-- 错误做法:分别创建索引
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_status ON orders(status);

-- 正确做法:创建联合索引
-- 注意:查询条件是 user_id = ? AND status = ?,所以索引顺序应该是 (user_id, status)
CREATE INDEX idx_user_id_status ON orders(user_id, status);

索引设计原则

  1. 最左前缀原则:联合索引 (a, b, c),查询条件必须包含 a 才能用到索引。
  2. 区分度高的列放前面:如果 user_id 的区分度远高于 status(比如 status 只有几个值),那么 user_id 应该放在索引的前面。
  3. 避免在索引列上进行函数操作或类型转换:这会导致索引失效。例如,WHERE YEAR(create_time) = 2023 会导致索引失效,应该改为范围查询 WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'

第四步:SQL语句优化

  1. 避免 SELECT *:只查询需要的字段,减少网络传输和内存占用。

  2. 大分页优化:电商商品列表经常需要大分页,LIMIT 100000, 10 这种查询会扫描大量无用数据。

    -- 优化前:性能极差
    SELECT * FROM products WHERE category_id = 100 ORDER BY sales DESC LIMIT 100000, 10;
    
    
    -- 优化后:利用子查询先定位主键,再回表
    SELECT p.* FROM products p 
    INNER JOIN (
        SELECT id FROM products WHERE category_id = 100 ORDER BY sales DESC LIMIT 100000, 10
    ) tmp ON p.id = tmp.id;
    

    或者使用“游标法”,记录上一页的最大ID:

    SELECT * FROM products WHERE category_id = 100 AND id > last_max_id ORDER BY id ASC LIMIT 10;
    
  3. 批量操作:避免在循环中执行单条INSERT/UPDATE,使用批量插入。

    // 错误:循环插入,每次一个事务
    for (Order order : orders) {
        orderMapper.insert(order);
    }
    
    
    // 正确:批量插入,一次事务
    orderMapper.batchInsert(orders);
    

读写分离:让数据库“专人专事”

当读多写少的场景成为主流(电商就是典型,浏览商品的人远多于下单的人),读写分离是提升吞吐量的有效手段。

架构原理

读写分离的核心思想是:主库(Master)负责写操作,从库(Slave)负责读操作。主库通过_binlog_将数据变更同步到从库。应用层通过中间件或代码逻辑,将读请求路由到从库,写请求路由到主库。

实战方案:基于MyCat或ShardingSphere的读写分离

这里以 Apache ShardingSphere 为例,它是一个轻量级的分布式数据库中间件,配置灵活,性能好。

1. 部署主从复制

确保MySQL主从复制正常工作。在主库执行:

-- 主库配置(my.cnf)
server-id = 1
log-bin = mysql-bin
binlog-format = ROW

-- 从库配置(my.cnf)
server-id = 2
relay-log = relay-bin

然后在从库执行:

CHANGE MASTER TO
  MASTER_HOST = 'master_ip',
  MASTER_USER = 'repl_user',
  MASTER_PASSWORD = 'repl_password',
  MASTER_LOG_FILE = 'mysql-bin.000001',
  MASTER_LOG_POS = 1234;

START SLAVE;

2. ShardingSphere 配置

# shardingsphere.yaml
dataSources:
  ds_master:
    url: jdbc:mysql://master:3306/db1
    username: root
    password: password
    driverClassName: com.mysql.jdbc.Driver
  ds_slave0:
    url: jdbc:mysql://slave0:3306/db1
    username: root
    password: password
    driverClassName: com.mysql.jdbc.Driver
  ds_slave1:
    url: jdbc:mysql://slave1:3306/db1
    username: root
    password: password
    driverClassName: com.mysql.jdbc.Driver

rules:
  - !READWRITE-SPLITTING
    dataSources:
      pr_ds:
        writeDataSourceName: ds_master
        readDataSourceNames:
          - ds_slave0
          - ds_slave1
        staticStrategy:
          readDataSourceRuleNames:
            - ds_slave0
            - ds_slave1
        dynamicStrategy:
          # 可以根据负载动态选择从库
          masterAllowReadFromSlave: true

props:
  sql-show: true

3. 应用层配置

在Spring Boot中引入ShardingSphere的 starter,配置数据源即可,应用代码无需改动。

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-spring-boot-starter</artifactId>
    <version>5.3.2</version>
</dependency>

读写分离的注意事项

  1. 主从延迟:这是读写分离最大的痛点。用户刚下单成功,立刻去查询订单状态,可能因为从库数据还没同步过来而查不到。解决方案:

    • 强制读主库:对于强一致性的操作(如订单状态、支付结果),通过路由规则强制读取主库。
    • 缩短同步延迟:使用高性能的网络和存储,或者采用半同步复制。
    • 业务容忍:对于非关键性数据,允许短暂的延迟。
  2. 从库负载:从库不是越多越好,每个从库都会增加主库的同步开销。一般建议主从比例不超过1:3。

  3. 连接池隔离:主库和从库的连接池应该分开配置,避免从库的慢查询占用主库的连接资源。

分库分表:应对海量数据的终极武器

当单库的数据量达到千万级甚至亿级,即使优化了索引和SQL,性能瓶颈依然会出现。这时,分库分表是必然选择。

分库与分表的区别

  • 分表:将一个大表拆分成多个小表,存储在同一个数据库中。目的是解决单表数据量过大导致的性能下降。
  • 分库:将数据分散到多个数据库中,每个数据库可以部署在不同的服务器上。目的是解决单机资源瓶颈,提升并发能力。

分片策略:如何选择?

常见的分片策略有:

  1. 范围分片:按ID范围,如1-100万在库1,100万-200万在库2。优点是实现简单,缺点是数据增长不均匀,热点ID集中在一个分片。
  2. 取模分片:按ID取模,如ID % 10,决定存放在哪个库。优点是分片均匀,缺点是扩容困难,需要重新分布数据。
  3. 哈希分片:对某个字段进行哈希,然后取模。比直接取模更均匀。
  4. 一致性哈希:解决扩容问题,新增节点时只需迁移部分数据。

电商场景推荐:订单表通常按 user_idorder_id 进行哈希分片,确保同一用户的数据落在同一个分片,便于查询。

实战:使用ShardingSphere进行分库分表

假设我们有订单表 orders,数据量预计达到亿级。我们计划分10个库,每个库100张表。

1. 数据源配置

dataSources:
  ds_0:
    url: jdbc:mysql://db0:3306/db0
    username: root
    password: password
  ds_1:
    url: jdbc:mysql://db1:3306/db1
    username: root
    password: password
  # ... 更多数据源

2. 分片规则配置

rules:
  - !SHARDING
    tables:
      orders:
        actualDataNodes: ds_${0..9}.orders_${0..99}
        tableStrategy:
          standard:
            shardingColumn: order_id
            shardingAlgorithmName: orders-table-inline
        databaseStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: orders-db-inline
    shardingAlgorithms:
      orders-db-inline:
        type: INLINE
        props:
          algorithm-expression: ds_${user_id % 10}
      orders-table-inline:
        type: INLINE
        props:
          algorithm-expression: orders_${order_id % 100}

3. 跨库查询与聚合

分库分表后,跨库查询变得复杂。ShardingSphere支持广播表(Broadcast Table)和绑定表(Binding Table)。

  • 广播表:数据量小且需要频繁关联的表,如