基于Stream消息队列的异步秒杀业务

前言

本文是笔者学习完黑马点评中秒杀接口后的总结,会梳理整个接口的设计,以及实现逻辑。但是受限于黑马教程,本接口不适用于Redis集群模式 部署,而且后续的异步请求使用了单线程的线程池,消费者组同样仅有一组,因此在性能上还有很大改进空间,生产环境下慎用本文的接口实现。

实现流程

1.根据优惠券ID得到优惠券信息 ->

2.执行Lua脚本 ->

3.开启新的线程处理消息 ->

4.更新数据库 -> over

Lua脚本

使用Lua脚本一次性完成对redis的原子操作,避免了线程安全问题。

java 复制代码
public static final DefaultRedisScript<Long> SECKILL_SCRIPT;  
static {  
    SECKILL_SCRIPT = new DefaultRedisScript<>();                      // 创建 Redis 脚本对象  
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));  // 指定脚本文件位置  
    SECKILL_SCRIPT.setResultType(Long.class);                         // 设置脚本返回值类型  
}  
  
  
/**  
 * 秒杀优惠券  
 * @param voucherId  
 * @return  
 */@Override  
public Result secKillVoucher(Long voucherId) {  
    //查询优惠券信息  
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);  
  
    //判断秒杀是否开始  
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){  
        return Result.fail("秒杀尚未开始");  
    }  
    //判断秒杀是否结束  
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){  
        return Result.fail("秒杀已经结束");  
    }  
    Long userId = UserHolder.getUser().getId();  
    long orderId = redisIdWorker.nextId("order");  
    //执行Lua脚本  
    Long result = stringRedisTemplate.execute(  
            SECKILL_SCRIPT,  
            Collections.emptyList(),                                           // KEYS(必须是List,使用singletonList创建一个只包含单个元素的不可变列表)  
            voucherId.toString(), userId.toString(), String.valueOf(orderId)   // ARGV(可变参数)  
    );  
    //判断结果是否为0(0有购买资格)  
    int r = result.intValue();  
    if (r != 0) {  
        return Result.fail(r == 1 ? "库存不足" : "没有购买资格");  
    }  
    return Result.ok(orderId);  
}

运行逻辑

  1. 根据voucherId拿到seckillVoucher实体,然后做前置判断,不通过直接返回fail。
  2. 获取脚本参数并运行脚本:
lua 复制代码
-- 1.参数列表  
-- 1.1.优惠券id  
local voucherId = ARGV[1]  
-- 1.2.用户id  
local userId = ARGV[2]  
-- 1.3.订单id  
local orderId = ARGV[3]  
  
-- 2.数据key  
-- 2.1.库存key  
local stockKey = 'seckill:stock:' .. voucherId  
-- 2.2.订单key  
local orderKey = 'seckill:order:' .. voucherId  
  
-- 3.脚本业务  
-- 3.1.判断库存是否充足 get stockKeyif(tonumber(redis.call('get', stockKey)) <= 0) then  
    -- 3.2.库存不足,返回1  
    return 1  
end  
-- 3.2.判断用户是否下单 SISMEMBER orderKey userIdif(redis.call('sismember', orderKey, userId) == 1) then  
    -- 3.3.存在,说明是重复下单,返回2  
    return 2  
end  
-- 3.4.扣库存 incrby stockKey -1redis.call('incrby', stockKey, -1)  
-- 3.5.下单(保存用户)sadd orderKey userId  
redis.call('sadd', orderKey, userId)  
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)  
return 0

脚本逻辑:库存不足返回1,用户已经下过一次订单返回2(确保一人一单)。库存充足且用户未下过单,符合下单条件,扣减Redis中的库存并将当前用户id加入指定set集合,最后写入消息队列并返回0。

  1. 根据脚本结果响应请求:
  • 1:库存不足
  • 2:没有购买资格
  • 0:有购买资格,直接返回ok,后续操作由其他线程完成。

注意事项

  • Lua脚本文件一般放在静态资源目录下,new ClassPathResource("seckill.lua")可在Resource目录下直接找到名为seckill.lua的脚本。
  • Lua脚本返回的值为数字,所以 Spring Data Redis 会将其映射为 Long 类型,可以使用intValue()方法将其转换为int类型。

异步处理

Lua脚本已经将符合条件的订单信息发送到了消息队列(需提前在Redis中创建名为stream.orders的Stream消息队列),接下来就可以在一个单线程的线程池中处理消息。优点是后续请求都会串行执行,规避了很多线程安全问题,缺点是性能受限。

java 复制代码
//创建线程池  
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
/**  
 * 启动后立即执行线程任务  
 */  
@PostConstruct//当前类初始化完毕后立即执行(提交线程任务)  
private void init(){  
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());  
}  
  
/**  
 * 线程任务的具体实现:读取消息队列中的订单信息  
 *  
 */private class VoucherOrderHandler implements Runnable{
	 @Override  
	public void run() {  
    while (true){
		消费消息...
		}
	}
 }

