电商大促场景下的Java面试:从JVM调优到微服务熔断,谢飞机硬扛三连问

📅 2026/8/14 10:58:54
电商大促场景下的Java面试:从JVM调优到微服务熔断,谢飞机硬扛三连问
电商大促场景下的Java面试从JVM调优到微服务熔断谢飞机硬扛三连问周二下午某互联网大厂面试间。面试官老张戴着黑框眼镜面前放着一台笔记本电脑屏幕上打开着一份简历。对面坐着一位穿着格子衫、头发略显凌乱的年轻人名叫谢飞机自我介绍说“擅长Java SE、Spring Boot、Redis和Kafka熟悉高并发场景”。老张微微点头心里盘算着就按电商大促的链路来问吧。第一轮从商品查询到JVM与缓存面试官老张先聊点热身的。假设我们正在做电商大促商品详情页的QPS瞬间冲到两万。你负责的商品服务用的是Spring Boot 2.7 Java 11数据库是MySQLRedis做缓存。那你先说说JVM在这么大的流量下年轻代和老年代大概会发生什么谢飞机擦了擦汗呃……这个我知道年轻代会频繁Minor GC因为新创建的对象特别多比如用户请求产生的商品DTO、订单预创建对象都朝年轻代扔。如果存活对象多就会晋升到老年代。Full GC次数可能会变多因为老年代也可能被占满。面试官老张嗯有点意思。那如果Young GC频繁而且每次回收后存活对象很多说明什么谢飞机说明……说明Eden区太小或者晋升阈值设置得不合理还有可能就是有对象被错误地提前晋升比如大对象直接进老年代还有可能是缓存了局部变量导致……声音越来越小面试官老张行算你半个对。那Redis这边大促时缓存穿透、击穿、雪崩你分别怎么处理谢飞机这个我会穿透用布隆过滤器或者缓存空值击穿用互斥锁或者逻辑过期雪崩就是加随机过期时间然后做多级缓存。还有……可以限流降级。面试官老张不错这些基础算过关。那如果MySQL已经扛不住读压力你又不想引入太多中间件最简单的方案是什么谢飞机那就是读写分离主库写从库读然后引入HikariCP连接池调一下最大连接数比如设成100再用Spring Data JDBC或者MyBatis做分页查询。对了还可以用Caffeine做本地缓存跟Redis组成二级缓存。面试官老张微微颔首好第一轮算你过了。我们进入第二轮。第二轮从下单链路到消息队列与分布式事务面试官老张大促时用户点“立即购买”后端调用订单服务、库存服务、优惠券服务。你用Spring Cloud OpenFeign做远程调用三个服务之间还涉及扣库存、扣优惠券、生成订单。如果库存扣减成功但订单生成超时了你怎么保证数据一致谢飞机眼睛一亮这个……用分布式事务SeataAT模式呃也可以是消息队列做最终一致性。先扣库存然后发Kafka消息订单服务消费消息创建订单失败就重试。面试官老张Kafka消息如果重复消费怎么办比如订单服务消费到一半宕机了重启后再次消费同一条消息。谢飞机用幂等可以建一个消息处理记录表用消息ID做唯一索引处理前先insert如果冲突说明已处理。或者用Redis SETNX反正保证唯一。面试官老张那Kafka本身的消息可靠性呢你怎么保证不丢消息谢飞机生产者开acksall消费者关掉自动提交改成手动提交offset。然后Broker的replication-factor设成3min.insync.replicas设成2这样就不会丢。面试官老张好。那如果下单高峰期Kafka堆积了100万条消息消费者完全消费不过来你有什么办法谢飞机加消费者但注意消费组里的分区数如果分区不够加消费者也没用所以得提前把Kafka分区数量设置大一点比如设置成24个或48个。另外还可以临时扩容消费者实例再不行就搞个“降级”开关把非核心消息先丢弃或延迟处理。面试官老张嗯总算有点架构意识。那再问一个——订单支付成功后要通知物流系统你用了WebSocket给用户推送配送进度。这WebSocket在网关层怎么做连接保持如果服务端宕机用户连接断了怎么办谢飞机可以用Netty做长连接或者Spring WebFlux的WebSocket支持。断开的话……前端会自动重连然后服务端要记录session重新连接时通过用户ID找到上次的状态。如果服务端多实例用Redis Pub/Sub广播消息。面试官老张可以。第二轮就这样。下面是最后一轮来点硬核的。第三轮从全链路监控到JVM故障排查再到云原生部署面试官老张大促压测时你发现某个接口P99延迟从50ms涨到了800ms。你怀疑是数据库慢查询也可能是Redis连接池不够用或者是某个依赖的第三方服务变慢了。你怎么快速定位谢飞机先看监控上Prometheus和Grafana查JVM的GC时间、CPU、内存再用Micrometer暴露指标。然后看链路追踪用Jaeger或Zipkin看调用链路哪个Span耗时长就是哪个节点的问题。面试官老张如果链路追踪显示订单服务调用库存服务耗时300ms但库存服务CPU很低你觉得可能是什么原因谢飞机呃……可能是网络或者是库存服务连接数据库的线程池满了也有可能是HikariCP的连接池被拿光了导致线程等待。面试官老张那你怎么查看HikariCP的等待时间谢飞机可以看日志或者通过Actuator的metrics端点查看hikaricp.connection.timeout和acquire等指标。嗯还有actuator的health。面试官老张好。接下来假设线上JVM突然Full GC频繁你用jstack和jmap怎么看谢飞机jstack看看线程堆栈有没有BLOCKED或者WAITINGjmap -heap看堆内存然后jmap -dump导出堆转储文件用MAT分析。如果发现老年代里有巨大的byte[]或者ConcurrentHashMap那可能是缓存没控制好。面试官老张嗯那最后问个部署相关的。你们的服务用Docker容器化用Kubernetes部署假如一台上线的Pod因为OOM被KilledK8s会不会自动重启它谢飞机会因为Deployment的restartPolicy是AlwaysPod崩溃后Kubelet会重启容器。但如果是OOMKilled说明容器内存超过limit了重启后还会再次OOM。所以要看JVM的内存设置得把堆内存限制到容器limit的75%左右而且要设置XX:MaxRAMPercentage。面试官老张很好最后一个问题如果大促期间你需要紧急下线某台有问题的节点但不影响其他节点在K8s里怎么操作谢飞机先kubectl cordon node停止调度然后kubectl drain node把上面的Pod驱离同时保证应用有优雅停机Spring Boot要开启shutdown graceful并且配上等待时间。面试官老张合上电脑谢飞机你虽然有些地方答得含糊但整体思路是有的。这样你先回去等通知吧我们后面还有几轮交叉面。谢飞机心里一凉好的好的那我回去等通知谢谢面试官答案解析面试官背后的技术点与小白的查漏补缺第一轮详解JVM与Redis高并发基础1. JVM年轻代和老年代在大促场景下的表现大促流量下大量请求对象如Controller中的Request、Service中创建的业务对象、MyBatis返回的ResultSet都在新生代Eden区分配。Eden区很快被填满触发Minor GCYoung GC。Minor GC把存活对象复制到S0/S1同时把年龄达到阈值默认15的对象晋升到老年代。如果瞬时创建对象过多且存活对象超过Survivor区容量会通过“分配担保”直接把对象晋升到老年代。这会导致老年代快速增长随后Full GC频率上升。排查该问题通常调整JVM参数Xmx和Xms设置为等值避免动态扩容使用G1收集器并调整MaxGCPauseMillis目标使用-XX:SurvivorRatio8控制Eden和Survivor比例使用-XX:MaxTenuringThreshold控制晋升年龄。另外如果代码中频繁创建大对象如byte[]传输文件可直接进入老年代需用-XX:PretenureSizeThreshold限制。2. 缓存穿透、击穿、雪崩穿透查询一个不存在的数据缓存没有数据库也没有。解决方案布隆过滤器BloomFilter先将可能存在的数据特征放入过滤器过滤掉非法key或者缓存空值即使数据库没有结果也缓存设置较短的过期时间。击穿某个热点key过期瞬间大量请求同时打到DB。解决方案互斥锁如Redis SETNX只允许一个线程回源DB其他线程等待或者逻辑过期即value中存储过期时间后台异步刷新但可能短暂读到旧数据。雪崩大量key在同一时间过期导致集体打向DB。解决方案给每个key的过期时间加随机值比如1-5分钟随机使用多级缓存本地Caffeine Redis开启限流降级比如Sentinel。3. HikariCP连接池与读写分离HikariCP是Spring Boot默认连接池性能极佳。核心参数maximumPoolSize控制最大连接数不是越大越好因为每个连接都占用数据库资源。通常设置为((核心线程数 * 2) 有效磁盘IO数)。在大促场景下可以把连接数设置合理并监控connectionTimeout获取连接超时时间、idleTimeout等。为了分担读压力MySQL使用主从复制主库写从库读。Spring Boot可以配置多个数据源或者使用Spring Data JDBC的ReadOnly。第二轮详解分布式事务、消息可靠性、WebSocket1. 分布式事务与最终一致性在微服务架构下下单链路涉及多个服务直接使用本地事务无法跨服务保证原子性。常见方案2PC/XA强一致但性能差不常用。TCCTry-Confirm-Cancel业务侵入强比如预扣库存资源。Seata AT模式对业务无侵入自动生成undo-log适合中小型团队。消息队列最终一致性将核心操作如扣库存提交本地事务同时发送一条Kafka消息。下游订单服务消费消息自己执行创建订单如果失败则重试或进入死信队列。面试中要突出“可靠消息”的两个方面生产者消息不丢 消费者消息不丢。生产端用acksall即等待所有副本都确认消费端手动提交offset业务处理成功后再提交。2. 幂等性设计重复消费是消息队列中常见问题。解决方案数据库唯一约束例如使用biz_id业务ID作为唯一索引消费时插入记录如果冲突则跳过。Redis SETNX利用SET key value NX只有第一次设置成功才算消费。状态机订单状态从“待支付”到“已支付”如果重复消费发现已经是“已支付”则直接返回。3. Kafka积压处理当消费速度跟不上生产速度会形成积压。解决思路提高消费并行度增加Topic分区数分区数决定消费者组内的最大并发数增加消费者实例数量。优化消费逻辑把非核心步骤异步化、批量处理。如果积压严重可以临时让消费者不处理耗时的业务只记录消息落库后面再补流程。或者采用“降级”直接把部分消息丢弃或延期处理优先保障核心链路。4. WebSocket在微服务中的问题WebSocket是长连接适合服务端主动推送物流进度场景。但是多实例部署时用户连接可能挂在A实例而消息发到B实例导致收不到。解决方案sticky session负载均衡粘滞会话不推荐。使用Redis Pub/Sub广播所有实例订阅同一个频道发布消息时所有实例都能收到然后从本地session中找到目标用户连接并推送。如果用户连接断线前端WebSocket监听onclose事件后自动重连。服务端需要存储“用户ID-token映射”重连后使用新连接替换旧连接。Spring WebFlux支持响应式WebSocket底层使用Netty对高并发更具优势。第三轮详解监控、JVM调优、K8s部署1. 如何快速定位接口延迟高首先要有可观测性体系指标Metrics通过Micrometer暴露JVM、连接池、接口调用量、延迟分布Prometheus采集Grafana展示。链路追踪Tracing使用Jaeger或Zipkin在每个服务间传递traceId可视化调用链查看每个Span的耗时。日志LoggingELK Stack集中采集日志按traceId聚合。当P99升高时先看Grafana里的数据库慢查询指标如果MySQL无慢查询再看Redis连接池是否acquire等待时间过长如果都是正常则看链路追踪确定耗时最长的Span在哪个服务。2. 线程池耗尽与连接池耗尽HikariCP默认maximumPoolSize10如果超过10个线程同时等待连接则其他请求排队。connectionTimeout默认30秒等待超时就抛异常。可以通过Actuator的/actuator/metrics/hikaricp.connections.pending查看等待数。如果pending不为0说明连接被耗尽。类似地Tomcat线程池server.tomcat.threads.max默认200若全部忙于等待数据库连接也会很快打满。解决办法调大HikariCP连接数使用Transactional时避免持有数据库连接做远程调用开启读写分离。3. jstack、jmap与JVM调优jstack打印Java进程的线程栈查找java.lang.Thread.State: BLOCKED、WAITING、TIMED_WAITING。如果大量线程阻塞在LockSupport.park或ThreadPoolExecutor.getTask上说明业务线程处于空闲或等待锁。jmap -heap查看堆内存各代使用情况。jmap -dump:formatb,fileheap.bin导出堆转储用MATMemory Analyzer Tool分析。一个常见的OOM场景byte[]占用过多通常是读文件、网络包、缓存未设置大小。Full GC频繁重点检查老年代是否被Collections静态容器缓存撑爆或者是否加载了过多ClassMetaspace问题。JVM调优参数示例在容器中java -jar app.jar \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -Xms4g -Xmx4g \ -XX:MaxRAMPercentage75.0 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/logs注意容器内不要使用-Xmx指定绝对最大堆而应使用-XX:MaxRAMPercentage根据CPU限制自动计算堆大小。4. Kubernetes Pod OOM与优雅停机容器运行Java应用时如果JVM堆内存配置大于Pod的limits.memory容器会被内核OOM Killer杀死。Kubelet检测到容器退出后按restartPolicy重启容器。OOMKilled会产生137退出码诊断方法是kubectl describe pod查看Last State以及容器退出原因。为了避免OOMJVM堆上限应该为容器内存limit的70%-80%比如limit为2Gi则-Xmx1536m约75%。优雅停机在Spring Boot中配置spring: lifecycle: timeout-per-shutdown-phase: 30s server: shutdown: gracefulK8s在删除Pod前先发送SIGTERM给容器Spring Boot接收到后不再接受新请求通过Spring Cloud的shutdown hook然后等待在途请求完成最多等30秒超时则强杀。K8s的terminationGracePeriodSeconds也需要配合设置如40秒。5. kubectl cordon / drain大促准备阶段如果有节点异常可以用kubectl cordon node-xxx将其标记为不可调度然后kubectl drain node-xxx --ignore-daemonsets --delete-emptydir-data驱逐Pod。被驱逐的Pod会按照Pod所在Deployment的重建策略在其他节点重新创建。操作前应确保应用是Deployment或StatefulSet且有多副本。如果Pod使用了本地存储emptyDir驱逐会丢失数据所以--delete-emptydir-data要谨慎使用。谢飞机最终回家等通知。但他心里知道自己虽然偶尔犯水但面试官其实在提示他把答案背后的原理再深入一层就能拿到Offer。他把所有问题整理成上方的笔记准备下一次再战。这次他决定把G1的日志、Kafka的副本协同、Spring WebFlux的背压机制、K8s的调度原理都彻底搞懂再去面试。