一、先看清楚问题长什么样
你有没有遇到过这种情况:系统平时跑得好好的,突然某天晚高峰订单量上来了,数据库直接卡死,前端页面转圈,用户骂骂咧咧地关掉App。你登录服务器一看,MySQL的连接数已经顶到上限,CPU飙到100%,日志里全是Lock wait timeout exceeded。
这就是典型的”连接风暴”——并发请求太多,数据库扛不住,锁冲突频繁,最终整个系统雪崩。
我见过太多团队在这个问题上栽跟头。有的团队一开始数据库就扛不住,只能应急加机器;有的团队分库分表做了一半发现数据一致性崩了,推倒重来;还有的团队完全没意识到问题,直到某天线上大规模故障才追悔莫及。
今天我们来系统性地解决这个问题,从锁冲突的根因分析,到连接池的调优,再到分库分表的架构设计,一步步避开这些性能陷阱。
二、先搞清楚MySQL高并发的核心矛盾
2.1 为什么高并发会让MySQL崩溃
MySQL的设计哲学是”单机单数据库”,它的架构是线程级的,每个连接都是独立的线程。这意味着什么?
假设你的MySQL服务器有16个CPU核心,理论上能同时处理16个线程。但实际生产中,你可能会配置几百甚至上千个连接。这些连接进来之后,发生了什么?
场景还原:一个电商网站在双11零点,1000个用户同时下单。每个用户都要:
- 查询库存(读)
- 扣减库存(写)
- 创建订单(写)
- 更新用户余额(写)
这4个操作,每个都需要数据库连接。1000个用户,理论上需要4000个数据库操作。如果每个操作耗时50ms,那1000个用户同时进来,数据库要处理多少个事务?多少个锁?
2.2 锁冲突的本质
锁是MySQL保证一致性的核心机制。但高并发下,锁就成了瓶颈。
InnoDB的行锁机制:InnoDB只对涉及到的行加锁,而不是整张表。这听起来很高效,对吧?但问题在于:
- 锁粒度:如果查询没有走索引,MySQL会锁住整张表
- 锁等待:事务A持有锁,事务B等待,如果等待时间过长,就会timeout
- 锁升级:在某些情况下,行锁会升级为表锁
实际案例:某支付系统,高并发下经常出现”锁等待超时”。排查发现,某个定时任务每天晚上对大表做批量更新,没有加LIMIT,导致整张表被锁住,后续所有查询都在等待。
2.3 连接池的真相
很多人以为连接池就是”复用连接”,其实没那么简单。
连接池的工作原理:
- 应用启动时,连接池创建N个连接
- 用户请求进来,从池中取出一个连接
- 请求结束,连接归还到池中
- 如果池中没有可用连接,新请求要么等待,要么创建新连接
问题来了:如果并发量突然暴涨,连接池不够用,会发生什么?
- 新请求等待,超时
- 连接池创建新连接,但数据库服务器扛不住
- 连接泄漏,池中的连接被占用但没归还
连接池调优的关键参数:
maxActive:最大连接数maxWait:最大等待时间minEvictableIdleTime:连接最小空闲时间timeBetweenEvictionRunsMillis:空闲连接回收间隔
三、实战:连接风暴的根因分析
3.1 第一步:找到瓶颈在哪里
当系统出现性能问题时,不要急着改代码,先搞清楚问题出在哪里。
常用工具:
MySQL慢查询日志:记录耗时超过阈值的SQL
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;performance_schema:MySQL内置的性能监控库
SELECT * FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;SHOW PROCESSLIST:查看当前正在执行的任务
SHOW FULL PROCESSLIST;SHOW ENGINE INNODB STATUS:查看InnoDB引擎状态,特别是锁等待信息
实际排查案例:某团队发现系统在高并发下经常超时,用SHOW ENGINE INNODB STATUS查看,发现大量LOCK WAIT,锁的表是一个订单表。进一步排查发现,查询语句没有走索引,导致InnoDB不得不锁住更多行。
3.2 第二步:分析锁冲突
锁冲突是高并发下的主要性能杀手。如何分析?
查看当前锁等待:
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
分析锁的类型:
- 共享锁(S锁):读锁,多个事务可以持有
- 排他锁(X锁):写锁,只有一个事务可以持有
- 意向锁:InnoDB自动管理的锁,用于表级锁的冲突检测
常见锁冲突场景:
- 大事务:事务A持有锁时间过长,其他事务等待
- 批量更新:没有加LIMIT,导致大量行被锁
- 死锁:事务A等待事务B的锁,事务B等待事务A的锁
3.3 第三步:分析SQL性能
很多锁冲突的根本原因是SQL性能差。如何分析?
使用EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 0;
重点关注:
type:连接类型,ref>range>index>ALLkey:实际使用的索引rows:预计扫描的行数Extra:额外信息,特别是Using filesort和Using temporary
实际案例:某团队的查询语句:
SELECT * FROM orders WHERE create_time > '2024-01-01' AND status = 0;
这个查询没有走索引,因为status字段选择性低,优化器认为全表扫描更快。但问题是,当并发量高时,全表扫描会锁住整张表,导致其他查询等待。
解决方案:
-- 添加复合索引
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
四、连接池调优:第一道防线
4.1 选择合适的连接池
常见的Java连接池有:
- HikariCP:性能最好,推荐使用
- Druid:阿里出品,功能丰富,监控强大
- C3P0:老牌连接池,性能一般
HikariCP配置示例:
spring:
datasource:
hikari:
maximum-pool-size: 20 # 最大连接数
minimum-idle: 5 # 最小空闲连接
idle-timeout: 30000 # 空闲超时时间
max-lifetime: 1800000 # 连接最大生命周期
connection-timeout: 30000 # 获取连接超时时间
leak-detection-threshold: 60000 # 连接泄漏检测
关键参数解释:
maximum-pool-size:最大连接数- 建议值:CPU核心数 × 2 + 有效磁盘数
- 过大:数据库连接过多,资源耗尽
- 过小:请求等待,性能下降
connection-timeout:获取连接超时时间- 建议值:30秒
- 过短:连接池满时,请求直接失败
- 过长:用户感知到卡顿
leak-detection-threshold:连接泄漏检测- 建议值:60秒
- 启用后可以发现连接未正确关闭的问题
4.2 避免连接泄漏
连接泄漏是连接池问题的根源之一。什么是连接泄漏?
连接泄漏的定义:应用从连接池获取连接,但执行完SQL后没有归还连接。
常见原因:
- 代码中没有正确关闭连接
- 异常处理不当,连接在finally块中没有关闭
- 使用了不正确的API
如何检测:
HikariCP提供了连接泄漏检测功能:
HikariConfig config = new HikariConfig();
config.setLeakDetectionThreshold(60000); // 60秒
正确关闭连接的写法:
// 错误写法
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
stmt.executeQuery("SELECT * FROM orders");
// 忘记关闭conn和stmt,导致连接泄漏
// 正确写法:使用try-with-resources
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM orders")) {
ResultSet rs = stmt.executeQuery();
// 处理结果
} // 自动关闭conn和stmt
4.3 连接池监控
连接池的监控很重要,可以及时发现异常。
HikariCP metrics:
HikariDataSource ds = (HikariDataSource) dataSource;
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
// 当前活跃连接数
int active = pool.getActiveConnections();
// 正在等待连接的数量
int waiting = pool.getThreadsAwaitingConnection();
// 连接总数
int total = pool.getTotalConnections();
定期监控:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
log.info("Active: {}, Waiting: {}, Total: {}",
pool.getActiveConnections(),
pool.getThreadsAwaitingConnection(),
pool.getTotalConnections());
}, 0, 10, TimeUnit.SECONDS);
五、SQL优化:减少锁冲突
5.1 索引优化:最核心的优化
索引是减少锁冲突最有效的手段。好的索引可以让查询只锁定需要的行,而不是整张表。
索引设计原则:
- 最左前缀原则:复合索引要遵循最左前缀
- 选择性高的字段放在前面:区分度高的字段放在索引前面
- 避免在索引列上做计算:
WHERE year(create_time) = 2024会导致索引失效
实际案例:
某电商系统的订单查询:
-- 原始查询,没有走索引
SELECT * FROM orders
WHERE user_id = 123
AND create_time > '2024-01-01'
AND status = 0;
-- 添加索引
ALTER TABLE orders
ADD INDEX idx_user_time_status (user_id, create_time, status);
为什么这样设计索引:
user_id:区分度高,放在最前面,可以快速定位用户的所有订单create_time:范围查询,放在中间status:区分度低,放在最后面
验证效果:
EXPLAIN SELECT * FROM orders
WHERE user_id = 123
AND create_time > '2024-01-01'
AND status = 0;
应该看到type: ref,key: idx_user_time_status。
5.2 避免大事务
大事务是高并发下的性能杀手。为什么?
大事务的问题:
- 持有锁时间长:事务A持有锁的时间越长,其他事务等待的时间越长
- 回滚成本高:大事务回滚需要更多时间和资源
- 容易产生死锁:事务越大,锁的范围越广,死锁可能性越高
如何避免大事务:
- 拆分事务:将一个大事务拆成多个小事务
- 减少事务范围:只在必要的操作周围加事务
- 异步处理:将非关键操作放到事务外,异步执行
实际案例:
某支付系统,创建订单的事务:
// 错误写法:事务过大
@Transactional
public void createOrder(Order order) {
// 1. 创建订单(写库)
orderDao.insert(order);
// 2. 扣减库存(写库)
inventoryDao.decrease(order.getProductId(), order.getQuantity());
// 3. 发送通知(网络IO)
notificationService.send(order.getUserId(), "订单创建成功");
// 4. 更新用户积分(写库)
pointsService.add(order.getUserId(), order.getPoints());
}
这个事务包含了网络IO,会导致事务持有锁的时间过长。
优化后的写法:
// 正确写法:事务只包含必要的数据库操作
@Transactional
public void createOrder(Order order) {
// 1. 创建订单
orderDao.insert(order);
// 2. 扣减库存
inventoryDao.decrease(order.getProductId(), order.getQuantity());
}
// 事务外异步执行
public void notifyAndAddPoints(Order order) {
// 发送通知(网络IO,异步)
CompletableFuture.runAsync(() -> {
notificationService.send(order.getUserId(), "订单创建成功");
});
// 更新积分(事务)
addPointsAsync(order.getUserId(), order.getPoints());
}
5.3 批量操作优化
批量操作如果没有优化,也会导致锁冲突。
常见问题:批量更新没有加LIMIT,导致大量行被锁。
解决方案:
- 分批处理:每次更新一定数量的行
- 使用批量插入:使用
INSERT INTO ... VALUES (...), (...), (...) - 关闭自动提交:批量操作时关闭自动提交,手动控制事务
实际代码:
// 错误写法:批量更新,没有分批
public void updateStatus(List<Long> ids) {
for (Long id : ids) {
orderDao.updateStatus(id, 1);
}
}
// 正确写法:分批更新,每批100条
public void updateStatus(List<Long> ids) {
int batchSize = 100;
for (int i = 0; i < ids.size(); i += batchSize) {
List<Long> batch = ids.subList(i, Math.min(i + batchSize, ids.size()));
orderDao.updateStatusBatch(batch, 1);
}
}
对应的SQL:
-- 批量更新
UPDATE orders
SET status = 1
WHERE id IN (<ids>);
六、分库分表:架构升级之路
6.1 什么时候需要分库分表
不是所有系统都需要分库分表。什么情况下需要考虑?
判断标准:
- 单表数据量过大:超过1000万行,查询性能下降明显
- 单库写入瓶颈:写入QPS超过数据库承载能力
- 存储瓶颈:单库存储空间不足
- 业务增长预期:预估未来1-2年数据量会翻倍
实际案例:
某社交平台的用户表:
- 用户量:5000万
- 日新增:10万
- 单表数据量:超过2亿行
查询性能开始下降,需要分库分表。
6.2 分库分表策略
分库分表有几种常见策略:
水平分表:
- 按用户ID取模分表
- 按时间分表(按月、按年)
- 按地区分表
垂直分库:
- 按业务模块分库(用户库、订单库、支付库)
- 按读写分离(主库写,从库读)
实际案例:
某电商系统的订单表分表策略:
// 按订单ID取模分16张表
int tableIndex = orderId % 16;
String tableName = "orders_" + tableIndex;
优点:
- 均匀分布,避免热点
- 查询性能提升明显
缺点:
- 跨表查询复杂
- 分布式事务困难
6.3 使用分库分表中间件
自己实现分库分表很麻烦,推荐使用成熟的中件:
ShardingSphere:
- 阿里出品,功能强大
- 支持分库、分表、读写分离
- 提供完整的生态
MyCAT:
- 老牌分库分表中间件
- 兼容MySQL协议
- 性能稳定
使用示例(ShardingSphere):
# sharding-config.yaml
shardingRule:
tables:
orders:
actualDataNodes: ds_${0..1}.orders_${0..3}
tableStrategy:
inline:
shardingColumn: order_id
algorithmExpression: orders_${order_id % 4}
databaseStrategy:
inline:
shardingColumn: user_id
algorithmExpression: ds_${user_id % 2}
对应的Java配置:
ShardingDataSource dataSource = new ShardingDataSource(shardingRuleConfig);
6.4 分库分表后的挑战
分库分表不是银弹,会带来新的问题:
**
