谢飞机面试大厂:Spring Boot、JVM、Redis、Kafka、微服务与音视频搜索场景求生实录
开场
某互联网大厂面试间。
面试官王工,四十岁,目光如炬,面前摆放着一杯冷掉的咖啡和一台笔记本电脑。他看了一眼简历,又看了一眼对面坐着的谢飞机------一个穿着格子衬衫、眼神涣散但透着清澈愚蠢的年轻人。
王工:"谢飞机同学,你好。我们开始吧。"
谢飞机:"好嘞,王工。我准备好了,随便问,我都能接住。"
王工笑了笑,那笑容里既有期待,也有一丝不祥的预感。
第一轮:基础热身,JVM 与 Spring Boot 的底裤
王工敲了敲电脑:"先热个身。你简历上写熟悉 Java SE 8/11/17,那我问你,Java 8 和 Java 11 相比,有哪些你实际用过的区别?"
谢飞机眼睛一亮:"这个我会!Java 8 有 Lambda 表达式,还有 Stream 流,集合处理起来特别爽。还有 Optional 防空指针。Java 11 的话......嗯,有个 var 类型推断?就是局部变量那个,但当时项目里怕老代码看不懂,基本没用。对了,有个 HttpClient 可以同步异步发请求,我试过,比老 HttpURLConnection 好用。"
王工点头:"不错,能用得上。那 JVM 运行时数据区域有哪些?每个区域干什么的?"
谢飞机:"这个也简单。堆,存对象;方法区,存类信息和常量,不过 Java 8 好像改成元空间了,不再用永久代。虚拟机栈,每个线程一个,里面都是栈帧,装局部变量、操作数栈。本地方法栈给 native 方法用。程序计数器就是记录下一步执行哪条字节码指令的。大概就这些吧。"
王工:"嗯,元空间为什么要改成本地内存?"
谢飞机愣住,挠头:"因为......永久代有大小限制,容易 OOM?改成元空间就......不用管最大限制了?反正能省点永久代的空间吧。嗯......具体原因我有点记不清了。"
王工没深究,换个问题:"Spring Boot 的自动配置原理,你大概说说。"
谢飞机:"这个我熟!Spring Boot 启动类上有 @SpringBootApplication,里面组合了 @EnableAutoConfiguration。然后会加载 META-INF/spring.factories 里配的自动配置类。这些类上有 @ConditionalOnClass、@ConditionalOnMissingBean 一堆条件注解,比如你引入了 redis 的 client 依赖,才会去配置 RedisTemplate。整个过程就是基于条件装配,你要是没导入对应依赖,它就不会瞎配置。最后配置完,还会放到容器里。我当时是这么理解的。"
王工难得地露出一丝赞许:"理解得不错。那 Maven 里依赖冲突怎么办?"
谢飞机:"依赖冲突太常见了。两个 jar 包里面有同一个类,或者版本不一样。我一般用 Maven 依赖树看,mvn dependency:tree,把重复的找出来,然后在 pom 里用 exclusions 排除掉不需要的传递依赖。或者直接在最外层 pom 里定义依赖的 version,强制统一版本。但有一次搞了半天,最后发现是本地仓库的 jar 坏了,clean 一下仓库才好。挺傻的。"
王工点点头:"第一轮不错,看来基础还是有的。我们进入第二轮,场景题。"
第二轮:电商高并发,Redis 与 Kafka 的考验
王工:"假设你在做一个电商秒杀系统。高峰期有大量用户同时抢购一件商品。你怎么用 Redis 防止缓存穿透、击穿和雪崩?"
谢飞机深吸一口气,像是背过武林秘籍:"这个我会!穿透就是用户疯狂查一个根本不存在的数据,每次都要打到数据库。我可以用布隆过滤器,把所有可能存在的 key 先放进去,查之前先判断,如果过滤器说没有,那就直接返回,不打数据库了。还有一招是即使查到空,也把空值缓存起来,设一个很短的过期时间。"
王工:"那击穿呢?"
谢飞机:"击穿就是有一个热点 key,突然过期了,这时候大量请求同时涌向数据库。解决办法是加互斥锁,只有拿到锁的线程可以去查数据库,其他线程就等着或者先返回旧值。还有一种方法是把热点 key 的过期时间设长一点,或者用逻辑过期。"
王工:"雪崩?"
谢飞机:"雪崩就是大量 key 同时过期,导致一堆请求全部打到数据库。解决办法很简单,给过期时间加上一个随机数,让过期时间分散开。而且可以做永久 key,或者后台线程去更新。"
王工:"不错。那 Redis 分布式锁呢?怎么实现?"
谢飞机:"简单,setnx key value,设置成功就拿到锁,用完删除。为了防止死锁,锁要加过期时间。为了防止删错,value 要用唯一的随机字符串,删除前判断一下是自己的值。还有......如果要实现高可用,可以用 Redisson 或者 RedLock。但我没在生产用过 RedLock,感觉有点复杂。对了,Redis 主从切换的时候锁可能会丢失,但我没说......"
王工笑了笑:"行。那 Kafka 怎么保证消息顺序性?"
谢飞机:"这题我懂一点点。Kafka 一个 topic 可以有多个分区,消息在同一个分区内是有序的。要保证全局有序,就只用一个分区,但这样吞吐量就低了。一般业务场景,只需要保证同一个 key 的消息有序,比如同一个订单号的消息,用 key 做分区器,让同一个订单的消息都进同一个分区。这样消费者在分区内按顺序消费,基本就满足需求了。"
王工:"那如果消费者是多线程消费,还会乱序,怎么办?"
谢飞机:"啊?多线程就会把原来的顺序打乱。可以......可以建多个内存队列,按 key 哈希到不同队列,然后每个队列对应一个单线程线程池。对,就这么干。不过我当时没实现过,是看博客学的。"
王工:"你在微服务里咋做服务熔断降级?"
谢飞机:"我用过 Resilience4j!在方法上加 @CircuitBreaker 注解,然后配置熔断阈值。当失败率达到一定比例,熔断器打开,后续请求直接走 fallback 方法,不调用下游服务。还有 @RateLimiter 限流,@Retry 重试。但我就用了最简单的,fallback 里返回一个兜底数据,比如返回一个空对象或者提示语。不过具体参数怎么写,我记不全,都是复制同事的......"
王工听完,若有所思:"第二轮也算沾边。最后来一轮综合场景,结合音视频和内容社区。"
第三轮:音视频社区,ES 与容器化的终极考验
王工:"我们做一个视频内容社区,用户会上传视频。你要设计一个大文件分片上传和断点续传的方案,你会怎么做?"
谢飞机:"这个我熟!前端把视频文件切成很多片,比如每片 5MB,然后一片一片上传到后端。后端每收到一片,就保存一块临时文件。全部传完后,再把这些分片合并成一个完整文件。断点续传就是前端通过一个接口查询已经上传了哪些分片,然后从没传的继续传。合并的时候用 FileInputStream 或者 RandomAccessFile 挨个写。嗯......但最后合并的顺序不能乱,每片要带一个序号。"
王工:"如果分片上传到一半,服务器重启了,还没合并,怎么办?"
谢飞机:"那就......那就得在服务器上把临时分片存到磁盘,重启后还能根据 session 或者 uploadId 找到它们。但如果服务器是多实例部署,文件存到本地是不是就找不到?所以应该把分片上传到 OSS 对象存储,比如阿里云 OSS,分片上传本身就是断点续传,而且不依赖本地磁盘。哎,这么一说我想起来了,应该用 OSS 的 MultipartUpload,而不是自己写......但是如果是公司内部私有云呢?我就有点犹豫了。"
王工看他开始冒虚汗,换了一题:"那用户上传的视频,需要在社区里搜索标题和简介,你会用 Elasticsearch 吗?简单说说原理。"
谢飞机:"会用!视频元数据存到 MySQL,然后通过同步工具监听 binlog 或者用定时任务扫表,把数据同步到 Elasticsearch。搜索的时候直接查 ES,不要查 MySQL。ES 核心是倒排索引,就是术语到文档的映射。比如用户搜索'搞笑猫', ES 会先分词,拆成'搞笑'和'猫',然后去倒排索引里匹配文档 ID,再打分排序。分词器可以用 IK 中文分词,比标准分词器更适合中文。"
王工:"那 ES 的搜索结果默认是按相关度打分,这个你会调整吗?"
谢飞机:"评分是 TF-IDF 或者 BM25 算法,新版默认 BM25。要调整......可以添加 function score,手动加权重新算分。比如视频的浏览量、点赞数作为权重加到分数上。但我当时改过一次,经常调不出想要的效果,最后还是靠 copy_to 和 keyword 字段硬搞的。哎。"
王工:"微服务里服务之间是怎么发现彼此的?说说过程。"
谢飞机:"我用过 Spring Cloud 和 Eureka。服务启动的时候,向注册中心注册自己,把自己的 IP 和端口告诉 Eureka,然后周期性地发心跳。调用方从注册中心拿到服务列表,缓存到本地。默认的话,Ribbon 会轮询选择一个实例来调。不过现在都换 Nacos 或者 Consul 了?Consul 我用过一点点,有健康检查,比 Eureka 更灵活,支持配置中心。但 gRPC 和 Thrift 我没实际调过,只写过 demo......"
王工:"最后问一个,你用 Docker 部署过 Spring Boot 应用吗?写一下 Dockerfile 的大致步骤?"
谢飞机:"写过!先 FROM openjdk:11-jre,然后把 jar 包 copy 进去,再 CMD java -jar app.jar。为了减小镜像,有时候用多层构建,先 maven 打包,第二个 FROM 才把 jar 拷过来。但之前有个坑,如果 jar 包很大,用 ADD 或 COPY 会触发缓存失效?所以一般先复制 pom.xml,然后 run mvn dependency:go-offline 把依赖缓存好,再复制源码打包。不过我这都是照搞资料来的,自己对镜像底层原理不咋通。"
王工喝了口冷咖啡,站起身,伸出手:"谢飞机同学,今天面试到这里。你的表现非常有特点,也展示了一些基础能力,但在深度和实际落地方面还需要加强。你先回去等通知吧。"
谢飞机也站起身,握住王工的手:"好的王工!那大概什么时候有消息?我手机二十四小时开机,没接到也会回拨的。"
王工抽出手,微微一僵:"我们会尽快。你回去多看看 JVM 调优和分布式系统的设计,祝你顺利。"
谢飞机走出面试间,顺手把桌上的企业宣传册塞进包里,心想:"等通知......那就是没戏了吧?不过面试官还挺好,教我回去看什么。回去赶紧补课!"
面试问题详解:让小白也能看懂的干货
第一轮问题详解
1. Java 8 与 Java 11 的区别
- Java 8 引入了 Lambda 表达式、Stream API、Optional、新的日期时间 API(java.time)、接口默认方法、方法引用等。它是后续 Java 版本的基石。
- Java 11 是 LTS(长期支持)版本,引入了:
var局部变量类型推断:var list = new ArrayList<String>();,但只能用于局部变量,不能用于成员变量、方法参数或返回值。- 一套标准化后的 HTTP Client API,支持 HTTP/2 和 WebSocket,替代了老旧的
HttpURLConnection。 - 基于嵌套的访问控制(nest-based access controls),让嵌套类之间访问私有成员不需要编译器生成合成方法。
- 在集合、Stream 和 Optional 上新增一些方法,如
String.repeat()、String.strip()、List.of()(Java 9 引入)等。
- 面试官问"你用过的区别",回答核心目的是体现你对版本演进的关注。仅答出 lambda 和 var 也可以,但最好说出 LTS 版本的选择对项目稳定性有影响。
2. JVM 运行时数据区域
- 程序计数器:每个线程私有,存储当前线程执行字节码的行号,是唯一不会 OOM 的区域。
- Java 虚拟机栈:线程私有,每个方法从调用到执行产生一个栈帧,栈帧里包含局部变量表、操作数栈、动态链接、方法返回地址。栈深度不足抛 StackOverflowError。
- 本地方法栈:为 native 方法服务,在 HotSpot 中和虚拟机栈合并了。
- 堆:所有线程共享,存放对象实例和数组,是 GC 的主要区域。可通过 -Xms / -Xmx 指定大小。
- 方法区:存储类信息、常量、静态变量、JIT 编译后的代码。Java 8 之前叫永久代,Java 8 之后改为元空间(Metaspace),使用本地内存,默认无上限,但可以通过 -XX:MaxMetaspaceSize 限制。
- 元空间替代永久代的原因:字符串常量池和类元数据从永久代移到堆/元空间,永久代的大小难以预测(受类加载数量影响),为 FullGC 带来压力;改为本地内存可以减少 OOM 概率。
3. Spring Boot 自动配置原理
核心注解:@SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan。
@ComponentScan扫描主类所在包及子包的 @Component。@EnableAutoConfiguration通过AutoConfigurationImportSelector导入自动配置类。- 自动配置类在
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 2.7+ 中)或META-INF/spring.factories(旧版本)中列出。 - 每个自动配置类使用
@Conditional系列注解,例如:@ConditionalOnClass:如果没有对应的类,则不配置。@ConditionalOnMissingBean:用户已经定义了自己的 Bean,不再自动配置。@ConditionalOnProperty:配置文件中没有指定值时默认启用。
- 比如
RedisAutoConfiguration上有@ConditionalOnClass(RedisOperations.class),你导入了 Spring Data Redis 依赖,它才会生效,并创建RedisTemplate/StringRedisTemplate等 Bean。 - 通过这个机制,应用可以"拿来即用",且不会抢走用户自定义 Bean 的优先权。
4. Maven 依赖冲突解决
- 冲突原因:多个依赖传递引入了同一个类库的不同版本,导致编译错误或运行时方法不存在 / NoSuchMethodError。
- 解决办法:
mvn dependency:tree查看依赖树,定位冲突来源。- 在声明依赖时使用
exclusions排除某个传递依赖。 - 在父 pom 中使用
<dependencyManagement>统一版本,这样子模块的传递依赖会被强制覆盖到指定版本。 - 如果本地仓库有损坏的 jar,可以删除对应目录或使用
mvn -U强制更新。
- 对于多模块项目,推荐使用 BOM(Bill of Materials)方式管理版本,如 Spring Cloud 的
spring-cloud-dependencies。
第二轮问题详解
5. Redis 缓存穿透、击穿、雪崩
- 穿透:查询一个不存在的 key,缓存无数据,导致每次请求都打到数据库,甚至被恶意利用打垮数据库。
- 解决方法:把空值也缓存起来(过期时间设短);使用布隆过滤器预先判断 key 是否存在;对非法请求做参数校验。
- 击穿:一个热点 key 刚好在某个时间点过期,恰有大量请求同时访问这个 key,全部打到数据库。
- 解决方法:加互斥锁(setnx key,查完数据库后再释放锁,其他线程等待重试);设置逻辑过期(在缓存 value 中写入过期时间,后台异步刷新);热点 key 不过期,由服务端主动更新。
- 雪崩:大量 key 在同一时间段集中过期,导致大量请求贯穿缓存打到数据库。
- 解决方法:给过期时间增加随机数(如 0~300 秒),让过期时间分散;对缓存做永不过期,但数据更新时主动刷新;使用多级缓存,Redis 之上再加一层本地缓存。
6. Redis 分布式锁
- 标准命令:
SET key value NX EX seconds(原子性),key 通常用业务资源名,value 使用唯一随机 ID(如 UUID),避免释放别人的锁。 - 释放锁时要先比 value 再用 Lua 脚本删除,保证原子性,防止误删过期后又被其他线程抢到的锁。
- 如果要更可靠,可以用 Redisson 的
RLock,它会自动续期(看门狗机制)。原理解释:Redisson 内部使用 Lua 脚本加锁,默认 30 秒锁存活,如果业务没执行完,会在 30 秒的三分之一处自动续期。 - RedLock 是 Redis 官方提出的一种跨多实例锁算法,要求多个独立的 Redis 节点中大多数都设置成功才算获取锁。但社区对其安全性有争议,实际使用较少。
- 注意:Redis 主从架构中,如果 master 宕机,锁数据未同步到 slave,锁会丢失。因此很多场景会改用 ZooKeeper / etcd 的强一致性锁。
7. Kafka 消息顺序性
- Kafka 中一个主题(Topic)可以有多个分区(Partition),每个分区内部是有严格顺序的,但分区之间不保证顺序。
- 全局顺序:只使用一个分区,比如使用 Partition = 1 的主题。但这样把所有消息都放在一个分区里,消费并发度受限于单分区,吞吐量低,不适用于大多数场景。
- 局部有序:按业务 key(如订单 ID、用户 ID)通过 producer 的 hash 分区器,把相同 key 的消息发送到同一个分区。这样同一业务实体的消息在分区内被顺序写入。
- 消费端:如果单线程消费同一个分区,天然就是有序的。如果要用多线程并发处理,需要自己设计内存队列:将消息按 key 哈希到多个队列,每个队列由一个单线程线程池消费,这样既保证同一个 key 的顺序,又能利用多核。
- 需要注意:重试、消费失败后的再次投递、消息积压等都可能打破顺序,所以最终往往需要结合业务逻辑判定是否需要严格顺序。
8. Resilience4j 熔断降级
-
Resilience4j 是一个轻量级容错库,其主要功能包括熔断器(CircuitBreaker)、限流(RateLimiter)、重试(Retry)、隔舱(Bulkhead)、时间限制(TimeLimiter)等。
-
熔断器状态:关闭(CLOSED)→ 打开(OPEN)→ 半开(HALF_OPEN)→ 关闭。失败率达到阈值(如 5 次内失败 50%)时,熔断器打开,后续调用快速失败,不请求下游;经过一段冷却时间,进入半开状态,允许少量试探请求,成功后恢复关闭。
-
使用方式:在 Spring Boot 中引入
spring-cloud-starter-circuitbreaker-resilience4j,然后写:java@CircuitBreaker(name = "videoService", fallbackMethod = "fallback") public Video getVideo(Long id) { ... }fallback 方法需要与原方法参数一致或加一个异常参数。
-
可以结合
@RateLimiter限制 QPS,@Retry做临时失败重试,@Bulkhead限制并发调用线程数。
第三轮问题详解
9. 大文件分片上传与断点续传
- 分片上传流程:
- 前端用
File.slice()把文件切成固定大小的分片(比如 5MB),并为每个分片生成序号。 - 调用后端接口创建上传任务(uploadId),返回 uploadId 和已上传的分片列表。
- 前端逐个上传分片。每个分片请求携带 uploadId、分片序号、文件内容。
- 后端收到分片后,保存到临时目录或对象存储(如 OSS 的 multipart 上传)。
- 所有分片上传完成后,前端调用合并接口,后端把分片按序号合并为完整文件,同时更新数据库记录。
- 前端用
- 断点续传:因为每个分片都是独立上传的,前端在异常中断后只需要查询哪些分片已经上传成功,然后上传缺失的分片即可。
- 如果使用阿里云 OSS / 腾讯云 COS 对象存储,在线分片上传本身就是成熟的解决方案,支持的上传任务可以管理,并且分片数据存储在后端,不占用应用服务器磁盘。自己实现时要注意:分片文件后缀名用
.part加 uploadId 和序号;合并时用支持随机的文件流;合并完成后必须清理临时分片;上传的分片大小要一致(最后一片可以不足)。 - 服务器重启恢复:如果保存了 uploadId 与分片元数据(存于 MySQL/Redis),重启后可以根据 uploadId 找到已上传分片,继续合并。如果部署在多实例上,必须把分片存储到共享文件系统或对象存储,不能只依赖本地磁盘。
10. Elasticsearch 与倒排索引
- Elasticsearch 是一个分布式的基于 Lucene 的搜索引擎,常用于内容搜索、日志分析、全站检索。
- 倒排索引:把每个文档的内容分词,建立"词条(Term)"到"文档 ID 列表"的映射。比如"搞笑猫"被分词为"搞笑""笑猫""猫"等词,搜索"搞笑"时能迅速找到包含该词的文档。
- 中文分词:使用 IK Analyzer(IK 分词器),支持自定义词典,比默认的 standard 或 cjk 分词更符合中文语义。
- 同步方案:常见有 Canal 监听 MySQL binlog 同步到 ES;或者使用 Logstash / Flink CDC;简单场景也可以定时扫表批量同步。
- 相关度评分:Elasticsearch 默认为 BM25 算法,它是 TF-IDF 的改进版,通过词频、逆文档频率、字段长度归一化来计算分数。可使用
function_score查询,让业务指标(浏览量、点赞数)参与加权排序。 - 使用建议:搜索接口只查 ES,写入落到 MySQL 后再同步;高速查询时要设置合理的索引 mapping,避免 use
text做精确匹配,精确匹配用keyword。
11. 微服务注册发现
- 核心组件:注册中心(Registry)存储所有服务的实例信息(IP、端口、健康检查地址)。服务提供者启动时注册,并按心跳/租约周期续约;服务消费者从注册中心拉取服务列表并缓存到本地,然后根据负载均衡策略选择一个实例发起调用。
- Eureka:经典 Netflix OSS 组件。Client 每 30 秒发心跳,若 90 秒没收到心跳则注销实例。Eureka Server 之间相互注册,默认有最终一致性。Eureka 服务端本身无明显主从,即使失去部分节点,仍能提供服务。
- Consul:使用 Raft 协议,强一致。支持服务发现、健康检查和 KV 配置中心。Spring Cloud Consul 可以替代 Eureka + Config Server。
- Nacos:阿里开源,既支持 AP(服务发现)又支持 CP(配置管理),功能强大。
- gRPC 和 Thrift 是 RPC 框架,它们有自己的服务注册接口,但通常需要结合服务发现组件,如 gRPC + etcd / Nacos。
- 在 Spring Cloud 中,负载均衡器从 Ribbon 演进到 Spring Cloud LoadBalancer;OpenFeign 集成 LoadBalancer 后,通过服务名即可调用。
12. Dockerfile 与 Spring Boot 部署
-
Spring Boot 应用生成的 jar 包是可直接运行的 fat jar。简单 Dockerfile 如下:
dockerfileFROM openjdk:11-jre WORKDIR /app COPY target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -
为了充分利用 Docker 缓存,通常把依赖层和业务层分离:
- 先在基础镜像中复制
pom.xml,执行mvn dependency:go-offline或mvn package -DskipTests用-fl方式预拉依赖。 - 再复制源码并构建。
- 先在基础镜像中复制
-
更现代的方式是使用 Google 的 Jib 插件或 Spring Boot 的
layertools。spring-boot-maven-plugin支持将 jar 分成dependencies、spring-boot-loader、snapshot-dependencies、application多个层,Docker 构建时只需在依赖层未改变时直接复用缓存。 -
注意基础镜像版本使用 JRE 而不是 JDK 可以减小体积;也可以考虑
eclipse-temurin、azul-zulu等精简镜像,或使用 Alpine + glibc 的镜像。
以上,就是谢飞机这次面试的全过程以及完整的知识点解析。希望各位同学能从中既能看到"水货"的欢乐,也能看到真正需要掌握的硬核技术。祝大家面试顺利,早日拿到大厂 offer,而不是只收到一句"回家等通知"。