说真的,之前我们公司的那套系统,只要晚高峰一到,后台报警就响个不停。那时候我盯着监控大屏,看着数据库CPU直接飙到95%,请求队列堆成山,心里那个急啊。明明代码写得挺漂亮,怎么就卡在这儿了?后来我们一步步拆解,从连接池调优到读写分离,再到引入缓存并行,才算把这套MySQL的高并发性能给硬生生拽上来。今天就把这段实战经历,连同那些踩过的坑,掰开揉碎讲给你听。

一、 先别急着上架构,把连接池这个“喉咙”捏好

很多人一上来就想着搞什么微服务、搞分布式,结果连最基本的数据库连接池都没调好。这就好比你给一辆自行车装了个法拉利的引擎,但进气口还堵着,能跑得快才怪。

1.1 为什么连接池这么重要?

每一次数据库连接,其实都是一次昂贵的网络握手。TCP三次握手、TLS加密协商(如果开了SSL)、MySQL的身份验证……这一套流程下来,建立一个新的连接可能要耗费几百毫秒甚至更久。如果高并发场景下,每个请求都去新建连接,数据库服务端瞬间就会被这些“建立连接”的请求淹没,根本没时间处理真正的业务SQL。

连接池的作用,就是把那些已经建立好的连接存起来,用的时候取,不用的时候还。这样就能避免频繁的建连和断连开销。

1.2 常见连接池的配置陷阱

我们以目前最主流的 HikariCP 为例(它是 Spring Boot 默认的,也是性能标杆),来看看那些容易被忽视的配置项。

陷阱一:maximumPoolSize 设得太大

很多人觉得连接越多越好,直接拉到100、200。大错特错。

数据库服务端是有上限的,MySQL默认的 max_connections 是151。如果你应用端开了100个连接,再算上运维、监控、其他服务的连接,数据库很容易就爆了。而且,连接数过多,线程切换的开销也会变大,CPU会在上下文切换上浪费大量时间。

最佳实践:连接池大小应该根据 CPU核数 和 IO等待特性 来估算。

对于MySQL这种IO密集型任务,有一个经验公式:

\[ 最大连接数 = CPU核数 \times 2 + 磁盘有效数 \]

假设你的服务器是8核,磁盘是1个SSD,那连接池设为 17-20 左右就差不多了。如果是纯内存操作,可以设到 CPU核数 + 1。

陷阱二:connectionTimeout 设置不当

默认超时是30秒。这在开发环境没啥问题,但在生产环境,30秒意味着你的用户要对着加载圈转半分钟,体验极差。而且,超时后抛出的异常如果被业务层静默处理,会掩盖真实的问题。

建议设置为 5-10秒。如果5秒都拿不到连接,说明系统已经过载了,应该立刻触发熔断或降级,而不是让请求在那里干等。

陷阱三:忽视了 maxLifetime

这是很多新手容易忽略的。maxLifetime 指的是连接的最大存活时间。默认是30分钟(1800000毫秒)。

为什么需要这个?因为网络不稳定,或者数据库重启、防火墙断连,客户端可能不知道连接已经死了。如果继续用这个“僵尸连接”,就会报错。设置一个比MySQL服务端 wait_timeout(默认8小时)短的 maxLifetime,可以让连接池在连接被服务端断开前,主动回收并重建,确保取出来的连接永远是活的。

1.3 实战代码:HikariCP 的精准配置

下面是一段经过实战打磨的 HikariCP 配置,你可以直接参考:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

public class DataSourceConfig {

    public static DataSource getDataSource() {
        HikariConfig config = new HikariConfig();
        
        // 数据源URL
        config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/mydb?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai");
        
        // 用户名密码
        config.setUsername("root");
        config.setPassword("your_password");
        
        // 驱动类名,HikariCP通常会自动检测,但显式指定更安全
        config.setDriverClassName("com.mysql.cj.jdbc.Driver");
        
        // 连接池名称
        config.setPoolName("MyHighConcPool");
        
        // 核心配置:最大连接数,根据CPU核数调整,这里是8核机器,设为16
        config.setMaximumPoolSize(16);
        
        // 最小空闲连接,建议设置为最大连接数的20%-50%,保持一定的热连接
        config.setMinimumIdle(4);
        
        // 连接超时时间,5秒内拿不到连接就报错,避免请求无限等待
        config.setConnectionTimeout(5000);
        
        // 空闲连接超时时间,6分钟无使用的连接会被回收
        config.setIdleTimeout(360000);
        
        // 连接最大存活时间,比MySQL wait_timeout短,防止僵尸连接
        // 假设MySQL wait_timeout是28800秒(8小时),这里设为10分钟
        config.setMaxLifetime(600000);
        
        // 心跳检测SQL,用于验证连接是否有效,避免使用失效连接
        config.setConnectionTestQuery("SELECT 1");
        
        // 启用泄漏检测,如果连接获取后10秒未归还,打印警告日志
        // 生产环境调试时可以开,稳定后建议关闭以提升性能
        config.setLeakDetectionThreshold(10000);
        
        HikariDataSource dataSource = new HikariDataSource(config);
        return dataSource;
    }
}

