最近在帮几个朋友做大厂模拟面试,发现一个特别普遍的现象:大家单拎出来都能聊,Spring Boot 自动装配背得滚瓜烂熟,Redis 分布式锁也能说两句,Kafka 生产端参数张口就来,Spring Security 过滤器链也看过。但只要面试官把场景串成一条完整链路——比如从"用户登录"到"下单"再到"异步通知"——就开始卡壳,互相之间的衔接、容错、数据一致性完全讲不清楚。
这其实就是大厂 Java 面试和普通面试最大的区别:不考孤立知识点,考的是你在一套 Spring Boot + Redis + Kafka + Spring Security 的真实系统里,能不能把每个环节讲透。这篇文章我就用一场面试实录的形式,把这个全链路拆开揉碎了讲一遍。整个面试的载体是一个非常接地气的项目——上门烹饪预约服务系统,你把它换成任何带订单、带用户、带消息通知的业务系统都成立。内容会尽量贴近真实面试的追问节奏,适合准备 Java 中高级岗位、或者想系统梳理中间件知识体系的朋友。
1. 面试开场:一个"全链路项目"是如何被层层深挖的
面试官上来第一句话通常是:"先介绍一个你最有代表性的项目吧。"
很多人这个时候就开始背项目流水账:我们用了 Spring Boot、Redis、Kafka、Spring Security,做了用户管理、订单管理、通知管理……听起来什么都做了,但面试官根本抓不住重点。
谢飞机的回答方式值得参考,他抛出来的是核心链路:
我做的是一个上门烹饪预约服务系统。用户通过小程序选厨师、选时段、下单支付,厨师端接单并上门服务。我主要负责的是预约下单这条核心链路:用户登录鉴权走 Spring Security + JWT,下单时先把热门时段的库存预热到 Redis 做并发控制,下单成功发出订单创建事件到 Kafka,消费端异步处理支付回调、给厨师发单、给用户发通知,再把结果回写数据库。
这几句话一出来,面试官立刻就能把整个系统的技术链路画出来,并且从每个环节往里挖。
这个项目的核心价值在于:它不是一个 CRUD 堆砌的管理系统,而是一个有并发、有异步、有安全控制的真实业务场景。预约下单天然存在热点问题——热门厨师的热门时段会被多人同时抢;也天然存在解耦需求——下单成功了不能同步等着发短信、推小程序通知;更天然需要权限控制——顾客、厨师、运营管理员三类角色看到的数据完全不一样。
所以面试官接下来的每一个问题,都可以从这条链路上延伸出去。这也是我建议大家准备项目时的一个核心思路:与其准备几十个八竿子打不着的知识点,不如把一条主链路上的技术全部吃透,让面试官顺着你的项目问,问到哪里你都能接住。
下面这场"面试实录",就是沿着这条链路逐层展开的。我按技术栈拆成四段来复盘,每一段都是面试官真实会追问的深度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Boot 层:启动、自动装配、兼容性和部署方式
2.1 自动装配的底层原理,别只说"约定大于配置"
面试官问的第一个问题往往是:"项目为什么选 Spring Boot?它的启动原理是什么?"
很多人的回答是:"因为 Spring Boot 内嵌了 Tomcat,打一个 jar 包就能跑,而且自动装配很方便。"这种回答太表面了,面试官接下来一定会追问:"自动装配到底是怎么实现的?"
完整的链路是这样的:@SpringBootApplication 是一个组合注解,它包含了 @SpringBootConfiguration、@EnableAutoConfiguration 和 @ComponentScan。其中最关键的是 @EnableAutoConfiguration,它通过 @Import(AutoConfigurationImportSelector.class) 导入一个选择器,这个选择器会去读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件(Spring Boot 2.7 之后是 spring.factories,更早版本用的是 spring.factories),把里面声明的所有自动配置类都加载进来。
注意,是"加载进来",不是"全部生效"。每个自动配置类上都有大量的条件注解,比如 @ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty。像 RedisAutoConfiguration 上就有 @ConditionalOnClass(RedisOperations.class),只有你的 classpath 里有 Redis 相关的 jar 包,这个配置类才会生效。@ConditionalOnMissingBean 的意思是,如果你自己定义了一个 RedisTemplate 的 Bean,那么自动配置的就会退位让贤,以你的为准。
这个机制最重要的意义在于:它把"配置"和"生效"解耦了。你引入一个 spring-boot-starter-data-redis,自动配置类被加载,但因为条件注解的存在,不会覆盖你已有的自定义配置。
面试中如果能答到这一层,已经超过了 80% 的候选人。如果再能补充一句"条件注解是 Spring Boot 扩展机制的核心,自定义 Starter 也是基于这个原理",那这一题就很稳了。
2.2 Springfox 3.0.0 与 Spring Boot 2.6+ 的兼容坑
这套项目里有一个很真实的坑:如果你用的是 Spring Boot 2.6 以上的版本,还继续用 Springfox 3.0.0 做 Swagger 文档,启动的时候大概率会直接抛异常,报错信息类似:
code复制java.lang.NullPointerException: Cannot invoke
"springfox.documentation.spring.web.WebMvcPatternsRequestConditionWrapper.getPatterns()"
because "this.condition" is null
这个坑的本质原因是:Spring Boot 2.6 开始,Spring MVC 的路径匹配策略从 AntPathMatcher 换成了 PathPatternParser,Springfox 3.0.0 内置的 WebMvcPatternsRequestConditionWrapper 只适配了旧的 AntPathMatcher,两者不兼容,启动时就会 NPE。
我当时实际的排查过程是这样的:项目从 Spring Boot 2.5 升到 2.6 之后,一启动就报这个 NPE,第一反应以为是 Bean 冲突,排查了半天没结果,后来去查 Spring Boot 2.6 的 release notes,才发现是路径匹配策略变更导致的破坏性更新。解决办法很简单,在 application.yml 里加一行配置:
yaml复制spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
或者更彻底一点,换掉 Springfox,迁移到 springdoc-openapi,它的底层走的不是这套匹配逻辑,性能和长期维护性都更好。面试的时候如果能把"问题现象 → 问题根因 → 解决方案 → 更优替代"讲完整,面试官会很有好感,因为这就是真实项目里踩坑和解决问题的完整闭环。
2.3 命令行运行和 Tomcat 部署的差别
这题看着基础,但真问起来能筛掉一半人。Spring Boot 项目可以用 java -jar 直接跑,是因为内置了 Tomcat,本质上就是在一个 main 方法里启动了 Spring 容器,然后启动了内嵌的 Web 服务器。你可以在命令行指定端口:
bash复制java -jar order-service.jar --server.port=8081
也可以传环境变量、外部配置文件:
bash复制java -jar order-service.jar --spring.profiles.active=prod --spring.config.location=/etc/order/application-prod.yml
但如果项目要部署到外部 Tomcat,就没这么简单了。你需要把打包方式改成 war,并且把启动类改造成:
java复制@SpringBootApplication
public class OrderApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(OrderApplication.class);
}
}
SpringBootServletInitializer 的作用是让外部 Tomcat 在启动 Web 应用时,能够找到并初始化 Spring 的根容器。两者的本质区别:java -jar 是应用自己启动内嵌容器,而 war 包部署是把应用丢给外部容器去加载。大厂实际项目里,除非有运维侧必须用外部容器的限制,一般都会采用 java -jar 的方式,部署简单、环境一致性好,这也是 Spring Boot 最核心的设计初衷之一。
3. Redis 层:缓存穿透、分布式锁和 Stream 队列消息拉取
3.1 预约场景下的缓存穿透、击穿、雪崩怎么答
在这个预约系统里,Redis 的第一个职责是缓存。热门厨师的信息、常见时段的库存、首页的推荐列表,这些都属于读多写少的数据,非常适合放缓存。
面试官的追问方式通常是一个场景接一个场景:"如果某个厨师突然火了,大量用户同时去查他的详情页,你的缓存会出什么问题?"
这就把三个经典问题全部带出来了。缓存穿透是查一个不存在的 key,请求直接打到数据库;缓存击穿是某个热点 key 在过期的一瞬间,大量请求直接穿透到数据库;缓存雪崩是大批 key 在同一时间过期,或者 Redis 宕机,导致大量请求全部落到数据库。
实际的解决方案是这样的:
- 穿透:用布隆过滤器拦截,或者对查不到的 key 也写一个空值的缓存,设置较短的过期时间。布隆过滤器判断"不存在"是准确的,判断"存在"可能是误判,用在这类场景刚好。
- 击穿:热点 key 不设过期时间,改成逻辑过期;或者加互斥锁,只允许一个请求去重建缓存,其他请求等待或返回旧值。我这里用的是"逻辑过期"的方案,key 里存一个过期时间字段,过期后异步去刷新,用户拿到的可能是一个延迟几秒的旧数据,但对于介绍页这种场景完全可接受。
- 雪崩:过期时间加随机值,比如 3 分钟到 5 分钟之间随机散列,避免大批 key 同时过期;同时做 Redis 主从加哨兵,保证高可用。
回答的时候建议强调一点:缓存方案没有银弹,必须结合业务场景来判断。比如这个预约系统里,库存数据是不能接受"旧值"的,所以库存缓存和详情缓存的处理逻辑必须分开。
3.2 热门时段防并发:分布式锁的实现与 Redisson 看门狗
预约系统最核心的并发问题:某个厨师周六晚上 6 点到 8 点的时段,库存只有 2 个名额,但瞬间来了 10 个用户同时抢。如果不加控制,10 个请求同时读到"还剩一个名额",全部通过校验,就会超卖。
单机环境下可以用 synchronized 或者 ReentrantLock,但服务一旦部署多台,JVM 级别的锁就失效了,必须用分布式锁。最经典的实现有两个层次。
第一层,用 Redis 的 SETNX 手动实现。核心思路是抢锁的时候执行一个原子操作:
java复制// 加锁
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent("lock:order:1001", requestId, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 业务逻辑
try {
// do something
} finally {
// 释放锁,必须用 Lua 脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList("lock:order:1001"), requestId);
}
}
这里有两个关键点:第一,加锁时要带上一个唯一标识 requestId,防止把别人持有的锁释放掉;第二,释放锁时必须用 Lua 脚本,先比对再删除,保证"判断"和"删除"的原子性。如果不比对就直接 del,极端情况下会出现锁过期后,线程 A 的锁被线程 B 覆盖,然后 A 把 B 的锁释放了。
第二层,直接上 Redisson。它帮我们解决了两个核心痛点:看门狗自动续期、以及可重入。看门狗的逻辑是,如果锁默认 30 秒过期,而业务执行超过了 30 秒,Redisson 的后台线程会每隔 10 秒自动把锁的过期时间续到 30 秒,确保业务执行完之前锁不会提前过期。可重入则保证同一线程可以多次加锁而不死锁。
这里要特别提醒一点,面试官很喜欢追问"RedLock 呢?"——Redisson 的 RedissonMultiLock 实现了 RedLock 算法,要求加锁成功需要大多数节点同意。但 RedLock 在分布式系统领域是有争议的,它在极端场景下依然不能保证绝对安全。实际项目中大部分场景用单节点 Redisson 加主从加哨兵就够了,关于 RedLock 你能说出它的原理和争议点,这一题就能拿到高分。
3.3 Spring Boot 里如何拉取 Redis Stream 队列消息
这里要单独拿出来讲,因为它是这次搜索热度非常高的一个问题,也是 Redis 5.0 之后一个特别实用但很多人不熟的功能。
用过 Redis 做消息队列的人,早期通常选 List 的 LPUSH + BRPOP,简单粗暴但功能太弱:不支持多消费者组,消费后没有确认机制,消息丢了就丢了。后来 Redis 5.0 引入了 Stream,才真正有了像样的消息队列能力。XADD 发布消息,XGROUP 创建消费者组,XREADGROUP 消费消息,XACK 确认消息,整套机制和 Kafka 的消费组模型非常像。
在 Spring Boot 里拉取 Stream 消息,有两种主流方式。第一种是用 RedisTemplate 的 opsForStream() 手动拉取,适合对消费逻辑有精细控制的场景:
java复制// 创建消费者组
StreamOperations<String, Object, Object> streamOps = redisTemplate.opsForStream();
streamOps.createGroup("order:stream", "order-consumer-group");
// 拉取消息,count=10 表示每次最多拉 10 条,block=5000 表示最多阻塞 5 秒
StreamReadOptions readOptions = StreamReadOptions.empty().count(10).block(Duration.ofMillis(5000));
Consumer consumer = Consumer.from("order-consumer-group", "consumer-1");
List<MapRecord<String, Object, Object>> records = streamOps.read(
consumer,
readOptions,
StreamOffset.create("order:stream", ReadOffset.lastConsumed())
);
for (MapRecord<String, Object, Object> record : records) {
try {
// 处理业务
handleOrderMessage(record.getValue());
// 确认消息已经消费完成
streamOps.acknowledge("order:stream", "order-consumer-group", record.getId());
} catch (Exception e) {
// 处理失败的消息会进入 PEL 待确认列表,可以做重试或死信
log.error("handle stream message failed, id: {}", record.getId(), e);
}
}
这里有几个坑,都是实测踩过的。第一,ReadOffset.lastConsumed() 表示从该消费者组消费进度之后开始拉取,新消费者加入时用它最合适;如果你用 ReadOffset.latest(),会只消费新消息,组里历史积压的消息就不管了。第二,消息处理完一定要 XACK,否则消息会一直留在 Pending Entries List 里,积压多了会拖慢消费速度。第三,消费失败的场景要单独处理,我的做法是先用 XACK 确认掉,避免重复消费,再手动把消息写入一个专门的失败列表,配合定时任务扫描重试。
第二种方式是用 Spring Data Redis 的错误处理器和监听器,不过手动 XREADGROUP 的方式更直观、也更方便在面试中讲解底层逻辑。顺带说一句,如果你的场景对消息可靠性要求非常高、需要分区、需要长时间保存消息回溯,那就别选 Redis Stream,直接用 Kafka,这是两个不同定位的东西。
3.4 Redis 数据类型、主从部署和可视化管理
面试官还喜欢问一个看似基础实则能看出功力的问题:"Redis 有哪些数据类型?各自的应用场景是什么?"
String 对应缓存、计数器、分布式锁;Hash 对应对象存储,比如存储用户信息;List 对应简单的消息列表、最近浏览记录;Set 对应去重、共同好友、抽奖;ZSet 对应排行榜、延时队列。这是人人都会背的分法。但如果你想拉开差距,可以主动提 Stream(消息队列)、Geo(地理位置)、HyperLogLog(基数统计),并且给出一两个业务场景。比如这个预约系统里,可以用 ZSet 按"预约配额剩余量"排序实现"无厨师可约"的快速判断,用 HyperLogLog 统计某个时段的 UV。
关于部署,现在开发环境基本都走 Docker。Redis 主从的配置其实不复杂,关键是理解主从复制的流程:从节点启动后向主节点发送 PSYNC,主节点生成 RDB 快照传给从节点,同时把快照期间的增量写命令放到缓冲区,快照同步完成后继续把增量命令同步给从节点,实现最终一致。主从解决的是读扩展和高可用问题,但从节点默认是只读的,写操作必须走主节点。
可视化工具方面,我推荐 Another Redis Desktop Manager,跨平台、免费、颜值高,比老牌的 Redis Desktop Manager 更轻量,尤其是查看 Stream、查看 key 的 TTL、批量删除 key 这些高频操作都做得很顺手。这个不用在面试里说,是给实际开发提效用的。
4. Kafka 层:消息可靠性、消息延迟高的完整排查链路
4.1 概念题怎么答得有深度
"说说 Kafka 的基本模型。"
标准答法:Topic 用来分类消息,Topic 拆成多个 Partition,Partition 内保证有序,每个消息有唯一 Offset;Partition 有多个副本,副本之间通过 ISR 集合来管理,Leader 负责读写,Follower 负责同步;消费者组内每个分区只会被一个消费者消费,实现并行消费。
但面试官通常会追问一句:"为什么 Kafka 性能那么高?"这里需要答出三点:顺序写磁盘、页缓存(Page Cache)、零拷贝。
顺序写磁盘是说 Kafka 的消息追加写入是接在文件末尾的,避免了随机寻址的开销,即使在机械硬盘上顺序写的速度都可以非常快;页缓存是说 Kafka 读写利用了操作系统的 Page Cache,而不是自己在 JVM 里维护缓存,避免了双份内存拷贝;零拷贝是指消费消息时数据从磁盘到网卡发送,通过 sendfile 系统调用直接在内核态完成,减少了用户态和内核态之间的拷贝次数。能把这一题答到这个深度,说明你是真懂 Kafka,不是背概念。
4.2 消息不丢失,三端都要保证
预约系统里,用户下单后发出一个订单创建事件,这个事件如果丢了,厨师就收不到新订单通知,这是绝对不能接受的。所以消息可靠性是必问题。
完整的保证要从三个端来聊。生产端,设置 acks=all,表示分区 Leader 和所有 ISR 内副本都写入成功才返回成功;同时开启重试 retries=3,并设置 enable.idempotence=true 开启幂等,避免重试导致消息重复。Broker 端,Topic 设置 replication.factor=3,min.insync.replicas=2,意思是至少有两个副本同步成功才认为写入成功。消费端,关闭自动提交,改为手动提交 Offset。
手动提交这里有一个非常容易被问到的坑:先处理后提交,还是先提交后处理?如果先提交后处理,业务逻辑抛异常时消息已经提交了,就会丢消息;如果先处理后提交,业务处理成功但提交失败,重启后会重新消费这条消息,造成重复处理。两种方案都有问题,实际项目里我的选择是"先处理后提交 + 消费幂等"——业务侧通过唯一业务单号做幂等,这样即使重复消费也只是查一下发现已处理然后跳过,不会造成重复发单或者重复扣款。
4.3 Kafka 消息延迟高,我应该怎么排查
这是最近搜索热度非常高的问题,也是面试官非常喜欢拿出来考"排查思路"的题目。因为延迟高的原因太多了,直接问"为什么延迟高"没有标准答案,考的就是你的排查链路是否清晰。
我把这个问题的完整排查路径整理成了一张表:
| 排查维度 | 具体动作 | 常见根因 |
|---|---|---|
| 消费者线程 | 看单条消息的平均处理耗时、poll 循环是否被阻塞 | 业务逻辑里有慢 SQL、远程调用未设置超时时间 |
| 消费参数 | 检查 max.poll.records、max.poll.interval.ms |
单次拉取太多导致处理超时,被踢出消费组触发 rebalance |
| 分区数量 | 对比分区数和消费者数 | 分区数远小于消费者数,消费者闲置;或 key 分布不均导致数据倾斜 |
| Broker 侧 | 看磁盘 IO、网络带宽、页缓存命中率 | 磁盘满了、网络被打满、Page Cache 频繁被换出 |
| 客户端 GC | 查看消费端 JVM GC 日志 | Full GC 频繁,导致消费线程暂停 |
实际排查中最隐蔽的问题往往是消费端的一条慢逻辑。比如我在另外一个项目里遇到过一次,线上 Kafka 消费延迟从 5 分钟涨到了 2 个小时,加分区没用、调并发没用,最后定位到消费端代码里调了一个第三方接口,那个接口在高峰期偶尔要等 30 秒才超时,而 max.poll.interval.ms 默认是 5 分钟,一旦单条消息处理超过这个时间,消费者就会被判定为失联,触发 rebalance,rebalance 期间分区还会重新分配,形成恶性循环。
排查的时候可以先看两个指标:消费 Lag(未消费消息数)和每消费一条的平均耗时。如果平均耗时正常但 Lag 很高,看分区数;如果平均耗时本身就很高,优先查消费逻辑里的外部依赖和 GC。顺着这个思路,面试官再往下问什么都有底气。
4.4 Kafka 集群怎么装、怎么看
这部分在面试里不一定直接问,但属于"用了 Kafka 的人应该知道"的常识。现在 Kafka 3.x 已经全面推荐 KRaft 模式了,不再依赖 ZooKeeper。
集群部署最简单的验证方式,是本地起三个 Kafka 节点,各自配置不同的 node.id 和监听端口,然后把 controller.quorum.voters 指向这三个节点。配置里需要注意的坑有两个:advertised.listeners 必须填对,如果配成 localhost 而容器和宿主机网络不通,外部客户端是连不上的;另外单机模拟集群时,log.dirs 和三套配置的端口都不能冲突。
可视化工具方面,轻量级的推荐 Kafdrop,网页界面直接看 Topic、Partition、消息内容;功能更全面的推荐 Kafka UI(原来叫 Kafdrop 的加强版),可以查看消费者组、Lag、修改 Topic 配置。个人习惯用 Kafka UI 多一点,调参数的时候能直接看到效果。
5. Spring Security 层:Filter 注册机制、认证流程和 OAuth2 的坑
5.1 Spring Security Filter 到底是怎么注册进去的
这个问题被搜索的次数非常多,也是 Spring Security 面试题里最刁钻的一题。答案是"你不清楚框架的注册链路,就很容易在自定义过滤器时踩坑"。
完整的链路是这样的。我们部署的是一个 Servlet 应用(哪怕 Spring Boot 内嵌 Tomcat,本质依然是 Servlet 容器),Servlet 规范里,Filter 要通过 FilterRegistrationBean 或 @WebFilter 注册到容器里。Spring Security 在这里做了一个非常关键的转接:它注册了一个名为 springSecurityFilterChain 的 Filter,这个 Filter 本身是 DelegatingFilterProxy,它的作用不是做安全校验,而是把请求委托给一个 Spring 容器里的 Bean——FilterChainProxy。
DelegatingFilterProxy 是 Spring 和 Servlet 容器之间的桥梁,Servlet 容器管理的是这个代理 Filter 的生命周期,但它真正干的活是到 Spring 容器里找名字叫 springSecurityFilterChain 的 Bean,把请求转交出去。
FilterChainProxy 是 Spring Security 的核心入口,它内部维护了一个或多个 SecurityFilterChain,每个 SecurityFilterChain 里又是一个过滤器列表。SecurityFilterChain 通过 securityMatcher 决定哪些请求路径走这条过滤器链,比如 /api/** 走一组规则,/admin/** 走另一组规则。请求进来了,FilterChainProxy 根据路径找到匹配的链,然后按顺序执行链上的过滤器,最终才到我们的业务 Servlet。
这套机制在 Spring Boot 启动时是怎么串起来的?核心是自动配置类 SecurityAutoConfiguration 和 SecurityFilterAutoConfiguration,它们往容器里注册了 DelegatingFilterProxyRegistrationBean。这个 DelegatingFilterProxyRegistrationBean 是专门为了让 Spring Boot 不需要手动在 web.xml 里声明 Filter 而设计的,它创建 DelegatingFilterProxy 并让 Servlet 容器感知到 springSecurityFilterChain 这个 Filter。整个依赖关系是:Servlet 容器 → DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → 各个 Filter。
如果你要往链里加自定义过滤器,记住这三个方法:
java复制http.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(myFilter, BasicAuthenticationFilter.class);
http.addFilterAt(myFilter, BasicAuthenticationFilter.class);
addFilterBefore 和 addFilterAfter 是把自定义过滤器插到某个已有过滤器前后,addFilterAt 的意思是"放在和某个过滤器相同的位置",但注意它不是替换,实际上两个过滤器会同时存在。面试的时候把这个链路讲清楚,再补一句"自定义过滤器如果要拿到当前登录用户信息,必须放在 FilterSecurityInterceptor 之前、并且最好是放在认证过滤器之后",这道题的分数就拿到了。
5.2 认证流程和授权模型,怎么讲得既完整又简洁
接着问的往往是:"那请求进来之后,认证是怎么完成的?"
我推荐的讲法是以 UsernamePasswordAuthenticationFilter 为起点:请求携带用户名密码 → 该过滤器封装成 UsernamePasswordAuthenticationToken(注意此时 isAuthenticated() 还是 false)→ 交给 AuthenticationManager → 根据类型找到合适的 AuthenticationProvider → Provider 调用 UserDetailsService.loadUserByUsername() 拿到用户信息 → 再用 PasswordEncoder.matches() 比对密码 → 比对成功则生成一个全新的已认证的 Authentication 对象 → 放回 SecurityContextHolder,失败则抛异常走失败处理器。
这个流程里最常见的追问是:认证信息存在哪?如果项目用的是 JWT,那么每次请求进来,JWT 过滤器解析 token → 从 token 里提取用户信息 → 手动构造认证对象放到 SecurityContextHolder,这样下游的 @PreAuthorize 注解才能取到当前用户。记住一个原则:SecurityContextHolder 里没有认证对象,接口权限注解就判断不了你是谁。
授权模型方面,重点是 hasRole 和 hasAuthority 的区别。hasRole("ADMIN") 实际上是 hasAuthority("ROLE_ADMIN") 的简写,数据库或 token 里存的如果是 ROLE_ADMIN,两者等价;如果权限字段里存的是 order:create 这种粒度更细的权限码,就用 hasAuthority("order:create")。这个知识点会在后面的 OAuth2 里再次用到。
5.3 Spring Security OAuth2 没有 hasScope 方法了吗
这个问题也是最近被搜索得非常多的,答案是:旧版框架里确实有,新版框架里没了,如果你用新写法写 hasScope(),项目根本编译不过。
背景是这样的:早期的 Spring Security OAuth2 是独立的 spring-security-oauth2 项目(2.x 时代),里面提供了 @EnableResourceServer、@EnableAuthorizationServer 注解,以及 oauth2Server().resourceServer().accessTokenConverter() 这一套 API,hasScope("read") 就是那个时代的方法,用来在方法级别校验 token 里的 scope。但这个项目已经停止维护了,功能被拆分成两个新的项目。
Spring Boot 2.x 之后,官方新方案是 spring-security-oauth2-authorization-server 负责授权服务器,资源服务器直接用 Spring Security 原生的 oauth2ResourceServer() 配置。在这个新框架里,token 里的 scope 不再自动变成权限对象,需要自己写转换器。默认情况下 scope 会通过 JwtGrantedAuthoritiesConverter 转换成带 SCOPE_ 前缀的权限,所以你要做 scope 校验,应该这样写:
java复制@PreAuthorize("hasAuthority('SCOPE_read')")
public List<OrderVO> listOrders() {
// ...
}
如果你坚持要一个类似 hasScope 的便捷写法,可以自定义一个安全表达式,把 hasScope("read") 映射成 hasAuthority("SCOPE_read"),但本质还是 hasAuthority 在起作用。
这个点非常能体现候选人有没有跟过框架升级。真正在项目里从旧版往新版迁移过的人,一定会踩到编译错误,然后去查文档发现 API 变了。面试的时候主动说一句"旧版的 hasScope 是 spring-security-oauth2 里的,新版用 hasAuthority('SCOPE_xxx') 替代",面试官就知道你不是纯背题。
5.4 认证相关的小细节
顺带提几个高频细节:密码存储用 BCrypt,因为它内置盐值、慢哈希、抗彩虹表;Session 会话管理要防止 Session Fixation;JWT 有一个天然的缺陷是无法在后端主动失效,所以退出登录只能靠客户端删 token,真正要求严格的系统会用短 token + refresh token,或者服务端维护一个黑名单。
这些细节不一定会全问到,但都是围绕 Spring Security 的加分项,答出来一个就能让面试官觉得你经验够足。
6. 整场面试复盘:谢飞机的答题思路、翻车点和复习路线
6.1 这条链路上最容易被问倒的三个位置
整场模拟面试下来,我发现大家最容易被问倒的位置高度集中。
第一个是分布式锁的边界。很多人上来就说"我用 Redis 分布式锁解决了并发问题",但说不清楚锁的过期时间、看门狗机制、以及"分布式锁不是万能的,极端情况下锁会失效"这个认知。面试官只要追问一句"如果 Redis 主节点挂了,锁会怎样",就答不上来了。
第二个是 Redis Stream 和 Kafka 的边界。这两个都是消息队列,什么场景用哪个?原则是:需要一个轻量级、无需额外部署、消息量不大且允许极端情况少量丢失的场景,用 Redis Stream;需要消息长时间保存、分区有序、吞吐量大、保证不丢的场景,用 Kafka。混淆这个边界,说明你只是会用 API,不理解中间件的设计差异。
第三个是消息积压的排查思路。很多人只会说"加分区、加消费者",但分布式系统里消息积压超过 90% 的情况都是消费端逻辑有问题,不懂这个就永远找不准根因。加分区和加消费者只是兜底手段,不是解决方案。
6.2 面试官可能追问的问题清单
我把这场面试里可能出现的追问整理了一下,方便大家对照自测:
| 问题方向 | 具体问题 | 是否答到原理层面 |
|---|---|---|
| Spring Boot | 自动装配的过程?条件注解怎么生效? | 需答到 AutoConfiguration.imports |
| Spring Boot | Springfox 3.0.0 和 Boot 2.6+ 为什么不兼容 | 需讲到 PathPatternParser 切换 |
| Redis | 缓存穿透/击穿/雪崩以及对应方案 | 需说清布隆过滤器、逻辑过期 |
| Redis | 分布式锁怎么保证原子性和续期 | 需讲 Lua 脚本和看门狗 |
| Redis | Stream 的消费者组和确认机制 | 需手写 XREADGROUP + XACK |
| Kafka | 消息不丢失怎么保证 | 需覆盖生产端、Broker、消费端三块 |
| Kafka | 消息延迟高从哪几个方向排查 | 需给出完整排查链路而非一个答案 |
| Security | Filter 是如何注册进 Servlet 容器的 | 需讲清 DelegatingFilterProxy 到 FilterChainProxy |
| Security | 新版 OAuth2 怎么校验 scope | 需说清 hasScope 已废弃,用 hasAuthority('SCOPE_') |
| Java 基础 | 冒泡排序、Lambda 函数、集合源码 | 属于高频基础题,不能丢分 |
6.3 一套务实的复习路线
从 Java 基础往上走,正确的顺序是:Java 基础语法和集合源码 → JVM 内存模型和 GC → 并发编程(JUC) → Spring 核心 → Spring Boot 自动装配 → Redis(数据类型、持久化、分布式锁、Stream) → Kafka(生产者、消费者、可靠性) → Spring Security(过滤器链、认证授权、OAuth2)。
很多人一上来就死磕 Spring Security 源码,结果因为 Servlet 规范和 Spring 容器都没吃透,看了两天就放弃了。中间件这套东西,最好按"先会用 → 再懂原理 → 能讲清为什么"的节奏来。比如 Redis Stream,你先在项目里跑通 XADD、XREADGROUP、XACK,再去看源码,理解就完全不一样了。
最后说点题外话。陪人模拟面试这段时间,我最大的体会是:面试官真正想招的,不是背诵能力最强的人,而是出了线上问题能最快定位、面对陌生技术栈能快速上手的人。这套全链路实战的价值,恰恰是逼你在一个真实的业务场景里,把每个中间件的作用、边界、坑都搞清楚。你不用记住每一个细节,但你要能讲清楚"为什么这个环节用这个东西",这是我给所有准备 Java 大厂面试的朋友最实在的一条建议。
