Redis应用

Redis凭借其高性能、丰富的数据结构和灵活的持久化选项,成为缓存、消息队列、实时分析等场景的首选工具

1.会话存储

cyborg2077.github.io/2022/10/22/...

2.缓存

缓存(Cache)就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地,下次获取数据就可以直接从缓存中获取,不再查询数据库

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力

为什么redis可以用作缓存?

  • Redis将数据存储在内存中,读写速度可达微秒级
  • 支持多种数据结构(字符串、哈希、列表、集合、有序集合等),能灵活应对不同场景
  • 单线程设计:避免多线程锁竞争
  • 原子操作:如INCR(计数器)、SETNX(分布式锁),保证数据一致性
  • 提供两种持久化机制(RDB、AOF),防止内存数据丢失
  • 通过集群或主从复制分担流量,提升系统整体吞吐量

2.1 缓存基本使用

可以在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据

java 复制代码
//查询商品信息,并且添加缓存
public Result queryById(Long id) {
    //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //如果不为空(查询到了),则转为Shop类型直接返回
    if (StrUtil.isNotBlank(shopJson)) {
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //否则去数据库中查
    Shop shop = getById(id);
    //查不到返回一个错误信息或者返回空都可以,根据自己的需求来
    if (shop == null){
        return Result.fail("店铺不存在!!");
    }
    //查到了则转为json字符串
    String jsonStr = JSONUtil.toJsonStr(shop);
    //并存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr);
    //最终把查询到的商户信息返回给前端
    return Result.ok(shop);
}

2.2 缓存更新

缓存更新是Redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们想Redis插入太多数据,此时就可能会导致缓存中数据过多,所以Redis会对部分数据进行更新,或者把它成为淘汰更合适

  • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为很长一段时间不需要更新)
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存

数据库和缓存不一致的解决方案

由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等

有三种方式解决:

  1. Cache Aside Pattern 人工编码方式:缓存调用者更新完数据库之后再去更新缓存,也称双写方案
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
  3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

在企业的实际应用中,还是方案一最可靠

缓存更新又分为两种方式:

  • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让删除缓存,再次查询时更新缓存

保证缓存与数据库的操作同时成功/同时失败,单系统时,缓存与数据库操作放在同一个事务;分布式系统:利用TCC等分布式事务方案

先操作缓存还是先操作数据库?

(1)先删除缓存,再操作数据库

删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题

(2)先操作数据库,再删除缓存

线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短,但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题

虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以最终采用后者先操作数据库,再删除缓存的方案

具体操作就是:

  1. 根据id查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
  2. 根据id修改店铺时,先修改数据库,再删除缓存

2.3 缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库

缓存空对象:简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去,这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。

可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了

java 复制代码
@Override
public Result queryById(Long id) {
    //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //如果不为空(查询到了),则转为Shop类型直接返回
    if (StrUtil.isNotBlank(shopJson)) {
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //如果查询到的是空字符串,则说明是我们缓存的空数据
    if (shopjson != null) {
        return Result.fail("店铺不存在!!");
    }
    //否则去数据库中查
    Shop shop = getById(id);
    //查不到,则将空字符串写入Redis
    if (shop == null) {
        //这里的常量值是2分钟
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺不存在!!");
    }
    //查到了则转为json字符串
    String jsonStr = JSONUtil.toJsonStr(shop);
    //并存入redis,设置TTL
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //最终把查询到的商户信息返回给前端
    return Result.ok(shop);
}

2.4 缓存雪崩

缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

  • 解决方案

    • 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
    • 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)

2.5 缓存击穿

存击穿也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击

常见的解决方案有两种

  1. 互斥锁
  2. 逻辑过期

2.5.1互斥锁

利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题

核心思路:进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁

  • 如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询
  • 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿
