高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障

举报
江南清风起 发表于 2025/07/19 18:15:17 2025/07/19
【摘要】 高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障 1. 背景与痛点:高并发秒杀场景到底难在哪?秒杀业务有三个“高”:高并发——瞬时 QPS 可达数十万,单点数据库极易被打挂。高一致性——不能超卖、不能少卖。高可用——不能因为某一台 Redis、某一段网络抖动就全站不可用。传统方案往往在第三步“一致性”与第四步“可用性”之间反复权衡:悲观锁(select … for upd...

高并发秒杀系统 Redis 优化:从分布式锁到库存扣减的原子性保障


1. 背景与痛点:高并发秒杀场景到底难在哪?

秒杀业务有三个“高”:

  1. 高并发——瞬时 QPS 可达数十万,单点数据库极易被打挂。
  2. 高一致性——不能超卖、不能少卖。
  3. 高可用——不能因为某一台 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

调优要点

  1. 关闭持久化(AOF/ RDB)或使用 appendfsync everysec;秒杀场景允许秒级数据丢失。
  2. 开启 pipeline:批量发送 Lua 脚本,减少 RTT。
  3. 网卡 IRQ 亲和:把 Redis 线程绑到 NUMA 本地 CPU,降低延迟。

8. 总结:无锁、Lua、异步,三位一体

  • 无锁:利用 Redis 单线程模型,把竞争收敛到内存。
  • Lua:把业务校验、库存扣减、幂等写在一个原子脚本里。
  • 异步:通过 MQ 最终落库,提升吞吐,保证高可用。

image.png

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。