黑马点评项目记录
基于 Session 短信登录
- @RequestBody注解是用来接收前端发送过来的json字符串
发送短信验证码
用户提交手机号,服务器生成验证码并保存到session,然后将验证码发给用户
短信验证码登录和注册
用户提交手机号和验证码,服务器先校验验证码,然后根据手机号去数据库查询用户,若不存在,则创建新用户,然后保存用户信息到session
- 前台发送的数据格式是json的样式 后台要用@RequestBody注解 实现用LoginFormDTO实体类接收
- 登录成功之后要把用户信息存储到session中。
登录校验(验证登录状态)
用户请求(request)中携带cookie(cookie中带有sessionid,而登录的凭证就是sessionid就保存在cookie中)
服务器从session中获取用户 获取到之后将用户缓存到ThreadLocal
中,方便后续的使用
[!TIP]
在业务中用户的每个请求都是一个独立的线程,所以不能将用户信息保存到本地变量中,这样会出现多线程并发修改的安全问题,因此要使用ThreadLocal技术 #### 登录拦截校验功能
- ⚠️有有很多controller(业务)都需要校验登录状态,不能在每一个controller中都写一遍校验登录状态的业务代码,所以引入spiringmvc中的拦截器,拦截器可以在所有controller执行之前执行,将校验用户登录的流程都统一放在拦截器中
- 同时要注意将拦截到的用户信息传递给每一个controller的时候不能出现线程安全问题,所以用到
ThreadLocal
,让用户的每一个请求都拥有独立的线程。然后每个controller都从对应的ThreadLocal
中取出用户即可。
隐藏用户敏感信息
在/me
中,是从UserHolder中获取的用户信息直接返回,而UserHolder是从拦截器session中取出来的,
而session中的信息是在登录业务中存入的
这里会有两个问题:
session是tomcat的内存空间,存太多信息也会增加服务器的负担
登录校验返回的信息有些太多了,时间密码等敏感信息不需要返回。
- 所以定义UserDTO类来简化存入session的用户信息,即在登录业务的时候将User转为UserDTO
基于session登录的集群的session共享问题
问题:为了服务器的负载均衡,通常会配置多个tomcat服务器进行轮循访问,而多台tomcat不共享session的存储空间(每个tomcat有独立的session),当请求切换到不同的tomcat服务器的时候会导致用户登录数据的丢失
解决方法:让session共享,且应满足:
- 数据共享(让任何一台tomcat访问)
- 内存存储
- key,value结构
(即使用Redis代替session)
Redis代替session
发送短信验证码业务
保存验证码到Redis,且要设置一个有效期
- 以手机号为key(为了确保每一个手机号都有不一样的key,且有助于后面根据手机号获取验证码) 验证码为value
短信验证码登录、注册业务
- 保存用户信息到redis,key用随机token(使用UUID生成)
- value是用户对象,保存对象可以使用string结构(将java对象序列化为json字符串,优点是比较直观)或hash结构(再次将value分为key和value两个部分,将对象中的每个字段独立存储,优点是可以针对单个字段做crud,且内存占用更少),所以优先推荐使用hash结构
- 然后需要将token作为登录凭证(使用手机号作为key的话有泄露的风险),登录之后需要手动把生成的token返回给前端
token有效期的设置
- 更新校验登录状态的代码(写在拦截器中),在里面设置token的更新逻辑
登录拦截器的优化
- 问题1: 此时的拦截器只拦截了部分请求,所以并不是所有请求都能刷新token有效期
solution:在现在的拦截器之前再加一个拦截器,在这个拦截器中拦截一切路径,但只做刷新token和保存用户信息的工作,第二个用户才做登录拦截
- 问题2: 控制拦截器的先后顺序
solution:在注册拦截器的时候添加.order
商户查询缓存
什么是缓存?(cache)
缓存就是数据交换的缓冲区,是存储数据的临时的地方,读写性能较高
如何使用缓存?
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
应用层缓存: 可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
数据库缓存: 在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
CPU缓存: 当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
缓存的成本
- 数据一致性
- 代码维护
- 运维成本
- 硬件成本
添加商户缓存
缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存
如果缓存数据存在,则直接从缓存中返回
如果缓存数据不存在,再查询数据库,然后将数据存入redis,然后将信息返回
缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适
常见的几个策略如下:
内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式),一致性比较差,无维护成本。
超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存。一致性的强弱取决于ttl时间,一致性一般,低维护成本(只要在缓存逻辑上添加一个超时)。
主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。一致性好,维护成本较高。
具体选哪个方法主要看业务场景。
数据库缓存不一致解决方案
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案
Cache Aside Pattern(旁路缓存) 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案✅
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
数据库和缓存不一致采用什么方案
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效(发生了写远大于读的操作,则无效写的操作很多),中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多❎
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存✅
如何保证缓存与数据库的操作的同时成功或失败?(保证事务的原子性)
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用
TCC
(微服务内容)等分布式事务方案
[!TIP]
事务的原子性是指事务中的所有操作要么全部执行成功,要么全部执行失败回滚,没有中间状态。原子性保证了事务的完整性和一致性,即事务中的所有操作要么都生效,要么都不生效,不会出现部分操作生效而部分操作失败的情况。
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
- 先操作缓存还是先操作数据库?(线程安全性问题)
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存✅(该方案的出现问题的可能性较低,因为操作数据库的时间要远长于操作缓存)
缓存更新策略方案的最佳实践总结
低一致性需求
- 使用Redis自带的内存淘汰机制
高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。(只要有请求那么一定会到达数据库)
解决方法
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
布隆过滤器:在客户端和缓存之间再添加一层布隆过滤器的拦截,请求来了之后让布隆过滤器判断这个数据是否存在,若不存在则直接返回,若存在则放行到缓存,后续的流程不变
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器是哈希思想,只要哈希思想,就可能存在哈希冲突
总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
被动方案(亡羊补牢):
- 缓存null值
- 布隆过滤
主动方案(未雨绸缪):
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩
缓存雪崩:缓存中的大量key同时失效或者缓存服务器Redis突然宕机,导致大量请求到达数据库,给服务器带来巨大压力
解决思路
- 给不同的Key的TTL添加随机值(解决key同时失效)
- 利用Redis集群提高服务的可用性(解决Redis宕机)
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存(可以在反向代理nginx等等部分也建立缓存,类似于多层防弹衣)
缓存击穿(热点key问题)
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击(大量线程同时缓存重建)。
常见的解决方案:
互斥锁(排队依次重建)
逻辑过期(不是真的过期 而是由程序员判断是否真的过期,本质上是设置热点key永久有效)
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在redis热点key的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。实际流程如下:
假设线程1去查询缓存,然后从value中判断当前的数据是否过期(如果直接在缓存中未命中,则直接返回空)
如果未过期,那么直接返回信息。
如果过期了,此时线程1去尝试获得互斥锁
如果获取失败了,那么代表以及有线程在缓存重建,线程1直接返回旧信息
如果获取成功了,那么线程1会开启另外一个独立线程2去执行缓存重建,自己依旧返回旧信息。
假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
两种方案的对比
两种方案都是在解决缓存重建过程中的并发问题
互斥锁方案: 强调一致性
由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,
缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 强调可用性
线程读取过程中不需要等,性能好,有一个额外的线程持有锁去进行重构数据,
但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
利用互斥锁解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询。
操作锁的代码:
核心思路就是利用redis的setnx方法来表示获取锁(锁其实就是redis中存储的一个key),
该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true,、如果有这个key则插入失败,则返回0,
在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
注意
在 Java 中,Boolean 是一个包装类,用于封装基本数据类型 boolean 的值。
当使用 Boolean 类型进行自动拆箱(Unboxing)时,如果 Boolean 对象的值为 null,就会触发 NullPointerException 异常。
例如:
1 | Boolean boolObj = null; |
在上面的例子中,当试图将 null 的 Boolean 对象自动拆箱为基本数据类型 boolean 时,会导致 NullPointerException 异常。
为了避免这种情况,应该在进行自动拆箱之前先检查 Boolean 对象是否为 null,或者使用条件语句处理可能为 null 的情况。
利用逻辑过期解决缓存击穿问题
- 添加过期时间
因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,但是这个方案修改了原来的代码,不符合代码的ocp(开闭原则)。
因此我们选择新建一个实体类,此时又有两种选择,一个是继承原来的shop类,优点是较为简单,但是依然需要对原来的代码进行少量修改;而另一种方案是在该类中新建一个存储数据的对象。
- 开启缓存重建的线程的时候调用线程池
缓存工具封装(⭐️)
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
存:
- 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间(往redis中存数据)
- 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
取:
- 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
- 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
方法1、3用来解决普通缓存问题;方法2、4是用来解决热点key问题的
⭐️优惠券秒杀
全局唯一ID
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
Redis实现全局唯一ID
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分(⭐️):
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
在序列号上添加日期部分(每天一个key)可以避免序列号数量超过redis的存储上限(2^64,其实此处最多只能达到2^32,其次还可以便于基于日期部分统计每一天的下单量。)
全局唯一ID的生成策略
- UUID
- Redis自增
- snowflake(雪花)算法
- 数据库自增(用一张额外的表来专门实现自增)
优惠券(这一块细节很多,可以作为项目的难点)
普通券和秒杀券
库存超卖问题
问题原因
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
解决方法
悲观锁:认为线程安全问题一定会发生,因此在操作数据之前一定要先获取锁,确保线程串行执行
乐观锁:认为线程安全问题不一定会发生,因此不加所锁,只是在数据更新时才去判断有没有别的线程对数据进行了修改。
- 如果没有修改则认为是安全的,自己更新数据
- 如果已被其他线程修改过说明发生了安全问题,此时可以重试或异常
乐观锁CAS法解决超卖问题
乐观锁的关键是判断之前查询的数据是否被修改过,常见的方法有两种
- 版本号法:简单来说就是给数据添加一个版本号字段,一旦执行修改数据的操作,那么同时就要修改版本号,修改的之前要确认此时的版本号和查询数据库的时候的版本号是否一致,一致则说明在查询和修改的这段时间内没有其他线程修改过数据,不一致则不执行操作
- CAS法(compare and switch):版本号法的简化,由于每次查询和修改的时候版本号和数据数量都做了修改,那么就不用多此一举多设置一个版本号,只要在修改之前再一次比较一下当前的数量是否和查询时的数量一致即可。
一人一单问题
只需要在秒杀业务中添加一个判断:在判断库存是否足够之后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单。
也会出现高并发问题,需要给减库存操作和创建订单操作添加悲观锁,要注意因为是一人一单,所以只要对userid加锁,而不用对整个方法加锁;这里还有一个细节,在判断userid前后是否一致的时候,由于是一个字符串对象,所以必须要对该对象调用.intern()方法才能到字符串常量池中去找原先的对象,否则就会new一个新的对象,导致即使数值一致,但判断依然是不一致(因为是两个不同的对象)
集群环境下的并发问题(syn锁失效)
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
有关锁失效原因分析:
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
分布式锁
基本原理和实现方式
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。
那么分布式锁他应该满足一些什么样的条件呢?
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环
常见的分布式锁有三种
Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx(当我们尝试往数据库中set一个数据的时候,只有数据不存在才能set成功)这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于本套视频并不讲解zookeeper的原理和分布式锁的实现,所以不过多阐述
### Redis分布式锁的实现核心思路
实现分布式锁时需要实现的两个基本方法:
获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间,如果服务器发生了宕机或者别的意外,锁始终会自动释放,从而避免了死锁的发生
核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可
[!TIP]
在使用自动拆箱的时候一定要记得考虑发生空指针异常的可能性。
实现分布式锁
加锁逻辑
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
获取锁的时候还需要存入线程标识(可以用UUID表示)
释放锁的逻辑
- 释放锁的时候要确认锁的线程标识,判断是否与当前线程标示一致,如果一致则释放锁,如果不一致则不释放
分布式锁的原子性问题
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了(发生了阻塞或其他原因导致超时自动释放),那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性(一起执行,中间没有间隔)的,我们要防止刚才的情况发生,
Lua脚本解决多条命令原子性问题
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
这里重点介绍Redis提供的调用函数,语法如下:
1 | redis.call('命令名称', 'key', '其它参数', ...)␍ |
我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。
利用Java代码调用Lua脚本改造分布式锁
接下来我们来回一下我们释放锁的逻辑:
释放锁的业务流程是这样的:
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样:
1 | -- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示 |
Java代码
1 | private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; |
小总结:
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
- 特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
- 特性:
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦
测试逻辑:
第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。
分布式锁redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
分布式锁-redisson功能介绍
基于setnx实现的分布式锁存在下面的问题:
重入问题:重入问题是指获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: (可简单理解为读写分离模式,主节点和从节点可能分别负责写和读的操作,这样就可以同时在多个节点上进行写和读的操作,如果主节点宕机了,那么还可以从分节点中挑一个继续作为主节点,但是主从节点之间的同步是有延迟的)如果Redis提供了主从集群(一个主节点和多个分节点),当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
redisson 入门
- 引入依赖
1 | <groupId>org.redisson</groupId> |
- 配置Redisson客户端
1 |
|
redisson可重入锁的原理
在尝试获取锁的时候,如果发现现在的锁已经被人持有,会去再判断一下持有锁的人是不是自己,如果是,那么也会获取锁,同时也会设置一个计数器来计算获取锁的次数
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
redisson锁重试和watchdog机制
看门狗机制是用来在获取锁的时候更新锁的失效时间的
为了解决基于setnx实现的分布式锁的不可重入、不可重试、超时释放和主从一致性的问题,redisson分布式锁的解决方案
可重入:利用hash结构记录线程id和重入次数
可重试:利用信号量和PubSub功能实现等待(等待释放锁的消息)、唤醒,获取锁失败的重试机制
超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间
redisson锁的mutilock原理
为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例
此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了
为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有 所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
所谓的联锁,就是多个独立的锁,而每一个独立的锁就和前面的锁完全一样
redisson分布式锁原理总结
1)不可重入Redis分布式锁:
原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效
2)可重入的Redis分布式锁:
原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
缺陷:redis宕机引起锁失效问题
3)Redisson的multiLock(可看作多个可重入式锁的集合):
原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂
Redis优化秒杀
秒杀优化-异步秒杀思路
当用户发起请求,此时会请求nginx,nginx会访问到tomcat,而tomcat中的程序,会进行串行操作,分成如下几个步骤:
查询优惠卷
判断秒杀库存是否足够
查询订单
校验是否是一人一单
扣减库存
创建订单
优化方案:
们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点:
第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断
第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了
redis完成秒杀资格判断
基于阻塞队列实现秒杀优化
阻塞队列:当一个线程从队列中获取元素的时候,如果队列中有元素会返回,如果队列中没有元素则会被阻塞,直到队列中有元素才会被唤醒去获取元素。
[!TIP]
@PostConstruct注解:在 Java 中,@PostConstruct 注解用于指定一个方法在构造函数执行之后、依赖注入完成之后执行。它标识了一个初始化方法,该方法会在对象的所有依赖项都注入完成后自动被调用。
使用 @PostConstruct 注解的方法必须满足以下条件:
方法不应该有任何参数。
方法的返回类型应该为 void。
方法不能是静态的。
总结:
秒杀优化的思路:
- 改同步下单为异步下单,将业务分为两部分,利用Redis完成下单资格(库存余量、一人一单)的判断,及时响应
- 具体下单的业务则放入阻塞队列,开启独立线程慢慢完成。
基于阻塞队列的异步秒杀有什么问题?
- 内存限制问题
现在使用的是jdk的阻塞队列,使用的是jdk的内存,在该并发的情况下可能会有无数的订单被创建放进阻塞队列里,可能导致内存溢出的问题(OOM),虽然在创建阻塞的队列时候设置了上限,但是如果队列满了,那么有新的订单来的时候就存不进去了。 - 数据安全问题
现在是基于内存来保存订单信息的,但是如果服务器突然宕机了,那么内存中的所有订单信息就会消失。
有一个线程从队列中取出了一个订单要去执行,此时突然发生异常导致该任务执行失败,由于任务从队列中取出来就没有了,那么该任务以后再也不会被执行,导致任务丢失。
Redis消息队列(Message Queue)实现异步秒杀
认识消息队列
什么是消息队列:字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
使用队列的好处在于 解耦:所谓解耦,举一个生活中的例子就是:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的。
这里我们可以使用一些现成的mq,比如kafka,rabbitmq等等,但是呢,如果没有安装mq,我们也可以直接使用redis提供的mq方案,降低我们的部署和学习成本。
redis 消息队列
基于List结构模拟消息队列
双向链表很容易模拟出队列的效果
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。
基于List的消息队列有哪些优缺点?
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点
- 无法避免消息丢失
- 只支持单消费者
基于PubSub(发布订阅)的消息队列
顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。与基于list 结构的消息队列相比最大的改进就是能支持多个消费者,但是不支持数据持久化。
SUBSCRIBE channel [channel] :订阅一个或多个频道
PUBLISH channel msg :向一个频道发送消息
PSUBSCRIBE pattern[pattern] :订阅与pattern(通配符)格式匹配的所有频道
基于PubSub的消息队列有哪些优缺点?
优点:
- 采用发布订阅模型,支持多生产、多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失(消息不在内存中保存,当发布消息的时候,如果有消费者监听,那么消息会放在消费者客户端的缓存区域,如果在消费者处理的时间段内,又来了很多消息,那么就会导致消费者缓存区域的堆积,超出数据会丢失)
基于Stream的消息队列
Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。
STREAM类型消息队列的XREAD命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
基于stream的消息队列-消费者组(XREADGROUP)
STREAM类型消息队列的XREADGROUP命令特点:
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
总结redis在秒杀场景的应用
- 缓存
- 分布式锁
- 超卖问题
- lua脚本
- Redis 消息队列
达人探店
发布探店笔记
查看探店笔记
点赞功能
完善点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
为什么采用set集合:
因为我们的数据是不能重复的。
点赞排行榜(展示最先给笔记点赞的几个人)
之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet
我们接下来来对比一下这些集合的区别是什么
所有点赞的人,需要是唯一的,所以我们应当使用set或者是sortedSet
其次我们需要排序,就可以直接锁定使用sortedSet
好友关注功能
关注和区关
针对用户的操作:可以对用户进行关注和取消关注功能。
实现思路:
需求:基于该表数据结构,实现两个接口:
关注和取关接口
判断是否关注的接口
用户与用户之间关注的关系是一种多对多的关系,所以在数据库中专门创建一张表,设计博主id和粉丝id,用插入和删除来表示关注和取关,存在则关注,不存在则没关注。
共同关注
需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同关注呢。
当然是使用我们之前学习过的set集合咯,在set集合中,有交集并集补集的api,我们可以把两人的关注的人分别放入到一个set集合中,然后再通过api去查看这两个set集合中的交集数据。
关注推送
当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找
Feed流的实现有两种模式:
Feed流产品有两种常见模式:
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈␍
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷␍
- 缺点:如果算法不精准,可能起到反作用␍
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
我们本次针对好友的操作,采用的就是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可
因此采用Timeline的模式。该模式的实现方案有三种:
- 拉模式
- 推模式
- 推拉结合
拉模式:也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从他关注的所人的发件箱中的读取到他自己的收件箱,再进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
推模式:也叫做写扩散。(群发)
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取␍
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。(粉丝多的用主要用拉模式,但是会主动推到活跃粉丝,粉丝少的用推模式)
推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
推送到粉丝收件箱
需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
传统了分页在feed流是不适用的,因为我们的数据会随时发生变化
假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10 ~ 6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做
Feed流的滚动分页
我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据
举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
[!TIP]
如果在数据会发生变化的情况下,不要用list,用sortedSet
实现分页查询邮箱
- 首先不能按照角标查,必须按照score滚动分页(最大值,最小值,偏移量(第一次0 ,以后取决于上一次查询的最小值的个数),查询的数量)
附近商户的功能
GEO数据结构的基本用法
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
将数据库中的店铺信息导入到Redis中的Geolocation
将数据库表中的数据导入到redis中去,redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。
但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,
所以我们可以按照商户类型做分组,类型相同的商户作为同一组,
以typeId为key存入同一个GEO集合中
用户签到功能
BitMap
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。
BitMap中常见操作指令:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值,读多个比特位。
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
实现签到功能
需求:实现签到接口,将当前用户当天签到信息保存到Redis中
思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。
我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。
签到统计
问题1:什么叫做连续签到天数? 从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。(优雅)
关键逻辑代码:
1 | // 6.循环遍历 |
UV统计
HyperLoglog
首先我们搞懂两个概念:
UV
:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。PV
:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog
(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理可以参考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。