这里有个细节要注意:connectionTestQuery 即 SELECT 1。有些老教程会推荐 SELECT 1 FROM DUAL 或者更复杂的查询。其实 SELECT 1 就够了,它最快。但要注意,HikariCP 官方并不推荐使用 connectionTestQuery,因为它每次借出连接时都要执行这个查询,有轻微性能损耗。更推荐的方式是开启 automaticTestTable 或者依赖 TCP KeepAlive 和 maxLifetime 来保证连接有效性。如果非要测,就用最简单的。

二、 读写分离:把压力分摊出去

搞定了连接池,我们发现CPU还是高。仔细一查日志,发现大部分请求都是查询(SELECT),只有少量是写入(INSERT/UPDATE/DELETE)。查询占90%,写入占10%。

这时候,如果主库既要处理写入的事务,又要扛住大量的查询,那简直是暴殄天物。写操作需要加锁、同步,读操作不需要。能不能让它们分开?

当然能,这就是读写分离。

2.1 原理与架构

读写分离的核心思想很简单:

  • 主库(Master):负责所有的写操作(INSERT, UPDATE, DELETE)和强一致性的读操作。
  • 从库(Slave):负责所有的读操作(SELECT)。

数据如何从主库同步到从库?靠的是 MySQL 的主从复制(Replication)。

主库会把所有的数据变更操作记录到 binlog(二进制日志) 中。从库开启一个 IO 线程,持续拉取主库的 binlog,并写成本地的 relay log。然后,从库有一个 SQL 线程,读取 relay log 并重放这些操作,从而保持数据一致。

2.2 实战中的难点:数据延迟

读写分离听起来很美,但有一个致命问题:数据延迟。

主库写完数据,要同步到从库,这个时间差可能是毫秒级,也可能是秒级,甚至在网络抖动或主库压力大时会更长。

场景示例: 用户注册成功后,立刻去查自己的用户信息,结果发现查不到!为什么?因为刚写入主库,从库还没来得及同步过来。这就是所谓的“最终一致性”带来的脏读问题。

2.3 解决方案:强制主库读

我们不能一刀切地说“所有读都去从库”。对于写后立即读的场景,必须强制走主库。

如何在代码层面实现?我们可以结合 MyBatis 或 Spring 的 AOP,做一个简单的路由。

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private MasterSqlSessionTemplate masterSqlSession; // 指向主库
    @Autowired
    private SlaveSqlSessionTemplate slaveSqlSession;  // 指向从库

    /**
     * 注册逻辑:先写主库,再查主库,确保数据一致性
     */
    public Long register(User user) {
        // 1. 写入主库
        masterSqlSession.insert("UserMapper.insert", user);
        
        // 2. 关键:写完后,查询必须走主库,不能走从库
        // 这里用一个标记位,或者在事务上下文中强制指定数据源
        User registeredUser = masterSqlSession.selectOne("UserMapper.selectById", user.getId());
        
        return registeredUser.getId();
    }

    /**
     * 普通查询:走从库,分担主库压力
     */
    public User getUserInfo(Long userId) {
        return slaveSqlSession.selectOne("UserMapper.selectById", userId);
    }
}

多数据源配置的关键:

你需要配置两个 DataSource,一个主库,一个从库。然后通过 AOP 或 ThreadLocal 来切换。

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import javax.sql.DataSource;

@Configuration
public class MybatisConfig {

