先别急着去改代码,咱们先聊聊“秒杀”到底是个什么玩意儿。
很多做电商的同学,一听到“秒杀”两个字,脑子里蹦出来的第一个念头就是:“加大服务器配置”、“多搞几台MySQL”、“上Redis集群”。这没错,但太浅了。真正的硬仗,是在高并发流量像海啸一样拍过来的那一瞬间,你的系统能不能不仅不崩,还能稳稳地把订单记下来,钱算清楚,货扣对。
我见过太多系统在“双11”或者热点抢购面前,MySQL连接数瞬间打满,Redis被打爆,最后前端显示“系统繁忙”,后端日志一片报错。那种感觉,就像是你站在大坝前,看着洪水冲垮堤坝,却束手无策。
今天,我不讲那些干巴巴的理论,咱们像拆炸弹一样,把这整套高并发秒杀架构从头到尾拆碎了、嚼烂了,让你看完不仅能懂原理,还能回去直接照着改。
一、 问题的本质:为什么秒杀这么难?
首先,你得明白,秒杀的本质是什么?不是“买”,而是“竞争”。
在普通购物场景下,用户A看看商品A,用户B看看商品B,大家各买各的,数据库压力是线性的。但在秒杀场景下,假设只有一件商品,却有两百万人在同一秒点击“购买”。这时候,流量不是线性的,而是脉冲式的。
这就引出了三个核心矛盾:
- 读多写少,但写的瞬间极集中:99.9%的时间大家在浏览、犹豫、排队,最后0.1%的时间所有人同时提交请求。
- 数据库的原子性与并发性的冲突:秒杀要求“超卖不可行”,即库存不能变成负数。MySQL的行锁、表锁在百万级并发下,就是拦路虎。
- IO瓶颈:每一次下单,理论上都要落盘。千万次落盘,磁盘I/O直接瘫痪。
所以,我们的优化思路必须遵循一个铁律:尽量把请求拦截在数据库之外,能不在内存里做的事,绝不去碰磁盘。
二、 第一道防线:让流量“软着陆”
在流量打到你的Java应用之前,甚至打到数据库之前,我们需要有多层拦截机制。
1. 前端层面的“节流”
很多小白会忽略前端,觉得那是展示层。大错特错!前端是成本最低、效率最高的过滤层。
- 静态资源CDN化:秒杀页面的HTML、JS、CSS、图片,全部上CDN。用户加载页面本身就要消耗带宽和服务器连接,如果这些静态资源从你的源站拉取,源站还没接到请求就先累死了。
- 按钮防重:用户点击“立即抢购”后,按钮立刻置灰,禁用30秒。这能防止手抖的用户疯狂点击,产生重复请求。虽然单个用户只点两次,但百万用户就是两百万次无效请求,能拦就拦。
- 验证码或滑块:这不是为了安全,是为了验证真人和增加请求成本。机器脚本每秒能发100个请求,人类滑块需要1-2秒。这一两秒的延迟,能过滤掉90%的恶意脚本流量。
2. 网关层/反向代理层的限流
当请求进入你的应用服务器(比如Spring Boot)之前,必须经过Nginx或专门的API网关(如Kong、Spring Cloud Gateway)。
这里要用到令牌桶算法或漏桶算法。
想象一下,你的系统每秒最多能处理1000个请求。那么Nginx就像一个检票口,每秒钟只发放1000张票。没拿到票的人,直接在网关层返回“系统繁忙,请稍后再试”,根本进不到你的Tomcat容器。
# Nginx限流配置示例
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /seckill/ {
limit_req zone=mylimit burst=20 nodelay;
proxy_pass http://backend_service;
}
}
这段配置的意思是:允许每秒10个请求的基础速率,突发情况允许20个请求立即处理(burst=20),超过的拒绝。这层过滤,直接挡住了99%的无效流量。
三、 核心神器:Redis缓存架构
流量进了应用层,第一件事不是查MySQL,而是查Redis。
秒杀场景是典型的热点数据场景。商品信息、库存数量,这些读的频率极高,但写的频率相对较低(只有下单时才改)。这正是Redis的天下。
1. 缓存商品库存
我们不能把库存直接放在MySQL里等并发来抢。我们要把库存预热到Redis中。
流程如下:
- 活动开始前,将秒杀商品的库存数量(比如100个)写入Redis,设置Key为
seckill_stock:{itemId}。 - 用户请求到来,先从Redis中用Lua脚本执行
DECR操作。 - Lua脚本保证原子性:如果库存减完变为-1,直接返回失败,不再向下执行。
为什么用Lua脚本? 因为Redis是单线程模型,执行Lua脚本是原子的。如果你用Java代码去“判断库存>0 -> 减库存”,在高并发下会出现两个线程同时看到库存为1,都去减,结果变成-1,这就是超卖。
-- Lua脚本:原子性扣减库存
local key = KEYS[1]
local stock = redis.call('get', key)
if stock == false then
return -1 -- 库存不存在
end
if tonumber(stock) > 0 then
redis.call('decr', key)
return 1 -- 扣减成功
else
return 0 -- 库存不足
end
在Java中调用这个脚本,返回1才继续往下走,返回0或-1直接抛出异常或返回前端“已售罄”。
2. 缓存穿透与雪崩的预防
- 缓存空值:如果请求的商品ID不存在,也要在Redis里缓存一个空值(TTL设短一点,比如30秒)。防止恶意用户遍历ID,每个不存在的ID都打到MySQL,导致缓存击穿。
- 热点Key防穿透:对于秒杀这种极端热点Key,Redis集群可能会因为单个Key的过大压力导致节点挂掉。可以使用本地缓存(如Caffeine/Guava Cache)在应用服务器内存里再缓存一份热点库存,配合Redis做二级缓存。
四、 异步解耦:消息队列的妙用
好,现在用户在Redis里抢到了库存。接下来要做什么?要生成订单、要扣减真实数据库库存、可能要通知积分系统、可能要发短信。
如果这时候直接同步调用这些操作,响应时间会很长,用户会觉得卡。而且,如果积分系统挂了,会不会导致下单失败?
这时候,消息队列(MQ) 就派上用场了。常用的有RocketMQ、Kafka、RabbitMQ。
优化后的流程:
- Redis扣减库存成功。
- 应用程序不直接操作MySQL,而是向MQ发送一条“秒杀成功消息”。
- 立即返回给用户:“抢购成功,请等待发货”。
- 后端消费者服务从MQ中拉取消息,慢慢悠悠地去操作MySQL,生成订单,扣减库存。
这样做有两个巨大的好处:
- 削峰填谷:MQ像一个蓄水池,瞬间涌进来的百万请求被暂存在池子里,消费者按照数据库能承受的速度(比如每秒5000单)去处理。即使上游流量再大,下游数据库也是稳的。
- 异步解耦:下单和后续业务(发券、短信)解耦,提升主链路的响应速度。
注意:MQ也可能成为瓶颈。所以要配合本地消息表或RocketMQ的事务消息,保证消息不丢失、不重复消费。如果MQ挂了,或者消费失败了,要确保最终一致性。
五、 数据库层:MySQL的极限优化
即使有Redis和MQ,最终订单还是要落到MySQL里的。如果MySQL扛不住,一切都白搭。这时候,分库分表就登场了。
1. 为什么需要分库分表?
单表数据量超过1000万,查询性能就会显著下降。单库的写吞吐量也有上限(大概几千QPS)。对于百万级秒杀,单表肯定扛不住。
2. 分库策略
不要随便分!乱分会导致跨库查询、分布式事务等噩梦。
推荐策略:按用户ID或订单ID取模分片。
假设我们要存1000万订单,分10个库,每个库100张表(共1000张表)。
database_id = user_id % 10
table_id = order_id % 100
这样,同一个用户的订单一定落在同一个库,避免跨库JOIN。
但是! 秒杀场景中,我们通常只关心“我买没买到”,不关心历史订单查询。所以,对于秒杀订单表,我们可以采用更激进的分片策略,甚至按时间+Hash分片,均匀打散流量。
3. ShardingSphere实战
现在业界主流使用 Apache ShardingSphere 来透明化分库分表。它能让你的代码几乎不用改,就能实现分片。
# sharding-jdbc 配置示例
dataSources:
ds_0:
url: jdbc:mysql://localhost:3306/db0
ds_1:
url: jdbc:mysql://localhost:3306/db1
rules:
- !sharding
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..3}
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: t_order_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
t_order_inline:
type: INLINE
props:
algorithm-expression: t_order_${order_id % 4}
keyGenerators:
snowflake:
type: SNOWFLAKE
这段配置告诉ShardingSphere:有2个数据源,4张表,根据 order_id % 4 决定落到哪张表。ID生成用雪花算法,保证全局唯一。
4. MySQL本身的优化
- 索引优化:秒杀订单表,通常只需要按
user_id和order_status查询。建立联合索引(user_id, create_time)。避免在大表上做select *。 - 字段瘦身:订单表只存必要字段。金额、库存等敏感字段用
BIGINT存分(避免浮点精度问题),不用DECIMAL(性能稍差)。状态用TINYINT。 - 主键策略:千万不要用UUID做主键,会造成索引页分裂,性能极差。用雪花算法生成的Long型ID,或者数据库自增ID配合分片。
六、 极端情况:本地锁与集群锁
虽然Redis帮我们挡了一大部分请求,但依然有并发竞争。当两个请求同时通过了Redis检查,同时进入MQ,同时准备写MySQL时,MySQL的行锁还是会成为瓶颈。
这时候,可以在应用层加本地锁(Guava RateLimiter 或 ConcurrentHashMap 模拟的锁)做最后一道关卡,但这只能保护单机,需要配合Redis分布式锁。
更优雅的方案:Redis分布式锁 + 乐观锁
在写MySQL前,先获取一个短暂的分布式锁(比如50毫秒),执行完数据库操作后释放。或者,在更新库存时使用乐观锁:
UPDATE seckill_item
SET stock = stock - 1
WHERE id = #{itemId} AND stock > 0;
如果 rows affected = 0,说明库存已被抢光,放弃本次操作。这比锁更高效。
七、 全链路架构总结
让我们把上面的碎片拼起来,画一幅完整的全景图:
- 用户端:点击抢购,前端按钮禁用,发起请求。
- CDN/Nginx:静态资源直接返回,动态请求进入限流模块,非法或超额请求直接拦截。
- API网关:鉴权(Token校验),再次限流。
- 应用服务(Spring Boot):
- 第一步:调用Redis Lua脚本,原子扣减库存。失败则返回“已售罄”。
- 第二步:扣减成功,生成秒杀订单对象,发送到RocketMQ。
- 第三步:立即返回前端“排队中,请稍后查看”。
- 消息队列(RocketMQ):堆积消息,平滑流量。
- 消费者服务:
- 从MQ拉取消息。
- 再次校验库存(双重校验,防止MQ延迟期间库存已空)。
- 调用MySQL,执行
UPDATE ... WHERE stock > 0。 - 落库成功后,发送“秒杀成功”消息到另一个Topic,通知积分服务、短信服务异步处理。
- MySQL(分库分表):存储订单数据,通过ShardingSphere透明分片。
八、 几个容易被忽视的细节
1. 库存预热与数据一致性 活动开始前,必须将库存从MySQL同步到Redis。如果同步过程中MySQL的库存变了怎么办?
- 方案:在活动开始前,锁定库存,停止写入,同步到Redis,然后解锁。或者使用Canal监听MySQL Binlog,实时同步到Redis,保证毫秒级一致。
2. 幂等性设计 用户可能因为网络超时重试,或者MQ消息重复消费。必须保证同一个请求,无论执行多少次,结果都一样。
- 方案:利用订单号的全局唯一性,在MySQL建立唯一索引。插入失败即视为重复请求。
3. 监控与熔断 必须接入Prometheus + Grafana,监控QPS、RT、错误率。当某个服务(如短信服务)响应超时,要使用Hystrix或Sentinel进行熔断,避免雪崩效应拖垮整个系统。
4. 灰度发布与压测 上线前,必须进行全链路压测。不要相信理论性能,要相信压测数据。根据压测结果,调整Redis并发数、MQ消费速度、数据库连接池大小。
结语
百万级秒杀,拼的不是单一技术的深度,而是系统设计的广度和对流量漏斗的精准控制。
从Nginx的一级过滤,到Redis的原子扣减,再到MQ的削峰填谷,最后到MySQL的分库分表与乐观锁,每一层都是为了解决特定阶段的问题。没有银弹,只有层层把关的严密体系。
记住,最高级的优化,不是让数据库跑得更快,而是让请求根本进不了数据库。这就是高并发秒杀架构的核心心法。
