【面试实战】校园管理平台项目

version:1.0

文章目录

  • 登录部分
    • 是如何记录用户登录状态的? / 登录的业务逻辑是怎么实现的?
    • cookie是什么?和session有什么区别?
    • Interceptor拦截器的使用? / 为什么使用?
  • Redis部分
    • 为什么用Redis作为MySQL的缓存?
    • 项目中哪里用到了Redis?
    • 为什么用Redis保存点赞/关注的数据?使用了哪些数据类型?用到了哪些命令?
    • 为什么用Redis保存验证码、登录凭证、用户信息?
    • 为什么用Redis保存UV/DAU?使用了哪些数据类型?用到了哪些命令?
    • 用过Redis的事务吗?怎么使用的?
    • Redis 有哪些数据类型?SDS 了解么?
  • Kafka部分
    • Kafka是什么?
    • 为什么使用Kafka?
    • Kafka消息模型?
    • Kafka如何保证数据不丢失?
    • Kafka如何保证消息不重复消费?
    • Kafka如何保证消息的顺序性?
  • 项目其他
    • 为什么做这个项目? / 项目的收获?
    • 项目的架构是怎样的? / 模块? / 介绍下项目?
    • 统一异常处理怎么做的?
    • 统一记录日志怎么做的?
    • 项目难点?/ 遇到的问题? / 项目最有挑战的模块,如何解决 ?
    • 项目的亮点?
    • 你是如何解决项目中遇到的问题的? / 解决问题的思路?
    • 项目中使用的设计模式?
    • 项目还有什么存在的问题和完善解决方案 ?

登录部分

是如何记录用户登录状态的? / 登录的业务逻辑是怎么实现的?

使用Cookie来解决用户无状态的问题。在用户登录时,我们给用户设置一个登录凭证ticket【ticket中包含userid,当前状态的信息】,放入cookie返回给浏览器。response.addCookie()

在用户下一次再访问时,浏览器会携带cookie(ticket),服务端可以根据cookie确定用户的登录状态。

确定用户登录以后,可以通过ticket查询到用户id,再通过用户id来查询用户的具体信息,之后把user放⼊model,再由模板引擎渲染后,返回网页给客户端浏览器。

cookie是什么?和session有什么区别?

cookie是保存在客户端的,不占用服务器资源。对于并发量比较高的网站,比如我们做的交流平台,就使用cookie。

session是保存在服务端的,每个用户都会产生一个session,那么如果并发访问多,每个用户都会生成session,就会很占据资源,所以对于并发访问不多的网站,比如之前做的后台管理系统,可以使用session,也更加安全。

Interceptor拦截器的使用? / 为什么使用?

  • 拦截用户凭证[loginTicketInterceptor]:拦截器做的就是将ticket拦截,之后根据ticket查找user,最终返回到页面上。
  • 拦截未读消息[messageInterceptor]:在拦截器中查询未读消息数量,保存到ModelAndView中返回给页面。
  • 拦截网站数据[dataInterceptor]:在拦截器中记录网站UV和DAU。UV根据ip统计,DAU根据用户统计。

拦截器是不破坏业务逻辑的,可以定义一些通用的行为。因为我们所有的请求中都需要显示用户的登录信息,所以使用拦截器实现这个功能。

Redis部分

为什么用Redis作为MySQL的缓存?

  • 主要是因为Redis 具备「高性能」和「高并发」两种特性

    • 高性能:假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。

    • 高并发:单台Redis的QPS(Query Per Second,每秒钟处理完请求的次数) 是MySQL的10倍左右,MySQL单机很难突破1w,Redis是可以轻松突破10w的。所以Redis 能够承受的请求是远远大于MySQL ,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

追问:为什么不用Memcached?

两者都是基于内存的数据库,一般都用来当做缓存使用,并且性能也比较高。使用Redis而不使用Memcached的主要原因如下:

  • 但是Redis的数据结构更加丰富,而Memcached只支持K-V的数据类型;
  • 并且Redis 支持数据的持久化,可以将内存中的数据写入AOF或者保存在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
  • Redis 还可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务功能,实现命令的顺序执行。

总之,Memcached是解决简单缓存问题的可靠选择,但是Redis的功能更多,可以解决更多复杂的使用场景,所以选择Redis作为缓存。

项目中哪里用到了Redis?

  1. 使用Redis作为缓存数据库来保存数据,比如点赞的数据、关注的数据。
  2. 验证码、登录凭证、用户信息都缓存在Redis中。
  3. 统计网站DAU、UV的数据也保存到了Redis中。

为什么用Redis保存点赞/关注的数据?使用了哪些数据类型?用到了哪些命令?

因为点赞、关注是一个很高频的操作,如果直接在数据库中使用Count,效率很低,所以可以使用Redis来提升性能,从缓存中查询数据,会比直接在硬盘中查询数据要快。

