• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

黑马点评项目笔记三分布式锁、异步秒杀、lua脚本

武飞扬头像
小那么小小猿
帮助1

全局唯一ID

在项目中唯一的商品就是优惠券,优惠券的抢购就类似于商品的抢购,会有 voucher-order表,而表的若采用自增会有如下问题

  • id 规律太明显:用于是订单,用户会看到订单的id,会根据id猜测到其他信息
  • 单表数据限制:单表过大,就要进行分表,而每张表都有自己的自增id,可能导致重复
    因此要使用全局唯一id ,全局唯一id要求的具有的特性:
    唯一性
    高可用:不会轻易的无法使用
    高性能:生成id的速度足够快
    递增性:整体呈递增趋势,使得数据库更方便建立索引
    安全性:不能有规律

唯一id生成策略

  • uuid :32位16进制值,字符串结构,没有递增特性
  • redis自增:使用redis维护id
  • snowflake算法:机器内部维护自增的id,但对时间维护依赖性高,要求准确
  • 数据库自增:单独创建表用于id自增,类似于redis,只是交给数据库维护

redis自增主键

利用redis的incr特性实现,但redis生成的有明显规律性,违背安全性,因此需要改造

设计思路:时间戳 维护的id
学新通
redis只负责递增,限制递增位数为32位,java负责取出redis递增结果并拼接上当前的31位时间戳组成id,最后进行十进制转换

redis只记录当前生成id起始值,不记录最终生成的id

学新通

秒杀优惠卷

项目中,优惠卷分为两种,一种是普通卷随意领,没有库存限制,只有信息表,订单表;另一种是秒杀卷,既有信息表又有库存表(时间限制,库存限制)、订单表

添加秒杀类型优惠卷
保存秒杀优惠卷信息的同时,添加到库存信息

秒杀优惠卷

  • 创建订单数据
  • 扣除该卷的库存
    流程图
    学新通

