Redis从入门到入土 --- 黑马点评判断秒杀资格

黑马点评秒杀优化实战:Redis判断秒杀资格 + 阻塞队列异步下单

今天整理一下黑马点评项目里我觉得非常经典的一块内容:秒杀优化模块 。 这部分的核心思路非常值得学习,因为它不是单纯实现一个"下单功能",而是在高并发场景下,解决了下面几个典型问题:

  • 库存超卖
  • 一人多单
  • 数据库被瞬时流量打垮
  • 接口响应慢

而这套优化方案里,最关键的两部分就是:

  1. Redis + Lua 脚本完成秒杀资格判断
  2. BlockingQueue 阻塞队列实现异步下单

这篇文章我就重点讲这两个方面


一、为什么秒杀场景下不能直接操作数据库?

先看最朴素的下单流程:

  1. 查询优惠券信息
  2. 判断秒杀是否开始/结束
  3. 查询库存是否充足
  4. 查询用户是否已经下过单
  5. 扣减库存
  6. 创建订单

这个流程在并发不高的时候没问题,但一旦到了秒杀场景(高并发),请求量会瞬间暴涨,问题就出来了:

1. 容易超卖

多个线程同时查询库存,发现都有库存,于是都去扣减,最后可能把库存扣成负数。

2. 容易一人多单

多个请求几乎同时查询"该用户是否下过单",结果都发现没下过,然后都创建订单。

3. 数据库压力太大

即使最后没抢到,很多请求也已经打到数据库了,数据库会承受巨大的无效流量。

所以秒杀优化的核心目标其实很明确:

把高频判断前移到 Redis,把真正慢的数据库操作异步化。


二、整体优化思路

1. 请求线程做什么?

请求线程只做两件事:

  • 用 Redis 判断用户是否具备秒杀资格
  • 如果具备资格,就把订单任务投递到阻塞队列

2. 后台线程做什么?

后台线程专门做真正的下单动作:

  • 从阻塞队列中取出订单任务
  • 执行数据库扣库存
  • 保存订单记录

也就是说:

前台负责"验资格",后台负责"真下单"。

这样一来,请求线程的执行时间会非常短,接口响应速度会明显提升。

整体流程如下图所示:


三、Redis 如何完成秒杀资格判断?

这一部分是整个秒杀优化最核心的地方。

1. Redis 中保存什么数据?

为了让 Redis 能快速完成判断,通常会提前保存两类数据:

库存

用一个 key 保存优惠券库存,例如:seckill:stock:{voucherId} value 就是库存数量。

已下单用户

用一个 Set 保存已经抢到券的用户 ID,例如:seckill:order:{voucherId}

Set 中存放的是所有已抢购成功的 userId。


2. 为什么要用 Lua 脚本?

判断秒杀资格需要做两步:

  1. 判断库存是否充足
  2. 判断用户是否已经下过单

这两步本质上都是 Redis 操作,但如果用 Java 分两次调用 Redis,就会存在原子性问题:两次操作之间可能被其他线程插入,导致并发漏洞。

Lua 脚本的核心价值:在 Redis 中原子性地执行多步操作。

Redis 执行 Lua 脚本是单线程的,整个脚本执行过程不会被打断,天然保证了原子性,完美解决并发问题。


3. Lua 脚本实现

lua 复制代码
-- seckill.lua
-- 参数说明:
-- KEYS[1]: 库存 key,如 seckill:stock:1
-- KEYS[2]: 订单 key,如 seckill:order:1
-- ARGV[1]: 用户 ID

-- 1. 判断库存是否充足
local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
    -- 库存不足,返回 1
    return 1
end

-- 2. 判断用户是否已经购买过
local isMember = redis.call('sismember', KEYS[2], ARGV[1])
if isMember == 1 then
    -- 用户已经购买过,返回 2
    return 2
end

-- 3. 两个条件都满足,扣减库存,记录用户
redis.call('incrby', KEYS[1], -1)
redis.call('sadd', KEYS[2], ARGV[1])

-- 返回 0 表示有购买资格
return 0

返回值含义:

返回值 含义
0 有资格,可以下单
1 库存不足
2 该用户已经购买过

4. Java 中如何调用 Lua 脚本?

首先在项目中加载 Lua 脚本(建议放在 resources 目录下):

java 复制代码
// 在 Service 类中,静态加载 Lua 脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    // 脚本路径在 classpath 下
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

然后在秒杀方法中调用:

VoucherOrderServiceImpl 复制代码
public Result seckillVoucher(Long voucherId) {
    // 获取用户ID
    Long userId = UserHolder.getUser().getId();
    // 1.执行Lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            userId.toString()
    );
    // 2.判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
        // 2.1.不为0,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 2.2.为0,代表有购买资格,把下单信息保存到阻塞队列
    VoucherOrder voucherOrder = new VoucherOrder();

    // 2.3.订单ID
    Long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 2.4.用户ID
    voucherOrder.setUserId(userId);
    // 2.5.代金券ID
    voucherOrder.setVoucherId(voucherId);
    // 2.6.创建阻塞队列
    orderTasks.add(voucherOrder);
    // 获取代理对象(事务)
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    // 3.返回订单ID
    return Result.ok(orderId);
}

小结: 到这一步,用户请求线程已经结束了,整个过程只有 Redis 操作,速度极快,数据库完全没有被碰到。


四、BlockingQueue 阻塞队列实现异步下单

1. 什么是阻塞队列?