使用的数据类型:

  • 给帖子、评论点赞使用Set来存储。key代表某个帖子或者评论,value表示点赞的用户Id。
    • 使用Set进行存储,可以防止同一个用户多次点赞的情况,因为Set可以去重。
  • 用户收到的赞使用String来存储。key代表具体用户,value就是收到的赞数。
    • 比如用户的帖子获得一条点赞,那么就在给帖子增加点赞数的同时,给用户收到的赞 + 1 即可,所以就使用String来进行存储。
  • 关注、粉丝数据使用Zset来存储,key代表用户的关注 / 粉丝 ,value代表关注 / 粉丝 userId。关联的score 来保存关注时间,这样我们可以按照关注时间进行排序。
    • 使用Zset可以防止重复关注的问题,并且我们可以根据关注事件进行排序。

使用的命令:

  • Set:add、remove、ismember、size
  • String:decrement、increment
  • Zset:add、remove、ZCard【统计元素个数】、ZREVRANGE【时间从大到小排序】,ZRANGE【从小到大排序】

为什么用Redis保存验证码、登录凭证、用户信息?

用Redis保存验证码:

  1. 验证码需要频繁的刷新和访问,对性能的要求比较高
  2. 并且验证码不需要永久保存,需要设计过期时间来节约空间。
  3. 之前验证码都是保存在session中,在分布式部署中session会出现共享问题。将验证码保存到Redis中避免分布式session不可用的问题。

用Redis保存登录凭证、用户信息:

  1. 之前将登录凭证保存到MySQL中,导致每次都需要去login_ticket表中查询登录凭证,由于每次访问页面都需要在拦截器中对登录凭证进行检查,访问频率很高,所以用Redis进行存储。Redis是内存型数据库,访问很快。
  2. 需要在拦截器中根据登录凭证去查询用户信息,访问频率很高,所以用Redis进行存储。

为什么用Redis保存UV/DAU?使用了哪些数据类型?用到了哪些命令?

用户每访问一次网页,就需要向数据库中插入一条数据,如果日活很高,会消耗数据库资源。
并且如果要进行月度、年度的统计会使用count、group by这样效率很低,所以使用Redis来保存UV / DAU。

  • UV:独立访客,通过IP进行排重计算。HyperLogLog就可以满足去重要求。并且每个 HyperLogLog 键只需要花费 12 KB 内存,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
    • ADD方法:传入VALUE = IP进行统计。
    • UNION方法:合并HyperLogLog的集合到新的HyperLogLog。
  • DAU:日活跃用户,通过用户ID进行去重。使用Bitmap一连串的二进制数组,可以统计精确结果,并且占用空间很小。
    • SETBIT方法:通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作。SETBIT login_status 101 1表示ID = 101 的用户 已登录。
    • OR运算:统计一周、月度的活跃用户。

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。

用过Redis的事务吗?怎么使用的?

Redis的事务提供了三个操作:

  • MULTI:事务开始执行的命令。
  • EXEC:执行队列中的命令。
    • 在MULTI和EXEC之间发送的所有命令都会被记录下来,并在执行EXEC命令时按照顺序执行。这些命令并不会立即执行,而是被放入一个队列中等待执行。
  • DISCARD:取消事务。
  • WATCH:监视key,执行事务之前key如果被修改,事务的执行会被打断。

Redis事务的主要作用就是串联多个命令防止别的命令插队。项目中点赞操作就使用了事务。当用户执行点赞操作,我们需要在Set中增加一条数据,并且给被点赞的用户获赞数 + 1。这是两个操作,防止别的命令插队,所以使用事务。

注意:Redis不提供事务的回滚。所以这里实际的作用不大。

Redis 有哪些数据类型?SDS 了解么?

String、List、Hash、Set、Zet都是比较常用的数据类型。其中 String 数据类型的底层数据结构是 SDS(simple dynamic string,SDS)。

SDS这个数据结构中存在几个字段:

  • len,记录了字符串的实际长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,并且根据需求自动修改空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。不会出现二进制安全的问题。
  • flags,用来表示不同类型的 SDS

对比C语言,主要有以下优势:

  • O(1)复杂度获取字符串长度
    • C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。
    • 而 Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)
  • 二进制安全
    • 因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。
      通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。
  • 不会发生缓冲区溢出
    • Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。
    • 而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。
  • 节省内存空间
    • SDS 设计了不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。
    • 使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

Kafka部分

Kafka是什么?

是一个分布式流式处理平台,流平台一个关键的功能就是消息队列。

项目中使用Kafka消息队列,对评论、点赞、关注功能发布通知,封装为Event实体类。
消费者负责将消息队列中的Event取出,并将其封装为Message对象,并持久化到数据库中保存。

