点评项目面经总结

点评项目面经总结

1. 使用Redis解决了在集群模式下的Session共享问题,使用拦截器实现用户的登录校验和权限刷新

Session共享问题:在多台Tomcat服务器的集群模式下,多台Tomcat之间不会共享Session,导致在Tomcat1中携带有用户的登录信息,但Tomcat2中没有,当用户使用Tomcat2时,会判断为未登录

  • 基于Redis实现:
    • 将随机生成的token作为key值,保证了唯一性的同时又方便携带,同时token有对应的存活时间
    • 使用RedisHash 数据结构存储用户数据(对象)

img

  • 登录校验+权限刷新(两个拦截器)
    • 权限刷新:第一个拦截器拦截一切路径,获取网页携带过来的token,使用这个token去查询Redis中对应的用户信息,将查到的用户保存到ThreadLocal中,刷新对应token的ttl,然后放行。
    • 登录校验:第二个拦截器只拦截需要登录的路径,查询上面的ThreadLocal中是否有用户信息(在上面一步中,如果token查不到Redis中有对应的用户信息,说明用户没登录,ThreadLocal中是空值),如果没有则拦截;否则放行。

2. 基于Cache Aside模式解决数据库与缓存的一致性问题

Cache Aside(旁路缓存)是一种常用的缓存策略,用于优化系统性能并减少数据库的压力。在这种模式下,缓存被视为辅助存储介质,当需要访问某个数据时,系统首先尝试从缓存中获取数据,如果缓存中不存在该数据,则从数据库中获取数据,并将其缓存起来。

读写策略

  • 读策略

    1. 读取缓存:首先尝试从缓存中读取数据。
    2. 缓存未命中:如果缓存中没有数据,则从数据库中读取数据。
    3. 更新缓存:将从数据库中读取的数据写入缓存,以便下次访问时可以直接从缓存中获取。
  • 写策略

    1. 更新数据库:首先更新数据库中的数据。
    2. 使缓存失效:然后使缓存中的数据失效,而不是直接更新缓存。这是为了避免数据不一致的问题

操作缓存和数据库时有三个问题需要考虑:

删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

如果采用第一个方案,那么假设我们每次操作数据库后,都更新缓存,但是中间如果没有人查询,那么这个更新动作实际上意义并不大;我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

如何保证缓存与数据库的操作同时成功或失败?

  • 单体系统:将缓存和数据库的操作放在一个事务
  • 分布式系统:分布式事务

先操作缓存还是先操作数据库?

  • 先删除缓存再操作数据库
  • image-20250211203731172image-20250211203755582
  • 先操作数据库再删除缓存
  • image-20250211203939763image-20250211203955306
  • 左边为正常情况,右边为异常情况
  • 相比之下,前者出现异常情况的概率较大,而后者出现异常情况的概率较小,这是因为更新数据库的耗时相对而言较长导致的,因此可以选择后者。

3. 使用Redis对高频访问的信息进行缓存,降低了数据库查询的压力,解决了缓存穿透、雪崩、击穿等问题

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,从而对数据库造成巨大压力。

  • 方案一:缓存空对象 如果查询的这个数据在数据库中也不存在,我们也把这个空数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据

    实现简单,维护方便,但会造成额外的内存消耗,以及可能造成短期的不一致

  • 方案二:布隆过滤 布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,

    内存占用较少,没有多余key,缺点是实现复杂,存在误判可能(哈希冲突)


缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 对于大量key同时失效的情况,可以给不同key的ttl添加随机值
  • 对于Redis服务宕机的情况,可以利用Redis集群提高服务的可用性,还可以给缓存业务添加降级限流策略

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

  • 方案一:互斥锁第一个未命中缓存的线程加锁、查询数据库并写入缓存后再释放锁,其他线程在此期间需要等待

    • 优点:1. 没有额外的内存消耗 2. 保证一致性 3. 实现简单
    • 缺点:1. 线程需要等待,性能受影响 2. 可能有死锁风险
  • 方案二:逻辑过期 不设置TTL,而是设置一个逻辑过期时间,首个发现逻辑时间过期的线程会开启一个新的线程用于更新数据,其本身以及在此期间查询的其他线程则会返回当下的过期数据

    • 优点:线程无需等待,性能良好
    • 缺点:1. 不保证一致性 2. 有额外内存消耗 3. 实现复杂

