多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Security与消息队列场景题:状态管理与幂等设计实战

Spring Security与消息队列场景题:状态管理与幂等设计实战 周末帮一个学弟做模拟面试前20分钟还在聊Spring Security的过滤器链下一个问题突然跳到“消息队列重复消费了怎么办”。很多准备Java开发岗的人会把这两个专题分开复习但互联网大厂Java面试里的场景题恰恰喜欢把安全框架和消息队列串在一起。我去年集中刷题时也踩过这个坑Spring Security的认证流程背得很熟消息队列的幂等方案也整理过可一被追问“用户状态变了token怎么同步失效”“消息重复投递订单状态怎么保证只更新一次”还是会卡壳。后来复盘才明白大厂要的不是“会背框架API”而是把你放到真实的请求链路上看你能不能同时处理好状态管理、失败恢复和数据一致性。这篇文章我想从Spring Security和消息队列这两条线的交叉点出发拆几个高频场景题聊聊我从八股文转向场景设计的经验。适合正在准备大厂Java面试、被这两块内容反复拷问的同学。1. 先拆题为什么Spring Security和消息队列总被放在同一轮我在整理面试真题时发现一个规律Spring Security不管怎么变化核心就三件事——你是谁、你能干什么、你的状态存在哪。消息队列看似不一样但消费者手里的offset、幂等表、本地消息表本质上也在回答三个问题消息到哪了、怎么保证不丢、重复了我认不认。这两个主题凑在一起真不是面试官随机抽题。大型互联网系统的请求链路是典型的认证、网关、业务服务、MQ、下游。只要链路里有安全控制和异步投递就一定会遇到状态同步、数据一致性这些共同话题。比如用户下单登录态从网关传到订单服务订单服务又把支付结果投递到MQ下游消费者更新订单状态。这条链上JWT需要管理过期和吊销MQ需要管理投递和确认两者都是状态机。面试官把这两个专题放在同一轮想看的其实就是你懂不懂状态管理。1.1 两条技术线的共同底层状态管理与失败恢复只把Spring Security当成“登录时候的一堆过滤器”把MQ当成“发消息的工具”遇到大厂场景题会很吃亏。安全框架里的Session、Token、SecurityContext本质都是“你是谁”的状态消息队列里的位点、消费组、死信队列本质都是“一条消息走到哪了”的状态。状态管理好了失败恢复才有基础。最常见的失败恢复场景token过期了客户端拿着refresh_token去换新的消费者处理到一半服务重启了MQ重新投递消息。这类问题在两边都叫“失败恢复”但很多候选人答完认证那边再答消息那边却完全想不到它们共享同一套思路先写日志再执行核心操作最后确认/提交失败就重试重试还不行就进死信或者告警。举个具体的例子。登录成功时系统给用户发一份短期access_token和一份长期refresh_token支付回调消息来的时候消费者先查本地消费记录再更新订单状态最后提交offset。这两条流程里都藏着“扣库存/发token”和“提交offset/写session”之间的非原子性。能主动说出这一步的人面试官基本不会再拿概念题压你了。1.2 场景题答题框架需求、边界、方案、兜底很多候选人容易犯的错是直接给方案。比如问“消息重复消费怎么办”上来就说“用Redis的setnx做幂等”。面试官追问“setnx的key怎么设计过期时间多长如果服务宕机key没来得及删呢”就会卡住。我后来总结了一个四段式结构基本能应付这一类问题确认需求、列边界、给方案、讲兜底。以登录态设计为例。需求是用户登录后能访问受保护接口边界条件是token过期、用户被禁用、多端登录、token泄露方案是短期access_token加长期refresh_token客户端的access_token不带敏感信息兜底是刷新令牌轮换、设备指纹异常告警。如果面试官再问性能就在网关层加缓存。这套结构用到消息队列上也一样需求是异步通知下游边界是重复、丢失、乱序、积压方案是生产重试、消费幂等、分区有序兜底是对账任务和死信队列。四段式Spring Security场景消息队列场景确认需求保护资源、完成认证授权异步解耦、削峰填谷边界条件令牌过期、权限变化、并发刷新重复消费、消息丢失、乱序、积压方案选型JWT、Session、OAuth2Kafka、RabbitMQ、RocketMQ兜底设计黑名单、版本号、重新登录重试、死信、幂等、对账这套框架不是万能的但至少能让你的回答有层次。面试官追问时你心里也知道自己正在回答哪一层。2. Spring Security场景题不能只会配置过滤器链Spring Security的面试题这几年变化很大。早几年问“说出过滤器链顺序”就能过现在问的是“用户被禁用了已发出的token怎么立即失效”“refresh_token并发刷新怎么办”。真把项目跑过一遍的人才有答案。2.1 登录态设计Session、JWT和Redis的混战面试官问“你们项目登录用什么”如果只回答“用JWT”基本拿不到加分。因为“用什么”不是重点“状态怎么管理”才是。Session是服务端存储浏览器只保存session id单体应用没问题分布式环境要么共享Session要么做粘滞服务端一重启用户就掉线。JWT把用户信息、过期时间签名后发给客户端服务端无状态可token一旦签发就很难撤销除非加黑名单。大厂常见的组合是短期access_token加长期refresh_token。access_token有效期短比如15分钟放在客户端refresh_token有效期长比如7天存在Redis里并且绑定设备指纹。每次刷新时签发新的access_token同时轮换refresh_token。用户被禁用时把该用户的版本号加一Redis里的版本号一变旧token全部失效。这个方案能同时解决“无状态鉴权”和“及时吊销”两个问题面试里拿出来讲比单纯背JWT原理好得多。这里有一个我实际踩过的小坑并发刷新时两个请求同时拿着旧refresh_token去刷新接口如果只做“先删后发”第二个请求会很尴尬。正确做法是使用分布式锁或者Redis的原子操作保证刷新串行至少也要在刷新逻辑里比对当前refresh_token是否还是最新版本避免一个设备把另一个设备顶下线。在Spring Security里JWT验证逻辑通常放在OncePerRequestFilter中。注意要在finally里面调用SecurityContextHolder.clearContext()防止线程池复用时把上一个请求的用户信息带到下一个请求。代码结构大致是这样Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String token resolveToken(request); if (token ! null tokenValidator.validate(token)) { UserDetails userDetails loadUserByToken(token); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } finally { SecurityContextHolder.clearContext(); } } }这段代码本身不难但很多出问题的地方在细节比如解析token失败时是直接抛异常还是放行比如SecurityContextHolder是ThreadLocal异步线程里不会自动传递。面试聊到这些远比报一遍类名强。2.2 URL级授权和Controller层防爬虫方法级注解PreAuthorize很方便但它通常加在Service或Controller方法上。如果接口路径本身需要黑白名单、限流、爬虫识别更适合在HTTP过滤器层统一处理。最近看到很多人在搜“controller层如何防护防止爬虫”这在大厂安全面试里属于基础题接口被脚本刷你不能只靠Nginx限流还得有验证码、用户行为画像、敏感接口二次校验之类的手段。在Spring Security里做这件事思路很简单自定义一个OncePerRequestFilter检查请求URI和当前登录用户角色不满足就直接返回403满足就继续走过滤器链。限流时要注意本地内存计数器在单机上能用多实例部署必须用Redis否则每个节点各计各的限了个寂寞。防爬虫和鉴权不是一回事爬虫可能带合法token但访问频率异常所以还要结合IP维度、设备指纹、User-Agent去识别。还有一个容易被忽略的坑PreAuthorize放在Controller方法上如果方法是从同类内部调用的Spring AOP代理不会生效。把权限校验放到Service层或者用外部代理调用不然你会看到权限注解跟没写一样。2.3 自定义过滤器失效的排查链路我曾经接手一个项目加了一个登录频率限制Filter也配进了Spring Security结果压测时接口照样被刷。排查到最后发现过滤器被写成了WebFilter交给了Servlet容器管理而Spring Security走的是FilterChainProxy代理两条过滤器链的优先级完全不一样我的过滤器根本没进代理链。排查这种问题我的经验是三步走。第一步在doFilter开头打日志看过滤器到底有没有被调用日志都没打印说明问题在注册方式。第二步看应用启动日志里FilterChainProxy的过滤器列表确认自定义过滤器是否真的被加入如果列表里根本没有就要检查HttpSecurity配置是否生效。第三步检查用的是addFilterBefore还是addFilterAtaddFilterAt看起来指定了位置但同名过滤器可能被覆盖坑很多。我的建议是优先用addFilterBefore(usernamePasswordAuthenticationFilter, MyFilter.class)这种方式指定明确位置不要用WebFilter加ServletComponentScan去管理Spring Security上下文里的过滤器。这个问题在真实项目里非常常见面试时把排查链讲清楚比单纯回答“我加了个过滤器”打动人大得多。3. 消息队列场景题把“至少一次”讲出架构感消息队列最常见的三个高频考点是重复消费、顺序性和最终一致性。其中“消息队列重复消费问题”几乎是搜索热度最高的词因为它不仅是面试题线上也天天遇到。3.1 重复消费根因和幂等方案为什么重复消费没法从源头根治因为绝大多数MQ实现的是at-least-once语义。生产端重试可能把同一条消息发两次消费端处理完业务但还没提交offset时进程挂了MQ会重新投递消费者重启也会导致同样的问题。所以“不重复”不能靠MQ配置消除只能靠消费端幂等。幂等设计我一般分三档来讲。第一档是业务表状态机支付回调消息里带订单号消费者执行一条带条件的更新SQLUPDATE order_info SET status PAID, version version 1 WHERE order_no #{orderNo} AND status PAYING AND version #{version};如果影响行数为0说明这条消息要么重复要么订单状态已经不是待支付这时候直接返回成功不需要再走一遍业务。注意这里不能抛异常否则会触发无限重试把消息搞成死信。第二档是唯一消费记录表建一张mq_consume_record把biz_id和event_type做成唯一索引消费前先insertinsert重复就catchDuplicateKeyException说明之前已经处理过。第三档是Redis的SETNX适合短时间去重key的过期时间要覆盖重试窗口比如24小时。不管哪一档都必须考虑并发两个重复消息同时到达时数据库唯一索引能挡住一个Redis也最好用原子指令不要先get再set。面试加分点是答出重复消息的“处理语义”如果业务已经完成重复消息应当按成功处理而不是反复重试只有真正处理失败的消息才应该回到重试队列。3.2 顺序消息从全局有序到分区有序面试官问“怎么保证消息严格有序”如果你直接答“用单个队列消费”下一步就是“那怎么支撑高吞吐”。正确思路是大部分场景只需要局部顺序。同一个订单的状态变更需要按顺序创建、支付、发货。对订单号取哈希分到同一个分区这个分区被同一个消费者单线程消费顺序就保住了。RocketMQ里用MessageQueueSelectorKafka里用partition key道理一样。真正的难点在后面消费失败时怎么保持顺序。如果某一条消息处理失败进入重试后面的消息要不要继续继续处理就会乱序不继续处理吞吐又受影响。行业里常见做法是失败消息先阻塞该分区或者投递到专门的重试队列保证同一业务key的消费严格串行同时监控重试队列避免消息堆积。这部分如果能把“局部有序”和“重试可能导致乱序”讲清楚已经超过很多候选人。3.3 可靠投递与最终一致性本地消息表还是事务消息经典场景是下单后给用户加积分。下单SQL和发MQ不是同一个原子操作。先下单再发消息消息发送失败积分就丢了先发消息再下单消费者可能读到还没提交的订单数据。现实项目里的解法主要有两种。第一种是本地消息表。业务库里建一张message_outbox表下单事务里同时插入业务数据和大口消息记录状态是pending后台定时任务扫到pending消息就发给MQ收到发送成功回执后把状态改成sent。注意这个job本身可能重复发送所以消费者还是要幂等。第二种是RocketMQ的事务消息。先发送half消息Broke确认收到后应用执行本地事务事务成功就commit失败就rollback。Kafka原生没有这套机制很多团队会退回到本地消息表方案。面试时不要只抛名词要能对比两者代价。维度本地消息表事务消息实现复杂度低但要建表、写定时任务中依赖特定MQ能力对业务库的影响多一张表业务事务里多一次插入不大commit/rollback由客户端控制失败恢复定时扫描能补发本地事务失败可回滚应用范围多语言、多种MQ都通用通常绑定RocketMQ讲到这里还要补一句最终一致性解决的是“弱一致”的需求如果业务要求强一致比如转账扣款不能只靠MQ异步要用分布式事务或事务性消息之外的手段。能说出这个边界说明你不是只会套模板。3.4 消息积压和消费慢的排查链路线上消息积压我通常按四条线排查。第一看消费者实例是否在线有没有线程卡死第二看MQ的消费速率和积压量曲线确定是从哪个时间点开始涨的第三看下游数据库慢SQL、行锁等待、连接池打满都会拖垮消费第四看生产端是不是突然有大流量入口比如活动秒杀。临时扩容也有讲究。如果topic的分区数固定不是简单加几台机器就能线性提升单分区消费速度的因为同一个分区同一时刻只能被一个消费者实例消费。真遇到积压一种做法是临时新建一个topic把旧topic的消息重新投递过去新topic开更多分区消费者开更多线程并行处理。处理过程中先保证消息不丢业务允许的话先落本地表再慢慢补。面试官问“消息积压怎么办”最忌讳只答“加机器”。你应该先讲排查链路再讲为什么加机器不一定有用最后讲临时拓容和后续治理。这就是场景题和八股文的区别。4. 场景题背后的Java基本功面试官不会明说但一定会考你可能会奇怪Spring Security和消息队列的场景题跟Java基础有什么关系关系很大。SecurityContextHolder本质是一个ThreadLocal你如果不知道ThreadLocal的传递和泄漏规则说不清楚请求间隔离MQ的消费逻辑常常跑在线程池里你如果不知道线程池任务调度规则也说不清楚并发消费时为什么会重复处理。4.1 数据一致性从并发编程到分布式状态“数据一致性”是一个热词但它不只在分布式系统里出现。JWT解析出来的用户信息应该是不可变对象这样放到SecurityContext里才是线程安全的如果有人把可变对象存进去请求线程结束前字段被改掉就可能越权。消息消费时同一订单的多条消息被多个线程并发处理数据库里的状态更新如果没加保护就可能把已支付状态覆盖回待支付。这种情况下数据库乐观锁是常见解法。比如订单表加version字段更新时set versionversion1 where version旧值更新行数为0说明版本冲突要么重试要么放弃。这套思路和Java并发编程里的CAS、synchronized其实是一回事只是作用域从JVM内部扩大到了多个服务之间。面试官问“你怎么保证数据一致性”你如果能从ConcurrentHashMap的弱一致性聊到数据库乐观锁再聊到MQ幂等他会很满意。ThreadLocal也是容易踩坑的点。Spring Security默认把SecurityContext存放在ThreadLocal里每个请求一个线程请求结束要清空否则Tomcat线程池复用时下一个请求可能会看到上一个登录用户的信息。如果你想在异步任务里继续使用登录用户直接复制ThreadLocal是不行的要显式传递或者使用装饰器把请求上下文传过去。这些细节在场景题里很加分。4.2 MyBatis-Plus生成建表SQLORM开发里的面试考点最近搜索热度里有“mybatisplus根据java实体类生成创建表的sql语句”很多快速迭代的项目确实会用实体类自动建表。但大厂生产环境一般不建议这么做。我在面试里会主动讲开发环境可以自动建表生产表结构必须走DBA审批和版本化迁移工具比如Flyway或Liquibase。这样一说面试官就知道你不只是会用ORM。继续引申TableName、TableId、Version、TableLogic这些注解都是考点。Version是乐观锁版本号在MQ消费更新订单状态时非常有用TableLogic是逻辑删除但要注意逻辑删除字段和唯一索引一起用的时候可能出现重复数据。这些细节不深但能体现你到底有没有在真实项目里被坑过。4.3 手写排序和容器基础题别翻车场景题聊得再好手写代码挂了也进不了下一轮。大厂可能会突然来一道“用Java写冒泡排序”。很多人平时写代码有IDE提示手写反而容易漏掉边界。外层循环n-1趟内层循环n-1-i一趟没发生交换就提前结束这些都要写对。Arrays.sort底层用的是DualPivotQuicksortCollections.sort底层是归并排序能解释排序稳定性的人不多但解释出来就是加分项。HashMap的扩容、红黑树退化、为什么String适合当key这些和JWT的不可变对象、签名散列其实还能串联起来。如果你能讲清楚hashCode和equals的约定再聊到JWT签名和hash散列的区别面试官会觉得你的基本功是成体系的而不是背题背出来的。5. 模拟一次从Spring Security到消息队列的连环追问把知识变成面试答案最有效的方法是模拟追问。我每次准备面试都会把一道题的所有分支画出来然后练到能不假思索地答出主线。5.1 一段典型的面试对话面试官“你项目的登录态怎么做的”候选人“JWTRedis存refresh_token。”面试官“用户被封禁正在使用的token为什么还能用”候选人“token里带了版本号用户被封禁时Redis里的版本号自增过滤器每次做敏感操作时会比对版本号不一致直接拒绝。”面试官“每次请求都查Redis性能能接受吗”候选人“access_token有效期短普通请求不查只有敏感接口或权限变更才二次校验。网关可以缓存版本号用户状态变更时再用PubSub推缓存失效。”面试官“用户支付成功后支付结果通过MQ通知订单服务由于网络重试同一消息被消费两次怎么保证订单只更新一次”候选人“消费者幂等。用支付流水号作为幂等键订单状态更新加条件只允许从待支付改成已支付同时消费记录表建唯一索引捕获重复消息后直接返回成功。”面试官“如果订单状态更新成功但发消息给积分服务失败了呢”候选人“不能在一个普通事务里先更新业务表再发MQ。用本地消息表下单事务里写一条待发送记录定时任务扫到再发积分服务消费时用幂等兜底。”这套回答的主线是先方案后细节先主链路后异常。面试官的每一次追问其实都在检查你有没有想到兜底。5.2 常见的几个回答雷区第一个雷区是把“消息不丢失”和“消息不重复”混为一谈。第二个是提到幂等就只会说Redis的setnx但key怎么设计、过期时间多久完全没想过。第三个是回答Spring Security时全程报过滤器类名却没有解释每一个过滤器的状态语义。第四个是方案只讲优点不讲代价比如“用事务消息解决一切”但不提它要求特定的MQ实现。第五个是面试官开始深挖边界就慌了答非所问。我建议准备面试时给每个高频题写一个“业务场景、边界条件、候选方案、失败兜底”的小卡片。写的时候你会发现自己很多地方其实并不理解正好补漏。5.3 我整理场景笔记的三个习惯第一个习惯是按业务场景组织笔记不按知识点组织。比如“用户修改密码后token怎么办”“订单支付消息重复消费怎么办”而不是“Spring Security的过滤器链”“MQ的消费端配置”。第二个习惯是每个方案后面都追问一句“如果这样会怎样”比如“如果Redis挂了幂等怎么兜底”“如果refresh_token被并发刷新了会怎样”。第三个习惯是录音复述。对着题目用自己的话讲一遍你才知道哪些地方是背的哪些地方是真懂的。最后分享一个我自己的教训。有一年准备面试把Spring Security过滤器链和Kafka的ack机制背得很熟觉得自己稳了。结果面试官问的是“用户改了密码已登录的token怎么办”和“同一个订单的支付消息重复消费了八次你的幂等表会不会炸”。这两道题都不难但我因为习惯按知识点背没能第一时间把它们归到“状态变更与兜底”这一类里。后来我花了一个月把遇到的真题全部改写成“业务场景、边界条件、兜底方案”三段式再进面试明显从容很多。如果你也正在准备Java开发岗别急着背更多八股先把Spring Security和消息队列这两条线的共同点打通。真正值钱的不是你知道多少框架API而是你面对一个没见过的场景能不能一步步拆出关键点。
返回列表