记得去年双十一前夕,我的业务系统突然崩了一波。监控大屏上那条红色的“接口超时”曲线,像是一条愤怒的龙,直冲云霄。排查了半天,最后定位到死锁和锁等待。那时候我才深刻意识到:高并发下,MySQL 最怕的不是查询慢,而是大家都在抢那把唯一的锁,然后排队排到怀疑人生。
今天就把这几年踩坑踩出来的5个实战优化策略,掰开了揉碎了讲给你听。咱们不整那些虚头巴脑的理论,直接上干货,连代码示例都给你备好了。
策略一:把连接池配成“精准匹配”,别搞一锅端
很多团队遇到性能瓶颈,第一反应是:“加大连接池!” 结果呢?数据库连接数飙到几百,CPU 上下文切换直接爆表,系统反而更卡了。
核心逻辑:连接池的大小不是越大越好,而是要匹配你的业务峰值并发量。
1. 如何计算最优连接数?
有一个经典的公式可以参考:
\[ 最优连接数 = 核心CPU核数 \times 2 + 有效磁盘数 \]
但这只是基础。更精准的做法是通过压测找到拐点。你可以用 JMeter 或者 wrk 打测试,观察 QPS 和 响应时间的关系。当响应时间开始线性飙升的那个点,就是连接数的上限。
2. 代码示例:Druid 连接池的黄金配置
假设你用的是 Spring Boot + Druid,下面这配置是我在实战中调优出来的“稳态”参数:
spring:
datasource:
druid:
initial-size: 5 # 初始化时建立物理连接的个数,别设太大
min-idle: 10 # 最小连接池数量,保证基础负载
max-active: 50 # 【关键】最大连接数,根据压测结果定,别超过200
max-wait: 3000 # 【关键】获取连接时的最大等待时间(毫秒),超时直接抛异常,别让它卡死线程
time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接
min-evictable-idle-time-millis: 300000 # 连接保持空闲而不被驱逐的最小时间
validation-query: SELECT 1 # 验证连接有效的SQL
test-while-idle: true # 申请连接的时候检测,建议false,影响性能,靠validation-query弥补
test-on-borrow: false # 借出连接时不检测
test-on-return: false # 归还连接时不检测
pool-prepared-statements: true # 开启PSCache,提升性能
max-pool-prepared-statement-per-connection-size: 20
为什么要设 max-wait?
这是防止雪崩的关键。当连接池耗尽时,如果没有超时机制,线程会无限期挂起,直到 Tomcat 的线程池也爆满,整个服务就假死了。设置3秒超时,可以让调用方快速失败,触发降级或重试,而不是让数据库被无效连接拖死。
策略二:索引设计——让“锁”尽可能小,尽可能快
锁冲突的本质是:事务A占着资源不走,事务B等着抢。索引优化有两个维度:一是让查询更快,事务更早结束;二是让锁的粒度更细。
1. 最左前缀法则的生死线
很多开发者喜欢建联合索引 (a, b, c),然后查询时写 WHERE b = 1 AND c = 1。这时候,索引完全失效,全表扫描,锁住整个表。
正确姿势:查询条件必须从索引的最左列开始匹配。
-- 错误的查询,索引失效,可能导致大范围锁
SELECT * FROM orders WHERE status = 1 AND user_id = 100;
-- 假设索引是 (user_id, status),这才是走索引
SELECT * FROM orders WHERE user_id = 100 AND status = 1;
2. 用“覆盖索引”避免回表
回表(Bookmark Lookup)是性能杀手。当索引不够完整,MySQL 拿着主键去聚簇索引里再查一次数据,这个过程慢,而且持有的锁时间更长。
案例:
表 orders 有索引 idx_user_status (user_id, status)。
-- 坏例子:需要回表,持有锁时间变长
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
-- 好例子:覆盖索引,只查索引树,不碰数据行,锁释放更快
SELECT order_id, amount FROM orders WHERE user_id = 100 AND status = 'PAID';
3. 避免在大字段上建索引
如果你建了 INDEX (content),而 content 是 TEXT 类型,MySQL 可能会使用前缀索引,或者根本不走索引。大索引意味着更多的页分裂和更长的锁持有时间。
建议:对于大文本,单独存一个表,主表只存 ID。
策略三:短事务——让锁“活不过一秒”
锁冲突最大的敌人是长事务。你在事务里调用了一个远程 RPC 接口,或者处理了一段复杂的业务逻辑,锁就持有了好几百毫秒。这时候,其他请求进来,排队排到绝望。
1. 事务内只做数据库操作
// 错误示范:事务包裹了外部调用
@Transactional
public void createOrder(Long userId, OrderDTO dto) {
// 1. 数据库插入
orderMapper.insert(buildOrder(userId, dto));
// 2. 【危险】这里调用了外部短信服务,耗时可能500ms+
// 在这500ms内,数据库连接和锁一直被占用!
smsService.sendSMS(userId, "订单创建成功");
// 3. 更新状态
orderMapper.updateStatus(orderId, "SENT");
}
优化后:
public void createOrder(Long userId, OrderDTO dto) {
Long orderId;
// 1. 先完成所有数据库操作,快速提交事务,释放锁
try {
orderId = orderMapper.insert(buildOrder(userId, dto));
orderMapper.updateStatus(orderId, "PENDING");
} finally {
// 确保数据库操作完成
}
// 2. 事务提交后,再异步调用外部服务
// 使用消息队列或 CompletableFuture 异步处理
asyncService.sendSMS(userId, "订单创建成功");
// 3. 如果需要更新最终状态,可以写个定时任务或对账逻辑去补偿
}
记住:@Transactional 的粒度要小,能不带就不带,非要带的话,里面别放任何“慢操作”。
2. 合理设置事务隔离级别
默认的 REPEATABLE_READ(可重复读)在 MySQL 中通过 MVCC 机制避免了大部分读锁,但在高并发写场景下,间隙锁(Gap Lock)可能会锁住一段范围,导致其他事务插入失败。
如果你的业务允许弱一致性(比如统计表、日志),可以考虑降到 READ_COMMITTED。
-- 会话级别降低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
策略四:慢查询日志——抓住“锁等待”的元凶
很多时候,锁冲突是慢查询引发的。一个跑了10秒的查询,会持有一个行锁10秒,这10秒内所有竞争这把锁的线程都在排队。
1. 开启并分析慢查询日志
# my.cnf 配置
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 # 超过1秒的查询才记录,别太敏感,也别太粗
log_queries_not_using_indexes = 1 # 记录没走索引的查询
2. 用 pt-query-digest 分析
不要手动看日志,用 Percona 的工具:
pt-query-digest /var/log/mysql/slow.log
它会给你出一份报告,告诉你哪些 SQL 最耗时间、锁等待最严重。重点关注 Query_time 和 Lock_time。如果 Lock_time 占比很高,说明锁竞争严重。
3. 实时查看锁等待
当线上出现超时,别猜,直接看:
-- 查看当前正在运行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看锁等待情况(MySQL 5.7+)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- 或者用较老的表(MySQL 5.6及之前)
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
实战技巧:找到阻塞源头后,如果是个无关紧要的长事务,可以考虑 KILL 掉它,释放资源。
策略五:读写分离 + 缓存层——把压力分流
如果数据库已经累得喘不过气,锁冲突不可避免,那我们就减少去数据库的次数。
1. 二级缓存架构
对于高频读取、低频修改的数据(比如商品详情、配置信息),千万别直接打数据库。
// 伪代码:缓存优先
public Product getProduct(Long id) {
// 1. 先查缓存
Product product = redisClient.get("product:" + id);
if (product != null) {
return product;
}
// 2. 缓存miss,查数据库
product = productMapper.selectById(id);
if (product != null) {
// 3. 写回缓存,设置过期时间,防止穿透
redisClient.setex("product:" + id, 3600, product);
}
return product;
}
2. 读写分离的配置
使用 MyBatis 的动态数据源,或者 ShardingSphere。
# ShardingSphere 配置示例
dataSources:
ds_master:
url: jdbc:mysql://master:3306/db
ds_slave:
url: jdbc:mysql://slave:3306/db
shardingRule:
tables:
orders:
actualDataNodes: ds_${0}.orders
databaseStrategy:
inline:
shardingColumn: user_id
algorithmExpression: ds_${user_id % 2}
tableStrategy:
none:
注意:读写分离有延迟,对于“查询后立即需要看到结果”的场景(如支付状态查询),必须读主库。
结语:优化是一场平衡的艺术
高并发下的锁冲突优化,没有银弹。
- 连接池要精准,别贪多;
- 索引要精细,让锁范围最小;
- 事务要短小,别让外部调用拖后腿;
- 监控要实时,快速定位阻塞源;
- 缓存要好用,把压力挡在数据库门外。
这5个策略,我建议你按顺序落地。先做连接池和事务优化,成本最低,效果最直接;再做索引和缓存,逐步加固。
希望这些经验能帮你避开那些深夜报警的坑。如果还有具体问题,欢迎留言讨论,咱们一起折腾。