4. 基于乐观锁CAS解决秒杀产生的超卖问题

超卖问题:当库存只剩最后一个时,同时有多个线程在对方扣减库存之前,查询并判断库存充足,然后对库存进行减少,导致最终库存为负数

CAS解决超卖问题

乐观锁的常见实现方式有两种:版本号法、CAS(Compare and Swap)

  • 版本号法:在原有数据基础上,为每个数据添加一个版本号version,数据每进行一次修改就使版本号增加。当要修改数据时,比较之前查询该数据得到的版本号与当前的版本号是否一致,若不一致则说明数据出现了修改。
  • CAS:在版本号的基础上,直接拿数据中要进行修改的字段进行比较,若前后不一致则说明发生了修改。

image-20250212170924768

CAS解决超卖问题时出现的问题

我们选择使用上方的CAS方法解决超卖问题,在jMeter压力测试中,选择使用200个线程同时对100张秒杀优惠券进行抢购,这一次优惠券的库存没有变为负数,但是优惠券只卖出了20张,秒杀成功率大大减少了。

这是因为当有多个线程同时查到了同样的库存时,只有一个线程能够抢到优惠券,其他线程会因为当前剩余库存与前面查询到的库存不一致导致秒杀失败。

解决方案:将sql语句中的:stock = 前面查询到的stock 更改为 stock > 0 即可。

为什么超卖问题需要加锁?

超卖问题是因为多个用户对不足的库存同时进行库存查询及获取导致库存减为负数的情况,为每个用户加一个乐观锁,能够保证一个商品只被一个用户购买。

5. 基于Redisson分布式锁解决集群下一人一单的并发安全问题

集群下一人一单的并发安全问题

在单体系统下,我们解决一人一单问题的实现方式是在对应代码上加锁,在单体部署的情况下是没有问题的,因为此时只有一台Tomcat1,即只有一台JVM1,线程获取的锁都是这台JVM1中的同一把锁(锁的UUID保存在常量池中),故多个线程竞争这一把锁,保证了线程安全。

但是如果在集群部署的情况下,就说明有多台Tomcat提供服务,即有多台JVM,故Tomcat1中的线程竞争的是JVM1中的锁,而Tomcat2中的线程竞争的是JVM2中的锁,此时有多把锁。故此时如果一个用户在两台Tomcat中都实现了下单操作,则两边都能获取到锁,故生成了两个订单,违背了一人一单的规定,这就导致了集群下一人一单问题的并发安全问题。

解决方案一:Redis实现分布式锁

  1. 使用redis实现分布式锁就会出现一个问题:当一个进程占有锁时,若此时redis宕机了,就会导致锁无法被释放,造成死锁现象的产生。解决这个问题也很简单,我们只需要给这个锁设置一个过期时间,超时自动释放锁,就不会出现由于redis宕机导致的死锁现象。

  2. 但是,正是由于给锁设置了过期时间,新的问题产生了——锁的误删问题。如下图所示,当线程1获取锁但是业务阻塞导致超时释放锁,在线程1业务完成之前,线程2趁虚而入拿到了锁并开始执行业务,这时候线程1完成了业务并按部就班去释放锁,但是这时候占用锁的是线程2,也就是说线程1把线程2的锁给释放了,这时候如果又有一个线程3来获取锁是能够获取成功的,这就导致了线程2、线程3同时执行业务,产生了并发安全问题。

    image-20250213130001479

    解决锁的误删问题可以采用如下方法:在给锁设置value值时,使用线程ID作为锁的value值,这样就能知道当前的锁是不是本线程所设置的,当线程业务执行完毕想要释放锁时,先执行一个判断,判断当前锁的value值与自身线程ID是否相同,如果相同说明是同一把锁可以释放,否则说明是别的线程的锁,不做操作,这样就避免了锁的误删问题。

    当然,仅仅只使用线程ID作为value值是不够的,因为在不同的进程之间可能存在相同的线程ID,有小概率出现混淆的情况,我们可以选择在线程ID之前拼接一个UUID确保唯一性,将拼接的结果作为锁的value值。

  3. 但是,还会有一个问题:当线程1获取锁之后,未执行业务就发生了阻塞,此时如果锁释放了,线程2来获取锁是能够获取得到的,这就会造成线程1、2同时执行业务的情况出现,还是会发生一个用户下了多个订单的情况。如上图中线程1、2执行业务有重叠的部分