BlockingQueue 是 Java 并发包中的一个接口,核心特点是:

  • 入队(put/offer) :队列满了会阻塞,直到有空间
  • 出队(take/poll) :队列空了会阻塞,直到有元素

在秒杀场景下,我们利用它的这个特性来做生产者-消费者模型:

  • 生产者:请求线程把订单信息放入队列
  • 消费者:后台线程不断从队列中取任务,执行数据库操作

2. 定义阻塞队列和后台线程

VoucherOrderServiceImpl 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>
        implements IVoucherOrderService {

    // 阻塞队列,容量设置为 1024 * 1024
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

    // 线程池,用于执行异步任务
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    // Spring 初始化完成后,启动后台线程
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 内部类:后台消费线程任务
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    // 从队列中取出订单信息(队列为空时会阻塞,不占用 CPU)
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 执行真正的数据库下单逻辑
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }

    // 真正操作数据库的方法
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        // 注意:此处仍然需要加分布式锁保证安全(兜底)
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        boolean isLock = lock.tryLock();
        if (!isLock) {
            log.error("不允许重复下单!");
            return;
        }
        try {
            // 通过代理对象调用,保证事务生效
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }

    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();

        // 兜底校验:数据库层面再次确认是否已下过单(防止极端情况)
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            log.error("用户已经购买过一次!");
            return;
        }

        // 扣减库存(使用乐观锁:stock > 0 作为条件)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)  // 乐观锁,防超卖
                .update();
        if (!success) {
            log.error("库存不足!");
            return;
        }

        // 保存订单
        save(voucherOrder);
    }
}

3. 为什么数据库操作里还要加分布式锁?

有人可能会问:Redis 的 Lua 脚本不是已经保证原子性了吗?为什么数据库操作里还要加锁?

这里其实是双重保险的设计思路:

层次 手段 作用
Redis 层 Lua 脚本原子操作 拦截 99% 的并发请求,快速判断资格
数据库层 分布式锁 + 乐观锁 兜底,防止极端情况下的数据不一致

Redis 判断资格虽然是原子的,但 Redis 和数据库之间的数据同步存在时间差。在极端情况下,Redis 中记录了购买成功但数据库还没落库,这时候数据库层的校验就是最后一道防线。


4. 关于代理对象调用的问题

handleVoucherOrder 方法中,调用的是 proxy.createVoucherOrder(voucherOrder) ,而不是 this.createVoucherOrder(voucherOrder)

这是因为:Spring 事务依赖 AOP 代理,如果直接用 this 调用同类方法,事务注解会失效。

需要提前获取代理对象并保存起来:

java 复制代码
// 在 seckillVoucher 方法中,提前获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 将 proxy 存成成员变量,供后台线程使用
this.proxy = proxy;

五、整体流程总结

到这里,整套秒杀优化方案就完整了,来做一个总结:

markdown 复制代码
秒杀开始前:
  将优惠券库存写入 Redis:SET seckill:stock:{id} 100
用户发起秒杀请求:
  ① 执行 Lua 脚本(原子操作):
     - 判断库存 > 0
     - 判断用户未购买过(SISMEMBER)
     - 扣减库存(INCRBY -1)
     - 记录用户(SADD)
  ② 有资格 → 生成订单ID → 投递到 BlockingQueue → 返回订单ID
  ③ 无资格 → 直接返回失败
后台线程(@PostConstruct 启动):
  ① 阻塞等待队列中的订单任务
  ② 加分布式锁(Redisson)
  ③ 操作数据库:扣库存 + 保存订单
  ④ 释放锁

这套方案的核心优势:

优化点 实现方式
防止超卖 Lua 脚本原子扣减 Redis 库存 + 数据库乐观锁兜底
防止一人多单 Lua 脚本 SISMEMBER 检查 + 分布式锁 + 数据库校验
减少数据库压力 请求线程只操作 Redis,数据库操作完全异步化
提升接口响应速度 请求线程执行时间 = Redis Lua 脚本耗时(通常 < 1ms)

总结

这套秒杀优化方案的设计思路其实可以用一句话概括:

用 Redis 的速度挡住高并发,用异步队列解耦耗时操作,用数据库兜底保证数据正确性。

三层各司其职,这也是高并发系统设计中非常经典的分层思路,值得好好消化。

相关推荐
兆子龙2 小时前
lodash 到 lodash-es 多的不仅仅是后缀!深入源码看 ES Module 带来的性能与体积优化
java·前端·架构
lisus20072 小时前
GO并发统计文件大小
开发语言·后端·golang
Memory_荒年2 小时前
限流算法:当你的系统变成“网红景点”,如何避免被游客挤垮?
java·后端
我命由我123452 小时前
Git 问题:Author identity unknown*** Please tell me who you are.
java·服务器·git·后端·学习·java-ee·学习方法
AskHarries2 小时前
网站被人疯狂爬了 1.5TB 流量
后端
Arya_aa2 小时前
java中的方法重写,重载,接口和抽象类
java
xiaoye37082 小时前
Spring 内置注解 和自定义注解的异同
java·后端·spring
低调小一2 小时前
OpenClaw 从安装到可用:把 Tools/Skills 变成“可控操控面板”,并用飞书做远程入口
java·大数据·人工智能·飞书·openclaw·clawbot·skil
CRMEB系统商城2 小时前
CRMEB标准版系统(PHP)v6.0公测版发布,商城主题市场上线~
java·开发语言·小程序·php