JAVA-实战8 Redis实战项目—雷神点评(6)秒杀优化

遠くへと広がる海の色暖かく

秒杀优化

判断优惠券库存以及一人一单都涉及到数据库操作,为了提高效率,将数据库操作优化为Redis操作。判断是否可以下单以及下单操作均在Redis中完成,而下单操作会存入异步阻塞队列中,后台服务器单开异步线程读取异步阻塞队列,完成数据库同步。示意图如下:
image

使用Redis维护每款优惠券库存,使用优惠券Redis中的Set结构维护每款优惠券已经被哪些用户下单,使用Lua脚本保证判断库存是否充足、判断一人一单、扣减库存、将当前用户Id存入Set等一系列操作的原子性。示意图如下:
image

改进秒杀业务

需求:

①新增秒杀优惠券的同时,将优惠券信息保存到Redis中
②基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
③如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
④开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

新增秒杀优惠券并保存到Redis

修改新增秒杀优惠券代码,新增添加Redis缓存添加操作

@Service
public class VoucherSecondKillServiceImpl implements VoucherSecondKillService {
    @Autowired
    private VoucherSecondKillMapper voucherSecondKillMapper;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void AddVoucherSecondKill(VoucherData NewVoucher) {
        VoucherSecondKillData NewVoucherSecondKillData = new VoucherSecondKillData();
        NewVoucherSecondKillData.setVoucherId(NewVoucher.getId());
        NewVoucherSecondKillData.setStock(NewVoucher.getStock());
        NewVoucherSecondKillData.setBeginTime(NewVoucher.getBeginTime());
        NewVoucherSecondKillData.setBeginTime(NewVoucher.getEndTime());
        voucherSecondKillMapper.insert(NewVoucherSecondKillData);
        // Redis缓存添加操作
        stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+NewVoucher.getId(),NewVoucher.getStock().toString());
    }
}

封装常量工具类

public class RedisConstants {
    public static final String SECKILL_STOCK_KEY = "seckill:stock:";
}

编写Lua脚本

Lua脚本执行行程图如下:
image

使用Redis中的sismember操作来判断key对应的set类型value是否存在相关成员

sismember key member

image
image

Lua脚本编写如下,其中..是Lua脚本的字符串拼接操作:

local VoucherId = ARGV[1]
local UserId = ARGV[2]

local StockKey = 'seckill:stock:' .. VoucherId
local OrderKey = 'seckill:order:' .. VoucherId

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

-- 判断用户是否下单
if(redis.call('sismember',OrderKey,UserId) ==1) then
    -- 用户存在说明重复下单,返回2
    return 2
end

-- Redis库存-1
redis.call('incrby',StockKey,-1)
-- Redis下单用户集合添加当前用户
redis.call('sadd',OrderKey,UserId)
-- 成功返回0
return 0;

服务层方法修改如下,其中使用stringRedisTemplate.execute方法调用Lua脚本执行,Objects.requireNonNull().intValue()进行类型转换:

@Slf4j
@Service
public class VoucherOrderServiceImpl implements VoucherOrderService {
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 使用静态代码块初始化DefaultRedisScript,添加执行Lua脚本路径
    private static final DefaultRedisScript<Long> SecondKill_SCRIPT;
    static {
        SecondKill_SCRIPT = new DefaultRedisScript<>();
        SecondKill_SCRIPT.setLocation(new ClassPathResource("Lua/SecondKill.lua"));
        SecondKill_SCRIPT.setResultType(Long.class);
    }

    @Override
    @Transactional
    public ResultData VoucherSecondKill(Long VoucherId) {
        Long UserId = Long.valueOf(CurrentHolder.getCurrent().getId());
        // 调用脚本
        int LuaResult = Objects.requireNonNull(stringRedisTemplate.execute(
                SecondKill_SCRIPT,
                Collections.emptyList(),
                VoucherId.toString(),
                String.valueOf(UserId)
        )).intValue();
        // 错误情况判断
        if(LuaResult==1) {
            return ResultData.error("Stock is not Enough!");
        } else if (LuaResult==2) {
            return ResultData.error("You have already purchased this voucher!");
        }
        // 新增订单记录
        long VoucherOrderId = redisIdWorker.NewId("Order");

        return ResultData.success();

    }
}

