Redis除了做缓存,还能做什么?
- [登录授权]【Redis应用】基于Redis实现短信登录 - 掘金 (juejin.cn):Redis做短信登录,共享session应用
- 排行榜:基于redis的list实现点赞列表,基于redis的SortedSet实现点赞排行榜
- [秒杀系统]【Redis应用】基于redis实现秒杀 - 掘金 (juejin.cn):Redis实现计数器,分布式锁,消息队列实现秒杀效果
- [缓存]【Redis应用】基于Redis实现缓存 - 掘金 (juejin.cn):解决并发是三种问题
- 粉丝关注:redis的set数据结构 【Redis应用】基于Redis实现排行榜,点赞,关注功能 - 掘金 (juejin.cn)
- 定位:redis中的GEO结构
- 用户签到:BitMAp
- UV统计:HyperLogLog的统计功能 【Redis应用】基于redis实现定位,签到,统计等功能 - 掘金 (juejin.cn)
前置(全局ID的实现)
对于订单表,我们的ID通常不采用自增的行为,主要是自增的ID是有序的,用户容易在其中获取到规律。那么一般对于订单表的ID,我们采用全局ID生成器。
全局ID生成器:(应满足的特点)
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
根据上面的特点,我们就可以想到我们的redis了,redis的集群实现了高可用,基于内存实现高性能,又能保证唯一,和递增,不过对于安全性,我们不能直接使用Redis自增的数值,而是拼接一些其他信息。
@Component
public class RedisIDWorker {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP = 1672531200L;
private static final int COUNT_BIT= 32;
public long nextId(String keyPrefix){
// 生成时间错
long END_TIMESTAMP = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
long timestamp = END_TIMESTAMP - BEGIN_TIMESTAMP;
// 生成序列号
// 获取当前日期,精确到天
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment(RedisConstants.ID_INCREMENT_KEY + keyPrefix + ":" + date);
// 拼接
return timestamp << COUNT_BIT | count;
}
}
引入秒杀下单实战案例
两种情况:(优惠券的类型)
- 平价券(无数量限制)
- 特价券(有库存数量限制)
针对不同的类型我们采用不同方案,我们的秒杀思想就是针对特价券而言的。
案例:实现优惠券秒杀的下单功能需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
思路流程:
暂时无法在飞书文档外展示此内容
一个基础的用户抢购下单流程是这样子的,在没有经过任何处理的时候,我们使用jmeter模拟多线程,进行压测,可以发现会在这个汇总变中,明显可以看见不是所有请求都成功的。
回到数据库看一下,数据库的库存出现了负数,这是一个典型的超卖问题。
超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。
- 悲观锁:会认为线程一定是不安全的,所以每次操作数据的时候都会获取锁,(锁:Synchronized,Lock都属于悲观锁)
- 乐观锁:认为线程安全不一定会发生,不会加锁,只是在更新数据时去判断有没有其他线程对数据做修改,没有修改则安全,自己更新数据,如果被其他线程修改,说明发生安全问题,此时可以重试或异常。
- 分布式锁(redis):
乐观锁
添加一个版本号,在这里我们可以使用CAS,去判断版本号,在更新前判断当前库存数量和修改前库存数量是否相等 但是现在写一下其实我们在查询的时候只需要保证库存大于0的要求就欧克了。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.根据优惠券id查询优惠券信息
SeckillVoucher seckillVoucher = seckillVoucherService.lambdaQuery().eq(voucherId != null, SeckillVoucher::getVoucherId, voucherId).one();
if (seckillVoucher == null){
return Result.fail(SystemConstants.VOUCHER_NOT_EXIST);
}
// 2.判断优惠券开始时间和结束时间与当前时间是否有效
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now()) || seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail(SystemConstants.VOUCHER_ORDER_TIME_NOT_LEGALLY);
}
// 3.有效 可获取库存 判断优惠券库存是否充足
if (seckillVoucher.getStock() <= 0){
// 不充足 返回
return Result.fail(SystemConstants.STOCK_UN_ENOUGH);
}
// 5.充足 则进行减库存 todo 多线程并发会引发线程安全问题
boolean success = seckillVoucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0).update();
if (!success){
return Result.fail(SystemConstants.STOCK_UN_ENOUGH);
}
// 6.减库存成功生成订单 ==> 使用到用户id,商品id
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIDWorker.nextId("order"));
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
}
一人一单问题(单机)
我们在抢购的场景下,其实我们经常遇到的一个现象就是一个用户对抢购的商品只能抢购一单,那么我们就需要将上面的流程图改一下
暂时无法在飞书文档外展示此内容
@Override
public Result seckillVoucher(Long voucherId) {
// 1.根据优惠券id查询优惠券信息
SeckillVoucher seckillVoucher = seckillVoucherService.lambdaQuery().eq(voucherId != null, SeckillVoucher::getVoucherId, voucherId).one();
if (seckillVoucher == null){
return Result.fail(SystemConstants.VOUCHER_NOT_EXIST);
}
// 2.判断优惠券开始时间和结束时间与当前时间是否有效
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now()) || seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail(SystemConstants.VOUCHER_ORDER_TIME_NOT_LEGALLY);
}
// 3.有效 可获取库存 判断优惠券库存是否充足
if (seckillVoucher.getStock() <= 0){
// 不充足 返回
return Result.fail(SystemConstants.STOCK_UN_ENOUGH);
}
return createVoucherOrder(voucherId, seckillVoucher);
}
@Transactional
public synchronized Result createVoucherOrder(Long voucherId, SeckillVoucher seckillVoucher) {
// 判断是否属于一人一单
Long count = lambdaQuery().eq(VoucherOrder::getUserId, UserHolder.getUser().getId()).eq(VoucherOrder::getVoucherId, seckillVoucher.getVoucherId()).count();
if (count > 0){
return Result.fail(SystemConstants.VOUCHER_NOT_RE_BUY);
}
// 5.充足 则进行减库存 todo 多线程并发会引发线程安全问题
boolean success = seckillVoucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0).update();
if (!success){
return Result.fail(SystemConstants.STOCK_UN_ENOUGH);
}
// 6.减库存成功生成订单 ==> 使用到用户id,商品id
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIDWorker.nextId("order"));
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
save(voucherOrder);
// 7.返回订单id
return Result.ok(voucherOrder.getId());
}
优化一下上面的写法,我们知道synchronized是同步锁,对性能的影响是比较大的,所以一般我们是不会把它加在方法上的,而是尽量减小他的粒度。使用代码块的形式。
我们在想,我们的一人一单并不是每一次请求都需要判断的,而是只有是同一用户的情况下才需要去判断。那意思就是我们只需要对用户的id进行加锁
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()){
return createVoucherOrder(voucherId, seckillVoucher);
}
注意:这里我们用到了事务,其实就要注意是否会出现事务失效的问题了
我们知道spring中事务的生效与AOP息息相关。其实就是做了一个代理,拿到了事务代理对象,去做一个事务处理。
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService) AopContext. currentProxy () ;
return proxy.createVoucherOrder(voucherId, seckillVoucher);
}
使用前,我们需要做两件事:添加依赖坐标,启动了添加暴露注解
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication
一人一单的并发安全问题
我们模拟开启两个服务,进行集群处理,使用nginx进行负载均衡,然后我们可以发现,尽管我们使用了synchronized加锁,但是仍然会出现线程安全的问题。
这是为什么呢?
在单机情况下,所有的线程都在JVM中,由同一个锁监听器进行监控。但是在集群并发的情况下,会有多个JVM,每个JVM都会有属于自己的锁监听器,所以同一用户,在不同服务器下同时发送请求。就会出现多个线程并行,每个锁获取不同线程,所以我们需要想办法让多个JVM使用同一把锁
分布式锁(⭐⭐⭐)
定义:满足分布式系统或集群模式下多进程可见并且互斥的锁
要求:1.多进程可见(共享),2.互斥,3。高可用 4.高性能 5.安全性 6.。。。
| MySQL | Redis | ZooKeeper | |
|---|---|---|---|
| 互斥 | 自身互斥锁 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
| 高可用 | 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接,自动释放锁 | 设置过期机制 | 临时节点,断开连接自动释放 |
基于Redis实现分布式锁
- 获取锁:setnx
- 释放锁:删除key , (为防止服务宕机,出现死锁情况,设置过期时间)
- 为防止服务宕机发送在获取锁和设置超时时间之间,导致锁无法及时释放,而引发的死锁。我们需要保证这两个操作的原子性
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
private static final String KEY_PREFIX = RedisConstants.LOCK_KEY_PREFIX;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().threadId();
// 尝试获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
基于上面的锁工具,我们重新优化一下一人一单并发问题
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, RedisConstants.LOCK_ORDER_PREFIX_KEY + userId);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
return Result.fail(SystemConstants.VOUCHER_NOT_RE_BUY);
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, seckillVoucher);
}finally {
lock.unlock();
}
但是这并不能完全解决并发带来的安全问题,我们知道我们对redis锁的释放有两种方式,一种是自动释放,一种是超时释放。那么如果在业务中出现了阻塞,且这个阻塞时间大于超时时间那么在线程1还在阻塞未完成的时候就会发送超时锁释放,那么在线程2来的时候就又回去创建锁,那么当线程1执行完成后就可能会释放掉线程2的锁,但是他们其实并不知道。看下面的图就会一目了然了。
要解决这一问题,我们在释放锁的时候应该去判断一下锁的标识,从而去避免误伤。
解决误删问题
改进redis分布式锁中因为阻塞带来的误删锁问题:
-
在获取锁的时候给锁加一个标识(可以使用线程id,也可以使用uuid)
-
在释放锁的时候先获取锁的标识,去判断当前锁的标识与原来是否一致
- 如果一致,则释放锁
- 如果不一致,则不释放锁
private static final String KEY_PREFIX = RedisConstants.LOCK_KEY_PREFIX;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().threadId();
// 尝试获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId , timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().threadId();
// 获取锁中标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断线程标识释放一致
if (threadId.equals(id)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
实现原子性【引入Lua脚本】
尽管我们前面添加了线程标识,但是还是会出现锁误删的情况,这又是怎么回事呢?主要原因就是我们的判断锁标识和删除锁并不是原则性的,可能第一次判断锁标识的时候是一致的,但是如果在删除锁的时候发生了阻塞,导致锁过期,那么我们真正删除的锁可能就不是原来的锁了。如下图:
Lua脚本的基础概念
前置知识:
redis提供了lua脚本的功能,可以实现操作的原子性。参考资料www.runoob.com/lua/lua-tut…
redis中提供了什么方式给我们写lua脚本?
- redis中提供了call() 方法的方式,我们可以使用call方法去调用函数。
redis中是如何执行lua脚本的?
- redis中采用eval关键字去执行我们的脚本,
- EVAL “return redis.call('set','name','zhangshan')" 0
- 注意,我们的脚本实际上是一个字符串,所以我们需要用一个双引号引起来作为脚本的内容
- 后面的0,其实就是参数的数量,这里表示无参数
redis中写lua脚本,如何不把参数写死:
- 如果脚本中的key,value不想写死,可以作为参数传递,key类型的参数会放入keys数组,其他参数会放入argv数组,在脚本中可以从keys和argv数组获取这些参数
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name wangwu
-- 2.判断是否与指定的标识一致
if (redis.call('get',KEYS[1]) == ARGV[1]) then
-- 3.一致则释放锁
return redis.call('del',KEYS[1])
end
-- 4。不一致则什么都不做
return 0;
java整合redis基于lua脚本实现分布式锁
RedisTemplate 调用Lua脚本的API是execute
- 创建一个lua脚本,编写lua脚本
- 引入lua脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
- 执行lua脚本
@Override
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX+Thread.currentThread().threadId());
}
Redisson优化分布式锁
基于setnx实现的分布式锁存在一些问题:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就会返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时比较长,也会导致锁释放,存在安全隐患
- 主从一致性:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
Redisson基础使用
- 引入坐标依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.6</version>
</dependency>
- 配置Redisson客户端
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://101.34.56.15:40670").setPassword("Poi-+852456");
return Redisson.create(config);
}
}
- 测试使用Redisson的分布式锁
@Autowired
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLogk");
// 尝试获取锁,参数分别时:获取锁的最大等待时间(期间会发生重试),锁自动释放时间,时间单位
boolean islock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断释放获取成功
if (islock) {
try {
System.out.println("执行任务");
} finally {
lock.unlock();
}
}
}
异步秒杀
基于JDK的阻塞队列实现异步秒杀
改进秒杀业务(异步),提高并发性能
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到redis中
- 基于Lua脚本,判断秒杀库存,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
- 修改新增优惠券
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到redis中
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
- 基于Lua脚本,判断秒杀库存,决定用户是否抢购成功
-- 1.参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1];
-- 1.2 用户id
local userId = ARGV[2];
-- 2.数据key
-- 2.1 库存key lua脚本中使用 .. 拼接
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
if tonumber(redis.call('get', stockKey)) <= 0 then
--库存key不足
return 1
end
-- 3.2 判断用户是否下单 SISMEMBER orderKey userId
if redis.call('sismember', orderKey, userId) == 1 then
-- 3.3 存在,说明是重复下单,返回2
return 2
end
-- 3.4 扣减库存
redis.call('incrby',stockKey,-1)
-- 3.5 下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列(执行lua脚本)
补充:什么是阻塞队列:
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里获取元素的线程。
当阻塞队列不可用时,会有四种相应的处理方式:
- 返回特殊值:插入元素时,会返回是否插入成功,成功返回true。如果是移除方法,则是从队列中取出一个元素,没有则返回null。
- 一直阻塞:当阻塞队列满时,如果生产者线程往队列里面put元素,则生产者线程会被阻塞,知道队列不满或者响应中断退出。当队列为空时,如果消费者线程从队列里take元素。
- 超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定时间,生产者线程就会退出。
在这里阻塞队列存放的是所有等待下单的商品信息,那么我们的其他用户就回去这个阻塞队列中获取信息。
// 阻塞队列
private ArrayBlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
// 采用异步优化
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString());
// 2。判断结果是否为0
int r = result.intValue();
if (r != 0){
return Result.fail(r == 1?SystemConstants.STOCK_UN_ENOUGH:SystemConstants.VOUCHER_NOT_RE_BUY);
}
// 为 0 有购买资格,把下单信息保存到阻塞队列
// 封装订单信息
VoucherOrder voucherOrder = new VoucherOrder();
// 生成订单id
long orderId = redisIDWorker.nextId("order");
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
// todo 下单信息保存到阻塞队列
orderTasks.add(voucherOrder);
return Result.ok(orderId);
}
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 判断是否属于一人一单
Long count = lambdaQuery().eq(VoucherOrder::getUserId, userId).eq(VoucherOrder::getVoucherId, voucherOrder.getVoucherId()).count();
if (count > 0){
return ;
}
// 5.充足 则进行减库存 todo 多线程并发会引发线程安全问题
boolean success = seckillVoucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock",0).update();
if (!success){
return ;
}
// 6.减库存成功生成订单 ==> 使用到用户id,商品id
save(voucherOrder);
}
// 创建线程池
private final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct // spring初始化完毕就执行
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true) {
// 1.获取队列中的订单信息
try {
VoucherOrder voucherOrder = orderTasks.take();
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error(SystemConstants.ORDER_HANDLE_FAIL);
}
}
}
}
private Result handleVoucherOrder(VoucherOrder voucherOrder) {
// 创建锁对象
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock(RedisConstants.LOCK_KEY_PREFIX + RedisConstants.LOCK_ORDER_PREFIX_KEY + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error(SystemConstants.VOUCHER_NOT_RE_BUY);
return null;
}
try {
proxy.createVoucherOrder(voucherOrder);
}finally {
lock.unlock();
}
return Result.ok();
}
IVoucherOrderService proxy = null;
// 采用异步优化
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString());
// 2。判断结果是否为0
int r = result.intValue();
if (r != 0){
return Result.fail(r == 1?SystemConstants.STOCK_UN_ENOUGH:SystemConstants.VOUCHER_NOT_RE_BUY);
}
// 为 0 有购买资格,把下单信息保存到阻塞队列
// 封装订单信息
VoucherOrder voucherOrder = new VoucherOrder();
// 生成订单id
long orderId = redisIDWorker.nextId("order");
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
// todo 下单信息保存到阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}
总结:
秒杀业务的优化思路是什么? ===》 将同步改异步
先利用redis完成库存余量,一人一单判断,完成抢单业务
再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题 (大量数据并发,超出jvm内存上限)
- 数据安全问题 (数据基于内存存储,服务器宕机,数据丢失)
Redis消息队列实现异步秒杀
前置知识:
消息队列的三大角色
- 生产者:发送消息到消息队列
- 消费者:从消息队列中获取消息
- 消息队列:存储和管理消息
Redis提供了三种不同的方式来实现消息队列:
- List
- Pubsub
- stream
Redis基于List实现的消息队列:BPUSH,BPOLL
基于List的消息队列,对比jdk提供的消息队列的优缺点:
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息对视
- 只支持单消费者
基于PubSub(发布订阅)的消息队列
- SUBSCRIBE channel [channel] : 订阅一个或多个频道
- PUBLISH channel msg : 向一个频道发送消息
- PSUBSCRIBE pattern [pattern] : 订阅与pattern格式匹配的所有频道
基于PUBSUB的消息队列有哪些优缺点?(可靠性要求高的话不建议使用)
- 优点:支持多生产,多消费
- 缺点:不支持数据持久化;无法避免消息丢失;消息堆积有上限,超出时数据丢失
基于stream的消息队列
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险 ($ 读取最新消息)
基于Stream的消费队列-消费者组
消费者组:将多个消费者划分到一个组中,监听同一个队列
特点:
- 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
- 消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息都会被消费
- 消息确认:消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除
// 创建消费者组:XGROUP CREATE key groupName ID [MKSTREAM]
XGROUP CREATE test name 0 MKSTREAM
key:队列名称
groupName:消费者组名称
ID:起始ID标识,$代表队列中的最后一个消息,0代表队列中的第一个消息
MKSTREAM:队列不存在时自动创建队列
基于Redis的Stream结构作为消息队列,实现异步秒杀下单
需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包括voucherid,userid,orderid
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单。
- 创建一个Stream类型的消息队列,名为stream.orders
xgroup create stream.orders go1 0 mkstream
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包括voucherid,userid,orderid
.....
.....
.....
-- 1.3 订单id
local orderId = ARGV[3];
.....
.....
.....
-- 2.3 队列key
local streamKey = 'stream.orders'
.....
.....
.....
-- 3.5 下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
-- 3.6 发送消息到消息队列中 XADD stream。orders * k1 v1 k2 v2
redis.call('xadd',streamKey,'*',"userId",userId,"voucherId",voucherId,"id",orderId)
return 0
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
引入了这个redis的消息队列,那么我们就不需要将数据加入阻塞队列了;
private final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct // spring初始化完毕就执行
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
@Override
public void run() {
while (true) {
try {
// 1.获取队列中的订单信息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("go1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
// 2.判断消息是否获取成功
if (list == null || list.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = list.get(0);
// 3.如果获取成功可以下单
Map<Object, Object> map = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
// 4ACK确认
handleVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(queueName,"go1",record.getId());
} catch (Exception e) {
log.error(SystemConstants.ORDER_HANDLE_FAIL);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取队列中的订单信息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("go1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
// 2.判断消息是否获取成功
if (list == null || list.isEmpty()) {
break;
}
MapRecord<String, Object, Object> record = list.get(0);
// 3.如果获取成功可以下单
Map<Object, Object> map = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
// 4ACK确认
handleVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(queueName,"go1",record.getId());
} catch (Exception e) {
log.error(SystemConstants.ORDER_HANDLE_FAIL);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
@Override
public Result seckillVoucher(Long voucherId) {
// 生成订单id
long orderId = redisIDWorker.nextId("order");
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(),String.valueOf(orderId));
// 2。判断结果是否为0
int r = result.intValue();
if (r != 0){
return Result.fail(r == 1?SystemConstants.STOCK_UN_ENOUGH:SystemConstants.VOUCHER_NOT_RE_BUY);
}
// 获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}