前言
你有没有经历过这样的场景?
- 凌晨12点,iPhone新品发售,你守在手机前,手指紧贴屏幕;
- 倒计时结束,点击"立即抢购",页面卡住、按钮无响应、提示"库存不足";
- 刷新一看,商品已经售罄------但你根本没成功下单。
这就是典型的高并发秒杀场景。它不仅仅是"卖东西",更是对系统架构的极限挑战。
什么是秒杀?
秒杀,是指在极短时间内(通常几秒到几分钟),大量用户集中访问并尝试购买限量商品的行为。其核心特征是:
特征 | 描述 |
---|---|
瞬时高并发 | 10万+用户同时发起请求 |
资源有限 | 商品库存可能只有100件 |
强一致性要求 | 不能超卖、不能重复下单 |
用户体验敏感 | 页面卡顿、失败提示都会影响口碑 |
为什么普通系统扛不住?
想象一下:10万人在同一毫秒点击"抢购",如果系统不做任何优化,所有请求直接打到数据库,会发生什么?
- 数据库连接池瞬间耗尽
- MySQL锁竞争激烈,事务排队
- CPU飙升,响应延迟从几毫秒变成几秒甚至超时
- 最终结果:系统崩溃、库存超卖、订单错乱
这就像双十一零点的超市收银台------只有一个收银员,却有上万人排队结账。
一、前端优化:第一道防线
虽然前端不能完全阻止恶意行为,但它是用户体验的第一道屏障,能有效减少无效请求进入后端。
1. 按钮防重复点击(防误触)
这是最基本也是最重要的防护措施。用户手滑连点,会生成大量无意义请求。
Vue 实现示例
html
<template>
<button
:disabled="isDisabled || countdown > 0"
@click="handleClick"
class="seckill-btn"
:class="{ 'disabled': isDisabled || countdown > 0 }"
>
{{ buttonText }}
</button>
</template>
<script>
export default {
data() {
return {
isDisabled: false,
countdown: 0, // 倒计时功能
maxCountdown: 5 // 最大禁用时间(秒)
}
},
computed: {
buttonText() {
if (this.countdown > 0) {
return `请等待${this.countdown}秒`;
}
return this.isDisabled ? '抢购中...' : '立即抢购';
}
},
methods: {
async handleClick() {
if (this.isDisabled || this.countdown > 0) return;
this.isDisabled = true;
try {
const result = await this.$api.createOrder();
if (result.success) {
this.$message.success('抢购成功');
} else {
this.startCountdown();
this.$message.error(result.message || '抢购失败');
}
} catch (error) {
this.startCountdown();
this.$message.error('请求失败,请重试');
} finally {
this.isDisabled = false;
}
},
startCountdown() {
this.countdown = this.maxCountdown;
const timer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(timer);
}
}, 1000);
}
}
}
</script>
说明 : 使用
:disabled
控制按钮状态 提供友好的用户反馈(Toast提示) 请求失败后禁用按钮防止重复提交
2. 请求频率限制(节流)
即使有人使用脚本刷请求,前端也应尽可能做初步限制。
节流函数实现(Throttle)
js
/**
* 函数节流:在指定时间内最多执行一次
* @param {Function} fn - 要节流的函数
* @param {Number} delay - 时间间隔(毫秒)
*/
function throttle(fn, delay) {
let lastCall = 0
return function (...args) {
const now = Date.now()
if (now - lastCall < delay) {
console.warn('请求过于频繁,已被节流')
return
}
lastCall = now
return fn.apply(this, args)
}
}
// 使用方式
const throttledCreateOrder = throttle(createOrder, 500) // 500ms内只能请求一次
防抖 vs 节流
类型 | 适用场景 | 行为 |
---|---|---|
防抖 debounce | 搜索框输入 | 停止输入后才触发 |
节流 throttle | 秒杀按钮 | 固定频率执行 |
注意:节流只能防"正常用户误操作",无法防御恶意脚本攻击。但它是多层防御的第一环。
二、后端核心方案
1. 流量削峰:使用 Redis Streams 实现请求缓冲
设计目标
- 防止瞬时百万级请求打爆数据库
- 实现"削峰填谷",平滑消费请求
- 支持失败重试与消息确认机制
架构角色
角色 | 职责 |
---|---|
生产者 | 用户点击抢购 → 写入 Redis Streams |
消费者组 | 多个服务实例组成消费者组,竞争消费 |
ACK机制 | 成功处理后确认,防止消息丢失 |
死信队列(DLQ) | 处理失败消息,支持人工干预或延迟重试 |
完整实现流程
java
@Component
@Slf4j
public class SeckillQueueService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String STREAM_KEY = "seckill:requests";
private static final String GROUP_NAME = "seckill_group";
private static final String DLQ_KEY = "seckill:dlq"; // 死信队列
/**
* 初始化消费者组(仅需执行一次)
*/
@PostConstruct
public void initConsumerGroup() {
try {
redisTemplate.opsForStream().createGroup(STREAM_KEY, ReadOffset.from("0-0"), GROUP_NAME);
} catch (Exception e) {
log.warn("消费者组已存在,无需重复创建");
}
}
/**
* 生产者:接收秒杀请求并入队
*/
public boolean enqueueRequest(String userId, String productId) {
// 1. 检查是否已参与(防重复提交)
String participatedKey = "seckill:participated:" + productId;
Boolean hasParticipated = redisTemplate.opsForSet().isMember(participatedKey, userId);
if (Boolean.TRUE.equals(hasParticipated)) {
return false;
}
// 2. 构造消息体
Map<String, Object> message = Map.of(
"userId", userId,
"productId", productId,
"timestamp", System.currentTimeMillis(),
"requestId", UUID.randomUUID().toString()
);
// 3. 写入 Stream
try {
redisTemplate.opsForStream().add(STREAM_KEY, message);
redisTemplate.opsForSet().add(participatedKey, userId);
redisTemplate.expire(participatedKey, 10, TimeUnit.MINUTES);
return true;
} catch (Exception e) {
log.error("消息入队失败", e);
return false;
}
}
/**
* 消费者:异步处理秒杀请求
*/
@Async("seckillTaskExecutor")
public void startConsuming() {
StreamReadOptions options = StreamReadOptions.empty()
.count(1)
.block(Duration.ofSeconds(5));
while (true) {
try {
Map<String, StreamMessage> messages = redisTemplate.opsForStream().read(
Consumer.from(GROUP_NAME, "consumer_" + Thread.currentThread().getId()),
options,
StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())
);
if (messages == null || messages.isEmpty()) continue;
for (Map.Entry<String, StreamMessage> entry : messages.entrySet()) {
Map<Object, Object> body = entry.getValue().getValue();
String requestId = (String) body.get("requestId");
String userId = (String) body.get("userId");
String productId = (String) body.get("productId");
try {
boolean success = processSeckillRequest(userId, productId);
if (success) {
// ACK:确认消费成功
redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME, entry.getKey());
log.info("秒杀成功,requestId={}", requestId);
} else {
// 失败:进入死信队列
redisTemplate.opsForList().leftPush(DLQ_KEY, body);
log.warn("秒杀失败,进入DLQ,requestId={}", requestId);
}
} catch (Exception e) {
log.error("处理消息异常,requestId={}", requestId, e);
redisTemplate.opsForList().leftPush(DLQ_KEY, body); // 进入DLQ
}
}
} catch (Exception e) {
log.error("消费流异常", e);
try {
Thread.sleep(1000); // 避免空轮询
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
private boolean processSeckillRequest(String userId, String productId) {
// 扣减库存 → 创建订单 → 发送通知
if (reduceStock(productId, 1)) {
createOrder(userId, productId);
return true;
}
return false;
}
}
优势总结 : 支持多消费者组负载均衡 ACK 机制保障消息不丢失 可通过
XPENDING
查看待确认消息 支持从任意位置重新消费
2. 库存扣减:Redis + Lua 原子操作
设计目标
- 防止超卖(一人多单)
- 高并发下保证原子性
- 与数据库最终一致
执行流程如下:
1.在活动开始前5~10分钟,把商品的库存从MySQL复制到Redis
java
/**
* 预热:把MySQL库存加载到Redis
*/
public void preloadStock(String productId) {
Product product = productMapper.selectById(productId);
Integer stock = product.getStock(); // 从MySQL读取库存
String redisKey = "product:stock:" + productId;
redisTemplate.opsForValue().set(redisKey, stock); // 写入Redis
}
2.用户下单时用Lua
脚本在Redis
扣减库存
它像一个"保险柜",把"检查库存 + 扣减"打包成一个动作,其他人不能插队。
Lua
-- Lua脚本:原子扣减库存
local stock = redis.call('get', KEYS[1]) -- 读库存
if not stock or tonumber(stock) < 1 then -- 如果库存不足
return 0 -- 返回失败
else
redis.call('decrby', KEYS[1], 1) -- 否则扣1
return 1 -- 返回成功
end
3.秒杀结束后批量同步mysql,避免频繁写数据库。
java
/**
* 活动结束后,把Redis库存写回MySQL
*/
public void syncStockToMySQL(String productId) {
String redisKey = "product:stock:" + productId;
Integer finalStock = (Integer) redisTemplate.opsForValue().get(redisKey);
Product product = new Product();
product.setId(productId);
product.setStock(finalStock); // 更新MySQL库存
productMapper.updateById(product);
}
完整示例代码
java
@Service
@Slf4j
public class StockService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 预热:把MySQL库存加载到Redis
*/
public void preloadStock(String productId) {
Product product = productMapper.selectById(productId);
Integer stock = product.getStock(); // 从MySQL读取库存
String redisKey = "product:stock:" + productId;
redisTemplate.opsForValue().set(redisKey, stock); // 写入Redis
}
/**
* 扣减库存(原子操作)
* @return true: 扣减成功, false: 库存不足
*/
public boolean reduceStock(String productId, int quantity) {
String stockKey = "product:stock:" + productId;
String luaScript =
"local stock = redis.call('get', KEYS[1])\n" +
"if not stock then\n" +
" return 0\n" +
"elseif tonumber(stock) < tonumber(ARGV[1]) then\n" +
" return 0\n" +
"else\n" +
" redis.call('decrby', KEYS[1], ARGV[1])\n" +
" return 1\n" +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);
try {
Long result = redisTemplate.execute(
script,
Collections.singletonList(stockKey),
String.valueOf(quantity)
);
return result != null && result == 1L;
} catch (Exception e) {
log.error("Lua脚本执行失败", e);
return false;
}
}
/**
* 增加库存(用于回滚)
*/
public void increaseStock(String productId, int quantity) {
String stockKey = "product:stock:" + productId;
redisTemplate.opsForValue().increment(stockKey, quantity);
}
/**
* 活动结束后,把Redis库存写回MySQL
*/
public void syncStockToMySQL(String productId) {
String redisKey = "product:stock:" + productId;
Integer finalStock = (Integer) redisTemplate.opsForValue().get(redisKey);
Product product = new Product();
product.setId(productId);
product.setStock(finalStock); // 更新MySQL库存
productMapper.updateById(product);
}
}
总结流程
MySQL库存 → 预热 → Redis库存 → 秒杀中扣减 → 秒杀结束 → 写回MySQL
3. 防重复下单:幂等性 + 分布式锁
为什么需要防重复?
- 用户手滑连点
- 网络超时导致前端重试
- 脚本恶意刷单
方案选择:业务幂等校验为主,分布式锁为辅
方案 | 适用场景 | 性能 | 推荐度 |
---|---|---|---|
Redis 是否已参与 | 第一道防线 | 中 | ⭐⭐⭐⭐ |
数据库唯一索引 | 最终防护 | 中 | ⭐⭐⭐⭐ |
Token 校验机制 | 前端防刷 | 高 | ⭐⭐⭐⭐ |
Redis 分布式锁 | 高并发抢购 | 高 | ⭐⭐⭐ |
方式一:Redis 记录"已参与"(最快)
在 Redis 中记录"用户A已参与iPhone秒杀",用完即焚,活动结束后自动过期
java
String participatedKey = "seckill:participated:" + productId;
Boolean hasParticipated = redisTemplate.opsForSet().isMember(participatedKey, userId);
if (hasParticipated) {
throw new BusinessException("您已参与本次秒杀,请勿重复提交");
}
// 首次参与,记录
redisTemplate.opsForSet().add(participatedKey, userId);
redisTemplate.expire(participatedKey, 30, TimeUnit.MINUTES); // 30分钟后过期
快,不依赖数据库,高并发防重第一道防线
方案二:数据库唯一索引(最终一致性保障)
sql
CREATE TABLE `seckill_order` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` VARCHAR(32) NOT NULL,
`product_id` VARCHAR(32) NOT NULL,
`activity_id` VARCHAR(32) NOT NULL, -- 秒杀活动ID
`create_time` DATETIME DEFAULT NOW(),
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
) ENGINE=InnoDB;
同一个用户在同一个活动里只能下一单,但可以参加不同活动,或活动结束后正常购买
方案三:Token 机制(防止脚本刷单)
每次抢购前必须先获取一个"令牌",用完即失效,防止机器人无限刷
流程图
markdown
用户 → 请求Token → Redis存Token → 用户抢购 → 后端验证 → 成功则删Token → 下单
↓
失败/重复 → Token不存在 → 拒绝
完整示例代码
java
@RestController
public class TokenController {
@GetMapping("/token")
public ResponseEntity<String> getToken(@RequestParam String productId) {
String token = UUID.randomUUID().toString();
String key = "seckill:token:" + token;
redisTemplate.opsForValue().set(key, productId, 5, TimeUnit.MINUTES);
return ResponseEntity.ok(token);
}
@PostMapping("/order")
public ResponseEntity<?> createOrder(@RequestParam String token, @RequestParam String productId) {
String key = "seckill:token:" + token;
String expectedProductId = (String) redisTemplate.opsForValue().get(key);
if (!productId.equals(expectedProductId)) {
return ResponseEntity.badRequest().body("非法请求");
}
// 删除Token,防止重复使用
redisTemplate.delete(key);
// 继续下单逻辑...
return ResponseEntity.ok("success");
}
}
防止脚本无限刷接口,即使网络超时,前端重试也会因 Token 失效而失败
方案四:Redisson 分布式锁(防止并发创建)
java
@Autowired
private RedissonClient redissonClient;
public boolean createOrderWithLock(String userId, String productId) {
String lockKey = "order:create:" + userId + ":" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(2, 10, TimeUnit.SECONDS);
if (!isLocked) {
throw new BusinessException("操作过于频繁");
}
// 再次检查是否已下单(双重检查)
if (orderService.exists(userId, productId)) {
return false;
}
// 创建订单
orderService.create(userId, productId);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
4. 限流与降级:Sentinel 全链路防护
使用 Alibaba Sentinel 实现多维度限流
java
@PostConstruct
public void initAllRules() {
// 1. QPS 流控规则
List<FlowRule> flowRules = new ArrayList<>();
FlowRule flowRule = new FlowRule();
flowRule.setResource("createOrder");
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS); // QPS模式
flowRule.setCount(1000); // 每秒1000次
flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
flowRules.add(flowRule);
FlowRuleManager.loadRules(flowRules);
// 2. 热点参数限流规则
List<ParamFlowRule> paramRules = new ArrayList<>();
ParamFlowRule paramRule = new ParamFlowRule();
paramRule.setResource("createOrder");
paramRule.setParamIdx(1); // 方法参数索引:productId
paramRule.setCount(100); // 单商品每秒最多100次
paramRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
paramRules.add(paramRule);
ParamFlowRuleManager.loadRules(paramRules);
// 3. 降级规则(异常比例熔断)
List<DegradeRule> degradeRules = new ArrayList<>();
DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("createOrder");
degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO);
degradeRule.setCount(0.5); // 异常比例 > 50%
degradeRule.setTimeWindow(60); // 熔断60秒
degradeRules.add(degradeRule);
DegradeRuleManager.loadRules(degradeRules);
log.info("✅ Sentinel 所有规则加载完成:流控、热点、降级");
}
Sentinel
支持从外部加载规则,我们可以把所有规则写在一个sentinel-rules.json
文件中
json
{
"flowRules": [
{
"resource": "createOrder",
"grade": 1,
"count": 1000,
"controlBehavior": 0,
"clusterMode": false
}
],
"paramFlowRules": [
{
"resource": "createOrder",
"paramIdx": 1,
"grade": 1,
"count": 100,
"durationSec": 1
}
],
"degradeRules": [
{
"resource": "createOrder",
"grade": 2,
"count": 0.5,
"timeWindow": 60
}
]
}
配置好后再到Java
代码读取并加载
5. 库存回滚:预扣 + 超时释放
场景
用户抢到库存但未支付,需在一定时间后释放库存。
方案一:Redis TTL 自动过期
java
// 抢购成功后标记预扣
String reservedKey = "seckill:reserved:" + userId + ":" + productId;
redisTemplate.opsForValue().set(reservedKey, "1", 15, TimeUnit.MINUTES);
// 定时任务扫描并释放
@Scheduled(cron = "0 0/5 * * * ?")
public void releaseExpiredReservations() {
Set<String> keys = redisTemplate.keys("seckill:reserved:*");
if (keys != null) {
for (String key : keys) {
Boolean exists = redisTemplate.hasKey(key);
if (!exists) continue;
// 模拟检查订单状态
boolean paid = orderService.isPaid(key); // 伪代码
if (!paid) {
// 解析 productId
String[] parts = key.split(":");
String productId = parts[3];
stockService.increaseStock(productId, 1);
redisTemplate.delete(key);
}
}
}
}
方案二:RocketMQ 延时消息(推荐)
java
// 抢购成功后发送15分钟延时消息
Message msg = new Message("SECKILL_TOPIC", "RELEASE_TAG", requestId, body);
msg.setDelayTimeLevel(3); // 15分钟
producer.send(msg);
消费者收到消息后判断订单状态,决定是否回滚库存。
总结
高并发秒杀系统的本质是:用空间换时间,用异步换同步,用缓存换数据库。
核心原则
原则 | 实现方式 |
---|---|
前端防刷 | 按钮禁用、节流、验证码、行为分析 |
流量削峰 | Redis Streams 队列、异步处理 |
数据一致 | Redis+Lua 原子操作、分段库存 |
系统保护 | Sentinel 限流、降级、热点参数控制 |
防重复下单 | 业务幂等校验 + 分布式锁(按需) |
库存安全 | 预扣机制 + 超时回滚 |
可观测性 | 日志、监控、告警三位一体 |
如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发!也欢迎在评论区交流你的秒杀设计经验。
若有不对的地方也欢迎提出指正。
公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》
《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》