微服务面试题 - 2026-06-19 18:24
生成时间:2026-06-19 18:24 | 共 10 题 | 难度递进(基础→进阶→高阶)
【面试题】1. 雪花算法(Snowflake)的 64 位 ID 结构是怎么划分的?时钟回拨问题到底是什么、有哪些解法?
【标准答案】雪花算法把 64 位的 Long 切成五段:第 1 位保留(始终为 0 保证正数)+ 41 位毫秒时间戳(能用 69 年)+ 10 位工作机器 ID(5 位数据中心 + 5 位机器 ID,支持 1024 节点)+ 12 位序列号(单毫秒内 4096 个 ID)。所以一台机器一毫秒最多产 4096 个 ID,全局趋势递增但不严格递增——因为不同机器 ID 不同,后生成的 ID 可能比先生成的小。时钟回拨是雪花算法的阿克琉斯之踵:NTP 校时把机器时钟往前拨然后又拨回,时间回到了过去→同一毫秒、同一机器、同一序列号→产生重复 ID,唯一性就崩了。三种解法:一是抛异常等时钟追上——美团 Leaf 的默认策略,回拨超 5ms 就拒绝服务等 NTP 校准回来;二是用历史最大时间戳兜底——雪花算法内部记录 lastTimestamp,发现回拨就用 lastTimestamp + 1 继续生成,这在分布式环境下生成趋势上的"未来时间"耗时但不重复;三是把 10 位机器位拆一部分做回拨计数器——百度 UidGenerator 的 CachedUidGenerator,用 RingBuffer 预生成 ID,校验规则为过去时间生成的 ID 直接丢弃。面试一句:雪花算法最优美的地方是"去中心化单机自增",最尴尬的地方是整个方案依赖"时间单向流动"——物理世界不跟你讲这个道理。
【面试题】2. SkyWalking 里的 Span、Trace 和 Segment 分别是什么关系?一次 RPC 调用在 SkyWalking 界面上能看到哪些信息?
【标准答案】三者是俄罗斯套娃关系。Trace——一次完整的用户请求链路,从网关 → A 服务 → B 服务 → 数据库的全路径。一个 Trace 由一个全局唯一的 TraceId 串起来。Segment——一个 Trace 落在单个 JVM 进程里的那一段。A 服务被调 → A 再调 B → A 返回结果,A 服务这整段就是一个 Segment,由一个 SegmentId 标识。Span——Segment 里每一步操作都是一个 Span。一次 HTTP 接收是 EntrySpan、一次 HTTP 调用下游是 ExitSpan、一次本地方法执行是 LocalSpan。关系一句话:Trace = 多个 Segment(一个服务一段),Segment = 多个 Span(一个操作一跨)。SkyWalking 界面上能看到的核心信息:拓扑图——各服务间调用关系和健康状态(红黄绿);调用链路瀑布图——每个 Span 的开始时间、耗时、执行状态;慢 SQL 自动抓取——哪条 SQL 参数是啥、耗时多少;JVM 指标——堆内存、GC 次数、线程数自动采集。面试亮点:SkyWalking 采用 Java Agent 字节码增强零代码侵入——你的业务代码不用加一行埋点代码,启动时 -javaagent:skywalking-agent.jar 就全自动采集,这是它比 Zipkin 手动埋点强得多的工程优势。
【面试题】3. Spring Cloud Gateway 和 Zuul 1.x 的性能差距根源在哪?Zuul 2.x 改进后为什么还是没干过 Gateway?
【标准答案】性能差距的本质是"同步阻塞"和"异步非阻塞"的根本差异。Zuul 1.x 基于 Servlet 2.5——每个请求一个线程,线程在等下游响应的过程中全程阻塞。1000 个并发请求就需要 1000 个线程,线程上下文切换把 CPU 全吃了——吞吐量上不去。Gateway 基于 Spring WebFlux(Reactor + Netty)——一个线程池里少量线程(等于 CPU 核数),请求来了注册一个回调就去处理下一个请求,下游响应到了回调触发继续处理。线程数不随并发数线性增长,所以 QPS 能到 Zuul 1.x 的 2~3 倍。Zuul 2.x 用 Netty 重写了内核也支持异步了,为啥还是没干过 Gateway?一是出生太晚——Gateway 已经是 Spring 官方第二代网关,Spring Cloud 全家桶原生集成、文档和社区远超 Zuul 2.x;二是 Zuul 2.x 的 API 设计还是 Netflix 内部风格,Filter 的类型(Inbound/Endpoint/Outbound)概念重、学习曲线陡,Gateway 用 Java 8 Lambda 风格的 Predicate + Filter + Route 三件套简洁得多。面试定音:Gateway 赢不是因为技术上的不可替代性,而是 Spring 生态的"官方嫡系"优势——配置一个 spring.cloud.gateway.routes 就能跑,排查问题时搜中文博客全是 Gateway 的,这就够了。
【面试题】4. SAGA 分布式事务模式和 TCC 有什么区别?SAGA 的两种协调方式——编排(Choreography)和编排(Orchestration)各适合什么场景?
【标准答案】两者的核心区别:TCC 是"先预留再确认"——两阶段操作,一阶段 Try 只冻结资源不放锁,二阶段 Confirm/Cancel 决定提交或回滚,适合能预留资源的场景(余额冻结、库存预占)。SAGA 是"做了就做了,错了就补偿"——一阶段各服务直接提交本地事务(无预留阶段),如果后续步骤失败,调前面所有成功步骤的补偿操作(CancelOrder/RefundPayment)。SAGA 适合长流程且不可预留的场景——比如机票预订 5 步(下单→支付→出票→发短信→攒积分),你不会先"冻结一个座位"再确认,你会直接出票、出票失败再退款取消。SAGA 两种协调方式:编排(Choreography)——去中心化,每个服务做完自己的事就发事件,下一个服务监听事件自动触发,链式推进。优点是松耦合没单点,缺点是业务流程散落在各服务的监听器里,PM 问"下单完整流程是啥样"你要翻 5 个工程的代码才能拼出来。编排(Orchestration)——中心化,有一个 Saga 协调器(OrderSagaOrchestrator)按顺序指挥各服务:先发"支付"→支付成功→发"出票",哪个步骤失败协调器反向调补偿。优点是业务流程清晰一串代码可见,缺点是协调器是单点。面试定音:简单 3~4 步流程用 Choreography(事件驱动轻量级),复杂长流程(电商下单 10+ 步)用 Orchestration(流程可视化 + 补偿可控)。
【面试题】5. Sentinel 的线程池隔离和信号量隔离有什么区别?为什么 Sentinel 默认用信号量而 Netflix Hystrix 推崇线程池?
【标准答案】本质区别:线程池隔离——给每个下游服务分配独立线程池(比如订单服务调支付服务用 pay-thread-pool、调积分服务用 point-thread-pool),调用下游时把任务提交到对应线程池执行。好处是物理隔离——支付线程池满了不影响积分调用;代价是线程开销大——每增加一个下游就要一个线程池,1000 个服务就 1000 个线程池,线程数爆炸上下文切换开销惊人。信号量隔离——不切换线程,在当前线程里获取信号量许可(tryAcquire),拿到就执行拿不到就快速失败。好处是零线程切换开销;代价是"伪隔离"——信号量只能限制并发数,如果下游超时了你这个线程会一直阻塞直到超时,超时期间的线程也在扛——不像线程池隔离那样超时就熔断不占资源。Hystrix 为什么推崇线程池:时代背景——Hystrix 诞生时微服务还在早期,服务数量少、下游不稳定,线程池的物理隔离对防止级联故障至关重要。Sentinel 为什么默认信号量:当下微服务动辄几十上百个下游,每个下游一个线程池不现实——信号量的轻量 + 与熔断器的组合使用(超时熔断 + 信号量限并发)是更经济的方案。面试一句:线程池隔离是"重型武器"——安全感满分但后勤压力大,信号量隔离是"轻骑兵"——快但需要熔断器配合兜底。
【面试题】6. 跨服务数据一致性除了分布式事务还有什么方案?对账系统怎么设计?对账发现差异后的补偿策略有哪几种?
【标准答案】跨服务数据一致性有两条路:实时强一致靠分布式事务(2PC/TCC/SAGA/Seata),准实时最终一致靠对账 + 补偿。对账系统设计核心四件套:一、定时对账——T+1 凌晨拉两边数据:订单服务今日创建成功的订单号列表 vs 支付服务已支付的订单号列表,做 diff 找差异(有订单没支付、有支付没订单、金额不一致)。二、实时对账——核心链路(支付)不要等 T+1,基于 MQ 实时消费两边事件做比对,延迟秒级。三、差异分级——差异一(有订单没支付):挂了补偿;差异二(金额不一致):挂工单人工介入;差异三(有支付没订单):高度敏感,可能资金安全事件,立刻告警 oncall。四、对账结果落库,每笔差异有唯一差异 ID 追踪处理全生命周期。发现差异后的补偿策略三种:自动补偿——差异一(订单超时未支付→自动取消订单发退款),逻辑确定、没人管也敢跑;人工审批补偿——差异二(金额少了 1 分钱),自动补差有风险,走审批流人工确认后再执行;手动补偿——差异三(支付多了一笔钱,查无订单),可能涉及欺诈,人工核实后再操作。面试核心:对账不是"分布式系统的不完美补丁",而是"分布式系统的一部分"——CAP 保证了理论上的最终一致性,对账是对这个"最终"做兜底验证——过多久算最终?对账过了才算。
【面试题】7. 微服务 API 版本管理——URL 版本、Header 版本、Query 参数版本三种方式各有什么利弊?你实际项目里怎么选?
【标准答案】三种方式的取舍。URL 版本(/v1/order, /v2/order):最直观——看 URL 就知道版本号,网关路由规则写 Path=/v1/order/** 直接转发。缺点是 URL 语义干扰——REST 倡导资源定位,把 /order/123 改成 /v1/order/123 破坏了 URI 的稳定性。适合对外 Open API。Header 版本(Accept: application/vnd.company.v2+json 或 X-API-Version: 2):URI 干净不污染,版本信息在协议头里。缺点是调试不直观 —— curl 不加 Header 看不出调的是哪个版本,排查问题时容易搞混。Query 参数版本(/order?version=2):实现最省事 —— 不用切网关路由,代码里直接读参数。缺点是 Query 参数是用来过滤/查询的,混入版本控制概念语义不清。实际项目的务实选型:对内 RPC 或服务间调用→ Header 版本,因为网关统一拦截 Header 做路由,代码从 Header 取版本号判断逻辑;对外 Open API→ URL 版本,因为第三方接入方看文档、测接口时 URL 版本最不易误解;内部新老版本过渡→ 用 Nacos 配置中心放版本开关,按租户/用户白名单动态切,不需改 API 协议。面试定音:API 版本管理的核心不是选哪种格式,而是"废弃旧版本的时间表"——没有 Deadline 的版本兼容承诺就是技术债永动机,最终你会维护 /v1、/v2、/v3 一直到退休。
【面试题】8. 事件溯源(Event Sourcing)和传统 CRUD 模式的根本差异在哪?Event Store 的 Event 长什么样?事件溯源的最大工程代价是什么?
【标准答案】传统 CRUD:订单状态从"待支付→已支付→已发货",每次只改数据库当前行——支付改为"已支付",发货改为"已发货"。最终数据库里只有最新状态,中间过程丢了——你不知道"已支付"是谁改的、什么时间、原因是什么。事件溯源:不存最终状态,只存"发生了什么事件"。Event Store 里三条记录:OrderCreated{orderId:123, amount:100} → OrderPaid{orderId:123, payTime:12:00} → OrderShipped{orderId:123, trackingNo:SF123}。要查订单当前状态?从头重放事件:创建→已支付→已发货→当前状态是已发货。优势三件套:审计完备——每一笔变更都永久记录,溯源到上帝视角;时间旅行——重放到 12:00 时刻就知道当时订单是"已支付",纠正数据时直接追加补偿事件不改老数据;读写分离天然——写侧只追加事件,读侧异步投影生成视图。最大代价两座大山:一是事件版本演进——OrderCreated 事件你加了字段,老事件是旧版本,重放时得处理所有历史版本的兼容,这不是 if-else 能解决的;二是投影侧的数据延迟——读模型是异步从事件构建的,用户支付完立刻查"我的订单"可能还是未支付,得做"读自己的写"(从写侧缓存读)。面试总结:事件溯源是"不可变数据库"思想在架构层的落地——你不改数据,你追加事实。代价是你必须对"事件就是真相"有宗教般的坚持,否则投影读到的不一致会让你怀疑人生。
【面试题】9. 生产环境 JVM 堆内存持续增长、GC 频率越来越高但始终不 OOM——内存泄漏怎么排查?MAT 的 Dominator Tree 和 Leak Suspects 分别看什么?
【标准答案】症状:堆使用率线性上涨→ Full GC 回收后不会回落到初始值→ GC 频率越来越密但始终还能回收一些→ 没 OOM 但 P99 持续恶化。第一步:jmap -dump:live,format=b,file=heap.hprof <pid> 导出堆快照,注意 live 参数会触发一次 Full GC 把垃圾清了,能缩小 dump 但也会暂时影响服务。MAT 打开后标准起手两步:先看 Leak Suspects Report——MAT 自动计算每个对象持有的堆内存大小,按嫌疑度排名,直接定位到"内存大户"的类名和持有链。90% 的泄漏一眼就看到了——一个 HashMap 里的 OrderDTO 占 2GB。再看 Dominator Tree——按"支配树"列出所有对象按"如果这个对象被回收,能释放多少内存"排序。这和 Leak Suspects 的区别:Leak Suspects 告诉你"谁占最多",Dominator Tree 告诉你"谁在维持这些对象不被 GC"。排查套路:Dominator Tree 点开 → Path to GC Roots(看谁在引用它不被回收)→ 找到引用链源头。常见元凶:ThreadLocal 没 remove——线程池复用的线程 ThreadLocal<Map> 里塞了数据没清,下次请求还在;静态集合 static List<byte[]> cache 只增不减;Netty 的 PooledByteBufAllocator 没释放;MappedByteBuffer 没 clean(JDK 的官方 bug)。面试定音:MAT 的 Dominator Tree 比 Leak Suspects 更底层——Leak Suspects 是 "where",Dominator Tree 是 "why"——你能跟面试官说清楚 Dominator Tree 的支配关系就已经碾压 90% 的候选人了。
【面试题】10. 缓存穿透、缓存击穿、缓存雪崩三兄弟分别是什么、各怎么防?假如你现在要设计一个扛 10 万 QPS 的热点商品查询缓存架构,怎么整?
【标准答案】穿透——查一个根本不存在的 key(ID=-1),缓存没有,每次穿透到 DB 也返回 null。防法:布隆过滤器(BloomFilter)——把全量有效 key 哈希到一个位数组,请求先问布隆"这 key 存在吗",说不存在就直接拒绝不再查 DB。但布隆有误判率——不存在的说可能存在,不会反过来。空值缓存是补丁——把 null 也缓存几分钟(设短 TTL),防恶意穿透。击穿——一个热 key 刚好过期瞬间,海量请求同时砸到 DB 把 DB 打挂。防法:互斥锁(Mutex Lock)——缓存过期后不让所有请求去查 DB,只让拿到锁的那一个去查并把结果写回缓存,其余睡眠等缓存就绪。雪崩——大量 key 同时过期或 Redis 集群挂了,全部请求压到 DB。防法:过期时间加随机偏移——不要把 10000 个 key 全设 30 分钟过期,设 30 + random(0,300) 分钟,把过期分散开;Redis 高可用——Cluster + Sentinel 防单点故障;多层缓存——本地 Caffeine → Redis → DB。十万 QPS 热点缓存架构:本地缓存(Caffeine)扛第一层——热点商品数据缓存在服务实例本地,访问零网络开销;Redis 做第二层——设置永不过期但定时异步更新,用 Redis 的 Pub/Sub 通知所有实例刷新本地缓存;CDN/边缘缓存做第三层——LBS 静态化 + CDN 推到离用户最近的节点。面试定音:防穿透靠"先知"(布隆),防击穿靠"排他"(互斥锁),防雪崩靠"离散"(过期打散 + 多级兜底)——三兄弟的解法哲学完全不一样,千万别搞混。
📋 考点覆盖:分布式 ID 生成(雪花算法)| 服务链路追踪(SkyWalking)| 服务网关(Gateway vs Zuul)| 分布式事务(SAGA)| 服务熔断降级限流(Sentinel 线程池 vs 信号量)| 跨服务数据一致性(对账补偿)| 微服务架构设计原则(API 版本管理)| 消息驱动架构(事件溯源 Event Sourcing)| 线上故障排查与调优(JVM 内存泄漏)| 分布式缓存设计(穿透/击穿/雪崩)