1. 面试官真正在考察什么:从一道Spring Security题说起
这几年我在大厂做后端技术面试官,面过几百个Java候选人,发现一个很有意思的现象:很多人背了一堆八股文,但一碰到Spring Security和分布式缓存这类“实战型考点”,就露馅了。
原因很简单,这两块内容不属于“背了就能答”的范畴。Spring Security的过滤器链机制、认证授权流程,牵扯到Servlet容器、Spring容器、AOP代理这些底层知识;分布式缓存则涉及到数据一致性、高并发场景下的架构取舍。面试官问这两个方向,本质上不是在考你记没记住某个API,而是在考察你有没有真正用这些技术解决过线上问题。
这篇文章我就围绕“Spring Security + 分布式缓存”这条大厂Java面试主线,把核心考点、底层原理、答题思路和实操经验一次讲透。适合准备大厂Java面试的同学,也适合那些已经把SSM、SpringBoot玩得差不多、想往架构方向进阶的后端开发。
先说个我面试时经常用来开场的问题:“Spring Security的Filter到底是怎么完成注册的?”
很多候选人听到这个问题,第一反应是背源码:DelegatingFilterProxy、FilterChainProxy、SecurityFilterChain……但当我追问一句“Servlet容器和Spring容器是怎么通过这个机制接上的”时,十个人里有七八个会卡壳。
这个问题之所以经典,是因为它一个点串起了三条技术线:Servlet规范、Spring IOC容器、Spring Security的过滤器链模型。你只有把这三条线在脑子里串成一张图,才算真正理解了Spring Security的运作机制,而不是停留在“会用@EnableWebSecurity”的层面。
再说分布式缓存。面试官问缓存,几乎不会只问“Redis有哪些数据结构”这种入门题。真正高频的是这么几个:缓存穿透、缓存击穿、缓存雪崩的应对方案,缓存与数据库的一致性怎么保证,缓存Key怎么设计。
这些问题表面上是考Redis,实际上考的是你对“高并发场景下的一致性取舍”有没有全局认知。我见过不少候选人,能把布隆过滤器、互斥锁这些名词说得头头是道,但一问他“为什么你的方案能解决这个问题”“有没有考虑过并发窗口期”,就答不上来了。
所以这篇博文我不打算按“列出考点-给出答案”的八股形式写,而是换个思路:从面试官视角出发,拆解每个考点背后的考察逻辑,再给出一套可复用的答题框架和实操经验。 这样你拿到的不只是答案,而是应对一类问题的底层能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Security核心机制深度拆解
2.1 过滤器链的注册机制:DelegatingFilterProxy与FilterChainProxy
Spring Security的入口是一个Filter,这是很多初学者知道的结论,但“为什么是一个Filter”却很少有人想清楚。
Servlet规范没有给Spring Security留特定的扩展点,它只认Filter和Servlet。Spring Security想要介入HTTP请求的处理流程,最自然的做法就是把自己伪装成一个Filter——这就是DelegatingFilterProxy存在的意义。
我在给团队做技术分享时经常打一个比方:DelegatingFilterProxy就像一个“中介”。Servlet容器只知道注册了一个名字叫springSecurityFilterChain的Filter,但这个Filter内部并不干活,它只是把请求“代理”给Spring容器里真正干活的Bean。这个Bean就是FilterChainProxy。
这个设计的精妙之处在于解耦。Servlet容器和Spring容器是两个独立的世界,Servlet容器负责管理Filter的生命周期,Spring容器负责管理Bean的生命周期。如果让Spring Security的过滤器直接注册到Servlet容器里,它就无法利用Spring的依赖注入能力。通过DelegatingFilterProxy这个中间层,两边各管各的,又通过一个代理关系连接起来。
面试时我建议你这样回答这个问题:
第一步,说清楚入口注册。 Spring Boot场景下,SecurityFilterAutoConfiguration会自动注册一个名为springSecurityFilterChain的DelegatingFilterProxy。这个Proxy实现了Filter接口,所以Servlet容器认它。
第二步,说清楚内部委托。 当请求到达时,DelegatingFilterProxy会从Spring容器中查找名为springSecurityFilterChain的Bean。这个Bean的实际类型是FilterChainProxy,它才是Spring Security真正的主过滤器。
第三步,说清楚请求分发。 FilterChainProxy内部持有多个SecurityFilterChain,每个SecurityFilterChain由一组过滤器组成,并带有RequestMatcher用于匹配请求路径。FilterChainProxy根据请求URL选择合适的SecurityFilterChain,然后按顺序执行链上的过滤器。
最后再补一句关键点:Servlet容器和Spring容器是两套上下文,中间的桥梁就是DelegatingFilterProxy。 这一句能让面试官确认你是真懂,而不是背过。
2.2 SecurityFilterChain的执行顺序与过滤器的“责任链”模式
前面提到FilterChainProxy会按SecurityFilterChain去分发请求,但这里还有一个隐藏考点:多个SecurityFilterChain之间是怎么排序的?
在Spring Security 6.x中,你可以这样配置:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
这段配置里有两个SecurityFilterChain,一个管/api/**,一个管其他路径。这里的关键就是@Order注解。
FilterChainProxy在分发请求时,会按照Order从低到高遍历所有的SecurityFilterChain,用每个链的RequestMatcher去匹配当前请求,命中第一个就不再继续往后找了。这个机制和nginx的location匹配规则有点像,一旦命中就进去执行,不会做“合并匹配”。
这个细节在面试中很容易被追问出第三个层次:如果两个SecurityFilterChain的匹配规则有交集,请求会走哪个? 答案就是按Order顺序,先匹配先处理。所以/api/**这种更精确的规则,通常建议放在Order更小的位置,避免被兜底规则提前截胡。
再说过滤器链内部的执行。SecurityFilterChain内部是一个标准的Filter链,链上的每个Filter都有明确的职责。大厂面试最爱问的几个过滤器包括:
UsernamePasswordAuthenticationFilter:处理表单登录,从请求中提取用户名密码,封装成Authentication,交给AuthenticationManager认证。BasicAuthenticationFilter:处理HTTP Basic认证。ExceptionTranslationFilter:捕获过滤器链中的异常,翻译成HTTP响应,比如未认证时重定向到登录页或返回401。AuthorizationFilter(Spring Security 6.x中替代了旧的FilterSecurityInterceptor):做最终授权判断,调用AuthorizationManager决定当前用户能否访问该资源。
这个过滤器链的设计模式就是经典的责任链模式。每个过滤器只处理自己关心的那件事,处理完就调用filterChain.doFilter()把请求传给下一个过滤器。如果某个过滤器认证失败或授权失败,它会直接抛出异常或写出响应,终止链条的继续执行。
我建议你把这个链的执行顺序梳理成一句话:先认证、再授权、最后执行真正的业务代码。 认证相关的过滤器在前面,授权判断的过滤器在最后,中间穿插着各种增强逻辑(比如CSRF防护、CORS处理、Session管理等)。
2.3 OAuth2与hasScope方法变迁:从注解到新式DSL
热搜词里有一条很有意思:“spring security oauth2没有hasscope方法了吗?”
这问题一看就是老手遇到的,多半是项目从Spring Security 5.x升级到6.x时踩了坑。我先直接回答:hasScope()方法还在,但它的典型用法变了,而且很多老写法在6.x中确实不再支持。
先说hasScope()的定位。在OAuth2资源服务器场景下,scope是客户端在申请令牌时声明的权限范围,令牌里会带上一个scope字段,比如read、write。资源服务器校验令牌后,还要确认这个令牌的scope是否满足访问某个接口的权限要求。
Spring Security 5.x时代,典型的配置写是这样:
java复制http
.authorizeRequests()
.antMatchers("/orders/**").hasAuthority("SCOPE_read")
.antMatchers("/admin/**").hasAuthority("SCOPE_admin")
注意,5.x里更常见的是用hasAuthority("SCOPE_read"),因为JWT令牌中的scope会被映射成以SCOPE_为前缀的权限。也有团队直接写成:
java复制.antMatchers("/orders/**").hasScope("read")
hasScope("read")本质上就是hasAuthority("SCOPE_read")的语法糖。这个语法糖在6.x中依然存在,没有删。
那为什么有人会遇到“没有hasScope方法”的报错?最典型的原因是在方法级安全注解里使用了@PreAuthorize("hasScope('read')"),但忘了开启@EnableGlobalMethodSecurity或@EnableMethodSecurity。还有一个原因是混用了新旧两套API——比如在Spring Security 6.x中,authorizeRequests()已经废弃,新的写法是authorizeHttpRequests(),如果你把旧的antMatchers()和新的配置方式混在一起写,IDEA会提示很多方法找不到,hasScope()在IDE里显示不出来就会让人误以为“这个方法没了”。
更细一点,Spring Security 6.x中方法级安全性推荐使用@EnableMethodSecurity注解,替代旧的@EnableGlobalMethodSecurity。同时支持基于组件的安全表达式,比如:
java复制@PreAuthorize("hasAuthority('SCOPE_read')")
public Order getOrderById(String orderId) {
...
}
或者用Bean来封装权限判断逻辑:
java复制@Component
public class OrderSecurity {
public boolean canAccessOrder(String username, String orderId) {
// 自定义校验逻辑
return true;
}
}
@PreAuthorize("@orderSecurity.canAccessOrder(#username, #orderId)")
public Order getOrder(String username, String orderId) {
...
}
这两种写法升级到6.x都很稳。
所以面试如果问到这个点,你先表明结论:hasScope方法没有消失,只是你要注意配置风格的变化,以及确认是否开启了方法级安全支持。 然后再展开讲一讲新旧配置的迁移细节,面试官就会觉得你是个在项目里真正处理过升级问题的人,而不是只看过文档的新手。
2.4 认证与授权的主流程梳理:从AuthenticationManager到SecurityContext
前面讲过滤器链时提到认证过滤器会把解析出来的Authentication交给AuthenticationManager。这块是Spring Security最核心、也最容易在面试中深挖的部分,我单独展开说。
整个认证流程,我建议你按这个顺序理解:
第一步:未认证的Authentication对象诞生。 比如表单登录,UsernamePasswordAuthenticationFilter从请求参数中拿到用户名和密码,构造一个UsernamePasswordAuthenticationToken。这个Token对象的authenticated字段是false,表示尚未认证。
第二步:AuthenticationManager出场。 AuthenticationManager是一个接口,只有一个方法authenticate(Authentication authentication)。它的默认实现是ProviderManager,内部维护了一组AuthenticationProvider列表,按顺序尝试用每个Provider去认证。
第三步:AuthenticationProvider真正干活。 最常见的是DaoAuthenticationProvider,它会调用UserDetailsService.loadUserByUsername()加载用户信息,然后通过PasswordEncoder比对密码。比对通过后,返回一个已认证的Authentication对象,同时把用户的权限列表(GrantedAuthority集合)塞进去。
第四步:SecurityContextHolder保存认证结果。 认证成功后,Spring Security会把已认证的Authentication对象放入SecurityContextHolder。SecurityContextHolder默认使用ThreadLocal策略,意味着同一个线程的后续代码都能从SecurityContextHolder.getContext().getAuthentication()拿到当前用户信息。
面试时我会追加一个高频问题:为什么SecurityContextHolder用ThreadLocal?那异步线程里怎么获取用户信息?
答案分两层。用ThreadLocal是为了避免在方法调用链中频繁传递用户上下文参数,让业务代码能随时取用当前登录用户。但ThreadLocal的代价是异步场景下上下文不会自动传递——你开了一个新线程,新线程的ThreadLocal是空的。解决方案是使用SecurityContextHolder的策略模式:在Web场景下用SecurityContextHolderFilter在请求开始时从SecurityContextRepository(比如基于Session的存储)加载上下文,在异步场景下手动传递SecurityContext,或者把SecurityContextHolder配置为MODE_INHERITABLETHREADLOCAL(不推荐,容易导致上下文串线程)。
授权流程相对简洁。过滤器链走到AuthorizationFilter时,会拿到当前认证信息和请求的访问控制规则,调用AuthorizationManager判断是否允许访问。Spring Security 6.x的授权模型比5.x更灵活,支持AuthorityAuthorizationManager、AuthenticatedAuthorizationManager、CustomAuthorizationManager等多种策略,但核心逻辑没变:请求必须带着合法的身份凭证,并且身份必须拥有访问目标资源的权限。
3. 分布式缓存的技术剖析与选型
3.1 为什么分布式缓存成了大厂Java面试的必考题
先看现象。几乎每一次大厂Java面试,缓存都是绕不开的板块。原因不是面试官爱考热门技术,而是缓存这层东西在互联网后端架构里的地位太特殊了。
我习惯用一个三层递进来说明这个问题:第一层,缓存解决的是“性能”问题——数据库能抗住的QPS有限,加一层缓存就能大幅提升读多写少场景的吞吐量;第二层,缓存解决的是“成本”问题——有了缓存扛流量,就不需要无限扩容数据库实例,省下来的机器成本很可观;第三层,缓存解决的是“架构”问题——在高并发场景下,数据库连接数是稀缺资源,无脑请求数据库可能导致连接池耗尽、服务雪崩,缓存层相当于给数据库加了一个缓冲保护带。
所以在面试里,分布式缓存考的不是“你会不会用Redis”,而是“你能不能讲清楚缓存层在整个高并发架构中的位置和职责”。大厂很多业务场景,比如热搜列表、商品详情、用户Session、库存扣减,都是基于缓存的。你要是连这些场景的缓存设计逻辑都说不清楚,面试官基本可以判定你没有真正接触过核心链路。
面试时你可以在自我介绍环节就埋一个加分点,比如:“我之前负责的商品详情接口,QPS高峰期到了两万,我们通过Redis缓存商品基础信息、分布式锁控制热点缓存重建,把数据库的查询量压到了几百。” 面试官一听就知道你不是只会写CRUD,后面聊缓存深度话题时,你的可信度也会高很多。
3.2 Redis核心数据结构与典型业务场景的对应关系
Redis能成为分布式缓存的首选,除了它够快(单线程 + 纯内存操作,避免锁竞争,同时用IO多路复用处理网络请求),更重要的是它的数据结构丰富,能覆盖很多业务场景。
大厂面试对Redis数据结构这部分,基本会从两个维度考察:知道有哪些数据结构,以及知道每种结构适合解决什么问题。
String:最基本的键值对。适用场景是缓存简单的值,比如商品详情JSON、用户Token、验证码。还可以用INCR命令做计数器,比如秒杀库存、访问量统计。Hash:适合存对象,比如用户信息、购物车。一个Hash维护一个对象,避免了序列化整个对象的开销,也能单独更新对象的某个字段。List:底层是双向链表或压缩列表,适合做消息队列(简单场景)、最新列表(时间线)。Set:自动去重的无序集合,适合做好友关系、标签系统、抽奖去重。ZSet:有序集合,每个元素带一个score,按score排序。这个太常用了,排行榜、延迟队列(用score存执行时间戳)都靠它。
我面试时喜欢给候选人出一个场景题:“做一套实时排行榜,要求按分数倒序,分数相同的按时间正序,你会怎么设计?” 候选人如果说“用ZSet,score存分数”,我会接着问“那同分怎么处理”。合理的方案是用score = 分数 * 100000 - 时间戳或者score = 分数 * 10^N + (MAX_TIMESTAMP - 时间戳),把时间因素编码进score。能想到这一步的候选人,说明是真做过排行榜功能,而不是背过“ZSet可以做排行榜”这句话。
你再看Hash的一个典型面试场景:缓存用户信息时,key = user:1001,field = userName / age / email等。这样要注意,大量String key存储同一个对象的多个属性,会造成key的碎片化,且更新某个字段要重写整个对象——Hash在这里的工程价值就体现出来了。
新手最容易犯的错误是:把所有缓存数据都无脑用String存,序列化成JSON后塞进去。这在数据量小的时候没问题,但一旦涉及到部分更新、聚合计算、过期时间精细管理,就会很别扭。选数据结构的思路应该是:先看业务对数据的操作模式,再选数据结构,而不是反过来。
3.3 缓存与数据库的一致性问题:更新策略该怎么选
分布式缓存面试进阶题,十有八九会落到“缓存和数据库的一致性”上。这个问题的难度在于没有一个方案是完美且普适的,面试官其实在考察你怎么做技术选型、怎么权衡一致性、性能和复杂度。
业内最常见的策略是Cache Aside(旁路缓存)。写入时先更新数据库,再删除缓存;读取时先查缓存,未命中则查数据库后回填缓存。
为什么“先更新数据库,再删除缓存”而不是“先更新缓存”?这里有三个关键原因:
第一,删除缓存比更新缓存更安全。 如果你更新了数据库里的值,但又把旧值写回了缓存,那缓存和数据库就永远不一致了。删除缓存则不存在这个问题——下次读取时缓存未命中,会回填最新值。
第二,写多读少的情况下,删除缓存能避免无效更新。 假设一个值被频繁修改,但很少被读取,每次都更新缓存纯属浪费。
第三,删除缓存可以延迟不一致窗口。 先改数据库、再删缓存,两者之间如果客户端读到旧值,可能短暂读到旧值,业界把这个窗口期称为“不一致间隙”。而如果先删缓存、再更新数据库,读取请求在缓存删除后、数据库更新前会直接打到旧的数据库数据,不一致窗口更大。
但Cache Aside还有两个经典坑,面试高频:
坑一:先更新数据库,再删缓存时,删缓存失败怎么办? 我司遇到过生产事故,缓存删除失败导致大数据量的脏数据持续被读到。你可以在面试时回答:用Binlog订阅监听数据库变更,或者用本地消息表 + MQ保障删除动作的最终执行,也可以引入重试机制。无论用哪种,核心思路都是:不用“删一次就完事”的心态,要给删除动作加上可靠性和可观测性。
坑二:并发读写导致的脏数据问题。 比如线程A写数据库,线程B读缓存未命中、读数据库旧值、回填缓存,线程A再删除缓存。此时缓存里是旧值,数据库是新值,脏数据会持续一段时间。这个场景的经典解法是引入版本号或者更新序号:每次写操作带一个单调递增的版本号,回填缓存时如果版本号小于已有值则放弃回填。这个方案能极大缩小不一致窗口,但需要业务配合。
还有另一种思路是Read/Write Through:让缓存组件自身代理操作数据库,应用只和缓存交互。这种方案对应用透明,但实现复杂度高,业界落地相对少。**Write Behind(异步写回)**则适合写吞吐量极大的场景,但数据丢失风险大。面试时把这几种方案都列出来,说明各自的使用场景、优势和代价,面试官通常会认可你的架构视野。
3.4 缓存穿透、击穿、雪崩:三种故障场景的答题框架
这三兄弟是分布式缓存面试里雷打不动的高频题,很多候选人能背出定义,但一到“具体怎么解决”就露怯。我先复盘定义,再给出一个标准答题框架。
缓存穿透:请求的key在缓存和数据库中都不存在。每次请求都会穿透缓存直达数据库,如果恶意攻击者构造一堆不存在的key,数据库可能直接被压垮。经典场景是刷单平台用随机不存在的商品ID狂刷接口。
缓存击穿:某个热点key的缓存过期瞬间,大量请求同时打到数据库。这里的“热点key”可能是高流量的商品详情、热搜词、秒杀商品。
缓存雪崩:大量key同时过期,或者缓存节点宕机,导致大量请求同时落到数据库。区别在于击穿是“单个热点key的过期”,雪崩是“大规模key过期或服务整体不可用”。
面试答题时不要只背定义,按照“现象 - 原因 - 解决方案 - 取舍”四步来讲,这是最稳的。
缓存穿透的解决方案有两种主流:
- 空值缓存:如果查询结果不存在,在缓存中存一个空值,并设置较短的过期时间(比如30-60秒)。这样同一个不存在的key不会反复穿透到数据库。
- 布隆过滤器(Bloom Filter):把所有存在的key预先加载到布隆过滤器,查询前先判断key是否可能存在。布隆过滤器存在“误判”(可能把不存在的key判为存在),但不会“漏判”(存在的key一定不会判为不存在)。工程上一般是两层配合:布隆过滤器拦截大部分非法key,空值缓存兜底剩下的小概率穿透。
布隆过滤器本身的原理在面试中也常被追问:它用多个哈希函数把一个key映射到位数组的多个位,查询时只要有一个位为0,就说明key一定不存在。它用“一定范围内的误判率”换取了巨大的内存节省。如果面试官让你估算内存占用,你大概记住:十个亿级别的key,1%误判率,大约需要不到1GB的内存(实际按位数组大小和哈希函数数量计算,这个量级基本不会错)。
缓存击穿的解决方案:
- 互斥锁(Mutex Key):当缓存未命中时,不是所有线程都去查库,而是先尝试获取分布式锁。抢到锁的线程查数据库并回填缓存,其他线程短暂自旋等待或直接返回旧值。这个方案简单有效,但引入了额外的锁开销和一定的等待延迟。
- 逻辑过期(逻辑TTL):热点key不设置物理过期时间,而是在value里存一个逻辑过期时间字段。线程发现逻辑过期后,抢到锁的线程去刷新数据,其他线程直接返回旧值。这个方案避免了“缓存过期瞬间的惊群效应”,但会增加数据短暂不一致的风险。
- 热点key永不过期 + 后台异步刷新:在活动期间把热点key设置为永不过期,由后台任务主动更新。最稳,但要防止后台刷新失败导致的数据静止。
缓存雪崩的解决方案:
- 过期时间加随机值:把key的TTL设置为基础值加一个随机偏移量,防止大量key在同一秒过期。
- 多级缓存:本地缓存(Caffeine/Guava) + 分布式缓存(Redis) + 数据库,本地缓存的存在能有效挡住Redis整体不可用时的流量。
- 缓存高可用:Redis集群模式下开启主从 + 哨兵,或使用Redis Cluster。尽量别让“缓存节点宕机”直接演变成“数据库被穿透”。
我在面试中比较看重的点是:候选人答完方案之后,能不能说出这些方案分别带来了什么额外代价。互斥锁会引入延迟和锁争用、空值缓存会浪费一点缓存空间(但换来数据库安全)、多级缓存增加了数据一致性的维护成本——能把这些“代价”和“取舍”清楚讲出来,说明是真的思考过,而不是背题。
4. Spring Security与分布式缓存在项目中的落地配合
4.1 登录态与Token的存储选型:从Session到Redis扩展
很多候选人把Spring Security和Redis当作两个独立的知识点复习,但其实两者在真实项目中有大量交集。最常见的交点是登录态的存储。
单体时代,Spring Security默认把认证成功的Authentication存进HttpSession,Session信息存在于单机内存中。一旦应用部署多台机器,负载均衡把请求分发到不同节点,Session就串不起来了——这就是经典的“Session不共享”问题。
解决方案通常有三种:
方案一:Session复制(不推荐)。 各个节点之间同步Session数据,实现简单但性能和可扩展性差。
方案二:基于Redis的Session共享。 引入spring-session-data-redis,把Session数据持久化到Redis。Spring Session会自动用SessionRepositoryFilter替换掉Servlet原生的Session管理逻辑。配置上只需引入依赖和配置Redis连接,注册一个@EnableRedisHttpSession注解,Spring Session就能把HttpSession的创建、读取、过期都代理到Redis。
我在项目中实测下来,这个方案在中小规模集群中很稳。但有两个坑要提醒你:Session数据里不能放不可序列化的对象,否则存Redis的时候会报错;另外,Session的过期时间必须结合Redis的maxmemory策略一起设计,别把一次性会话数据当成长期数据存Redis。
方案三:无状态Token方案。 最常见的是JWT。认证成功后服务端签发一个JWT,客户端保存,后续请求通过Authorization头携带。服务端不需要保存会话状态,天然支持水平扩展。劣势是无法主动让Token失效,除非引入黑名单机制(通常还是依赖Redis)。
大厂面试里,我强烈建议你把这个选型逻辑梳理清楚:
- 如果业务是To B管理系统、对实时踢人需求高,优先考虑Session + Redis扩展。
- 如果业务是开放API、对接第三方客户端,优先考虑JWT。
- 如果既要无状态,又要能控制Token失效,就做“JWT + Redis黑名单/白名单”的组合方案。
JWT方案还有一个Spring Security层面的细节:在网关或服务端拿到JWT后,需要通过JwtAuthenticationConverter把Token中的scope、role等声明转换成Spring Security的GrantedAuthority列表。这个转换逻辑要自己写,一般长这样:
java复制@Component
public class JwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// 从JWT的scope字段提取权限
List<String> scopes = jwt.getClaimAsStringList("scope");
if (scopes != null) {
for (String scope : scopes) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope));
}
}
return new JwtAuthenticationToken(jwt, authorities);
}
}
这样后续的@PreAuthorize("hasAuthority('SCOPE_read')")才能正确判断。
4.2 权限信息的缓存化设计:避免每次请求都查DB
另一个Spring Security和缓存的天然结合点,是权限数据的缓存。
有些非JWT场景,或者自定义认证逻辑比较多的项目,每来一个请求,Spring Security就要根据用户信息和请求资源去数据库里查一遍权限。在高并发下,这块查询会成为性能瓶颈。比较好的做法是把权限数据缓存起来。
比如用UserDetailsService加载用户信息时,如果每次调用都去数据库查,压力很大。可以包一层缓存:
java复制@Component
public class CachedUserDetailsService implements UserDetailsService {
private final UserDetailsService delegate;
private final StringRedisTemplate redisTemplate;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String cacheKey = "user:details:" + username;
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return JSON.parseObject(cachedJson, UserDetailsImpl.class);
}
UserDetails userDetails = delegate.loadUserByUsername(username);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(userDetails), 30, TimeUnit.MINUTES);
return userDetails;
}
}
这个方案的优点是大幅降低数据库读取压力,但要注意两个问题:
- 缓存刷新时机。用户被删除、角色被变更时,必须主动删掉旧的缓存。一般是在权限变更的业务代码中,手动
delete对应的缓存key;更可靠的做法是使用Redis的PUBLISH/SUBSCRIBE或消息队列通知其他节点刷新。 - 序列化方案。不要把
UserDetails接口直接序列化,因为不同实现类的序列化形式差异大。建议定义一个明确的UserDetailsImpl实现,或者干脆缓存一个自定义的“权限模型对象”,自定义转换逻辑。
如果能讲出这些细节,面试官会认为你真的自己写过权限模块,而不是只在教程里见过UserDetailsService接口。
4.3 组合战术:分布式锁 + 缓存刷新 + 安全过滤链的协同
再说一个偏实战的组合场景,面试中如果能把这道题答好,会非常加分。
场景:一个热点资源的权限判断结果需要缓存,但缓存会过期,怎么保证并发下不会因为“缓存过期 + 权限校验”导致大量请求穿透到数据库?
完整方案是分三层:
第一层,用Spring Security做认证授权。 通过过滤器链完成身份识别。这里不再细说,安全校验是第一道入口。
第二层,业务权限结果用Redis缓存。 比如“用户1001是否有权限访问订单编号101010”的判断结果,可以缓存成permission:1001:101010 -> true,设置合理的TTL。
第三层,缓存过期瞬间用分布式锁保护。 当缓存未命中时,先尝试获取分布式锁(用Redis的SET NX实现),拿到锁的线程才去查询权限数据源并回填缓存;没拿到锁的线程短暂等待后重试查询缓存。
代码轮廓:
java复制public boolean hasPermission(String userId, String resourceId) {
String cacheKey = "permission:" + userId + ":" + resourceId;
Boolean cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
String lockKey = "lock:permission:" + userId + ":" + resourceId;
boolean locked = tryLock(lockKey, 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查,可能其他线程已经回填了
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
boolean result = loadPermissionFromDB(userId, resourceId);
redisTemplate.opsForValue().set(cacheKey, result, 30, TimeUnit.MINUTES);
return result;
} finally {
unlock(lockKey);
}
}
// 没抢到锁,短暂等待后重查缓存
Thread.sleep(50);
return redisTemplate.opsForValue().get(cacheKey);
}
这个组合场景本质上考察的就是:你知道缓存穿透/击穿的理论,但你能不能在Spring Security上下文里把它落地。把理论知识放到真实业务场景中讲出来,面试官对你的评价会直接上一个档次。
5. 项目实战复盘:一个分布式缓存构建的完整流程
5.1 从需求分析到缓存维度设计
讲完了Spring Security和缓存的理论配合,我再拿一个具体项目复盘完整的构建流程。这样你能把这些知识串成一条线。
假设我们要做一个电商后台的商品管理模块。原始需求是:商品详情读取频率极高,但商品基础信息变动频率较低。技术负责人决定引入Redis做缓存。作为开发,你要做的不只是“在代码里调一个get/set”,而是走完下面这条完整链路:
第一步,梳理数据的读写特征。 哪些数据读多写少——商品信息、分类信息、品牌信息;哪些数据实时性要求高——库存、价格;哪些数据不能缓存——敏感信息、强一致数据。
第二步,确定缓存维度。 主维度是商品ID,细分为商品基础信息product:base:{id}、商品详情product:detail:{id}、商品库存product:stock:{id}。这样不同维度可以分别设置TTL、分别做失效处理。
第三步,预估缓存容量和QPS。 假设商品数量100万,每个对象的JSON平均1KB,那基础信息缓存约1GB。高峰期QPS 2万,Redis单实例能扛住(读操作每秒轻松过10万+),所以单主多从的架构即可。
第四步,设计缓存读写流程。 读:先查缓存,命中则返回,未命中查库回填。写:更新数据库后删除缓存,配合MQ兜底删除失败。
5.2 Redis集群架构与Key设计规范
在动手开发前,还有两个关键预设计,这两个也是大厂面试的高频考点。
第一个是缓存Key的命名规范。我在团队内部强制要求一套统一规范:
code复制业务名:实体名:ID[:子维度]
示例:
product:base:1001
product:detail:1001
user:info:8888
好处有三个:业务维度一目了然、排查问题时能快速定位、按前缀批量扫描删除也方便。面试时你要能说出来:Key设计要考虑可读性、可维护性、可扩展性,而不是随手写一个a123。
第二个是集群架构选型。如果Redis是单点,缓存节点挂了就是缓存雪崩的源头。而Redis主从 + 哨兵模式,主节点故障时哨兵自动完成主从切换,应用无感知。更大规模场景,用Redis Cluster。
这里有一个绝大多数人容易说错的点:Redis Cluster不支持多Key操作(比如MGET多个key如果分布在不同的slot就会报错),所以做集群规划时要考虑hash tag或者按业务拆分。
如果面试官问“Redis单线程为什么还能这么快”,标准回答是:单线程避免了锁竞争和线程切换开销,加上纯内存访问、IO多路复用模型,Redis在单实例上就能支撑每秒10万+的读请求。 快速说完,不要拖沓,重点放在后面你能延伸多少实操细节上。
5.3 缓存监控、日志与报警体系搭建
最后补一个很多初级开发容易忽略、但大厂尤其看重的环节:缓存的可观测性。
没有监控的缓存,出了问题是两眼一抹黑。我建议至少要盯这几个指标:
- 缓存命中率。低于80%必须排查。要么是缓存设计的维度不对,要么是Key失效策略太激进。
- Redis内存使用率。接近
maxmemory时配置的淘汰策略会生效,可能导致大量key被LRU清理,引发缓存穿透。 - 慢查询数量。Redis慢查询日志要开起来,超过阈值(比如100ms)的操作要重点分析。
- 连接数和阻塞事件。连接数打满说明应用侧的资源问题;阻塞事件(比如
KEYS *命令)要严厉禁止。
报警方面,命中率骤降、内存突增、慢查询频率上升,都要设置告警。
日志方面,缓存未命中的时候一定打一条WARN级别日志,标记当前key、请求来源、耗时。这样线上万一出现“缓存穿透”,你能快速回溯是哪一段代码导致的。
面试时能主动讲出监控指标的候选人,我通常会给更高的评价,因为它说明你有线上意识,而不只是能写代码。
6. 常见问题与面试避坑实录
6.1 面试高频追问与示范回答
下面整理几个我在面试中反复追问的高频问题,附上推荐回答思路。
追问1:Spring Security的过滤器链和普通Servlet Filter有什么区别?
回答思路分三点:第一,普通Servlet Filter是Servlet容器直接管理的,Spring Security的过滤器链是通过DelegatingFilterProxy委托到Spring容器的FilterChainProxy,再由它分发到具体的SecurityFilterChain;第二,Spring Security过滤器链上有很多定制Filter,支持按URL匹配、按顺序执行;第三,普通Servlet Filter无法访问Spring Security的安全上下文,Spring Security的过滤器链则环绕着SecurityContext的创建和清理。
追问2:JWT和Session的选型依据是什么?
参考4.1节的内容。建议按照业务场景展开:内部系统选Session + Spring Session扩展;开放API选JWT;混合场景用JWT + Redis黑名单。回答时一定要提到“主动失效”和“无状态”这两个核心权衡点。
追问3:缓存一致性最强的方案是什么?
先说结论:不存在“最完美的方案”,只有最合适的方案。然后分析Cache Aside的适用场景和缺陷,补充Binlog订阅 + Canal + MQ方案适合强一致性场景,最后主动说明工程选型要平衡复杂度和一致性要求。面试官问这种开放性问题的目的,不是要一个标准答案,而是看你能不能结构化表达。
追问4:使用Redis做缓存,内存满了以后会发生什么?
Redis的maxmemory-policy决定了淘汰策略。常见的有noeviction(不淘汰,新写失败)、allkeys-lru(全局LRU)、volatile-lru(仅对有TTL的key做LRU)、allkeys-random等。如果你的缓存key大部分都没有设置TTL,volatile-lru就不合适。一个很好的加分点是:缓存场景建议allkeys-lru,而不要用默认的noeviction,否则缓存写满后大量请求直接打到存储层。
6.2 容易翻车的细节清单
这些是我自己踩过坑、也在面试中经常发现候选人忽略的细节,列成清单。
- Spring Security 6.x中
WebSecurityConfigurerAdapter已废弃。旧教程里的extends WebSecurityConfigurerAdapter写法在6.x直接编译不通过,必须改为基于SecurityFilterChainBean的配置方式。 Not authorized和403的区分。401是未认证,403是已认证但无权限。Spring Security中ExceptionTranslationFilter会根据异常类型决定返回401还是403,不要混淆。- 缓存key的过期时间不能太短,也不能太长。太短会造成频繁穿透,太长会导致数据长期不一致。业界常见的是“基础TTL + 随机偏移”策略。
- Redis事务和Lua脚本的区别。Redis事务只是“批量执行”,不保证回滚;Lua脚本能保证原子性,但不要把耗时长的逻辑写进脚本,会阻塞Redis。
- 不要在业务代码里使用
KEYS *命令。数据量大的时候直接阻塞Redis,用SCAN代替。 - 分布式锁的锁粒度要小。锁的粒度是商品维度而不是整个商品模块维度,否则高并发下锁争用会拖垮性能。
6.3 从面试题到工程能力的进阶建议
最后聊点更宏观的。
面试题只是敲门砖,工程能力才是你真正跟别人拉开差距的地方。我在面试中,判断一个候选人处于什么水平,不是看他给出了多少标准答案,而是看三个信号:
第一个信号:能不能主动讲出场景。 比如问缓存穿透,候选人如果能补充一句“我当时用布隆过滤器拦截掉了大部分不存在ID的查询,剩余用空值兜底”,说明他做过这件事。
第二个信号:能不能讲清方案的代价。 比如空值缓存解决了穿透,但会带来多少额外存储Cost;互斥锁解决了击穿,但引入了多少等待延迟。能主动分析代价的候选人,架构思维已经建立起来了。
第三个信号:能不能说清楚“为什么选它而不是选另一个”。比如你选了Redis Cluster而不是主从 + 哨兵,那你要说清楚是出于容量规划、网络分区还是运维成本的考量。这个思维习惯平时就可以训练:每做一个技术选型,都写一段选型记录,列出候选方案、权衡点、最终决策。
这三个信号,对应的其实不是“面试能力”,而是把技术当成解决业务问题的手段的工程视角。我的建议是,每次面试准备不要只背答案,而是抽一个周末把自己项目里的某个功能模块完整复盘一遍——画清楚架构图、梳理出核心流程、找出所有可能的优化点。你会发现,面试时能讲出来的东西远比背八股文多得多。
我在实际面试过程中还有一个体会:候选人讲到自己真正解决过的问题时,眼睛是会发光的。 那种状态,远比任何背诵出来的标准答案都有说服力。所以,少背一些“互联网大厂Java面试精选”,多去把项目里的技术细节吃透——这才是你在大厂面试中最稳的底牌。
