在项目中唯一的商品就是优惠券,优惠券的抢购就类似于商品的抢购,会有 voucher-order表,而表的若采用自增会有如下问题
唯一性
高可用
:不会轻易的无法使用高性能
:生成id的速度足够快递增性
:整体呈递增趋势,使得数据库更方便建立索引安全性
:不能有规律唯一id生成策略
利用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
对象
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
出现集群导致jvm的悲观锁失效,出现一人多单问题
因此集群环境下要使用分布式锁
类似于解决缓存击穿时使用的进程锁
分布式锁是一个全局可见的唯一的标识,并能够处理异常情况下锁的释放问题
同处理缓存击穿时相同,利用setNx
的互斥性 进行线程锁,使得同一个用户发来的多个进程争抢锁,使得同一时间只能有一个线程通过
阻塞式获取:
获取锁失败后,阻塞等待
线程释放
非阻塞获取:
获取锁失败后,结果直接
返回
将锁的基本操作封装一个工具类
使用redis锁替代 synchronized ,解决锁同一个用户的多个进程方法是在锁上加上用户id
存在问题:
线程1 还没有遇到阻塞,导致没有执行完锁超时释放被线程2获取,而线程1执行完后,删除了不是自己的锁 (因为同用户锁的key相同),导致正在执行的线程2没有了锁的保护
解决方法,在创建线程时,放入自己线程的标识,在删除时进行判断是否是自己的线程,防止误删
若果创建key时,拼上自己线程编号,会是的每个线程都有自己的锁,导致锁无效
因此,需要在value中放入自己的线程编号
由于线程id是一个jvm内部递增的数字,集群条件下多个jvm可能导致线程id重复,因此要确保线程标识唯一
对封装的锁操作进行改写
线程1刚进入锁就阻塞过期,线程2进入后 线程1立刻阻塞消失 ,两个线程同时在锁中 也会引发安全问题 。在释放锁时若判断不是自己的,是不是应该考虑回滚?
之前缓存击穿是否也需要防止锁被误删?
解决方法 :判断锁和删除锁应该具有原子性,一气呵成
但redis事务只能批处理,一次性执行所有命令,无法获取结果进行判断,要再次结合乐观锁,实现麻烦
所以使用Lua脚本,脚本的在redis中执行是一条命令具有原子性,所有使得其中的所有redis命令执行一气呵成
lua脚本执行是 java调用redis,redis调用lua脚本
在spring资源目录下 创建一个lua脚本,名为unlock.lua
修改封装的释放锁方法
原来方式缺少的功能:
不可重入
:同一个线程多次尝试获取通一把锁setNx
的互斥性,即使是同一个线程,在不释放的前提下也不能再次获取 。 若B要等待锁的释放,会导致A执行阻塞,锁长时间不释放 形成死锁,不可重试
: 锁被占用,会进行阻塞重试超时释放
:锁的过期时间设定问题 ,过长会导致故障时间长,过短会导致任务没有执行完,锁被其他线程抢去主从一致问题
:读写一致问题,在集群条件下,锁在多个节点同步过程可能出现延迟,或在同步过程宕机导致锁丢失redisson :使用redis的在分布式情景下的一款redis客户端工具
使用方式:
springboot redission starter
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
一个线程多次获取到锁,称为可重入锁。自定义获取锁的方式是采用 setNx lock thread
,当此线程想要再次获取锁时,还是通过setNx lock thread
,导致获取失败。对此线程和其他线程不加判别一律失败。
而 Redission 采用的是 setNx lock thread 1
这种hash结构,key 是锁的标识 filed是线程的标识,value是获取的次数。当此线程再次获取时,会对线程进行判断,然后value值+1,在释放时,同样进行判断然后 value-1 直到为0,释放锁
流程图
有多次查询判断和数据操作不是同时进行,为防止线程安全问题,保证原子性,使用lua脚本
redission并没有一味的循环尝试,而是根据自己的剩余的时间和锁
RedissonLock 类
第一次获取,若失败,则在自己剩余的时间里,监听锁的释放时间
若监听到锁的释放却没有争取到,则开始再次进行订阅,在自己剩余的时间里,进行循环
在获取锁时,若传递锁过期释放时间,则过期就释放。若不传递,采用看门狗方式,初始为30s,每隔10s刷新过期时间,重新设置10s,即使锁重入也是如此。在释放锁时取消刷新
判断是否是新创建的锁,若是,给添加上定时刷新任务
当redis以主从模式,读写分离下,主节点负责写,然后将数据同步给从节点,从节点负责读。如果创建锁后,在同步给从节点一瞬间主节点宕机,将导致锁的数据的丢失,其他线程还能创建锁,导致并发安全问题
redission 解决方案:
设置三个及以上的主节点,同时向各个节点保存锁的标识。只要有一个节点没来得及同步就宕机,会导致获取锁失败
解决了服务器宕机锁失效问题
缺点 所需服务器个数多,成本高 。代码变复杂
原方式采用的同步连续4次访问数据库,好比饭店服务员接待客人后亲自做好饭才会告诉客人下单成功,使得客人等待时间过长。
异步思路:
具备条件后,就返回信息,留下确定的活慢慢干。先将活揽下,并返回确认信息,使得客户等待时间只有揽活时间,没有背地里干活时间
同时,可以将购买资格判断放入redis中,用户响应信息只有和redis操作,性能大幅提高
优惠券信息:key为该优惠券编号,存入库存。因此采用String结构
订单表信息 :key为该优惠券编号,值为购买过的用户,且不能重复。因此需要采用Set类型
解决以上问题,使用:
由于本身具有的特性:Lpush、Rpush、Lpop、Rpop 具有双向链表的结构。可以利用模拟出一个队列。队列特点:出入口不一致。因此可使用 Lpush和Rpop组合、Rpush和Lpop组合进行模拟。且有BRpop、RLpop进行阻塞获取,可能模拟数据获取等待
由于采用pop方式(移除并获取),只要进行获取数据,无论失败与否,队列中都不会存在此数据
优点:
发布订阅模型 reids2.0引入
每个生产者负责将数据发送至一个或多个频道,多个消费者只需要订阅该频道,即可获取生产者发布消息
生产者常用命令
publish 【频道名称】【要发布的消息】
消费者常用命令
优点:
缺点:
redis5.0 引入的新数据类型
,一个功能完善的消息队列
生产者常用命令:
优点:
缺点:
将多个消费者划分到一个组中,监听同一个队列
被处理
的消息,使得即使宕机也会找到没有处理的消息。确保每一个消息都会被读取。只有一个标记,只要一个消费者读取,其他消费者就不能读取XACK
确认,组中才会将消息从padding-list中移除。确保即使数据被消费后,消息会有3个状态:未消费
,已消费未确认
,已确认
。使得没有被成功处理 也可以再次重来 。确保消息会被成功处理常用组命令:
创建
一个消费者组
删除
哪个组
常用消费者命令:
添加消费者
,通查在读取时自动创建删除
组中消费者
读取消息命令
>
:代表未消费的最新消息 其他任意字符
:已消费 未确认的消息 从padding-list中读取】java中使得每个处理的消息都能够被反复执行,而不丢失
使用MQ的Stream方式结合group机制 代替之前的JVM的阻塞队列
stream.order.voucher
xGroup create stream.order.voucher g1 0 mkstream
通过redisMq处理异步消息,使得前台返回速度加快,后台消息不会丢失,且集群环境下能够一起处理,合理分配任务且不会重复