java 复制代码
    public Result queryById(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        Shop shop = null;
        try {
            //否则去数据库中查
            //查询数据库操作上锁:
            // 利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。
            // 在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock:shop:" + id, "1", 10, TimeUnit.SECONDS);
            if (!BooleanUtil.isTrue(flag)) {
                Thread.sleep(50);
                return queryById(id);
            }
            //查不到,则将空值写入Redis
            shop = getById(id);
            if (shop == null) {
                stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 2, TimeUnit.MINUTES);
                return Result.fail("Error: not found");
            }
            //查到了则转为json字符串
            String jsonStr = JSONUtil.toJsonStr(shop);
            //并存入redis,设置TTL
            stringRedisTemplate.opsForValue().set("cache:shop:" + id, jsonStr, 2, TimeUnit.MINUTES);
            //最终把查询到的商户信息返回给前端
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //释放锁
            stringRedisTemplate.delete("lock:shop:" + id);
        }
        return Result.ok(shop);
    }

2.5.2逻辑过期

之所以会出现缓存击穿问题,主要原因是在于对key设置了TTL,如果不设置TTL,那么就不会有缓存击穿问题,但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案

假设线程1去查询缓存,然后从value中判断当前数据如果已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据

这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据

选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性

java 复制代码
public class RedisData<T> {
    private LocalDateTime expireTime;
    private T data;
}
java 复制代码
//这里需要声明一个线程池,因为下面需要新建一个线程来完成重构缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Result queryById(Long id) {
    //1. 从redis中查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
    //2. 如果未命中,则返回空 (逻辑过期,不会设置TTL,默认这些缓存不会自己过期)
    if (StrUtil.isBlank(json)) {
        return Result.fail("Error: not found");
    }
    //3. 命中,将json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    //3.1 将data转为Shop对象
    JSONObject shopJson = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    //3.2 获取过期时间
    LocalDateTime expireTime = redisData.getExpireTime();
    //4. 判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        //5. 未过期,直接返回商铺信息
        return Result.ok(shop);
    }
    //6. 过期,尝试获取互斥锁
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock:shop:" + id, "1", 10, TimeUnit.SECONDS);
    //7. 获取到了锁
     if (!BooleanUtil.isTrue(flag)) {
        //8. 开启独立线程(使用线程池),完成缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                //查询把数据写到缓存
                Shop shop_temp = getById(id);
                RedisData redisData1 = new RedisData();
                redisData1.setData(shop_temp);
                redisData1.setExpireTime(LocalDateTime.now().plusMinutes(30));
                stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisData1));
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                //释放锁
                stringRedisTemplate.delete("lock:shop:" + id);
            }
        });
        //9. 直接返回商铺信息
        return Result.ok(shop);
    }
    //10. 未获取到锁,直接返回商铺信息
    return Result.ok(shop);
}

3.秒杀

3.1 全局唯一ID

数据库自增ID在有些场景使用会出现一些问题,比如某个商城的订单表,如果的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适

MySQL的单表容量不宜超过500W,数据量过大之后,就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是就要保证id的唯一性

那么这就引出全局ID生成器了(这里基于Redis实现),为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息

工具类:RedisIdWorker

java 复制代码
@Component
public class RedisIdWorker {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    //设置起始时间,我这里设定的是2022.01.01 00:00:00
    public static final Long BEGIN_TIMESTAMP = 1640995200L;
    //序列号长度
    public static final Long COUNT_BIT = 32L;

    public long nextId(String keyPrefix){
        //keyPrefix 表示业务标识,不同的业务生成的id不同
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = currentSecond - BEGIN_TIMESTAMP;
        //2. 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.1 这里还要加个date,如果所有订单业务都是一个key的话,当业务量达到一定量的时候会生成的自增值会达到上限,如果key是每天不同的则不会(上限2^64,虽然很大但是当前场景只用到32位,一天还是很难超过的)
        long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date); 
        //3. 拼接并返回,简单位运算
        return timeStamp << COUNT_BIT | count;
    }
}

全局自增还有其他策略:像UUID、雪花算法等

3.2 场景

3.2.1限量优惠卷秒杀

商城发放限量的优惠卷

java 复制代码
@Autowired
private ISeckillVoucherService seckillVoucherService;

@Autowired
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id",voucherId)
        .update();
    if (!success) {
        return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
}

当遇到高并发场景时,会出现超卖现象

假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:

  1. 悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock等,都是悲观锁
  2. 乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改,如果没有修改,则认为自己是安全的,自己才可以更新数据;如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常

乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过

当前情况下,可以使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同

java 复制代码
 //5. 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .eq("stock",seckillVoucher.getStock()) // 比较查询到的优惠券库存和实际数据库中优惠券库存是否相同,相同的才更新
        .update();
if (!success) {
    return Result.fail("库存不足");
}

只要扣减库存时的库存和之前查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败

继续完善代码修改逻辑,在这种场景,可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作

java 复制代码
//5. 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .eq("stock",seckillVoucher.getStock())
        .gt("stock", 0)
        .update();

3.2.2一人一单 ***

修改秒杀业务,要求同一个优惠券,一个用户只能抢一张

判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在

java 复制代码
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
+       // 一人一单逻辑
+       Long userId = UserHolder.getUser().getId();
+       int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
+       if (count > 0){
+           return Result.fail("你已经抢过优惠券了哦");
+       }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();

还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,这里使用悲观锁来解决这个问题

初步代码,把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁

不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。

java 复制代码
private synchronized Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("你已经抢过优惠券了哦");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
}

上面方法锁的对象是当前的实例对象this

但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId

java 复制代码
@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId(); 
    //    由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串
    synchronized (userId.toString().intern()) {
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        if (count > 0) {
            return Result.fail("你已经抢过优惠券了哦");
        }
        //5. 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 设置订单id
        long orderId = redisIdWorker.nextId("order");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }
    //执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}

但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    ...
    ...
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        return createVoucherOrder(voucherId);
    }
}

但是以上做法依然有问题,因为调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService中创建createVoucherOrder方法

java 复制代码
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}

注意:该方法需要导入依赖aspectjweaver以及在启动类上加@EnableAspectJAutoProxy(exposeProxy = true)

3.2.3集群环境下的并发问题

过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

由于每个JVM的锁监视器是不同的,无法监控其他的JVM的情况

需要使用分布式锁来解决,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

3.3 分布式锁

分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁

分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

常见的分布式锁实现三种:

  1. MySQL:MySQL本身就带有锁机制,但是由于MySQL的性能一般,所以采用分布式锁的情况下,使用MySQL作为分布式锁比较少见
  2. Redis:Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用Redis或者Zookeeper作为分布式锁,利用SETNX这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥,从而实现分布式锁
  3. Zookeeper:Zookeeper也是企业级开发中较好的一种实现分布式锁的方案,但本文是学Redis的,所以这里就不过多阐述了

3.3.1Redis实现

实现分布式锁时需要实现两个基本方法

1、获取锁

redis 复制代码
# NX 互斥 EX 超时时间
SET xxx thread01 NX EX 10 

2、释放锁

redis 复制代码
DEL xxx

用redis的SETNX方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试

java 复制代码
public interface ILock {
    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期自动释放
     * @return true表示获取锁成功,false表示获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}
java 复制代码
public class SimpleRedisLock implements ILock {
    //锁的前缀
    private static final String KEY_PREFIX = "lock:";
    //具体业务名称,将前缀和业务名拼接之后当做Key
    private String name;
    //这里不是@Autowired注入,采用的是构造器注入,在创建SimpleRedisLock时,将RedisTemplate作为参数传入
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标识
        long threadId = Thread.currentThread().getId();
        //获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        //自动拆箱可能会出现null,这样写更稳妥
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        //通过DEL来删除锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

修改之前的业务代码

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    //1. 查询优惠券
    //2. 判断秒杀时间是否开始
    //3. 判断秒杀时间是否结束
    //4. 判断库存是否充足
    ...
    
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    // 获取锁对象
    boolean isLock = redisLock.tryLock(120);
    // 加锁失败,说明当前用户开了多个线程抢优惠券,但是由于key是SETNX的,所以不能创建key,得等key的TTL到期或释放锁(删除key)
    if (!isLock) {
        return Result.fail("不允许抢多张优惠券");
    }
    try {
        // 获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    } finally {
        // 释放锁
        redisLock.unlock();
    }
}

3.3.2锁误删问题

