超级场景
问答论坛项目
怎么存储热门标签?有什么问题?怎么改进?
使用本地 arraylist 存储热门标签(优先级最高的 10 个标签),可以改进为 copyonwritearraylist (保证多线程的安全性)。
为什么缓存热门标签不使用成熟的缓存框架?
热门标签数量较少,引入专门的缓存框架还需要配置和管理,使用 arraylist 简单又便捷。
如何更新热门标签列表?优先级怎么计算?
使用 @scheduled 注解定义定时任务(fixedRate 指定每隔 3 小时),从数据库中分页查询(避免一次性查询大量数据,减少数据库压力)所有问题,对每个问题的标签列表进行拆分,统计每个标签的优先级(优先级计算方式为“该问题的评论数”),计算完成后更新热门标签缓存。
项目中还缓存了哪些内容?是怎么缓存的?
利用 Google Guava 缓存库对置顶问题列表进行缓存,提升系统响应速度。包括缓存的最大容量(100 条),当缓存条目超过 100 条时,会按照一定策略移除某些条目;规定缓存项在写入后的 10 分钟后过期;设置移除监听器,当缓存项被移除时记录日志信息。
如果缓存中存在键为 sticky 的置顶问题列表,直接返回;否则查询数据库中所有置顶问题,将它们存入缓存当中。
为什么对于置顶问题,要设置 10 分钟为过期时间?
如果过期时间设置过长,会导致数据更新不及时(管理员已经在数据库层面更新了置顶问题,但用户仍看不到任何变化)。如果过期时间设置过短,会增加数据库的查询频率。
使用 Guava 缓存置顶问题,会出现哪些问题?怎么解决?
置顶问题过期后,多个线程同时想要获取置顶问题列表,发现缓存中没有数据,就会同时查询数据库,引发缓存击穿问题。
分布式锁方案,只有一个线程去查询数据库,其他线程等待该线程查询完成并更新缓存后,就可以从缓存中获取数据了。
为什么不使用 Redis 进行缓存?
Guava 缓存是一种本地缓存,数据直接存储在应用程序的内存当中,当应用程序需要访问置顶问题列表时,无需进行网络通信,直接从本地内存读取数据,数据极快。相比之下,Redis 是一种远程缓存,数据存储在独立的服务器上,应用程序访问 Redis 时需要通过网络通信,这会引入一定的开销。
Guava 是 Google 开源的 Java 工具库,与 Java 应用程序的集成非常简单,无需额外配置和管理。而使用 Redis 则需要搭建和维护独立的 Redis 服务器。
在该项目中,置顶问题列表的更新频率相对降低,并且对数据一致性的要求不是非常严格。此外,置顶问题列表的数据量较小,使用 Guava 缓存可以充分利用应用程序的内存资源。
程序中有没有用到一些限流机制?是如何实现的?
使用 Guava 创建缓存,缓存的内容是用户 ID 和用户发布问题的计数,缓存项将在 1 分钟后过期。判断达到频率限制的标准是从缓存库中获取指定用户 ID 对应的发布问题计数,若缓存中不存在,则计数初始化为 0,将用户的发布问题计数加 1 后重新存入缓存。当用户发布问题的计数达到 2 时,意味着在 1 分钟内用户连续发布了 2 条问题,达到了限流标准,会告知用户发布频率太快。
在多线程情况下,可能会存在多个线程对用户的发布问题计数进行更新,导致计数不准确。可以将计数器从 Integer 改为 AtomicInteger;并且在更新计数时,可以使用 synchronized 关键字和 reentrantlock 保证同一时间只有一个线程修改计数器的值。
限流机制是如何发挥作用的?
Spring 容器在启动时会自动扫描所有实现了 ApplicationListener 接口的类,并将它们注册为事件监听器。当调用 ApplicationContext 的 publishEvent 方法发布事件时,Spring 容器会负责将事件广播给所有监听该事件类型的监听器。
在 QuestionRateLimiter 类中,当满足一定条件时,会发布 QuestionRateLimiterEvent 事件。通过注入 ApplicationContext,可以调用其 publishEvent 方法来发布事件。
if (isReachLimited) { |
定义 QuestionRateLimiterEvent 类,它继承自 ApplicationEvent,这是 Spring 事件体系的基础。在这个类中包含了事件相关的数据,比如 userId,并且提供了构造函数用于初始化这些数据。
定义 QuestionRateLimiterListener 类,它实现了 ApplicationListener
- 获取用户的违规次数:从 disableUsers 缓存中获取当前用户的违规次数,如果缓存中不存在该用户的记录,则默认违规次数为 0
- 更新违规次数:将用户的违规次数加 1,并更新到 disableUsers 缓存中
- 记录日志:记录接收到的事件和用户的违规次数
- 判断是否达到禁用条件:如果用户的违规次数达到 60 次,则执行以下操作:1.禁用用户:通过 userMapper 查询用户信息,将用户的 disable 字段设置为 1,表示用户被禁用,并更新到数据库中。2.删除用户的问题:使用 QuestionExample 构建查询条件,查询该用户创建的所有问题,并将这些问题从数据库中删除
在该类中,还存在一个 disableUsers 缓存,即使用 Guava 的 CacheBuilder 创建一个缓存,用于存储用户的违规次数。该缓存最多存储 10 个元素,且元素在写入 1 小时后自动过期。
当管理员手动修改了置顶问题列表(例如添加或删除了某个置顶问题),如何保证缓存中的置顶问题列表与数据库中的数据保持一致?
- 缓存失效策略:在管理员修改置顶问题列表后,立即清除对应的缓存项。例如,在管理员执行修改操作的业务逻辑中,调用 cacheQuestions.invalidate(“sticky”) 方法,将缓存中的置顶问题列表缓存项失效,这样下次请求时就会重新从数据库中查询并更新缓存
- 监听数据库变更:使用数据库的触发器或者监听机制,当置顶问题列表对应的数据库表发生变更时,发送一个消息通知应用程序。应用程序接收到消息后,清除对应的缓存项。例如,可以使用数据库的 binlog 日志,通过监听 binlog 中置顶问题表的变更事件,然后通过消息队列(如 Kafka、RabbitMQ 等)将变更消息发送给应用程序进行处理
- 定期校验:设置一个定时任务,定期对比缓存中的置顶问题列表和数据库中的数据,如果发现不一致,则更新缓存。但这种方式存在一定的时间窗口内数据不一致的问题,所以通常会结合前面两种方式一起使用
登录注册逻辑是如何实现的?
当用户登录成功后,会生成一个 token 并存储在 User 对象中,然后调用 userService.createOrUpdate(user) 方法将用户信息保存到数据库。接着,创建一个名为 token 的 cookie,将生成的 token 作为值存储在 cookie 中,并设置 cookie 的有效期为 6 个月,最后将 cookie 添加到响应中发送给客户端浏览器。
项目整体登录注册逻辑(token,cookie和session)
用户登录成功后,生成 token,将 token 存储在 user 表中与用户关联,同时将 token 存储到 cookie 中返回给客户端。
客户端在后续请求中携带 cookie,服务器从 cookie 中获取 token。
服务器根据 token 在 user 表中查询用户信息,如果能查询到用户信息(说明已登录),则将用户信息存储在会话中,并进行后续操作;如果 cookie 中没有 token 或者根据 token 无法查询到用户信息,则认为用户未登录。
用户信息存储在 session 中,使得在后续的请求处理过程中,比如在 Controller 层的各个方法中,很容易从会话中获取到当前用户的信息,而不需要每次都从数据库或其他地方重新查询。例如在 CommentController 的 post 方法中,User user = (User) request.getSession().getAttribute(“user”); 这行代码就是从会话中获取用户信息,然后进行登录验证等操作。并且避免了频繁地访问数据库来获取用户信息。一旦用户登录成功并将其信息存入会话,在会话有效期内,后续请求可以直接从会话中读取,减少了数据库的负载。
登录用户校验是如何实现的?
定义 SessionInterceptor 拦截器,重写 preHandle方法,它的主要作用是在每个请求处理之前检查用户的登录状态,即获取 cookie,根据 cookie 查找对应的 token,从数据库中查找该 token 对应的用户信息,并将用户信息和未读通知数量存储在会话中,以便后续处理和视图渲染使用。
- postHandle 方法:用于请求处理之后,视图渲染之前拦截
- afterCompletion:用于整个请求处理完成(包括视图渲染)之后拦截
SessionInterceptor 拦截器拦截哪些方法?是如何配置的?
在 WebConfig 配置类中,通过实现 WebMvcConfigurer 接口并重写 addInterceptors 方法来配置拦截器。具体配置如下:
|
addPathPatterns(“/**”):
- 这个配置表示 SessionInterceptor 拦截器会拦截所有的请求路径。/** 是一种通配符,表示匹配任意层级的任意路径。也就是说,理论上所有发送到该应用的请求都会经过 SessionInterceptor 的拦截处理
excludePathPatterns(“/callback/**”, “/logout”):
- 此配置指定了不进行拦截的路径。其中 /callback/** 表示以 /callback/ 开头的任意路径都不会被拦截,例如 /callback/github、/callback/gitee 等。而 /logout 表示精确匹配 /logout 这个路径,当请求路径为 /logout 时,不会被 SessionInterceptor 拦截
拦截器的原理?(程序中使用拦截器进行合法登录校验)
当客户端发送请求到服务器时,拦截器会在请求到达 Controller 方法之前执行 preHandle 方法,在 Controller 方法处理完请求但还未进行视图渲染时执行 postHandle 方法,在整个请求处理完成(包括视图渲染)之后执行 afterCompletion 方法。借助这些方法,拦截器能够对请求和响应进行一系列的操作,比如权限验证、日志记录、性能监控等。
项目中用户回复评论/问题后,会发送通知给评论/问题的创建者,是如何实现的?
在 insert 方法中,首先判断 comment.getType() 的值,若为CommentTypeEnum.COMMENT.getType(),表示回复评论;若不是,则表示回复问题。
当回复问题时,通过 QuestionMapper 根据 comment.getParentId() 查询对应的 Question 对象,若查询不到则抛出异常。
然后将评论插入数据库,同时增加问题的评论数。在插入评论操作完成后,调用 createNotify 方法来创建通知。
createNotify 方法接收评论对象 comment、接收者 ID(即问题的创建者 ID)receiver、评论者名称 notifierName、问题标题 outerTitle、通知类型 notificationType(这里是 NotificationTypeEnum.REPLY_QUESTION)以及问题 IDouterId作为参数。
在 createNotify 方法中,首先判断接收者 IDreceiver 是否与评论者 IDcomment.getCommentator() 相等,如果相等则直接返回,不发送通知。
若不相等,则创建一个新的 Notification 对象,设置其相关属性:
- gmtCreate 设置为当前时间戳 System.currentTimeMillis()
- type 设置为传入的通知类型 notificationType.getType()
- outerid 设置为问题 IDouterId
- notifier 设置为评论者 IDcomment.getCommentator()
- status 设置为未读状态 NotificationStatusEnum.UNREAD.getStatus()
- receiver 设置为接收者 IDreceiver
- notifierName 设置为评论者名称 notifierName
- outerTitle 设置为问题标题 outerTitle
最后调用 notificationMapper.insert(notification) 将通知插入数据库,这样问题的创建者就会收到一条关于该回复的未读通知。
并且,在项目中,用户插入评论操作,即 insert 函数是使用了 @transactional 注解的,这就意味着,如果插入评论失败,那么通知也会创建失败;如果通知创建失败,插入评论操作也会回滚。
现在需要新增一种通知类型 “点赞问题通知”,应该如何对项目进行修改?
- 枚举类:在 NotificationTypeEnum 枚举类中添加新的通知类型 LIKE_QUESTION,并为其分配唯一的类型值
- 创建通知逻辑:在触发点赞问题的业务代码中,添加创建点赞问题通知的逻辑。例如,在 QuestionService 中添加点赞方法时,调用 NotificationService 创建新通知
- Mapper 与 SQL:如果新通知类型需要特殊的查询或存储逻辑,在 NotificationMapper 及对应的 SQL 映射文件中添加相应的方法和 SQL 语句
- Service 层:在 NotificationService 的 list 和 read 方法中,添加对新通知类型的支持,如在 list 方法中为新类型的通知设置正确的 TypeName
- Controller 层:在 NotificationController 的 profile 方法中,添加对新通知类型的跳转逻辑处理
在高并发情况下,多个用户同时读取同一条通知,导致数据库压力过大,有哪些优化方案?
- 缓存机制:引入缓存(如 Redis),将通知信息缓存起来。在 NotificationService 的 read 方法中,先从缓存中获取通知信息,如果缓存中存在,直接返回;如果不存在,再从数据库查询,并将查询结果存入缓存。设置合理的缓存过期时间,保证数据的时效性
- 批量读取:在 NotificationService 的 list 方法中,优化数据库查询逻辑,采用批量读取的方式,减少数据库查询次数。例如,使用 IN 语句一次性查询多条通知,而不是多次单条查询
- 读写分离:配置数据库的读写分离架构,将读操作分散到多个从库上,减轻主库的压力。在 NotificationMapper 查询方法中,配置从库进行查询
项目中通知功能可能存在哪些问题(高并发多线程情况)
- 数据库插入冲突:多个线程同时尝试插入通知记录到数据库,可能导致唯一键冲突(如果有唯一约束)或数据库锁竞争,降低系统性能甚至导致部分插入操作失败。例如,当大量用户几乎同时回复问题触发通知创建时,数据库的插入操作可能会相互干扰
- 通知重复发送:由于并发执行,可能会出现重复创建通知的情况。比如,两个线程同时判断接收者与评论者不相等,然后各自创建并插入相同的通知记录,导致问题创建者收到重复通知
- 数据一致性问题:在创建通知的过程中,可能涉及多个数据操作(如插入评论、更新评论计数、插入通知等),如果并发控制不当,可能导致数据不一致。例如,评论插入成功但通知插入失败,或者评论计数更新和通知创建的顺序混乱,使得数据状态不符合预期
- 事务管理混乱:现有的@Transactional注解在高并发下可能无法正确管理事务边界。多个并发事务可能相互影响,导致事务回滚不完整或事务提交顺序错误,进而影响数据的正确性和一致性
如何解决通知功能的上述问题
数据库层面:
- 为通知表的关键字段(如通知 ID 等)添加合适的唯一约束,避免重复插入。同时,合理设置数据库的隔离级别,减少并发事务之间的干扰。例如,将隔离级别设置为 SERIALIZABLE 可以确保事务串行化执行,但会影响并发性能,需要根据实际情况权衡
- 使用数据库的乐观锁或悲观锁机制,在插入通知记录前进行锁操作,保证同一时刻只有一个线程能够插入通知。例如,采用悲观锁时,在查询通知记录前加锁,防止其他线程同时修改或插入相同数据
代码层面: - 引入分布式锁,如使用 Redis 的 SETNX 命令实现分布式锁。在创建通知前获取锁,只有获取到锁的线程才能执行通知创建操作,完成后释放锁。这样可以避免多个线程同时创建通知导致的重复问题
- 对创建通知的操作进行幂等性设计。在插入通知前,先查询数据库中是否已经存在相同的通知记录(根据特定的唯一标识,如评论 ID 和接收者 ID 等组合),如果存在则不再插入,确保通知不会重复发送
- 优化事务管理,将相关操作(如评论插入、评论计数更新和通知创建)放在一个事务中,并确保事务的原子性、一致性、隔离性和持久性。可以考虑使用更细粒度的事务控制,如使用 TransactionTemplate 来手动管理事务,根据业务需求灵活控制事务的提交和回滚
- 增加日志记录,详细记录创建通知过程中的关键操作和异常情况,方便排查问题。例如,记录每次获取锁、插入通知等操作的时间和结果,以及发生异常时的具体信息
- 将通知的创建操作异步化,使用消息队列(如 RabbitMQ、Kafka 等)来解耦通知的生成和发送。当有回复操作时,将创建通知的任务发送到消息队列中,由专门的消费者线程从队列中取出任务进行处理。这样可以避免主线程的阻塞,提高系统的响应速度和并发性能
如果要添加一个新的排序规则,例如按照问题的收藏数排序,需要对项目做哪些修改?
- 枚举类:在 SortEnum 枚举类中添加新的排序规则,如 COLLECTION_COUNT
- 查询对象:在 QuestionQueryDTO 类中添加收藏数排序相关的字段,用于传递排序规则
- Mapper 和 SQL:在 QuestionExtMapper 中添加根据收藏数排序的查询方法,并在对应的 SQL 映射文件中编写相应的 SELECT 语句
- Service 层:在 QuestionService 的 list 方法中,增加对新排序规则的处理逻辑,根据新的排序规则调用相应的查询方法
- Controller 层:在接收前端请求时,支持新的排序规则参数,并将其传递给 QuestionService
在高并发情况下,多个用户同时浏览一个问题,导致数据库压力过大,如何优化?
- 缓存机制:引入缓存,将热门问题的信息缓存起来。当有用户请求问题详情时,先从缓存中获取数据,如果缓存中不存在再去数据库查询,并将查询结果存入缓存。可以设置合理的缓存过期时间,保证数据的时效性
- 异步处理:将问题浏览量的更新操作异步化。例如,使用消息队列,当用户浏览问题时,将更新浏览量的任务发送到消息队列中,由专门的消费者线程从队列中取出任务并更新数据库,避免大量的同步数据库操作
- 数据库优化:对数据库进行优化,如创建合适的索引,提高查询效率。例如,在 question 表的 id 字段上创建索引,加快根据问题 ID 查询问题信息的速度
当多个用户同时创建问题时,可能会出现数据库插入冲突的情况,如何解决?
- 唯一约束:在数据库的 question 表中,为关键字段(如问题标题等)添加唯一约束,防止插入重复的问题记录。当插入重复数据时,数据库会抛出异常,可在业务代码中捕获该异常并给用户相应的提示
- 分布式锁:引入分布式锁(如 Redis 实现的分布式锁),在创建问题前先获取锁,只有获取到锁的线程才能执行插入操作,插入完成后释放锁。这样可以保证同一时间只有一个线程能够插入问题记录,避免冲突。示例代码如下(使用 Redis 实现分布式锁)
在高并发场景下,用户频繁搜索问题,导致搜索性能下降,如何优化搜索功能?
- 全文搜索引擎:引入全文搜索引擎(如 Elasticsearch),将问题的标题、描述、标签等信息存储到 Elasticsearch 中。当用户进行搜索时,直接从 Elasticsearch 中查询,利用其强大的全文搜索功能提高搜索性能
- 缓存搜索结果:对于一些热门的搜索关键词,将搜索结果缓存起来。当有相同的搜索请求时,先从缓存中获取结果,如果缓存中不存在再进行实际的搜索操作,并将结果存入缓存
- 搜索关键词优化:对用户输入的搜索关键词进行预处理,如去除停用词、进行词干提取等,减少搜索范围,提高搜索效率。同时,在数据库查询时,使用更高效的查询语句和索引
若要在发布问题时,增加对问题内容的敏感词过滤功能,应该如何对代码进行修改?
- 敏感词库:创建或引入一个敏感词库,可以是文本文件、数据库表或内存数据结构,用于存储敏感词
- 过滤方法:编写一个敏感词过滤方法,该方法接收问题的标题和描述作为参数,检查其中是否包含敏感词
- 代码集成:在 doPublish 方法中,调用敏感词过滤方法对用户输入的标题和描述进行检查。若包含敏感词,向 Model 中添加错误信息,并返回 publish 页面
当高并发发布问题时,数据库插入操作成为性能瓶颈,如何提升系统的并发处理能力?
- 批量插入:对 QuestionService 的 createOrUpdate 方法进行优化,将多个问题的插入操作合并为批量插入。减少数据库连接的开销和插入操作的次数,从而提升性能
- 数据库连接池优化:合理配置数据库连接池的参数,如最大连接数、最小连接数、连接超时时间等,确保在高并发情况下,数据库连接池能够快速为请求分配连接,避免连接等待
- 读写分离和缓存:引入读写分离架构,将读操作和写操作分离开来,减轻数据库的压力。同时,对频繁读取的问题数据进行缓存,减少数据库读操作。可以使用 Redis 等缓存工具实现数据缓存
项目中,XXX类、XXXDTO类、XXXExample类分别是用于做什么的?各自承担什么样的责任?
XXXDTO 负责数据传输,XXX 负责数据持久化,XXXExample负责构建查询。
区别:
- CommentDTO 是数据传输对象,主要用于在不同层次(如控制器层和服务层之间)传输评论数据,可根据需要添加额外信息,如评论者的详细信息
- Comment 是数据库映射模型类,与数据库中的 comment 表一一对应,用于数据的持久化操作,字段与数据库表列完全一致
- CommentExample 是查询条件构建类,配合 MyBatis 框架使用,用于动态生成查询条件,以筛选符合特定条件的评论记录
作用: - CommentDTO 的作用是在应用程序的不同部分之间传递评论数据,方便数据的展示和处理,同时可以对数据进行适当的封装和转换
- Comment 的作用是作为数据库和应用程序之间的桥梁,实现数据的读取和写入操作,保证数据在数据库存储和应用程序中的一致性
- CommentExample 的作用是提供一种灵活的方式来构建查询条件,使开发者能够根据不同的需求动态地查询数据库中的评论记录
用户反馈发布问题时标签明明合法,却提示非法标签,可能是什么原因?如何解决?
- 缓存不一致:TagCache 缓存中存储的合法标签信息可能过时,导致原本合法的标签被误判为非法
- 过滤逻辑错误:TagCache.filterInvalid 方法的过滤逻辑存在缺陷,错误地将合法标签识别为非法
解决方案:
- 更新缓存机制:定期更新 TagCache 中的合法标签信息,确保缓存数据的时效性。可在系统启动时、标签数据发生变化时主动更新缓存
- 校验过滤逻辑:检查 TagCache.filterInvalid 方法的代码逻辑,通过单元测试覆盖各种标签输入场景,修正可能存在的错误判断逻辑
在项目中,提到了使用 flyway 管理数据库脚本,是怎么样实现的?
- 配置连接信息:在项目中,需配置 Flyway 与数据库的连接信息,像数据库 URL、用户名、密码等。这能让 Flyway 与数据库建立连接,从而对数据库进行操作
- 识别和分类脚本:Flyway 按特定命名规范识别数据库脚本。通常,版本控制脚本以V开头,后跟版本号、下划线和脚本描述,像V1__Initial_schema_setup.sql;可重复执行脚本以R开头,比如R1__Add_column_if_not_exists.sql。通过这种命名方式,Flyway 能区分不同类型的脚本,便于管理和执行
- 版本控制与迁移:Flyway 执行脚本时,会在数据库中维护一个特殊的表(默认为flyway_schema_history),用于记录已执行的脚本信息,包括脚本版本、描述、执行时间等。当有新脚本添加到项目中,Flyway 会对比数据库中已执行的脚本版本和新脚本版本。如果新脚本版本高于已执行的最高版本,Flyway 就会按版本顺序依次执行新脚本,实现数据库的迁移和升级
- 执行顺序与事务处理:对于版本控制脚本,Flyway 严格按版本号顺序执行,保证数据库升级的顺序性和一致性。同时,Flyway 默认将每个脚本的执行作为一个事务处理。若脚本执行过程中出现错误,事务会回滚,确保数据库状态的完整性。不过,也可通过配置让多个脚本在一个事务中执行
- 重复脚本执行:可重复执行脚本(以 R 开头)用于处理那些需要在不同环境或多次部署中重复执行的操作,如添加或修改特定的数据库对象。Flyway 会根据脚本内容的校验和来判断是否需要重新执行该脚本。若脚本内容发生变化,校验和改变,Flyway 就会重新执行该脚本
- 命令行和集成使用:Flyway 提供命令行工具,可直接在命令行中执行数据库迁移操作,像 flyway migrate 用于执行所有未执行的脚本。此外,Flyway 还能集成到构建工具(如 Maven、Gradle)和开发框架(如 Spring Boot)中,方便在项目构建和部署过程中自动执行数据库迁移任务
使用 flyway 管理数据库脚本和不使用 flyway 管理数据库脚本有什么区别吗?
使用 Flyway 时,它会自动跟踪数据库的版本,记录哪些脚本已经执行,哪些还未执行。开发人员可以方便地将数据库升级或回滚到特定版本,确保数据库在不同环境(开发、测试、生产等)中的一致性。
Flyway 可以在应用启动时自动执行数据库迁移脚本,无需人工手动干预,提高了部署的效率和准确性。
没有 Flyway 时,可能需要开发人员手动在数据库客户端或通过命令行逐个执行脚本,容易遗漏或执行错误的脚本,尤其是在频繁更新数据库结构的项目中,这种方式效率低下且容易出错。
Flyway 通过事务来确保脚本的原子性执行,如果一个脚本执行失败,它可以回滚整个事务,保证数据库的一致性。同时,它还提供了一些验证机制,如检查脚本的语法和顺序,有助于发现潜在的问题,提高数据库的稳定性。
RBAC 权限系统
AOP 的底层原理
AOP(面向切面编程)是一种编程范式,其核心思想是将横切关注点(如日志记录、事务管理等)从业务逻辑中分离出来。Spring AOP 底层主要采用两种代理方式来实现:
- JDK 动态代理:基于接口实现,它会在运行时创建一个实现了目标接口的代理类,通过调用处理器(InvocationHandler)来处理方法调用。
- CGLIB 代理:基于继承实现,它会在运行时生成一个继承自目标类的子类作为代理类,通过方法拦截器(MethodInterceptor)来处理方法调用。
项目中自定义注解是如何实现的?
定义一个 RequiresPermissions 注解,它的用途是对接口访问所需的权限进行标记。在项目里,可以运用这个注解对特定接口或者类进行标注,以此表明访问这些接口或者类时需要具备哪些权限。
|
@Target 注解:此注解用来指定 RequiresPermissions 注解可以应用的目标元素类型。{ElementType.TYPE, ElementType.METHOD} 意味着该注解既能应用于类、接口、枚举等类型,也能应用于方法。
@Retention 注解:该注解用于指定注解的保留策略。RetentionPolicy.RUNTIME 表明该注解在运行时仍然保留,这样在程序运行期间就可以通过反射机制来获取这个注解的信息。
Logical 是一个枚举类型,用于表示权限验证的逻辑关系,AND 表示需要同时满足所有权限,OR 表示只需满足其中一个权限即可。
项目中如何对带有 RequiresPermissions 注解的方法进行权限校验?
定义一个名为 PermissionAspect 的切面类,使用前置通知:
|
前置通知即:在目标方法执行之前,会根据用户的权限列表和注解中指定的权限要求进行比较。
需要获取当前用户的信息和权限列表,以及获取目标方法上的 RequiresPermissions 注解,并提取所需的权限代码。
根据 RequiresPermissions 注解的 logical 属性判断权限校验逻辑:
- 如果是 Logical.AND,则要求用户必须具备所有指定的权限,否则抛出 UnauthorizedException 异常
- 如果是 Logical.OR,则要求用户只需具备其中一个指定的权限,否则抛出 UnauthorizedException 异常
JoinPoint 在 AOP 中有什么作用?
JoinPoint 是 AOP 中的一个重要概念,它代表了程序执行过程中的一个点,如方法调用、异常抛出等。在这个代码中,JoinPoint 用于获取目标方法的签名信息,通过 joinPoint.getSignature() 可以获取到方法的签名,进而获取到目标方法上的注解信息。
请解释一下权限校验的逻辑?
- 首先,从 TokenService 中获取当前用户的信息,并提取用户拥有的权限列表 myCodes。
- 然后,通过 JoinPoint 获取目标方法上的 RequiresPermissions 注解,并提取注解中指定的权限列表 perms。
- 根据注解中的 logical 属性进行权限校验:
- 如果 logical 为 Logical.AND,则要求用户必须具备所有指定的权限,遍历 perms 列表,只要有一个权限不在 myCodes 中,就抛出 UnauthorizedException 异常
- 如果 logical 为 Logical.OR,则要求用户只需具备其中一个指定的权限,遍历 perms 列表,只要有一个权限在 myCodes 中,就标记为通过校验,否则抛出 UnauthorizedException 异常
如何在程序中记录记录 Web 请求的相关信息,包括请求的入参、出参、请求路径、请求耗时等,方便对系统的请求进行监控和调试?
1. 切面定义与配置:
- 使用 @Aspect 注解将 WebLogAspect 标记为一个切面类,用于定义横切关注点
- @Component 注解将该类纳入 Spring 容器管理
- @Order(1) 指定该切面的优先级为 1,数字越小越先执行
2. 定义切入点: - @Pointcut(“execution(public * com.heeexy.example.controller..(..))”) 定义了一个切入点webLog,匹配 com.heeexy.example.controller 包及其子包下所有公共方法
- @Pointcut(“execution(public * com.heeexy.example.config.exception.GlobalExceptionHandler.*(..))”) 定义了另一个切入点 exceptions,匹配 GlobalExceptionHandler 类中的所有公共方法
3. 前置通知(记录请求信息): - @Before(“webLog()”) 注解表示在 webLog 切入点所匹配的方法执行前执行 doBefore 方法
- 在 doBefore 方法中,首先通过 RequestContextHolder 获取当前请求的 ServletRequestAttributes,进而得到 HttpServletRequest 对象
- 记录请求路径和进入的方法名到日志中,并使用 MDC (Mapped Diagnostic Context) 记录请求的详细信息 req 和开始时间 startTime,其中 req 是通过 getRequestInfo 方法获取的请求信息的 JSON 字符串表示
4. 后置通知(打印请求日志): - @AfterReturning(pointcut = “webLog()|| exceptions()”, returning = “result”) 注解表示在 webLog 或 exceptions 切入点所匹配的方法执行结束后执行 afterReturning 方法,returning 指定返回值的变量名 result
- 在 afterReturning 方法中,获取当前请求的 HttpServletRequest 对象和 MDC 中的上下文信息
- 构建一个 JSONObject 对象,包含请求的 URI、耗时、用户 ID(如果存在)、请求信息 req 以及响应结果 res (如果有返回值)
- 将构建好的 JSONObject 转换为字符串并记录到日志中,方便查看完整的请求和响应信息
什么是 MDC?
MDC 即 Mapped Diagnostic Context(映射诊断上下文),是 SLF4J(Simple Logging Facade for Java)中的一个概念。它是一个与当前执行线程绑定的 Map,用于在多线程环境下存储和传递与特定线程相关的诊断信息。
JoinPoint 和 PointCut 讲一下?
JoinPoint 代表程序执行过程中的一个特定点,比如方法调用、异常抛出、字段访问等。在 Spring AOP 里,主要关注的是方法调用这个连接点。简单来说,JoinPoint 就是程序执行流程中可以插入切面逻辑的地方。
Pointcut 是一个表达式,其作用是定义哪些 JoinPoint 会被 AOP 切面所影响。简单来讲,Pointcut 就是用来筛选出你想要应用切面逻辑的连接点集合。
Pointcut 就像是一个过滤器,它从众多的 JoinPoint 中挑选出符合条件的连接点,然后在这些连接点上应用切面逻辑。可以把 Pointcut 看作是一个规则,而 JoinPoint 则是具体的执行点,Pointcut 决定了哪些 JoinPoint 会受到切面的影响。
请详细描述从用户发起登录请求到最终获取到登录后页面(假设请求到控制器方法并成功处理)的整个流程中,MDC 是如何被使用的?
当用户发起登录请求,请求到达 RequestFilter 过滤器时,首先会生成一个 traceId 并通过 MDC.put(“traceId”, UUID.randomUUID().toString().replace(“-“, “”).substring(0, 12)) 放入 MDC 中,用于后续日志追踪。接着从请求头中获取 token,如果 token 存在,将其放入 MDC(MDC.put(“token”, token))。然后尝试通过 TokenService 获取用户信息,若成功获取到用户信息,提取用户名并放入 MDC(MDC.put(“username”, username))。从请求参数中获取 productId 并在其存在时放入 MDC。在请求处理完成后(无论是否发生异常),RequestFilter 会调用 MDC.clear() 清理 MDC 中的所有上下文信息。在权限校验的 PermissionAspect 切面中,也会利用 MDC 中存储的用户信息等数据进行权限判断相关的日志记录等操作。这样 MDC 在整个登录请求处理流程中,为日志记录提供了丰富的上下文信息,方便排查问题和监控系统。
通过 TokenService 获取用户信息,即在 Caffine 缓存中根据 token 查询用户信息。
getUserInfo 方法中,为什么要先从 MDC 中获取 token,而不是直接从请求头中获取?
从 MDC 中获取 token 是为了方便在整个请求处理过程中共享 token 信息。MDC 是线程本地的映射,在请求处理的不同阶段(如过滤器、拦截器、服务层等)都可以方便地存储和获取相关信息。这样可以避免在不同层次的代码中重复从请求头获取 token 的操作,提高代码的复用性和可维护性。同时,使用 MDC 也有助于统一管理和记录与请求相关的上下文信息,方便日志追踪和问题排查。
MDC 中存储的信息的清除时机讲一讲?
try { |
finally 块中的代码会在 try 块中的代码执行完毕后,无论是否发生异常都会执行。它等待响应返回后才运行,是因为 filterChain.doFilter(request, response) 这行代码。
这行代码会将请求传递给下一个过滤器或者最终到达目标资源(如 Servlet)进行处理,这个过程是同步的。也就是说,当前过滤器会一直等待目标资源处理完请求并生成响应后,才会继续执行 finally 块中的代码。所以,看起来 finally 块中的代码是在响应返回后才运行,实际上是因为请求处理和响应生成的过程在 filterChain.doFilter(request, response) 这一步被阻塞了,只有当这个方法返回,才会继续执行后续的 finally 块代码来清理资源等操作。
在用户登录的流程中,TokenService 里的 generateToken 方法生成的 token 为什么要设置成 20 位的字符串,有什么好处?
generateToken 方法生成 20 位的字符串作为 token 主要有以下好处。一方面,20 位的长度在保证一定随机性的同时,不会过长导致存储和传输成本过高。使用 UUID 生成并经过处理得到 20 位字符串,能够提供足够的唯一性,降低 token 重复的概率,确保每个用户登录生成的 token 具有较高的独特性,从而准确标识用户身份。另一方面,适中的长度也便于在请求头、缓存键值等场景中使用和处理,不会给系统带来额外的负担,提升了系统的易用性和性能表现。
generateToken 方法中为什么要使用 UUID 来生成 token,有什么优缺点?
使用 UUID 生成 token 的优点在于它具有全球唯一性,几乎可以保证生成的 token 不会重复,能有效标识不同用户的登录状态。而且生成过程简单,无需依赖外部服务,能快速生成。缺点是 UUID 生成的字符串较长,在存储和传输时会占用一定资源,并且随机性相对较弱,可能存在一定的安全隐患。
除了 UUID,还可以通过哈希算法(如 MD5、SHA - 256 等)对特定信息进行处理生成 token。通常会结合用户的关键信息(如用户名、密码、时间戳等),再添加一个盐值(salt)来增加 token 的安全性;还可以使用 JWT,它是一种用于在网络应用中安全传输信息的开放标准(RFC 7519)。它由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。头部包含了使用的签名算法等信息,载荷包含了用户的相关信息(如用户 ID、角色等),签名则用于验证 token 的完整性和真实性。
在权限校验的 PermissionAspect 切面中,如果权限校验不通过抛出了 UnauthorizedException 异常,这个异常后续是如何被处理的?
当 PermissionAspect 切面中权限校验不通过抛出 UnauthorizedException 异常后,这个异常会根据项目的异常处理机制进行处理。一般来说,项目中会有全局异常处理器(如 GlobalExceptionHandler 类)来捕获并处理这类异常。全局异常处理器会根据异常类型(这里是 UnauthorizedException)进行相应的处理,例如返回一个包含错误信息的 JSON 响应给客户端,告知用户权限不足,无法访问该资源。同时,也可能会记录相关的异常日志,以便开发人员进行问题排查和系统监控。
在高并发情况下,TokenService 中使用 Caffeine 缓存来存储用户信息,如何保证缓存的一致性和数据的正确性?
Caffeine 本身提供了一些机制来保证在高并发场景下的缓存一致性和数据正确性。首先,Caffeine 的缓存加载是线程安全的,多个线程同时请求同一个不存在的 token 对应的用户信息时,只有一个线程会去实际加载数据(如从数据库获取用户信息),其他线程会等待并获取加载后的数据,避免了重复加载。其次,可以设置合理的缓存过期策略,如基于时间的过期(如 expireAfterWrite 方法设置写入后的过期时间),这样可以保证在一定时间后自动更新缓存数据,避免因数据长时间未更新导致的不一致。另外,在用户登出时,通过 invalidateToken 方法主动从缓存中移除对应的用户信息,确保缓存中的数据与用户的实际登录状态一致。同时,在更新用户信息(如用户权限变更等情况)时,及时更新缓存中的数据,以保证数据的正确性。
RequestFilter 继承自 OncePerRequestFilter,在高并发多线程环境下,它是如何保证每个请求只被过滤一次的?
OncePerRequestFilter 内部维护了一个基于请求的 ThreadLocal 状态来实现每个请求只被过滤一次的功能。在 doFilterInternal 方法执行前,会检查当前请求是否已经被过滤过。当一个请求到达时,OncePerRequestFilter 会在 ThreadLocal 中记录该请求已被处理的状态。如果后续再次进入 doFilterInternal 方法(在同一个请求的处理过程中),会先检查 ThreadLocal 中的状态,如果发现该请求已经被处理过,则直接跳过过滤逻辑,将请求传递给过滤器链中的下一个组件。由于 ThreadLocal 是线程本地的存储,每个线程都有自己独立的副本,所以在高并发多线程环境下,不同线程处理不同请求时,各自的 ThreadLocal 状态互不干扰,从而保证了每个请求只被过滤一次,避免了重复过滤带来的性能问题和逻辑错误。
cacheMap 作为缓存存储 SessionUserInfo,当缓存满了之后会发生什么?
cacheMap 使用的是 Caffeine 缓存,其缓存策略由 CacheConfig 类中的配置决定。在代码里,设置了 maximumSize 属性,当缓存中的条目数量达到最大限制时,Caffeine 会根据其默认的淘汰策略(通常是 LRU,即最近最少使用)来淘汰一些缓存项,为新的缓存项腾出空间。
在 setCache 方法中,将用户信息存入缓存,有没有考虑过缓存穿透、缓存击穿和缓存雪崩的问题,如何解决?
- 缓存穿透:指查询一个一定不存在的数据,由于缓存中没有,每次都会去数据库查询,导致数据库压力增大。可以在查询数据库后,如果结果为空,也将一个空值(如 null 或特定的空对象)存入缓存,并设置一个较短的过期时间,这样下次查询相同的不存在数据时可以直接从缓存中获取,避免频繁访问数据库
- 缓存击穿:指一个热点 key 在缓存过期的瞬间,有大量请求同时访问该 key,这些请求都会直接访问数据库,导致数据库压力过大。可以采用分布式锁,当一个请求发现缓存中该 key 过期时,先获取锁,然后去数据库查询并更新缓存,其他请求等待,更新完成后其他请求可以直接从缓存中获取数据
- 缓存雪崩:指大量的缓存 key 在同一时间过期,导致大量请求直接访问数据库,造成数据库压力过大甚至崩溃。可以给不同的 key 设置不同的过期时间,避免大量 key 同时过期。或者使用缓存集群,提高缓存的可用性和稳定性
在高并发场景下,generateToken 方法中使用 UUID 生成 token 是否会出现重复的情况?
理论上,UUID 具有全球唯一性,在高并发场景下出现重复的概率极低。UUID 是基于时间戳、随机数和计算机硬件信息等生成的,在同一时刻不同计算机或同一计算机的不同线程生成相同 UUID 的可能性几乎为零。不过,在极端情况下,如果系统的时钟出现问题或者存在随机数生成器的缺陷,可能会导致重复的 UUID 生成,但这种情况非常罕见。
在高并发场景下,invalidateToken 方法中清除缓存的操作是否会出现并发问题,如何解决?
在高并发场景下,invalidateToken 方法清除缓存的操作可能会出现并发问题。例如,多个线程同时尝试清除同一个 token 的缓存,可能会导致部分操作失败或者出现不一致的情况。可以使用分布式锁来解决这个问题,在清除缓存之前先获取锁,确保同一时间只有一个线程可以进行清除操作,避免并发冲突。也可以考虑使用 Caffeine 缓存本身提供的原子操作来保证操作的原子性。
在插入/更新文章时,需要传入参数 JSONObject,除了前端校验,后端还能通过哪些方式保证 JSONObject 格式正确
在后端的控制器方法中,可以使用 @RequestBody 注解接收 JSONObject 对象,并结合 CommonUtil 工具类进行参数校验。例如,在 ArticleController 中:
|
也可以自定义注解,结合 AOP(面向切面编程)技术,在方法执行前对 JSONObject 对象进行校验。
在业务运行过程中,用户在新增文章时,突然发现提交的文章内容包含敏感词汇,假设系统要求检测并禁止这类文章入库,你会如何在现有代码结构基础上进行实现?
可在 ArticleController 的 addArticle 方法中,在调用 articleService.addArticle 前,添加敏感词检测逻辑。例如,构建一个敏感词库,将用户提交的文章内容与敏感词库进行比对。以 Java 实现为例,可使用 Trie 树算法构建敏感词库,实现快速检索。
import java.util.HashSet; |
在updateRole方法中,更新角色权限时,为什么要分别处理新权限的添加和旧权限的移除,而不是直接覆盖?
在 updateRole 方法中分别处理新权限的添加和旧权限的移除,是为了避免不必要的数据覆盖,从而减少对系统中其他依赖该角色权限的模块造成影响。如果直接覆盖角色权限,可能会意外删除其他模块正在使用的权限,导致相关功能无法正常运行。分别处理可以精确控制权限的变更,只添加新的权限,移除不再需要的旧权限,这样既实现了角色权限的更新,又保证了系统的稳定性和兼容性。
在获取用户列表的listUser方法中,频繁地与数据库交互获取用户数量和用户列表。请提出优化方案,减少数据库的压力。
可以通过引入缓存机制来优化。例如,使用 Redis 作为缓存,在查询用户列表前,先从 Redis 中查找是否存在对应的缓存数据。若缓存命中,直接返回缓存数据,避免数据库查询;若缓存未命中,再进行数据库查询,并将查询结果存入 Redis 缓存,设置合理的过期时间。同时,在数据发生变更(如添加、删除、修改用户)时,及时更新或删除相应的缓存数据,以保证缓存数据的一致性。
在 addRole、updateRole 和 deleteRole 方法上都使用了 @Transactional 注解,这有什么作用?如果这些方法执行过程中出现异常,事务是如何回滚的?
@Transactional 注解的作用是确保方法内的所有数据库操作要么全部成功提交,要么全部回滚,保证数据的一致性和完整性。当这些方法执行过程中抛出异常时,Spring 的事务管理机制会捕获异常,然后根据事务传播机制和回滚规则,撤销方法执行过程中对数据库的所有修改,将数据库状态恢复到事务开始前的状态。例如,在 addRole 方法中,如果 insertRole 操作成功,但 insertRolePermission 操作失败,事务会回滚,之前插入的角色信息也会被撤销,避免出现数据不一致的情况。
在高并发场景下,多个用户同时尝试添加相同角色时,可能会出现数据冲突。如何防止这种情况的发生?
可以通过数据库的唯一性约束来防止添加相同角色。在数据库的角色表中,对角色名称等唯一标识字段添加唯一性约束,当多个用户同时尝试添加相同角色时,数据库会抛出唯一性冲突异常,应用程序可以捕获该异常并进行相应处理,提示用户角色已存在。另外,也可以使用分布式锁,如 Redis 锁,在添加角色前获取锁,确保同一时间只有一个线程可以执行添加角色的操作,避免数据冲突。以
为什么要区分角色和权限?
角色的引入主要是为了方便对用户权限进行管理。通过将权限分配给角色,而不是直接分配给单个用户,当用户的职责发生变化时,只需将其所属角色进行调整,而无需逐个修改用户的权限,大大简化了权限管理的复杂度,提高了系统的可维护性和安全性。同时,角色也有助于在系统设计和开发过程中,根据不同角色的需求来设计和优化系统的功能和界面,提高系统的易用性和用户体验。
角色是可以添加的,权限是固定的(数据库中定义好的)。
在 updateRole 方法中,若角色权限更新失败,如何通过事务保证数据一致性,回滚所有已执行的操作?
updateRole 方法使用了 @Transactional(rollbackFor = Exception.class) 注解,这意味着当方法执行过程中抛出任何异常时,Spring 事务管理机制会自动触发事务回滚。在更新角色权限时,若添加新权限或移除旧权限操作失败,抛出的异常会被事务管理器捕获,从而撤销之前对角色名称的更新以及部分已执行的权限变更操作,确保数据库数据始终保持一致状态。
如果要添加新的角色,比如 “数据分析员”,请描述添加新角色的具体步骤,并说明如何为该角色分配相应的权限?
- 数据库层面:在角色表中插入新角色 “数据分析员” 的记录,获取新生成的角色 ID
- 业务代码层面:在服务层或控制层中使用 addRole 方法,并传入包含新角色名称和相应权限 ID 列表的 JSONObject。例如,如果数据分析员需要查看数据报表、生成分析报告的权限,可以在 addRole 方法的 JSONObject 参数中,指定角色名称为 “数据分析员”,并将查看数据报表和生成分析报告对应的权限 ID 作为 permissions 字段的值
- 配置相关权限:根据系统的业务需求,在权限表中配置数据分析员所需的具体权限操作,如赋予对数据报表的读取权限、对分析报告生成接口的访问权限等
电商网站
Spring Security & JWT
认证和鉴权的区别是什么?
认证(Authentication):是指核实用户身份的过程,确认用户是否是其所声称的那个人。通俗来讲,就是要弄清楚你是谁。例如,你在登录银行的网上账户时,输入用户名和密码,银行系统会验证这些信息是否与预先存储的信息匹配,以此来确认是你本人在操作。
鉴权(Authorization):是在认证的基础上,确定用户是否有权限访问特定的资源或执行特定的操作。也就是判断你能做什么。比如,你成功登录银行账户后,银行系统会根据你的账户权限,决定你是否能够进行转账、查看交易记录等操作。
项目中身份验证和权限校验的逻辑是怎么样的?
用户请求进入系统后,首先经过 JwtAuthenticationTokenFilter 进行 JWT 认证,确定用户身份,解析用户信息并将其存入安全上下文;然后进入 DynamicSecurityFilter,该过滤器触发权限验证流程,调用 DynamicSecurityMetadataSource 获取安全元数据,再调用 DynamicAccessDecisionManager 进行权限决策,最终根据决策结果决定是否允许请求继续处理。
1.请求进入 JwtAuthenticationTokenFilter
JwtAuthenticationTokenFilter 是整个请求处理流程中的第一个关键过滤器,它在 SecurityConfig 类中被配置在 UsernamePasswordAuthenticationFilter 之前执行。
addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); |
- 获取请求头中的 JWT:过滤器会从请求头中获取配置好的 tokenHeader 字段的值,检查该值是否以 tokenHead(通常为 Bearer)开头
- 解析 JWT 中的用户名:如果存在有效的 JWT,提取出 JWT 部分(去除 tokenHead 前缀),并使用 JwtTokenUtil 工具类解析出用户名
- 检查用户是否已认证:若用户名不为空且当前安全上下文(SecurityContextHolder)中没有认证信息,则继续进行认证
- 加载用户详细信息:使用 UserDetailsService 根据用户名加载用户详细信息
- 验证 JWT:使用 JwtTokenUtil 验证 JWT 的有效性,确保 JWT 未过期且签名正确
- 创建认证对象并设置到安全上下文:如果 JWT 验证通过,创建一个 UsernamePasswordAuthenticationToken 对象,包含用户详细信息和用户的权限信息,然后将该认证对象设置到安全上下文中,表示用户已认证
- 继续处理请求:最后,将请求传递给过滤器链中的下一个过滤器
tokenHeader:它是一个配置项,通过 @Value(“${jwt.tokenHeader}”) 从配置文件中读取。通常表示 JWT 在请求头中的字段名,例如常见的字段名是 Authorization。客户端在发送请求时,会将 JWT 放在该字段中。
tokenHead:同样是一个配置项,通过 @Value(“${jwt.tokenHead}”) 从配置文件中读取。它表示 JWT 在请求头中的前缀,通常是 Bearer。在 HTTP 请求头中,JWT 的格式一般是 Bearer,其中 Bearer 就是 tokenHead, 是真正的 JWT 字符串。
2.请求进入 DynamicSecurityFilter
DynamicSecurityFilter 在 JwtAuthenticationTokenFilter 之后执行,在 SecurityConfig 类中被配置在 FilterSecurityInterceptor 之前。
addFilterBefore(dynamicSecurityFilter, FilterSecurityInterceptor.class); |
- 初步检查:对请求进行初步检查,例如检查请求方法是否为 OPTIONS、请求的 URL 是否在白名单中。如果请求符合直接放行的条件,则直接将请求传递给下一个过滤器
- 触发权限验证流程:若请求不符合直接放行的条件,则调用 AbstractSecurityInterceptor 的 beforeInvocation 方法触发权限验证流程
3.DynamicSecurityFilter 调用 DynamicSecurityMetadataSource 获取安全元数据
- 获取安全元数据:AbstractSecurityInterceptor 会调用 DynamicSecurityMetadataSource 的 getAttributes 方法,根据当前请求的 URL 从预先加载的 configAttributeMap 中查找匹配的权限配置信息。configAttributeMap 是在系统启动或权限配置发生变化时,由 DynamicSecurityService 从数据库或其他数据源中加载所有资源(URL)及其对应的权限配置信息后存储的
- 匹配 URL 模式:使用 AntPathMatcher 等工具来判断请求的 URL 是否匹配预先配置的 URL 模式。例如,配置的 URL 模式可能是 admin/**,它可以匹配 admin/categories 和 admin/user/userorders 等路径
4.DynamicSecurityFilter 调用 DynamicAccessDecisionManager 进行权限决策
- 权限决策:AbstractSecurityInterceptor 获取到安全元数据后,会调用 DynamicAccessDecisionManager 的 decide 方法进行权限决策
- 比对权限信息:DynamicAccessDecisionManager 会将用户的认证信息(Authentication 对象)与请求所需的权限配置信息(ConfigAttribute 集合)进行比对。如果用户拥有的权限(GrantedAuthority)中包含访问该资源所需的权限(needAuthority),则允许请求继续处理;否则,抛出 AccessDeniedException 异常,表示用户没有访问权限
5.请求继续处理或被拒绝
- 允许访问:如果 DynamicAccessDecisionManager 允许请求继续处理,DynamicSecurityFilter 会继续调用过滤器链中的下一个过滤器,直到请求到达目标资源。
- 拒绝访问:如果 DynamicAccessDecisionManager 拒绝了请求,会抛出 AccessDeniedException 异常,DynamicSecurityFilter 会捕获该异常并进行相应的处理,例如返回权限拒绝的响应。
JWT 由哪几部分组成,项目中是如何使用这些部分的?
JWT 由 Header、Payload 和 Signature 三部分组成。Header 包含令牌的类型和签名算法;Payload 存放用户信息、令牌过期时间等声明;Signature 用于验证消息在传递过程中有没有被更改。在项目中,JwtTokenUtil 类负责生成和验证 JWT。生成时,将用户信息和创建时间等放入 Payload,使用配置的密钥和 HS512 算法生成 Signature。验证时,通过解析 Signature 来确保 JWT 的完整性和合法性,并从 Payload 中获取用户名等信息进行进一步验证。
JWT token 的格式:header.payload.signature
header 的格式(算法、token的类型):{“alg”: “HS512”,”typ”: “JWT”}
payload 的格式(用户名、创建时间、生成时间):{“sub”:”wang”,”created”:1489079981393,”exp”:1489684781}
signature 的生成算法:HMACSHA512(base64UrlEncode(header) + “.” +base64UrlEncode(payload),secret)
Spring Security 的过滤器链在项目中是如何配置和工作的?
在 SecurityConfig 类中配置过滤器链。JwtAuthenticationTokenFilter 被添加到 UsernamePasswordAuthenticationFilter 之前,用于 JWT 认证;DynamicSecurityFilter 被添加到 FilterSecurityInterceptor 之前,用于动态权限控制。当请求进入时,过滤器链按顺序依次执行。每个过滤器对请求进行特定处理,如 JwtAuthenticationTokenFilter 验证 JWT,DynamicSecurityFilter 进行权限验证,只有通过所有过滤器的检查,请求才能到达目标资源。
接上文,再具体说说项目中过滤器链在 SecurityConfig 中是如何配置的?
1.定义不需要进行安全保护的资源路径,允许这些路径的请求直接访问。
即白名单,例如登录接口。
2.允许跨域请求中的 OPTIONS 请求直接通过。
在跨域请求中,浏览器会先发送一个 OPTIONS 请求进行预检,此配置确保这些预检请求不会被拦截。
3.要求除上述允许的请求外,其他所有请求都必须进行身份认证。
要求除了前面允许的请求外,其他所有请求都必须进行身份认证。只有经过认证的用户才能访问这些资源。
4.关闭跨站请求防护(CSRF),并设置会话管理策略为无状态(不使用会话)。
在前后端分离的应用中,通常不需要使用 CSRF 防护。并且应用不会创建或使用会话来跟踪用户的状态,而是使用 JWT 等方式进行身份验证。
5.配置自定义的权限拒绝处理类和认证入口点,用于处理权限不足和未认证的情况。
6.添加自定义的 JWT 认证过滤器,在用户名密码认证过滤器之前执行。
添加自定义的 JWT 认证过滤器,并且在用户名密码认证过滤器之前执行。该过滤器会从请求的头部中提取 JWT 令牌,并进行验证和解析,以确定用户的身份。如果验证通过,会将用户信息存入 Spring Security 的上下文,以便后续的权限验证。
7.如果存在动态权限配置,添加动态权限校验过滤器,在过滤器安全拦截器之前执行。
如果存在动态权限配置(即 dynamicSecurityService 不为空),则添加动态权限校验过滤器,并且在过滤器安全拦截器之前执行。该过滤器用于动态地检查用户是否具有访问某个资源的权限,适用于权限规则经常变化的场景。
项目中是如何处理跨域请求的,这样处理有什么优缺点?
项目中通过在 SecurityConfig 中配置 registry.antMatchers(HttpMethod.OPTIONS).permitAll() 来允许跨域预检请求(OPTIONS 请求)通过。优点是简单直接,能够快速解决跨域预检请求的问题,保证正常的跨域请求流程。缺点是这种配置相对宽松,对所有 OPTIONS 请求都放行,缺乏细粒度的控制。在实际应用中,可能需要结合其他手段,如检查请求头中的 Origin 字段,对跨域请求进行更严格的限制,以提高系统的安全性。
SecurityContextHolder 有了解过吗?具体是怎么用的?
1.认证时设置认证信息
在 JwtAuthenticationTokenFilter 里,当 JWT 验证通过之后,会创建一个 UsernamePasswordAuthenticationToken 对象,此对象代表了已认证的用户信息。接着,借助 SecurityContextHolder.getContext().setAuthentication(authentication) 把该认证对象存于当前线程的安全上下文之中。
2.后续请求处理时获取认证信息
在后续的请求处理流程中,若需要对用户的权限进行验证或者获取用户的相关信息,就可以从 SecurityContextHolder 里获取认证信息。例如,在 DynamicSecurityFilter 或者控制器方法里,可以通过 SecurityContextHolder.getContext().getAuthentication() 来获取当前用户的认证对象,进而开展权限判断等操作。
使用 SecurityContextHolder 有什么好处?
1.集中管理认证信息
SecurityContextHolder 提供了一个集中的位置来存储和管理当前用户的认证信息。这样一来,项目中的各个组件(如过滤器、控制器等)都能够方便地访问和使用这些信息,避免了在不同组件之间传递认证信息的麻烦,提高了代码的可维护性和可扩展性。
2. 实现请求处理过程中的信息共享
在一个请求的处理流程中,通常会经过多个过滤器和处理器。通过 SecurityContextHolder,不同的组件可以共享认证信息。例如,在 JwtAuthenticationTokenFilter 完成 JWT 认证之后,将认证信息设置到 SecurityContextHolder 中,后续的 DynamicSecurityFilter 或者其他需要进行权限验证的组件,就可以从 SecurityContextHolder 中获取认证信息,从而进行权限判断。
3.确保线程安全
在多线程环境下,每个请求通常由一个独立的线程来处理。SecurityContextHolder 采用线程绑定的方式,确保每个线程都有自己独立的安全上下文副本,不同线程的安全上下文不会相互干扰。这样,在多用户并发访问的情况下,每个用户的认证信息都能得到正确的处理和存储,保证了系统的安全性和稳定性。
4.遵循 Spring Security 的设计理念
SecurityContextHolder 是 Spring Security 框架的核心组件之一,使用它符合 Spring Security 的设计理念。通过统一的接口和方式来管理认证信息,使得项目与 Spring Security 框架更好地集成,能够充分利用 Spring Security 提供的各种安全功能。
SecurityContextHolder 和 threadlocal 非常相似,那它会不会造成内存泄露问题呢?
SecurityContextHolder 和 ThreadLocal 有相似之处,因为 SecurityContextHolder 内部默认是使用 ThreadLocal 来存储 SecurityContext 的。在使用不当的情况下,SecurityContextHolder 可能会造成内存泄露问题,原因如下:
- 线程重用场景:在一些线程池环境中,线程会被重用。如果在使用完 SecurityContextHolder 后没有及时清理其中存储的信息,那么当这个线程被分配到下一个任务时,上一个任务的 SecurityContext 信息仍然存在于 ThreadLocal 中。随着时间的推移,可能会导致大量的无用对象在内存中累积,从而引发内存泄露
- 异常处理不当:如果在业务逻辑执行过程中发生异常,导致正常的清理流程没有执行,那么 SecurityContext 可能会残留在 ThreadLocal 中。例如,在一个需要进行权限验证的方法中,由于发生了未捕获的异常,没有机会去清除 SecurityContextHolder 中的信息,就可能造成内存泄露。
为了避免 SecurityContextHolder 可能导致的内存泄露问题,通常需要在请求处理完成后,手动清除 SecurityContextHolder 中的信息。例如,在 Spring Web 应用中,可以使用 FilterChain 的 doFilter 方法中的 try - finally 块来确保无论请求处理过程中是否发生异常,都能及时清理 SecurityContextHolder。示例代码如下:try {
// 执行请求处理逻辑
chain.doFilter(request, response);
} finally {
// 清理SecurityContextHolder
SecurityContextHolder.clearContext();
}
在项目中的过滤器链还用到了哪些过滤器?
UsernamePasswordAuthenticationFilter 通常在过滤器链的中间位置,用于处理表单登录请求。在配置文件中,你将 jwtAuthenticationTokenFilter 添加到 UsernamePasswordAuthenticationFilter 之前,这样可以确保在处理表单登录之前先对 JWT 进行验证。
FilterSecurityInterceptor 是过滤器链中的最后一个拦截器,负责进行最终的权限验证。在配置文件中,当 dynamicSecurityService 不为 null 时,将 dynamicSecurityFilter 添加到 FilterSecurityInterceptor 之前,这样可以在 FilterSecurityInterceptor 进行权限验证之前,先进行动态权限的校验。
过滤器和拦截器的区别?在实现原理方面介绍
1.过滤器:是 Servlet 规范中的一部分,是 JavaEE 的标准,所有的 Web 服务器都支持过滤器。它可以对进入 Servlet 容器的请求和响应进行过滤处理,例如对请求进行字符编码的设置、对响应进行压缩等。需要实现 javax.servlet.Filter 接口,并重写 init()、doFilter() 和 destroy() 方法。init()方法在过滤器初始化时调用,doFilter() 方法用于对请求和响应进行过滤处理,destroy() 方法在过滤器销毁时调用。
2.拦截器:是 Spring 框架提供的功能,是 Spring MVC 的一部分,依赖于 Spring 框架。它主要用于对 Spring MVC 控制器的请求进行拦截和处理,例如对请求进行权限验证、日志记录等。需要实现 org.springframework.web.servlet.HandlerInterceptor 接口,并重写 preHandle()、postHandle() 和 afterCompletion()方法。preHandle() 方法在控制器方法执行之前调用,postHandle() 方法在控制器方法执行之后、视图渲染之前调用,afterCompletion() 方法在整个请求处理完成后调用。
在一个 Web 应用中,过滤器和拦截器的执行顺序通常是:过滤器 -> 拦截器 -> 控制器方法。具体来说,当一个请求进入 Servlet 容器时,首先会经过过滤器的处理,过滤器可以对请求进行预处理,如设置字符编码、验证请求参数等。然后,请求会进入 Spring MVC 的拦截器链,拦截器会在控制器方法执行前后进行相应的处理,如权限验证、日志记录等。最后,请求会到达控制器方法,进行具体的业务逻辑处理。在响应返回时,执行顺序则相反,先经过拦截器的后处理,再经过过滤器的后处理,最后返回给客户端。
SpringSecurity 是基于过滤器还是拦截器的?
过滤器可以拦截所有进入 Servlet 容器的请求,包含静态资源请求;而拦截器只能拦截 Spring MVC 控制器的请求。Spring Security 需要对所有请求进行安全控制,所以过滤器更适合。
Spring Security 借助 Servlet 过滤器来构建一个安全过滤链,以此对进入应用程序的请求进行拦截和处理。这些过滤器会在请求到达目标 Servlet 或控制器之前对其进行安全检查,从而保障应用程序的安全性。
Spring Security 构建了一个过滤器链,链中的每个过滤器都有特定的安全职责,按顺序依次执行。以下是 Spring Security 过滤器链中一些常见的过滤器及其作用:
- ChannelProcessingFilter:负责根据安全规则来确定请求是需要通过 HTTP 还是 HTTPS 进行传输
- SecurityContextPersistenceFilter:负责在请求开始时从 HttpSession 中获取安全上下文(SecurityContext),并在请求结束时将更新后的安全上下文保存回 HttpSession
- UsernamePasswordAuthenticationFilter:处理基于表单的用户名和密码登录认证
- ExceptionTranslationFilter:捕获安全异常,例如认证异常和授权异常,并将其转换为合适的 HTTP 响应,比如重定向到登录页面或返回 403 状态码
- FilterSecurityInterceptor:对请求进行最终的授权检查,判断用户是否有权限访问请求的资源
消息队列
项目中是怎么使用消息队列的,说一说?
用户下单后生成订单,然后向消息队列中发送一个消息(包含订单 id 以及过期时间),消息会在消息队列中等待一段时间直到过期,然后会被发送到死信队列,有一个监听器 RabbitListener 会监听该队列,当有消息到达时,这个监听器会处理取消订单。
1.订单生成后,CancelOrderSender 向 orderTtlQueue 发送带有延迟时间的消息。
2.消息在 orderTtlQueue 中等待,直到超过延迟时间,消息过期。
3.由于 orderTtlQueue 配置了死信交换机和死信路由键,过期的消息会被当作死信,从 orderTtlQueue 转发到 orderQueue。
4.CancelOrderReceiver 监听 orderQueue,接收到消息后处理订单取消操作。
在订单超时取消系统中,若消息在从 orderTtlQueue 转发到 orderQueue 的过程中丢失,如何保证订单依然能被正确取消?
在项目中搭建消息补偿机制:定时扫描订单状态,对于超时未支付且未取消的订单,直接调用 OmsPortalOrderService 的 cancelOrder 方法进行取消操作。
这个方案在一定程度上保证订单最终都能被正确处理。
消息队列可能因网络波动、消费者重启等原因导致重复消费,是如何解决的?
在订单表中增加cancel_status字段(0 - 未取消,1 - 已取消),消费者处理时先通过SELECT FOR UPDATE检查状态,若已取消则直接返回。
为什么不选用定时任务处理订单取消,而选择消息队列?
定时任务的本质是轮询数据库,每次轮询需执行SELECT * FROM order WHERE status = 未支付 AND create_time < NOW() - 60分钟,索引若优化不足会导致全表扫描,CPU 和 IO 负载飙升。并且定时任务的执行间隔(如 1 分钟)是固定的,无法保证订单在刚好 60 分钟时被取消,可能存在最长 1 分钟的延迟(例如任务在 0:00 执行,某订单在 0:59 创建,需等到 1:00 才会被处理,实际延迟了 61 分钟)。
消息队列的核心优势:
- 消息队列作为中间层,将订单创建与订单取消解耦,后续新增通知、库存释放等功能时,只需新增消费者,不影响核心流程
- 消息队列自带持久化和重试机制(RabbitMQ 的持久化队列),若消费者处理失败,消息会重新入队,避免任务丢失
为什么不选用其他消息队列,反而选用 RabbitMQ 的 TTL + 死信队列?
给消息或队列设置过期时间(如 60 分钟),到期后消息变为死信(Dead Letter)。支持消息级 TTL(每条消息单独设置过期时间)和队列级 TTL(队列中所有消息统一过期时间),灵活性高。
订单创建时只需发送一条带 TTL 的普通消息到延迟交换机,无需关心后续取消逻辑;死信队列的消费者专注于处理过期订单(如调用订单服务 API 取消订单、释放库存),职责清晰,符合单一职责原则。并且 TTL 消息和死信队列均可开启持久化(durable = true),即使 RabbitMQ 节点宕机,重启后消息仍有效。
RocketMQ 的延迟级别固定(如 1s、5s、10s…1h),不支持任意时间延迟(如项目中需要精确 60 分钟。Kafka 本身不支持延迟队列,需结合 Redis/ZooKeeper 实现时间轮。
死信消息可以分发到多个死信队列吗?
1.通过 direct/topic 交换机按路由键分发。
2.通过 fanout 交换机广播到所有死信队列。
发送订单延迟消息到延迟队列失败的处理方式?
1.重试机制:当发送消息失败时,进行多次重试,避免因临时的网络问题或 RabbitMQ 服务短暂不可用导致消息发送失败。
2.记录日志:记录发送失败的消息信息,包括订单 ID、延迟时间等,方便后续排查问题。
3.消息补偿:如果多次重试仍然失败,可以将失败的消息信息存储到数据库中,通过定时任务或人工干预的方式进行消息补偿。
现在的项目还有哪些不足?
从死信队列取出消息进行处理时,会先判断是否支付过,只有未付款(status == 0)的订单才会进行取消操作,这在一定程度上能避免已支付订单被错误取消的问题,但仍存在一些潜在的不足:
- 即使订单已经支付,其对应的延迟消息依然会在延迟队列中等待到期,然后进入死信队列,最后触发 cancelOrder 方法。在这个过程中,消息的存储和处理会占用 RabbitMQ 的资源,包括内存、磁盘空间等。尤其是在高并发场景下,大量已支付订单的延迟消息会造成不必要的资源浪费
- 延迟消息到期后进入死信队列,再触发 cancelOrder 方法进行订单状态检查,这一系列操作会增加系统的处理负担。虽然最终不会对已支付订单进行取消操作,但中间的查询和判断过程会消耗一定的时间和性能,影响系统的整体响应速度
针对上述不足,有什么改进方向?
1.增加标识字段:在订单实体类(OmsOrder)中增加一个标识字段(比如isInDelayQueue),用于标记该订单是否已经被发送到延迟队列。当订单被发送到延迟队列时,将该字段设置为true;当订单支付成功时,检查该字段,如果为true,则需要从延迟队列中移除该订单对应的消息。
2.消息确认机制:利用 RabbitMQ 的消息确认机制,当订单支付成功后,向 RabbitMQ 发送一个确认消息,告知其该订单已支付,无需再处理延迟队列中的相关消息。
3.定时检查:通过定时任务,定期检查延迟队列中的消息对应的订单状态,如果发现订单已经支付,则从延迟队列中移除该消息。
fanout & direct & topic 交换机的区别
1.fanout 类型的 Exchange 路由规则非常简单,它会把所有发送到该 Exchange 的消息路由到所有与它绑定的 Queue 中,不需要做任何判断操作,所以 fanout 类型是所有的交换机类型里面速度最快的。fanout 类型常用来广播消息。
2.direct 类型的 Exchange 路由规则也很简单,它会把消息路由到那些 Bindingkey 与 RoutingKey 完全匹配的 Queue 中。
3.direct 类型的交换器路由规则是完全匹配 BindingKey 和 RoutingKey,但是这种严格的匹配方式在很多情况下不能满足实际业务的需求。topic 类型的交换器在匹配规则上进行了扩展,它与 direct 类型的交换器相似,也是将消息路由到 BindingKey 和 RoutingKey 相匹配的队列中,但这里的匹配规则有些不同,它约定:
- RoutingKey 为一个点号 ‘.’ 分隔的字符串(被点号分隔开的每一段独立的字符串称为一个单词),如 “com.rabbitmq.client”
- BindingKey 和 RoutingKey 一样也是点号 ‘.’ 分隔的字符串;BindingKey 中可以存在两种特殊字符串 * 和 #,用于做模糊匹配,其中 * 用于匹配一个单词,# 用于匹配多个单词(可以是零个)
RabbitMQ 如何保证消息的可靠性?
1.消息到 MQ 过程中
生产者确认机制(Publisher Confirm):生产者发送消息后,RabbitMQ 会将消息持久化到磁盘(如果开启了持久化),然后给生产者发送一个确认信号。生产者可以通过回调函数来确认消息是否成功发送到 MQ。如果未收到确认,生产者可以选择重发消息。
事务机制:生产者可以将发送消息的操作放在一个事务中,只有当事务中的所有消息都成功发送到 MQ 后,事务才会提交。如果有任何消息发送失败,事务将回滚,所有已发送的消息都会被撤销。不过,事务机制会影响性能,一般不建议在高并发场景下使用。
2.MQ 自身
- 消息持久化:通过将队列和消息都设置为持久化,RabbitMQ 在服务器重启或崩溃时可以恢复消息。将队列声明为持久化,使用QueueBuilder.durable()方法;将消息设置为持久化,在发送消息时设置MessageProperties.PERSISTENT_TEXT_PLAIN。
- 集群与镜像队列:部署 RabbitMQ 集群,并使用镜像队列将队列复制到多个节点上。这样即使某个节点出现故障,其他节点上的镜像队列可以继续提供服务,确保消息不会丢失。
3.MQ 到消费过程中 - 消费者确认机制(Consumer Acknowledgment):消费者从 MQ 接收消息后,需要向 MQ 发送确认信号。只有收到确认后,MQ 才会将消息从队列中删除。如果消费者在处理消息过程中出现异常或崩溃,没有发送确认,MQ 会认为消息未被成功消费,会将消息重新放入队列,再次分发给其他消费者或在一定时间后重新分发给同一个消费者。
- 设置 autoAck = false:在消费者端将自动确认模式设置为 false,这样消费者在处理完消息后手动发送确认。如果消费者在处理消息时崩溃,消息不会被自动确认,会重新回到队列中,避免消息丢失。
- 死信队列(Dead-Letter Queue):当消息在队列中出现异常情况,如超过最大重试次数、消息过期等,可以将其发送到死信队列。死信队列可以单独进行处理和分析,以便发现和解决消息处理过程中的问题,防止消息被丢弃。
RabbitMQ 的三种模式讲一讲?
1.单机模式
- 部署方式:仅在一台服务器上部署 RabbitMQ 服务,所有的消息队列、交换机等组件都运行在这一台机器上
- 优点:部署简单,易于安装和配置,适合开发、测试环境以及小型应用场景,能够快速搭建起消息队列服务,方便进行功能验证和代码调试
- 缺点:可靠性低,一旦服务器出现故障,整个消息队列服务将不可用,会导致消息丢失和业务中断;性能有限,单机的处理能力和资源有限,无法满足高并发、大规模消息处理的需求
2.普通集群模式 - 部署方式:将 RabbitMQ 节点分布在多个服务器上,形成一个集群。节点之间通过网络进行通信,共同组成一个逻辑上的整体。在普通集群模式下,队列的元数据会在所有节点上进行复制,但是队列中的消息只会存储在其中一个节点上
- 优点:提高了系统的可靠性和可用性,当部分节点出现故障时,其他节点可以继续提供服务,不会导致整个系统瘫痪;可以通过增加节点来扩展系统的性能,提高消息的处理能力和吞吐量
- 缺点:存在数据不一致的风险,由于消息只存储在一个节点上,如果该节点出现故障,可能会导致部分消息丢失;在跨节点访问消息时,可能会产生网络开销,影响性能
3.镜像集群模式 - 部署方式:在普通集群模式的基础上,将队列的消息在多个节点上进行镜像复制,每个队列的所有消息都会在多个节点上有完整的副本。这样,任何一个节点都可以处理对该队列的读写请求
- 优点:提供了高可靠性和数据一致性,即使某个节点出现故障,其他节点上的镜像副本可以立即接管服务,确保消息不会丢失;可以在多个节点上并行处理消息,提高系统的并发处理能力和整体性能
- 缺点:资源消耗大,由于消息需要在多个节点上进行复制,会占用大量的磁盘空间和内存资源;性能开销大,数据同步会带来一定的网络带宽占用和性能开销,特别是在大规模消息处理和高并发场景下,可能会对系统性能产生一定的影响
在高并发下单场景下,大量延迟消息同时过期并被转发到 orderQueue,如何确保 orderQueue 能够稳定处理这些消息,避免消息堆积和处理超时?
采用消息批量处理:在消费者端实现批量消费,减少消费次数。例如在 @RabbitListener 注解中配置 batchSize 属性。
|
当订单取消操作涉及多个数据库表的更新时,如何保证这些操作的原子性,避免数据不一致的情况?
1.使用事务管理:在 OmsPortalOrderService 的 cancelOrder 方法上添加 @Transactional 注解,确保多个数据库操作要么全部成功,要么全部失败。
2.使用分布式事务框架:如果订单取消操作涉及多个微服务的数据库更新,可以使用 Seata 等分布式事务框架,保证跨服务的事务一致性。以 Seata 的 AT 模式为例,通过注解 @GlobalTransactional 来开启全局事务。
在分布式部署环境下,多个服务实例都部署了 OrderTimeOutCancelTask 定时任务,可能会导致同一订单被多次取消,如何避免这种重复操作?
分布式锁机制:借助 Redis 实现分布式锁。在定时任务执行取消操作前,尝试获取锁。如果获取成功,才执行订单取消操作;如果获取失败,说明其他实例正在执行,直接返回。
import org.springframework.beans.factory.annotation.Autowired; |
项目中是如何解决超卖问题的?
用户下单时,在锁定库存前,先判断已锁定库存(已被别人抢先下单,但还未支付)与当前要锁定的数量之和是否小于等于真实库存,若不满足就无法下单。用户支付后,将会同时扣减锁定库存和真实库存。(真实库存只有在支付后才会扣减)
用户下单时,系统遍历购物车商品,使用 lockStock 方法锁定库存,lockStockBySkuId 会检查锁定库存加上本次要锁定的数量是否小于等于真实库存,若不满足则提示库存不足无法下单;订单支付时,reduceSkuStock 方法会同时扣减锁定库存和真实库存,且会检查扣减后库存是否大于等于 0,避免出现负库存;若用户下单后未按时支付,系统会通过 cancelOrder 方法取消订单,releaseStockBySkuId 释放锁定库存;同时,OrderTimeOutCancelTask 定时任务每 10 分钟扫描并取消超时未支付订单、释放库存,从多个方面保障库存数据准确,防止超卖。
假如项目采用了分布式架构,多个服务实例同时处理订单请求,如何避免分布式环境下的超卖问题?
在分布式环境下,可以采用以下策略避免超卖问题。一是引入分布式锁,例如基于 Redis 实现分布式锁,在每个服务实例处理订单前,先尝试获取锁,获取成功的实例才能进行库存锁定、订单处理等操作,处理完毕后释放锁,确保同一时间只有一个服务实例能操作库存。二是利用分布式事务框架,如 Seata。Seata 能够协调多个服务实例之间的事务,保证订单创建、库存锁定、扣减等操作在分布式环境下的原子性,要么全部成功,要么全部回滚,避免因部分操作成功导致的超卖问题。
Redis
生成订单编号在高并发的情况下是如何保证幂等性的?
首先根据当前日期、REDIS_DATABASE 和 REDIS_KEY_ORDER_ID 构建一个 Redis 的键 key。
然后调用 redisService.incr(key, 1) 方法,对 Redis 中指定键的值进行自增操作(自增步长为 1),返回自增后的数值 increment。这里 redisService.incr 方法是对 Redis 的 INCR 命令的封装,用于实现原子性的自增操作,保证生成的订单编号具有唯一性。
最后将日期、订单来源类型、支付方式以及自增后的数值按照一定格式拼接起来,生成最终的 18 位订单编号。
项目中是如何使用 Redis 的?存储的是什么信息?
- 会员信息:以REDIS_DATABASE + “:” + REDIS_KEY_MEMBER + “:” + 用户名作为键,将 UmsMember 对象作为值存储。UmsMember 对象包含会员的用户名、密码、手机号、创建时间、会员等级 ID 等信息。
- 验证码:以REDIS_DATABASE + “:” + REDIS_KEY_AUTH_CODE + “:” + 手机号作为键,将生成的 6 位验证码字符串作为值存储。
项目中具体是怎么使用 Redis 的?
- 存储会员信息:在 UmsMemberServiceImpl 的 getByUsername 方法中,先尝试从 Redis 中获取会员信息,如果不存在,则从数据库中查询,查询到后将其存储到 Redis 中,并设置过期时间(由REDIS_EXPIRE配置)。
- 删除会员信息:在 UmsMemberServiceImpl 的updatePassword和 updateIntegration 方法中,当会员信息发生变更(如修改密码、更新积分)时,会调用UmsMemberCacheService 的 delMember 方法删除 Redis 中对应的会员信息缓存,以保证数据的一致性。
- 存储验证码:在 UmsMemberServiceImpl 的 generateAuthCode 方法中,生成 6 位验证码后,调用 UmsMemberCacheService 的 setAuthCode 方法将验证码存储到 Redis 中,并设置过期时间(由REDIS_EXPIRE_AUTH_CODE配置)。
- 获取验证码:在 UmsMemberServiceImpl 的 verifyAuthCode 方法中,需要验证验证码时,调用 UmsMemberCacheService 的 getAuthCode 方法从 Redis 中获取存储的验证码,与用户输入的验证码进行比较。
如何解决缓存击穿问题?
1.使用互斥锁:在缓存失效的瞬间,让一个线程去获取锁,只有获取到锁的线程才能去查询数据库并更新缓存,其他线程则阻塞等待。当获取锁的线程更新完缓存后,释放锁,其他线程再去缓存中获取数据。
2.热点数据永不过期:对于一些热点数据,不设置过期时间,这样就不会出现缓存击穿的问题。不过,需要在数据发生变化时及时更新缓存。
3.使用分布式锁:与互斥锁类似,但适用于分布式系统环境。通过在分布式锁的控制下,保证只有一个节点的线程能在缓存失效时去查询数据库并更新缓存。
4.缓存预热:在系统启动时或者在缓存数据过期前,提前将热点数据加载到缓存中,这样可以避免在缓存过期时大量请求同时穿透到数据库。
5.多级缓存:采用多级缓存架构,如内存缓存(如 Redis)和本地缓存(如 Guava Cache)结合。当内存缓存中的数据过期时,先从本地缓存中获取数据,如果本地缓存中也没有,则再去查询数据库,并将数据同时更新到内存缓存和本地缓存中。
在高并发场景下,会员信息缓存可能出现缓存击穿问题,即大量请求同时访问一个过期的缓存键,瞬间压垮数据库。你将如何解决这一问题?
可以采用互斥锁(Mutex)机制来解决缓存击穿问题。在查询缓存未命中时,先尝试获取一个分布式锁(如 Redis 的 SETNX 命令实现)。只有获取到锁的请求,才去数据库查询数据,并将结果写入缓存;其他未获取到锁的请求则等待一段时间后重新尝试从缓存读取数据。
在分布式系统中,如何保证不同服务实例之间会员缓存数据的一致性?例如,一个服务实例更新了会员信息,如何确保其他实例能及时获取最新数据?
当会员信息更新时,发送一条消息到消息队列(如 Kafka、RabbitMQ)。所有服务实例监听该消息队列,接收到消息后删除本地缓存中的会员信息,从而保证缓存一致性。
项目中先更新数据库,后删除缓存,可能会出现哪些问题?
1.用户权限变更后,其他服务可能在短时间内读取到旧的缓存。
2.若数据库更新成功,而缓存删除失败,那么除非缓存过期,否则后续请求会一直获取旧的信息。
如果查询的数据在缓存中不存在,在数据库中也不存在,导致请求直接穿透缓存到达数据库,如何解决?
1.缓存空值:当查询数据库发现数据不存在时,仍然将一个空值或者特殊标记存入缓存,并设置一个较短的过期时间。这样,下次有相同的查询请求时,缓存就可以直接响应,避免再次查询数据库。
2.布隆过滤器:布隆过滤器是一种概率型数据结构,它可以快速判断一个元素是否存在于一个集合中。在缓存穿透场景中,使用布隆过滤器存储数据库中已存在的键。当有查询请求时,先通过布隆过滤器判断该键是否存在,如果不存在,则直接返回,无需查询数据库和缓存;如果存在,再去缓存和数据库中查询。
如果解决缓存穿透问题,使用缓存空值策略,如何避免大面积内存被空值白白占用?
对于缓存的空值,设置较短且合理的过期时间非常关键。如果过期时间过长,空值会在缓存中长时间占用空间;而过短则可能导致缓存穿透问题再次出现。需要根据业务场景和数据特点来确定合适的过期时间。例如对于一些很少变化且偶尔查询的冷数据,空值缓存的过期时间可以设置为几分钟;对于可能频繁查询的热点数据,空值缓存的过期时间可以更短,如几十秒。
也可以定期在缓存中扫描空值,并删除那些过期或者长时间未被访问的空值。通过定时任务或者在缓存访问量较低的时间段进行清理操作,以释放内存空间。也可以根据缓存的内存使用情况,当内存占用达到一定阈值时触发清理操作。
ES 搜索引擎
正向索引和倒排索引
正向索引是最传统的,根据 id 索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程。
而倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的 id,然后根据 id 获取文档。是根据词条找文档的过程。
项目中是如何使用 ES 搜索引擎的?
1.数据准备和导入
- 数据获取:在 EsProductServiceImpl 的 importAll 方法中,通过调用 productDao.getAllEsProductList(null) 方法从数据库中获取所有商品数据。productDao 是基于 MyBatis 的 DAO 接口,其对应的 XML 映射文件(EsProductDao.xml)中的 getAllEsProductList 查询从数据库表 pms_product、pms_product_attribute_value 和 pms_product_attribute 中关联查询商品及其属性信息
- 数据导入 ES:获取到商品数据列表 esProductList 后,使用 productRepository.saveAll(esProductList) 将这些商品数据保存到 Elasticsearch 中。productRepository 是 Spring Data Elasticsearch 提供的 ElasticsearchRepository 接口的实现类,负责与 ES 进行交互
2. 商品数据操作
- 创建商品:create 方法根据传入的商品 ID,先通过 productDao.getAllEsProductList(id) 从数据库中查询该商品的数据,若查询到数据,则将其通过 productRepository.save(esProduct) 保存到 ES 中,实现将数据库中的商品数据同步到 ES
- 删除商品:delete 方法有两个重载,一个根据单个商品 ID 调用 productRepository.deleteById(id) 删除 ES 中对应的商品文档;另一个根据商品 ID 列表,先构建包含这些 ID 的商品对象列表,然后调用 productRepository.deleteAll(esProductList) 批量删除 ES 中的商品文档
3. 商品搜索功能
- 简单搜索:search(String keyword, Integer pageNum, Integer pageSize) 方法根据传入的关键词,调用 productRepository.findByNameOrSubTitleOrKeywords(keyword, keyword, keyword, pageable) 进行搜索。productRepository 会根据定义的方法规则,在 ES 中搜索商品名称、副标题或关键词匹配的商品,并进行分页处理
- 综合搜索、筛选和排序:search(String keyword, Long brandId, Long productCategoryId, Integer pageNum, Integer pageSize, Integer sort) 方法构建更复杂的查询逻辑。首先根据品牌 ID 和商品分类 ID 构建布尔查询进行过滤;然后根据关键词构建 FunctionScoreQuery,对不同字段(如名称、副标题、关键词)设置不同权重进行搜索;最后根据传入的排序参数(sort)设置不同的排序方式(按新品、销量、价格、相关度等)。构建好查询后,使用 elasticsearchRestTemplate.search(searchQuery, EsProduct.class) 执行查询,并处理查询结果
4. 商品推荐功能
recommend(Long id, Integer pageNum, Integer pageSize) 方法根据传入的商品 ID,先从数据库中查询该商品的数据,提取商品名称、品牌 ID 和商品分类 ID 等信息。然后构建 FunctionScoreQuery,结合这些信息对相关字段设置权重进行搜索,并使用布尔查询过滤掉与传入 ID 相同的商品。最后构建查询并通过 elasticsearchRestTemplate 执行,处理查询结果并返回相关商品列表。
5. 获取搜索相关信息功能
searchRelatedInfo(String keyword) 方法构建NativeSearchQuery,根据关键词构建搜索条件(若关键词为空则查询所有)。同时添加聚合查询,包括聚合搜索品牌名称、分类名称以及商品属性(通过嵌套和过滤操作,去除属性类型为 0 的属性)。构建好查询后,使用 elasticsearchRestTemplate 执行查询,并将查询结果转换为 EsProductRelatedInfo 对象,提取出品牌名称列表、分类名称列表和商品属性列表等信息返回。
项目中有几种查询方式?
1.使用 ElasticsearchRestTemplate 查询:ElasticsearchRestTemplate 提供了更灵活、更底层的方式来构建和执行 Elasticsearch 查询。当需要构建复杂的查询逻辑,如涉及到多种查询条件的组合(布尔查询、函数评分查询等)、聚合查询等情况时,使用 ElasticsearchRestTemplate 配合 NativeSearchQueryBuilder 等工具可以方便地构建出符合需求的查询语句。例如在 search(String keyword, Long brandId, Long productCategoryId, Integer pageNum, Integer pageSize,Integer sort) 方法中,需要根据品牌 ID、商品分类 ID 进行过滤,根据关键词进行函数评分查询,并且还涉及到不同的排序方式,这种复杂的查询逻辑使用 ElasticsearchRestTemplate 来实现更为合适。
2.使用 productRepository.findByNameOrSubTitleOrKeywords(keyword, keyword, keyword, pageable) 查询:productRepository 是继承自 ElasticsearchRepository 的接口实现类,findByNameOrSubTitleOrKeywords 这种方法是 Spring Data Elasticsearch 根据方法命名规则自动生成的查询方法。当查询逻辑相对简单,例如仅根据几个字段进行简单的匹配查询,并且不需要进行复杂的条件组合和排序等操作时,使用这种基于接口方法的查询方式更为简洁和方便。如在 search(String keyword, Integer pageNum, Integer pageSize) 方法中,只是根据关键词在商品名称、副标题和关键词字段进行简单的匹配查询并分页,使用这种方式就足够满足需求。
在高并发场景下,大量的搜索请求涌入,你如何确保 Elasticsearch 服务的性能和响应时间?
- 集群配置:使用 Elasticsearch 集群,通过增加节点数量来提高整体的处理能力和吞吐量。合理设置分片和副本数量,例如将数据均匀分布在多个分片上,每个分片设置适当的副本,以提高数据的可用性和读写性能
- 限流和熔断:实现限流机制,例如使用令牌桶算法或漏桶算法,限制单位时间内的请求数量,防止过多的请求压垮 Elasticsearch 服务。同时,引入熔断机制,当 Elasticsearch 服务出现异常或响应时间过长时,暂时切断请求,保护服务的稳定性
- 异步处理:对于一些非实时性要求较高的搜索请求,可以采用异步处理的方式,将请求放入消息队列中,由后台线程进行处理,避免阻塞主线程
- 负载均衡:使用负载均衡器,如 Nginx 或 HAProxy,将客户端的请求均匀地分发到 Elasticsearch 集群的各个节点上,避免某个节点负载过高。
SpringBoot & Spring & 数据库
SpringBoot
为什么开发者往往选择自创线程池,而不是使用 SpringBoot 线程池?
Spring Boot 虽然提供了默认的线程池配置,但在某些特定场景下,开发者可能需要更精细、更个性化的配置。自创线程池可以让开发者根据具体业务需求,自由地设置线程池的核心线程数、最大线程数、阻塞队列类型及容量、线程存活时间等参数,以达到最佳的性能和资源利用。
比如,某些业务可能具有高并发、短任务的特点,适合使用无界队列的线程池来避免任务拒绝;而另一些业务可能是低并发、长任务,需要限制线程数量以防止资源过度消耗。开发者可以根据这些特点创建最适合的线程池,而 SpringBoot 的通用线程池可能无法做到如此精准的优化。
Spring
数据库
update 语句会加锁吗?
- 读未提交(READ UNCOMMITTED):在这个隔离级别下,UPDATE 语句加锁的时间最短,可能会出现脏读、不可重复读和幻读等问题。
- 读已提交(READ COMMITTED):UPDATE 语句在执行时会对操作的行加锁,操作完成后立即释放锁,避免了脏读问题,但可能会出现不可重复读和幻读。
- 可重复读(REPEATABLE READ):这是 MySQL InnoDB 引擎的默认隔离级别,UPDATE 语句会使用行级锁、间隙锁或临键锁,避免了脏读和不可重复读问题,但可能会出现幻读。
- 串行化(SERIALIZABLE):在这个隔离级别下,UPDATE 语句会对整个表加锁,事务会串行执行,避免了所有的并发问题,但并发性能最差。