优惠卷秒杀
1.全局唯一ID
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性。
为了增加ID的安全性,我们不直接使用redis的自增的数值,而是拼接时间戳和序列号(就是redis在一个键上一直自增出来的,最大是2的64次方),第一位时符号位。
redis实现全局唯一ID
//redis实现全局唯一ID
@Component
public class RedisIdWorder {
//2023年7月19日16点15分的时间戳
private static final long BEGIN_TIMESTAMP = 1689783300L;
//序列号长度
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
//传入业务,生成id
public long nextId(String keyPrefix){
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
//将当前时间根据时区转换成秒数
long timestamp = now.toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
// 2.生成序列号
//获取当前日期的格式化,并按redis分层,方便统计每一天的订单量
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//在指定key上一直自增,并把自增的数作为序列号,自增标识+业务+日期
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接,由于long是64位,我们第一位是符号位,不管,只要是正数就行,接着是31位的时间戳,剩下32位是序列号
// 我们在二进制上把时间戳往前移动32位然后或运算序列号,就可以拼接起来了
return timestamp << COUNT_BITS | count;
}
}
全局唯一ID生成策略
2.实现优惠卷秒杀下单
优惠卷中与普通优惠卷,也有秒杀优惠卷,点击秒杀优惠卷,会把优惠卷id传给我们,由于优惠卷主键id是是秒杀卷voucherId的外键,所以我们根据秒杀卷voucherId查询秒杀卷完成相应业务。
//传入秒杀优惠卷id,抢购
@Override
@Transactional//涉及两张表的操作,添加事务
public Result secKillVoucher(Long voucherId) {
// 1. 查询秒杀优惠卷
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断优惠卷秒杀时间是否开始、是否结束
// 2.1 秒杀未开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀未开始!");
}
// 2.1 秒杀已结束
if (LocalDateTime.now().isAfter(voucher.getEndTime())){
return Result.fail("秒杀已结束!");
}
// 3. 查询库存是否充足
// 3.1 库存不充足
if (voucher.getStock()<1){
return Result.fail("库存不足!");
}
// 3.2 库存充足
// 3.2.1 扣减库存
boolean success = seckillVoucherService.lambdaUpdate().setSql("stock = stock - 1")
.eq(SeckillVoucher::getVoucherId, voucherId).update();
// 3.2.1.1 扣减库存失败
if (!success) {
return Result.fail("库存不足!");
}
// 3.2.2 优惠卷下单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorder.nextId("order");//生成订单id
Long userId = UserHolder.getUser().getId();//获取用户id
voucherOrder.setId(orderId);//设置订单id
voucherOrder.setUserId(userId);//设置用户id
voucherOrder.setVoucherId(voucherId);//设置优惠卷id
boolean save = save(voucherOrder);//保存订单
// 3.2.2.1 保存订单失败
if (!save) {
return Result.fail("下单失败!");
}
// 4. 返回订单id
return Result.ok(orderId);
}
3.超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的解决方案就是加锁,锁有悲观锁和乐观锁。
乐观锁有两种方案(思想类似):1.版本号法。2.CAS法
扣减库存时添加乐观锁
// 3.2.1 扣减库存
boolean success = seckillVoucherService.lambdaUpdate()
.setSql("stock = stock - 1")
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock,0)//添加乐观锁,保证线程安全问题
.update();
4.一人一单
- 这个只能在单体应用实现,如果部署应用集群,由于多个jvm导致锁无法唯一,会失败,集群需要分布式锁。
- 每个jvm维护了一个锁监视器,当某个线程拿到锁,jvm就会把该线程名放入锁监视器,其他线程就无法获得锁。
- 添加依赖,需要获取优惠卷订单的代理对象,这样事务才能生效
<!--aop包-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
- 启动类暴露代理对象,方便获取
@EnableAspectJAutoProxy(exposeProxy = true)//暴露代理对象,方便我们获取
- 添加逻辑判断一人一单(乐观锁)
//根据用户id和优惠卷id查询订单数
Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
.eq(VoucherOrder::getVoucherId, voucherId).count();
// 订单数大于0,返回异常
if (count > 0) {
return Result.fail("一人限购一单!");
}
- 先提交事务然后释放锁(锁用户),解决多线程并发问题(悲观锁)。
//传入秒杀优惠卷id,抢购
@Override
public Result secKillVoucher(Long voucherId) {
// 1. 查询秒杀优惠卷
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断优惠卷秒杀时间是否开始、是否结束
// 2.1 秒杀未开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀未开始!");
}
// 2.1 秒杀已结束
if (LocalDateTime.now().isAfter(voucher.getEndTime())){
return Result.fail("秒杀已结束!");
}
// 3. 查询库存是否充足
// 3.1 库存不充足
if (voucher.getStock()<1){
return Result.fail("库存不足!");
}
// 一人一单,根据用户id和优惠卷id查询订单
Long userId = UserHolder.getUser().getId();//获取用户id
//先提交事务,再释放锁,避免查询不一致问题
//悲观锁,锁用户,相同的用户id只能拿到一把锁,加了intern()可以确保在常量池里拿,只要有值相等的用户id,就拿不到锁
synchronized (userId.toString().intern()){
//获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
}
}
@Transactional//涉及两张表的操作,添加事务
public Result createVoucherOrder(Long voucherId, Long userId) {
Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
.eq(VoucherOrder::getVoucherId, voucherId).count();
// 订单数大于0,返回异常
if (count > 0) {
return Result.fail("一人限购一单!");
}
// 3.2 库存充足
// 3.2.1 扣减库存
boolean success = seckillVoucherService.lambdaUpdate()
.setSql("stock = stock - 1")
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock,0)//添加乐观锁,保证线程安全问题
.update();
// 3.2.1.1 扣减库存失败
if (!success) {
return Result.fail("库存不足!");
}
// 3.2.2 优惠卷下单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorder.nextId("order");//生成订单id
voucherOrder.setId(orderId);//设置订单id
voucherOrder.setUserId(userId);//设置用户id
voucherOrder.setVoucherId(voucherId);//设置优惠卷id
boolean save = save(voucherOrder);//保存订单
// 3.2.2.1 保存订单失败
if (!save) {
return Result.fail("下单失败!");
}
// 4. 返回订单id
return Result.ok(orderId);
}
5.分布式锁
一、概念
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
一般分布式锁具有这些特征:多进程可见、互斥、高可用、高性能、安全性等
二、基于redis实现分布式锁基础代码
分布式锁接口
//分布式锁接口(可以采用不同的技术来实现分布式锁)
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
分布式锁接口实现类
//基于redis实现分布式锁(原理:使用setnx设置的键只能设置一次)
public class SimpleRedisLock implements ILock{
private String name;//使用分布式锁的业务名称,也就是待设置的键
private StringRedisTemplate stringRedisTemplate;//操作redis
private static final String KEY_PREFIX = "lock:";//拼接前缀名
//实现类由外部调用者传入必要参数
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//获取锁
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
long threadId = Thread.currentThread().getId();
//设置互斥值(相同的键只能设置一次)
Boolean ifAbsent = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);//设置键值,以及超时剔除
//如果ifAbsent为null,自动拆箱时会发生空指针异常,所以我们使用这种方式来判断
return Boolean.TRUE.equals(ifAbsent);
}
//释放锁
@Override
public void unlock() {
//直接删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
使用分布式锁代替synchronized锁
//获取锁对象(钥匙带上用户id,相当于只锁相同用户的id)
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean islock = simpleRedisLock.tryLock(1200);//这里超时剔除时间为1200秒,方便我们打断点调试
//获取失败
if (!islock) {
return Result.fail("一个用户只能购买一单");
}
/**
* 基于redis实现基础版的分布式锁,用户订单数大于零就无法下单,
* 在多线程并发访问情况下,一瞬间打大量线程查询到订单数没有大于零,然后都执行下单业务,这样明显不行。
* 所以我们使用悲观锁,当一个相同用户线程来时,就获取锁,然后相同用户其他线程就获取锁失败。
* 即使后面释放锁了,相同用户又来下单,也没事,因为订单数已经大于零了
* 锁住同一个用户的多个线程,只允许一个线程成功,实现一人一单
*/
//先提交事务,然后释放锁(这样当用户下单了,相同用户就无法下单了)
try {
//获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
} finally {
//无论成功与否,最终都会释放锁
simpleRedisLock.unlock();
}
三、使用线程标识解决分布式锁误删问题
问题原因:一个线程阻塞了,锁由于超时剔除,其他线程获取到了锁,这个时候原来线程又醒了,手动释放锁但是这个时候锁不是自己的了,就把别人的锁给释放了,然后就导致其他线程获取到了锁
- 获取分布式锁时设置线程标识
......
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";//每个线程生成一个uuid来标识,解决误删锁的情况
......
//获取锁
@Override
public boolean tryLock(long timeoutSec) {
// 拼接线程标识UUID+线程id(只用线程id不行,会发生误删锁的情况,每个jvm里的线程id都是各自递增的,有几率出现相同的线程id)
String threadId = ID_PREFIX + Thread.currentThread().getId();
//设置互斥值(相同的键只能设置一次)
Boolean ifAbsent = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);//设置键值,以及超时剔除
......
}
- 释放分布式锁时检查标识
//释放锁(释放锁之前需要根据线程标识来判断是不是自己的锁)
@Override
public void unlock() {
//取出原来的线程标识
String threadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//拼接自己线程的线程标识
String threadId2 = ID_PREFIX + Thread.currentThread().getId();
//线程标识相同的情况下,说明时自己的锁,释放锁,反之不是自己的锁,不做任何处理
if (threadId.equals(threadId2)){
//直接删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
四、使用Lua脚本解决多条命令原子性问题
-
由于判断线程标识和释放锁不具有原子性,分布式锁依然有误删问题
-
问题原因:一个线程执行完业务,判断完线程标识是自己的,准备释放锁时,线程阻塞了,这个时候其他线程获取到了锁,然后原来线程又恢复了,由于已经判断了线程标识,所以可以直接释放锁,但是这个时候锁已经不是自己的了,又发生误删问题。
Lua脚本改写业务逻辑(判断线程标识+释放锁)
-- lua脚本,保证redis中这两个操作的原子性
-- 1.判断线程标识是否相等
-- 2.释放锁
-- 获取key
local key = KEYS[1]
-- 获取原来的线程标识
local threadId = ARGV[1]
-- 比较标识是否相等
-- 标识相等,释放锁,成功返回1
if (redis.call('get', key) == threadId) then
return redis.call('del', key)
end
-- 标识不相等,返回0
return 0
代码改动
//基于redis实现分布式锁(原理:使用setnx设置的键只能设置一次)
public class SimpleRedisLock implements ILock {
......
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;//lua脚本对象,泛型是返回值类型
//静态变量在静态代码块里初始化
static {
UNLOCK_SCRIPT = new DefaultRedisScript();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));//加载Lua脚本
UNLOCK_SCRIPT.setResultType(Long.class);//设置返回值类型
}
......
//释放锁(释放锁之前需要根据线程标识来判断是不是自己的锁)
@Override
public void unlock() {
//lua脚本检验是不是自己的锁,如果是就释放,两个操作具有原子性
stringRedisTemplate.execute(
UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
到此,这个分布式锁基本可以用于生产环境。
五、基于Redis分布式锁的优化
虽然咱们上面实现的分布式锁可以基本使用,但是在不同的业务场景下,依然有一些问题,比如:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但是如果业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主中的锁数据,则会出现锁失效。
考虑这些业务场景,我们手动实现比较困难,所以我们可以采用组件Redisson。
一、Redisson简介
Redisson不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,它就是一个集成了很多分布式解决方案的一个框架,比如分布式锁等。
二、Redisson入门
导入依赖
<!--使用Redisson实现分布式锁-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.22.1</version>
</dependency>
编写配置类
//Redisson配置类
@Configuration
public class RedissonConfig {
//创建一个Redisson客户端,后续使用分布式锁,都使用这个客户端
@Bean
public RedissonClient redissonClient(){
// 创建配置对象
Config config = new Config();
// 设置配置参数
config.useSingleServer().setAddress("redis://localhost:6379").setPassword("123456");
// 创建Redisson客户端
return Redisson.create(config);
}
}
获取锁对象时,使用Redisson获取分布式锁,不在获取我们自定义的分布式锁。
//使用Redisson获取分布式锁对象
RLock lock = redissonClient.getLock("order:" + userId);
//获取锁
boolean islock = lock.tryLock();//采用无参构造,默认失败不重试,30超时剔除
三、Redisson可重入锁原理
- Redisson无参那个锁就是可重入的。采用无参构造,默认失败不重试,看门狗30超时剔除。
- 使用哈希结构+计数(获取锁加一,释放锁减一)。
四、Redisson的锁重试和WatchDog机制
- 传入等待时间,就可以重试,使用了看门狗刷新锁的过期时间。
五、Redisson的multiLock原理
-
连锁,多个Redis主节点,然后每个主节点各自有一些从节点,相当于多个主从集群,提高了可用性。
-
获取锁必须每个Redis主节点都拿到锁,才能获取成功,解决主从集群锁丢失问题。
6.Redis优化秒杀
一、优化思路
-
先利用Redis完成库存余量、一人一单判断,完成抢单业务
-
再将下单业务放入阻塞队列,利用独立线程异步下单
二、基于阻塞队列的异步秒杀存在哪些问题?
内存限制问题
数据安全问题
三、代码实现
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
//保存优惠卷信息到redis中(键是秒杀库存前缀+优惠卷id,值是优惠卷库存)
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 编写lua脚本
-- 秒杀优惠卷下单脚本
-- 1.获取参数
-- 获取优惠卷id
local voucherId = ARGV[1]
-- 获取用户id
local userId = ARGV[2]
-- 2.判断是否有下单资格
-- 根据优惠卷id查询优惠卷库存是否充足(redis中的键值对都是String类型)
if (tonumber(redis.call('get', 'seckill:stock:' .. voucherId)) <= 0) then
-- 库存不足
return 1
end
-- 根据优惠劵id的集合判断是否含有用户id
if (redis.call('sismember', 'seckill:order:' .. voucherId, userId) == 1) then
-- 已经购买过
return 2
end
-- 3.可以下单购买
-- 扣减库存
redis.call('incrby', 'seckill:stock:' .. voucherId, -1)
-- 加入用户id
redis.call('sadd', 'seckill:order:' .. voucherId, userId)
return 0
-
如果抢购成功,将优惠券id和用户id封装成订单后存入阻塞队列
-
开启异步线程任务,不断从阻塞队列中获取信息,实现异步下单功能
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorder redisIdWorder;
@Resource
private RedissonClient redissonClient;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;//lua脚本对象,泛型是返回值类型
//静态变量在静态代码块里初始化
static {
SECKILL_SCRIPT = new DefaultRedisScript();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));//加载Lua脚本
SECKILL_SCRIPT.setResultType(Long.class);//设置返回值类型
}
//初始化一个阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
//创建一个单线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//这个注解标记的方法会在类初始化完成后执行
@PostConstruct
public void init() {
SECKILL_ORDER_EXECUTOR.submit(new voucherOrderHandler());//类初始化完成后,线程提交任务
}
//内部类实现Runnable接口,定义任务
private class voucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1. 获取订单信息
//一直取队列中的订单,如果没有订单会阻塞在这里
VoucherOrder voucherOrder = orderTasks.take();
// 2. 创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常!", e);
}
}
}
}
//异步创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//由于异步,所以无法从原来线程获取用户id
Long userId = voucherOrder.getUserId();
//使用Redisson获取分布式锁对象
RLock lock = redissonClient.getLock("order:" + userId);
//获取锁
boolean islock = lock.tryLock();//采用无参构造,默认失败不重试,30超时剔除
//获取失败
if (!islock) {
log.error("一个用户只能购买一单!");
return;
}
/**
* 基于redis实现基础版的分布式锁,用户订单数大于零就无法下单,
* 在多线程并发访问情况下,一瞬间打大量线程查询到订单数没有大于零,然后都执行下单业务,这样明显不行。
* 所以我们使用悲观锁,当一个相同用户线程来时,就获取锁,然后相同用户其他线程就获取锁失败。
* 即使后面释放锁了,相同用户又来下单,也没事,因为订单数已经大于零了
* 锁住同一个用户的多个线程,只允许一个线程成功,实现一人一单
*/
//先提交事务,然后释放锁(这样当用户下单了,相同用户就无法下单了)
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
//无论成功与否,最终都会释放锁
lock.unlock();
}
}
//代理对象提成成员变量,方便异步线程获取
IVoucherOrderService proxy;
//异步秒杀优惠卷
@Override
public Result secKillVoucher(Long voucherId) {
// 1.获取用户id
Long userId = UserHolder.getUser().getId();
// 2.执行lua脚本
Long res = stringRedisTemplate.execute(
//redis中的键值对都是String类型,所以使用lua脚本查询时也需要字符串,所以我们要传递字符串
SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString()
);
// 3.判断是否含有下单资格
// 没有资格
if (res != 0) {
return Result.fail(res == 1 ? "库存不足" : "一人限购一单");
}
// 4. 有下单资格,成功就将订单信息加入阻塞队列
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorder.nextId("order");// 生成订单id
voucherOrder.setId(orderId);//设置订单id
voucherOrder.setUserId(userId);//设置用户id
voucherOrder.setVoucherId(voucherId);//设置优惠卷id
//添加订单到阻塞队列
orderTasks.add(voucherOrder);
//获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 5.返回订单id
return Result.ok(orderId);
}
@Transactional//涉及两张表的操作,添加事务
public void createVoucherOrder(VoucherOrder voucherOrder) {
//获取订单id
Long voucherId = voucherOrder.getVoucherId();
// 3.2 库存充足
// 3.2.1 扣减库存
boolean success = seckillVoucherService.lambdaUpdate()
.setSql("stock = stock - 1")
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0)//添加乐观锁,保证线程安全问题
.update();
// 3.2.1.1 扣减库存失败
if (!success) {
log.error("库存不足!");
return ;
}
// 3.2.2 优惠卷下单
boolean save = save(voucherOrder);//保存订单
// 3.2.2.1 保存订单失败
if (!save) {
log.error("下单失败!");
return ;
}
}
}
7.Redis消息队列实现异步秒杀
一、消息队列简介和redis实现消息队列的方式
二、基于List实现消息队列
三、基于Redis的PubSub的消息队列
四、比较完善的消息队列模型Stream
单消费模式
- 发送消息
- 读取消息
消费者组模式
-
消费者组简介
消费者读取消息后,队列中消息就没了,都存入pending-list中了,消息处于pending状态,都会把消息存入pending-list中,如果后续我们读取了消息,但是没有确认,说明发生异常,我们可以去pending-list中重新读取,然后需要我们确认消费ack,然后消息才会从pending-list中移除。
-
创建消费者组
-
读取消息
-
特点
基于Stream的消息队列实现异步秒杀
-
创建队列。
xgroup create stream.orders g1 0 mkstream
-
修改Lua脚本逻辑,获取订单id参数,发送订单消息到消息队列。
-- 秒杀优惠卷下单脚本 -- 1.获取参数 -- 获取优惠卷id local voucherId = ARGV[1] -- 获取用户id local userId = ARGV[2] -- 获取订单id local orderId = ARGV[3] -- 2.判断是否有下单资格 -- 根据优惠卷id查询优惠卷库存是否充足(redis中的键值对都是String类型) if (tonumber(redis.call('get', 'seckill:stock:' .. voucherId)) <= 0) then -- 库存不足 return 1 end -- 根据优惠劵id的集合判断是否含有用户id if (redis.call('sismember', 'seckill:order:' .. voucherId, userId) == 1) then -- 已经购买过 return 2 end -- 3.可以下单购买 -- 扣减库存 redis.call('incrby', 'seckill:stock:' .. voucherId, -1) -- 加入用户id redis.call('sadd', 'seckill:order:' .. voucherId, userId) -- 发送订单消息到消息队列 redis.call('xadd','stream.orders','*','id',orderId,'userId',userId,'voucherId',voucherId) return 0
-
执行lua脚本,传递参数。
//异步秒杀优惠卷 @Override public Result secKillVoucher(Long voucherId) { // 1.获取用户id Long userId = UserHolder.getUser().getId(); // 生成订单id Long orderId = redisIdWorder.nextId("order"); // 2.执行lua脚本 Long res = stringRedisTemplate.execute( //redis中的键值对都是String类型,所以使用lua脚本查询时也需要字符串,所以我们要传递字符串 SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(),orderId.toString() ); // 3.判断是否含有下单资格 // 没有资格 if (res != 0) { return Result.fail(res == 1 ? "库存不足" : "一人限购一单"); } //获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效 proxy = (IVoucherOrderService) AopContext.currentProxy(); // 5.返回订单id return Result.ok(orderId); }
-
异步调用线程获取订单消息下单,完善业务逻辑
//内部类实现Runnable接口,定义任务 private class voucherOrderHandler implements Runnable { @Override public void run() { while (true) { try { // 1. 从队列中读取消息 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. 获取订单对象,创建订单 //由于我们知道每次只读取1条消息,所以直接取第一个,record的string是消息id,Object是消息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> map = record.getValue();//获取订单信息 //map转对象,忽略异常,因为如果发生异常,我们后续可以在pending-list中再次处理 VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true); handleVoucherOrder(voucherOrder); // 4. ACK确认 stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); } catch (Exception e) { log.error("处理订单异常!", e); //读取消息过程中发生异常,消息没有确认,从pending-list中重新获取下单 handdlePendingList(); } } } //内部类,处理订单异常 private void handdlePendingList() { while (true) { try { // 1. 从pending-list队列中读取消息,指定0,不需要阻塞 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( //指定消费者组和消费者 Consumer.from("g1", "c1"), //指定消费参数,每次读一条,阻塞两秒 StreamReadOptions.empty().count(1), //指定在哪个队列读取消息,从最近一次未消费的消息开始读取 StreamOffset.create(queueName, ReadOffset.from("0"))//指定0读取pending-list的数据 ); // 2. 没有异常消息,直接退出 if (list == null || list.isEmpty()) { break; } // 3. 获取订单对象,创建订单 //由于我们知道每次只读取1条消息,所以直接取第一个,record的string是消息id,Object是消息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> map = record.getValue();//获取订单信息 //map转对象,忽略异常,因为如果发生异常,我们后续可以在pending-list中再次处理 VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true); handleVoucherOrder(voucherOrder); // 4. ACK确认 stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); } catch (Exception e) { log.error("处理订单异常!", e); //休眠20毫秒,防止太频繁 try { Thread.sleep(20); } catch (InterruptedException interruptedException) { interruptedException.printStackTrace(); } } } } }