执行测试:

分别测试两种错误情况
image
image

查看Redis相关,库存减少,优惠券购买用户添加完成,新增下单记录
image
image
image

开启异步队列,封装下单信息

将下单信息封装成为实体类,存入异步队列中,使用BlockingQueue<T>声明阻塞队列,容量为\(1024^2\)(约100w作用)

private BlockingQueue<VoucherOrderData> OrderTasksQueue = new ArrayBlockingQueue<>(1024*1024);

@Override
@Transactional
public ResultData VoucherSecondKill(Long VoucherId) {
    Long UserId = Long.valueOf(CurrentHolder.getCurrent().getId());

    int LuaResult = Objects.requireNonNull(stringRedisTemplate.execute(
            SecondKill_SCRIPT,
            Collections.emptyList(),
            VoucherId.toString(),
            String.valueOf(UserId)
    )).intValue();

    if(LuaResult==1) {
        return ResultData.error("Stock is not Enough!");
    } else if (LuaResult==2) {
        return ResultData.error("You have already purchased this voucher!");
    }


    long VoucherOrderId = redisIdWorker.NewId("Order");
    AddNewVoucherOrderToQueue(VoucherId,VoucherOrderId);


    proxy = (VoucherOrderServiceImpl) AopContext.currentProxy();

    return ResultData.success("Now OrderId is "+VoucherOrderId);

}

@Override
@Transactional
public void AddNewVoucherOrderToQueue(Long VoucherId,Long VoucherOrderId) {
    Long UserId = Long.valueOf(CurrentHolder.getCurrent().getId());
    VoucherOrderData NewVoucherOrder = new VoucherOrderData();
    NewVoucherOrder.setId(VoucherOrderId);
    NewVoucherOrder.setUserId(UserId);
    NewVoucherOrder.setVoucherId(VoucherId);
    OrderTasksQueue.add(NewVoucherOrder);
}

开启线程任务,实现异步下单

这一步非常复杂

首先实现异步队列的读取:

使用Executors.newSingleThreadExecutor()初始化一步处理线程池SECONDKILL_ORDER_EXECUTOR;这是一个单线程线程池,内部只维护一个工作线程,其可以提供独立的执行环境,专门用于从队列中取出订单并执行耗时的数据库操作。

VoucherOrderHandler是内部私有类,实现了Runnable接口,封装了具体的异步消费逻辑,是实际执行数据库写入的操作者。

init()函数添加了@PostConstruct注解,确保在SpringBean初始化完成后立即执行;其作用是将VoucherOrderHandler实例提交给线程池。此时,后台线程启动并进入while(true)循环,开始监听队列。

private static final ExecutorService SECONDKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

@PostConstruct
private void init() {
    SECONDKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while(true) {
            try {
                VoucherOrderData NewVoucherOrder = OrderTasksQueue.take();
                AddVoucherOrderFromQueueToDatabase(NewVoucherOrder);
            } catch (InterruptedException e) {
                log.error("Calc Order Failed:",e);
            }

        }
    }
}

接下来实现提取出数据后存入数据库

private VoucherOrderService proxy;
@Override
public void AddVoucherOrderFromQueueToDatabase(VoucherOrderData NewVoucherOrder) {
    // 使用RedissonClient优化锁
    RLock lock = redissonClient.getLock(REDISSON_KEY_PREFIX+NewVoucherOrder.getUserId());
    boolean IsLock = lock.tryLock();
    if(!IsLock) {
        log.error("Add Database Get Lock Failed!");
    }
    try {

        proxy.CreateNewVoucherOrder(NewVoucherOrder);
    } finally {
        lock.unlock();
    }
}

