说真的,之前我们公司的那套系统,只要晚高峰一到,后台报警就响个不停。那时候我盯着监控大屏,看着数据库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 缓存穿透、击穿、雪崩的应对
这是面试常客,也是实战必坑。
- 缓存穿透:查询根本不存在的数据,每次都打到数据库。
- 解法:缓存空值。如果数据库查不到,也把结果(null)缓存起来,设置一个较短的过期时间(比如30秒)。
- 缓存击穿:某个热点 key 过期,瞬间大量请求打到数据库。
- 解法:热点 key 永不过期;或者使用互斥锁(Mutex Lock),只让一个线程去查数据库并重建缓存,其他线程等待。
- 缓存雪崩:大量 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 整体架构流程
- 请求进入:先经过网关,做限流、鉴权。
- 缓存层:优先查 Redis。
- 命中:直接返回,结束。
- 未命中:进入下一步。
- 路由层:根据操作类型(读/写)决定走主库还是从库。
- 写操作:主库。
- 读操作:从库(如果是写后读,强制主库)。
- 连接池:从 HikariCP 中获取连接,执行SQL。
- 结果返回:如果是读操作,将结果写入 Redis,然后返回给前端。
4.2 监控与调优
上线后,一定要盯着监控看。
- 连接池监控:观察活跃连接数、等待获取连接的线程数。如果等待线程数长期