为什么使用Kafka?

  • 深入了解:
    Kakfa的性能好,采用的是磁盘的顺序I/O,并且生态比较完整,遇到问题可供查阅的资料也会比较多。
  • 横向对比:
    当时了解到除了Kafka还有其他的消息队列,比如RabbitMQ。但是相比之下还是选择了Kafka,因为我们要做的是一个系统通知,在处理大量数据时,kafka的性能更高,但是RabbitMQ相比Kafka的消息可靠性更高。
    RabbitMQ的灵活性要更好,允许自定义消息头,然后通过一些特殊的 Exchange,很简单的就实现了消息匹配分发。开发几乎不用成本。

Kafka消息模型?

  • Producer将生产者发布的消息放到Topic中,Topic作为消息的载体。
  • 这个Topic实际是在一个Broker中,一个Broker(代理)有多个Topic。
  • Partition(分区)是Topic的一部分,一个Topic可以有多个Partition,Partition作用就是消息队列中的队列。消息在被追加到 Partition的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。
    • 分区采用多副本机制。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。
  • Comsumer消费者被动消费Topic中的数据。

Kafka如何保证数据不丢失?

  • 生产者中:生产者发送给Broker消息可能丢失。
    • 可以使用ListenableFuture类,来添加回调函数,打印生产者发送失败的原因。
    • 给 retries (重试次数)设置一个比较合理的值,一般是 3。
  • Broker中:Broker中有可能出现存储丢失的问题。
    • 采用acks = all机制,会在所有分区副本follower保存之后,leader发送一条成功响应。
  • 消费者中:offset已经提交,但是customer宕机导致消息丢失。
    • 采用手动提交offset。手动提交可以保证消息至少被消费一次。在消费者消费完后设置异步提交,在finally中设置同步提交。【finally中的同步提交是最后的一个把关,防止异步提交出现异常】

Kafka如何保证消息不重复消费?

消息重复消费的原因就是有可能customer已经消费了数据,但是offset没有成功提交,这种情况的话我们采用手动提交offset。手动提交可以保证消息至少被消费一次。在消费者消费完后设置异步提交,在finally中设置同步提交。【finally中的同步提交是最后的一个把关,防止异步提交出现异常】

我在项目中实际是这样处理的,就是在处理消息的时候,因为我们将event封装为了Message对象,我们的Message有一个唯一标识id,那么我们在消费数据时就会判断这个id是否存在这个Message表中,如果存在就不进行消费。我觉得这种方法是最有效的。

Kafka如何保证消息的顺序性?

由于消费者组中的某一个customer可以会维护多个分区,但是只能保证一个分区中的消费顺序性,对于跨分区的消息顺序性无法保证。所以,如果想要保证按顺序处理,就要只提供一个分区。或者相同的业务只在同一个分区下存储和消费。

实际操作:在执行send时,可以指定分区号来进行发送; 或者设置同一个key来决定存储到同一个分区。

实际在这个项目中不需要保证消息的顺序性,因为都是通知类型,没有顺序可言。所以我这里只使用了两个参数的send方法,发送topic和event。

项目其他

为什么做这个项目? / 项目的收获?

做这个项目是因为当时自己在学习过程中想要巩固所学知识,也想要把自己学的东西运用到实际当中,就去网上找了一个项目,看评价也比较好,就自己实现了一遍,整体项目和自己之前学的Mybatis、Spring、MySQL、Redis相关的知识都在里面用上了,让自己从实践应用的角度更好的理解了这些技术,也提高了自己debug的能力还有解决问题的能力,自己培养了解决问题的逻辑

项目的架构是怎样的? / 模块? / 介绍下项目?

底层基于Spring Boot。项目可以分为几个模块:

  1. 权限模块【负责用户注册、登录、权限控制】,主要用到的技术是Spring Security、Interceptor拦截器。
  2. 核心模块【首页、帖子、评论、私信、统一异常处理、统一记录日志】,异常处理和记录日志主要运用了Spring AOP的思想,针对项目中的代码进行一个横向的扩展。
  3. 性能模块【点赞、关注、用户缓存、统计数据】,主要使用Redis保存点赞和关注、统计UV、DAU数据。并且缓存用户的信息、验证码、登录凭证到Redis,减小数据库访问压力。
  4. 通知模块【系统通知,点赞、评论、关注】,主要使用Kafka消息队列来发送通知,并且将通知转换为Message存到表中,使用id判重来解决kafka重复发送数据的问题。

项目介绍:项目基于Spring Boot,大体可以分为四个模块,权限、核心、性能、通知。
权限模块比如用户的登录注册,权限控制;核心模块比如首页,帖子,统一异常处理,统一记录日志;性能模块基于Redis包括点赞,关注,统计数据;通知模块基于Kafka,使用Kafka来发送通知。