解决方案二:Redisson分布式锁

具体原理可见:Redisson分布式锁原理

总之,使用Redisson分布式锁可以解决Redis分布式锁的:不可重入问题(Redisson可重入锁)、不可重试问题(Redisson锁重试机制)、超时释放带来的问题(WatchDog机制)、主从一致性问题(MultiLock)

为什么要加这个分布式锁

一人一单出现线程安全问题是因为一个用户可能在多个线程同时下单导致一人多单的情况。是针对一个用户的多个线程加的锁。

6. 利用Kafka+Lua脚本实现异步秒杀功能,提高系统秒杀功能

秒杀下单流程:查询优惠券、判断秒杀库存是否足够、查询订单、校验是否一人一单、扣减库存、创建订单(串行执行,需要异步优化)

优化方案

将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。

为什么选用Kafka,它和别的MQ有什么区别、优势?

7. 使用Redis的ZSet数据结构实现了点赞排行榜的功能,使用Set集合实现关注、共同关注功能

点赞排行榜

  • 当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的TOP5,形成点赞排行榜。之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset),将时间戳作为zset对应用户id的得分,根据得分排序即可实现显示最早点赞的top5。
  • 而Zset没有ismember的方法,我们可以选择score方法,该方法查询对应用户ID的score,如果没有这个用户,就返回空值。

关注与取关

关注与取关会传入一个isFollow参数,true表示关注,false表示取关

  • 关注只需要创建一个Follow对象,将关注者(当前用户)id与被关注者id赋给这个Follow对象,然后直接保存到数据库中即可
  • 同理,取关只需要把数据库中user_id = userIdfollow_user_id = followUserId的记录删除即可。

共同关注

共同关注可以利用redis中set数据类型,对两个key的set取交集来实现

  • key用于区分用户,模式为follow:userId
  • value则是对应用户的关注对象的set集合
  • 因此,需要在关注时,同步将关注信息传入redis中;同理取关时也要将被关注者从当前用户的set集合中删除
  • 使用set数据结构的intersect功能来实现取交集
  • 取得共同关注id集合(String集合)后,要将id集合解析(String转化为Long),然后查询各id对应的用户信息user并封装到userDTO中确保安全,然后返回。

8. 其他问题

Redis的大Key问题?

**通俗易懂的讲,Big Key就是某个key对应的value很大,占用的redis空间很大,本质上是大value问题。**key往往是程序可以自行设置的,value往往不受程序控制,因此可能导致value很大。

  • 一个String类型的Key,它的值为5MB(数据过大);
  • 一个List类型的Key,它的列表数量为20000个(列表数量过多);
  • 一个ZSet类型的Key,它的成员数量为10000个(成员数量过多);
  • 一个Hash格式的Key,它的成员数量虽然只有1000个但这些成员的value总大小为100MB(成员体积过大);

假设我们使用List数据结构保存某个明星/网红的粉丝,或者保存热点新闻的评论列表,因为粉丝数量巨大,热点新闻因为点击率、评论数会很多,这样List集合中存放的元素就会很多,可能导致value过大,进而产生Big Key问题。

  • 大key的危害

    • 阻塞请求:Big Key对应的value较大,我们对其进行读写的时候,需要耗费较长的时间,这样就可能阻塞后续的请求处理。Redis的核心线程是单线程,单线程中请求任务的处理是串行的,前面的任务完不成,后面的任务就处理不了。
    • 内存增大:引发OOM
    • 阻塞网络:读取单value较大时会占用服务器网卡较多带宽,自身变慢的同时可能会影响该服务器上的其他Redis实例或者应用。
  • 如何识别大key

    Redis官方客户端redis-cli加上–bigkeys参数,可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key

  • 如何解决大key

    • 对大key进行拆分
    • 对大Key进行清理
    • 压缩value
    • 定期清理失效数据

点评项目面经总结
http://example.com/2025/05/13/点评项目面经总结/
作者
Kon4tsu
发布于
2025年5月13日
许可协议