黑马点评 项目笔记&面试包装

很久很久没更新,因为这三个月都在学Java,现在准备去面试了,把自己对于这些项目的理解和知识点都梳理一遍。希望面试能过

Redis在Java客户端的使用

SpringDataRedis的使用

SpringDataRedis模块是对Jedis和Lettuce的整合与封装,并且提供了一套相同的Redis操作接口RedisTemplate.

1.引入SpringDataRedis的相关依赖,包括spring-boot-stater-data-redis依赖和连接池依赖commons-pool。这是因为不管是Jedis还是Lettuce,底层都是通过commons-pool连接池来实现连接池效果。

2.在application.yml文件中添加redis相关的配置,包括redis地址与端口,密码,Jedis实现还是Lettuce实现,以及对应的连接池配置。Spirng默认使用Lettuce实现,如果选择Jedis的连接池,需要在pom文件中额外引入Jedis的依赖。必须要配置连接池参数,不然连接池不会生效。

3.在代码中注入RedisTemplate并且使用


RedisTemplate的Serializer

RedisTemplate的方法接受的并不是字符串,而是Object。redisTemplate底层默认对这些接收的对象使用的是jdk的序列化工具ObjectOutputStream。这种序列化器可能会导致一些存储的数据不一致的问题。因此要改变RedisTemplate的序列化方式。

RedisSerializer接口有一些序列化器的实现类(包括JDK的序列化器),帮助我们实现自动的序列化与反序列化。其中有两个比较有用:StringRedisSerializer(我们存储的key一般是字符串,因此key的序列化器可以用它)、GernericJackson2JsonRedisSerializer(将对象转换成字符串的序列化工具,值value的序列化器可以使用)。

我们可以通过SpringBoot的自动装配机制,将自定义的RedisTemplate对象注册成Bean对象,依赖注入的时候通过对泛型的声明自动引入我们所创建的对象。RedisTemplate对象的创建需要一个Redis连接工厂,而这个连接工厂会由SpringBoot自动为我们创建, 直接import之后当作参数注入进来就OK了。创建Bean的具体步骤如下:

1.创建RedisTemplate对象

2.设置连接工厂

3.创建Key序列化工具(RedisSerializer接口的一个静态方法能直接返回)和JSON序列化工具(注意这里要在pom文件中引入Jackson的依赖)

4.设置Key和HashKey的序列化

5.设置Value和HashValue的序列化

6.返回RedisTemplate对象

依赖注入的时候直接注入RedisTemplate<String, Objet>。毕竟这个才是我们自定义的Bean。


StringRedisTemplate

上面这种方法会存在一点点问题:当我们写入对象到redis当中时,redis中存储的value的值不仅包含这个对象的属性值,还会包含这个对象所属类的字节码(包路径)。这是不可避免的,因为自动反序列化的时候需要知道对象具体的类的信息。但是有的时候类的字节码所占的内存空间比数据信息还要大,会造成空间浪费。

因此为了节省内存空间,我们不使用JSON序列化器来进行自动的序列化与反序列化,而是统一使用String序列化器(也就是上面提到用来给key序列化的StringRedisSerializer),要求只存储String类型的Key和Value就行了。存储对象时我们手动进行序列化与反序列化。

想要使用统一的String序列化器,我们是不是又要注册一个RedisTemplate对象到Bean容器里,重写它的序列化器呢?Spring默认提供了一个StringRedisTemplate类,他的序列化器就是StringRedisSerializer,并且会注册到容器中,使用时直接注入就好了。

因此使用这个类的时候,当我们要存储对象时,需要先手动序列化成json字符串;取出的时候要手动反序列成对应的对象。


短信登录

基于Session实现短信登录

登录流程:

这里多提一下Spring中Session的机制:假设说Controller方法中接收一个HttpSession session作为参数,那么Spring会根据客户端传递过来的cookie中的sessionId来找到对应的HttpSession对象,然后将其注入到这个方法中。

因此这个流程方法中,首先发送验证码时,假设本地没有存储cookie或者cookie失效了,Spring会自动创建一个新的Session注入到Controller方法中(前提是Controller中接受一个HttpSession对象作为参数),有的话就直接用现成的;之后生成验证码放入session中,或者覆盖掉原先的验证码;之后返回给前端时候,会自动将Session的sessionId返回给客户端,不需要显式去编写代码;之后的请求中就会携带这个cookie作为参数,然后Controller层注入对应sessionId的session对象,进行后续操作。

对于校验登录状态功能,可以放到一个拦截器Intercepter来做,因为后续可能会有很多请求都会需要校验用户登录状态,一个个在service里写代码太冗余了。拦截器里获取用户的登录状态,并将用户信息存储到threadLocal里,这样该请求后续的操作就能拿到用户信息。


