黑马点评项目03——优惠券秒杀引来的血案(超卖问题,一人一单解决)分布式锁,Lua脚本

在这里插入图片描述

1、关于顾客下单的订单号

为了保证顾客下单订单号唯一性以及不暴露给顾客一些敏感信息(例如:第几个下单的),所以不能采用单纯自增的方式。
在这里插入图片描述
这里采用redis自增id策略,id为时间戳+计数器。
需要说明的是 在redis保存的key+计数器,注意,这里的值是计数器,并非id;
key是icr:order:2025:05:21 (当天下单时间),id是当前时间戳-设定的起始时间戳+自增count,一起合成id,这是代码

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 开始时间戳(2022-01-01 00:00:00 UTC 对应的秒数)
    private static final long BEGIN_TIMESTAMP = 1640995200L;

    // 序列号占 32 位(意味着每天最多支持 2^32 = 42亿个 ID)
    private static final int COUNT_BITS = 32;

    public long nextId(String keyPrefix) {
        // 第一步:当前时间戳(单位:秒)
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC); // 当前秒
        long timestamp = nowSecond - BEGIN_TIMESTAMP;       // 距离起始时间的秒数

        // 第二步:自增序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); // 例如 "2025:05:21"
        String redisKey = "icr:" + keyPrefix + ":" + date;                   // Redis key: icr:order:2025:05:21  一天一个key,便于统计订单量
        long count = stringRedisTemplate.opsForValue().increment(redisKey); // 使用 Redis 原子自增

        // 第三步:拼接返回
        // 高32位:timestamp;低32位:count  时间戳+自增id
        return (timestamp << COUNT_BITS) | count;
    }
}

2、秒杀下单

前端发送这样一个请求:
在这里插入图片描述

需要考虑库存+下单时间是否在优惠券有效期内
在这里插入图片描述
初始代码实现:

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        // 库存不足
        return Result.fail("库存不足!");
    }
    //5,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    return Result.ok(orderId);
}

不过存在一个问题,在多线程并发情况下,比如现在库存就剩8个了,来了18个线程同时判断库存是否充足,此时,都是充足的,都减去库存,结果库存出现-10这个负数,这就是超卖问题

3、超卖问题的解决之乐观锁

乐观锁:会有一个版本号每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。

第一步:数据库表中加一个 version 字段

你的 seckill_voucher 表结构应该如下:

ALTER TABLE seckill_voucher ADD COLUMN version INT DEFAULT 0;

第二步:查询时读取 stockversion

SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
Integer stock = voucher.getStock();
Integer version = voucher.getVersion();

第三步:使用版本号控制更新,只有在 version 没变的情况下,才允许更新

boolean success = seckillVoucherService.update()
        .set("stock", stock - 1)
        .set("version", version + 1)
        .eq("voucher_id", voucherId)
        .eq("version", version) // 乐观锁核心条件
        .update();

这句 SQL 会被翻译为:

UPDATE seckill_voucher
SET stock = stock - 1, version = version + 1
WHERE voucher_id = ? AND version = ?

只有在 当前版本号没被别的线程改动的前提下才会更新成功,从而防止并发情况下多个线程同时扣减。

// 1. 查询库存和版本
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher.getStock() < 1) {
    return Result.fail("库存不足");
}
Integer version = voucher.getVersion();

// 2. 乐观锁更新库存
boolean success = seckillVoucherService.update()
        .set("stock", voucher.getStock() - 1)
        .set("version", version + 1)
        .eq("voucher_id", voucherId)
        .eq("version", version)
        .update();

if (!success) {
    return Result.fail("没抢到");
}

// 3. 创建订单

