基于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()方法。

相关推荐
曲幽3 小时前
FastAPI + PostgreSQL 实战:给应用装上“缓存”和“日志”翅膀
redis·python·elasticsearch·postgresql·logging·fastapi·web·es·fastapi-cache
momo学习版1 天前
带你实现基于 Redis 的分布式 Session 管理
redis
JavaGuide5 天前
字节二面:Redis 能做消息队列吗?怎么实现?
redis·后端
漫霂5 天前
基于redis实现登录校验
redis·后端
程序员小崔日记5 天前
一篇文章彻底搞懂 MySQL 和 Redis:原理、区别、项目用法全解析(建议收藏)
redis·mysql·项目实战
读书笔记5 天前
CentOS 7 安装 redis-6.2.6.tar.gz 详细步骤(从源码编译到启动配置)
redis
焗猪扒饭6 天前
redis stream用作消息队列极速入门
redis·后端·go
雨中飘荡的记忆8 天前
大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案
java·redis·后端