从Spring Boot到Kafka再到JVM调优:电商场景Java高频面试实战记录
面试开场
面试官王工推了推眼镜,看着对面坐着的一位头发略显凌乱、背着双肩包的年轻人——谢飞机。王工打开简历,目光在“熟练掌握微服务架构”一行停了两秒,然后抬起头。
王工:“谢飞机对吧?先做个简单自我介绍。”
谢飞机:“面试官好,我叫谢飞机,花名‘真机’,三年Java经验,主要在电商行业做后端开发,用过Spring Boot、MyBatis、Redis、Kafka这些,平时也看看JVM调优,喜欢研究新技术。”
王工点点头:“好,那咱们开始。今天聊一个实际的电商下单场景。用户从浏览商品、加购物车、提交订单、扣减库存、支付回调到最终发送通知,整个过程涉及很多技术点。我围绕这个场景问你几轮问题。”
第一轮:从Spring Boot到微服务架构
王工:“先从一个最基础的开始。你简历上写熟悉Spring Boot,那你说说,为什么我们一引入spring-boot-starter-web,就能直接写@RestController启动一个Web应用?Spring Boot的自动配置到底是怎么实现的?”
谢飞机:“哦,这个我知道。Spring Boot的自动配置就是通过@EnableAutoConfiguration注解,它会去读META-INF里的spring.factories文件,里面列了很多自动配置类,比如WebMvcAutoConfiguration、RedisAutoConfiguration这些。然后根据一些条件注解,像@ConditionalOnClass,如果classpath下有这个类就加载配置,再通过@Bean生成需要的Bean。这样就不用我们手动写一堆配置文件了。”
王工:“嗯,回答得还可以。那继续说,如果现在电商网站有用户服务、商品服务、订单服务,你作为一个架构师,会怎么拆分?拆分后服务之间怎么调用?”
谢飞机:“这个——我会按照业务域拆分,比如用户服务管用户,商品服务管商品,订单服务管订单。服务间调用可以用Feign,啊,就是OpenFeign,声明式HTTP客户端。也可以发消息队列解耦,比如订单支付成功后发个消息给库存服务。”
王工:“那如果商品服务需要同时调用订单服务和库存服务,而且其中一个服务挂掉了,怎么处理?”
谢飞机:“可以用Resilience4j做熔断降级,或者用Sentinel。Hystrix老旧了,不好用。超时重试,设置线程池隔离,避免服务雪崩。”
王工:“哦?那你详细讲讲Resilience4j的熔断器有哪几种状态?状态转换的条件是什么?”
谢飞机:“状态……有关闭、打开、半开吧?具体怎么转换的……我记得是失败率超过阈值就打开,过一段时间进半开,然后失败率又低就关……”
王工:“咱们接着问下一个。你刚才提到OpenFeign,它能做到负载均衡吗?底层怎么实现的?”
谢飞机:“Feign自带负载均衡,通过Ribbon或者Spring Cloud LoadBalancer,会根据服务名从注册中心拉取实例列表,然后用轮询或者随机算法选一个。核心是接口的@FeignClient注解,动态代理生成实现类,然后发HTTP请求。”
王工:“注册中心你用的什么?Eureka和Consul有什么区别?”
谢飞机:“Eureka是Netflix出的,AP模式,就是保证可用性,牺牲一致性;Consul是CP模式,保证一致性。现在Eureka闭源了,一般不用了,都用Nacos。”
第二轮:数据库、缓存与数据一致性
王工:“好,回到业务。商品详情页访问量非常高,但商品库存是强一致的数据,不能出错。假设你把商品信息放到了Redis缓存里,那你怎么保证缓存和MySQL数据库的一致性?比如运营改了价格,缓存多久更新?”
谢飞机:“这个我熟。用Cache Aside Pattern,就是先更新数据库,再删除缓存。读的时候先查缓存,没查到就查数据库,然后回填缓存。更新的时候先更新数据库,然后删缓存。”
王工:“那如果删除缓存失败了怎么办?缓存里还是旧值。”
谢飞机:“那可以发消息队列,异步重试删缓存。或者设置过期时间,兜底。对,得给缓存设TTL,比如商品价格缓存30分钟,过期后自动拉新。”
王工:“如果有大量请求同时来查一个缓存中没有的数据,比如秒杀爆款,会发生什么?如何解决?”
谢飞机:“会发生缓存穿透,不对——如果是缓存中没有但数据库有,大量请求同时打到数据库,这个叫缓存击穿。解决方法就是加分布式锁,让一个线程去查数据库回填,其他线程等待。还有一个叫缓存穿透,是查询不存在的key,请求直接打数据库,可以用布隆过滤器。缓存雪崩是大量key同时过期,可以给过期时间加随机值。”
王工:“很好,这几个概念区分得很准确。那再聊聊持久层。你项目里用的是MyBatis还是JPA?为什么?”
谢飞机:“用的MyBatis,因为SQL自己写,方便调优,复杂查询好控制。JPA也用过,就是Hibernate,简单CRUD很方便,不用写SQL,但复杂查询和性能优化很麻烦,搞不好N+1查询。”
王工:“那你说说MyBatis的一级缓存和二级缓存。”
谢飞机:“一级缓存在SqlSession里,默认开启,同一个会话里重复查询同一个SQL会命中缓存。二级缓存是namespace级别,可以跨会话,需要手动开启。不过坑很多,有时查出来脏数据,一般不建议用。”
王工:“分布式事务呢?在电商下单时,扣减库存和创建订单必须同时成功或同时失败,你会怎么做?”
谢飞机:“呃,分布式事务……可以用Seata,AT模式。或者用本地消息表加消息队列,最终一致性。两阶段提交太慢,一般不搞。我知道这个知识点,但具体没怎么实操过。”
王工:“那你聊聊Seata的AT模式和TCC模式有什么不同?”
谢飞机:“AT模式是自动生成回滚SQL,对业务侵入小;TCC需要业务实现Try、Confirm、Cancel三个接口,侵入大,但是更灵活。具体的……我就不展开说了。”
第三轮:消息队列、监控与JVM
王工:“OK。订单支付成功后,系统要发Kafka消息通知物流服务、积分服务、短信服务。你怎么保证这个订单消息不丢失、不重复消费?”
谢飞机:“不丢失的话,生产者设置acks=all,把ack设为all,还有重试机制,leader分区副本都写完才算成功。消费者关闭自动提交,业务处理完再手动提交偏移量。不重复消费的话,消费者要做幂等,用订单号做唯一键去查一下,或者用Redis setnx实现幂等。”
王工:“如果消费者处理消息的速度太慢,Kafka里的积压越来越多,你怎么处理?”
谢飞机:“加消费者实例,扩展分区,提高并发度。如果还是不行,可以临时把消息转发到另一个主题,让多个消费者组去处理。也可以写个批量导入程序,把积压的消息直接灌到数据库。”
王工:“那Kafka是如何保证消息的顺序性的?比如同一个订单的‘创建’、‘支付’、‘发货’消息必须按顺序消费。”
谢飞机:“Kafka只能保证同一个分区内有序。所以要把同一个订单的消息放到同一个分区,生产者在发消息时指定订单ID作为key,Kafka会hash到同一个分区。消费者呢,也要单线程消费这个分区,不然多线程处理还是会乱。”
王工:“好。那运维方面,线上JVM频繁Full GC,你会怎么排查?请给出你的思路。”
谢飞机:“Full GC频繁,先看堆内存分配是不是太少了,调大-Xmx。然后要dump堆快照,用jmap或者jvisualvm分析大对象,看是不是内存泄漏。用jstat监控GC日志,看老年代增长情况。还可以用arthas在线分析,heapdump后用MAT工具看哪个对象占用最多。”
王工:“如果是CPU飙到100%呢?”
谢飞机:“用top -Hp找线程ID,然后jstack导出线程快照,搜索nid=0x...转换成十六进制,看是哪个线程在跑,通常是有死循环或者频繁GC。再看代码里有没有大计算。”
王工:“最后问一下,你提到微服务监控。你们系统是怎么做链路追踪和指标监控的?”
谢飞机:“我们用Prometheus收集指标,Grafana展示。链路追踪用Jaeger或者Zipkin,配合Spring Cloud Sleuth。Micrometer可以暴露出JVM、CPU、内存这些指标。日志用ELK汇总,业务日志打印订单ID和traceId,方便排查问题。”
王工:“我看你还写了熟悉Elasticsearch,那你说说ES的倒排索引是什么?”
谢飞机:“倒排索引就是词到文档的映射,比如‘苹果’这个关键词出现在哪些文档里。ES的底层是Lucene,分词之后存储倒排列表,查询的时候直接查词条,然后合并文档ID。比关系型数据库的like查询快很多。”
王工:“嗯。那你对大数据这块了解吗?比如Spark和Flink区别?”
谢飞机:“Spark是批处理,但也能做微批流处理;Flink是真正的流处理,延迟更低,支持事件时间、窗口计算,状态管理更好。现在做实时数仓一般用Flink。”
王工:“好吧。最后一个问题,你简历里写了会用Lombok,那@Data注解生成equals和hashCode有什么坑?在JPA实体里使用会有什么问题?”
谢飞机:“额,这个……@Data会生成equals和hashCode,如果实体里有关联对象,比如多对多,会导致循环调用?还有,如果用了@Data,在放到HashSet或者HashMap时,如果有懒加载代理可能比较奇怪。具体……我一般实体用@Getter和@Setter,不用@Data,但为什么我也不太清楚。”
王工:“好的。我这边问题问完了。你还有什么要问我的吗?”
谢飞机:“我想问一下咱们团队的技术栈是什么?用的Spring Cloud Alibaba还是原生Spring Cloud?”
王工:“我们主要用Spring Boot和Spring Cloud Alibaba,K8s部署。我们有Nacos、Seata、Sentinel这些。”
谢飞机:“哦,那挺好的,JVM调优这块我其实很感兴趣……”
王工:“好的。今天面试到这里。你先回去等通知吧。”
谢飞机:“谢谢面试官!再见!”
附:三轮面试问题详细答案与知识点讲解
第一轮:Spring Boot自动配置、微服务拆分、服务间调用、熔断限流、OpenFeign、注册中心
1. Spring Boot 自动配置原理
核心:
@SpringBootApplication包含@EnableAutoConfiguration注解。@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class)导入选择器。AutoConfigurationImportSelector会扫描所有META-INF/spring.factories或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中声明的自动配置类。- 自动配置类通常使用条件注解:
@ConditionalOnClass:类路径存在指定的类才生效,如@ConditionalOnClass(DispatcherServlet.class)。@ConditionalOnMissingBean:容器中没有指定的 Bean 才生效,允许用户覆盖。@ConditionalOnProperty:配置项满足条件才生效。
- 自动配置类通过
@Bean创建组件并设置默认属性,属性的前缀由@EnableConfigurationProperties配合@ConfigurationProperties(prefix = "server")绑定。
示例:
用户引入 spring-boot-starter-web,类路径下存在 Servlet 和 DispatcherServlet,自动配置类 DispatcherServletAutoConfiguration 生效,创建一个 DispatcherServlet 并注册到内嵌 Tomcat。用户只需写一个 @RestController 就能提供 HTTP 接口。
2. 微服务的拆分原则与服务间调用
拆分原则:
- 按业务域拆分:用户、商品、订单、库存、支付等,遵循高内聚低耦合。
- 按数据拆分:每个服务拥有独立的数据库,避免单库单表瓶颈。
- 按团队组织拆分:小团队负责自己的服务。
- 按事务边界拆分:强一致性的业务尽量放在一个服务内,最终一致的跨服务。
服务间调用方式:
- 同步调用:OpenFeign / RestTemplate / Dubbo(RPC)。
- 异步调用:Kafka / RabbitMQ 等消息队列。
- 响应式调用:Spring WebFlux + R2DBC(非阻塞)。
3. 服务容错:Hystrix / Resilience4j / Sentinel
当商品服务调用库存服务超时或异常时,不能无限等待,否则会耗尽线程资源,导致服务雪崩。使用熔断器模式:
- 关闭(Closed):正常情况下调用被放行。
- 打开(Open):当失败率超过阈值(如 5 秒内 50% 请求失败),熔断器打开,直接返回 fallback,不再调用下游。
- 半开(Half-Open):熔断器打开后经过等待时间(如 10 秒),放一个探测请求,若成功则关闭熔断器;若失败则重新打开。
Resilience4j 基于 Java 2 库实现,支持熔断器 CircuitBreaker、限流 RateLimiter、隔离 Bulkhead、重试 Retry 和降级 FallbackDecorator。
4. OpenFeign 与负载均衡
OpenFeign 是一个声明式 HTTP 客户端,开发者定义接口并添加 @FeignClient(name = "order-service"),Spring 通过动态代理生成实现类。方法上的注解如 @GetMapping("/order/{id}") 被解析为 HTTP 请求。
负载均衡由 Spring Cloud LoadBalancer(旧版使用 Netflix Ribbon)实现。调用流程:
- 从服务注册中心(Nacos/Consul/Eureka)获取
order-service的实例列表。 - 根据负载均衡策略(轮询、随机、权重等)选择一个实例的 IP 和端口。
- 将被调用方法的参数序列化为 JSON/HTTP 请求发送到目标实例。
5. 注册中心:Eureka 与 Consul 的 CAP 区别
- Eureka:AP 设计,保证高可用、分区容错性。Eureka 节点之间互相注册,不互相复制数据,即使部分节点挂掉也能自愈。缺点是数据最终一致,可能读到过期服务列表。已经停止维护(2.x 未发布)。
- Consul:CP 设计,保证强一致性。基于 Raft 协议,需要选主,挂掉部分节点后还能继续工作,但分区时无法写入。提供健康检查和 KV 存储,原生支持多数据中心。
- 现在主流使用 Nacos,既能做 AP 又能做 CP,同时提供配置管理。
第二轮:缓存一致性、缓存穿透/击穿/雪崩、MyBatis与JPA、分布式事务
1. 缓存与数据库的一致性(Cache Aside Pattern)
核心操作:
- 读请求:先读缓存;缓存不命中则查数据库,然后写缓存并设置过期时间。
- 写请求:先更新数据库,然后删除缓存。注意不是更新缓存,而是删除缓存,因为更新缓存的并发问题更难处理。
为什么删除缓存而不是更新缓存?
- 更新缓存需要复杂计算,且可能立即被新查询覆盖。
- 删除缓存后,下次读会主动回填,保证数据最终一致。
删除缓存失败怎么办?
- 引入重试机制:发送 MQ 消息,后台任务消费并重试删除缓存。
- 设置过期时间兜底,例如商品价格缓存 TTL=10 分钟,最多不一致 10 分钟。
- 更完善的方案是使用 Canal 监听 MySQL 的 binlog,把数据变更事件投递到 MQ,由消费者同步到 Redis/ES。
2. 缓存穿透、缓存击穿、缓存雪崩
- 缓存穿透:查询一个不存在的 key,缓存没有,数据库也没有。攻击者发送大量不存在 ID 的请求,导致数据库压力过大。
- 解决:布隆过滤器(BluonFilter)判断 ID 是否存在;将空值缓存到 Redis(如
key=null并发 30s)。
- 解决:布隆过滤器(BluonFilter)判断 ID 是否存在;将空值缓存到 Redis(如
- 缓存击穿:一个 hot key 在过期瞬间,大量请求同时发现缓存失效,一起打到数据库。
- 解决:分布式锁(如 Redis setnx + Redisson),只让一个线程去数据库查询并回填,其他线程等待;或者设置逻辑过期时间(value 里存过期时间,异步刷新)。
- 缓存雪崩:大量 key 在同一时间过期,或 Redis 宕机。
- 解决:过期时间加随机值,防止同一时间失效;缓存高可用(哨兵/集群);多级缓存(本地 Caffeine + Redis);接口限流降级。
3. MyBatis 与 JPA 的对比
- MyBatis:
- 半自动 ORM,SQL 由开发者编写,灵活性高,容易调优。
- 适合复杂查询、多表关联、存储过程。
- 一级缓存:
SqlSession级别,默认开启,同一会话中相同 SQL 和参数会命中缓存。注意使用@Transactional等场景下会话生命周期。 - 二级缓存:
Mapper命名空间级别,可跨会话,需要<cache/>开启。由于不同表操作可能导致缓存脏数据,一般不推荐在分布式环境下使用。
- JPA / Hibernate:
- 全自动 ORM,基于实体关系映射,提供 JPQL 和 Criteria API。
- 简单 CRUD 开发效率高,无需写 SQL。
- 容易产生 N+1 查询问题(默认懒加载),需要
@EntityGraph或join fetch优化。 - 缓存有一级缓存(持久化上下文)、二级缓存(可选,需要第三方实现如 Ehcache)。
选择:电商项目如果以复杂报表、动态SQL为主,选 MyBatis;如果业务模型简单、以 CRUD 为主,可以用 Spring Data JPA。
4. 分布式事务:Seata AT 与 TCC
在微服务环境下,本地事务无法跨服务提交。常用方案:
- 两阶段提交(2PC):强一致,但同步阻塞、协调者单点,性能差。
- TCC(Try-Confirm-Cancel):补偿事务,需要写三个方法,侵入性大。
- 本地消息表:事务和消息写入同一个数据库,通过消息队列通知下游,最终一致。
- Seata AT 模式:自动生成 undo_log,通过全局锁和两阶段协议完成提交/回滚,对业务代码侵入小。原理:
- 第一阶段:业务 SQL 直接执行,同时生成前后镜像数据,写入 undo_log,并提交本地事务。
- 第二阶段:全局提交,删除 undo_log;全局回滚,使用 undo_log 中镜像数据执行补偿 SQL 恢复原值。
TCC 模式:
- Try:尝试执行业务,如冻结库存。
- Confirm:确认执行业务,如扣减冻结库存。
- Cancel:回滚执行业务,如释放冻结库存。
- 需要实现幂等性,适合长事务场景。
第三轮:Kafka 消息可靠性、顺序性、消息积压、JVM 排查、监控链路
1. Kafka 消息不丢失
- 生产者端:
acks=all:所有 ISR 副本都收到消息才返回成功。retries:重试次数,大于 0,同时设置max.in.flight.requests.per.connection等参数避免乱序。- 使用带回调的发送 API(
producer.send(record, callback))检查异常。
- Broker 端:
min.insync.replicas:设置 ISR 最小副本数,如 2,防止 leader 单独存活。replication.factor:副本数至少 3。
- 消费者端:
- 禁止自动提交
enable.auto.commit=false。 - 在业务处理成功后才手动提交偏移量
consumer.commitSync()。
- 禁止自动提交
2. 消息不重复消费与幂等
重复消费的常见原因:消费者处理完业务,但未提交 offset 就崩溃,重启后从旧 offset 重新消费。
解决方法(幂等):
- 给消息携带全局唯一业务 ID(如订单号、事务 ID)。
- 消费端在业务处理前查询该 ID 是否已存在(如数据库唯一索引),已存在则直接 ACK。
- 使用 Redis
SETNX将业务 ID 作为 key,设置过期时间,成功设置为 1 的才处理,否则跳过。 - 对于数据库写入操作,使用唯一约束,重复插入会报错或被忽略。
3. Kafka 如何保证消息顺序
Kafka 只能保证分区内有序,不能保证跨分区有序。要保证同一订单的事件有序:
- 生产者将订单 ID 作为 key 发往主题:
producer.send(new ProducerRecord<>(topic, orderId, event))。Kafka 使用 key 的哈希值对分区数取模,使相同 key 的消息进入同一分区。 - 分区内有序:Kafka 的分布式日志写入时追加,消费者单线程消费分区即可。
但注意:如果消费者开了多线程同时处理同一个分区的消息,顺序会被打破。所以需要调整线程模型,比如一个分区只对应一个处理线程,或者内部按 key 哈希到单独队列。
4. 消息积压的处理
- 提升消费者消费能力:
- 增加消费者实例,但消费者实例数不能超过分区数,否则多出的消费者空闲。
- 增加分区数,但会改变顺序性,需要谨慎。
- 提升单条消息处理速度,优化 SQL、减少 RPC 调用。
- 临时应急:
- 将消息转发到新的临时主题(扩容 10 倍分区),用多个消费者组并行消费。
- 下线非核心业务,先保障核心消息处理。
- 如果消息重要,可以考虑给消费者增加批量处理能力。
5. JVM Full GC 频繁排查思路
步骤:
- 查看 JVM 参数:
jcmd <pid> VM.flags,确认堆大小、GC 器类型。 - 获取 GC 日志:
jstat -gcutil <pid> 1000,观察 O、FGC、FGCT。 - 如果老年代(O)持续增长且 Full GC 频繁,说明有大对象或内存泄漏。
- 导出堆快照:
jmap -dump:format=b,file=heap.hprof <pid>。 - 使用 Eclipes MAT 或 VisualVM 分析堆快照:
- 查看支配树,找到最大的对象。
- 查看 GC Roots 引用链,定位泄漏点。
- 检查代码中的静态集合类(如
static List)、未关闭的连接、ThreadLocal 未清理、大 List 缓存等。
6. CPU 100% 排查
步骤:
top -Hp <pid>找到 CPU 占用最高的线程 ID,记为 nid(十进制)。- 将 nid 转换为十六进制:
printf "%x\n" <nid>。 jstack <pid> > thread.log,在文件中搜索nid=0x,找到对应线程的栈信息。- 分析栈顶代码,通常有几种情况:
- 业务死循环(如
while(true)) - 频繁 GC(GC 线程占 CPU)
- 正则在正则表达式回溯
- 锁竞争(轻量级锁/重量级锁自旋)
- 业务死循环(如
- 使用 arthas:
thread -n 3直接查看最忙碌线程,watch命令观察方法耗时。
7. 微服务监控与链路追踪
技术栈:
- 指标监控:Micrometer 作为门面,暴露
/actuator/prometheus端点,Prometheus 定时拉取指标,Grafana 可视化展示 JVM 内存、CPU、QPS、响应时间、线程池等。 - 链路追踪:Spring Cloud Sleuth 生成 traceId 和 spanId,集成 Jaeger 或 Zipkin。每个 HTTP 请求经过网关、服务、数据库时都带上 traceId,入 Elasticsearch 或 S3,通过 UI 查看调用链,定位耗时瓶颈。
- 日志聚合:ELK(Elasticsearch + Logback + Kibana)。在日志中打印 traceId,通过 traceId 关联全链路日志。
- 告警:Prometheus Alertmanager 根据规则发短信/邮件。
8. Elasticsearch 倒排索引
- 文档:一行 JSON,包含多个字段。
- 索引:类似数据库的库/表,但实际是 Lucene 的倒排索引文件。
- 分词:文本字段(
text)被分析器切分成词条(Term)。 - 倒排表:记录每个词条出现在哪些文档(DocID)及词频(TF)。
- 查询时,对查询词同样分词,然后在倒排表中查找词条对应的文档列表,返回相关度最高的结果。
- 优点:避免全表扫描,适合海量数据的搜索、筛选、聚合。
9. Spark 与 Flink 的区别
- Spark:以批处理为核心,将流数据切分为微批次(微批处理),延迟通常为几秒。适合离线 ETL、复杂数据分析。
- Flink:以流处理为核心,数据一来就处理,支持事件时间、窗口、状态管理、精确一次语义。延迟毫秒级,适合实时指标、实时数仓、风控。
- 典型场景:电商大屏的实时 GMV 用 Flink;离线报表用 Spark SQL。
10. Lombok @Data 在 JPA 实体中的坑
@Data生成equals()和hashCode(),默认包含所有非静态字段。如果两个代理对象都是同一条记录,但一个字段还没加载(懒加载),可能因为访问字段触发懒加载初始化导致异常。- 当实体关联集合时(如
@OneToMany),如果使用@Data,hashCode()可能会访问关联集合,导致循环调用或性能问题。 - 在 Hibernate 中,实体被代理后,字段访问方法可能被覆盖,Lombok 生成的代码基于字段直接操作,容易绕过代理,产生不一致。
- 规范做法:
- 实体类使用
@Getter和@Setter,避免@Data。 - 如果需要业务相等性,自己使用唯一业务键(如 ID)实现
equals()和hashCode()。 - 避免在
hashCode中引用集合字段或懒加载字段。
- 实体类使用
面试结束,谢飞机走出大门,手心全是汗。虽然有些问题回答得模棱两可,但他知道自己已经尽力了。回家等通知的这句话,既有一线希望,也可能意味着结束。不过没关系,他把这篇面试实录背熟之后,至少下一次,能够答得比这次更硬一点。

200

被折叠的 条评论
为什么被折叠?



