说实话,看到这个问题我脑子里全是前阵子那个深夜报警。凌晨三点,手机狂震,监控大屏上一片红,CPU占用率直接飙到98%,数据库连接数瞬间打满,用户端全是504 Gateway Timeout。那种感觉就像是你正在跑马拉松,突然有人把跑道给堵死了,你憋着气拼命蹬腿,但就是不动。

很多开发者一遇到这种场景,第一反应是加配置、扩硬件、或者甚至想加缓存。但如果你不懂底层原理,就像是在一辆爆缸的赛车里疯狂踩油门,除了更严重的崩溃,解决不了任何问题。今天咱们不聊虚的,就把这个坑挖开,从最根源的CPU飙升,到连接池爆炸,再到最后的读写分离实战,一步步带你把这个“拦路虎”驯服。

别急着加配置,先看看CPU到底在忙什么

当监控报警CPU飙升时,90%的新手运维或者开发会直接去改my.cnf,加大缓冲池或者调整线程数。停!先别动。你得先知道MySQL在CPU上到底在干什么。

CPU高通常就两件事:CPU在算(复杂的SQL、排序、临时表)或者CPU在等(I/O等待,但I/O等待通常体现为iowait,如果是纯CPU使用率高,多半是算不过来)。

第一步:定位“真凶”

别猜,用数据说话。登录数据库,执行以下操作:

-- 查看当前正在执行的SQL,按时间排序,找出最慢的
SHOW PROCESSLIST;

-- 或者用更专业的性能模式(如果你开启了performance_schema)
SELECT 
    DIGEST_TEXT, 
    COUNT_STAR, 
    SUM_TIMER_WAIT/1000000000000 AS total_sec,
    AVG_TIMER_WAIT/1000000000000 AS avg_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY total_sec DESC
LIMIT 10;

你会发现,可能只有那么几条SQL占用了绝大部分CPU。这就是我们要优化的目标。

第二步:解读Explain,别只看行数

拿到那条慢SQL,EXPLAIN是标配。但很多人只关心type是不是ALL(全表扫描)。其实,CPU飙升的常见凶手往往藏在这些地方:

  1. Filesort 和 Temporary:这是CPU杀手。当SQL需要排序(ORDER BY)且索引覆盖不到,或者分组(GROUP BY)数据量太大时,MySQL会在磁盘或内存里建临时表并排序。

    • 现象Extra列出现Using filesortUsing temporary
    • 例子SELECT * FROM orders WHERE status=1 ORDER BY create_time DESC LIMIT 10; 如果没有复合索引,每次翻页都要全表扫描后排序,CPU能不高吗?
    • 解法:加复合索引 (status, create_time)。记住,WHERE后面的字段放前面,ORDER BY放后面,这样索引既能过滤又能排序,一举两得。
  2. 隐式类型转换:这是最容易被忽视的坑。

    • 场景:字段user_idBIGINT类型,但你的代码里传的是字符串'123'
    • 后果:MySQL会把字段值转换成字符串来比较,这会导致索引失效,进而引发全表扫描。全表扫描产生的数据量巨大,过滤和比较操作全部在CPU里完成,CPU直接爆炸。
    • 修复:确保代码里的类型和数据库字段类型严格一致。
  3. 大字段查询SELECT * 是万恶之源。如果你的表里有TEXTBLOB类型的大字段,而你的业务只需要几个ID和名字,却用了SELECT *,MySQL会把整个行都读出来,然后在服务器内存里处理,最后又扔掉大部分数据。这不仅吃内存,还增加CPU负担。

    • 建议:明确指定需要的字段,如SELECT id, name, status

连接池爆满:不仅仅是数据库的问题

处理完CPU,下一个报警通常是:“连接数达到最大值,无法建立新连接”。这时候应用端报错Too many connections,用户请求堆积,超时激增。

很多人会把锅甩给MySQL,说它太脆弱。但其实,连接池爆满往往是应用层配置不当数据库端连接泄漏共同作用的结果。

