谢飞机大闹大厂面试:从音视频缓存到微服务熔断的JVM奇遇记

谢飞机大闹大厂面试:从音视频缓存到微服务熔断的JVM奇遇记

谢飞机大闹大厂面试:从音视频缓存到微服务熔断的JVM奇遇记

“谢飞机,是吧?坐。”面试官老猫推了推眼镜,面前摊开一台笔记本电脑,屏幕上是谢飞机的简历,“看了你的项目经历,写过电商秒杀,也搞过内容社区,还碰过一点AIGC的图像处理。今天咱们不聊虚的,就从你实际做过的场景开始。”

谢飞机挺直腰板,露出一个自信的笑容:“好的面试官,您尽管问,我虽然有些地方不太熟,但我学得很快。”

老猫点点头,目光落在第一行:“你之前说你们社区UGC系统,用户上传图片和视频特别多,高峰期整个服务经常卡顿,你当时是怎么排查和解决的?先说说你用了什么监控手段和缓存策略吧。”

第一轮:社区UGC与音视频场景的缓存和日志排查

  1. “你的服务用的是Spring Boot,用户上传视频后,你如何用Spring Cache或者Caffeine对视频元数据做本地缓存?如果缓存穿透了,比如大量请求同时查询一个不存在的视频ID,你会怎么处理?”
  2. “你提到用Redis缓存了热门的视频流列表。如果这时候Redis集群发生主从切换,或者某个key因为Big Key问题导致阻塞,你如何用Micrometer和Prometheus监控到?具体要监控哪些指标?”
  3. “你们的日志框架用的是Logback和SLF4J,线上突然出现了大量错误日志,磁盘快满了,你怎么通过Logback的配置来避免日志风暴?同时,你如何用ELK从这些海量日志里快速定位到某个视频上传失败的根本原因?”

谢飞机搓搓手:“第一个问题……我用Caffeine做本地缓存,value是视频信息对象。要是缓存穿透了,我就用空值缓存,把不存在的ID也缓存个空对象,设置很短的过期时间。还有布隆过滤器,不过那个我面试前刚看的,还没实际用过……”

老猫眼睛一亮:“不错啊,还知道空值缓存和布隆过滤器。那第二个问题呢?”

“第二个……Redis Big Key,呃,我知道大key会阻塞,但是监控指标我一般就看下Redis的命中率和慢查询日志。Micrometer……好像是Spring Boot Actuator里带的吧?我都是默认的/actuator/metrics,具体要看哪个指标,我得想想。”谢飞机额头微微冒汗,“主从切换那个,我好像遇到过,但是当时是运维处理的,我就看了下业务日志报错Connection reset。”

老猫没打断,继续问第三个问题。

“Logback的话,我配置过maxFileSize和maxHistory,防止磁盘满。日志风暴……是不是要加个过滤器,或者把error级别也做限流?ELK的话,我用Kibana搜过关键字,比如‘uploadFailed’,然后看堆栈,但要说根本原因,好像每次都是看有没有异常类型。”

老猫嘴角微微上扬:“行,能说到这个程度,基础还是有的。来,我们换个场景。假设你接下来去了一家做电商供应链金融的公司,他们的核心系统还在用Java 8和Spring MVC,要接一个支付回调,要求最终一致性,并且支付金额不能出错。你怎么设计?”

第二轮:支付与金融场景下的微服务、序列化与分布式事务

  1. “支付回调这个接口,你会怎么保证幂等性?如果用了Redis分布式锁,锁过期了怎么办?结合你了解的Resilience4j,怎么处理下游服务超时?”
  2. “你的服务要对接供应链金融的账务系统,对方用的是Apache Thrift接口,而你们内部是Spring Cloud OpenFeign。这个协议转换你会怎么做?如果传输的数据用Protobuf序列化,和Jackson比有什么优缺点?在Java 11环境下,序列化时你需要注意什么?”
  3. “财务需要数据对账,你的支付结果数据最终要同步到Elasticsearch供查询。你会用Kafka还是RabbitMQ作为消息队列?如果使用Kafka,如何保证消息不丢失?如果使用RabbitMQ,如何防止消息积压导致消费延迟?”

