【Redis实战|黑马点评】优惠券秒杀、分布式锁

857 阅读7分钟

全局唯一ID

每个店铺都可以发布优惠券:当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题。

  • id的规律性太明显
  • 受单表数据量的限制

全局ID生成器

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法(雪花算法)
  • 数据库自增

Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器 截屏2023-04-30 17.21.00.png

实现优惠券秒杀下单

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购。

数据表的关系:

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的库存、开始抢购时间、结束抢购时间

实现:

  • 在VoucherController中提供了一个接口,可以添加秒杀优惠券

实现优惠券秒杀的下单功能 -> 下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足无法下单

截屏2023-04-30 21.14.27.png

超卖问题

截屏2023-04-30 21.29.54.png

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁

截屏2023-04-30 21.31.55.png

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法

截屏2023-04-30 21.37.06.png

  • CAS法 -> 用数据本身是否变化来进行判断
    • 直接查库存,如果查询的值和原来查到的不一样,就不进行扣减
    • -> 压力测试显示有问题,很多线程无效,因此修改:
      • 更改库存操作时,加的条件不必是“当前查到的库存数和更改前查到的库存值一致”,只需要让更改的条件为查到的库存数大于0即可。
截屏2023-04-30 21.39.58.png

总结

超卖这样的线程安全问题,解决方案有哪些?

  • 悲观锁:添加同步锁,让线程串行执行
    • 优点:简单粗暴
    • 缺点:性能一般
  • 乐观锁:不加锁,在更新时判读是否有其他线程在修改
    • 优点:性能好
    • 缺点:存在成功率低的问题

一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

截屏2023-05-01 17.08.11.png

分布式锁

前面添加同步锁的方式,无法在集群模式下保证线程安全,所以需要引入分布式锁。

截屏2023-05-01 17.55.09.png 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

截屏2023-05-01 17.58.40.png 分布式锁的实现

截屏2023-05-01 18.12.44.png

基于Redis的分布式锁

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

  • 获取锁
    • 互斥:确保只能有一个线程获取锁(原子操作)
      • 添加锁,利用setnx的互斥特性
      • 添加锁过期时间,避免服务宕机引起死锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
截屏2023-05-01 18.38.17.png
  • 释放锁
    • 手动释放
    • 超时释放:获取锁时添加一个超时时间
      • 释放锁,删除即可
截屏2023-05-01 18.40.23.png 截屏2023-05-01 18.41.26.png

解决释放别人的锁的问题 -> 改进redis的分布式锁:

  • 在获取锁时存入线程标识(可以用UUID表示)
  • 在释放锁时先获取锁中的线程标识,判断与当前线程标识是否一致
    • 如果一致,则释放锁
    • 如果不一致,则不释放锁
截屏2023-05-01 19.31.52.png

截屏2023-05-01 19.44.37.png

Redis优化

Redis的Lua脚本:

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

写好脚本以后,需要用Redis命令来调用脚本。

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数。

基于Redis的分布式锁 -> 释放锁的业务流程:

  • 获取锁中的线程标识
  • 判断是否与指定的标识(当前线程标识)一致
  • 如果一致则释放锁(删除)
  • 如果不一致则什么都不做

用Lua脚本来表示:

截屏2023-05-01 20.13.12.png

再次改进Redis的分布式锁 -> 基于Lua脚本实现分布式锁的释放锁逻辑 -> RedisTemplate调用Lua脚本的API。

分布式锁总结

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标识
  • 释放锁时先判断线程标识是否与自己一致,一致则删除锁

特性:

  • 利用set nx满足互斥行
  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用Redis集群保证高可用和高并发特性

但是 -> 基于setnx实现的分布式锁存在下面的问题:

  • 不可重入: 同一个线程无法多次获取同一把锁
  • 不可重试: 获取锁只尝试一次就返回false,没有重试机制
  • 超时释放: 锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 主从一致性: 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从未同步主中的锁数据,则会出现安全问题

Redisson

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

Redisson可重入锁原理

  • 原理
    • 可重入:利用hash结构、记录线程标识id和重入次数
    • 利用watchDog延续锁时间
    • 利用信号量控制锁重试等待
  • 缺点
    • Redis宕机引起锁失效问题

截屏2023-05-02 10.54.42.png 利用Lua脚本实现

截屏2023-05-02 10.55.39.png

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用wactchDog,每隔一段时间(releaseTime/3),重置超时时间

截屏2023-05-02 11.26.19.png

Redisson分布式锁主从一致性问题 -> Redisson的multiLock连锁

  • 原理
    • 多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
  • 缺陷
    • 运维成本高、实现复杂
  • 主从一致性问题
    • 单个主节点,锁失效问题 -> 不再有主从节点
    • 多个主节点保证锁,一个主节点宕机了,其他线程只能获得一个新主节点的锁,获取不到其他两个锁,还会获取失败
      • 连锁multiLock策略:不再有主从节点,都获取成功才能获取锁成功,有一个节点获取锁不成功就获取锁失败
      • 主要防止主节点宕机后,其他线程获得主节点的锁,引起线程安全问题

主从节点 截屏2023-05-02 11.35.01.png 三个独立节点 截屏2023-05-02 11.39.14.png

秒杀优化

原来的秒杀

截屏2023-05-02 12.50.02.png 异步秒杀

截屏2023-05-02 12.55.39.png

  • 判断秒杀库存 -> string

  • 校验一人一单 -> set

截屏2023-05-02 13.02.01.png

改进秒杀业务,提高并发性能

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

秒杀业务的优化思路是什么?

  • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单

基于阻塞队列的异步秒杀存在哪些问题?

  • 内存限制问题
  • 数据安全问题