设计一个秒杀系统:限流、库存扣减、超卖防护的五层防御体系
设计一个秒杀系统:限流、库存扣减、超卖防护的五层防御体系
适读人群:Java中高级工程师、电商平台技术人员 | 阅读时长:约20分钟 | 难度:★★★★☆
开篇故事
2019年双十一前,我们公司做了一个限时秒杀活动,1000件商品,预计5万人抢购。产品说很简单,就是个抢购功能。
结果活动开始后前3秒,系统的QPS从平时的800直接飙到了12万,Nginx日志里清一色的502,数据库CPU跑到100%,Redis内存告警,订单服务完全不响应。最惨的是,数据库里显示卖出了1237件,但库存只有1000件,超卖了237件,客服电话被打爆。
那次复盘开了整整两天,我把整个架构推倒重来。之后两年的双十一、618,系统都稳稳撑住了,最高峰值23万QPS,没有一次超卖。
现在把完整的思路整理出来,这套"五层防御"是我们从线上教训里提炼出来的,不是纸上谈兵。
一、需求分析与规模估算
功能需求
- 用户在指定时间内抢购限量商品
- 严格保证不超卖(库存精确)
- 支持活动开始前的预热和倒计时
- 订单创建成功后,需要在15分钟内完成支付,否则自动释放库存
- 能扛住突发流量冲击
规模估算
以一次典型秒杀活动为基准:
参与人数: 50万用户同时刷 商品库存: 1000件 活动时长: 60秒(前10秒是流量最集中的时段)
QPS估算:
- 50万用户,假设每个用户每秒点击3次(手速快的用户)
- 前10秒总请求:50万 × 3 × 10 = 1500万次
- 折算峰值QPS:1500万 / 10 = 150万 QPS
这个数字不夸张,真实双十一核心系统峰值就是这个量级。
我们的目标:
- 只允许最多让1000个有效请求真正到达库存扣减层
- 其余149.9万 QPS在前几层就被拦截掉
- 整体系统延迟P99 < 200ms
存储估算:
- 每次秒杀活动:1000条订单记录,约100KB
- 活动元信息:1条记录,约1KB
- Redis中库存Key:单个Key,存整型
带宽估算:
- 峰值150万 QPS × 平均响应200字节 = 300 MB/s
- 实际上大部分请求在网关或前端就被拦截,真实带宽消耗远小于此
二、系统架构设计:五层防御
核心思路:让流量在越靠近用户的地方被拦截,成本越低。
第一层CDN拦截静态资源请求,减少动态请求量。第二层网关做粗粒度限流。第三层应用层做快速预检。第四层Redis做精确的库存扣减。第五层数据库作为最后的兜底防线。
三、核心组件详解
3.1 活动时间控制
秒杀开始前,接口要返回"活动未开始"。开始后,立刻开放请求。这里有个细节:服务器时钟不同步可能导致部分节点提前放开,要用NTP同步 + Redis中央时间校验双重保证。
3.2 用户资质预检
在流量进入库存扣减前,先做一批快速校验:
- 用户是否已购买(Redis Set记录已购用户ID)
- 用户账号是否正常(被封禁的账号直接拒绝)
- 是否在预约名单内(部分活动需要提前预约资格)
这些校验全部走Redis,不访问数据库,单次校验耗时<1ms。
3.3 本地库存预扣
每个应用节点在内存中缓存一部分库存(比如总库存1000件,10个节点,每个节点预分配100件)。本地扣减成功才去Redis扣减,本地库存耗尽直接返回抢完。
这样把Redis的压力再降低一个数量级。
3.4 Redis原子扣减
用Lua脚本保证"查库存 + 扣减"的原子性,防止并发下的超卖。
3.5 异步订单创建
Redis扣减成功后,不同步创建订单,而是往Kafka发一条消息,快速返回给用户"抢购成功,订单处理中"。Kafka消费者异步创建订单。这样主流程的耗时从"网络+Redis+DB写入"降为"网络+Redis+Kafka发送"。
四、关键代码实现
4.1 网关层令牌桶限流
@Component
public class SeckillRateLimiter {
// 整体限流:秒杀接口每秒最多放过10000个请求
private final RateLimiter globalLimiter = RateLimiter.create(10000);
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 双重限流:全局限流 + 用户维度限流
*/
public boolean isAllowed(String userId, String activityId) {
// 全局令牌桶:非阻塞尝试获取
if (!globalLimiter.tryAcquire()) {
return false;
}
// 用户维度:每个用户每5秒只允许提交一次
String userKey = "seckill:user:" + userId + ":" + activityId;
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(userKey, "1", 5, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isNew);
}
}4.2 本地库存预扣(AtomicInteger)
@Component
public class LocalStockCache {
// key: activityId, value: 本地预分配库存
private final ConcurrentHashMap<String, AtomicInteger> localStockMap =
new ConcurrentHashMap<>();
// 标记本地库存已耗尽,避免重复去Redis尝试
private final Set<String> emptyActivities = ConcurrentHashMap.newKeySet();
/**
* 活动开始前预加载:从总库存中分配一部分到本地
* 实际中通过配置中心推送,每个节点分配总库存/节点数
*/
public void preload(String activityId, int localStock) {
localStockMap.put(activityId, new AtomicInteger(localStock));
emptyActivities.remove(activityId);
}
/**
* 尝试本地扣减
* @return true=扣减成功,false=本地库存不足
*/
public boolean tryDecrease(String activityId) {
if (emptyActivities.contains(activityId)) {
return false;
}
AtomicInteger stock = localStockMap.get(activityId);
if (stock == null) return true; // 未初始化本地缓存,跳过本地扣减
int remaining = stock.decrementAndGet();
if (remaining < 0) {
// 已经减成负数,说明本地库存耗尽
stock.incrementAndGet(); // 回滚
emptyActivities.add(activityId);
return false;
}
return true;
}
/**
* 本地扣减回滚(Redis扣减失败时需要回滚本地扣减)
*/
public void rollback(String activityId) {
AtomicInteger stock = localStockMap.get(activityId);
if (stock != null) {
stock.incrementAndGet();
emptyActivities.remove(activityId);
}
}
}4.3 Redis Lua脚本原子扣减库存
@Component
public class RedisStockService {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua脚本:原子性地检查库存并扣减
private static final String DECREASE_STOCK_LUA =
"local stock_key = KEYS[1]\n" +
"local sold_key = KEYS[2]\n" +
"local user_id = ARGV[1]\n" +
"local activity_id = ARGV[2]\n" +
// 检查用户是否已购买(防重复下单)
"if redis.call('SISMEMBER', sold_key, user_id) == 1 then\n" +
" return -1\n" + // -1 表示已购买
"end\n" +
// 获取当前库存
"local stock = tonumber(redis.call('GET', stock_key))\n" +
"if stock == nil or stock <= 0 then\n" +
" return 0\n" + // 0 表示库存不足
"end\n" +
// 扣减库存,记录已购用户
"redis.call('DECR', stock_key)\n" +
"redis.call('SADD', sold_key, user_id)\n" +
"return 1\n"; // 1 表示成功
/**
* 原子扣减库存
* @return 1=成功, 0=库存不足, -1=已购买
*/
public int decreaseStock(String activityId, String userId) {
String stockKey = "seckill:stock:" + activityId;
String soldKey = "seckill:sold:" + activityId;
DefaultRedisScript<Long> script = new DefaultRedisScript<>(
DECREASE_STOCK_LUA, Long.class);
Long result = redisTemplate.execute(
script,
Arrays.asList(stockKey, soldKey),
userId, activityId
);
return result == null ? 0 : result.intValue();
}
/**
* 活动初始化:加载库存到Redis
*/
public void initStock(String activityId, int totalStock) {
String stockKey = "seckill:stock:" + activityId;
String soldKey = "seckill:sold:" + activityId;
redisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock));
redisTemplate.delete(soldKey);
// 设置过期时间,活动结束后自动清理
redisTemplate.expire(stockKey, 24, TimeUnit.HOURS);
redisTemplate.expire(soldKey, 24, TimeUnit.HOURS);
}
}4.4 秒杀主服务(五层串联)
@Service
@Slf4j
public class SeckillService {
@Autowired
private SeckillRateLimiter rateLimiter;
@Autowired
private LocalStockCache localStockCache;
@Autowired
private RedisStockService redisStockService;
@Autowired
private SeckillActivityCache activityCache;
@Autowired
private KafkaTemplate<String, SeckillOrderMessage> kafkaTemplate;
/**
* 秒杀主流程:五层防御依次执行
*/
public SeckillResult doSeckill(String userId, String activityId) {
// ====== 第一层:限流 ======
if (!rateLimiter.isAllowed(userId, activityId)) {
return SeckillResult.fail("系统繁忙,请稍后重试");
}
// ====== 第二层:活动时间和状态校验 ======
SeckillActivity activity = activityCache.get(activityId);
if (activity == null || !activity.isActive()) {
return SeckillResult.fail("活动不存在或已结束");
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(activity.getStartTime())) {
return SeckillResult.fail("活动还未开始");
}
if (now.isAfter(activity.getEndTime())) {
return SeckillResult.fail("活动已结束");
}
// ====== 第三层:本地库存预扣 ======
boolean localDecreased = localStockCache.tryDecrease(activityId);
if (!localDecreased) {
return SeckillResult.fail("商品已抢完");
}
try {
// ====== 第四层:Redis原子扣减 ======
int stockResult = redisStockService.decreaseStock(activityId, userId);
if (stockResult == -1) {
return SeckillResult.fail("您已参与过此活动");
}
if (stockResult == 0) {
return SeckillResult.fail("商品已抢完");
}
// ====== 第五层:异步创建订单 ======
String orderToken = generateOrderToken(userId, activityId);
SeckillOrderMessage message = SeckillOrderMessage.builder()
.userId(userId)
.activityId(activityId)
.orderToken(orderToken)
.createTime(System.currentTimeMillis())
.build();
kafkaTemplate.send("seckill-order", activityId, message);
log.info("秒杀成功, userId={}, activityId={}, token={}",
userId, activityId, orderToken);
return SeckillResult.success(orderToken, "抢购成功!订单处理中,请稍候查看");
} catch (Exception e) {
// Redis或Kafka异常时,回滚本地库存
localStockCache.rollback(activityId);
log.error("秒杀异常, userId={}, activityId={}", userId, activityId, e);
return SeckillResult.fail("系统异常,请重试");
}
}
private String generateOrderToken(String userId, String activityId) {
return DigestUtils.md5DigestAsHex(
(userId + activityId + System.currentTimeMillis()).getBytes()
);
}
}4.5 Kafka消费者:异步创建订单(数据库最终防线)
@Component
@Slf4j
public class SeckillOrderConsumer {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@KafkaListener(topics = "seckill-order", groupId = "seckill-order-group",
concurrency = "3")
public void consume(SeckillOrderMessage message) {
String userId = message.getUserId();
String activityId = message.getActivityId();
String orderToken = message.getOrderToken();
// 幂等校验:同一个token不能重复创建订单
if (orderMapper.existsByToken(orderToken)) {
log.warn("重复消息,跳过。token={}", orderToken);
return;
}
// 数据库层的最终兜底:乐观锁扣减库存
// SQL: UPDATE seckill_stock SET stock=stock-1
// WHERE activity_id=? AND stock > 0
int affected = stockMapper.decreaseStockWithCheck(activityId);
if (affected == 0) {
// 数据库库存也不足了(理论上不应该发生,Redis已精确控制)
// 需要回滚Redis中已扣减的库存
log.error("数据库库存不足,活动可能出现超扣。activityId={}", activityId);
// 补偿逻辑:Redis库存+1
return;
}
// 创建订单记录
Order order = Order.builder()
.orderNo(generateOrderNo())
.userId(userId)
.activityId(activityId)
.orderToken(orderToken)
.status(OrderStatus.PENDING_PAYMENT)
.expireTime(LocalDateTime.now().plusMinutes(15))
.createTime(LocalDateTime.now())
.build();
orderMapper.insert(order);
log.info("订单创建成功, orderNo={}, userId={}", order.getOrderNo(), userId);
}
private String generateOrderNo() {
return "SK" + System.currentTimeMillis() +
ThreadLocalRandom.current().nextInt(1000, 9999);
}
}五、扩展性设计
从1000件扩展到100万件
当活动规模很大(比如100万件库存)时,单个Redis Key的并发写入成为瓶颈(Redis单Key每秒约10万次写操作)。
解决方案:库存分段
把100万件库存拆成100段,每段1万件,存100个Redis Key。用一致性哈希把用户请求路由到不同的库存段。
public class SegmentedStockService {
private static final int SEGMENT_COUNT = 100;
/**
* 分段扣减库存
* 根据userId哈希路由到对应的库存段,减少单Key热点
*/
public boolean decreaseStock(String activityId, String userId) {
int segment = Math.abs(userId.hashCode()) % SEGMENT_COUNT;
String stockKey = "seckill:stock:" + activityId + ":seg:" + segment;
// 每段库存不足时,尝试相邻段(负载均衡)
for (int i = 0; i < 3; i++) {
int targetSegment = (segment + i) % SEGMENT_COUNT;
String key = "seckill:stock:" + activityId + ":seg:" + targetSegment;
Long result = redisTemplate.opsForValue().decrement(key);
if (result != null && result >= 0) {
return true;
}
// 当前段库存为负,回滚并尝试下一段
if (result != null && result < 0) {
redisTemplate.opsForValue().increment(key);
}
}
return false;
}
}超时未支付的库存回收
订单15分钟未支付,需要自动取消并归还库存:
@Scheduled(fixedDelay = 30000) // 每30秒扫描一次
public void cancelExpiredOrders() {
List<Order> expiredOrders = orderMapper.findExpired(LocalDateTime.now());
for (Order order : expiredOrders) {
// 1. 更新订单状态为已取消
orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED);
// 2. 归还数据库库存
stockMapper.increaseStock(order.getActivityId());
// 3. 归还Redis库存(让更多人能抢到)
String stockKey = "seckill:stock:" + order.getActivityId();
redisTemplate.opsForValue().increment(stockKey);
// 4. 从已购集合中移除,允许用户再次参与
String soldKey = "seckill:sold:" + order.getActivityId();
redisTemplate.opsForSet().remove(soldKey, order.getUserId());
}
}六、踩坑实录
坑1:Redis扣减成功,Kafka发送失败,库存永久丢失
Redis扣减了库存,但Kafka发消息时网络超时,消息没发出去。用户没收到成功提示,但库存已经扣了,这件商品永远卖不出去了。
解决方案:引入本地消息表(Transactional Outbox模式)。在数据库里创建一张seckill_outbox表,Redis扣减成功后,在本地数据库事务里写入消息记录,然后由另一个线程扫描outbox表,保证消息最终发送。
坑2:活动开始瞬间的"惊群"问题
活动开始的那一秒,数百万用户同时发请求,网关的令牌桶被瞬间消耗完,之后的一段时间内正常用户也被限流了。
原因是令牌桶在活动开始前积累了大量令牌(因为没有流量),开始时一次性被消费光。
解决方案:在活动开始前60秒,主动将令牌桶的令牌数清零,防止桶满突刺(burst)。或者使用漏桶算法代替令牌桶,漏桶天然没有突刺问题。
坑3:用户刷页面导致重复下单
用户点了"立即抢购"后,因为网络慢,等了3秒没反应,又点了一次。结果创建了两张订单(第一张还在Kafka队列里)。
解决方案:前端按钮点击后立刻禁用,同时在Redis里存一个"进行中"的状态,用setIfAbsent实现:只有第一次点击能设置成功,后续重复请求直接返回"订单处理中"。
坑4:本地库存分配不均导致部分节点早早"售罄"
总库存1000件,10个节点各分100件,但流量不均匀(nginx负载均衡没做到绝对均匀),有的节点分到了150个请求,有的只分到了50个。导致部分节点本地库存耗尽后,明明全局还有库存,却一直返回"抢完了"。
解决方案:本地库存耗尽时,不直接返回失败,而是去Redis重新申请一批(比如再申请50件),Redis里扣掉这50件,分配给本节点。这样本地缓存就变成了一个动态的"分批取货"机制。
坑5:超卖出现在订单超时回收环节
有一次出现了超卖,排查半天才发现是订单取消时的并发问题:两个定时任务节点同时扫描到了同一批超时订单,各自执行了一次库存归还,库存被多加了一次,后续抢购时超卖了。
解决方案:取消订单时,先对订单做状态CAS更新(UPDATE ORDER SET status='CANCELLED' WHERE id=? AND status='PENDING_PAYMENT'),只有更新成功的节点才执行库存归还。利用数据库的行锁保证只有一个节点能执行归还。
七、总结
五层防御的核心思想是:用最小代价拦截最大流量。
| 防御层 | 技术手段 | 拦截目标 | 单次耗时 |
|---|---|---|---|
| 第一层 CDN | 静态页面缓存 | 静态资源请求 | <10ms |
| 第二层 网关 | 令牌桶 + 用户去重 | 高频重复请求 | <5ms |
| 第三层 本地缓存 | AtomicInteger | 库存耗尽后的所有请求 | <1ms |
| 第四层 Redis | Lua原子扣减 | 精确控制并发数 | <5ms |
| 第五层 数据库 | 乐观锁 | 数据持久化兜底 | ~10ms |
真正到达数据库的请求数量 = 商品库存数量,实现了精确的"只有赢家才进数据库"的效果。
秒杀系统的本质不是技术有多复杂,而是在极度不确定的流量下,保证有界的、确定性的结果。这套五层防御,每一层都有明确的职责和边界,任何一层出问题,下一层都能兜住。