    @Bean(name = "masterSqlSessionFactory")
    public SqlSessionFactory masterSqlSessionFactory(@Qualifier("masterDataSource") DataSource dataSource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mapper/*.xml"));
        return bean.getObject();
    }

    @Bean(name = "masterSqlSessionTemplate")
    public SqlSessionTemplate masterSqlSessionTemplate(@Qualifier("masterSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

    @Bean(name = "slaveSqlSessionFactory")
    public SqlSessionFactory slaveSqlSessionFactory(@Qualifier("slaveDataSource") DataSource dataSource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mapper/*.xml"));
        return bean.getObject();
    }

    @Bean(name = "slaveSqlSessionTemplate")
    public SqlSessionTemplate slaveSqlSessionTemplate(@Qualifier("slaveSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

注意:实际生产中,建议使用 ShardingSphere 或 Dynamic Datasource 这样的开源框架来管理多数据源,它们能自动处理读写路由、负载均衡等问题,比手写 AOP 更稳健。

三、 缓存并行:让请求不再阻塞数据库

读写分离之后,从库的压力确实小了,但主库的写压力依然存在,而且查询如果都直接打到从库,从库也可能扛不住。这时候,我们需要引入缓存。

缓存的核心思想是:把热点数据放到内存里,下次请求直接从内存取,不再去数据库折腾。

3.1 为什么是“并行”?

这里的“并行”不是指多线程并发跑缓存,而是指 缓存与数据库的并行协同。

传统做法是:查缓存 -> 缓存未命中 -> 查数据库 -> 写缓存。 这种串行模式,一旦缓存失效,数据库就会瞬间承受所有请求的压力,这叫“缓存击穿”。

更好的做法是,利用异步刷新、并发加载等机制,让缓存的维护尽可能不影响主请求的响应速度。

3.2 选型:Redis 还是 Caffeine?

  • 单机缓存:Caffeine。速度极快,因为就在内存里,没有网络开销。适合存那些少量、高频、不需要同步到其他服务的数据。
  • 分布式缓存:Redis。适合跨服务共享数据,或者数据量较大的场景。

我们这里以 Redis 为例,因为高并发场景下,单机缓存往往不够用。

3.3 缓存穿透、击穿、雪崩的应对

这是面试常客,也是实战必坑。

  1. 缓存穿透:查询根本不存在的数据,每次都打到数据库。
    • 解法:缓存空值。如果数据库查不到,也把结果(null)缓存起来,设置一个较短的过期时间(比如30秒)。
  2. 缓存击穿:某个热点 key 过期,瞬间大量请求打到数据库。
    • 解法:热点 key 永不过期;或者使用互斥锁(Mutex Lock),只让一个线程去查数据库并重建缓存,其他线程等待。
  3. 缓存雪崩:大量 key 同时过期,或者 Redis 宕机。
    • 解法:过期时间加随机值,避免集中过期;Redis 集群部署,高可用。

3.4 实战代码:Redis 缓存封装

我们用一个简单的注解+AOP的方式,实现通用的缓存逻辑。

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.concurrent.TimeUnit;

@Component
public class RedisCacheHelper {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public RedisCacheHelper(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    /**
     * 获取缓存,如果不存在则通过 supplier 计算并缓存
     * 这就是“并行”的精髓:避免重复计算,且有一定的容错
     */
    public <T> T getOrCreate(String key, long expireSeconds, Supplier<T> supplier) {
        // 1. 先查缓存
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            try {
                return objectMapper.readValue(json, Class.forName(supplier.getClass().getName() + "$1")); // 简化处理,实际需传递Class
            } catch (Exception e) {
                // 反序列化失败,视为缓存失效,重新获取
            }
        }

        // 2. 缓存未命中,加锁防止缓存击穿(简单版)
        synchronized (this) {
            // 双重检查
            json = redisTemplate.opsForValue().get(key);
            if (json != null) {
                try {
                    return objectMapper.readValue(json, Class.forName(supplier.getClass().getName() + "$1"));
                } catch (Exception e) {}
            }

            // 3. 从数据库/业务逻辑获取
            T data = supplier.get();
            
            // 4. 写入缓存
            if (data != null) {
                try {
                    redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(data), expireSeconds, TimeUnit.SECONDS);
                } catch (Exception e) {
                    // 缓存写入失败,不影响主流程
                }
            }
            return data;
        }
    }
}

上面的代码有个简化,实际项目中推荐使用 Spring Cache 注解,更优雅:

import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    /**
     * 查询订单
     * key: "order:" + id
     * TTL: 10分钟
     * 如果缓存存在,直接返回;否则执行方法,并将结果放入缓存
     */
    @Cacheable(value = "orders", key = "#id", unless = "#result == null")
    public Order getOrder(Long id) {
        return orderMapper.selectById(id);
    }

    /**
     * 更新订单
     * 更新后,清除对应缓存,保证下次查询是最新的
     */
    @CacheEvict(value = "orders", key = "#id")
    public void updateOrder(Order order) {
        orderMapper.updateById(order);
    }
}

这里有个关键点:unless = "#result == null"。这意味着如果数据库查不到数据(返回null),我们不缓存这个null。为什么?因为如果缓存了null,下次有人查这个不存在的ID,又会走一次数据库。我们可以选择在代码层面单独处理,或者使用前面提到的“缓存空值”策略,但要用注解的话,可能需要自定义注解或AOP。

四、 三者结合:构建高并发铁三角

连接池、读写分离、缓存,这三者不是孤立的,它们需要协同工作。

4.1 整体架构流程

  1. 请求进入:先经过网关,做限流、鉴权。
  2. 缓存层:优先查 Redis。
    • 命中:直接返回,结束。
    • 未命中:进入下一步。
  3. 路由层:根据操作类型(读/写)决定走主库还是从库。
    • 写操作:主库。
    • 读操作:从库(如果是写后读,强制主库)。
  4. 连接池:从 HikariCP 中获取连接,执行SQL。
  5. 结果返回:如果是读操作,将结果写入 Redis,然后返回给前端。

4.2 监控与调优

上线后,一定要盯着监控看。

  • 连接池监控:观察活跃连接数、等待获取连接的线程数。如果等待线程数长期