想象一下,这是“双11”零点,秒杀活动刚刚开始,系统访问量瞬间涨了五百倍。后台监控大屏上一片警报红,数据库连接数直接顶满,应用服务器抛出一堆 Too many connections 的错误,用户端页面卡得转圈圈,客服电话被打爆。这时候,作为技术负责人,你必须在几分钟内做出决断。别慌,这种情况在电商大促中非常典型,核心问题往往不是数据库真的“坏”了,而是资源管理失控。今天,我们就把连接池爆满、慢查询优化、读写分离以及分库分表这四大终极武器,掰开了、揉碎了讲清楚,让你在大促时也能稳如泰山。
为什么连接池会瞬间爆满?先读懂“瓶颈”在哪里
首先,我们要搞清楚一个常识:数据库不是无限的,它像是一个餐厅,连接数就是桌椅数量。顾客(请求)进来,需要坐下来点菜(建立连接),吃饭(执行SQL),然后离开(释放连接)。如果后厨(SQL执行)出餐极慢,或者顾客坐在那里不吃饭光聊天(连接泄漏),桌椅很快就会被占满,新进来的顾客只能站在门口排队,直到餐厅崩溃。
在电商场景下,连接池爆满通常有三个罪魁祸首:
- 慢查询占用连接不释放:一个复杂的关联查询可能需要几秒甚至几十秒才能执行完,这段时间内,这个连接一直被占用。如果高并发下大量慢查询堆积,连接数会迅速打满。
- 连接泄漏:代码中获取了连接但没有正确关闭,或者异常处理逻辑不完善,导致连接“有去无回”。
- 突发流量超出预估:大促流量往往有不可预测的峰值,尤其是秒杀场景,瞬间并发可能达到平时的几十倍。
所以,解决的第一步,不是盲目加机器,而是诊断。
实战诊断:如何快速定位“占着茅坑不拉屎”的连接?
当监控报警时,先不要急着重启服务(重启会导致服务中断,影响用户体验)。登录数据库,执行以下SQL查看当前活跃连接:
-- 查看当前连接总数
SHOW STATUS LIKE 'Threads_connected';
-- 查看每秒创建的连接数,判断是否有大量新建连接
SHOW STATUS LIKE 'Threads_created';
-- 查看当前正在执行的查询,重点关注 Time 字段(执行时间)
SHOW FULL PROCESSLIST;
在 SHOW FULL PROCESSLIST 的结果中,你需要特别关注:
- State 为
Sending data或Sorting result且 Time 很大 的查询,这些就是慢查询,它们死死占住了连接。 - State 为
Sleep但 Time 很大 的连接,这很可能是连接泄漏或者应用层获取连接后没有及时使用。
如果发现有大量长时间运行的查询,可以考虑手动 KILL 掉那些最“碍事”的线程,释放资源。但这只是应急,根本解决还需要后续的优化。
连接池管理:给“餐厅”设定合理的规则
连接池(Connection Pool)是应用服务器和数据库之间的缓冲带。配置不当,要么连接不够用,要么连接太多占用内存。常见的连接池有 HikariCP、Druid、C3P0 等,其中 HikariCP 性能优异,是目前很多Java应用的首选。
核心参数调优实战
以 HikariCP 为例,几个关键参数需要精心调整:
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秒未归还视为泄漏Connection Timeout(获取连接超时时间):这个参数非常重要。如果连接池满了,新的请求获取连接时会阻塞。设置一个合理的超时时间(如30秒),让请求快速失败,而不是无限等待拖垮整个应用线程池。配合熔断降级策略,可以在极端情况下保护系统。
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 > ALL。ALL表示全表扫描,是最需要优化的。 - key:实际使用的索引。如果是
NULL,说明没有使用索引。 - rows:预计扫描的行数。行数越多,性能越差。
- Extra:额外信息。
Using filesort表示需要额外的排序操作,Using temporary表示使用了临时表,这些都是性能瓶颈。
第三步:索引优化实战
假设有一个订单表 orders,经常根据 user_id 和 status 查询。
-- 原始表结构
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);
索引设计原则:
- 最左前缀原则:联合索引
(a, b, c),查询条件必须包含a才能用到索引。 - 区分度高的列放前面:如果
user_id的区分度远高于status(比如status只有几个值),那么user_id应该放在索引的前面。 - 避免在索引列上进行函数操作或类型转换:这会导致索引失效。例如,
WHERE YEAR(create_time) = 2023会导致索引失效,应该改为范围查询WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'。
第四步:SQL语句优化
避免
SELECT *:只查询需要的字段,减少网络传输和内存占用。大分页优化:电商商品列表经常需要大分页,
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;批量操作:避免在循环中执行单条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:3。
连接池隔离:主库和从库的连接池应该分开配置,避免从库的慢查询占用主库的连接资源。
分库分表:应对海量数据的终极武器
当单库的数据量达到千万级甚至亿级,即使优化了索引和SQL,性能瓶颈依然会出现。这时,分库分表是必然选择。
分库与分表的区别
- 分表:将一个大表拆分成多个小表,存储在同一个数据库中。目的是解决单表数据量过大导致的性能下降。
- 分库:将数据分散到多个数据库中,每个数据库可以部署在不同的服务器上。目的是解决单机资源瓶颈,提升并发能力。
分片策略:如何选择?
常见的分片策略有:
- 范围分片:按ID范围,如1-100万在库1,100万-200万在库2。优点是实现简单,缺点是数据增长不均匀,热点ID集中在一个分片。
- 取模分片:按ID取模,如ID % 10,决定存放在哪个库。优点是分片均匀,缺点是扩容困难,需要重新分布数据。
- 哈希分片:对某个字段进行哈希,然后取模。比直接取模更均匀。
- 一致性哈希:解决扩容问题,新增节点时只需迁移部分数据。
电商场景推荐:订单表通常按 user_id 或 order_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)。
- 广播表:数据量小且需要频繁关联的表,如