谢飞机深吸一口气:“幂等性……我一般用订单号做唯一索引,或者Redis setnx。锁过期的话……嗯,可以加一个续期的线程,就是看门狗嘛。Resilience4j,我知道有熔断和限流,超时的话可以配一个TimeLimiter。”

老猫点点头:“那协议转换呢?”

“协议转换……Thrift是二进制的,OpenFeign是HTTP JSON。我可能会写一个适配器,先反序列化Thrift,再封装成内部DTO。Protobuf的话,比Jackson性能好,因为是二进制,占用空间小,但是调试的时候没法直接看到,而且需要维护.proto文件。Java 11……好像有ZGC,但是序列化好像没啥特别的注意?”谢飞机声音越来越小。

“消息队列呢?”老猫追问。

“Kafka吧,吞吐量高。保证不丢失的话,生产者要设置acks=all,消费者关闭自动提交,手动offset。RabbitMQ积压的话,可以多开几个消费者,或者用惰性队列。”谢飞机越说越快,显然这些背过。

老猫轻轻敲了敲桌子:“第三个场景,假设你现在参与一个在线教育平台的重构,他们的课程视频需要做转码,并且要推流到CDN,同时还要通过WebSocket实时推送学习进度。后端服务是用Spring WebFlux做的,配了R2DBC。你来说说这个架构的要点?”

第三轮:在线教育场景下的WebFlux、R2DBC与云原生部署

  1. “Spring WebFlux是基于响应式编程的,和Spring MVC在编程模型上最大的区别是什么?你的业务里有WebSocket推送,如何用WebFlux实现背压?”
  2. “你用R2DBC连数据库,它和JDBC本质上有什么不同?为什么在这种场景下宁愿用R2DBC而不是MyBatis?如果要做数据库迁移,Flyway和Liquibase在这中间会扮演什么角色?”
  3. “最后,这个平台要部署到Kubernetes集群里,用GitHub Actions做CI/CD。你会怎么将你的Spring Boot应用容器化?如果遇到Pod频繁重启,你怎么用Jaeger和Zipkin做链路追踪来定位问题?”

谢飞机已经有点晕了:“WebFlux……它是响应式的,非阻塞的,基于Netty。MVC是阻塞的,每个请求一个线程。背压……就是消费者处理不过来的时候,告诉生产者慢一点?具体API我不太记得了。R2DBC的话,是异步非阻塞的JDBC?MyBatis是同步的,会阻塞。”

“Flyway和Liquibase……都是做版本控制数据库的,Flyway比较轻量,Liquibase支持多数据库,反正就是用脚本管理表结构。”谢飞机挠挠头,“容器化的话,我会写Dockerfile,用maven打包成jar,然后基础镜像用openjdk:11-jre-slim。Pod重启的话,我会看logs,链路追踪,嗯,Jaeger可以看调用链,Zipkin也有UI,可以在里面搜traceId。”

老猫叹了口气,合上电脑:“谢飞机啊,你今天的表现……怎么说呢,一些八股概念背得不错,但一到真实业务场景,就有点飘。你的技术面广而不深,我这边需要能沉下心啃硬骨头的人。这样吧,你先回去等通知,我们后面还有几轮交叉面。”

谢飞机站起来,鞠了一躬:“谢谢面试官!我回去一定好好补课!”

老猫摆摆手,望着谢飞机离开的背影,喃喃自语:“这孩子,倒是有几分直率。可惜了,要是能把R2DBC的背压机制说清楚,我还真打算让他过。”


参考答案与场景技术点详解

第一轮:社区UGC与音视频场景的缓存、监控与日志排查