逻辑说明

  • 持有锁的线程1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放
  • 此时线程2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到
  • 但是现在线程1阻塞完了,继续往下执行,要开始释放锁了
  • 那么此时就会将属于线程2的锁释放,这就是误删别人锁的情况

解决方案

  • 解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
  • 假设还是上面的情况,线程1阻塞,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1阻塞完了,继续往下执行,开始删除锁,但是线程1发现这把锁不是自己的,所以不进行删除锁的逻辑,当线程2执行到删除锁的逻辑时,如果TTL还未到期,则判断当前这把锁是自己的,于是删除这把锁

在获取锁的时候存入线程标识(用UUID标识,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致

java 复制代码
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标识
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁
    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().getId();
    // 获取锁中的标识
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标识是否一致
    if (threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

3.3.3原子性问题

更为极端的误删逻辑说明

假设线程1已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如JVM垃圾回收机制) 于是锁的TTL到期了,自动释放了,那么现在线程2趁虚而入,拿到了一把锁,就又会出现误删的问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

Lua是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:www.runoob.com/lua/lua-tut...

可以使用Lua去操作Redis,而且还能保证它的原子性,这样就可以实现拿锁,判断标识,删锁是一个原子性动作

例如要执行set name David,在执行get name,则脚本如下

Lua 复制代码
--先执行set name David
redis.call('set', 'name', 'David')
--再执行get name
local name = redis.call('get', 'name')
--返回
return name

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下

redis 复制代码
# key和value不想写死,可以作为参数传递,key类型参数会放入KEYS数组,其他参数会放入ARGV数组(Lua中数组下标从1开始)
EVAL script numkeys key [key ...] arg [arg ...]

那么就可以写成:

redis 复制代码
# 1表示key类型的参数只有一个
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy 

利用Java代码调用Lua脚本改造分布式锁

编写unlock.lua脚本,放resource下

修改释放锁的逻辑

java 复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
    UNLOCK_SCRIPT = new DefaultRedisScript();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

@Override
public void unlock() {
    stringRedisTemplate.execute(UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

3.3.4Redisson

基于SETNX实现的分布式锁存在以下问题

  • 重入问题:重入问题是指获取锁的线程,可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,例如在HashTable这样的代码中,它的方法都是使用synchronized修饰的,加入它在一个方法内调用另一个方法,如果此时是不可重入的,那就死锁了。所以可重入锁的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的
  • 不可重试:我们编写的分布式锁只能尝试一次,失败了就返回false,没有重试机制。但合理的情况应该是:当线程获取锁失败后,他应该能再次尝试获取锁
  • 主从一致性:如果Redis提供了主从集群,那么当我们向集群写数据时,主机需要异步的将数据同步给从机,万一在同步之前,主机宕机了(主从同步存在延迟,虽然时间很短,但还是发生了),那么又会出现死锁问题

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

Redisson提供了分布式锁的多种多样功能

  • 可重入锁(Reentrant Lock)
  • 公平锁(Fair Lock)
  • 联锁(MultiLock)
  • 红锁(RedLock)
  • 读写锁(ReadWriteLock)
  • 信号量(Semaphore)
  • 可过期性信号量(PermitExpirableSemaphore)
  • 闭锁(CountDownLatch)
替换之前方案

添加依赖,然后创建配置类

java 复制代码
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        //配置单点服务器的地址,如果是redis集群,可以配置集群地址
        config.useSingleServer()
            .setAddress("redis://101.XXX.XXX.160:6379")
            .setPassword("root");
        return Redisson.create(config);
    }
}

使用

java 复制代码
@Resource
private RedissonClient redissonClient;

@Test
void testRedisson() throws InterruptedException {
    //获取可重入锁
    RLock lock = redissonClient.getLock("anyLock");
    //尝试获取锁,三个参数分别是:获取锁的最大等待时间(期间会重试),锁的自动释放时间,时间单位
    boolean success = lock.tryLock(1,10, TimeUnit.SECONDS);
    //判断获取锁成功
    if (success) {
        try {
            System.out.println("执行业务");
        } finally {
            //释放锁
            lock.unlock();
        }
    }
}

修改之前业务

java 复制代码
+   @Resource
+   private RedissonClient redissonClient;

   @Override
   public Result seckillVoucher(Long voucherId) {
       //1. 查询优惠券
       //2. 判断秒杀时间是否开始
       //3. 判断秒杀时间是否结束
       //4. 判断库存是否充足
       ...
       Long userId = UserHolder.getUser().getId();
-       SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
+       RLock redisLock = redissonClient.getLock("order:" + userId);
-       boolean isLock = redisLock.tryLock(120);
+       boolean isLock = redisLock.tryLock();
       if (!isLock) {
           return Result.fail("不允许抢多张优惠券");
       }
       try {
           IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
           return proxy.createVoucherOrder(voucherId);
       } finally {
           redisLock.unlock();
       }
   }
可重入锁原理

JDK中的Lock锁支持可重入功能 他是借助于一个voaltile的一个state变量来记录重入的状态,如果当前没有人持有这把锁,那么state = 0 如果有人持有这把锁,那么state = 1,如果持有者把锁的人再次持有这把锁,那么state会+1

在redisson中,也支持可重入锁,它采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有,内层value相当于Lock锁的state,释放锁的时候也只是将state进行-1,只有减至0,才会真正释放锁

由于需要额外存储一个state,所以用字符串型SET NX EX是不行的,需要用到Hash结构,但是Hash结构又没有NX这种方法,所以需要将原有的逻辑拆开,进行手动判断

为了保证原子性,所以流程图中的业务逻辑也是需要用Lua来实现的

获取锁的逻辑

lua 复制代码
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 锁不存在
if (redis.call('exists', key) == 0) then
    -- 获取锁并添加线程标识,state设为1
    redis.call('hset', key, threadId, '1');
    -- 设置锁有效期
    redis.call('expire', key, releaseTime);
    return 1; -- 返回结果
end;
-- 锁存在,判断threadId是否为自己
if (redis.call('hexists', key, threadId) == 1) then
    -- 锁存在,重入次数 +1,这里用的是hash结构的incrby增长
    redis.call('hincrby', key, thread, 1);
    -- 设置锁的有效期
    redis.call('expire', key, releaseTime);
    return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败

释放锁的逻辑

lua 复制代码
local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- 如果锁不是自己的
if (redis.call('HEXISTS', key, threadId) == 0) then
    return nil; -- 直接返回
end;
-- 锁是自己的,锁计数-1,还是用hincrby,不过自增长的值为-1
local count = redis.call('hincrby', key, threadId, -1);
-- 判断重入次数为多少
if (count > 0) then
    -- 大于0,重置有效期
    redis.call('expire', key, releaseTime);
    return nil;
else
    -- 否则直接释放锁
    redis.call('del', key);
    return nil;
end;
锁重试和WatchDog机制

实战篇-20.分布式锁-Redisson的锁重试和WatchDog机制_哔哩哔哩_bilibili

www.bilibili.com/video/BV1KF...

3.4 基于阻塞队列优化秒杀

当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤:

  1. 查询优惠券
  2. 判断秒杀库存是否足够
  3. 查询订单
  4. 校验是否一人一单
  5. 扣减库存
  6. 创建订单

在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致程序执行很慢,可以用异步操作来优化

  • 将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,是一定可以下单成功的,不用等数据真的写进数据库,直接告诉用户下单成功就好了。
  • 允许下单后,后台再开一个线程,慢慢执行队列里的消息,这样就能很快的完成下单业务

整体思路

  1. 当用户下单之后,判断库存(优惠卷信息数据库中存一份)是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。
  2. 如果充足,则在Redis中判断用户是否可以下单(创一个set集合表示用户是否下过单,根据set的特性),如果set集合中没有该用户的下单数据,则可以下单
  3. 将userId和优惠券存入到Redis中,并且返回0,表示可以下单
  4. 将优惠卷id、用户id信息保存到queue中去,然后返回,开一个线程来异步下单,可以通过返回订单的id来判断是否下单成功

在redis操作的过程需要保证是原子性的,所以要用Lua来操作,同时由于需要在Redis中查询优惠券信息,所以在新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中

3.4.1 基于redis完成下单资格判断

步骤一:修改保存优惠券相关代码,保存到redis

java 复制代码
@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);
    // 保存秒杀优惠券信息到Reids,Key名中包含优惠券ID,Value为优惠券的剩余数量
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); 
}