运行逻辑

  1. 声明一个单线程的线程池,后续线程都从这里获取。
  2. 声明具体的线程任务VoucherOrderHandler,该方法实现了Runnable接口,并将run方法重写为消费消息的逻辑,体现了面向函数编程思想。
  3. 声明初始化方法init,在方法体中提交线程任务。在方法上添加@PostConstruct注解,确保Spring初始化完成之后立即进行初始化,即程序启动时便可处理消息队列。
    处理消息队列的逻辑
typescript 复制代码
private class VoucherOrderHandler implements Runnable{  
    String queueName = "stream.orders";  
    @Override  
    public void run() {  
        while (true){  
            try {  
                //1.获取消息队列中的订单信息 xreadgroup group g1 ci count 1 block 2000 streams stream.order >                
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(  
                        Consumer.from("g1", "c1"),  
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),  
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())  
  
                );  
                //2.判断消息是否获取成功  
                if (list == null || list.isEmpty()) {  
                    //获取失败,没有消息,继续下一次循环  
                    continue;  
                }  
                //获取成功,可以下单  
                //3.解析消息队列中的数据  
                MapRecord<String, Object, Object> record = list.get(0);  
                Map<Object, Object> values = record.getValue();  
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);  
  
                try {  
                    // use proxy to ensure transactional proxy is applied  
                    proxy.createVoucherOrder(voucherOrder);  
                    // 只有在成功落库后才 ACK 确认消息  
                    stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());  
                } catch (Exception ex) {  
                    // 处理失败,不 ACK,保留在 pending 由重试处理  
                    log.error("下单处理失败,消息保留在 Pending 以重试 userId={}", voucherOrder.getUserId(), ex);  
                }  
            } catch (Exception e) {  
                //处理PendingList中的消息  
                handlePendingList();  
                log.error("处理订单异常", e);  
            }  
        }  
    }  
}
  1. 读取消息队列中的最新数据,(因为此处只有一个线程,所以只设计了一个消费者组,并写死了组名和消费者名)
  2. 若未读到数据,说明目前没有消息需要处理,直接continue进入下一次循环
  3. 若读到数据,则要对数据进行解析,封装为voucherOrder。
  4. 调用createVoucherOrder()方法,将订单保存到数据库:
less 复制代码
@Transactional  
@Override  
public void createVoucherOrder(VoucherOrder voucherOrder) {  
    Long voucherId = voucherOrder.getVoucherId();  
    //扣减库存  
    boolean success = seckillVoucherService.update()  
            .setSql("stock = stock - 1")  
            .eq("voucher_id", voucherId).gt("stock", 0)  
            .update();  
    if (!success){  
        log.error("库存不足");  
        // 抛出运行时异常以便调用方知道失败,且事务会回滚  
        throw new RuntimeException("库存不足");  
    }  
    save(voucherOrder);  
}
  1. 如果第4部执行时出现意外,则该处理中的消息不会被ACK确认,滞留在PendingList中,此时就会进入catch代码块中执行handlePendingList()方法(VoucherOrderHandler类的内部方法)来处理滞留在PendingList中的消息:
typescript 复制代码
    /**  
     * 处理PendingList中的异常消息  
     */  
    private void handlePendingList() {  
        while (true){  
            try {  
                //1.获取PendingList中的订单信息 xreadgroup group g1 ci count 1 block 2000 streams stream.order 0                
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(  
                        Consumer.from("g1", "c1"),  
                        StreamReadOptions.empty().count(1),  
                        StreamOffset.create(queueName, ReadOffset.from("0"))  
  
                );  
                //2.判断消息是否获取成功  
                if (list == null || list.isEmpty()) {  
                    //获取失败,PendingList没有异常消息,结束循环  
                    break;  
                }  
                //3.解析消息队列中的数据  
                MapRecord<String, Object, Object> record = list.get(0);  
                Map<Object, Object> values = record.getValue();  
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);  
                //获取成功,可以下单  
                try {  
                    proxy.createVoucherOrder(voucherOrder);  
                    stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());  
                } catch (Exception ex) {  
                    log.error("Pending 消息处理失败,保留继续重试 userId={}", voucherOrder.getUserId(), ex);  
                }  
            } catch (Exception e) {  
                log.error("处理PendingList异常", e);  
            }  
        }  
    }  

逻辑与处理正常消息时略有差别:

  • 读取的是未ACK的消息,如果读取不到消息则说明没有异常消息,直接break; 跳出循环。
  • 如果将PendingList中的消息加入数据库时依然出现异常(概率很小),可以记录错误日志,如果日后异常频发,再通过日志追踪解决问题。

注意事项

  • stringRedisTemplate.opsForStream().read()方法读取的结果是一个list集合,因为发送消息时可以发送多条消息,由于在Lua脚本中只发送了一条消息,我们确认得到的List集合只有一个元素,因此使用list.get(0)来获取数据并封装。
  • createVoucherOrder()带有事务注解@Transactional,要想该事务生效,必须使用代理对象,因此要使用
java 复制代码
@Autowired  
private IVoucherOrderService proxy;

注入proxy代理对象,并由代理对象调用createVoucherOrder()方法。

相关推荐
IT大白鼠2 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz2 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙3 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除3 天前
redis--018
redis
两锭子3 天前
Redis基础
redis
烟沙九洲3 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索3 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞3 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路3 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog3 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis