← 返回业务系统实战目录

🎫 电商秒杀实战 — 瞬时高并发的生存指南

秒杀 限流 缓存预热 防超卖 削峰

像春运抢票一样理解秒杀:放票瞬间100万人抢1万张票,系统怎么扛住

🎫 业务全景 — 秒杀场景的特殊性

类比:春运抢票 vs 日常买票。日常买票人少,系统轻松应付;春运放票瞬间100万人同时抢1万张票,这是完全不同的挑战——就像平时马路通畅,但春运火车站广场挤到水泄不通。

秒杀和普通下单的核心区别:

维度普通下单秒杀下单差异说明
并发量几百QPS几万~几十万QPS秒杀是普通场景的100~1000倍
库存相对充足极度有限(几十~几千件)秒杀库存往往秒光
用户行为慢慢选、慢慢买拼手速、抢不到就没了秒杀用户会疯狂刷新
超卖容忍度不能超卖绝对不能超卖(法律风险)秒杀超卖直接上新闻
刷单风险较低极高(黄牛、机器刷)秒杀有利可图,刷单严重
系统压力均匀分布瞬间峰值秒杀是尖刺流量

秒杀全流程:

活动发布 缓存预热 等待开抢 开始抢购 💥
开始抢购 限流过滤 Redis预扣库存 MQ异步下单
MQ异步下单 DB扣减库存 支付 发货 ✅

秒杀的核心矛盾:用户侧要"快"(1秒内出结果),系统侧要"稳"(不能超卖、不能崩溃)。解决方案的核心思想就是"层层过滤,逐步减少流量"——把100万请求一层层筛,最终到数据库的只有几百条。

核心原则:能拦截在网关层的绝不放到应用层,能拦截在Redis的绝不放到DB。流量就像洪水,要用多级水坝层层拦截,不能让洪峰直接冲到数据库。

🎯 核心挑战 — 秒杀的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}限购数量、活动时间等快速校验
秒杀URLseckill: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张)。

4道防线

/** * 秒杀风控校验 */ @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(); } }

🏗️ 架构设计 — 秒杀架构

CDN层
静态资源缓存(JS/CSS/图片) 秒杀页面CDN分发
Nginx层
负载均衡 IP限流 动静分离
Gateway层
用户限流 JWT鉴权 黑名单拦截
秒杀服务
风控校验 Redis预扣库存 发送MQ消息 本地缓存(活动信息)
Redis集群
库存扣减(Lua原子操作) 已购用户记录 令牌桶限流 分布式锁
MQ集群
削峰填谷 异步下单 延迟关单
订单服务 + DB
创建订单(消费者) DB扣减库存 分库分表

流量逐层递减漏斗图:

100万请求 (用户)
↓ CDN + Nginx过滤
10万请求 (Gateway)
↓ 限流 + 风控
1万请求 (秒杀服务)
↓ Redis扣减(大部分卖完或已购)
500条 (MQ)
↓ 消费者按能力消费
100条 (DB)
✅ 核心原则:流量从100万到100条,逐层递减。每一层都是一个"水坝",拦截掉大量不合法或无法处理的请求。到DB的只有真正能成交的订单。

🔄 关键流程详解

秒杀下单全链路