问题1:Spring Cache / Caffeine 缓存与缓存穿透

  • 业务背景:UGC社区中,视频元数据(标题、封面、作者、点赞数等)被频繁读取。使用本地缓存(Caffeine)可以避免每次请求都查MySQL,减少RT。但大量请求同时查询一个不存在的视频ID时(如恶意攻击或缓存过期后被删除),缓存没有命中,请求直达数据库,造成“穿透”。
  • 解决方案
    • 空值缓存:即使数据库中不存在该视频,也在缓存中写入一个空对象或特殊标记,设置较短的过期时间(比如5分钟)。这样后续相同ID的请求会命中空值,不会打到DB。
    • 布隆过滤器(Bloom Filter):在缓存前加一层布隆过滤器,维护所有合法视频ID的集合。请求进来先判断ID是否在过滤器中,如果不在,直接拒绝。优点是内存占用极低,缺点是无法删除元素(可以用Counting Bloom Filter或定期重建)。
    • 参数校验:对于明显不合法的ID(如负数、超长字符串),直接返回错误。
    • 其他增强:使用Spring Cache注解(如@Cacheable(cacheNames="videoMeta", key="#videoId"))时,底层配置Caffeine缓存管理器,设置初始容量、最大容量、过期策略。注意Caffeine本身支持recordStats(),可以监控命中率。

问题2:Redis Big Key、主从切换监控与Micrometer/Prometheus

  • 业务背景:Redis缓存视频流列表,如果某个用户ID下的视频ID列表非常大(例如千万级),该Key在Redis内部可能是一个大集合,读写时会导致阻塞。同时,Redis主从切换时,客户端连接会瞬时中断,需要监控到并快速恢复。
  • 监控指标
    • 使用Micrometer(Spring Boot Actuator暴露的指标系统)注册Redis相关指标。具体包括:
      • redis_operations_seconds:操作耗时直方图,如果某个操作耗时超过阈值(如100ms),说明可能有Big Key。
      • redis_connections_seconds/redis_connections_idle:连接池状态。
      • redis_command_latency:命令延迟。
      • 自定义指标:记录执行KEYSSMEMBERS等危险命令的次数。
    • 使用Prometheus拉取Actuator的/actuator/prometheus端点,配置Alertmanager规则:比如当redis_operations_seconds_count在5分钟内增长超过100次且耗时大于50ms时告警。
  • 主从切换:监控Redis连接状态,可以使用lettucejedis的拓扑刷新事件。在业务中,捕获RedisConnectionFailureException,利用Resilience4j重试机制,配合spring.redis.lettuce.pool的心跳检测。同时注意Redis集群模式下的MOVEDASK重定向,客户端会自动处理。
  • Big Key 排查:在Redis CLI中执行redis-cli --bigkeys,或者使用MEMORY USAGE key查看内存占用。优化方案是拆分大Key(如按时间分桶)、使用SCAN代替KEYS

问题3:Logback日志风暴与ELK定位问题

  • 业务背景:线上出现大量重复错误日志,消耗磁盘IO,导致服务变慢,甚至磁盘满。
  • Logback配置
    • 使用SizeAndTimeBasedRollingPolicy设置maxFileSizemaxHistory,保留最近30天,单文件不超过200MB。
    • 使用AsyncAppender异步写日志,减少阻塞,但注意队列满时的丢弃策略(discardingThreshold)。
    • 防止日志风暴:自定义Filter,基于速率限制(例如Guava的RateLimiter)过滤相同异常类型的日志。也可使用Logback的DuplicateMessageFilter,在5秒内相同消息只记录一次。
    • 设置root level="INFO",业务包内使用DEBUG,只在特定包开启ERROR
  • ELK排查
    • 日志通过Filebeat采集到Kafka,Logstash过滤器解析异常堆栈,输出到Elasticsearch。
    • 在Kibana中,搜索关键词videoId AND uploadFailed,利用Kibana的KQL语法。查看时间跨度,如果某个服务实例的日志都集中出现Connection timed out,说明可能是网络问题或依赖服务挂掉。
    • 通过log.error("upload failed, videoId={}", videoId, e)打印上下文。利用JSON结构化日志(LogstashEncoder),让ES可以索引字段,方便聚合。

第二轮:支付与金融场景下的幂等性、协议转换与消息队列

