第 233 章:秒杀系统设计
学习目标
- 秒杀业务核心挑战
- 系统分层设计
- 防超卖方案
- 高并发实战
一、秒杀特点
| 特点 | 描述 |
|---|---|
| 瞬时高并发 | 1 秒内 100 万请求 |
| 读多写少 | 查询 >> 下单 |
| 少量数据 | 几个 SKU 抢 |
| 公平性 | 防脚本、防羊毛党 |
| 超卖为 0 | 严格不超卖 |
二、秒杀架构演进
2.1 第一版:前端拦截
90% 请求在前端拦截。
2.2 第二版:Nginx + Redis
独立部署秒杀服务,与正常业务隔离。
2.3 第三版:消息队列削峰
三、防超卖设计
3.1 Redis 原子操作
java
public class SeckillService {
private static final String STOCK_KEY = "seckill:stock:";
private static final String ORDER_KEY = "seckill:order:";
private static final String SEC_LUA = """
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock and stock > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
""";
public Result<String> seckill(String skuId, String userId) {
// 1. 限流 + 验证
if (!fraudCheck(userId)) {
return Result.error("RISK_USER", "风险用户");
}
// 2. Redis 原子扣减
Long ok = redisTemplate.execute(
RedisScript.of(SEC_LUA, Long.class),
List.of(STOCK_KEY + skuId),
skuId
);
if (ok != 1) {
return Result.error("SOLD_OUT", "已售罄");
}
// 3. 投递 MQ,异步落单
messageTemplate.convertAndSend("seckill-orders",
new SeckillMessage(skuId, userId, Instant.now()));
return Result.ok("抢购成功,正在处理");
}
}3.2 库存签名校验
java
public String signSku(Long skuId) {
String sign = DigestUtils.md5Hex(skuId + ":" + SALT);
return sign.substring(0, 8);
}
// 前端:URL 加 ?token=签名
// 后端:校验签名,确保不是伪造请求3.3 限购
java
private static final String LIMIT_LUA = """
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
return 0 -- 已购买过
end
redis.call('SADD', KEYS[1], ARGV[1])
return 1
""";
public boolean tryAcquire(Long skuId, String userId) {
Long ok = redisTemplate.execute(
RedisScript.of(LIMIT_LUA, Long.class),
List.of("seckill:bought:" + skuId),
userId
);
return ok == 1;
}四、MQ 削峰
异步消费端:
java
@RocketMQMessageListener(topic = "seckill-orders")
public class SeckillConsumer implements RocketMQListener<SeckillMessage> {
@Override
public void onMessage(SeckillMessage msg) {
try {
// 幂等检查
String orderKey = "seckill:order:" + msg.skuId + ":" + msg.userId;
if (redisTemplate.hasKey(orderKey)) {
return;
}
// 真正落库
createOrder(msg);
// 标记订单完成
redisTemplate.opsForValue().set(orderKey, "1", 24, TimeUnit.HOURS);
} catch (Exception e) {
// 业务失败:回滚 Redis 库存
redisTemplate.opsForValue().increment("seckill:stock:" + msg.skuId);
log.error("秒杀落库失败", e);
}
}
}五、风控
5.1 设备指纹
javascript
// 浏览器:canvas / WebGL / Audio 等多维度
const fingerprint = {
userAgent: navigator.userAgent,
screen: `${screen.width}x${screen.height}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
canvas: canvas.toDataURL(),
plugins: Array.from(navigator.plugins).map(p => p.name).join(',')
};
const hash = sha256(JSON.stringify(fingerprint));5.2 行为分析
yaml
# 异常特征
- 同一 IP 10 秒内点击 100 次
- 同一设备 1 秒内通过 5 次验证码
- 凌晨 3 点批量下单
# 拦截策略
- 风险分 > 80 直接拒绝
- > 60 出图形验证码
- > 30 限流java
@Service
public class FraudService {
public boolean isRisk(String userId, String ip, String deviceId) {
int score = 0;
// 1. IP 频次
if (ipVisitCount(ip) > 50) score += 30;
// 2. 设备重复
if (userVisitCount(userId, deviceId) > 3) score += 30;
// 3. 时间异常
if (isInBlackHour(LocalTime.now())) score += 20;
return score > 80; // 高风险
}
}六、Lua 脚本深度优化
6.1 一脚搞定
lua
-- 完整秒杀 Lua
local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local orderQueueKey = KEYS[3]
local skuId = ARGV[1]
local userId = ARGV[2]
-- 检查是否重复购买
if redis.call('sismember', userSetKey, userId) == 1 then
return -1 -- 重复
end
-- 库存检查
local stock = tonumber(redis.call('get', stockKey))
if not stock or stock <= 0 then
return 0 -- 售罄
end
-- 扣减
redis.call('decr', stockKey)
redis.call('sadd', userSetKey, userId)
-- 投递订单队列
redis.call('lpush', orderQueueKey, skuId .. ',' .. userId)
return 1 -- 成功6.2 失败回滚
下游消费失败时,补偿回 Redis:
java
public void rollback(String skuId, String userId) {
String stockKey = "seckill:stock:" + skuId;
String userSetKey = "seckill:users:" + skuId;
redisTemplate.opsForValue().increment(stockKey);
redisTemplate.opsForSet().remove(userSetKey, userId);
}七、订单系统合并
订单创建独立服务,30 分钟未支付自动取消(Redis 延迟队列)。
八、性能压测
8.1 关键指标
| 指标 | 目标 |
|---|---|
| QPS | 10 万+ |
| P99 | < 100ms |
| CPU | < 70% |
| Redis 使用率 | < 60% |
| 库存差异 | 0 |
8.2 JMeter 压测
xml
<ThreadGroup>
<numThreads>1000</numThreads>
<rampTime>10</rampTime>
<duration>60</duration>
<loopCount>10</loopCount>
</ThreadGroup>8.3 全链路压测
bash
# 流量打标记
wrk -t 16 -c 1000 -d 60s \
-H "X-Test-Flag: stress-test" \
http://gateway/seckill/api压测数据不入库,只读不写。
九、监控大屏
java
// BigScreenMetric 推送指标
ScheduledExecutorService.scheduleAtFixedRate(() -> {
bigScreen.push("seckill_qps", meterRegistry.find("seckill_requests").counter().count());
bigScreen.push("stock_remaining", redisTemplate.opsForValue().get("seckill:stock:001"));
}, 0, 1, TimeUnit.SECONDS);十、回滚机制
10.1 库存预热
java
@Component
public class SeckillWarmup {
@PostConstruct
public void warmup() {
// 启动时把库存加载到 Redis
seckillSkuRepo.findAll().forEach(sku -> {
redisTemplate.opsForValue().set(
"seckill:stock:" + sku.getId(),
sku.getTotal()
);
});
}
}10.2 异常熔断
java
@PostConstruct
public void initFlowRule() {
FlowRule rule = new FlowRule("seckill");
rule.setCount(50_000); // 5 万 QPS 上限
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
FlowRuleManager.loadRules(List.of(rule));
}超过限流阈值,直接返回"SOLD_OUT"。
十一、总结架构
十二、本章小结
| 主题 | 方案 |
|---|---|
| 前端 | 验证码、按钮置灰、URL 随机化 |
| 接入层 | L7 + Nginx + Gateway 逐层限流 |
| 业务 | Redis Lua 原子扣减 |
| 异步 | MQ 削峰、消费者落库 |
| 防刷 | 限流、签名、风控 |
| 防护 | 熔断、库存补偿、监控 |
动手练习
设计一个简单的秒杀系统:
- 用 Redis Lua 实现原子扣减
- 写限流 + 签名校验中间件
- 设计 MQ 消费端做异步落库
- 用 JMeter 压测,观察瓶颈
下一章:第 234 章:大数据量处理