统一异常处理怎么做的?

使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解

这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP的运用),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。

统一记录日志怎么做的?

使用Spring AOP的思想,把所有的Service方法标记为切入点PointCut,在调用目标方法之前,记录日志。使用的是AspectJ框架,相比于AOP是静态代理,性能要比AOP好一些。

项目难点?/ 遇到的问题? / 项目最有挑战的模块,如何解决 ?

  • 难点一【技术问题】
    使用Spring Security时,点赞后会报错说找不到csrf字段。去网上找到解决方案就是在Spring Security的配置类中关闭跨域请求伪造的功能。
    但是一直都很奇怪为什么只有点赞这个功能不行。后来去StackOverFlow用关键字搜索了一下问题,Spring Security 默认开启csrf,可以防止跨域攻击。但是只会在POST表单中自动添加csrf_字段【token】防止跨域请求伪造。
    但是点赞功能是异步发送的POST请求。在异步的ajax请求中,Spring Security不会自动添加csrf字段,所以就会报错找不到。
  • 技术难点二
    项目中还使用了Elasticsearch和Redis,他们底层都使⽤Netty,导致这两者启动的时候,会有冲突,主要是ES底层的代码尝试启动Netty时,发现Netty已经启动,然后报异常。这里网上有很多解决方案,就是我们可以在项目启动时,就设置ES不要检测Netty是否初始化就可以。
  • 难点二【业务问题,也不算难点,算是一个经验】
    当时写完了用户发布帖子的功能,当时新注册了一个用户,之后用这个用户发布了帖子,后面我在数据库中把这个用户的信息删除了,再访问首页就报错了。当时没有意识到是因为删除用户信息导致的报错,就自己打了一个断点走了一遍,最后发现是在根据userId获取user时报错,userId为空。当时第一反应是加一个if条件判空,但是后来一想不需要这么做,因为是人为修改了数据库,实际不会出现这种情况。后来就把数据库中discuss_post那条数据给删掉了。
    • 给我的经验教训:报错要注意查看报错信息,自己打断点找一下错误的原因。

项目的亮点?

其实就是写到简历上的哪些:

  1. 使用Redis + Caffine 构建二级缓存,QPS得到了10倍的提升。
  2. 使用Kafka,基于生产者和消费者模式,发送系统通知。并且Kafka还可以保持一个很好的性能,相比RabbitMQ,适合发送大量的数据。

你是如何解决项目中遇到的问题的? / 解决问题的思路?

  1. 非业务功能:看一些博客,尝试一些博客中的解决方案;去StackOverFlow这样的平台,搜索解决方案;到这步就能解决大部分的问题;最后可能会去看一下官方文档中有没有对应的描述,去弄懂一下原理。
    • 举例:当时学校让基于SQL Server实现增删改查的项目,因为自己没用过SQL Server,用的Java17和 SQL Server 2022的版本,启动时报错无法通过SSL建立安全连接,当时先去国内一些博客搜索解决方案,但是大部分都是千篇一律,并且文章质量不高,也没有实质性的原因,并且相关的文章很少。后面就使用StackOverFlow通过关键字来查询解决问题,推荐是在连接字符串中设置一个encrypt=true;trustServerCertificate=true,这个的意思就是JDBC 将不验证SQL Server的 TLS 证书,但是这个设置常用于允许在测试环境中建立连接,实际开发中可能会出现安全问题。后面就去微软的官方文档中研究了一下,官网中推荐使用keytool导入SQL Server的证书,后面就将这个证书导入进JDK中,就没有出现问题了,我觉得通过这个也提升了自己解决问题的一个能力。
  2. 业务功能:首先要看报错原因,看看原因中是在哪里报的错【定位问题】,之后自己打断点走一遍【分析原因】,就能解决基本的问题【解决问题】。

项目中使用的设计模式?

  • Spring中就使用了设计模式。Spring中Bean是单例的,运用了单例设计模式。单例确保每个Bean都只有一个实例,Spring在进行依赖注入时,使用双重检查锁实现单例模式。
     // double-check 双重检查锁
     class Singleton{
         private static volatile Singleton instance;
         private Singleton(){}
         public static sychronize Singleton getInstace(){
             if(instance==null){
                 synchronized(Singleton.class){
                     if(instance==null) instance=new Singleton(); 
                 }
             }
             return instance;
         }
     }
    
  • Spring中的AspectJ使用了代理模式,AspectJ是静态代理,编译时增强,也就是在编译时就直接把代码逻辑编译到目标类中。相比于Spring AOP 的动态代理,效率要更高。

项目还有什么存在的问题和完善解决方案 ?

一些实现的细节要注意吧。比如判空之类的。完善的话,如果数据量增多,数据库方面可以考虑分库分表。

完。

你可能感兴趣的:(面试,redis,缓存)