电商大促订单瞬间涌入MySQL连接池被打满如何用读写分离Redis缓存与分库分表扛住高并发
想象一下:零点刚过,直播间一声“上链接”,三百万只手同时按下支付。你的后端系统像被扔进滚筒洗衣机的乐高积木——请求雪片般砸过来,数据库连接池的活跃数“嗖”地顶到上限,新订单只能排队等连接,超时、回滚、前端转圈,客服群开始报警。这不是段子,每年618、双11都在真实上演。
很多人第一反应是“把连接池调大”。但连接池不是无底洞。MySQL单实例能舒服撑住的并发连接通常在几十到几百之间,硬拉到几千只会换来更严重的上下文切换、磁盘I/O排队和锁竞争。真正能扛住大促的架构,从来不是靠单一神技,而是像洋葱一样一层层卸力:连接池合理打底 → 读写分离分流 → Redis缓存挡在前面 → 分库分表把数据摊平。咱们按实战顺序,把这组合拳拆透。
连接池不是越大越好,它更像餐厅的服务员
数据库连接池就像一家餐厅的服务员。客人(请求)再多,服务员太少会排队;服务员太多,后厨转不动,大家互相撞来撞去,反而更慢。
HikariCP 是目前 Spring Boot 默认的连接池,它的参数设置非常有讲究。很多团队把 maximumPoolSize 直接拉到 200、500,结果压测一跑,数据库 CPU 直接飙到 90%,TP99 延迟爆炸。MySQL 官方和社区都反复验证过:连接数过多时,线程调度开销会吃掉你省下来的性能。
# application-datasource.yml
spring:
datasource:
hikari:
minimum-idle: 5
maximum-pool-size: 32 # 生产环境建议 16~64,视MySQL实例规格而定
idle-timeout: 300000
max-lifetime: 1800000
connection-timeout: 30000
validation-timeout: 5000
leak-detection-threshold: 60000 # 超过60秒未归还连接,打印告警
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://db-master:3306/order_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai
几个容易被忽略的坑:
- 慢 SQL 是连接池杀手。一个没走索引的
SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC,在大促高峰会直接占住连接不放。 - 长事务同样致命。
@Transactional包住了整个 HTTP 请求,用户页面卡住,连接就不释放。 - 连接池泄露。代码里拿到连接没
close(),或者异常分支漏了finally。
开启慢查询日志 + 接入 Prometheus + Grafana 监控 HikariPool.ActiveConnections、WaitingThreads、ConnectionCreationFailureRate。一旦 WaitingThreads > 10 持续超过 5 秒,就该触发告警,而不是等用户投诉。
读写分离:让“查”和“写”各走各的快车道
大促期间,订单系统的流量结构通常是 70% 查询 + 30% 写入。如果所有请求都打到主库,主库既要处理插入、更新,又要扛住商品详情、订单状态、物流信息的查询,连接池自然先崩。
读写分离的核心思路很简单:主库负责写,从库负责读,中间件根据 SQL 类型自动路由。
// 动态数据源路由核心逻辑(简化版)
public class OrderDataSourceRouter extends AbstractRoutingDataSource {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setReadRoute() { CONTEXT.set("slave"); }
public static void setWriteRoute() { CONTEXT.set("master"); }
public static void clear() { CONTEXT.remove(); }
@Override
protected Object determineCurrentLookupKey() {
return CONTEXT.get() == null ? "master" : CONTEXT.get();
}
}
配合 AOP 或 MyBatis 拦截器,在 Service 入口统一切面:
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(readOnly)")
public void before(JoinPoint point, ReadOnly readOnly) {
OrderDataSourceRouter.setReadRoute();
}
@AfterReturning("@annotation(readOnly)")
public void after(ReadOnly readOnly) {
OrderDataSourceRouter.clear();
}
}
但读写分离不是无脑开。 最经典的问题是 主从延迟。主库刚写完订单,用户刷新页面去读,可能还在从库读到“未支付”。解决方案:
- 写后立即强制读主库:
@MasterOnly注解或关键业务路由到 master。 - 缩短主从同步方式:从异步 binlog 切换到半同步复制(semi-sync),牺牲一点写入性能换强一致。
- 对非实时状态允许短暂延迟,比如物流信息、历史订单列表。
Redis缓存:把高频请求拦在数据库门外
如果说数据库是仓库,Redis 就是门口的小卖部。大促最肥的肉,往往是那 20% 的热点商品、爆款 SKU、头部店铺首页。把这些数据缓存到 Redis,能直接砍掉 80% 以上的数据库查询压力。
库存扣减的实战写法
很多团队一上来就 redis.get("stock") 判断,再 redis.decr(),最后写 MySQL。这在大促下秒变超卖现场。正确做法是用 Lua 脚本保证原子性:
-- reduce_stock.lua
local key = KEYS[1]
local reduceNum = tonumber(ARGV[1])
local currentStock = tonumber(redis.call('get', key) or '0')
if currentStock < reduceNum then
return 0 -- 库存不足
end
redis.call('decrby', key, reduceNum)
return 1 -- 扣减成功
Java 侧调用:
String luaScript = """
local key = KEYS[1]
local reduceNum = tonumber(ARGV[1])
local currentStock = tonumber(redis.call('get', key) or '0')
if currentStock < reduceNum then return 0 end
redis.call('decrby', key, reduceNum)
return 1
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("stock:" + skuId),
String.valueOf(quantity)
);
if (result == 1) {
// 扣减成功,发MQ异步落库
orderProducer.sendOrderAsync(orderDTO);
} else {
throw new BusinessException("库存不足");
}
Redis 只是预扣库存,真实库存最终要同步到 MySQL。这里用 最终一致性:Redis 扣减成功后发 RocketMQ/Kafka,消费者慢慢写库。大促期间,队列能扛住峰值,MySQL 只需要按自己的节奏消化。
缓存三大“天坑”怎么填
| 问题 | 表现 | 解法 |
|---|---|---|
| 缓存穿透 | 查不存在的数据,每次都打到DB | 布隆过滤器拦截 + 空值缓存(短TTL) |
| 缓存击穿 | 热点key过期瞬间,大量请求涌向DB | 互斥锁 SETNX 重建 + 逻辑过期 |
| 缓存雪崩 | 大批key同时过期 | TTL加随机值 + 多级缓存 + Sentinel限流 |
// 缓存击穿:逻辑过期方案(不推荐死锁式SETNX)
public ProductDTO getProduct(String skuId) {
String cacheKey = "product:" + skuId;
String value = redisTemplate.opsForValue().get(cacheKey);
if (value != null && !isExpired(value)) {
return JSON.parseObject(value, ProductDTO.class);
}
// 逻辑过期:只重建一次,其他请求返回旧数据
if (redisTemplate.opsForValue().setIfAbsent("lock:" + skuId, "1", 5, TimeUnit.SECONDS)) {
try {
ProductDTO dbData = productMapper.selectById(skuId);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dbData), 30, TimeUnit.MINUTES);
return dbData;
} finally {
redisTemplate.delete("lock:" + skuId);
}
}
return JSON.parseObject(value, ProductDTO.class); // 返回旧数据兜底
}
分库分表:当单库的天花板挡不住增长时
缓存和读写分离能解决 流量 问题,但解决不了 数据量 问题。订单表一年涨几千万行,单张表超过 500 万~1000 万,索引膨胀、分页变慢、备份耗时,都会成为隐形炸弹。这时候就该分库分表登场了。
分库分表分两种:
- 垂直拆分:把订单主表、订单明细表、订单扩展属性表拆到不同库,按业务边界切。
- 水平拆分:同一张表按规则拆成 16 张
order_00~order_15,数据量摊平。
选对 分片键 是成败关键。订单系统通常用 user_id 或雪花算法生成的 order_id。如果按 user_id 分,查“某用户的历史订单”很顺;如果按 order_id 分,全局唯一查询更快。大促场景下,建议 订单主表按 order_id 分片,订单明细表跟主表同库同分片。
业界成熟方案首选 Apache ShardingSphere。下面是一份可直接落地的 YAML 配置:
# sharding-database.yaml
dataSources:
ds_0:
url: jdbc:mysql://db-master-0:3306/order_db_0?useSSL=false
username: root
password: ${DB_PASS}
ds_1:
url: jdbc:mysql://db-master-1:3306/order_db_1?useSSL=false
username: root
password: ${DB_PASS}
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_$->{0..1}.t_order_$->{0..7}
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: order_id_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
order_id_inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 8}
keyGenerators:
snowflake:
type: SNOWFLAKE
分库分表不是银弹,它会带来一堆代价:
- 跨分片查询变慢,比如“统计今天全平台GMV”,需要 MapReduce 或走 ES/ClickHouse。
- 分页困难,
LIMIT 10000, 20在分片环境下几乎不可用,必须游标分页。 - 扩容要迁移数据,ShardingSphere-Scaling 或 DTS 可以辅助,但大促前绝对不能临时扩。
- 分布式主键必须提前规划,雪花算法、号段模式、UUID 各有取舍,别等到上线才发现重复。
一笔订单的“闯关路线”:从网关到落库
把前面几层串起来,一笔大促订单的真实路径大概是这样的:
- 网关层:Nginx/Spring Cloud Gateway 做 IP 限流、用户维度限流、验证码校验。恶意刷单直接挡在外面。
- 缓存层:商品详情、库存预扣全部走 Redis。热点 SKU 库存用 Lua 原子扣减,失败直接返回“已售罄”。
- 削峰层:库存扣减成功后,不直接写 MySQL,而是发 MQ。订单服务消费消息,按自己能力写入分片库。
- 存储层:主库写订单、订单明细、支付流水。通过分片算法落到对应库表。
- 查询层:订单状态查询走读写分离的从库,或优先读 Redis 缓存(带版本号防脏读)。
- 兜底层:如果数据库响应时间超过阈值,网关直接返回排队页;如果 Redis 挂了一部分,走本地缓存 + 降级查询;如果 MQ 积压,临时扩容消费者。
幂等性必须写死在流程里。 用户手抖点两次、网络重试、MQ 重复消费,都会导致重复下单。标准做法:
- 请求头带
request_id,Redis SETNX 防重。 - 订单号全局唯一,MySQL 唯一索引兜底。
- 支付回调用订单号 + 金额双重校验。
别只盯QPS,这些线上细节才是救命稻草
很多团队压测报告写得漂亮:QPS 12 万,TP99 80ms。上线第一天照样崩。原因很简单,压测环境太干净了。
- 慢 SQL 治理要提前 1 个月。大促前跑一遍
EXPLAIN,把全表扫描、文件排序、临时表全清掉。 - 连接池监控要接告警。HikariCP 的
ActiveConnections、ThreadsWaitingForConnection配合钉钉/飞书机器人,别等用户反馈才看。 - 全链路压测必须走真实流量比例。用影子库影子表隔离大促流量,避免污染线上数据。
- 混沌工程不能省。提前模拟 MySQL 主库宕机、Redis 节点故障、MQ 积压,看系统能不能自动切换。
- 架构是取舍的艺术。没有“完美高并发”,只有“在当前成本下能扛住预期峰值”。过度设计分库分表,不如先把缓存和异步化做扎实。
大促就像一场马拉松,不是谁冲刺最快谁赢,而是谁能把每一段路的体力分配好。连接池控制呼吸,读写分离分配车道,Redis 缓存提前补给,分库分表摊平赛道。把这些环节一环扣一环,哪怕订单瞬间涌入,系统也能稳稳接住。