步骤二:编写Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

lua 复制代码
-- 订单id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 优惠券key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
    return 1
end
-- 判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2
end
-- 扣减库存
redis.call('incrby', stockKey, -1)
-- 将userId存入当前优惠券的set集合
redis.call('sadd', orderKey, userId)
return 0

修改秒杀逻辑

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    //1. 执行lua脚本(不再使用java代码判断,而是调用redis)
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId.toString(),
            UserHolder.getUser().getId().toString());
    //2. 判断返回值,并返回错误信息
    if (result.intValue() != 0) {
        return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
    }
    long orderId = redisIdWorker.nextId("order");
    //TODO 保存阻塞队列

    //3. 返回订单id
    return Result.ok(orderId);
}

3.4.2 基于阻塞队列实现秒杀优化

在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行

  1. 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
  2. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

步骤一:创建阻塞队列

阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素

java 复制代码
private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

把优惠券id和用户id封装后存入阻塞队列

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId.toString(),
            UserHolder.getUser().getId().toString());
    if (result.intValue() != 0) {
        return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
    }
    long orderId = redisIdWorker.nextId("order");
    //封装到voucherOrder中
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setId(orderId);
    //加入到阻塞队列
    orderTasks.add(voucherOrder);
    return Result.ok(orderId);
}

