高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障
【摘要】 高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障 1. 背景与痛点:高并发秒杀场景到底难在哪?秒杀业务有三个“高”:高并发——瞬时 QPS 可达数十万,单点数据库极易被打挂。高一致性——不能超卖、不能少卖。高可用——不能因为某一台 Redis、某一段网络抖动就全站不可用。传统方案往往在第三步“一致性”与第四步“可用性”之间反复权衡:悲观锁(select … for upd...
高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障
1. 背景与痛点:高并发秒杀场景到底难在哪?
秒杀业务有三个“高”:
- 高并发——瞬时 QPS 可达数十万,单点数据库极易被打挂。
- 高一致性——不能超卖、不能少卖。
- 高可用——不能因为某一台 Redis、某一段网络抖动就全站不可用。
传统方案往往在第三步“一致性”与第四步“可用性”之间反复权衡:
- 悲观锁(select … for update):一致性高,但 TPS 极低。
- 乐观锁(版本号 / CAS):在高冲突场景下重试率爆炸。
- 分布式锁(Redisson、ZooKeeper):锁粒度粗,一旦宕机或网络分区就面临“羊群效应”。
本文将沿着“锁 → 原子命令 → Lua 脚本 → 异步队列”的演进路线,给出可落地的 Redis 优化方案,并附完整代码。
2. 架构总览:四层降级模型
层级 | 目标 | 技术选型 | 降级预案 |
---|---|---|---|
L0 前端 | 过滤 90% 刷子流量 | CDN/验证码/本地缓存 | JS 限频 |
L1 接入层 | 万级 QPS 以内 | Nginx+Lua 共享字典 | 返回静态“已售罄”页 |
L2 缓存层 | 十万级 QPS | Redis Cluster + Lua 原子扣减 | 异步消息队列兜底 |
L3 存储层 | 最终一致性 | MySQL binlog 异步落库 | 人工补单 |
本文重点落位在 L2 缓存层,用 Redis 保证“不超卖”且“不低卖”。
3. 分布式锁的三种实现与对比
3.1 最简实现:SETNX + EXPIRE
// V1:最原始,两条命令非原子
String lockKey = "seckill:lock:sku123";
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, 5, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(ok)) {
return "排队中,请稍后重试";
}
try {
doBusiness();
} finally {
// 误删风险:A 的锁被 B 删掉
if (uuid.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
问题:SETNX 与 EXPIRE 非原子;误删;不支持可重入。
3.2 Redisson 可重入锁(基于 Hash + Lua)
RLock lock = redissonClient.getLock("seckill:lock:sku123");
if (!lock.tryLock(0, 5, TimeUnit.SECONDS)) {
return "系统繁忙";
}
try {
doBusiness();
} finally {
lock.unlock();
}
优点:可重入、看门狗自动续期;缺点:重量级、单 key 热点。
3.3 RedLock(多实例)
RedissonRedLock redLock = new RedissonRedLock(
redissonClient1.getLock("sku123"),
redissonClient2.getLock("sku123"),
redissonClient3.getLock("sku123"));
优点:容忍 n/2+1 节点故障;缺点:实现复杂,争议大(Martin Kleppmann 论文)。
结论:锁方案在高并发下仍会遇到“羊群效应”——100 w 请求同时抢一把锁,99.99% 的请求都在空转。因此需要进一步把“锁”变成“无锁”的原子操作。
4. 库存扣减的演进:从 INCR 到 Lua 原子脚本
4.1 朴素方案:先查后改
Integer stock = (Integer) redisTemplate.opsForValue().get("seckill:stock:sku123");
if (stock != null && stock > 0) {
redisTemplate.opsForValue().decrement("seckill:stock:sku123");
// 下单成功
}
致命缺陷:并发下产生“库存竞态”。
4.2 原子方案一:DECR 返回值判断
Long remain = redisTemplate.opsForValue().decrement("seckill:stock:sku123");
if (remain < 0) {
// 超卖回滚
redisTemplate.opsForValue().increment("seckill:stock:sku123");
return "已售罄";
}
优点:单命令原子;缺点:仍需一次回滚,且无法处理“一人一单”等业务规则。
4.3 原子方案二:Lua 脚本(推荐)
把“校验规则 + 库存扣减 + 幂等判重”封装在一段 Lua 里,Redis 会把它当一条命令执行。
Lua 脚本(seckill.lua)
-- KEYS[1] 库存key KEYS[2] 已购集合key
-- ARGV[1] 购买数量 ARGV[2] 用户id
local stockKey = KEYS[1]
local boughtKey = KEYS[2]
local quantity = tonumber(ARGV[1])
local userId = ARGV[2]
-- 1. 重复下单校验
if redis.call('SISMEMBER', boughtKey, userId) == 1 then
return -2 -- 重复
end
-- 2. 库存校验
local remain = tonumber(redis.call('GET', stockKey))
if (not remain) or remain < quantity then
return -1 -- 售罄
end
-- 3. 扣减库存 & 记录用户
redis.call('DECRBY', stockKey, quantity)
redis.call('SADD', boughtKey, userId)
return remain - quantity
Java 调用
private final String LUA_SHA;
@PostConstruct
public void init() {
String script = new String(Files.readAllBytes(
Paths.get("src/main/resources/lua/seckill.lua")), UTF_8);
LUA_SHA = redisTemplate.execute((RedisCallback<String>)
conn -> conn.scriptLoad(script.getBytes()));
}
public Long secKill(String sku, Integer quantity, Long userId) {
return redisTemplate.execute(
(RedisConnection conn) -> {
Object res = conn.evalSha(LUA_SHA, ReturnType.INTEGER, 2,
("seckill:stock:" + sku).getBytes(),
("seckill:bought:" + sku).getBytes(),
quantity.toString().getBytes(),
userId.toString().getBytes());
return (Long) res;
});
}
测试结果:单机 Redis 10 w QPS 下,脚本平均 RTT 0.3 ms,错误率 0。
5. 异步落库与最终一致性
5.1 Canal 监听 binlog 异步写 MySQL
- 订单表采用 分库分表 + 雪花 ID。
- Canal 投递到 Kafka,Consumer 幂等写库。
5.2 库存对账补偿
- 每 30 s 扫描 Redis 与 MySQL 库存差异,触发补偿任务。
- 补偿任务使用
SET key value XX
保证幂等。
6. 高可用保障:Redis Cluster + 降级策略
6.1 三副本 Cluster 拓扑
┌-------------┐ ┌-------------┐
│ Master-1 │◄------►│ Slave-1 │
└-------------┘ └-------------┘
▲ ▲
│ 双 AZ 专线 │
▼ ▼
┌-------------┐ ┌-------------┐
│ Master-2 │◄------►│ Slave-2 │
└-------------┘ └-------------┘
- 故障转移使用官方
redis-trib.rb
一键完成。 - 客户端配置
spring.redis.cluster.max-redirects=3
。
6.2 降级开关
@SentinelResource(value = "seckill", fallback = "seckillFallback")
public String seckill(Long sku, Long userId) {
if (degradeSwitch.get()) {
return "当前活动火爆,请稍后重试";
}
Long remain = secKill(sku, 1, userId);
if (remain >= 0) {
sendToKafka(sku, userId);
return "success";
}
return "soldOut";
}
public String seckillFallback(Long sku, Long userId, BlockException ex) {
return "系统繁忙,请稍后重试";
}
7. 压测报告与调优心得
场景 | 单机 QPS | 平均 RT | P99 RT | 错误率 |
---|---|---|---|---|
分布式锁 | 3,200 | 31 ms | 120 ms | 0.1% |
DECR 原子 | 45,000 | 0.7 ms | 2 ms | 0 |
Lua 脚本 | 98,000 | 0.3 ms | 1 ms | 0 |
调优要点:
- 关闭持久化(AOF/ RDB)或使用
appendfsync everysec
;秒杀场景允许秒级数据丢失。 - 开启 pipeline:批量发送 Lua 脚本,减少 RTT。
- 网卡 IRQ 亲和:把 Redis 线程绑到 NUMA 本地 CPU,降低延迟。
8. 总结:无锁、Lua、异步,三位一体
- 无锁:利用 Redis 单线程模型,把竞争收敛到内存。
- Lua:把业务校验、库存扣减、幂等写在一个原子脚本里。
- 异步:通过 MQ 最终落库,提升吞吐,保证高可用。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)