当然乐观锁还有一些变种的处理方式比如CAS,不过可能出现ABA问题,当然咱们这个场景不会出现ABA问题,因为咱只有减,库存只有不会增加,当然你说,万一有人退单咋办,但是这里并不影响我们的业务,具体业务需要具体分析CAS导致的ABA问题。
这里扩展一下:
ABA 问题:某个线程读取了一个值 A,在更新前的检查中发现它还是 A,于是认为值没有变就放心地去修改了,但实际上这个值曾经被改成过 B 又改回了 A —— 这种中间状态的变化对当前线程是不可见的。用版本号方法可以解决CAS出现的ABA问题。
这里给出CAS解决方案就是:在更新数据据库中的数据再加上一个库存是否大于0的判断条件,从而避免超卖问题。

  boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update() ;
        // 秒杀  悲观锁和乐观锁(版本号法(不会出现ABA问题)和CAS法(可能出现ABA问题)),秒杀不会发生ABA问题,因为库存只会减,不会增
        if(!success)
        {
            log.error("库存不足");
        }

不过,乐观锁,基于数据库实现,高并发数据库压力大,适合并发量中低的场景,不适合 “秒杀” 这种高并发场景。 而且乐观锁只能解决写写冲突( 两个线程同时修改同一份数据,可能相互覆盖)。

线程并发冲突时,可能的解决方案有:
在这里插入图片描述

4、一人一单问题

现在要求每人只能下一单,修改业务逻辑初步为:

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        // 库存不足
        return Result.fail("库存不足!");
    }
    // 5.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    //6,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);

    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);

}

假设,一个人开一个挂,编写一个脚本,也变成了一个人的高并发问题,如果这些线程同时到达判断订单表是否存在关于这个用户和该商品id的订单,结果发现都没有,这又会引起一个用户重复下单问题。
怎么解决呢?有人说,不是刚刚学习了乐观锁吗,用它,用它,用它啊!!!

令人遗憾的是,先查询是否有,再插入的逻辑,乐观锁根本无法控制,只有那种更新数据库同一条数据的时候才可以用乐观锁并发控制。

行吧,到这里我们使用悲观锁解决,就是说在查询是否已下单和建立订单数据同时进行即可,当然,你可以在之前再判断一下库存是否充足,这样就通过悲观锁解决了超卖问题,不过这里用乐观锁解决超卖问题。

插个题外话,在高并发下,数据库唯一索引可以杜绝重复下单问题,这里我们没有选择这种方式解决。

5、一人一单问题悲观锁解决

解决方案有synchronized、setnx机制锁、Redisson分布式锁、Redis+Lua脚本+队列异步下单
在这里插入图片描述

5.1 synchronized锁细节

synchronized锁一般不用,因为它是基于JVM,当存在多服务器的时候,多个JVM,就失效了,不过我们需要研究其实现的细节。

 public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠劵

        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 如果查不到结果(即没有匹配的记录),这个方法会直接返回 null。
        if (voucher == null) {
            return  Result.fail("优惠券不存在");
        }

        // 2.判断秒杀是否开始
        if(voucher.getBeginTime().isAfter(LocalDateTime.now()))
        {
            return Result.fail("秒杀还没有开始");
        }
        // 3. 判断秒杀是否已经结束
        if(voucher.getEndTime().isBefore(LocalDateTime.now()))
        {
            return Result.fail("秒杀已经结束");
        }
        // 4.判断库存是否存在
        if(voucher.getStock()<1)
        {
            return  Result.fail("库存不足");
        }

        UserDTO user = UserHolder.getUser();
        Long userId = user.getId();

        //1. 本地上锁
        // 在单服务器(本地)可以使用这种方式进行上锁
      synchronized (userId.toString().intern())
        {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
}

 @Transactional()
    public  Result createVoucherOrder(Long voucherId)
    {
        // 5.一个人只能抢购一个优惠券
        UserDTO user = UserHolder.getUser();
        Long userId = user.getId();

        // .count不会返回null,也可以用int来接
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count>0)
        {
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        /* 相当于
        UPDATE seckill_voucher
        SET stock = stock - 1
        WHERE voucher_id = #{voucherId}
        */
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update() ;
        // 秒杀  悲观锁和乐观锁(版本号法(不会出现ABA问题)和CAS法(可能出现ABA问题)),秒杀不会发生ABA问题,因为库存只会减,不会增
        if(!success)
        {
            return Result.fail("库存不足");
        }

        // 7.创建订单  订单表
        VoucherOrder voucherOrder = new VoucherOrder();

        // 7.1 订单id
        long voucherOrderId = redisIdWorker.nextId("voucherOrder");//传入的是前缀
        voucherOrder.setId(voucherOrderId);

        // 7.2 用户id  拦截器获取

       /* if(user==null)
        {
            return  Result.fail("用户没有登陆,无法抢购优惠劵");
        }*/  // 也可以在拦截器中实现 如果使用拦截器进行了登陆验证,就不用判null,因为肯定有啦,此处这里实现
        voucherOrder.setUserId(user.getId());

        // 7.3 代金券id
        voucherOrder.setVoucherId(voucherId);

        save(voucherOrder);

        // 8.返回订单id
        return Result.ok(voucherOrderId);

    }