步骤二:实现异步下单功能

创建一个线程池

java 复制代码
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到@PostConstruct注解

java 复制代码
@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

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);
            }
        }
    }
}

编写订单业务逻辑

java 复制代码
private IVoucherOrderService proxy;
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    //1. 获取用户
    Long userId = voucherOrder.getUserId();
    //2. 创建锁对象,作为兜底方案,万一redis出问题
    RLock redisLock = redissonClient.getLock("order:" + userId);
    //3. 获取锁
    boolean isLock = redisLock.tryLock();
    //4. 判断是否获取锁成功         
    if (!isLock) {
        log.error("不允许重复下单!");
        return;
    }
    try {
        //5. 使用代理对象,由于这里是另外一个线程,
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        redisLock.unlock();
    }
}

查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功

可以将proxy放在成员变量的位置,然后在主线程中获取代理对象

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId.toString(),
            UserHolder.getUser().getId().toString());
    if (result.intValue() != 0) {
        return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
    }
    long orderId = redisIdWorker.nextId("order");
    //封装到voucherOrder中
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setId(orderId);
    //加入到阻塞队列
    orderTasks.add(voucherOrder);
    //主线程获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    return Result.ok(orderId);
}

完整代码:

java 复制代码
/**
 * 服务实现类
 */
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RedissonClient redissonClient;

    private IVoucherOrderService proxy;


    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    static {
        SECKILL_SCRIPT = new DefaultRedisScript();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); // 线程池

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

    private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); // 阻塞队列
    
    @Override
    public Result seckillVoucher(Long voucherId) {
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(), voucherId.toString(),
                UserHolder.getUser().getId().toString());
        if (result.intValue() != 0) {
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        long orderId = redisIdWorker.nextId("order");
        //封装到voucherOrder中
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setId(orderId);
        //加入到阻塞队列
        orderTasks.add(voucherOrder);
        //主线程获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        return Result.ok(orderId);
    }
    
    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) {
        //1. 获取用户
        Long userId = voucherOrder.getUserId();
        //2. 创建锁对象,作为兜底方案
        RLock redisLock = redissonClient.getLock("order:" + userId);
        //3. 获取锁
        boolean isLock = redisLock.tryLock();
        //4. 判断是否获取锁成功 
        if (!isLock) {
            log.error("不允许重复下单!");
            return;
        }
        try {
            //5. 使用代理对象,由于这里是另外一个线程,
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            redisLock.unlock();
        }
    }
    
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        // 一人一单逻辑
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        synchronized (userId.toString().intern()) {
            int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
            if (count > 0) {
                log.error("你已经抢过优惠券了哦");
                return;
            }
            //5. 扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId)
                    .gt("stock", 0)
                    .update();
            if (!success) {
                log.error("库存不足");
            }
            //7. 将订单数据保存到表中
            save(voucherOrder);
        }
    }
}

