Java分布式架构面试题库
生成时间:2026-05-21 22:57 | 题目数:10
【面试题】1. CAP定理中,为什么分布式系统只能三选二?在实际架构中如何做取舍?
【标准答案】CAP定理的核心原因是网络分区(P)是客观存在的,不是你能选的——分布式系统跨网络通信,网络迟早会断,所以P必须选。剩下的就是在C和A之间做权衡。分区发生时,你要么牺牲一致性保证可用性(AP),要么牺牲可用性保证一致性(CP)。不存在三者兼得的系统。
实际取舍看业务场景:
- CP系统:金融转账、库存扣减,数据不能错,宁可不可用。典型如Zookeeper、Etcd、HBase。
- AP系统:社交动态、商品评论,短暂不一致可以忍,但服务不能挂。典型如Cassandra、Eureka、DynamoDB。
- 非分区时段,CAP三者可以同时满足,别被误导说"永远只能二选三"。
面试加分点:提到大多数互联网系统在分区恢复后通过补偿机制实现最终一致性,实际上是"AP + 最终C"的混合策略。
【面试题】2. Redis分布式锁用SET NX EX实现有什么问题?Redisson是如何解决的?
【标准答案】SET NX EX的三大经典问题:
- 锁超时误释放:业务没执行完,锁过期了,别的线程拿到了锁,前一个线程执行完把别人的锁删了。解决:value存唯一标识(如UUID),释放时Lua脚本判断是不是自己的锁再删。
- 不可重入:同一线程拿锁后再拿锁会被自己阻塞。解决:Redisson用Hash结构,field存线程ID,value存重入次数,加锁计数+1,解锁-1,归零才删。
- 续期问题:业务执行时间不确定,锁过期怎么办?解决:Redisson的看门狗(Watchdog)机制,默认30秒,每10秒(1/3超时时间)自动续期,业务完成或客户端宕机才停止。
Redisson还提供了:
- 公平锁:按请求顺序获取,避免饥饿
- 读写锁:读读共享,读写/写写互斥
- 联锁(MultiLock):多把锁同时获取
- RedLock算法:向N个独立Redis实例加锁,超过半数成功才算获取锁,解决单点故障问题
面试关键:RedLock在业界有争议(Martin Kleppmann写过文章质疑),实际生产中单Redis实例+Redisson看门狗是主流方案,RedLock用得相对少。
【面试题】3. TCC模式和Saga模式分别适用于什么场景?它们的补偿机制有什么本质区别?
【标准答案】
TCC模式:
- 适用于执行时间短、一致性要求高的业务,如资金转账、库存扣减。
- Try阶段做资源预留(冻结),Confirm阶段确认提交,Cancel阶段释放预留资源。
- 补偿是反向操作——Cancel是对Try的精确撤销,比如冻结100元→解冻100元,操作是对称的。
- 空回滚、幂等、悬挂是TCC三大难题,必须处理。
Saga模式:
- 适用于长事务、涉及多个服务调用的场景,如订单创建流程(下单→扣库存→扣余额→发积分)。
- 正向事务按顺序执行,某步失败则逆向补偿已完成的步骤。
- 补偿是正向操作的业务补偿,不是技术回滚——比如已扣库存就补偿为"加回库存",而不是数据库rollback。
- Saga有两种编排方式:** Choreography(事件驱动)** 和 Orchestration(集中编排)。
本质区别:
- TCC的Try是预留资源,业务层面数据还是隔离的,Confirm/Cancel是二选一;Saga每步都是真实提交,没有隔离性,补偿是额外写一条"冲正记录"。
- TCC一致性更强但侵入性大(每个服务写Try/Confirm/Cancel三个接口);Saga侵入性小但只有最终一致性,中间态可见。
面试加分:Seata框架中AT模式本质是自动化的TCC(框架帮你生成回滚SQL),降低了开发成本。
【面试题】4. Snowflake雪花算法的ID构成是怎样的?时钟回拨问题如何解决?
【标准答案】
ID构成(64位Long):
- 1位符号位(永远0)
- 41位时间戳(毫秒级,约69年)
- 10位工作机器ID(可拆分为5位数据中心ID + 5位机器ID,共1024个节点)
- 12位序列号(同一毫秒内可生成4096个ID)
单机QPS约400万/秒,完全够用。
时钟回拨问题: NTP时钟同步可能导致系统时间往回跳,此时生成的ID会重复。解决方案:
- 回拨时间小(<几毫秒):等待回拨时间过去再生成。百度的UidGenerator、美团的Leaf都采用这种策略。
- 回拨时间大:采用备用workerId,回拨时切换到新的机器ID。
- Leaf的方案:用Zookeeper维护workerId,每次启动时记录时间戳,回拨超过阈值(默认5ms)直接拒绝服务,等待时钟追上。
- 严苛方案:引入单调时钟(monotonic clock),不依赖系统时钟,自行维护时间递增。
面试提醒:别只说"等待回拨",面试官追问的就是回拨时间很长怎么办——备用workerId或拒绝服务是更完整的答案。
【面试题】5. 缓存穿透、缓存击穿、缓存雪崩分别是什么?各自的解决方案是什么?
【标准答案】
缓存穿透:请求查询一个根本不存在的数据,缓存没有,数据库也没有,每次请求都打到DB。
- 方案1:布隆过滤器,在缓存前加一层,不存在的key直接拦截。有误判率但可接受。
- 方案2:缓存空值,查不到也缓存null,设短TTL(如60秒)。注意防止恶意攻击构造大量不同key。
- 方案3:参数校验,在入口层拦截非法请求。
缓存击穿:某个热点key过期的瞬间,大量并发请求同时打到DB。
- 方案1:互斥锁,只让一个线程查DB并回写缓存,其他线程等。用Redis SETNX实现。
- 方案2:逻辑过期,缓存不设TTL,value里存逻辑过期时间,过期后异步更新,其他线程返回旧数据。
- 方案3:热点key永不过期,配合手动刷新。
缓存雪崩:大量key同时过期,或者缓存集群宕机,请求全部涌向DB。
- 方案1:过期时间加随机值,避免同时失效,如TTL = base + random(0~300s)。
- 方案2:多级缓存,本地缓存(Caffeine)+ 分布式缓存(Redis),兜底。
- 方案3:缓存高可用,Redis哨兵/Cluster,避免整个缓存层挂掉。
- 方案4:熔断降级,DB扛不住时直接降级返回默认值或排队。
面试速记口诀:穿透查不到→布隆/空值;击穿热点过→互斥锁;雪崩大批过→随机TTL+多级缓存。
【面试题】6. JWT无状态方案和Session集中存储方案各有什么优缺点?如何选择?
【标准答案】
Session集中存储(Redis/Memcached):
- 优点:服务端完全可控,能强制踢人、实时查看在线状态、token即时失效。
- 缺点:有状态,依赖Redis可用性,每次请求都要访问Redis,水平扩展时Session同步是个问题。
- 适用:后台管理系统、内部平台,用户量可控,安全性要求高。
JWT无状态方案:
- 优点:无状态,不依赖存储,天然支持水平扩展,移动端友好,跨域方便。
- 缺点:无法主动失效是最大问题——签发后只要没过期就一直有效;token体积大(Payload明文Base64),每次请求都携带浪费带宽;Payload不能存敏感信息。
- 适用:开放API、移动端、微服务间调用。
怎么选:
- 外部面向C端用户→JWT + 短有效期 + Refresh Token双Token方案。
- 内部管理系统→Session集中存储,安全可控。
- 混合方案:JWT做身份认证,敏感操作仍校验Redis中的状态(如是否被封禁),相当于JWT + Redis黑名单。
面试加分:提到Refresh Token的刷新机制——Access Token有效期短(如30分钟),Refresh Token有效期长(如7天),用Refresh Token换新的Access Token。Refresh Token存Redis,可以主动失效。
【面试题】7. 令牌桶和漏桶算法的区别是什么?分布式限流中Redis+Lua怎么实现滑动窗口?
【标准答案】
漏桶算法:请求进入漏桶,桶以固定速率漏水。不管突发流量多大,输出速率恒定。适合平滑流量,比如调用第三方API需要严格控制速率。缺点是面对突发流量时无法利用空闲期积累的处理能力。
令牌桶算法:桶以固定速率放令牌,请求消耗令牌,桶满则丢弃新令牌。允许突发流量——桶里攒够了令牌,瞬间可以处理一批请求。适合自身服务限流,合理利用处理能力。
核心区别:漏桶是恒速输出,令牌桶是匀速放行但允许突发。绝大多数场景用令牌桶更合适。
Redis+Lua实现滑动窗口限流:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 当前窗口计数
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('EXPIRE', key, window / 1000)
return 1 -- 允许
else
return 0 -- 限流
end核心思路:用Sorted Set,score存时间戳,每次请求先删除窗口外的旧记录,再统计当前窗口请求数,未超限则加入新记录。Lua脚本保证原子性。
面试加分:提到Sentinel的滑动窗口实现更高效——用环形数组+时间窗口,时间复杂度O(1),比ZREMRANGEBYSCORE轻量。
【面试题】8. Raft算法的Leader选举过程是怎样的?选举超时时间如何设计?
【标准答案】
Leader选举过程:
- 所有节点初始都是Follower状态,启动一个选举超时定时器。
- 如果Follower在超时时间内没收到Leader的心跳,转为Candidate,自增当前任期(Term),投自己一票,向其他节点发送RequestVote RPC。
- 其他节点收到RequestVote后,如果本轮Term没投过票且Candidate的日志至少和自己一样新,就投票给它。
- Candidate收到多数派(N/2+1)投票,成为Leader,立即发送心跳通知所有节点。
- 如果出现多个Candidate平票(split vote),超时后重新选举。
选举超时时间设计:
- 一般设为150~300ms的随机值。随机化是为了避免多个节点同时超时同时发起选举导致平票。
- 太短:容易误判Leader故障,频繁选举,集群不稳定。
- 太长:Leader真正故障后恢复慢,可用性下降。
- 实践经验:心跳间隔远小于选举超时(如心跳50ms,选举超时150~300ms),确保正常情况下Follower不会误触发选举。
面试加分:提到Raft的关键约束——日志完整性原则,Leader选举不仅要多数票,还要求Candidate的日志至少和投票者一样新(比较lastLogTerm和lastLogIndex),保证选出来的Leader一定包含所有已提交的日志。
【面试题】9. Zookeeper的Watch机制有什么局限性?如何实现配置变更的实时通知?
【标准答案】
Watch机制的三大局限性:
- 一次性触发:Watch被触发一次后自动失效,需要重新注册。如果在重新注册的间隙发生了数据变更,会丢失事件。
- 轻量通知:Watch事件只告诉客户端"数据变了",不携带变更后的新值,客户端需要主动再去读取。
- 不保证有序:客户端看到Watch事件之前,可能已经读到了更新的数据(通过其他途径),存在时序问题。
配置变更实时通知的实现:
- 客户端在配置节点(如
/config/app/db-url)上注册Watch。 - 配置中心修改节点数据时,触发Watch事件通知所有客户端。
- 客户端收到通知后重新读取最新值,并重新注册Watch。
- 对于一次性触发的问题,使用Curator的
PathChildrenCache或TreeCache,它内部自动处理Watch的重新注册和事件监听,对开发者透明。
更优的替代方案:
- Nacos配置中心用长轮询(Long Polling)+ UDP推送,既实时又可靠。
- Apollo用长轮询 + 客户端定时拉取,双保险。
面试加分:Zookeeper的Watch不适合高频变更场景(如秒级配置更新),因为每次变更都要通知所有客户端重新Watch,产生"羊群效应"。Nacos/Apollo在这类场景下更合适。
【面试题】10. XXL-Job的架构是怎样的?任务分片和故障转移分别怎么实现?
【标准答案】
XXL-Job架构:
- 调度中心(Admin):负责触发调度、管理任务、监控日志。可集群部署,通过数据库悲观锁保证同一触发时间只有一个节点触发调度。
- 执行器(Executor):嵌入到业务应用中的客户端,负责接收调度并执行任务。通过Netty与调度中心通信。
- 数据库:存储任务配置、调度日志、执行器注册信息。
任务分片实现:
- 调度中心按分片参数(分片总数+分片序号)下发任务,每个执行器收到自己的分片序号。
- 例如3个执行器处理10万条数据,分片总数=3,执行器A拿到分片0,处理第0、3、6…条数据;执行器B拿到分片1,处理第1、4、7…条。
- 代码中通过
XxlJobHelper.getShardIndex()和XxlJobHelper.getShardTotal()获取分片参数,自行按取模方式分配数据。 - 新增执行器后,下次调度自动重新分片,实现弹性扩容。
故障转移实现:
- 调度中心发出调度请求后,如果执行器在超时时间内未响应,调度中心标记该次调度失败,按策略处理:
- 故障转移策略:依次尝试其他空闲执行器,直到有一个成功接受。
- 失败重试策略:对同一执行器重试指定次数。
- 执行器宕机后,调度中心通过心跳检测感知,将该执行器从注册列表移除,后续调度不再分配给它。
面试加分:XXL-Job调度中心集群用数据库行锁实现调度互斥,高并发场景下数据库是瓶颈。对比Elastic-Job用Zookeeper做分布式协调,无需数据库但运维复杂度更高。