热门皮肤秒杀架构实战:Redis 预扣 + 令牌桶 + 本地缓存 + 用户分片锁
·
一、场景有多“狠”?
假设我们要做一次 限定传说皮肤秒杀:
-
✅ 仅 1 个 SKU
-
✅ 库存 1000 件
-
✅ 在线用户 50w+
-
✅ 活动开始瞬间 QPS 10w+
-
✅ 黄牛脚本 + 连点器
如果不做任何设计,你会同时踩中三个雷:
-
超卖(库存变负)
-
热点 Key(Redis 被打爆)
-
刷单 / 外挂(一个玩家买 N 次)
二、总体架构设计
客户端
↓ 令牌桶限流(网关层)
本地缓存(库存兜底)
↓
Redis Lua 预扣库存
↓
分布式锁(按 user_id 分片)
↓
订单落库(异步)
核心思想:
能拦在系统外的,绝不放进业务服务
能走本地内存的,绝不多一次 Redis
必须 Redis 的,绝不让它成为“单点 Key”
三、第一道防线:令牌桶限流(网关层)
1️⃣ 为什么不是计数器?
秒杀场景最怕的是:
-
前 100ms 被流量冲垮
-
后 10s 几乎没请求
✅ 令牌桶 = 平滑突发流量
2️⃣ Gateway + Redis 令牌桶
public boolean tryAcquire(String skuId) {
String key = "rate_limit:sku:" + skuId;
long now = System.currentTimeMillis();
long window = 1000; // 1 秒
int rate = 20000; // 每秒 2w 令牌
String lua =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) " +
"local count = redis.call('ZCARD', KEYS[1]) " +
"if count < tonumber(ARGV[3]) then " +
" redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1]) " +
" return 1 " +
"end " +
"return 0";
return redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
Collections.singletonList(key),
now + "", window + "", rate + ""
) == 1;
}
📌 部署位置:API Gateway(Nginx / Spring Cloud Gateway)
四、第二道防线:本地缓存兜底(非常关键)
1️⃣ 热点 Key 的本质问题
100w QPS
↓
Redis GET stock:sku:10001
👉 一个 Key,所有请求都打到同一个 Redis Slot
2️⃣ 本地缓存 + 短过期
@Configuration
public class LocalCacheConfig {
@Bean
public Cache<String, Integer> stockCache() {
return Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(500, TimeUnit.MILLISECONDS)
.build();
}
}
public boolean localStockAvailable(String skuId) {
Integer stock = stockCache.getIfPresent(skuId);
if (stock == null) {
stock = redisTemplate.opsForValue().get("stock:" + skuId);
if (stock != null) {
stockCache.put(skuId, stock);
}
}
return stock != null && stock > 0;
}
✅ 效果:
-
Redis QPS 下降 90%+
-
即使 Redis 抖动,系统仍可用
⚠️ 注意:本地缓存只做“是否可能还有库存”的判断,不做最终扣减。
五、第三道防线:Redis Lua 原子预扣库存
1️⃣ 为什么不用“先查再减”?
if (stock > 0) {
stock--; // ❌ 并发必超卖
}
2️⃣ Lua 脚本(核心)
-- KEYS[1]: stock key
-- KEYS[2]: user bought set
-- ARGV[1]: user_id
local stock = tonumber(redis.call("GET", KEYS[1]))
if not stock or stock <= 0 then
return -1 -- 已售罄
end
local bought = redis.call("SISMEMBER", KEYS[2], ARGV[1])
if bought == 1 then
return -2 -- 重复购买
end
-- 预扣库存
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return 1 -- 成功
3️⃣ Java 调用
public int tryBuy(String skuId, long userId) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
return redisTemplate.execute(
redisScript,
Arrays.asList(
"stock:" + skuId,
"bought:" + skuId
),
String.valueOf(userId)
).intValue();
}
✅ 原子性
✅ 防超卖
✅ 防刷单
六、第四道防线:分布式锁按 user_id 分片
1️⃣ 为什么要“分片锁”?
如果所有用户抢一把锁:
100w QPS
↓
Redis SETNX lock:sku:10001
👉 锁 Key 也是热点
2️⃣ 按 user_id 分片
int shard = Math.abs(userId) % 128;
String lockKey = "lock:sku:" + skuId + ":shard:" + shard;
✅ 128 把锁,分散 Redis Slot
✅ 同一用户串行,不同用户并行
3️⃣ Redisson 实现
public boolean tryLock(long userId) {
int shard = Math.abs(userId) % 128;
String lockKey = "lock:sku:" + skuId + ":shard:" + shard;
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(100, 500, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
七、完整下单流程(时序图式)
1. 网关令牌桶限流
2. 本地缓存判断“是否可能还有库存”
3. 获取 user_id 分片锁
4. Redis Lua 原子预扣
5. 发送 MQ 异步创建订单
6. 释放锁
八、失败与回滚设计(很重要)
1️⃣ 库存回补(MQ 消费失败)
-- 回补库存
redis.call("INCR", KEYS[1])
redis.call("SREM", KEYS[2], ARGV[1])
2️⃣ 订单状态机
|
状态 |
含义 |
|---|---|
|
INIT |
已扣库存 |
|
PAID |
已支付 |
|
CANCEL |
超时未支付 |
|
REFUND |
退款 |
九、压测结果(真实案例)
|
指标 |
优化前 |
优化后 |
|---|---|---|
|
峰值 QPS |
12w |
18w |
|
Redis CPU |
100% |
35% |
|
超卖 |
有 |
0 |
|
重复下单 |
有 |
0 |
|
响应 P99 |
2s |
80ms |
十、一句话总结
热门皮肤只有一个 SKU,真正的难点不是“扣库存”,而是“扛住热点”。
✅ 令牌桶拦洪峰
✅ 本地缓存挡 Redis
✅ Lua 脚本保原子
✅ 用户分片锁破热点
更多推荐

所有评论(0)