目录
上大选课交流论坛:
目的是为了给同学们提供一个课程评价和交流的平台。主要功能包括登陆注册、发帖评论、点赞、按热度排序等功能。使用redis来进行用户信息查询的优化;基于kafka消息队列实现点赞评论后进行系统通知;定时任务对帖子热度进行计算和刷新。
一、拦截器Interceptor
1. 什么是拦截器
在AOP中用于请求进入Controller之前,通过拦截器进行拦截,然后在Controller之前或之后加入某些操作。
拦截器起作用的位置分为3个部分:
- 请求进入Controller之前,通过拦截器执行代码逻辑【preHandle】
- Controller执行之后(只是Controller执行完毕,视图还没有开始渲染,viewResolver之前),通过拦截器执行代码逻辑【postHandle】
- Controller完全执行完毕(整个请求全部结束),通过拦截器执行代码逻辑【afterCompletion】
2. 项目中如何体现
想要自己配置一个拦截器,就必须用到HandlerInterceptor和WebMvcConfigurer这两个接口。
首先,定义LoginTicketInterceptor,该类继承并实现了HandlerInterceptor
- 重写preHandle方法:用户在登录后会以ticket为key,以及实际的ticket为value创建一个cookie存入浏览器。因此从请求request中获取cookie,从而拿到登陆凭证ticket。从redis中根据ticket获取对应的真正的登陆凭证,判断登陆凭证是否过期,从而决定是否拦截。对于放行的用户,要在本次请求中持有该用户的信息(ThreadLocal)。
- 重写postHandle方法:将用户信息从threadlocal中取出来保存在modelAndView中,以便后续ViewResolver处理以及视图渲染。
- 重写afterCompletion方法:在整个请求完全结束的时候,清除掉threadlocal中该用户的信息。
也就是说拦截器的作用是拦截请求,拿到请求中cookie里的ticket键,redis获取用户凭证,校验用户是否可放行,并让threadlocal持有用户信息。还要负责用户信息清理等。
其次,定义配置类,该类继承WebMvcConfigurer,并将该类注入spring容器(@Configuration)
- 对除静态资源外的所有路径进行拦截
二、ThreadLocal
1. 什么是ThreadLocal
todo
InheritableThreadLocal 的使用:https://www.nowcoder.com/discuss/514704377869885440
InheritableThreadLocal 是 ThreadLocal 的一个子类,它允许子线程继承父线程的线程本地变量。
2. 为什么使用ThreadLocal
通过使用 ThreadLocal,每个线程都可以在各自的线程范围内存储和访问自己的上下文信息,而不会干扰其他线程的数据。这种线程隔离性使得 ThreadLocal 成为传递线程上下文信息的一种有效方式。
主要是为了保证线程安全性。
3. 项目中如何体现
创建一个 ThreadLocal 对象来存储用户信息。这里的new ThreadLocal表明在每个线程中均保存独立的用户信息实例User,也就是每个线程中保存的用户信息是互不相干的。
三、AOP
1、什么是AOP
todo
spring AOP & AspectJ
2、为什么使用AOP
我们需要在业务中记录日志。例如:“【某ip】在【什么时间】访问了【什么页面】。”
由于日志不属于业务需求,它属于系统需求,因此不应该耦合在业务代码中。
其次,如果我们使用OOP(面向对象编程)的思想,将日志封装成bean去调用,会使得代码过于重复。
综上所述,需要使用AOP。
3、一些AOP术语
Target是我们已经开发好的业务逻辑的一个一个bean,我们称之为目标对象。
目标对象上有很多地方可以被织入代码,可以被织入代码的地方我们统称为连接点joinpoint。
4、项目中如何体现
AOP解决统一处理系统需求的方式是将代码定义到一个额外的bean,叫切面组件Aspect,我们项目中即为下图的ServiceLogAspect。这个组件在程序运行之前就需要被框架织入到某些连接点。切面组件的pointcut声明织入到哪个位置,通知Advice方法声明切面要处理什么样的逻辑。
下图中:
- @Aspect注解声明该类为切面组件
- 使用@Component注解将该组件注入spring
- @PointCut定义切点的具体位置
- @Before代码前置通知,在开头织入程序
通过上述代码,我们就可以实现在切点位置执行相应的操作。更具体而言,在@Pointcut定义的位置,@Before会发挥作用先于切点代码执行。
四、redis
1、如何保持redis和数据库的一致性
项目中需要考虑该问题的位置:优化项目时,将用户信息缓存在了redis中。
如何解决:采用旁路缓存模式(先更新数据库再删缓存)。
为什么不选择先删除缓存再更新数据库的主要原因:有可能导致请求因缓存缺失而访问数据库,给数据库带来压力
如何解决旁路缓存模式下数据不一致问题?
- 对于删除缓存失败导致的数据不一致:重试机制确保删除成功
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值:延迟双删
重试机制:
把要删除的缓存值或要更新的数据库值暂存在消息队列中。如果应用没有成功删除缓存值或者没有成功更新数据库值,那么消息队列中会一直存在这个数值,直到删除或更新操作完成后才会被去除。类似于CAS思想。
延迟双删:
假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
- 线程 B 读取到了旧值;
- 线程 B 是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
等到线程 B 从数据库读取完数据、更新了缓存后,线程 A 才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。
这种情况我们就用延迟双删来解决(解决的是数据的最终不一致性):
A删除缓存,并且更新数据库后,sleep一段时间,然后删除缓存。
为什么要sleep一段时间?目的是为了让B读取数据库并将读到的旧值更新至缓存。
为什么并发请求下,先更新数据库再删除缓存不需要专门的应对方案来保证数据一致性?
因为在这种情况下,当线程A数据库更新成功,尚未删除缓存时,线程B虽然会读取到缓存中的旧值,但是并不会影响到缓存和数据库之间的最终一致性。
操作顺序 | 是否有并发请求 | 潜在问题 | 现象 | 应对方案 | |||||||||||||||||||||||||||||
先删除缓存,再更新数据库 | |||||||||||||||||||||||||||||||||
无 | 缓存删除成功,但数据库更新失败 | 从数据库中读取到旧值 | 重试机制 | ||||||||||||||||||||||||||||||
有 | 删除缓存后,尚未更新数据库,有并发读请求 | 并发请求从数据库中读到旧值,并更新到缓存,导致后续请求都读到旧值 | 延迟双删 | ||||||||||||||||||||||||||||||
先更新数据库,再删除缓存 | |||||||||||||||||||||||||||||||||
无 | 数据库更新成功,但是缓存删除失败 | 从缓存中读取到旧值 | 重试机制 | ||||||||||||||||||||||||||||||
有 | 数据库更新成功后,尚未删除缓存,有并发读请求 | 并发请求从缓存中读到旧值 | 等待缓存删除完成,期间会有不一致数据短暂存在 |
2、项目中如何体现
缓存点赞和关注:
- 缓存用户点赞数。
使用Set, key为点赞对象【帖子/评论】,value为点赞用户Id
使用String,key为用户id,value为获得的点赞数量
用户点赞时,需要对用户获赞数String和实体获赞集合zSet进行修改。所以这个位置需要使用redis中的事务支持。
具体而言,我们重写了execute方法,使得点赞相关的几个操作可以成为一个原子性操作。
- 缓存用户的关注列表和粉丝列表。
使用zset,key为被关注者,set保存关注者以及关注时间为score,使用zCard获得粉丝数量。利用reverseRange的时间戳反向排序,按关注时间加载粉丝列表。
follower:entityType:entityId -> zset(userId, now) 粉丝
followee:userId:entityType -> zset(entityId, now) 以当前关注的时间进行排序
优化登录:
- redis缓存用户信息。
- redis缓存验证码。
- redis缓存登陆凭证。
随手记
- ticket保存在redis中,可能会由于存在大量的数据导致内存溢出,所以可以通过xxljob设置一个定时任务,定时对于redis中的无用ticket进行清理。更详细地说,需要定时去访问全部或者部分redis数据,对数据进行检查,只保留同一个用户id下的最新有效ticket。
难点和挑战
在开发这个项目过程中,我遇到了以下一些困难,以及我是如何解决它们的:
-
技术选型和搭建环境的困难:
在开始项目之前,选择适合的技术栈和工具是一个重要的决策。我花了一些时间来研究和评估各种技术选择,并最终确定了Spring Boot、MyBatis、MySQL、Redis和Kafka。然后,我花了一些时间来搭建整个开发环境,包括配置和连接数据库、安装和配置Kafka、Redis等。通过查阅文档和参考示例,我成功地搭建了开发环境,并确保各个组件正常运行。 -
数据库设计和数据模型的困难:
在设计数据库时,我遇到了一些挑战,主要是如何合理地设计表结构和建立各种关系。我认真研究了应用的需求和数据之间的关联关系,并进行了一些反复的迭代和讨论,以确保数据模型的合理性和可扩展性。基于这个理解,我成功地设计了数据库模式,并使用MyBatis进行数据库操作。 -
Redis缓存和数据同步的困难:
引入Redis作为缓存数据库是为了提升网站性能。在实现用户凭证、点赞和关注等功能时,我遇到了一些困难,主要是如何正确地使用Redis来存取数据,并保持数据的一致性。我仔细研究了Redis的数据结构和相关的操作命令,并使用Redis的事务和分布式锁等机制来确保数据的完整性和一致性。 -
Kafka消息队列的配置和集成的困难:
使用Kafka实现系统通知功能是一个新的挑战。我首先学习了Kafka的基本概念和工作原理,并在项目中集成了Kafka。我遇到了一些配置和生产/消费消息的问题,在解决时,我仔细检查了配置文件和代码,确保Kafka的各个组件正确运行,并实现了生产者和消费者的消息通信。 -
定时任务的配置和调度的困难:
对于热帖排行榜的实现,我使用了定时任务来定期计算帖子的分数,并在页面上展示。我遇到了一些配置和调度的问题,比如如何配置定时任务、如何设置任务的调度周期等。通过学习和查阅相关资料,我成功地配置了定时任务,并根据需要进行了调整,确保数据的准确性和展示的及时性。
在解决这些困难的过程中,我主要通过以下方法来解决:
- 深入学习和调查相关文档和资料,包括官方文档、社区论坛和博客等。
- 参考示例代码和项目案例,学习其他开发者的经验和做法。
- 与团队成员进行讨论和沟通,分享问题和解决方案。
- 进行实验和测试,不断调试和优化代码,确保功能的正确性和性能的可靠性。
- 寻求导师或同行的指导和建议,及时解决遇到的问题。
通过克服这些困难,我成功地完成了这个项目,并获得了宝贵的经验和技能。同时,我也意识到,在实际开发过程中,遇到问题是正常的,关键是要善于学习和探索,寻找解决方案,并与团队合作,共同克服困难,取得成功。