基于Redis实现短信登录

假设说我们的服务部署多台,也就是多个tomcat实例,那么不同tomcat下的session是不共享的,比如我在tomcat1这个服务实例下实现了登录,在session中已经有了我的user信息,但是在tomcat2下没有存储。后续在负载均衡请求到tomcat2时,还要重新进行登录。解决方法可以使用tomcat的session的共享方案,即多台tomcat的session进行拷贝共享,但是会造成存储空间浪费、数据延迟与不一致的问题。

因此,我们就可以使用Redis中的键值对来代替session。

登录流程图修改如下:

这里以随机token作为user对象的key主要是考虑安全性。假如以user:phone作为key,那么很容易就被人抓包修改token的值,假设被攻击用户已经登录,Redis存在该用户的信息,攻击者的请求就会判断当前用户已登录从而达到攻击目的。同时token作为手机号保存在前端,也有手机号泄漏的风险。

同时登录校验拦截器也要修改,需要从Redis中获取用户对象然后存入到ThreadLocal中。

这里多提一点,修改拦截器的时候,需要注入StringRedisTemplate,但是因为拦截器是我们自己定义并New出来的,不能使用Spring注解的方式进行注入,只能让StringRedisTemplate作为构造函数的参数,谁调用它,谁就注入它。


登录状态的刷新问题

目前我们的项目中,用户的登录凭据只会保存30分钟,不管你是否访问,30分钟后就要重新登录,这对用户体验不友好。因此,我们要采用登录凭据更新策略。

这个策略所考虑的内容如下:

1.首先 这个不能放在之前定义的拦截器中。因为之前定义的拦截器只是拦截对于用户信息有需求的路径,假设用户已经登录了,但是一直访问的是不需要用户信息的路径,那么无法触发拦截器中的更新策略。这个更新策略应该放在一个对所有路径生效的拦截器中。

2.因此可以考虑再当前拦截器之前再添加一个拦截器,将与token有关的操作全部放在这里,包括获取用户token、将用户信息存入threadLocal、刷新token有效期,之后放行到原先的拦截器,原先的拦截器只做对于threadLocal中用户信息的判断,如果存在,那么放行;不存在的话就是前一个拦截器没有查到token对应的用户信息,用户登录凭据失效或者根本没登录,直接拦截。

这里多提一下拦截器加载的顺序问题。拦截器有一个优先级的值,默认值是0,值越小优先级越高。如果优先级都一样,按照加载拦截器的顺序进行执行。

缓存相关

缓存更新策略

当数据库中的数据发生修改,但对应的缓存数据没有及时同步更新时,就会造成数据不一致的问题。要解决这些问题,就要采用一些缓存更新策略,如下:

1.内存淘汰:内存不足的时候淘汰部分数据,默认开启

2.超时剔除:给缓存添加TTL时间,到期自动删除缓存

3.主动更新:自己定义更新时机

其中主动更新有如下几种策略:

1.Cache Aside Pattern 更新数据库的同时,更新缓存

2.Read/Write Through Pattern 缓存和数据库整合为一个服务,由服务维护一致性

3.Write Behind Cacheing Pattern调用者值操作缓存,由其它线程异步将缓存数据持久化到数据库。相当于CacheAside的相反思想

企业中用的更多的是Cache Aside,一般是更新的时候删除缓存。通常需要考虑缓存与数据库的同时成功或失败。还有就是删缓存和更新数据库的先后顺序,一般是操作数据库再删缓存,具体原因如下:

下图的概率远远比上图的概率小,因此选择下图先操作数据库的方案


缓存穿透

用户构造恶意请求,请求数据在缓存和数据库中都不存在,缓存永远不会更新,所有请求都会打到数据库。这就是缓存穿透。

解决方法有两种,缓存空对象和布隆过滤。

缓存空对象就是将客户端发起的不存在的请求数据用一个null值缓存在redis当中,接下来如果攻击者恶意连续请求这个不存在的数据,都会命中缓存中的空对象。但是可能造成redis额外的内存消耗。

布隆过滤器是一种二进制位的过滤器,被布隆过滤器拒绝的请求,数据肯定不存在;允许的请求,数据不一定存在。


缓存击穿

当一个热点数据失效时,大量的请求都打到数据库里,给数据库造成巨大压力。

解决方案:

  1. 可以给缓存失效时查询数据库的代码块添加互斥锁,同一时间只有一个线程能进入数据库操作,解决了对数据库造成的压力;同时其它没有获取到锁的线程休眠一会,重新尝试查询缓存,命中了返回,没命中获取锁继续,重复这个流程。