/** * 秒杀全流程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高可用:哨兵模式或集群模式,主从自动切换

🔍 排查思路 — 出了问题先看什么

问题1:秒杀成功率低 — 大部分用户抢不到

1
查Redis库存是否正确初始化:GET seckill:stock:{activityId}
2
查限流是否过于严格:Gateway/Nginx限流阈值是否太小
3
查风控是否误杀:IP限流/设备指纹是否拦截了正常用户
4
查MQ消费延迟:是否有消息积压未消费

问题2:秒杀超卖 — 库存100件卖出了102件

1
查Lua脚本是否有并发漏洞:脚本是否原子执行
2
查Redis集群是否脑裂:主从切换期间是否丢失了写操作
3
查DB扣减SQL:是否用了 stock >= qty 条件
4
查是否有绕过Redis直接调DB的代码路径

问题3:秒杀响应慢 — 用户等了10秒才有结果

1
查Redis延迟:redis-cli --latency-history
2
查MQ消费延迟:消费者积压了多少消息
3
查DB慢查询:是否有锁等待或慢SQL
4
查网络延迟:Gateway到秒杀服务的网络是否正常

排查命令速查

# 查Redis秒杀库存 $ redis-cli GET seckill:stock:activity_10001 # 查已购用户 $ redis-cli SMEMBERS seckill:bought:activity_10001 # 查Redis延迟 $ redis-cli --latency-history -h redis-host -p 6379 # 查RocketMQ消费延迟 $ ./mqadmin consumerProgress -g seckill-order-group # 查秒杀活动DB库存 $ mysql -e "SELECT id, available_stock, sold_count FROM t_seckill_activity WHERE id=10001" # 查Nginx限流状态 $ curl http://nginx-host/stub_status # 查Gateway实时QPS $ curl http://gateway-host/actuator/metrics/gateway.requests # 查Sentinel熔断状态 $ curl http://seckill-host:8719/cnode?id=seckillExecute # 查MySQL连接数 $ mysql -e "SHOW STATUS LIKE 'Threads_connected'" # 查看秒杀服务GC情况 $ jstat -gcutil <pid> 1000 10

📝 面试高频 — 5道必考题

Q1:秒杀系统如何防止超卖?

参考答案:核心是保证"检查+扣减"的原子性——
1. Redis + Lua原子扣减:在Redis中维护实时库存,Lua脚本原子判断库存并扣减,避免并发超卖。
2. MQ异步下单:Redis扣减成功后发MQ消息,消费者按DB承受能力慢慢扣DB。
3. DB乐观锁兜底:最终扣DB时用 UPDATE SET stock=stock-1 WHERE stock>=1,保证DB层也不超卖。
4. 用户维度去重:Lua脚本中用Set记录已购用户,防止同一用户抢多件。
关键:Redis做第一道防线(高性能),DB做第二道防线(强一致),双层保障。

Q2:秒杀系统的限流策略有哪些?

参考答案:多层限流,层层递减——
1. CDN层:静态资源走CDN,减少源站压力
2. Nginx层:limit_req按IP限流,每IP每秒10个请求
3. Gateway层:Spring Cloud Gateway内置限流,按用户ID限流
4. Redis令牌桶:Lua脚本实现令牌桶,精确控制每秒放行数
5. 应用层Semaphore:Java信号量限制并发处理数
6. 前端层:按钮置灰、倒计时、验证码拦截机器刷单
核心思想:100万请求 → Nginx放行10万 → Gateway放行1万 → Redis放行3000 → DB处理500,逐层递减。

Q3:缓存预热是什么?为什么要做?

参考答案:缓存预热是指在活动开始前,提前把热点数据从数据库加载到Redis。
为什么:如果不预热,活动开始瞬间大量请求同时查DB加载缓存,会导致:
1. DB瞬间被打满(缓存击穿)
2. 多个线程同时查DB写缓存,造成资源浪费
3. 首次请求延迟高
预热什么:商品信息、库存数量、活动规则、秒杀URL、已购用户集合。
预热时机:活动开始前30分钟,通过定时任务触发。

Q4:MQ削峰填谷的原理是什么?有什么注意点?

参考答案
原理:秒杀请求在Redis扣减库存后,不直接操作DB,而是发MQ消息。消费者按DB的承受能力(如每秒1000条)消费消息,创建订单、扣减DB库存。把瞬间的高峰流量变成均匀的低流量——这就是"削峰填谷"。
注意点
1. MQ发送失败要回滚Redis库存
2. 消费者要幂等(MQ可能重复投递)
3. 消息超时要丢弃(用户等太久已经走了)
4. 消费者要有重试机制但限制重试次数
5. 死信队列兜底:重试N次仍失败的消息进死信队列,人工处理

Q5:秒杀系统如何防止黄牛和机器刷单?

参考答案:4道防线——
1. 验证码(人机识别):滑块验证码/图形验证码,机器难以通过。每次秒杀前必须先过验证码。
2. 用户风控:根据注册时间、购买记录、账号行为评分,高风险用户需要额外验证或直接拦截。
3. IP/设备限流:同一IP每秒最多5次请求,同一设备1分钟内最多3个账号参与。
4. 动态URL:秒杀接口的URL是动态生成的(活动开始时才下发MD5),防止提前知道URL刷接口。
5. 限购策略:每人限购1件,Redis Set记录已购用户。
核心思想:让正常用户能抢到,让机器和黄牛抢不到。没有100%的防护,但要不断增加作弊成本。

业务系统实战 · 订单系统 · 支付系统 · 秒杀系统

返回目录