记得去年双十一前夕,我的业务系统突然崩了一波。监控大屏上那条红色的“接口超时”曲线,像是一条愤怒的龙,直冲云霄。排查了半天,最后定位到死锁和锁等待。那时候我才深刻意识到:高并发下,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_timeLock_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个策略,我建议你按顺序落地。先做连接池和事务优化,成本最低,效果最直接;再做索引和缓存,逐步加固。

希望这些经验能帮你避开那些深夜报警的坑。如果还有具体问题,欢迎留言讨论,咱们一起折腾。