解析:
1、synchronized (userId.toString().intern()) 的作用是给每个用户加一把“本地互斥锁”,防止同一个用户同时发起多个抢购请求。.intern() 会让 Java 把这个字符串放进 字符串常量池,所以 相同的 userId(比如 123)会得到相同的锁对象,保证同一个用户加的是同一把锁。

2、@Transactional 为什么要加?
本质作用:保证“扣减库存”和“创建订单”两个操作要么都成功,要么都失败,保持业务的一致性。

3、为什么使用代理对象执行事务方法?

IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);

这是为了让 @Transactional 生效!
原因如下:

Spring 的 @Transactional 是通过 AOP代理 实现的。

如果你在 同一个类内部直接调用自己类中的 @Transactional 方法,它会绕过代理,事务不会生效。
正确做法:用 AopContext.currentProxy() 获取当前的代理对象,再去调用目标方法,事务才能被 Spring 拦截并生效。

4、锁要加在事务外面,保证事务提交后才解开锁!!!

5.2 setnx机制实现锁(少用)

虽然少用,但是帮助我们理解原理!!!

 @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            // 库存不足
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //创建锁对象(新增代码)
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //获取锁对象
        boolean isLock = lock.tryLock(1200);
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

细节:
order: + userId 作为key,线程 id 作为值,保证只有一个用户能拿到锁。

存在的问题:锁误删问题
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况。
解决方案:删除锁的时候,判断是否为自己的,不是自己的不删除。

一个细节,在存入Redis线程ID是一个JVM唯一前缀+线程·ID,保证不同JVM相同线程id相同的时候也能保持不同,同一个JVM,前缀是相同的!!!如何实现呢??? 用static final 实现,在类加载到虚拟机时就进行初始化,保证了一个JVM有一个前缀ID,你可能会问了万一另外一台服务器的生成的前缀和他相同咋办,哈哈,几乎不可能,不需要考虑哈。

 private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
 public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        // 可能返回是null,这里要加一个拆箱判断,如果是true,返回true;如果是false或者不存在,返回false
        return Boolean.TRUE.equals(success);
    }
       public void unlock() {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

现在出现一个极端情况:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的。

   public void unlock() {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 此时锁到期,同一个用户拿到锁后,此时,执行后面方法,结果又会出误删。原因是获取、判断和删除没有同时进行
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

所以如何确保同时执行一系列的redis语句,Lua脚本孕育而生!!

5.3 Lua脚本的使用(实现释放锁的原子性)

package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    // 释放锁的Lua脚本
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    // Lua脚本静态初始化
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        // 可能返回是null,这里要加一个拆箱判断,如果是true,返回true;如果是false或者不存在,返回false
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
    /*@Override
    public void unlock() {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 此时锁到期,同一个用户拿到锁后,此时,执行后面方法,结果又会出误删。原因是获取、判断和删除没有同时进行
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }*/
}

lua脚本:

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

扩展:
如果多个key和value

List<String> keys = Arrays.asList("lock:order:1", "lock:order:2");
Object[] args = new Object[] { "UUID-1", "UUID-2" };

stringRedisTemplate.execute(
    LUA_SCRIPT,
    keys,
    args
)

如果你使用的是 Redisson 提供的分布式锁(如 RLock),就不需要你再手动用 Lua 脚本解锁了。Redisson 会自动帮你处理锁释放、线程标识比对、原子性、安全性等一整套机制!!!所以,有点白雪,不过理解加深了!!!

5.4 Redisson分布式锁

基本使用:

RLock lock = redissonClient.getLock("lock:order:123");
lock.lock(); // 加锁
try {
    // 业务逻辑
} finally {
    lock.unlock(); // 自动比对标识 + 释放
}

一人一单问题代码:

@Resource
private RedissonClient redissonClient;

@Override
public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            // 库存不足
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
        //SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //获取锁对象
        boolean isLock = lock.tryLock();
       
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
 }