这个方法的缺点是会有大量的线程在获取锁的线程操作的这一时间段内只能等待,性能较差。

2.设置热点Key逻辑过期时间

给热点Key存储的时候不设置TTL,而是存储一个逻辑过期时间(逻辑过期时间作为数据的一个字段)。当请求查询到热点Key时,需要判断一下是否超过逻辑过期时间,如果过期了,那么尝试获取一个互斥锁,获取成功之后创建一个独立的子线程进行查询数据库更新缓存的操作,执行完毕之后释放锁。原来的父线程直接返回旧的数据,并且若是有其他线程获取热点Key也发现逻辑时间过期,但是没获取到锁的时候,直接返回旧数据。


缓存雪崩

缓存雪崩是指当大量数据都是在同一时间内失效的时候,同时大量的用户请求这些数据,缓存中命中不到,这些大量的请求就会打到数据库里,给数据库造成巨大的压力。

解决方法有以下两点:

1.打散缓存数据的过期时间,在设置有效期的额外加一个小的随机时间值

2.当redis宕机的时候,也会导致缓存雪崩的问题,针对这个问题可以使用Redis集群来提高服务的可用性

优惠券秒杀

用户抢购时,就会生成订单并保存到tb_voucher_order这张表中。

全局唯一id

订单ID是不能做自增长的,一是会暴露一些信息,二是当订单量较大时,可能会采用多表的系统,如果每个订单表的Id都自增长,就会导致订单的id全局不唯一的问题,因此就要用到全局ID生成器。

我们可以自定义一个全局ID生成器,用一个long型的数据类型,第一位0是符号位,再往后31位当前时间戳,再往后32位是序列号,支持每秒32位个不同ID。这个序列号可以用一个Redis中的一个key来做自增长


优惠券秒杀功能 version.01

优惠券秒杀功能是在voucher/seckill/{id}接口的。用户请求这个接口,携带id,随后进行业务逻辑:

但是以上情况可能存在多线程并发的数据不一致问题,比如库存超卖,这个时候使用乐观锁尝试去解决:

优惠券秒杀功能 version.02

这里多提一下,这个地方不仅应用了乐观锁,还有数据库对于update操作的原子性。我们的update语句是判断库存>0才修改的一条完整语句。你想,假如说还剩最后一件库存,并且数据库对于update操作没有原子性,这样就会导致两个线程同时认为库存充足,并且同时扣减库存。原子性使得库存的扣减只能先后执行,当第一个线程update完了之后,第二个线程执行update时,发现不满足判断条件,自然就不会扣减。

代码就是在上面第一版本的代码基础上,在进行update语句的时候多加一个条件判断当前库存是否大于0。然后假如扣减失败的话,直接返回失败给用户。

优惠券秒杀功能 version.03 添加一人一单功能

添加一人一单功能之后,代码的执行流程如下:

但是这个代码还是会存在问题:假如一个用户使用某些工具高并发地请求这个接口,同时有多个当前用户请求的线程进入判断订单是否存在的代码块,此时是还没有下单的,因此多个线程都会判断为不存在订单,然后执行扣减库存的策略。这样,库存就会扣减多份,生成多份订单Id。

是个时候也得用锁的方案,但是不能是乐观锁,只能是悲观锁。因为乐观锁的思想是去修改一条数据的时,判断它是否被其它线程修改过;我们现在是要查询是否存在一条订单数据,并且添加一条订单数据,这个过程无法使用一条sql语句去完成,至少两条,因此就要用到悲观锁。

我们需要将从查询订单、判断订单是否存在、扣减商品库存、到新增订单这个流程都加上悲观锁(实际上就是查询订到新增订单这个过程)。把这个过程封装成一个方法,并且将这个过程添加上事务。

我们加锁可以对当前用户加锁。因为一人一单出现线程安全问题时,往往是一个用户并发请求造成的。我们直接将用户的id转成字符串,sychronized锁住这个字符串。当不同用户的请求线程执行这段代码块时,是不影响并发性能的。

这里多提一点,用户id转换成字符串的时候,直接调用userId.toString()方法的时候,会创建一个新的String对象,每次创建的对象都不一样,这样也没办法锁住当前用户。要再加一个intern方法。userId.toString().intern()。这个方法会从字符串常量池中寻找值一样的字符串,就能保证字符串的唯一性,锁住当前用户。

这里再多提一点。我们想要锁住用户对象时,是对代码块加锁的。但是事务是对于整个方法的。当我们锁释放的时候,此时事务还没提交,其它线程获取锁发现数据库依旧没有这个订单,也会造成线程安全问题。因此我们的锁的范围就应该大一些,锁住整个关于这个流程的方法,在锁里面调用这个封装好的方法。这样就没问题了。

