微服务面试题 - 2026-06-19 17:50
生成时间:2026-06-19 17:50 | 共 10 题 | 难度递进(基础→进阶→高阶)
【面试题】1. DDD 里的限界上下文怎么识别?事件风暴现场到底在做什么?怎么从一堆便利贴里抽出服务边界?
【标准答案】限界上下文的核心识别工具是事件风暴(Event Storming)——不是画架构图,而是把业务专家、产品、开发拉到同一个房间,用便利贴把业务流程拼出来。三步:第一步贴领域事件——所有人一起按时间线贴出"发生了什么"(橙色便利贴),比如"订单已提交""支付已完成""库存已扣减",不讨论系统、不讨论数据库,只讨论业务。第二步找聚合——看哪些领域事件总是围绕同一批数据发生,比如"订单提交""订单支付""订单取消"都围绕订单——这就是一个聚合的候选。第三步画边界——看不同的事件群用的是不是同一套业务术语。同一个词"用户"在客服上下文里是"昵称+手机号",在风控上下文里是"设备指纹+历史行为评分"——语言不一样就是不同的限界上下文。面试一句话总结:限界上下文画在哪,不靠技术驱动,靠"通用语言"——同一个词在不同场景下含义变了,就该画一条边界线。
【面试题】2. Nacos 的"临时实例"和"持久实例",健康检查到底是怎么做的?TCP 检查和 HTTP 检查的背后逻辑有什么不同?
【标准答案】临时实例靠客户端主动上报心跳,持久实例靠服务端反向探测。临时实例(AP 模式下默认):客户端每 5 秒向 Nacos Server 发一条心跳,Server 15 秒没收到心跳把实例标记为"不健康",30 秒没收到直接剔除。注意:标记不健康和剔除是两个时间阈值——15 秒给了客户端一次网络抖动的容错窗口。持久实例(CP 模式):Server 主动向实例发健康探测请求,支持三种协议——TCP 检查最简单,连一下端口通了就算健康,但端口通不代表服务正常(线程池满了端口照样通);HTTP 检查会调一个健康检查接口(比如 /actuator/health),返回 200 才健康,能感知到线程池满、DB 挂了等应用层问题;MySQL 检查专为数据库实例设计。生产建议:临时实例就够了,健康检查发心跳开销极小,配合优雅下线(主动注销→等剩余请求处理完→真正停)比各种探测都靠谱。面试金句:Nacos 不是万能体检医生,它只能判断"你在不在线"——线程池满了这个实例还活着但跟死了没区别,这个要配合 Sentinel 的熔断兜底。
【面试题】3. Spring Cloud 的 @RefreshScope 到底是怎么做到不重启服务就能热更新配置的?@Value 和 @ConfigurationProperties 的热更新行为有什么不同?
【标准答案】@RefreshScope 本质是"代理 + 懒加载 + 重建"。加了 @RefreshScope 的 Bean,Spring 不会直接给你原始对象,而是包了一层 CGLIB 代理。这个代理内部的实现是:第一个请求来时从 ScopeCache 里找 Bean——没有就创建新的放进缓存,有就直接返回。所以"刷新"的时候干了什么?调用 /actuator/refresh 触发 ContextRefresher→把 Environment 里的配置值全部更新→清空 RefreshScope 的缓存(scopeCache.clear())→下一次请求代理对象发现缓存没了→重新创建 Bean→拿到最新配置。核心是"不做任何修改,只是下次调用时重建"。@Value 配合 @RefreshScope 才能刷新——因为 Bean 重建时会重新解析 ${},拿到新的值。但 @ConfigurationProperties 不需要 @RefreshScope——它直接绑定到 Environment 对象,Environment 刷新后自动拿到新值。坑:@RefreshScope 会导致这个 Bean 变成延迟初始化——启动时不会加载,第一个请求才创建;而且如果你的 Bean 里有 @PostConstruct 做了初始化(比如连数据库建连接池),刷新后重建会再执行一次——要小心幂等。面试一言:@ConfigurationProperties 天然支持刷新,@Value 必须戴 @RefreshScope 的帽子。
【面试题】4. 不用 RocketMQ 事务消息、不用 Seata,光靠一张"本地消息表"怎么保证跨服务数据的最终一致性?这个方案最致命的缺陷是什么?
【标准答案】本地消息表是分布式事务里最朴素但最耐用的方案——不需要额外中间件,纯靠数据库。流程:第一步,业务操作和写消息表在同一个本地事务里——BEGIN; UPDATE account SET balance=balance-100 WHERE user_id=1; INSERT INTO outbox(event_type,payload,status) VALUES('ORDER_CREATED','...','PENDING'); COMMIT;——要么全成功要么全失败。第二步,后台定时任务(每 10 秒跑一次)扫 outbox 表里 status='PENDING' 的记录,每条记录调一次下游服务的接口,下游返回成功就 UPDATE outbox SET status='SENT'。第三步(兜底),下游服务必须做幂等——收到重复消息不会重复扣库存。最致命的缺陷不是"定时扫表慢"(10 秒延迟很多场景完全能接受),而是"顺序"。如果同一个订单产生了消息 A 和消息 B,A 下单、B 支付——定时任务扫出来 A 发了成功但下游挂了,B 先被处理——下游收到 B 发现订单还没创建直接报错。解法:消息表加 sequence_id,消费者校验顺序,或者单条链路上用单线程消费。另一个坑:消息表数据无限增长。要定期归档已成功且超过 N 天的消息。面试核心:本地消息表不是为了替代 MQ,而是"在没有 MQ 的情况下,用最低的架构复杂度实现最终一致性"——创业项目、MVP 阶段的最佳选择。
【面试题】5. Hystrix 的线程池隔离和信号量隔离有什么区别?为什么 Netflix 说网络请求用线程池、本地缓存用信号量?Sentinel 为什么只支持信号量模式?
【标准答案】线程池隔离:每个下游依赖分配一个独立的线程池(默认 10 个线程),调用时从线程池借一个线程去执行——主线程把任务丢进线程池后返回 Future,由线程池的线程真正调用下游。好处:线程池满了新请求直接 reject——下游挂了不会把主线程拖死,隔离最彻底。坏处:每个请求要跨线程→线程切换开销→额外内存。信号量隔离:在主线程内执行调用,只是通过一个 Semaphore 计数器控制并发量。比如设 semaphore.maxConcurrentRequests=20,同一时刻最多 20 个请求并发调下游,第 21 个直接拒绝。好处:零线程切换、延迟极低。坏处:不能处理"超时中断"——信号量里线程在执行任务,哪怕下游超时 30 秒,信号量也只会"等它自己执行完"而不能强行中断。Netflix 的实践:网络调用(HTTP/RPC)用线程池——因为可能超时,需要能中断;本地调用(查本地缓存、CPU 密集计算)用信号量——开销低、不存在超时问题。Sentinel 为什么只用信号量?因为 Sentinel 的设计理念不同——它不是给你隔离线程,而是通过"统计 + 规则"在调用前就判断是否放行。调用本身还是通过原本的业务线程执行,只不过加了一层统计器的拦截。这避免了线程池的内存开销,但也牺牲了"降级时能超时中断"的能力——Sentinel 走的思路是让你配合"超时设置 + 熔断"而不是靠线程池中断。面试总结:线程池隔离是物理隔离(重但安全),信号量是逻辑隔离(轻但只管并发数不管超时),选择看你能接受多大的内存开销。
【面试题】6. 面试官让你"讲讲 BASE 理论怎么落地",你怎么用真实项目中的三个例子把 BA、S、E 各说清楚?
【标准答案】BASE = 基本可用(Basically Available)+ 软状态(Soft State)+ 最终一致性(Eventually Consistent)。不是三个独立原则,而是一条线。基本可用:大促时电商首页——正常时展示个性化推荐,大促时推荐服务扛不住直接切静态热门商品列表。用户感受到的是"推荐不那么准了"而不是"页面打不开"——这就叫基本可用。核心是"有损服务"——主动牺牲非核心体验,保住核心功能。软状态:订单从"待支付"到"已支付"的中间状态——15 分钟内没付款订单就取消。这 15 分钟里订单处于"软状态"——数据库里确实有一条未付款的订单,但业务上这条订单可能下一秒就没了。CAP 理论不允许出现"不确定"状态,但 BASE 允许中间态存在。最终一致性:用户下单后,积分 30 秒内到账——不是支付成功瞬间就有积分,但最终一定有。实现手段:订单服务发消息→积分服务消费→消费失败则重试+对账——5 分钟跑一次对账任务,把"订单付了但积分没加"的补上。三个概念在一条链路上串起来:秒杀场景下→商品详情页降级保证基本可用→库存扣减时允许短暂超卖(软状态)→1 分钟后对账精确扣减到真实库存(最终一致)。面试一句定音:强一致性是理想,最终一致性是现实,BASE 教你在现实中保证业务不崩。
【面试题】7. Nginx 的平滑加权轮询算法是怎么做到"权重高的被多选、但不连续选"的?你口述一下核心流程。
【标准答案】平滑加权轮询解决一个直觉问题:A 权重 5、B 权重 1——简单的加权轮询结果是 AAAAA B AAAAA B,流量全集中在 A 上,B 的机器闲着。平滑算法的目标:让分布变成 A A B A A A B A A A——A 多但均匀分散。Nginx 的实现三步:第一步,每个节点维护一个 current_weight,初始值 = 0。第二步,每次选 current_weight 最大的节点作为本次选中的节点,然后它的 current_weight 减去所有节点的 effective_weight 之和。第三步,每轮开始前,所有节点的 current_weight 加上自己的 effective_weight。举例:A 权重 5、B 权重 1,总权重 6。轮次 1:A=5、B=1→选 A→A=5-6=-1。轮次 2:A=-1+5=4、B=1+1=2→选 A→A=4-6=-2。轮次 3:A=-2+5=3、B=2+1=3→打平选 A→A=3-6=-3。轮次 4:A=-3+5=2、B=3+1=4→选 B→B=4-6=-2。最终 6 轮:A、A、A、B、A、A——A 5 次 B 1 次,而且 B 在第 4 轮被选中插在中间。算法的精妙:current_weight 像一个"亏欠值"——你权重高就一直欠着你,直到别的节点追上来了才让别的执行一次。Dubbo 的 RoundRobinLoadBalance 完全照着这个实现。面试官听到"current_weight 等于权重的盈亏平衡"就知道你真懂了而不是背概念。
【面试题】8. Redisson 分布式锁的"看门狗"机制是什么?如果我设了 10 秒过期,业务执行了 15 秒——锁会不会被别人抢走?
【标准答案】看门狗(Watch Dog)就是 Redisson 给锁续命的守护线程。你如果调 lock() 不传超时时间,Redisson 默认给你设 30 秒的锁过期时间——同时启动一个看门狗定时任务,每 10 秒(lockWatchdogTimeout/3)检查一次:如果锁还在当前客户端手里,就用 Lua 脚本把过期时间重新续到 30 秒。效果:只要你的业务代码没执行完且 JVM 没挂,锁就不会过期——看门狗无限续期直到你主动 unlock()。如果你手动设了超时 lock(10, TimeUnit.SECONDS),看门狗不生效——10 秒一到锁自动释放,业务跑 15 秒绝对被抢。所以你那个场景正确答案是:别设过期时间,让看门狗自动续期,业务执行完调用 unlock()。如果担心死锁(拿了锁的机器突然宕机),看门狗断了 30 秒内锁自动过期释放,不会永久死锁。额外提醒:Redis 主从异步复制可能导致锁丢失——主节点写了锁还没同步到从节点就挂了,从节点升主后不知道锁的存在。对一致性要求极高的场景用 RedLock 算法(多 Redis 实例多数派确认),但代价是性能大幅下降。面试核心:看门狗解决的是"业务执行时间不确定 + 不想设死过期时间"的问题,它把"锁自动过期"从功能变成了最后的兜底保险。
【面试题】9. 一个创业公司单体应用跑了一年,现在日均 5 万 PV、团队 8 个人——要不要拆微服务?你用什么标准判断"拆的时机到了"?最坑的三个反模式是什么?
【标准答案】5 万 PV、8 人团队——大概率不该拆。判断拆不拆不看日活,看"痛不痛"。四个停止信号:一,部署痛——改一行代码要全量回归全量部署 30 分钟,每次上线都像拆炸弹;二,协作痛——两个人同时改了同一个模块,每回合并代码解冲突解到崩溃,Git Flow 救不了;三,资源痛——某个模块是 CPU 密集型,要给它多分几核,但单体要扩只能整机扩容——那部分模块实际上在浪费资源;四,组织痛——团队 8 人已经分成了两个小组各自负责不同业务,但代码仓库是一个,需求评审天天扯皮。四条中至少满足两条才考虑拆。三个最坑反模式:第一,没拆数据库就拆服务——A 服务直连 B 服务的数据库查数据,部署是微服务、数据是单体——"分布式单体"比原来单体还烂。第二,按技术层拆——拆出 Controller 微服务、Service 微服务、DAO 微服务——一个请求链路要穿三层服务,延迟翻倍、定位问题时 trace 图像迷宫。服务应该按业务领域拆(用户服务、订单服务),不是按分层架构拆。第三,拆太细——一个服务一个人维护还要拆 3 个服务,结果是 8 个人维护 25 个服务,运维成本吃掉所有开发效率。经验值:一个人维护 1~3 个服务是上限,超过这个数你的 oncall 会疯。面试金句:微服务不是架构升级的终点,而是你痛到不得不拆时的止痛药——不痛就别吃。
【面试题】10. 凌晨三点,线上订单服务突然 OOM 崩溃——你没有预先把 -XX:+HeapDumpOnOutOfMemoryError 加到 JVM 参数里,怎么办?重启后怎么查是谁吃了内存?
【标准答案】先止血再破案。止血:凌晨三点第一要务是恢复服务——直接重启,不要在现场犹豫。重启后加 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof,等着复现。没设这参数第一次 OOM 拿不到 dump,但你趁第一次重启就把参数加上——OOM 大概率会复现。破案工具链:第一步,拿到 dump 后用 MAT(Memory Analyzer Tool)打开,直接看 Dominator Tree——它告诉你"如果把对象 X 回收了能释放多少内存",按保留空间降序排,排第一的就是凶手。90% 的 OOM 是前三名里就找到了。第二步,看对象是什么——byte[] 占第一说明有人把大文件读进内存了;某个业务对象(比如 OrderDTO)里有几十万条记录,说明分页没生效查了全表;HashMap 里塞了几十万个 Entry,大概率是本地缓存没设大小上限。第三步,走 GC Root 路径——选中大对象→"Path to GC Roots"→看谁引用了它→找到代码位置的引用链。常见 OOM 三大原因:ThreadLocal 里存了大对象用完没 remove(线程复用时内存泄漏)、第三方接口没设超时导致大量请求堆积在内存中的响应体、本地缓存无界增长(Caffeine 没设 maximumSize)。预防三板斧:JVM 参数标配 HeapDumpOnOutOfMemoryError、所有本地缓存设最大条数、所有外部调用设超时。面试定音:OOM 不可怕,可怕的是第二次 OOM 还没抓到 dump——第一次重启时就要把参数焊死。