连接池不是越大越好

Spring Boot项目里,我们通常用HikariCP。有些同学为了“高并发”,直接把最大连接数设成200甚至500。这里有个误区:连接数不等于并发数

MySQL处理每个连接都需要消耗内存(每个连接大约256KB~几MB,取决于配置),更重要的是,上下文切换开销巨大。当连接数超过一定阈值,数据库的CPU会花大量时间在管理连接上,而不是执行SQL。

实战建议

  • 一般业务场景,maximum-pool-size设置为20-50足够应对几百个并发请求。
  • 如果确实需要高并发,应该通过增加应用实例(横向扩展)来分担压力,而不是在一个连接池里堆砌连接数。

连接泄漏:隐蔽的杀手

有时候,配置没问题,但连接就是被占满。这时候要查连接泄漏

// HikariCP配置检查
HikariConfig config = new HikariConfig();
config.setLeakDetectionThreshold(60000); // 设置60秒未归还连接则报警

leakDetectionThreshold启用后,如果某个查询卡住超过60秒,HikariCP会打印警告日志,甚至抛出异常。

常见原因

  1. 事务未提交或回滚:代码里开启了事务,但中间抛出了异常,finally块里没有正确关闭连接或提交事务。
  2. 长事务:一个连接拿了锁,然后去调用外部HTTP接口,等待了几秒钟。这段时间,这个连接一直被占用,其他请求抢不到连接。

例子

@Transactional
public void processOrder(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    // 这里调用外部支付接口,耗时可能几秒到几十秒
    // 事务一直挂着,连接一直占用,数据库锁也可能一直持有
    paymentService.pay(order); 
    orderMapper.updateStatus(orderId, 2);
}

解法:尽量缩小事务范围。把查询和写操作分开,或者将耗时操作移出事务。

查询超时:为什么我的SQL慢得像蜗牛

连接池爆满之后,新请求拿不到连接,最终表现为查询超时。但这背后往往是单条SQL执行慢导致的。

慢查询日志分析

MySQL自带慢查询日志,打开它:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的SQL记录

日志路径通常在/var/log/mysql/slow.log

典型优化案例:子查询改Join

假设我们有两张表:users(用户表)和orders(订单表)。我们要查出所有有过订单的用户。

错误写法(子查询)

SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE create_time > '2023-01-01');

如果orders表有几百万数据,这个子查询可能每次都全表扫描,而且对于每个用户都要去子查询结果里匹配,效率极低。

优化写法(Join)

SELECT u.* FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.create_time > '2023-01-01';

INNER JOIN通常能让优化器更好地利用索引,执行计划也更优。

深度分页问题

“第10000页,每页10条数据” —— 这是经典性能陷阱。

SELECT * FROM orders LIMIT 100000, 10;

MySQL会扫描前100010条记录,然后扔掉前100000条,只返回最后10条。页数越深,性能越差。

优化方案

  1. 延迟关联:先查出ID,再关联回原表。

    SELECT o.* FROM orders o 
    INNER JOIN (SELECT id FROM orders LIMIT 100000, 10) AS tmp ON o.id = tmp.id;
    

    这样子查询只需要扫描索引(覆盖索引),速度极快,然后再去回表查完整数据。

  2. 游标法:记录上一页最大ID。

    SELECT * FROM orders WHERE id > last_max_id LIMIT 10;
    

    这是分页最推荐的方式,尤其是无限滚动加载的场景。

读写分离:架构层面的终极解决方案

当单机MySQL即便优化了SQL也扛不住高并发读请求时,读写分离是必经之路。

原理很简单:主库(Master)负责写,从库(Slave)负责读。通过MySQL的主从复制(Replication),数据从主库同步到从库。

架构示意图

      [客户端]
         |
    [负载均衡/中间件]  <-- 关键:识别读写请求
      /           \
   [主库 Master]   [从库 Slave 1]
      |              [从库 Slave 2]
   [写操作]        [读操作]

中间件选型