@Override
@Transactional
public void CreateNewVoucherOrder(VoucherOrderData NewVoucherOrder) {
    Long VoucherId = NewVoucherOrder.getVoucherId();
    Long UserId = NewVoucherOrder.getUserId();

    QueryWrapper<VoucherOrderData> orderlwp = new QueryWrapper<VoucherOrderData>();
    orderlwp.eq("voucher_id",VoucherId).eq("user_id",UserId).select("count(*) as Number");
    List<Map<String,Object>> Result = voucherOrderMapper.selectMaps(orderlwp);
    Long Count = (Long) Result.get(0).get("Number");
    if(Count>0) {
        log.info("You have already purchased this voucher!");
    }

    LambdaUpdateWrapper<VoucherSecondKillData> lwp = new LambdaUpdateWrapper<VoucherSecondKillData>();
    lwp.eq(VoucherSecondKillData::getVoucherId,VoucherId);

    voucherSecondKillMapper.UpdateSecondKillVoucher(lwp,1L);

    voucherOrderMapper.insert(NewVoucherOrder);
}

其实际执行的流程为:

启动阶段‌:Spring容器初始化VoucherOrderServiceImpl完成后,执行@PostConstruct,启动SECONDKILL_ORDER_EXECUTOR中的 VoucherOrderHandler线程,该线程阻塞在orderTasks.take()处等待。

请求阶段:前端发起优惠券秒杀下单请求,在经过处理和校验后最终订单信息存入OrderTasksQueue队列

处理阶段:VoucherOrderHandler线程从OrderTasksQueue取出订单,获取分布式锁,获取成功则通过代理对象proxy调用CreateVoucherOrder方法执行数据库相关操作


Q:为什么proxy从之前的局部变量变成类成员变量

A:因为AopContext.currentProxy()强依赖于当前线程的ThreadLocal上下文。

使用局部变量声明,在‌主线程‌(如Controller调用的Service层方法)中获取proxy并传递给子线程使用,这是可行的,因为传递的是代理对象的‌引用‌;但如果试图在‌子线程内部‌直接调用AopContext.currentProxy(),通常会抛出IllegalStateException,因为子线程没有继承主线程的 AOP 上下文栈。

使用类成员变量声明,一旦在主线程初始化阶段(如@PostConstruct或首次请求时)将代理对象赋值给成员变量this.proxy,该引用就脱离了ThreadLocal的束缚。在异步线程(如秒杀队列消费者VoucherOrderHandler)中,直接使用this.proxy调用方法是‌安全且有效‌的。因为此时只是通过持有引用的普通Java对象调用方法,Spring 的事务拦截器(Interceptor)会根据方法调用触发,并在当前子线程中开启新事务,而不需要访问 AopContext的ThreadLocal。

‌因此,对于异步秒杀场景,‌成员变量方式优于在子线程中动态获取‌,它避免了子线程无法获取AOP上下文的问题。

值得注意的是,在异步秒杀场景中,‌推荐使用成员变量方式,但应避免使用AopContext.currentProxy()进行初始化‌,而是采用‌自我注入‌的方式,这样更稳健,确实但是抱歉我实在是不想写了

@Service
public class VoucherOrderServiceImpl implements VoucherOrderService {

    @Autowired
    private VoucherOrderService self; // Spring 会自动注入代理对象

    // ... 其他代码

    @Override
    public void AddVoucherOrderFromQueueToDatabase(VoucherOrderData NewVoucherOrder) {
        // ... 锁逻辑
        
        // 直接使用注入的代理对象,无需担心 ThreadLocal 上下文问题
        self.CreateNewVoucherOrder(NewVoucherOrder);
        
        // ... 解锁逻辑
    }
}


基于阻塞队列的异步秒杀存在的问题

①阻塞队列OrderTasksQueue基于内存实现,存在大小限制
②一旦出现环境异常(例如宕机),基于内存的Redis和阻塞队列数据全部丢失,从而引发数据安全问题

posted @ 2026-05-06 20:28  tcswuzb  阅读(21)  评论(0)    收藏  举报