3.4.3 还存在的问题

内存限制问题:

  • 使用的是JDK里的阻塞队列,它使用的是JVM的内存,如果在高并发的条件下,无数的订单都会放在阻塞队列里,可能就会造成内存溢出,所以在创建阻塞队列时,设置了一个长度,但是如果真的存满了,再有新的订单来往里塞,那就塞不进去了,存在内存限制问题

数据安全问题:

  • 经典服务器宕机了,用户明明下单了,但是数据库里没看到

下面介绍redis消息队列的方案可以解决这些问题

3.5 Redis消息队列

什么是消息队列?字面意思就是存放消息的队列,最简单的消息队列模型包括3个角色

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

使用队列的好处在于解耦:举个例子,快递员(生产者)把快递放到驿站/快递柜里去(Message Queue)去,我们(消费者)从快递柜/驿站去拿快递,这就是一个异步,如果耦合,那么快递员必须亲自上楼把快递递到你手里,服务当然好,但是万一我不在家,快递员就得一直等我,浪费了快递员的时间。所以解耦还是非常有必要的

那么在这种场景下秒杀就变成了:在下单之后,利用Redis去进行校验下单的结果,然后在通过队列把消息发送出去,然后在启动一个线程去拿到这个消息,完成解耦,同时也加快响应速度

这里可以直接使用一些现成的(MQ)消息队列,如kafka,rabbitmq等,也可以使用redis提供的消息队列方案

3.5.1 基于Stream实现消息队列

Stream是Redis 5.0引入的一种新数据类型,可以实现一个功能完善的消息队列

发送消息

读取消息 起始ID为0,可以重复读取,数据不会丢失

阻塞方式,读取最新消息

redis 复制代码
XREAD COUNT 2 BLOCK 10000 STREAMS users $

可以使用循环调用的XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下

java 复制代码
while (true){
    //尝试读取队列中的消息,最多阻塞2秒
    Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $");
    //没读取到,跳过下面的逻辑
    if(msg == null){
        continue;
    }
    //处理消息
    handleMessage(msg);
}

注意:当我们指定其实ID为$时,代表只能读取到最新消息,如果当我们在处理一条消息的过程中,又有超过1条以上的消息到达队列,那么下次获取的时候,也只能获取到最新的一条,会出现漏读消息的问题,可以使用消费者组解决

消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列,具备以下特点

  • 消息分流: 队列中的消息会分留给组内的不同消费者,而不是重复消费者,从而加快消息处理的速度
  • 消息标识: 消费者会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
  • 消息确认: 消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除

消费者监听消息的基本思路

java 复制代码
while(true){
    // 尝试监听队列,使用阻塞模式,最大等待时长为2000ms
    Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >") // '>':从下一个未消费的消息开始(pending-list中)
    if(msg == null){
        // 没监听到消息,重试
        continue;
    }
    try{
        //处理消息,完成后要手动确认ACK,ACK代码在handleMessage中编写
        handleMessage(msg);
    } catch(Exception e){
        while(true){
            //0表示从pending-list中的第一个消息开始,如果前面都ACK了,那么这里就不会监听到消息
            Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0"); // '0',是从pending-list中的第一个消息开始
            if(msg == null){
                //null表示没有异常消息,所有消息均已确认,结束循环
                break;
            }
            try{
                //说明有异常消息,再次处理
                handleMessage(msg);
            } catch(Exception e){
                //再次出现异常,记录日志,继续循环
                log.error("..");
                continue;
            }
        }
    }
}

3.5.2 消息队列实现异步秒杀下单

  1. 创建一个Stream类型的消息队列,名为stream.orders
  2. 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
  3. 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

步骤一:创建一个Stream类型的消息队列,名为stream.orders

bash 复制代码
XGROUP CREATE stream.orders g1 0 MKSTREAM

步骤二:修改Lua脚本,新增orderId参数,并将订单信息加入到消息队列中