你可以自己在代码里硬编码判断是读还是写,但这太脏了,维护成本高。推荐使用中间件:

  1. MyCat:老牌国产中间件,功能强大,支持读写分离、分库分表。配置稍复杂,但社区成熟。
  2. ShardingSphere:Apache顶级项目,现在更流行。提供Proxy模式和Java Agent模式。Proxy模式零侵入,对应用透明;Agent模式需要改代码。
  3. Mycat2 / Vitess:新兴选择,性能更好。

ShardingSphere-Proxy 实战配置

假设你已经搭建了一主两从的MySQL集群。

1. 修改server.yaml(基础配置)

authentication:
  users:
    root:
      password: root
      privileges:
        ALL PRIVILEGES:
          host: "%"

2. 修改config-sharding.yaml(数据源定义)

dataSources:
  ds_master:
    url: jdbc:mysql://127.0.0.1:3306/db_demo?serverTimezone=UTC&useSSL=false
    username: root
    password: root
    connectionTimeoutMilliseconds: 30000
    idleTimeoutMilliseconds: 60000
    maxLifetimeMilliseconds: 1800000
    maxPoolSize: 20
  
  ds_slave_1:
    url: jdbc:mysql://127.0.0.1:3307/db_demo?serverTimezone=UTC&useSSL=false
    username: root
    password: root
    # ... 其他配置同master
  
  ds_slave_2:
    url: jdbc:mysql://127.0.0.1:3308/db_demo?serverTimezone=UTC&useSSL=false
    username: root
    password: root
    # ... 其他配置同master

rules:
  - !READWRITE_SPLITTING
    dataSources:
      prx_ds:
        staticStrategy:
          writeDataSourceName: ds_master
          readDataSourceNames:
            - ds_slave_1
            - ds_slave_2

这里配置了一个逻辑数据源prx_ds,写操作路由到ds_master,读操作负载均衡到两个从库。

3. 启动Proxy

bin/start.sh

应用端连接127.0.0.1:3307(Proxy端口),无需修改代码,SQL自动分流。

读写分离的坑:主从延迟

这是读写分离最大的痛点。主库写完数据,异步复制到从库需要时间(通常毫秒级,但在高并发或大数据量写入时可能达到秒级)。

场景

  1. 用户下单,写入主库。
  2. 立即查询订单详情,请求被路由到从库。
  3. 从库数据还没同步过来,用户看到“订单不存在”。

解决方案

  1. 强制读主库:对于关键业务(如支付结果查询、订单状态),在代码里明确指定使用主库连接。ShardingSphere支持通过Hint或注解实现。
    
    // ShardingSphere-JDBC 注解方式
    @MasterOnly
    public Order queryOrder(Long orderId) {
        return orderMapper.selectById(orderId);
    }
    
  2. 缩短延迟:优化主从同步机制,使用semi-sync(半同步复制),保证至少一个从库写入成功才返回,虽然牺牲了一点性能,但保证了数据一致性。
  3. 业务容忍:对于非强一致场景(如猜你喜欢、评论列表),可以接受短暂的延迟。

结语:没有银弹,只有权衡

回到最初的问题:MySQL高并发时CPU飙升、连接池爆满,真的撑不住吗?

答案是:单靠硬件和配置调整,确实撑不住。真正的解决之道在于层层递进的优化体系

  1. SQL层:通过Explain和慢查询日志,优化索引,消除全表扫描和临时表。这是成本最低、收益最高的步骤。
  2. 连接层:合理配置连接池,避免泄漏,缩短事务时间。
  3. 架构层:引入读写分离,分担读压力;如果读压力依然巨大,再考虑分库分表或使用缓存(Redis)。

记住,数据库不是用来存数据的,是用来查数据的。把计算压力前移(应用层缓存、预计算),把存储压力后移(分片、归档),才是高并发系统的正道。

希望这篇分享能帮你在下一个凌晨三点,从容地打开监控面板,而不是对着红色的报警灯发呆。如果还有具体问题,欢迎在评论区留言,我们一起讨论。