这里再再多提一点,因为封装的这个函数加了事务,我们调用这个函数的时候就要调用它的代理对象。我们可以通过AopContext.currentProxy()拿到当前对象的代理对象,然后通过代理对象调用这个函数。


集群模式下的并发安全问题

之前我们添加的synchronized悲观锁的时候,是基于jvm的。如果我们启动多台实例,单个线程下的synchronized锁的作用范围只能是请求到当前这台实例的用户对象,对于不同实例,用户的请求可以重新获取锁,就会导致并发安全问题。

如下:

因此就需要引入分布式环境下的锁:分布式锁


分布式锁

我们考虑使用Redis进行构建分布式锁。Redis中有一个setnx的命令,当key不存在时,则创建并返回1;当key存在时,则不创建并且返回0。这种操作就满足了分布式锁的互斥性;同时可以释放锁,直接del key就行。同时可以给锁设置过期时间,当获取锁的线程执行过程中出现异常,执行不到释放锁,过段时间锁会自动释放。

分布式锁可能会有误删问题,如下:

解决误删问题的方法就是释放锁的时候,判断当前锁的标识是不是自己的线程,如果不是,那么就不释放。

但是还有一种极端情况,如下:

解决问题的方式就是让判断锁和释放锁两个操作原子化。这是两条Redis命令,我们可以使用Lua脚本编写这两条命令,保证原子性。

RedisTemplate调用Lua脚本的API如下:


基于Redis的分布式锁优化

以上的Redis分布式锁还是存在一些问题:

1.不可重入:可能会导致死锁的问题。比如线程1调用方法A,方法A需要获取锁;获取锁成功之后在方法A里调用方法B,方法B也要获取锁,但是此时的锁不可重入,方法A持有并等待方法B完成,方法B因为等待锁的释放无法继续执行,形成死锁。

2.不可重试:当前我们设计的锁,只尝试一次,失败就返回false,没有重试机制。

3.超时释放:虽然我们设置了超时释放,并且设置了一些关于线程安全的机制,但是假如说业务时间比较长,锁超时释放了,还是会造成线程并发的安全问题。

4.主从一致性:假如使用的是Redis集群,Redis集群的主从同步是存在延迟的。假如说一个线程获取锁,主节点没有同步给其它从节点时,主节点宕机了,其它从节点挑选一个作为主节点,此时主节点中没有那个锁了,可以再次获取,造成线程安全问题。

对于以上这些问题,Redisson框架可以完美解决,不需要亲自实现


Redisson

使用Redisson很简单,先在pom文件引依赖,然后创建一个@Configuration配置类,将redissonClient注册为一个Bean。这实际是一个工厂类。在之后的代码中,调用这个Bean对象的工厂方法就能拿到对应的工具。

这里贴一个入门案例:

这样我们就能把之前自己实现的Redis分布式锁改成Redisson中的getLock的锁,实际上不用怎么改动,改一下创建锁对象、获取锁的代码就OK:

秒杀优化-优惠券异步秒杀

目前我们写的优惠券秒杀的接口中,所有的操作都是对数据库的操作(除了锁的操作),并且是串行执行,所以性能比较堪忧。

对于秒杀库存和一人一单的判断,我们都可以依赖于Redis来操作。若是有资格之后,保存对应的信息到消息队列中,另一个线程读取队列中的消息,完成下单。

对于基于Redis完成秒杀资格的判断,我们需要在Redis中存储对应的信息:优惠券库存的信息、优惠券是否被多次购买的信息。这两个很好实现,库存信息就用一个String结构,Key是优惠券Id,值是库存数量;对于第二个,可以使用Set结构,Key是优惠券Id,值是购买了这个优惠券的用户的Id。这里涉及到了多条的Redis命令,因此要使用Lua脚本保证原子性。具体的实现思路如下:

这里多提一点,新增秒杀优惠券的同时,要把优惠券的信息写入Redis。


基于阻塞队列实现异步下单

首先我们通过阻塞队列BlockingQueue来实现异步下单。

之后就是将优惠券id、用户id、订单id放入阻塞队列中。首先将这些信息都封装成一个订单对象,然后调用阻塞队列的add()方法把订单对象放入阻塞队列。

然后就是准备线程池和线程任务,不断地从阻塞队列取出订单下单。

我们需要在类初始化完毕之后,订单处理的任务(实现runnable的那个方法)就立刻开始执行。这里我们可以用一个Spring提供的注解@PostConstruct来声明一个任务。对应代码如下:


基于Stream消息队列实现异步下单

实现思路:

下面贴一下我在简历上对于黑马点评这个项目的描述:

希望能有所帮助!

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