那个周五下午的惊魂时刻
我至今还记得那个周五下午,线上服务突然全线报警,监控大屏上一片通红。作为当时负责基础架构的工程师,我接到电话时咖啡还没喝完,但已经知道——出大事了。
那家电商公司的下单系统在促销活动期间彻底崩溃,用户无法提交订单,客服热线被打爆。当我和团队连夜排查时,发现问题的根源并非大家最初猜测的“代码bug”或“硬件故障”,而是MySQL连接池配置不当在高并发场景下引发的连锁反应。
今天我想和你聊聊这个真实的案例,以及我们是如何从废墟中重建系统的。这不仅仅是关于一个配置项的修改,而是一次对数据库架构的深度反思。
案例背景:促销活动的“至暗时刻”
业务场景
那是一家中型电商平台,日常QPS(每秒查询率)大约在500左右,能够平稳运行。但每当促销活动期间,尤其是“秒杀”场景,瞬时流量可能飙升至平时的10-20倍。
这次事故发生在一次限时秒杀活动中,商品库存仅500件,但报名参与的用户超过了10万。系统架构如下:
- 应用层:Java Spring Boot集群,约20台应用服务器
- 数据库层:MySQL 8.0,主从架构(1主2从)
- 中间件:HikariCP作为连接池,Redis作为缓存层
事故现象
活动开始后15分钟,监控显示:
- 应用服务器响应时间从平均50ms飙升至3秒以上
- 数据库连接数迅速达到上限
- 大量请求返回“Cannot get a connection, pool error”错误
- 最终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请求不仅需要数据库连接,还需要:
- 查询库存
- 扣减库存
- 创建订单
- 写入订单详情
- 更新用户积分
如果每个请求平均需要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);
}
}
}
这段代码有两个致命问题:
- 在try块中抛出异常后,finally块虽然会执行,但如果
conn.close()本身抛出异常,可能会掩盖原始异常 - 更严重的是,如果异常处理逻辑有问题,连接可能永远不会被释放
问题三:数据库连接数配置不合理
我们检查了MySQL服务器配置:
# MySQL配置文件 - my.cnf
[mysqld]
max_connections = 500 # 最大连接数500
wait_timeout = 28800 # 8小时超时
interactive_timeout = 28800
thread_cache_size = 50 # 线程缓存大小
乍看之下,500个连接似乎足够支撑200个应用连接。但问题在于:
- 连接数计算错误:我们只考虑了应用服务器的连接,忽略了其他用途(如运维工具、批处理任务等)
- 线程创建开销:MySQL为每个连接创建一个线程,线程创建和销毁开销很大
- 连接数限制:操作系统也有文件描述符限制,可能比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后,我们发现:
- before:
type: ALL,rows: 1000000(全表扫描) - after:
type: ref,rows: 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_id和user_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
