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

27 阅读8分钟

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

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

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

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

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

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


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

先看最朴素的下单流程:

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

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

1. 容易超卖

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

image.png

2. 容易一人多单

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

image.png

3. 数据库压力太大

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

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

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


二、整体优化思路

1. 请求线程做什么?

请求线程只做两件事:

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

2. 后台线程做什么?

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

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

也就是说:

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

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

整体流程如下图所示:

image.png


三、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 脚本实现

-- 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 目录下):

// 在 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);
}

然后在秒杀方法中调用:

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. 定义阻塞队列和后台线程

@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 调用同类方法,事务注解会失效。

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

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

五、整体流程总结

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

image.png

image.png

这套方案的核心优势:

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

总结

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

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

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