问题4:支付回调幂等性与RESTful服务的可靠性

  • 业务背景:支付平台回调你的接口,可能重复发送(因为网络超时重试)。必须保证同一笔订单只能被处理一次。
  • 幂等方案
    • 数据库唯一约束:订单表增加payment_id字段,创建唯一索引。重复插入时抛出异常,捕获后返回成功。
    • Redis SETNX:用SET key value NX EX 60orderId:paymentId)确保只有第一次能获得锁。但锁过期后,如果业务还没处理完,第二个请求进来会再次获得锁,导致重复处理。解决方案是Redisson看门狗,自动续期;或者使用数据库乐观锁version字段)。
    • 状态机:把订单状态设计为UNPAID -> PAYING -> PAID,只有当PAYING状态时回调才允许更新为PAID,否则拒绝。
  • Resilience4j:当通知下游账务系统时,使用Resilience4j TimeLimiter(超时)、CircuitBreaker(熔断)和Retry(重试)。例如,配置:
    • TimeLimiter:超时3秒。
    • CircuitBreaker:10秒内错误率超过50%则打开熔断,熔断后快速失败,避免拖垮调用方。
    • Retry:最多重试3次,间隔指数退避。重试时注意幂等性(下游也需要用同样的幂等键)。

问题5:Thrift vs OpenFeign,Protobuf vs Jackson

  • 业务背景:供应链金融账务系统是老系统,暴露Thrift接口,内部数据结构用IDL定义。而你的服务是Spring Cloud微服务,平时用OpenFeign调用JSON API。
  • 协议转换
    • 编写一个TCP客户端(使用Apache Thrift生成的Client类),先在本地将Thrift响应反序列化为Java对象,再包装为内部DTO。将这个步骤封装在一个FinanceAccountClient接口中,供Service调用。
    • 注意Thrift是长连接二进制协议,需要连接池管理,可以使用commons-pool2。如果并发高,改用gRPC(HTTP/2)进行内部通信,但对方是老系统,只能写适配器。
  • Protobuf vs Jackson
    • Protobuf:二进制格式,体积小(压缩后比JSON小3-10倍),序列化/反序列化速度快(通过生成的代码直接操作字节),强类型。需要定义.proto文件,并编译成Java类。缺点:可读性差,调试需要工具;字段变更需要保证兼容性。
    • Jackson:JSON格式,可读性好,通用性强,各种REST API都用它。性能相比Protobuf低,体积大,但对于大多数场景足够。
    • Java 11注意事项:如果使用Java 11的模块化系统(JPMS),需要确保.proto生成代码依赖的javax.annotation模块被正确引入。另外,Protobuf的反射和动态消息(DynamicMessage)在Java 11下可能遇到模块反射限制,需要使用--add-opens。如果使用Record(Java 16+)作为DTO,注意Jackson的ParameterNamesModule或Lombok的@ConstructorProperties
  • 补充:在Java 11中,序列化还要注意:
    • JAXB(Java Architecture for XML Binding)在Java 11中已被移除,如果历史代码中有XML序列化需要额外引入依赖。
    • 使用List.of()等不可变集合时,Jackson 2.10以上版本才能正确反序列化。

问题6:Kafka vs RabbitMQ 保证消息不丢失与防积压

  • 业务背景:支付结果需要异步同步到ES供财务查询。消息队列作为中间缓冲。
  • 选择理由
    • Kafka:吞吐量高,适合日志、事件流、大数据场景。但配置多,保证不丢失需要同时配置生产者和消费者。
    • RabbitMQ:功能丰富,支持ACK、死信、延迟队列,适合业务消息但吞吐量低于Kafka。
  • Kafka不丢失
    • 生产者:acks=all(等待所有副本确认);retries>0(默认Integer.MAX_VALUE);enable.idempotence=true(幂等生产者防止重复写入)。
    • Broker:min.insync.replicas=2,配合replication.factor=3;如果有一个副本挂了,生产者会收到NotEnoughReplicasException
    • 消费者:enable.auto.commit=false,手动提交offset。在业务逻辑处理完后再提交。如果处理失败,则seek到当前记录,重新消费。
  • RabbitMQ防积压
    • 增加消费者数量(提高并发度),一个队列绑定多个消费者。
    • 使用basic.qos控制预取数量(prefetchCount),避免单个消费者累积大量消息。
    • 如果消费速度永久跟不上生产速度,使用**惰性队列(Lazy Queue)**将消息存磁盘,减少内存压力。
    • 监控RabbitMQ Management APImessages_ready数量,超过阈值时动态扩容消费者(K8s HPA)。