lua 复制代码
-- 订单id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 新增orderId,但是变量名用id就好,因为VoucherOrder实体类中的orderId就是用id表示的
local id = ARGV[3]
-- 优惠券key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
    return 1
end
-- 判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2
end
-- 扣减库存
redis.call('incrby', stockKey, -1)
-- 将userId存入当前优惠券的set集合
redis.call('sadd', orderKey, userId)
-- 将下单数据保存到消息队列中
redis.call("sadd", 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', id)
return 0

步骤三:修改秒杀逻辑

java 复制代码
//由于将下单数据加入到消息队列的功能,我们在Lua脚本中实现了,所以这里就不需要将下单数据加入到JVM的阻塞队列中去了
//同时Lua脚本中我们新增了一个参数,
    @Override
    public Result seckillVoucher(Long voucherId) {
+       long orderId = redisIdWorker.nextId("order");
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(), voucherId.toString(),
+               UserHolder.getUser().getId().toString(), String.valueOf(orderId));
        if (result.intValue() != 0) {
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下 单");
        }
-       long orderId = redisIdWorker.nextId("order");
-       //封装到voucherOrder中
-       VoucherOrder voucherOrder = new VoucherOrder();
-       voucherOrder.setVoucherId(voucherId);
-       voucherOrder.setUserId(UserHolder.getUser().getId());
-       voucherOrder.setId(orderId);
-       //加入到阻塞队列
-       orderTasks.add(voucherOrder);
        //主线程获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        return Result.ok(orderId);
    }

修改VoucherOrderHandler

java 复制代码
String queueName = "stream.orders";

private class VoucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while (true) {
            try {
                //1. 获取队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
                List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        //ReadOffset.lastConsumed()底层就是 '>'
                        StreamOffset.create(queueName, ReadOffset.lastConsumed()));
                //2. 判断消息是否获取成功
                if (records == null || records.isEmpty()) {
                    continue;
                }
                //3. 消息获取成功之后,我们需要将其转为对象
                MapRecord<String, Object, Object> record = records.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                //4. 获取成功,执行下单逻辑,将数据保存到数据库中
                handleVoucherOrder(voucherOrder);
                //5. 手动ACK,SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
            } catch (Exception e) {
                log.error("订单处理异常", e);
                //订单异常的处理方式我们封装成一个函数,避免代码太臃肿
                handlePendingList();
            }
        }
    }
}

private void handlePendingList() {
    while (true) {
        try {
            //1. 获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders 0
            List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0")));
            //2. 判断pending-list中是否有未处理消息
            if (records == null || records.isEmpty()) {
                //如果没有就说明没有异常消息,直接结束循环
                break;
            }
            //3. 消息获取成功之后,我们需要将其转为对象
            MapRecord<String, Object, Object> record = records.get(0);
            Map<Object, Object> values = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
            //4. 获取成功,执行下单逻辑,将数据保存到数据库中
            handleVoucherOrder(voucherOrder);
            //5. 手动ACK,SACK stream.orders g1 id
            stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
        } catch (Exception e) {
            log.info("处理pending-list异常");
            //如果怕异常多次出现,可以在这里休眠一会儿
            try {
                Thread.sleep(50);
            } catch (InterruptedException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
}
相关推荐
麓殇⊙10 小时前
redisson锁的可重入、可重试、超时续约原理详解
redis·分布式锁
jstart千语10 小时前
【Redisson】锁可重入原理
redis·分布式·redisson
软件20513 小时前
【redis——缓存雪崩(Cache Avalanche)】
数据库·redis·缓存
一眼万年0415 小时前
Redis单机模式
redis·微服务
float_六七1 天前
Redis:极速缓存与数据结构存储揭秘
数据结构·redis·缓存
blammmp1 天前
Redis : set集合
数据库·redis·缓存
昂子的博客1 天前
Springboot仿抖音app开发之消息业务模块后端复盘及相关业务知识总结
java·spring boot·redis·后端·mysql·mongodb·spring
冷崖1 天前
Redis事务与驱动的学习(一)
数据库·redis·学习
你好龙卷风!!!1 天前
mac redis以守护进程重新启动
redis