5.5 Redisson锁+Lua脚本+队列异步下单

总体思路:
事先将订单信息存放在Redis中,商品ID(key)+库存量(value),当用户下单之后,判断库存是否充足只需要根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,并将商品ID(key)+用户ID(value) 存放在Set集合中,整个过程需要保证是原子性的,我们可以使用lua来操作。
队列选择: 阻塞队列,Redis的Stream流队列,中间件(Kafka、RocketMQ),阻塞队列不推荐!!!
以下是对比:
在这里插入图片描述
OK,基于Stream流实现伪代码,大家主要是品尝这个过程!!!

1、Lua脚本

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

-- 2.判断用户是否下过单
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    return 2  -- 重复下单
end

-- 3.扣减库存 & 记录用户
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])

-- 4.写入Stream队列
redis.call('xadd', KEYS[3], '*',
  'userId', ARGV[1],
  'voucherId', ARGV[2],
  'orderId', ARGV[3])

return 0  -- 成功

简要说明,
xadd: 向Redis Stream添加数据结构的命令,
KEYS[3]:表示Stream队列的名称
‘*’:表示让Redis自动生成消息ID(时间戳-序列号格式)
后面是三个键值对,构成消息内容:
‘userId’, ARGV[1]:用户ID,值来自参数数组第1个元素
‘voucherId’, ARGV[2]:优惠券ID,值来自参数数组第2个元素
‘orderId’, ARGV[3]:订单ID,值来自参数数组第3个元素
2、秒杀接口逻辑

@Override
public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");

    // 执行 Lua 脚本(库存key、订单记录key、消息流)
    Long result = stringRedisTemplate.execute(
        unlockScript,
        Arrays.asList(
            "seckill:stock:" + voucherId,
            "seckill:order:" + voucherId,
            "stream.orders"
        ),
        userId.toString(), voucherId.toString(), String.valueOf(orderId)
    );

    if (result == 1L) return Result.fail("库存不足!");
    if (result == 2L) return Result.fail("不能重复下单!");
    return Result.ok(orderId); // 异步处理
}

3、异步处理订单的线程

@Scheduled(fixedDelay = 1000)
public void handleVoucherOrder() {
    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream()
        .read(Consumer.from("group1", "consumer1"),
              StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
              StreamOffset.create("stream.orders", ReadOffset.lastConsumed()));

    if (list == null || list.isEmpty()) return;

    for (MapRecord<String, Object, Object> record : list) {
        Map<Object, Object> values = record.getValue();
        Long userId = Long.valueOf(values.get("userId").toString());
        Long voucherId = Long.valueOf(values.get("voucherId").toString());
        Long orderId = Long.valueOf(values.get("orderId").toString());

        try {
            createOrder(voucherId, userId, orderId);
            stringRedisTemplate.opsForStream().acknowledge("stream.orders", "group1", record.getId());
        } catch (Exception e) {
            log.error("处理订单异常", e);
            // 可选:进入 dead letter 或重新投递
        }
    }
}

补充:
Pending 列表 是 Redis Stream 消费组中,已经被某个消费者读取但尚未确认(ack) 的消息集合。
也叫做:PEL(Pending Entries List)或 待确认消息队列!
发回ack,消息会从Pending列表中移除。

4、异步线程后台,减库存它没写

@Transactional
public void createOrder(Long voucherId, Long userId, Long orderId) {
    // 再次保证幂等(可加唯一索引 user_id+voucher_id)
    int count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
                              .eq(VoucherOrder::getVoucherId, voucherId).count();
    if (count > 0) return;

    // 写入数据库
    VoucherOrder order = new VoucherOrder();
    order.setId(orderId);
    order.setUserId(userId);
    order.setVoucherId(voucherId);
    save(order);
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值