全程只用到redis的全局唯一id生成
学新通
当前问题:
在查询库存和扣除库存之间,有多个线程同时查询满足库存条件,创建了订单,导致`超卖问题。查询和修改之间不具有原子性

线程安全问题:
学新通
此类问题特征:先进行查询判断条件,在进行修改。可在查询和修改之间,多线程问题导致修改时和当初查询时结果不一样
通过加锁来解决问题:

悲观锁

认为线程安全问题一定会发生,在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁
缺点:串行执行,性能低
优点:简单的保证线程安全

乐观锁

认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
如果没有修改则认为是安全的,自己才更新数据。
如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
方法是利用字段使其查询和修改有原子性
版本号法
单独使用一个字段version 作为修改标记,规定只要修改数据就一定要修改version 。使得数据在修改前,先查询version,在修改时,判断verison是否和之前查询相同。相同则修改
学新通
CAS
本项目中,使用版本号法目的也是保护库存 在查询时和修改时能够保持一致,库存字段和version字段目的相同,因此可以进行整合
修改中发现问题
虽然能够保证超卖,但导致库存剩余,成功率过低
学新通
针对本项目优化失败率过低问题
只要在修改时,判断库存>0即可
学新通
优点:性能好
缺点:失败率低(通过分段锁解决)

一人一单问题

单体项目

在之前,创建订单前 判断在order表中 判断是否该用户是有具有该商品,若有则违背一人一单,返回错误
学新通

但又有了以上问题,查询和修改不具有原子性 ,先查询再操作数据库,中间有间隔,再一次导致一人多单;一人多单的原因是一个用户同时发起大量请求 ,如果是不同用户发起,只会有超卖问题

与以上不同的是,以上使用 乐观锁是修改同时使用同本条数据中的其他字段与之前查询数据进行判断,是在数据已经存在的基础上进行条件判断
而本次是 插入数据导致乐观锁无效,就好比 插入语句没有where ,数据都不存在不可能进行条件判断,就无法使用乐观锁
为了保证线程安全,只能使用悲观锁
一人一单问题是一个用户同时发起大量请求 导致判断与插入之间出现线程安全问题

查询用户购买记录->判断用户是否购买->创建订单->保存订单 之间 添加悲观锁

添加方式一:
学新通

添加方式二:
学新通
解决释放早于事务提交方法
由于提交是spring通过JDK动态代理进行补充后提交的,因此需要扩大锁的范围,将整个方法锁住
学新通
此时 调用的是VoucherOrderServiceImpl对象

就比如
学新通

通过动态代理对象调用方法

  1. 要有依赖
  <dependency>
      <groupId>org.aspectj</groupId>
       <artifactId>aspectjweaver</artifactId>
  </dependency>
  1.  

学新通
3.
学新通
解决单体项目一人一单问题

集群项目

学新通
出现集群导致jvm的悲观锁失效,出现一人多单问题
学新通
因此集群环境下要使用分布式锁

分布式锁

类似于解决缓存击穿时使用的进程锁

  • 多进程可见:确保锁的唯一性
  • 互斥
  • 高可用:复杂环境下也能使用
  • 高性能 :获取速度锁快
  • 安全 :获取锁异常情况处理

分布式锁的常用的实现
学新通

分布式锁是一个全局可见的唯一的标识,并能够处理异常情况下锁的释放问题

同处理缓存击穿时相同,利用setNx的互斥性 进行线程锁,使得同一个用户发来的多个进程争抢锁,使得同一时间只能有一个线程通过
学新通
阻塞式获取
获取锁失败后,阻塞等待线程释放
非阻塞获取
获取锁失败后,结果直接返回
将锁的基本操作封装一个工具类
学新通
使用redis锁替代 synchronized ,解决锁同一个用户的多个进程方法是在锁上加上用户id
学新通
存在问题:

超时释放导致误删锁问题

线程1 还没有遇到阻塞,导致没有执行完锁超时释放被线程2获取,而线程1执行完后,删除了不是自己的锁 (因为同用户锁的key相同),导致正在执行的线程2没有了锁的保护
学新通

解决方法,在创建线程时,放入自己线程的标识,在删除时进行判断是否是自己的线程,防止误删
若果创建key时,拼上自己线程编号,会是的每个线程都有自己的锁,导致锁无效
因此,需要在value中放入自己的线程编号

由于线程id是一个jvm内部递增的数字,集群条件下多个jvm可能导致线程id重复,因此要确保线程标识唯一
学新通
对封装的锁操作进行改写
学新通

线程1刚进入锁就阻塞过期,线程2进入后 线程1立刻阻塞消失 ,两个线程同时在锁中 也会引发安全问题 。在释放锁时若判断不是自己的,是不是应该考虑回滚?

之前缓存击穿是否也需要防止锁被误删?

当前问题:判断和释放是两个动作,在这两期间仍会发生锁过期
学新通

解决方法 :判断锁和删除锁应该具有原子性,一气呵成

但redis事务只能批处理,一次性执行所有命令,无法获取结果进行判断,要再次结合乐观锁,实现麻烦
所以使用Lua脚本,脚本的在redis中执行是一条命令具有原子性,所有使得其中的所有redis命令执行一气呵成

编写Lua脚本

lua脚本执行是 java调用redis,redis调用lua脚本

在spring资源目录下 创建一个lua脚本,名为unlock.lua
学新通
修改封装的释放锁方法
学新通

Redission

原来方式缺少的功能:

  • 不可重入:同一个线程多次尝试获取通一把锁
    例如 A方法获取锁 ,要执行B方法,而B方法中也要获取锁才能执行。同一个线程要获取两次锁 ,由于setNx的互斥性,即使是同一个线程,在不释放的前提下也不能再次获取 。 若B要等待锁的释放,会导致A执行阻塞,锁长时间不释放 形成死锁,学新通

学新通

  • 不可重试: 锁被占用,会进行阻塞重试
  • 超时释放:锁的过期时间设定问题 ,过长会导致故障时间长,过短会导致任务没有执行完,锁被其他线程抢去
  • 主从一致问题:读写一致问题,在集群条件下,锁在多个节点同步过程可能出现延迟,或在同步过程宕机导致锁丢失

简介

redisson :使用redis的在分布式情景下的一款redis客户端工具

使用方式

  1. 引入 pom依赖
    防止与spring中对redis的配置冲突,不使用 springboot redission starter
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>
  1. 创建配置类
    学新通

  2. 使用例
    学新通

可重入锁原理

一个线程多次获取到锁,称为可重入锁。自定义获取锁的方式是采用 setNx lock thread,当此线程想要再次获取锁时,还是通过setNx lock thread,导致获取失败。对此线程和其他线程不加判别一律失败。

而 Redission 采用的是 setNx lock thread 1这种hash结构,key 是锁的标识 filed是线程的标识,value是获取的次数。当此线程再次获取时,会对线程进行判断,然后value值 1,在释放时,同样进行判断然后 value-1 直到为0,释放锁

学新通
流程图
学新通
有多次查询判断和数据操作不是同时进行,为防止线程安全问题,保证原子性,使用lua脚本

获取锁时lua脚本
学新通
释放锁时lua脚本
学新通

重试原理

redission并没有一味的循环尝试,而是根据自己的剩余的时间和锁
RedissonLock 类
第一次获取,若失败,则在自己剩余的时间里,监听锁的释放时间
学新通
若监听到锁的释放却没有争取到,则开始再次进行订阅,在自己剩余的时间里,进行循环

学新通

学新通
流程图
学新通

锁释放原理

在获取锁时,若传递锁过期释放时间,则过期就释放。若不传递,采用看门狗方式,初始为30s,每隔10s刷新过期时间,重新设置10s,即使锁重入也是如此。在释放锁时取消刷新
学新通

判断是否是新创建的锁,若是,给添加上定时刷新任务
学新通

学新通
lua脚本判断并刷新过期时间
学新通

学新通
整体流程
学新通

主从一致原理

当redis以主从模式,读写分离下,主节点负责写,然后将数据同步给从节点,从节点负责读。如果创建锁后,在同步给从节点一瞬间主节点宕机,将导致锁的数据的丢失,其他线程还能创建锁,导致并发安全问题

redission 解决方案:
设置三个及以上的主节点,同时向各个节点保存锁的标识。只要有一个节点没来得及同步就宕机,会导致获取锁失败
学新通
解决了服务器宕机锁失效问题

缺点 所需服务器个数多,成本高 。代码变复杂

异步秒杀

原方式采用的同步连续4次访问数据库,好比饭店服务员接待客人后亲自做好饭才会告诉客人下单成功,使得客人等待时间过长。
学新通

异步思路:
具备条件后,就返回信息,留下确定的活慢慢干。先将活揽下,并返回确认信息,使得客户等待时间只有揽活时间,没有背地里干活时间

同时,可以将购买资格判断放入redis中,用户响应信息只有和redis操作,性能大幅提高
学新通
优惠券信息:key为该优惠券编号,存入库存。因此采用String结构

订单表信息 :key为该优惠券编号,值为购买过的用户,且不能重复。因此需要采用Set类型

  1. 将对redis的查询、判断、数据操作写入一个lua脚本,防止线程并发安全问题
    学新通
  2. 在java中调用redis,使得redis调用该脚本,同时将信息返回用户

学新通
3. 开启阻塞队列 ,将信息放入队列
学新通

学新通

  1. 新开辟一个线程,从阻塞队列中获取信息后,执行数据库操作

学新通

学新通
当前问题

  • 内存限制 若不加以限制,可能导致内存溢出;若限制,队列中满了可能会丢失数据
  • 数据安全问题:
    • 基于内存储存,如果突然宕机,使得任务丢失
    • 从队列取出后,处理发生异常,导致任务丢失

解决以上问题,使用:

redis 消息队列

学新通
redis 中提供三种方式实现消息队列功能:

list结构

由于本身具有的特性:Lpush、Rpush、Lpop、Rpop 具有双向链表的结构。可以利用模拟出一个队列。队列特点:出入口不一致。因此可使用 Lpush和Rpop组合、Rpush和Lpop组合进行模拟。且有BRpop、RLpop进行阻塞获取,可能模拟数据获取等待
由于采用pop方式(移除并获取),只要进行获取数据,无论失败与否,队列中都不会存在此数据

学新通
学新通
学新通
缺点:

  • 不支持多个消费者
  • 获取数据后,数据在队列中就消失。若处理失败,则数据也无法再次获取

优点:

  • 采用的是list数据类型(原本是用来存储数据的),redis自动进行持久化,即使宕机数据依然在
  • 满足消息有序性 (先进先出)

PubSub

发布订阅模型 reids2.0引入

每个生产者负责将数据发送至一个或多个频道,多个消费者只需要订阅该频道,即可获取生产者发布消息
生产者常用命令
publish 【频道名称】【要发布的消息】
消费者常用命令

  • subscribe channel【频道名称】
  • subscribe pattern 【与pattern格式匹配的所有频道】
    要先订阅在进行发布

优点

  • 支持多个消费者

缺点

  • 不支持数据持久化,发布出去数据就消失,缓存在客户端那里
  • 无法避免消息丢失
  • 消息有上限,如果消费者订阅的消息没有及时显示就会堆积,堆积有上限

Stream

redis5.0 引入的新数据类型,一个功能完善的消息队列
生产者常用命令

  • xAdd 【队列的key】* 【【key】【value】…】
    每添加一次,即使有多个k_v对,也算一条消息
    学新通
  • xLen 【队列的key】 查看队列中消息的数量
    消费者常用命令
  • Xread count 【一次读取条数】block 【阻塞时间】 strames 【队列的key…】 $ 读取阻塞等待读取最新的消息
    学新通
    注意:只读取最新的消息,当突然来了很多消息时 例如 按序依次 n1,n2,n3,可能只读取其中最新的 n3 。造成 n1、n2漏读

优点

  • 消息可回溯 读完之后不会消失,可永久的保存在消息队列中
  • 一个消息可以被多个消费者重复读取
  • 可以阻塞读取

缺点

  • 当每次读取最新消息时,存在漏读风险

消费者组

将多个消费者划分到一个组中,监听同一个队列

  • 消息分流:队列中的消息 只能由组内其中一个消费者获取,形成组内竞争关系,只有最早获取的才能够进行消费,加快信息处理的速度
  • 消息标识:消息组中会记录最后一个被处理的消息,使得即使宕机也会找到没有处理的消息。确保每一个消息都会被读取。只有一个标记,只要一个消费者读取,其他消费者就不能读取
  • 消息确认(可选):组中消息被消费后,会处于一个padding状态,并放入padding-list中(每个消费者都有自己的padding-list)。直到被消费的消息 被成功处理后,消费者返回一个XACK确认,组中才会将消息从padding-list中移除。确保即使数据被消费后,消息会有3个状态:未消费,已消费未确认,已确认 。使得没有被成功处理 也可以再次重来 。确保消息会被成功处理

常用组命令

  • Xgroup create 【要在哪个队列中创建 队列的key】【组的key】【消息编号 0:第一个 ,表示想要之前的队列的数据 $:最新的一个,不要之前的数据】 在消息队列中 创建一个消费者
  • Xgroup destory 【队列的key】【组的key】 在哪个消息队列中删除哪个

常用消费者命令

  • Xgroup createConsumer 【队列的key】【组的key】【消费者key】 在组中添加消费者,通查在读取时自动创建
  • Xgroup delConsumer 【队列的key】【组的key】【消费者key】 删除组中消费者

读取消息命令

  • XreadGroup group 【组的key】【消费者key ,没有则创建】count 【每次读取的消息条数】(block 【阻塞读取时间】) streams 【队列key】【消息种类:>:代表未消费的最新消息 其他任意字符:已消费 未确认的消息 从padding-list中读取】
    学新通
  • Xack 【队列key】【组key】【消息编号…】 确认已经消费的消息,将他们从padding-list中移除
  • Xpending 【队列key】【组的key】【消息起始序号 - :为最小】【消息截止序号 :为最大】【每次读取的消息数】(【消费者key】)
    学新通

java中使得每个处理的消息都能够被反复执行,而不丢失

学新通
处理流程
学新通
继承stream的优点

  • 消息可回溯,stream是redis的一种数据结构,可以进行持久化
  • 可以阻塞获取
    添加组机制后优点
  • 同组消费者进行争抢消费,且只能消费一次 确保消息处理效率且防止重复处理
  • 采用消息标识,不会漏读消息
  • 采用消息确认,保证消息在被成功处理前不会丢失

三者对比

学新通
reids 提供的mq共同缺点:

  • 消息持久化是建立在redis持久化基础上的 ,而redis持久化不能百分百保证
  • 只支持消费者的确认(发动Ack),而没有生产者确认机制。在生产者向消息队列中发送消息这一过程,也有可能出错
    更多功能需要使用专业的MQ。

使用redisMQ完成异步秒杀

使用MQ的Stream方式结合group机制 代替之前的JVM的阻塞队列

  1. 为每个异步任务创建一个消息队列
    异步秒杀任务,创建一个队列名为 stream.order.voucher
    采用创建组时,如果指定的队列key不存在将队列也进行创建 使用 mkstream
    xGroup create stream.order.voucher g1 0 mkstream
  2. 修改lua脚本,成功后直接向redis队列中发送订单信息 免去java自己去指定阻塞队列,自己去放入数据,取出数据
    学新通
  3. 父线程先去生成订单编号作为参数,执行脚本 根据脚本结果进行判断返回前台,如果创建成功,脚本会自动将订单信息发送到队列中
    学新通
    学新通
  4. 子线程从redis指定消息队列中获取消息,完成真正下单任务
    从队列取出进行处理
    学新通
    从失败队列取出再次处理
    学新通
    真正操作数据库的方法

学新通
通过redisMq处理异步消息,使得前台返回速度加快,后台消息不会丢失,且集群环境下能够一起处理,合理分配任务且不会重复

总结

学新通
学新通

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgfheja
系列文章
更多 icon
同类精品
更多 icon
继续加载