最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
秒杀系统设计只会用update?从架构到代码:一篇文章讲透
时间:2026-07-17 08:43:50 编辑:袖梨 来源:一聚教程网
一、秒杀到底难在哪?(先认清问题)
假设你开了一家线上商店,今天做活动:1块钱抢最新款手机,只有100台。消息一出,瞬间来了10000个人。

这时候会遇到三个致命问题:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 瞬时高并发 | 平时QPS=500,秒杀瞬间飙到5万+ | 服务器直接宕机,所有人都买不了 |
| 库存超卖 | 100个并发同时扣库存,数据库可能扣成负数 | 卖了120台,平台亏钱,用户投诉 |
| 恶意刷单 | 用脚本疯狂点击,一个人抢走好几台 | 普通用户抢不到,活动失去公平性 |
所以我们的设计目标就三条:抗住高并发、保证不超卖、防刷防重复。
二、核心设计思想:漏斗模型(层层过滤)
不要把所有请求都直接冲到数据库,而是像漏斗一样,一层一层过滤掉无效流量,最后真正落到数据库的只有少量有效请求。
整体架构图(四层防御) :
复制代码用户请求
↓
【前端层】按钮防抖、验证码 → 拦截手抖党和脚本
↓
【网关层】Nginx限流 → 每IP每秒只放行10个请求
↓
【业务层】Redis原子扣库存 + 分布式锁防重复 → 核心战场
↓
【消息队列】异步下单 → 削峰填谷,保护数据库
↓
【数据库】最终落库 → 压力已削减99%
每一层都在"拦截",让无效请求尽早返回"已售罄"或"太火爆",绝不往下一层传递。
三、分层详解 + 代码实战
1. 前端层:把"手速党"和"脚本党"拦住
做法:点击秒杀按钮后立即变灰,5秒内不能重复点;加上简单验证码(滑块或算术题)。
复制代码// 按钮防重复点击
document.getElementById('buyBtn').onclick = function() {
this.disabled = true;
this.innerText = '正在抢购...';
// 实际发起请求
doSeckillRequest();
// 5秒后恢复(即使请求失败也不让狂点)
setTimeout(() => {
this.disabled = false;
this.innerText = '立即秒杀';
}, 5000);
};
2. 网关层(Nginx):限流,只放行一部分
做法:用Nginx的limit_req模块,限制每个IP每秒最多10个请求,突发20个,超过的直接返回503。
复制代码# nginx.conf
http {
# 定义限流区域,按IP,10MB内存,每秒10个请求
limit_req_zone $binary_remote_addr zone=seckill_limit:10m rate=10r/s;
server {
location /seckill {
# 允许突发20个,超出部分直接拒绝
limit_req zone=seckill_limit burst=20 nodelay;
limit_req_status 503;
proxy_pass ;
}
}
}
3. 业务层:Redis原子扣库存(最核心)
核心逻辑:库存提前预热到Redis中,用Lua脚本保证"查库存→判足够→扣库存"三步是原子操作,绝不出错。
Lua脚本(保存为 deduct.lua)
复制代码-- KEYS[1] : 库存Key,例如 seckill:stock:1001
-- ARGV[1] : 要扣减的数量,通常为1
local stock = redis.call('get', KEYS[1])
if not stock then
return -1 -- 库存key不存在(活动未开始)
end
if tonumber(stock) < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功
Java调用(Spring Boot + RedisTemplate)
复制代码@Component
public class SeckillService { @Autowired
private StringRedisTemplate redisTemplate; // 扣库存,返回true表示成功
public boolean deductStock(String productId, int quantity) {
String script =
"local stock = redis.call('get', KEYS[1]) " +
"if not stock then return -1 end " +
"if tonumber(stock) < tonumber(ARGV[1]) then return 0 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return 1";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList("seckill:stock:" + productId),
String.valueOf(quantity)
);
return result != null && result == 1L;
}
}
预热库存:秒杀开始前,将数据库库存加载到Redis。
复制代码public void preheatStock(String productId, int stock) {
redisTemplate.opsForValue().set("seckill:stock:" + productId, String.valueOf(stock));
}
4. 防重复下单(分布式锁)
问题:同一个用户可能因为网络延迟,同时发来两个请求,结果抢到两次(明明限制每人只能买一个)。
方案:用Redis的setIfAbsent加一个短暂锁,key包含用户ID+商品ID。
复制代码public boolean tryAcquireLock(Long userId, String productId) {
String lockKey = "seckill:lock:" + userId + ":" + productId;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
return Boolean.TRUE.equals(locked);
}
完整抢购流程:
复制代码public String handleSeckill(Long userId, String productId) {
// 1. 防重复
if (!tryAcquireLock(userId, productId)) {
return "您已参与过,请勿重复抢购";
}
// 2. 扣Redis库存
if (!deductStock(productId, 1)) {
return "库存不足,已售罄";
}
// 3. 发送消息到MQ(异步下单)
sendOrderMessage(userId, productId);
return "恭喜,抢购成功!正在生成订单";
}
5. 消息队列削峰(异步下单)
扣库存成功后,不立即写数据库,而是发消息给RocketMQ或RabbitMQ,让后台消费者慢慢创建订单。
发送消息(在业务层)
复制代码@Autowired
private RocketMQTemplate rocketMQTemplate;public void sendOrderMessage(Long userId, String productId) {
SeckillMessage msg = new SeckillMessage(userId, productId);
rocketMQTemplate.syncSend("seckill_order_topic", msg);
}
消费者(后台服务)
复制代码@RocketMQMessageListener(topic = "seckill_order_topic", consumerGroup = "seckill_group")
@Component
public class SeckillConsumer implements RocketMQListener<SeckillMessage> { @Autowired
private OrderService orderService; @Override
public void onMessage(SeckillMessage message) {
// 实际落库:插入订单,并更新数据库库存(使用乐观锁)
orderService.createSeckillOrder(message.getUserId(), message.getProductId());
}
}
数据库更新库存使用乐观锁:
复制代码UPDATE product SET stock = stock - 1
WHERE id = #{productId} AND stock > 0;
由于MQ已经削峰,同时写库的并发数很低,这条语句基本不会冲突。
四、用户取消订单,怎么安全释放库存?
用户抢到了但不想要,点"取消订单";或者超时未支付,系统自动关闭。核心原则:先改数据库(以它为准),再改Redis(释放名额),并且保证幂等(无论点多少次取消,库存只加一次)。
1. 用户主动取消(带状态校验)
复制代码@Service
@Transactional(rollbackFor = Exception.class)
public class SeckillCancelService { @Autowired
private OrderMapper orderMapper;
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate; public boolean cancelOrderAndReleaseStock(Long userId, String orderId) {
// 1. 查询订单(顺便做权限校验)
Order order = orderMapper.selectById(orderId);
if (order == null || !order.getUserId().equals(userId)) {
return false;
}
// 2. 如果不是"待支付"状态,直接返回(幂等性保证)
if (!"PENDING".equals(order.getStatus())) {
return false; // 已取消或已支付,什么都不做
} // 3. 更新订单状态为已取消(利用数据库行锁和状态判断)
int updateOrder = orderMapper.updateStatus(
orderId,
"CANCELLED", // 新状态
"PENDING" // 旧状态(乐观锁条件)
);
// 如果更新影响行数为0,说明刚才被其他线程抢先修改了,直接返回
if (updateOrder < 1) {
return false;
} // 4. 恢复数据库中的库存(此时订单已是取消状态)
productMapper.increaseStock(order.getProductId(), order.getQuantity()); // 5. 释放Redis库存(原子增加)
releaseRedisStock(order.getProductId(), order.getQuantity()); return true;
} private void releaseRedisStock(String productId, int quantity) {
String stockKey = "seckill:stock:" + productId;
// 直接用 increment 原子增加,不涉及判断
redisTemplate.opsForValue().increment(stockKey, quantity);
}
}
对应的SQL:
复制代码-- 只有状态为PENDING时才能改为CANCELLED
UPDATE orders
SET status = 'CANCELLED', cancel_time = NOW()
WHERE order_id = #{orderId}
AND status = 'PENDING';
2. 支付超时自动取消(用延迟消息,别用定时扫全表)
触发方式:用户抢到库存创建订单后,立刻发送一条延迟消息(15分钟后才被消费)。
复制代码// RocketMQ 发送延迟消息(订单创建时调用)
public void sendDelayCloseMessage(Order order) {
Message msg = new Message(
"order_close_topic",
JSON.toJSONBytes(order.getOrderId())
);
// 延迟级别16 ≈ 15分钟(RocketMQ 4.x 默认)
msg.setDelayTimeLevel(16);
rocketMQProducer.send(msg);
}
消费者收到消息后,处理超时取消:
复制代码@Component
public class OrderTimeoutConsumer implements RocketMQListener<String> { @Autowired
private OrderMapper orderMapper;
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate; @Override
public void onMessage(String orderId) {
// 1. 尝试把订单从【待支付】改为【已取消】
// 必须同时满足:status='PENDING' 且 创建超过15分钟
int updateResult = orderMapper.updateStatusByTimeout(
orderId,
"CANCELLED",
"PENDING",
LocalDateTime.now().minusMinutes(15)
); // 2. 如果更新失败,说明订单已被支付或其他线程处理,直接放弃
if (updateResult == 0) {
log.info("订单 {} 状态已变更,超时取消处理放弃", orderId);
return;
} // 3. 超时取消成功,立刻恢复库存
Order order = orderMapper.selectById(orderId);
productMapper.increaseStock(order.getProductId(), order.getQuantity());
redisTemplate.opsForValue().increment(
"seckill:stock:" + order.getProductId(),
order.getQuantity()
);
log.info("订单 {} 超时取消成功,库存已归还", orderId);
}
}
3. 最关键:支付回调 vs 超时取消的"并发争夺战"
用户在第14分59秒点击支付,而超时消息恰好在第15分00秒被消费。两个线程同时在改这条订单。谁先改数据库状态,谁赢。
支付回调的代码:
复制代码public void handlePaySuccess(String orderId, String transactionId) {
// 尝试把订单从【待支付】改为【已支付】
int updateResult = orderMapper.updateStatus(
orderId,
"PAID", // 新状态
"PENDING" // 旧状态(乐观锁条件)
); // 如果更新失败,说明订单已经不是 PENDING 状态
if (updateResult == 0) {
Order order = orderMapper.selectById(orderId);
if ("CANCELLED".equals(order.getStatus())) {
// 【关键!】用户付钱时,订单已经被超时取消了!
// 必须自动退款
refundService.refundToUser(order.getUserId(), order.getPayAmount());
log.warn("订单 {} 已超时取消,触发退款", orderId);
}
return;
} // 支付成功,正常发货
log.info("订单 {} 支付成功", orderId);
orderService.afterPaid(orderId, transactionId);
}
核心SQL:
复制代码-- 超时取消(带时间条件,双重保险)
UPDATE orders
SET status = 'CANCELLED', close_time = NOW()
WHERE order_id = #{orderId}
AND status = 'PENDING'
AND create_time < #{timeoutTime};-- 支付成功(只靠状态)
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_id = #{orderId}
AND status = 'PENDING';
五、完整流程串一遍(从点击到结束)
复制代码用户点击秒杀
↓
前端防抖 → 按钮变灰5秒
↓
Nginx限流 → 超出的返回"太火爆"
↓
业务层:分布式锁(防重复)→ Redis Lua扣库存
↓
扣成功 → 发MQ消息,返回"抢购成功"
↓
MQ消费者 → 插入订单(状态PENDING),发延迟消息
↓
用户15分钟内付款
├── 付款成功 → 订单状态 PAID → 发货
└── 超时未付 → 延迟消息触发
↓
乐观锁更新订单 CANCELLED
↓
恢复数据库+Redis库存
↓
如果恰好用户也在付款 → 支付回调检测到状态CANCELLED → 自动退款
六、避坑指南(实战经验)
| 坑点 | 解决方案 |
|---|---|
| Redis库存扣了,但MQ消息丢失 | 使用RocketMQ的事务消息或手动确认机制,保证消息必达。 |
| Redis挂了怎么办? | 部署Redis集群(主从+哨兵),并设置降级:Redis不可用时直接返回"活动太火爆",不查数据库。 |
| 数据库库存和Redis不一致 | 库存扣减优先用Redis,数据库作为最终落地。消费消息时如果更新数据库失败(如乐观锁冲突),可重试+告警人工对账。 |
| 消息队列积压严重 | 增加消费者实例,动态扩容,同时前端显示排队进度。 |
| 用户使用脚本绕过前端验证 | 后端必须做所有校验(限流、防重复、库存),前端只是锦上添花。 |
| 超时取消成功但Redis加库存失败 | 记录库存补偿日志,后台定时任务扫描重试。 |
七、技术选型推荐(轻量级方案)
| 组件 | 推荐 | 备注 |
|---|---|---|
| 网关 | Nginx | 免费、高效、配置简单 |
| 缓存 | Redis(单机或Cluster) | 必须支持Lua脚本 |
| 消息队列 | RocketMQ / RabbitMQ | 前者吞吐更高,后者更轻量 |
| 应用框架 | Spring Boot | 无状态,便于水平扩展 |
| 数据库 | MySQL + 乐观锁 | 分库分表视数据量而定 |
| 限流降级 | Sentinel | 阿里开源,集成简单 |
八、总结(背下来)
设计口诀:
取消和超时口诀:
一句话核心:把大部分请求挡在Redis层,用Lua脚本原子扣库存;用消息队列异步下单;取消和超时靠数据库乐观锁裁决,谁先改状态谁说了算。
这套方案足够应对百万级别的秒杀场景,所有代码都可以直接复制到项目中改造。如有问题,欢迎随时交流~
相关文章
- hive元数据库怎样关联 07-17
- 如何查询hive元数据库 07-17
- hive元数据库使用方法 07-17
- 《世界启元》平阳马超阵容搭配推荐-最佳组合解析 07-17
- hive元数据库怎样配置 07-17
- hive元数据库怎样扩展 07-17