🎯 核心挑战 — 秒杀的5道难关
挑战1:瞬时高并发 — 100万人同时点"抢购"
类比:1000人同时挤一扇门,如果不排队不限制,门就被挤坏了。多层限流就像在门口设了多道关卡——第一道放500人进院子,第二道放100人进大厅,第三道放10人进包间。层层过滤,保护里面的人(数据库)不被踩踏。
多层限流架构:
100万请求
→
Nginx限流 (放行10万)
→
Gateway限流 (放行1万)
Gateway限流
→
Redis令牌桶 (放行3000)
→
Semaphore (放行500)
→
DB (500条)
每一层的限流代码:
第1层:Nginx限流
# nginx.conf — 限制每个IP每秒最多10个请求
limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s;
server {
location /api/seckill/ {
# burst=20:允许突发20个请求排队
# nodelay:排队的请求不延迟,超出直接拒绝
limit_req zone=seckill burst=20 nodelay;
proxy_pass http://gateway;
}
}
第2层:Gateway限流(Spring Cloud Gateway)
@Configuration
public class SeckillRateLimiterConfig {
@Bean
public KeyResolver seckillKeyResolver() {
// 按用户ID限流
return exchange -> Mono.just(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
);
}
}
// application.yml
// spring.cloud.gateway.routes[0].filters:
// - name: RequestRateLimiter
// args:
// redis-rate-limiter.replenishRate: 100 # 每秒放行100个
// redis-rate-limiter.burstCapacity: 200 # 突发容量200
// key-resolver: "#{@seckillKeyResolver}"
第3层:Redis令牌桶
/**
* Redis + Lua 实现令牌桶限流
* 每个秒杀活动一个桶,控制每秒放行量
*/
public class RedisRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private DefaultRedisScript<Long> rateLimiterScript;
/**
* @param activityId 活动ID
* @param maxPermits 每秒最大放行数
* @return true=放行 false=限流
*/
public boolean allowRequest(String activityId, int maxPermits) {
Long result = redisTemplate.execute(
rateLimiterScript,
Collections.singletonList("ratelimit:" + activityId),
String.valueOf(maxPermits),
String.valueOf(System.currentTimeMillis() / 1000)
);
return result != null && result == 1;
}
}
-- Lua脚本:令牌桶限流
local key = KEYS[1]
local max_permits = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local info = redis.call('HMGET', key, 'permits', 'last_time')
local permits = tonumber(info[1])
local last_time = tonumber(info[2])
if permits == nil then
permits = max_permits
last_time = now
end
-- 计算应该补充的令牌数
local elapsed = now - last_time
if elapsed > 0 then
permits = math.min(max_permits, permits + elapsed * max_permits)
end
if permits >= 1 then
permits = permits - 1
redis.call('HMSET', key, 'permits', permits, 'last_time', now)
return 1 -- 放行
else
redis.call('HMSET', key, 'permits', permits, 'last_time', now)
return 0 -- 限流
end
第4层:应用层Semaphore(信号量)
@Service
public class SeckillService {
// 每个秒杀活动一个信号量,限制并发处理数
private final Map<String, Semaphore> semaphoreMap = new ConcurrentHashMap<>();
public SeckillResult seckill(SeckillRequest request) {
String key = "seckill:" + request.getActivityId();
Semaphore semaphore = semaphoreMap.computeIfAbsent(
key, k -> new Semaphore(200)); // 最多200个并发
if (!semaphore.tryAcquire()) {
return SeckillResult.busy(); // 系统繁忙
}
try {
// 执行秒杀逻辑...
return doSeckill(request);
} finally {
semaphore.release();
}
}
}
挑战2:防超卖 — 库存只有100,卖出去101就是事故
类比:春运只有1万张票,如果卖出1万零1张,就有一个人到了火车站发现没票——这在大促里就是"超卖事故",是要上新闻的。
3种防超卖方案:
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
| DB乐观锁 |
UPDATE SET stock=stock-1 WHERE stock>0 |
简单、强一致 |
DB扛不住高并发 |
⭐⭐⭐ |
| Redis+Lua |
Redis原子扣减,异步同步到DB |
高性能 |
Redis和DB可能短暂不一致 |
⭐⭐⭐⭐⭐ |
| 分布式锁 |
每次扣减先获取Redis锁 |
强一致 |
性能差(串行化) |
⭐⭐ |
推荐方案:Redis预扣 + Lua原子操作 + MQ异步扣DB:
-- Lua脚本:原子预扣库存(秒杀专用版)
-- KEYS[1] = 秒杀库存key seckill:stock:activity_10001
-- KEYS[2] = 已购买用户集合 seckill:bought:activity_10001
-- ARGV[1] = 用户ID
local stock_key = KEYS[1]
local bought_key = KEYS[2]
local user_id = ARGV[1]
-- 1. 检查用户是否已购买(防止同一用户重复抢购)
if redis.call('SISMEMBER', bought_key, user_id) == 1 then
return -2 -- 用户已购买
end
-- 2. 获取当前库存
local stock = tonumber(redis.call('GET', stock_key))
if stock == nil then
return -1 -- 活动不存在
end
-- 3. 库存不足
if stock <= 0 then
return 0 -- 库存不足
end
-- 4. 扣减库存
redis.call('DECR', stock_key)
-- 5. 记录已购买用户
redis.call('SADD', bought_key, user_id)
return 1 -- 抢购成功
// Java调用
@Service
public class SeckillStockService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedisScript<Long> seckillStockScript;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 秒杀扣库存
*/
public SeckillResult deductStock(String activityId, String userId) {
String stockKey = "seckill:stock:" + activityId;
String boughtKey = "seckill:bought:" + activityId;
Long result = redisTemplate.execute(
seckillStockScript,
Arrays.asList(stockKey, boughtKey),
userId
);
switch (result.intValue()) {
case -2:
return SeckillResult.alreadyBought();
case -1:
return SeckillResult.activityNotFound();
case 0:
return SeckillResult.soldOut();
case 1:
// 抢到了!发送MQ消息异步创建订单
SeckillOrderMessage msg = new SeckillOrderMessage();
msg.setActivityId(activityId);
msg.setUserId(userId);
msg.setCreateTime(LocalDateTime.now());
rocketMQTemplate.asyncSend("seckill-order-topic", msg,
new SendCallback() {
@Override
public void onSuccess(SendResult r) {
log.info("秒杀订单消息发送成功: {}", msg);
}
@Override
public void onException(Throwable e) {
log.error("秒杀订单消息发送失败,需要回滚Redis库存: {}", msg, e);
// 回滚Redis库存
rollbackStock(activityId, userId);
}
});
return SeckillResult.success("排队中,请稍候...");
default:
return SeckillResult.error();
}
}
/**
* 回滚Redis库存(MQ发送失败时)
*/
private void rollbackStock(String activityId, String userId) {
redisTemplate.opsForValue().increment("seckill:stock:" + activityId);
redisTemplate.opsForSet().remove("seckill:bought:" + activityId, userId);
}
}
挑战3:缓存预热 — 活动开始前把数据加载到Redis
核心机制:缓存预热(Cache Warming)——秒杀活动开始前,必须提前把热点商品数据、库存信息、活动规则全部加载到Redis中。如果等活动开始后用户请求来了才查数据库写入缓存,第一个请求必然打穿到DB,高并发下瞬间击垮数据库。
需要预热什么?
| 数据 | Redis Key | 示例 | 用途 |
| 商品信息 | seckill:goods:{activityId} | JSON序列化的商品详情 | 避免查DB |
| 库存数量 | seckill:stock:{activityId} | 100 | 原子扣减 |
| 已购用户 | seckill:bought:{activityId} | Set集合 | 防重复购买 |
| 活动规则 | seckill:rules:{activityId} | 限购数量、活动时间等 | 快速校验 |
| 秒杀URL | seckill:url:{activityId} | 动态生成的加密URL | 防止提前刷接口 |
/**
* 缓存预热服务
* 在活动开始前30分钟执行
*/
@Service
public class SeckillWarmUpService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SeckillActivityMapper activityMapper;
@Autowired
private GoodsMapper goodsMapper;
/**
* 预热指定活动的数据到Redis
*/
public void warmUp(String activityId) {
log.info("开始预热秒杀活动: {}", activityId);
SeckillActivity activity = activityMapper.selectById(activityId);
if (activity == null) {
log.error("活动不存在: {}", activityId);
return;
}
// 1. 预热商品信息
Goods goods = goodsMapper.selectById(activity.getGoodsId());
redisTemplate.opsForValue().set(
"seckill:goods:" + activityId,
JSON.toJSONString(goods),
2, TimeUnit.HOURS // 活动期间有效
);
// 2. 预热库存
redisTemplate.opsForValue().set(
"seckill:stock:" + activityId,
String.valueOf(activity.getStock()),
2, TimeUnit.HOURS
);
// 3. 初始化已购用户集合(空Set)
redisTemplate.opsForSet().add(
"seckill:bought:" + activityId,
"__init__" // 先加一个占位元素
);
redisTemplate.opsForSet().remove(
"seckill:bought:" + activityId,
"__init__" // 再移除,确保key存在
);
redisTemplate.expire("seckill:bought:" + activityId,
2, TimeUnit.HOURS);
// 4. 预热活动规则
SeckillRules rules = new SeckillRules();
rules.setLimitPerUser(activity.getLimitPerUser());
rules.setStartTime(activity.getStartTime());
rules.setEndTime(activity.getEndTime());
redisTemplate.opsForValue().set(
"seckill:rules:" + activityId,
JSON.toJSONString(rules),
2, TimeUnit.HOURS
);
// 5. 生成动态秒杀URL(防止提前知道URL刷接口)
String md5 = DigestUtils.md5Hex(activityId + System.currentTimeMillis());
redisTemplate.opsForValue().set(
"seckill:url:" + activityId,
md5,
2, TimeUnit.HOURS
);
log.info("秒杀活动预热完成: {}, 库存: {}", activityId, activity.getStock());
}
/**
* 定时预热:每天0点检查当天要开始的活动,提前30分钟预热
*/
@Scheduled(cron = "0 0 0 * * ?")
public void scheduleWarmUp() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime deadline = now.plusHours(24);
List<SeckillActivity> activities = activityMapper
.selectList(new LambdaQueryWrapper<SeckillActivity>()
.between(SeckillActivity::getStartTime, now, deadline)
.eq(SeckillActivity::getStatus, NOT_STARTED));
for (SeckillActivity activity : activities) {
// 提前30分钟预热
long delayMs = Duration.between(now,
activity.getStartTime().minusMinutes(30)).toMillis();
if (delayMs <= 0) {
warmUp(activity.getId().toString()); // 已到预热时间,立即执行
} else {
scheduler.schedule(
() -> warmUp(activity.getId().toString()),
delayMs, TimeUnit.MILLISECONDS);
}
}
}
}
挑战4:削峰填谷 — 瞬间流量不能全打到DB
类比:地铁早高峰,所有人8:30同时到站。如果所有列车同时发车,站台就爆了。削峰填谷就像排队进站——人在站外排队(消息队列),按站台容量分批放人进站(消费者按DB能力消费)。
10万请求/秒
→
Redis扣库存 (快)
→
成功者入MQ
→
MQ (缓冲)
MQ
→
消费者 (慢,但稳)
→
创建订单 (DB)
→
通知用户
前端也要配合削峰:
- 按钮置灰:点击"抢购"后按钮变灰3秒,防止连点
- 倒计时:活动未开始时显示倒计时,按钮不可点
- 验证码:抢购前先过滑块验证码,过滤机器刷单
- 排队提示:抢购后显示"排队中",而不是一直转圈
// 前端防重复提交代码
let isSubmitting = false;
async function handleSeckill(activityId) {
if (isSubmitting) {
showToast('请勿重复点击');
return;
}
isSubmitting = true;
// 按钮置灰
const btn = document.getElementById('seckill-btn');
btn.disabled = true;
btn.textContent = '抢购中...';
try {
// 1. 先获取验证码token
const captchaToken = await getCaptchaToken();
if (!captchaToken) {
showToast('请完成验证码');
return;
}
// 2. 发起秒杀请求
const res = await fetch(`/api/seckill/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
activityId,
captchaToken,
timestamp: Date.now()
})
});
const data = await res.json();
if (data.code === 0) {
showToast('排队中,请稍候...');
// 开始轮询订单状态
pollOrderStatus(activityId);
} else {
showToast(data.message || '抢购失败');
}
} catch (e) {
showToast('网络异常,请重试');
} finally {
// 3秒后才允许再次点击
setTimeout(() => {
isSubmitting = false;
btn.disabled = false;
btn.textContent = '立即抢购';
}, 3000);
}
}
挑战5:防作弊 — 机器刷单、黄牛怎么防?
类比:春运抢票,黄牛用机器0.001秒就能抢1000张票,普通人根本抢不到。怎么防?实名制(用户风控)、验证码(人机识别)、限购(每人限2张)。
/**
* 秒杀风控校验
*/
@Service
public class SeckillRiskService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 风控校验(在秒杀执行前调用)
* @return true=通过 false=被拦截
*/
public RiskCheckResult check(SeckillRequest request) {
// 1. 验证码校验(防机器人)
if (!captchaService.verify(request.getCaptchaToken())) {
return RiskCheckResult.fail("验证码校验失败");
}
// 2. IP限流(每IP每秒最多5次)
String ipKey = "seckill:ip:" + request.getIp();
Long ipCount = redisTemplate.opsForValue().increment(ipKey);
if (ipCount == 1) {
redisTemplate.expire(ipKey, 1, TimeUnit.SECONDS);
}
if (ipCount > 5) {
return RiskCheckResult.fail("操作过于频繁");
}
// 3. 用户风控(黑名单、新号、异常行为)
UserRiskLevel riskLevel = userRiskService.getRiskLevel(request.getUserId());
if (riskLevel == BLOCKED) {
return RiskCheckResult.fail("账号异常,请联系客服");
}
if (riskLevel == HIGH_RISK) {
// 高风险用户需要额外验证
if (!request.hasExtraVerify()) {
return RiskCheckResult.needExtraVerify();
}
}
// 4. 设备指纹校验(同一设备多账号)
String deviceKey = "seckill:device:" + request.getDeviceId();
Long deviceCount = redisTemplate.opsForValue().increment(deviceKey);
if (deviceCount == 1) {
redisTemplate.expire(deviceKey, 1, TimeUnit.MINUTES);
}
if (deviceCount > 3) {
// 同一设备1分钟内超过3个账号抢购
return RiskCheckResult.fail("检测到异常操作");
}
return RiskCheckResult.pass();
}
}
🔄 关键流程详解
秒杀下单全链路
/**
* 秒杀全流程Controller
*/
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
@Autowired
private SeckillRiskService riskService;
@Autowired
private SeckillStockService stockService;
/**
* 执行秒杀
* 整个链路:风控 → 校验URL → Redis扣库存 → 发MQ
* 全程不访问数据库!
*/
@PostMapping("/execute")
public Result<SeckillVO> execute(@RequestBody SeckillRequest request) {
// 0. 活动时间校验(从本地缓存读,不查Redis)
SeckillActivity activity = localCache.get(request.getActivityId());
if (activity == null) {
return Result.fail("活动不存在");
}
if (LocalDateTime.now().isBefore(activity.getStartTime())) {
return Result.fail("活动未开始");
}
if (LocalDateTime.now().isAfter(activity.getEndTime())) {
return Result.fail("活动已结束");
}
// 1. 风控校验(验证码 + IP限流 + 用户风控 + 设备指纹)
RiskCheckResult riskResult = riskService.check(request);
if (!riskResult.isPass()) {
return Result.fail(riskResult.getMessage());
}
// 2. 校验动态URL(防止提前刷接口)
String expectedMd5 = redisTemplate.opsForValue()
.get("seckill:url:" + request.getActivityId());
if (!request.getMd5().equals(expectedMd5)) {
return Result.fail("非法请求");
}
// 3. Redis原子扣减库存(Lua脚本)
SeckillResult seckillResult = stockService
.deductStock(request.getActivityId(), request.getUserId());
if (!seckillResult.isSuccess()) {
return Result.fail(seckillResult.getMessage());
}
// 4. 返回"排队中",前端开始轮询
return Result.success(SeckillVO.queueing(request.getActivityId()));
}
}
MQ消费者:异步创建订单
@RocketMQMessageListener(
topic = "seckill-order-topic",
consumerGroup = "seckill-order-group"
)
public class SeckillOrderConsumer
implements RocketMQListener<SeckillOrderMessage> {
@Override
public void onMessage(SeckillOrderMessage msg) {
try {
// 1. 幂等校验(防止MQ重复消费)
String dedupeKey = "seckill:order:" + msg.getActivityId()
+ ":" + msg.getUserId();
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(dedupeKey, "1", 10, TimeUnit.MINUTES);
if (!isFirst) {
log.info("重复消费,忽略: {}", msg);
return;
}
// 2. 再次校验库存(双保险)
SeckillActivity activity = activityMapper
.selectById(msg.getActivityId());
if (activity.getAvailableStock() <= 0) {
log.warn("库存已空,回滚Redis: {}", msg);
rollbackRedisStock(msg.getActivityId(), msg.getUserId());
return;
}
// 3. 创建订单(操作DB)
Order order = orderService.createSeckillOrder(
msg.getActivityId(), msg.getUserId());
// 4. 扣减DB库存
int rows = activityMapper.deductStock(msg.getActivityId());
if (rows == 0) {
log.error("DB扣库存失败,可能超卖: {}", msg);
orderService.cancelOrder(order.getId());
rollbackRedisStock(msg.getActivityId(), msg.getUserId());
return;
}
// 5. 发送订单创建成功通知(WebSocket/轮询)
redisTemplate.opsForValue().set(
"seckill:result:" + msg.getUserId() + ":" + msg.getActivityId(),
JSON.toJSONString(order),
30, TimeUnit.MINUTES
);
log.info("秒杀订单创建成功: orderId={}, userId={}, activityId={}",
order.getId(), msg.getUserId(), msg.getActivityId());
} catch (Exception e) {
log.error("秒杀订单创建失败: {}", msg, e);
// 回滚Redis库存
rollbackRedisStock(msg.getActivityId(), msg.getUserId());
// 抛出异常让MQ重试
throw new RuntimeException(e);
}
}
}
前端轮询订单状态
/**
* 前端轮询秒杀结果
* 每2秒查一次Redis,最多查30次(1分钟)
*/
function pollOrderStatus(activityId) {
let count = 0;
const maxCount = 30;
const timer = setInterval(async () => {
count++;
if (count > maxCount) {
clearInterval(timer);
showToast('排队超时,请到订单列表查看');
return;
}
try {
const res = await fetch(`/api/seckill/result?activityId=${activityId}`);
const data = await res.json();
if (data.code === 0) {
clearInterval(timer);
if (data.data.orderId) {
// 跳转到支付页面
window.location.href = `/order/pay?orderId=${data.data.orderId}`;
} else {
showToast('很抱歉,商品已售罄');
}
}
} catch (e) {
console.error('轮询异常', e);
}
}, 2000); // 每2秒查一次
}
💥 踩坑实录 — 血泪教训
坑1:Redis缓存击穿 — 热点key过期瞬间大量请求打到DB
场景:秒杀活动的库存key设置了1小时TTL。某次运维误操作把这个key删了,瞬间10万请求绕过Redis直接打到数据库,DB直接挂了,整个系统不可用。
解决方案:
1. 热点key永不过期(逻辑过期:后台定时刷新)
2. 互斥锁重建:key不存在时只允许1个线程查DB重建缓存
3. 布隆过滤器:快速判断key是否应该存在
4. 多级缓存:Caffeine本地缓存 + Redis分布式缓存
// 互斥锁重建缓存
public String getWithMutex(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 缓存不存在,尝试获取锁重建
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
value = redisTemplate.opsForValue().get(key);
if (value != null) return value;
// 查DB重建缓存
value = queryFromDB(key);
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没拿到锁,等一会再试
try { Thread.sleep(50); } catch (InterruptedException e) {}
return getWithMutex(key);
}
return value;
}
坑2:MQ消息积压 — 消费者处理不过来
场景:秒杀开始后MQ瞬间堆积了50万条消息,消费者每秒只能处理1000条,需要500秒才能消费完。用户等了5分钟还没出结果,全在投诉。
解决方案:
1. 增加消费者数量(临时扩容)
2. 批量消费(一次消费50条而不是1条)
3. 丢弃已超时的消息(用户已经走了)
4. 根本解决:控制入队速度(Redis扣库存时限制并发)
// 消费者处理时检查消息是否已超时
@Override
public void onMessage(SeckillOrderMessage msg) {
// 如果消息已经超过3分钟,说明用户等太久了,直接丢弃
long ageMs = System.currentTimeMillis() - msg.getCreateTimestamp();
if (ageMs > 180_000) {
log.warn("消息超时丢弃: age={}ms, activityId={}, userId={}",
ageMs, msg.getActivityId(), msg.getUserId());
// 回滚Redis库存
rollbackRedisStock(msg.getActivityId(), msg.getUserId());
return;
}
// 正常处理...
processOrder(msg);
}
坑3:数据库连接池耗尽 — 秒杀流量泄漏到DB
场景:某次秒杀Redis挂了5秒,所有请求直接打到DB。MySQL连接池200个连接瞬间用完,所有服务都报"获取连接超时",连普通的查询接口也挂了。
解决方案:
1. Redis不可用时直接拒绝请求(快速失败),不要降级到DB
2. Hystrix/Sentinel熔断:Redis超时率>50%时熔断,直接返回"系统繁忙"
3. 独立数据库:秒杀服务用独立的DB,不影响主站
4. 连接池监控:连接使用率>80%时告警
// Sentinel熔断配置
@SentinelResource(value = "seckillExecute",
fallback = "seckillFallback",
blockHandler = "seckillBlockHandler")
public Result execute(SeckillRequest request) {
// 正常秒杀逻辑
}
// 限流/熔断时的降级处理
public Result seckillBlockHandler(SeckillRequest request, BlockException e) {
log.warn("秒杀被限流: userId={}", request.getUserId());
return Result.fail("当前排队人数过多,请稍后再试");
}
// 异常时的降级处理
public Result seckillFallback(SeckillRequest request, Throwable e) {
log.error("秒杀异常: userId={}", request.getUserId(), e);
return Result.fail("系统繁忙,请稍后再试");
}
坑4:雪崩 — 一个服务挂了拖垮整个链路
场景:Redis集群挂了一个节点,导致所有请求超时等待。Gateway等待Redis响应也超时了,线程池耗尽,其他正常的接口也挂了。一个Redis故障导致全站不可用。
解决方案:
1. 熔断降级:Sentinel/Hystrix检测到错误率上升,自动熔断,直接返回降级结果
2. 超时设置:所有外部调用必须设超时(Redis 200ms、HTTP 1s、DB 3s)
3. 舱壁隔离:不同业务用不同的线程池/连接池,互不影响
4. 多级缓存:Redis挂了还有Caffeine本地缓存兜底
5. Redis高可用:哨兵模式或集群模式,主从自动切换