双11秒杀订单瞬间涌入数据库崩溃怎么办用MySQL读写分离加Redis缓存和分库分表轻松扛住高并发实战方案
凌晨0点0分0秒,按钮按下去的瞬间,前端页面还停留在“加载中”,后台的MySQL主库已经在报警群里疯狂@DBA了。连接数从200直接飙到3000,事务队列排成长龙,慢SQL日志像瀑布一样刷,最后干脆抛出Too many connections。这不是电影特效,这是每年大促真实上演的场面。
很多团队第一次遇到这种问题,本能反应是“升配”。把RDS从8核32G换成64核256G,把SSD换成更大带宽的。短期确实能多扛一会儿,但流量是指数级的,硬件是线性增长的,算不过来账。真正在线上活下来的架构,从来不是靠硬刚,而是给流量“修渠引流”。
咱们今天就把这套经过生产环境验证的方案拆开来看:Redis缓存做第一道拦截,MySQL读写分离化解IO压力,分库分表突破存储与写入瓶颈。中间会穿插真实代码、配置片段,以及我见过不少团队踩进去的坑。
先把“假请求”挡在数据库门外
秒杀场景有一个很反直觉的事实:100个人同时点抢购,最后能成交的可能只有10个。如果这100个请求全部打到数据库查库存、扣库存、插订单,数据库不是在扛流量,而是在替无效请求买单。
所以第一反应不应该是“怎么让数据库更快”,而是“怎么让数据库少干活”。
Redis在这里的角色,就像学校小卖部柜台上的“抢号本”。老师提前把100张号码纸放在本子上,学生来了先撕纸,撕到纸的人才有资格去排队交钱。教室里(数据库)门口根本挤不进那么多没抢到的人。
具体到代码层面,库存扣减必须保证原子性。不能先GET库存,再判断大于0,再DECR,这三步在并发下一定会超卖。最稳的做法是用Redis执行Lua脚本,把判断和修改打包成一次原子操作:
@Component
public class SeckillStockService {
private final StringRedisTemplate redisTemplate;
public SeckillStockService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 预扣库存,返回状态码
* 1: 抢购成功,可进入下单流程
* 0: 库存不足
* -1: 商品不存在或未预热
*/
public Long deductStock(Long itemId) {
String key = "seckill:stock:" + itemId;
String luaScript =
"local stock = redis.call('get', KEYS[1]) " +
"if stock == false then return -1 end " +
"if tonumber(stock) <= 0 then return 0 end " +
"redis.call('decr', KEYS[1]) " +
"return 1";
return redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key)
);
}
/**
* 活动开始前调用,把库存打入Redis
*/
public void warmUpStock(Long itemId, int stock) {
String key = "seckill:stock:" + itemId;
if (!redisTemplate.hasKey(key)) {
redisTemplate.opsForValue().set(key, String.valueOf(stock));
}
}
}
这段代码看着简单,但有几个细节决定了它能不能扛住真流量:
- 预热时机:库存不能等用户来了再查数据库塞进去,必须提前异步加载。通常在大促前10分钟,由定时任务从MySQL批量灌入Redis,顺便校验商品是否上架、活动是否开始。
- Redis集群模式:单机Redis虽然快,但单节点就是单点故障。生产环境建议用Cluster,库存key按hash slot分散到多个节点,抗住更高QPS。
- 防重放:同一个用户同一秒狂点10次,Redis会扣10次库存。需要在Lua里加一层“用户抢购标记”:
local userKey = 'seckill:user:' .. KEYS[2] .. ':' .. KEYS[1]
if redis.call('exists', userKey) == 1 then
return -2 -- 已抢过
end
-- 后续扣库存逻辑...
redis.call('set', userKey, '1', 'EX', 10) -- 10秒内防重复
库存扣成功后,请求并没有结束,但它已经脱离了“同步阻塞”的危险区。接下来,订单落库的压力需要用读写分离和异步化来消化。
MySQL读写分离:让写字的和看字的各干各的
很多人以为读写分离就是“主库写、从库读”,其实没那么简单。它的核心价值在于把数据库的两种负载模型拆开:
- 写请求:短事务、强一致、改数据,吃的是磁盘IO和锁资源。
- 读请求:长事务、弱一致、查数据,吃的是CPU和内存带宽。
大促期间,用户疯狂刷新订单详情页、物流信息、活动规则,这些全是读。如果全部压在主库上,写事务还没跑完就被读请求挤掉,锁等待时间直线上升,整个库直接卡死。
把读流量切到从库,主库专心写订单,吞吐量能翻好几倍。落地方案有很多,我推荐用ShardingSphere-JDBC,轻量、透明、对业务代码侵入小。配置文件大概长这样:
spring:
shardingsphere:
datasource:
names: seckill-master,seckill-slave
seckill-master:
url: jdbc:mysql://10.0.1.10:3306/seckill_db?useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USER}
password: ${DB_PASS}
seckill-slave:
url: jdbc:mysql://10.0.1.11:3306/seckill_db?useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USER}
password: ${DB_PASS}
rules:
readwrite-splitting:
data-sources:
seckill_ds:
write-data-source-name: seckill-master
read-data-source-names: [seckill-slave]
load-balancer-name: round_robin
props:
sql-show: true
业务层只需要在数据源上使用@DataSource("seckill_ds"),路由就会自动生效。不过这里有几个坑,不提前说清楚,上线必翻车:
主从延迟是读写分离最大的隐患
MySQL主从复制默认是异步的。主库刚写完订单,从库可能还没同步过来。用户刚下单成功,转头查“我的订单”,发现列表里空空如也。这种体验在大促期间会被无限放大。
解决办法分两层:
- 核心链路强制读主库:下单成功后立即查订单状态,用
@DataSource("seckill-master")绕开从库。 - 非核心链路容忍延迟:物流查询、历史订单浏览、推荐页展示,全部走从库。延迟几秒对用户无感。
可以用AOP统一切面控制路由策略,避免每个Service手写数据源注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}
@Aspect
@Component
public class DataSourceRoutingAspect {
@Around("@annotation(masterRead)")
public Object routeToMaster(ProceedingJoinPoint pjp, MasterRead masterRead) throws Throwable {
DataSourceContextHolder.setDataSource("seckill-master");
try {
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
从库挂了怎么办?
读写分离不是把从库当摆设。生产环境至少准备两台从库,配合负载均衡做读路由。其中一台做延迟监控,一旦主从延迟超过2秒,自动摘除该从库,流量只打到延迟正常的节点。Prometheus抓取Seconds_Behind_Master指标,Grafana设阈值告警,DBA手机钉钉群实时收到通知。
分库分表:单库单表终究有天花板
读写分离解决了读多写少的瓶颈,但订单量一旦突破单库的写入上限,或者单表数据量超过2000万行,索引效率会明显下降,全表扫描、分页查询都会变慢。这时候就该分库分表登场了。
分库分表的本质就一句话:把一个大仓库拆成多个小仓库,每个请求只去对应的小仓库找东西。
我常用的是ShardingSphere 5.x,配置直观,支持水平拆表。假设我们按订单ID哈希分8张表:
rules:
sharding:
tables:
t_order:
actual-data-nodes: seckill_ds$->{0..3}.t_order$->{0..7}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order-table-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
order-table-inline:
type: INLINE
props:
algorithm-expression: t_order$->{order_id % 8}
key-generators:
snowflake:
type: SNOWFLAKE
binding-tables:
- t_order,t_order_item
分库分表不是越拆越好,选错分片键会比不分更麻烦。举个真实案例:某团队按user_id分库,结果某天运营要做“某爆款商品的销量排行榜”,SQL变成SELECT count(*) FROM t_order WHERE item_id = ?。因为item_id不在分片键上,ShardingSphere只能广播查询所有分片,再应用层合并。原本1次查询,变成了16次,还跨了4个库,性能反而不如单库。
所以分片键的选择必须回答一个问题:你们最高频的查询条件是什么?
- 如果是“查用户订单”,分片键用
user_id。 - 如果是“查商品销量”,分片键用
item_id。 - 如果是“查订单详情”,分片键用
order_id。
秒杀场景下,用户查自己的订单、商家查自己的发货单、财务查对账单,order_id和user_id是最稳的分片键组合。商品维度的统计,建议走ES或ClickHouse,别硬塞关系型数据库。
把三件套串起来:一条订单的真实生命周期
光看单个组件不够直观,咱们把一次完整的大促下单请求走一遍,看看流量是怎么被层层消化的:
用户点击【立即抢购】
↓
API网关层:Sentinel限流 + IP黑名单 + 签名验签
↓ (被挡掉的请求直接返回429/403)
秒杀服务:校验活动状态、用户资格
↓
Redis:Lua脚本预扣库存 + 防重复标记
↓ (扣失败 → 直接返回“已售罄/请勿重复点击”)
↓ (扣成功)
RocketMQ:发送【创建订单】消息,立即返回“排队中,请稍后查看结果”
↓
订单消费者:从MQ拉取消息,幂等校验(order_no去重)
↓
MySQL主库:写入 t_order(走读写分离,强制写主库)
↓
MySQL从库:主从同步,前端轮询/WebSocket查询订单状态时走从库
↓
Redis:更新订单状态缓存,设置30秒TTL供前端快速读取
这个链路里,数据库看到的不再是每秒几万条同步请求,而是几百条被MQ平滑拉平的订单写入。就像城市高架桥的ETC匝道,平时一条车道,高峰期临时开放八条,车流被均匀导入,主路不会堵死。
前端也不用傻等。用户点击后0.5秒内就收到响应,之后通过WebSocket或轮询查状态。Redis里缓存的订单状态会先返回,查不到再去MySQL主库补。用户体验丝滑,后端压力可控。
那些线上才踩得到的坑,提前告诉你
方案写得再漂亮,真到大促那天,往往是被细节拖垮的。下面这几条,都是实打实交过学费换来的经验:
1. 缓存和数据库的一致性,别追求强一致
大促期间不可能每笔订单都先写DB再删缓存。正确姿势是:Redis为准,DB兜底。库存扣在Redis,订单异步落库。如果DB写入失败,MQ重试3次,仍失败则进入人工对账队列。不要为了“绝对一致”搞分布式事务,Seata在秒杀场景下只会把系统拖死。
2. 幂等性是底线
网络抖动、用户狂点、MQ重复消费,都会导致同一条订单被创建两次。订单号必须全局唯一(雪花算法+业务前缀),落库前查一次order_no是否存在。消费者里加一句简单的幂等判断:
INSERT INTO t_order (order_id, user_id, item_id, amount)
SELECT #{orderId}, #{userId}, #{itemId}, #{amount}
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM t_order WHERE order_id = #{orderId}
);
3. 慢SQL是分库分表的隐形杀手
分库分表之后,EXPLAIN出来的执行计划会变样。有些SQL在单表上跑得快,拆表后因为路由策略不对,直接扫全部分片。上线前务必用全量数据压测,重点盯这几个指标:
- 单条SQL执行时间 < 50ms
- 慢SQL比例 < 0.1%
- 分片广播查询数量 = 0
4. 监控不是上线后补的
Prometheus + Grafana + AlertManager 这套组合拳,在大促前一周就必须跑通。面板上至少要有:
- 各接口QPS、P99延迟、错误率
- Redis命中率、内存使用率、集群节点状态
- MySQL连接数、活跃事务数、主从延迟、慢SQL Top10
- MQ堆积量、消费者处理速度
阈值别拍脑袋。先平时跑一周基线,取平均值+3倍标准差作为告警线。双11当天,任何指标偏离基线20%就要人工介入。
如果Redis集群突然挂了怎么办?
再严密的架构也有降级预案。高并发系统的设计哲学不是“永远不挂”,而是“挂了还能活”。
Redis宕机时,秒杀服务自动切换到降级模式:
- 关闭Redis库存预扣,改为本地Guava Cache + 数据库乐观锁兜底。
- 网关层把QPS限制到平时的10%,只放行真实用户请求。
- 前端提示“当前抢购人数过多,请稍后再试”,而不是直接报错。
降级不是失败,是保命。宁可少卖10%的货,也不能让整个交易系统瘫痪。
写在最后
把这套方案落到你的项目里,不需要一步到位。可以分阶段演进:
- 第一阶段:先上Redis库存预扣 + Lua原子扣减,解决80%的无效请求冲击。
- 第二阶段:接入读写分离,主库专心写,从库分担查询。
- 第三阶段:订单量破千万后,上分库分表,配合MQ异步化。
- 全程:限流、降级、监控、幂等、对账,一个都不能少。
高并发从来不是玄学,也不是靠堆机器就能解决的。它更像水利工程:洪水来的时候,硬筑高坝迟早决口,聪明的做法是开渠、分流、建蓄水池。Redis是蓄水池,读写分离是分流闸,分库分表是拓宽河道。三者配合,再加上消息队列和限流降级兜底,哪怕配置不高,也能稳稳接住双11级别的流量洪峰。
下次再遇到数据库报警,别急着升配。先看看流量是从哪个环节淤住的,然后对症下药。系统扛得住扛不住,往往不在最后一道防线,而在前面拦住了多少不该进来的请求。