第三轮:在线教育场景下的WebFlux、R2DBC与云原生

问题7:WebFlux响应式编程与WebSocket背压

  • 对比 Spring MVC:Spring MVC基于Servlet,每个请求占用一个线程,线程池有限,高并发下容易阻塞。WebFlux基于Reactor(Netty),使用事件循环线程,用少量线程支撑大量连接。编程模型:MVC是命令式同步,WebFlux是声明式异步,返回Mono<T>Flux<T>
  • WebSocket + 背压
    • WebFlux的WebSocketHandler处理消息流:WebSocketMessage作为Flux的一部分。背压是响应式流的核心——消费者通过request(n)告诉生产者每次最多发多少个数据项。
    • 例如直播弹幕场景,用Flux.create(fluxSink -> {...}, FluxSink.OverflowStrategy.BACK_PRESSURE),生成者速率快于消费者时,会通过背压信号反馈。但注意WebSocket协议本身不支持背压,所以只能在应用层模拟:当服务端处理不过来时,可以丢弃非重要消息、合并消息批次、或者降低发送频率。实际中可以用onBackpressureBuffer(1000)onBackpressureDrop()
  • R2DBC与MyBatis本质区别
    • JDBC/MyBatis是同步阻塞的,每个数据库连接在执行SQL时会被占用,直到结果返回。连接池数量有限,线程等待在高并发下会积压。
    • R2DBC是反应式数据库驱动,基于Reactive Streams,非阻塞地执行SQL。不占用线程等待数据库响应,而是当结果到达时回调。
    • 使用R2DBC时,整个链路必须都是响应式的(从Controller到Repository),如果中间用了block(),就会阻塞EventLoop线程,破坏架构。
    • MyBatis也有异步版本(MyBatis Reactive)或结合Spring的@Async,但本质上还是线程池。
  • Flyway / Liquibase
    • 在数据库版本管理层面,Flyway使用版本化SQL脚本(V1__init.sql, V2__add_column.sql)按顺序执行。适合简单快速。
    • Liquibase使用XML/YAML/JSON描述变更,支持自动生成回滚脚本,适合复杂数据库(支持存储过程、视图)。
    • 在R2DBC项目中,Flyway/Liquibase可以在应用启动时连接一个JDBC连接(非R2DBC)执行脚本,因为Flyway本身是同步的。但它们不受WebFlux的非阻塞限制,因为只在启动时运行一次。

问题8:容器化与Kubernetes定位问题

  • Dockerfile示例
    FROM maven:3.8-eclipse-temurin-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:11-jre WORKDIR /app COPY --from=builder /app/target/online-education.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
    • 使用镜像时注意UseContainerSupport让JVM识别容器内存限制。
  • CI/CD GitHub Actions:工作流中构建镜像并推送仓库,然后通过kubectl set image deployment滚动更新。
  • Pod频繁重启排查
    • 先看kubectl describe pod的Events,判断是OOM还是健康检查失败。
    • kubectl logs --previous=true查看上一次退出的日志。
    • Jaeger / Zipkin链路追踪:在Spring Cloud中集成sleuth,给每个请求分配traceId。调用链数据发送到Jaeger UI,搜索traceId,可以看到每个Span(服务调用)的耗时。如果某个服务(如视频转码服务)的Span都显示超时,说明瓶颈在那里。
    • 监控金丝雀发布时,用Prometheus对比新旧版本的P99延迟。

总结

谢飞机在面试中暴露的问题:概念知道但无法深入,尤其是“为什么”和“怎么做”的细节。对于求职Java后端岗位,不仅要知道工具名称,还要理解原理和结合业务设计。面试官的问题环环相扣,从具体场景出发,考察候选人的架构意识和排查能力。

如果能完整回答出以上要点,特别是R2DBC背压、Thrift转OpenFeign的适配器、Kafka幂等生产者配置,那么这份工作基本稳了。而谢飞机,还得回去继续啃《Spring in Action》和《Java并发编程实战》——至少,他记住了面试官的最后一句话:“回去等通知。”