咱们先别急着去改配置,先想象一个场景:周五晚上八点,某电商平台的“秒杀”活动开始了。一瞬间,成千上万的请求像洪水一样涌向服务器。你的应用层可能扛得住,但背后的数据库MySQL呢?它就像是一个只会单线程处理订单的老会计,面对堆积如山的单据,不仅手速跟不上,还容易因为压力过大直接“晕倒”(宕机)。
这就是很多开发者在初期容易踩的坑:以为把数据存进MySQL就万事大吉,却忘了MySQL也是有脾气和极限的。 今天,我不跟你扯那些枯燥的理论定义,咱们直接钻进代码和架构里,看看怎么一步步把这个“老会计”训练成能处理百万并发的“超级数据中心”。
第一步:别让连接池成为新的瓶颈
很多新手一遇到数据库慢,第一反应是:“是不是SQL写得不好?”或者“是不是索引没加?”但在这些之前,有一个更隐蔽、更致命的杀手——连接耗尽。
每次应用发起一个数据库请求,都需要建立TCP连接,握手、认证、分配资源,最后还要断开。这个过程在低并发时感觉不到什么,但在高并发下,频繁地创建和销毁连接,CPU开销巨大,而且极易导致 Too many connections 错误。
1.1 为什么默认的连接池不够用?
Java生态里最常用的连接池是 HikariCP(Spring Boot 2.x+ 默认)或 Druid。如果你什么都不配置,它们通常使用默认的阈值。比如,HikariCP 默认最大连接数可能是 10 个(取决于驱动版本和配置),对于单核小机器来说,这确实够了。但对于生产环境,尤其是高并发场景,10个连接就像是一条单车道的高速公路,瞬间就会堵死。
1.2 实战:调优 HikariCP
我们要做的不是盲目加大数字,而是要根据服务器的硬件资源和业务特征来科学计算。
假设你的应用部署在一台 4核 8G 的服务器上,MySQL也在同一网段内。
// application.yml 中的 HikariCP 配置示例
spring:
datasource:
hikari:
# 连接池名称,方便监控排查
pool-name: HighConcurrencyPool
# 核心参数:最小空闲连接数。建议设置为并发峰值的 20%-30%。
# 如果设得太低,突发流量来时还要重新创建连接,延迟飙升;
# 设得太高,平时占用内存和文件描述符资源。
minimum-idle: 10
# 核心参数:最大连接数。这是关键!
# 计算公式参考:(CPU核心数 * 2) + 有效磁盘数
# 这里我们激进一点,设为 50,前提是MySQL max_connections 支持
maximum-pool-size: 50
# 连接超时时间。如果5秒内拿不到连接,直接报错,防止线程一直等待耗尽系统资源。
connection-timeout: 3000
# 空闲连接存活时间。超过这个时间的空闲连接会被回收。
idle-timeout: 600000
# 连接最大生命周期。防止数据库端强制踢掉长期持有的连接导致应用报错。
max-lifetime: 1800000
专家视角的补充:
很多人喜欢把 maximum-pool-size 设得非常大,比如 200 甚至 500。这是错误的思维!连接池的大小永远不应该超过数据库本身的承受能力。 你需要检查 MySQL 的 max_connections 设置。如果应用侧有 200 个连接,MySQL 侧只开了 150,那多出来的 50 个连接会在握手阶段就失败,或者导致 MySQL 进程崩溃。
此外,务必开启连接测试。虽然 HikariCP 性能极佳,默认使用 SELECT 1 进行健康检查,但在某些特殊网络环境下,你可以自定义:
// Java 代码中动态配置(如果不想改yml)
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50);
config.setConnectionTestQuery("SELECT 1"); // 确保数据库支持此查询
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
注意最后三行,这是开启 PreparedStatement Cache。在高并发下,解析 SQL 语句是非常消耗 CPU 的。缓存预编译语句可以将这一开销降低几个数量级。
第二步:SQL 与索引的极致优化
连接池搞定了“通道”问题,接下来要看“货物”(数据)怎么运。很多时候,瓶颈不在于连接数,而在于一条烂 SQL 锁住了整个表。
2.1 慢查询日志是你的朋友
不要猜哪里慢,让数据说话。开启慢查询日志:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录为慢查询
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
2.2 覆盖索引与最左前缀原则
假设你有一张订单表 orders,字段包括 user_id, status, create_time, amount。
常见的查询:
SELECT * FROM orders WHERE user_id = 1001 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;
如果你没有索引,MySQL 会全表扫描,这在百万级数据下是灾难。
即使你加了 (user_id) 索引,MySQL 找到 user_id=1001 的记录后,还需要回表去查其他字段(除非是覆盖索引)。
最佳实践:复合索引
创建联合索引 (user_id, status, create_time)。
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, create_time);
这样,MySQL 可以直接在索引树中找到满足条件的记录,并且 create_time 已经排好序了,无需额外的 filesort 操作。这就是所谓的 “索引下推” 和 “避免回表” 带来的性能飞跃。
2.3 避免 SELECT *
这是一个老生常谈但极其重要的点。在高并发下,网络传输带宽和内存拷贝都是成本。
-- 差
SELECT * FROM orders WHERE ...
-- 好
SELECT id, user_id, amount, status FROM orders WHERE ...
只查需要的字段,不仅能减少网络 IO,还能更容易实现覆盖索引,让查询直接在索引节点完成,彻底免去回表操作。
第三步:引入缓存,给数据库“减负”
就算 SQL 再优化,读多写少的场景下,数据库依然会累。这时候,我们需要请出中间件——Redis。
3.1 缓存穿透、击穿与雪崩
很多人会用 Redis,但用得不对。比如,用户查询一个不存在的数据(穿透),每次都打到数据库;热点 Key 过期瞬间(击穿),所有请求同时打穿缓存直达 DB;或者大量 Key 同时过期(雪崩)。
3.2 实战:使用 Caffeine + Redis 二级缓存
为了极致性能,我们可以采用本地缓存(Caffeine)+ 分布式缓存(Redis)的双层架构。
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 本地缓存:TTL 1分钟,最大容量10000,基于LRU淘汰
private final Cache<String, OrderDTO> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
public OrderDTO getOrderById(String orderId) {
// 1. 先查本地缓存
OrderDTO order = localCache.getIfPresent(orderId);
if (order != null) {
return order;
}
// 2. 查分布式缓存
Object cachedObj = redisTemplate.opsForValue().get("order:" + orderId);
if (cachedObj != null) {
order = convertToDTO(cachedObj);
localCache.put(orderId, order); // 回填本地缓存
return order;
}
// 3. 查数据库
order = orderMapper.selectById(orderId);
if (order != null) {
// 写入分布式缓存,设置较短的TTL,防止脏数据长期存在
redisTemplate.opsForValue().set("order:" + orderId, order, 30, TimeUnit.SECONDS);
localCache.put(orderId, order);
} else {
// 缓存空对象,防止穿透
redisTemplate.opsForValue().set("order:" + orderId, "", 30, TimeUnit.SECONDS);
}
return order;
}
}
关键点解析:
- 空值缓存:即使数据库没有这条数据,也缓存一个空字符串或特定标识,防止恶意攻击或爬虫反复查询不存在的数据。
- 短 TTL:对于热点数据,Redis 里的过期时间设短一点(如 30 秒),保证数据一致性。如果需要强一致,可以考虑 Canal 监听 MySQL binlog 异步更新缓存,或者使用 Redisson 的分布式锁。
- 本地缓存优势:Caffeine 的速度比 Redis 快几个数量级,因为它就在 JVM 堆内,没有网络 IO。
第四步:读写分离,架构升级的必经之路
当单机 MySQL 的 CPU 使用率持续超过 70%,且 IOPS 达到瓶颈时,你就必须考虑读写分离了。
原理很简单:主库(Master)负责写,从库(Slave)负责读。通过 MySQL 的主从复制机制,将数据同步到从库。
4.1 架构示意图
[Client App]
/ \
/ \
[Load Balancer] <-- 可以是 MyCat, ShardingSphere, 或简单的 Nginx + 路由逻辑
/ | \
/ | \
[Master] [Slave1] [Slave2]
(Write) (Read) (Read)
4.2 Spring Boot + ShardingSphere 实现读写分离
现在很少人手动写路由逻辑了,推荐使用 Apache ShardingSphere。它提供了透明的读写分离能力。
# application-sharding.yml
spring:
shardingsphere:
datasource:
names: master,slave1,slave2
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://master-db:3306/mydb?useSSL=false
username: root
password: secret
slave1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave1-db:3306/mydb?useSSL=false
username: root
password: secret
slave2:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave2-db:3306/mydb?useSSL=false
username: root
password: secret
rules:
readwrite-splitting:
data-sources:
ds:
load-balancer-type: round_robin # 负载均衡策略:轮询
props:
write-data-source-name: master
read-data-source-names: slave1,slave2
static-strategy: # 静态配置,生产环境建议用动态发现如Zookeeper
write-data-source-name: master
read-data-source-names: slave1,slave2
props:
sql-show: true # 开发环境开启SQL显示,方便调试
注意延迟问题: 读写分离最大的痛点是主从同步延迟。用户刚写完数据,马上读,结果从库还没同步过来,读到的是旧数据。
解决方案:
- 强制读主库:对于涉及资金、库存等强一致性的操作,在 Service 层加上注解,强制路由到 Master 库。
@DS("master") // 自定义注解,切换数据源 public void updateOrderStatus(Long orderId) { orderMapper.updateStatus(orderId, "SHIPPED"); } - 缩短同步间隔:调整 MySQL 主从配置
sync_binlog=1和innodb_flush_log_at_trx_commit=1,虽然牺牲了一点写入性能,但能保证数据强一致且同步更快。
第五步:分库分表,应对海量数据
如果读写分离还不够,数据量达到了千万级甚至亿级,单表查询变慢,索引维护成本极高,那就必须分库分表了。
5.1 垂直拆分 vs 水平拆分
- 垂直拆分:按业务模块拆。比如订单库、用户库、商品库分开。这能解决业务耦合和单表字段过多的问题。
- 水平拆分:按数据行拆分。比如将
orders表拆成orders_0,orders_1… 直到orders_n。
5.2 分片键的选择
选择分片键(Sharding Key)至关重要。通常选择 user_id 或 order_id。
- 如果按
user_id分:同一个用户的所有订单都在同一个分片,查询用户历史订单很快。 - 缺点:跨用户查询(如统计所有订单总额)会变慢,需要全库扫描或借助 Elasticsearch。
5.3 ShardingSphere-JDBC 实战
spring:
shardingsphere:
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds${0..1}.t_order_${0..1} # 2个库,每个库2张表
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-inline
sharding-algorithms:
user-inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 2} # 简单取模
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds${user_id % 2} # 2个数据源
给小朋友也能听懂的比喻: 想象一个大图书馆(数据库)。
- 不分库分表:所有书都堆在一个巨大的房间里,找一本书得像大海捞针。
- 读写分离:借书的人多,买书的人少。于是弄两个房间,一个专门卖书(写),一个专门看书(读),大家排队借书去卖书房间,看书的去另一个房间。
- 分库分表:房间还是太多,找书太慢。于是把书按“作者姓氏”分类。姓“李”的书都放在 A 楼,姓“王”的都放在 B 楼。你要找“李白”的诗,直接去 A 楼,不用跑遍全校。
结语:没有银弹,只有权衡
解决 MySQL 高并发瓶颈,不是一蹴而就的。
- 连接池优化是基础,确保通道畅通。
- SQL 与索引是根本,确保每笔交易高效。
- 多级缓存是缓冲,挡住大部分读请求。
- 读写分离是扩展,利用多核多机资源。
- 分库分表是终极手段,应对海量数据。
在实际工程中,你需要根据QPS(每秒查询率)、RT(响应时间)、CPU/内存利用率以及数据增长速度来决定走到哪一步。不要一开始就上分库分表,那是架构过度设计,会增加运维复杂度。先从连接池和慢 SQL 抓起,往往能解决 80% 的性能问题。
记住,架构师的价值不在于使用了多么炫酷的技术,而在于在成本、复杂度、性能和一致性之间找到那个完美的平衡点。希望这篇指南能帮你理清思路,让你的数据库在高并发浪潮中稳如泰山。
