分布式(三):限流熔断、服务治理与高可用
分布式(三):限流熔断、服务治理与高可用
导语:分布式不是"拆成多个服务"就完事了,真正难的是让它在部分节点故障时依然可用。本篇按"流量治理 → 服务治理 → 稳定性保障"三层展开:限流算法与集群限流、熔断降级与隔离、拆分原则与注册发现、负载均衡与 RPC 原理、会话与缓存一致性,最后落到链路追踪、顺序性、MQ 与高可用综合设计,共 14 题。
一、流量治理:限流、熔断、降级、隔离
1. 常见限流算法有哪些?计数器、滑动窗口、漏桶、令牌桶怎么选?
答: 四种经典算法,本质是"用什么方式控制通过的速率":
| 算法 | 原理 | 是否允许突发 | 缺点 |
|---|---|---|---|
| 固定窗口计数器 | 每个窗口(如 1s)独立计数,超阈值拒绝 | 否(但临界点会双倍) | 临界问题:0:59 与 1:00 各放满阈值,瞬时可达 2 倍流量 |
| 滑动窗口 | 把窗口切成多个小格(如 1s → 10 个 100ms),滚动统计"最近一个窗口"的总量 | 否(更精确) | 内存换精度;格子越细越准但开销越大 |
| 漏桶(Leaky Bucket) | 请求先入桶,以恒定速率流出,桶满则丢弃 | 不允许(输出严格匀速) | 无法应对合理突发;需队列/定时器实现 |
| 令牌桶(Token Bucket) | 以恒定速率往桶里放令牌,桶有容量上限;请求拿到令牌才通过 | 允许(最多突发到桶容量) | 参数(速率 + 容量)需按业务调 |
关键对比:
- 漏桶 vs 令牌桶:漏桶恒定输出(平滑,对抗突发);令牌桶允许积累令牌,所以能放行一段突发流量。互联网业务大多选令牌桶(既限制平均速率,又不误伤正常突发,如秒杀开始的瞬时洪峰需要"放一波再限")。
- 滑动窗口 vs 固定窗口:滑动窗口是固定窗口的精度改良,Sentinel 的
LeapArray(把 1s 切成 2 个 500ms 的滑动窗口)就是典型实现。 - "匀速排队"也是一种限流:Sentinel 的 匀速排队(Rate Limiter) 让请求排队等待而非直接拒绝,本质是漏桶思路,适合"宁可慢也不丢"的场景。
实现参考:
| 组件 | 算法 |
|---|---|
Guava RateLimiter | 令牌桶(SmoothBursty 允许突发;SmoothWarmingUp 支持冷启动预热,避免刚启动就抗满流量) |
| Sentinel | 滑动窗口统计 + 快速失败 / Warm Up / 匀速排队 三种流控效果 |
Nginx limit_req | 漏桶(burst + nodelay 控制突发) |
追问"限流阈值怎么定":不能拍脑袋——要靠压测拿到单机容量(如单机 500 QPS 后 RT 开始上升),再留 20%~30% 余量,并随容量变化动态调整;阈值过松等于没限,过紧会误伤正常流量。
2. 分布式限流怎么做?
答: 单机限流(Guava / Sentinel 单机模式)的问题:各实例独立计数,N 台机器 × 单机阈值 = 集群实际阈值的 N 倍;流量分布不均(某台被打满、其他闲着)时,单机限流要么误杀要么失效。
集群限流的三种做法:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis + Lua 原子脚本 | 把计数/令牌状态放 Redis,用 Lua 保证"读-算-写"原子 | 精度高、实现直接、易理解 | 每次请求一次 Redis IO(RT 增加);Redis 成为关键路径与热点 |
| Redis + 本地批量预取 | 本地按小窗口(如 100ms)预取一批配额,用不完退回 | 大幅降低 Redis 压力 | 精度略降(可能略微超放) |
| Sentinel 集群流控 | 由 Token Server 统一计算配额,客户端上报并领取 | 精度高、可动态配置、生态完整 | 需额外部署 Token Server(或 embedded 模式);Token Server 是单点需高可用 |
Redis 实现要点(面试常要求手写思路):
-- 固定窗口:INCR + 首次设置过期(原子)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1]) -- 窗口长度
end
if current > tonumber(ARGV[2]) then
return 0 -- 被限流
end
return 1- 滑动窗口:用 ZSet,
ZREMRANGEBYSCORE清掉窗口外的成员,ZCARD统计窗口内数量,超过阈值则拒绝(member用"时间戳 + 随机数"避免同毫秒覆盖); - 令牌桶:在 Redis 存
{tokens, lastRefillTime},Lua 里按(now - lastRefillTime) × rate计算应补的令牌数,取min(容量, 现有 + 补充),够则扣减放行; - 注意:Lua 脚本里不要用随机数、不要用
redis.call('TIME')之外的不确定逻辑(Redis 5 后影响较小),并给脚本设KEYS/ARGV规范以支持集群模式。
分层落地建议(这才是"做过生产"的答案):
- 接入层(Nginx / 网关):按 IP / 接口 / 租户 做粗粒度限流,拦掉恶意与异常流量(
limit_req、Spring Cloud Gateway + Redis 令牌桶); - 应用层:按业务维度(用户 ID、商品 ID、参数)做细粒度限流(Sentinel 热点参数限流);
- 服务提供者层:对下游依赖做保护性限流(并发数限流,避免打挂下游);
- 原则:限流宁可"多放一点"也别"误伤正常用户";被限流后的响应要有明确的兜底(排队 / 返回"稍后重试" / 降级),而不是抛异常。
3. 熔断与降级的区别是什么?熔断器状态机怎么运转?
答:
(1)概念区分
| 熔断(Circuit Breaker) | 降级(Fallback) | |
|---|---|---|
| 目的 | 保护调用方:依赖服务异常时快速失败,不再发起真实调用,给下游喘息时间 | 保护自身/核心链路:牺牲非核心功能,返回兜底结果 |
| 触发 | 自动(错误率 / 慢调用比例 / 异常数超阈值) | 自动或人工(压力大、故障、大促预案) |
| 动作 | 直接拒绝请求(快速失败) | 返回兜底数据(默认值、缓存、静态页)、关闭非核心功能 |
| 关系 | 熔断是"保护机制",降级是"兜底策略"——熔断后通常走降级逻辑 | 降级可以不依赖熔断独立存在 |
三者辨析(高频):
- 限流:控制别人访问我的速率(保护自己不被压垮);
- 熔断:控制我访问别人的行为(保护自己不被下游拖垮);
- 降级:牺牲部分功能换取核心链路可用(是熔断/限流之后的"兜底手段")。
(2)熔断器三态状态机
┌──────────── 成功 ────────────┐
↓ │
Closed(关闭)── 失败率/慢调用超阈值 ──→ Open(打开)
↑ │
│ 探测成功 休眠期结束(如 5s / 10s)
│ ↓
└──── Half-Open(半开)←────────────────┘
│
└── 探测失败 ──→ 回到 Open- Closed:正常放行,滑动窗口统计失败率 / 慢调用比例 / 异常数;
- Open:拒绝请求(快速失败),进入
sleepWindow休眠期; - Half-Open:休眠结束后,放少量试探请求——成功则回
Closed(恢复),失败则回Open(继续熔断)。
配置时的四个关键点(区分"背过"与"调过"):
- 必须设置"最小请求数":如"1 秒内至少 10 个请求才开始统计",否则样本太少(3 个请求错 2 个 → 66% 错误率)会误熔断;
- 阈值要有梯度:错误率(如 50%)、慢调用比例(如 RT > 1s 的占比 > 50%)、异常数(如 1 分钟内 20 个),按依赖重要性分别设;
- 必须有超时:没有超时的慢调用会把线程池占满,熔断也救不了——超时是熔断的前提;
- 半开探测要"慢":一次只放一个(或极少量)请求,避免刚恢复就被打挂。
实现与降级手段:
- 实现:Sentinel(
DegradeRule)、Resilience4j、Hystrix(已停更); - 降级手段:返回兜底数据(默认值 / 缓存 / 兜底页面)、读降级(只读不写)、写降级(异步写 / 排队)、功能降级(关闭推荐、评论等非核心)、页面降级(静态化)。
经典总结:"一个慢依赖会耗尽整个线程池"是雪崩的根因,而超时 + 熔断 + 隔离是标准解药。
4. 服务隔离有哪些方式?怎么防止雪崩?
答: 雪崩的典型路径:某依赖变慢 → 调用方线程被阻塞占满 → 该服务所有接口不可用 → 上游继续阻塞 → 级联崩溃。隔离的本质是"限制爆炸半径"。
| 隔离方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 线程池隔离 | 每个依赖独立线程池,某个依赖慢只耗尽自己的池 | 彻底隔离;且支持超时中断(能主动打断阻塞线程) | 线程上下文切换开销;线程数膨胀(依赖多 → 几百个线程);ThreadLocal / 事务上下文传递麻烦 |
| 信号量隔离 | 用计数信号量限制某依赖的并发数(如最多 20 个并发) | 轻量、无线程切换、无上下文丢失;性能好 | 不支持超时中断(调用方线程被占用直到返回);无法隔离慢调用(只能限制并发,慢请求仍会占住许可) |
| 舱壁模式(Bulkhead) | 按业务 / 重要性划分资源池(如核心链路池、非核心池) | 保证核心业务不被非核心拖累 | 需提前划分与容量规划 |
| 集群 / 机房隔离 | 不同业务线、不同租户独立部署;重要业务独立集群 | 故障不跨业务扩散 | 成本高、资源利用率下降 |
| 单元化(异地多活) | 按用户维度切分单元,单元内闭环,故障只影响部分用户 | 容灾能力强、可逼近无限扩容 | 架构复杂度极高、数据同步难 |
配合使用(缺一不可):
超时(前提) + 熔断(快速失败) + 隔离(限制爆炸半径) + 限流(保护自己) + 降级(兜底体验)选型建议:
- 绝大多数场景用信号量隔离(Sentinel / Resilience4j 默认),因为轻量且够用;
- 只有当依赖会长时间阻塞、必须主动超时中断时(如调用不可控的第三方 HTTP)才用线程池隔离,并注意上下文透传(
TransmittableThreadLocal); - ThreadLocal 丢失是线程池隔离的经典坑:TraceId、事务上下文、用户信息都可能在切线程后丢失,需要显式包装任务。
一句话:隔离的目标不是"不出故障",而是"一个依赖出故障时,只影响它自己"。
二、服务拆分与治理
5. 微服务划分(服务拆分)的原则是什么?什么时候不该拆?
答:
核心原则:
| 原则 | 说明 |
|---|---|
| 单一职责 / 高内聚低耦合 | 一个服务只负责一类紧密相关的业务能力;数据与逻辑内聚在服务内部 |
| DDD 限界上下文(Bounded Context) | 按领域模型划分子域,一个子域一个服务(主流方法论);区分核心域 / 支撑域 / 通用域,核心域投入最多 |
| 康威定律(Conway's Law) | 系统结构镜像组织结构——团队边界即服务边界;组织结构不变而强行拆服务往往失败 |
| 独立部署 / 独立扩容 | 服务必须能单独发布;若两个服务总是"必须一起发",说明根本没拆开(分布式单体) |
| 数据独占 | 每个服务独占自己的库,不共享表;跨服务只能通过接口 / 事件访问数据 |
| 粒度适中 | 过细 → 调用链爆炸、分布式事务增多、运维成本上升;过粗 → 退化成单体 |
| 演进式拆分(Monolith First) | 先单体跑通业务,等痛点出现再拆——这是 Martin Fowler 的明确建议 |
"什么时候不该拆"(同样重要的反面):
- 团队规模小(如 < 10 人):微服务的收益(独立部署)远小于成本(分布式复杂度);
- 业务边界还不清晰:领域模型还在剧烈变化时拆,会反复重构;
- 没有配套基础设施:没有注册发现、配置中心、链路追踪、CI/CD、监控告警,拆了就是灾难;
- 只为了"技术时髦":微服务是为了解决组织与规模问题,不是为了技术先进。
两个反模式(面试加分):
- 分布式单体:服务拆了,但必须一起发布、共享数据库、强耦合调用——成本翻倍、收益为零;
- 共享数据库:多个服务直接读写同一张表 → 表结构变更影响所有服务,耦合度比单体还高。
拆分的常见信号:发布冲突频繁、团队协作阻塞、某模块独立扩容需求强烈、单个服务的代码/数据库已达瓶颈、业务领域边界逐渐清晰。
6. 服务注册与发现的原理是什么?注册中心的 CAP 怎么取舍?
答: 服务提供者启动 → 注册到注册中心(IP、端口、权重、健康状态);消费者订阅 → 拉取/接收推送 → 本地缓存服务列表 → 负载均衡选节点调用;注册中心通过心跳或主动探测保活,实例下线/失联则剔除。
关键细节(区分"背过"与"懂原理"):
| 环节 | 说明 |
|---|---|
| 注册时机 | 通常启动后、能提供服务时才注册(避免流量打到未就绪实例);结合优雅停机先摘除再停服 |
| 健康检查 | 客户端主动上报心跳(Eureka/Nacos 临时实例)或服务端主动探测(Nacos 持久实例、Consul) |
| 推送 vs 拉取 | ZK:基于 watch 的推送(实时但需维护长连接);Nacos:推拉结合(UDP 推送 + 客户端定时拉取兜底);Eureka:客户端定时拉取(30s),所以变更有延迟 |
| 本地缓存 | 消费者必须缓存服务列表:注册中心全挂,已建立的调用不受影响(Dubbo 甚至能靠本地缓存长期运行) |
注册中心的 CAP 取舍:
| 注册中心 | 一致性模型 | 特点 |
|---|---|---|
| ZooKeeper / etcd / Consul | CP | 强一致(多数派写入),分区时可能不可用;Dubbo 传统默认用 ZK |
| Eureka | AP | 高可用优先,自我保护机制:当心跳丢失比例 超过阈值(默认 85%) 时,不再剔除任何实例——宁可保留"可能已死"的实例,也不因网络抖动误删大批健康实例 |
| Nacos | AP / CP 可切换 | 临时实例 → AP(Distro 协议,自研);持久化实例 → CP(Raft)。Spring Cloud Alibaba 主流选择 |
| Consul | CP | Raft + 多数据中心支持,健康检查能力强 |
为什么注册中心多数选 AP(重要认知):服务发现的核心诉求是"可用性"——宁可短暂拿到稍旧的列表(可能包含已下线实例,调用失败后重试即可),也不能因为注册中心不可用导致整个集群无法发现服务。因此 Eureka 的 AP 设计、Nacos 的 AP 模式在互联网场景更常见;CP 更适合"绝不能读到旧数据"的场景(如配置、选主、元数据)。
一句话:注册中心是"服务的电话簿",电话簿短暂过期可以忍,电话簿打不开不可忍 —— 这就是 AP 优先的原因。
7. 常见负载均衡策略有哪些?一致性哈希为什么需要虚拟节点?
答:
常见策略:
| 策略 | 说明 | 适用 |
|---|---|---|
| 轮询 / 加权轮询 | 依次分发;加权按性能分配(Nginx 的平滑加权轮询避免连续打到同一台) | 各节点性能一致(或已知权重),最常用 |
| 随机 / 加权随机 | 随机选,权重按性能 | 实现简单,长尾略差于轮询 |
| 最少活跃调用(Least Active) | 选当前处理请求数最少(或响应最快)的节点 | 各节点性能不均、请求耗时差异大时效果最好(Dubbo 默认) |
| 一致性哈希(Consistent Hashing) | 同 key 永远落同一节点(hash 环) | 需要会话粘滞 / 缓存亲和(如分布式缓存、本地缓存路由) |
| 源 IP 哈希 | 按客户端 IP 哈希 | 简单粘滞,但节点变动会大面积失效,且 NAT 场景下分布不均 |
| P2C(Power of Two Choices) | 随机选 2 个,取较优(如活跃数更少) | 效果接近"最少活跃"但开销极小,Envoy / gRPC 常用 |
客户端 LB vs 服务端 LB:
- 服务端 LB:Nginx、LVS、F5、云 SLB——对业务透明,但多一跳;
- 客户端 LB:Ribbon、Spring Cloud LoadBalancer、Dubbo——少一跳、可感知服务列表,但需每种语言实现,且配置分散。
一致性哈希与虚拟节点(高频追问):
基本思想:把 hash(节点) 与 hash(key) 都映射到 [0, 2^32) 的哈希环上,key 顺时针找到第一个节点。节点增删时,只影响相邻区间的数据(而不是全部 rehash),这是它的核心价值。
为什么需要虚拟节点:
- 数据倾斜:物理节点少时,哈希环上分布极不均匀(可能 80% 的 key 落在一台机器);
- 雪崩风险:某节点宕机,其数据全部压到顺时针的下一个节点——那个节点可能直接被打挂(连锁雪崩);
- 解法:给每个物理节点生成大量虚拟节点(如 160 个),把虚拟节点的哈希值分散到环上——虚拟节点越多,分布越均匀,单节点故障的影响也被均摊到所有节点。
实现注意:虚拟节点数量要按节点性能差异化分配(性能强的机器可以多分虚拟节点 = 更大权重);扩容时通过数据迁移 + 双读平滑过渡。
8. RPC 框架(如 Dubbo)的核心原理是什么?
答: RPC 的目标是让调用远程服务像调用本地方法。核心是"代理 + 序列化 + 网络传输"三件套,加上注册发现与协议。
核心组件:
| 组件 | 作用 | 典型实现 |
|---|---|---|
| 动态代理(Client/Server Stub) | 客户端用 JDK/CGLIB 代理把方法调用转成请求对象;服务端反射调用真实实现 | Dubbo 的 Invoker + ProxyFactory |
| 序列化协议 | 把对象编解码成字节流 | Hessian2(Dubbo 默认)、Kryo、Protobuf、JSON |
| 通信框架 | 长连接 + 多路复用 | Netty(NIO) |
| 注册中心 | 服务发现与路由 | ZK / Nacos |
| 协议 | 定义报文格式(魔数、请求 ID、状态、序列化类型、body) | Dubbo 协议、gRPC(HTTP/2 + Protobuf) |
| 集群容错 | 失败重试、路由、负载均衡、Mock | Dubbo Cluster(Failover 默认) |
调用流程(能完整说出这一步就过关):
1. 消费者调用接口方法(实际调用的是动态代理对象)
2. 代理封装 Request:唯一 requestId + 接口名 + 方法名 + 参数 + 超时
3. 序列化 → 通过 Netty 长连接发送(多条请求复用同一连接)
4. 提供者反序列化 → 按接口名找到实现 → 反射调用
5. 结果按 requestId 封装 Response → 原连接回传
6. 消费者按 requestId 找到等待中的 Future → 唤醒并返回结果四个高频深入点:
- 序列化选型:性能上 Protobuf ≈ Kryo > Hessian2 > JSON;跨语言用 Protobuf(gRPC),Java 内部用 Kryo / Hessian2(注意 Kryo 需注册类、对版本兼容不友好);
- Dubbo 为什么默认"单一长连接 + 线程池":小包高频场景省去反复建连的开销;但大文件/大包会占满单一连接造成阻塞——此时应改用多连接(
connections)或走 HTTP/独立通道; - 超时与重试:Dubbo 默认失败重试 2 次(共 3 次)——重试会放大下游压力,且要求接口幂等(对应《分布式(二)》);建议:查询类可重试,写入类慎重重试、并设熔断兜住重试风暴;
- 线程模型与"超时不可靠":业务线程池满时请求排队,客户端可能已超时,但服务端仍在执行——所以超时不是"请求没执行",只是"没等到结果",这也是必须幂等的另一理由。
9. 分布式会话(Session)如何共享?
答: 单体应用 Session 存本地内存,集群后用户请求可能落到没有该 Session 的节点,因此需要共享方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Session 复制 | 节点间互相广播同步 Session | 无额外组件、无状态改造 | 网络开销随节点数平方增长,节点多时不可用(已基本淘汰) |
| 会话粘滞(Sticky) | 网关 / LB 用 IP Hash 把同一用户固定到同一节点 | 零改造 | 单点故障(节点挂 → 会话丢失);扩缩容会 rehash 导致大面积掉登录 |
| 集中存储(推荐) | Session 存 Redis(Spring Session + Redis),节点完全无状态 | 高可用、易扩容、共享自然 | 多一次 Redis 访问(如 1ms);Redis 需高可用 |
| 客户端存储(Token / JWT) | 用户信息签名后放客户端,服务端不存 Session | 完全无状态,最适微服务 / 跨域 | 无法主动注销(需黑名单)+ 无法强制下线;token 体积大;续期需 refresh 机制 |
JWT 的关键注意点:
- 不能主动失效:除非维护黑名单(Redis)或短有效期 + refresh token;
- 别在 JWT 里放敏感信息(payload 只是 Base64,不是加密);
- 签名密钥要保密并支持轮换(kid 机制)。
生产主流做法(分层组合):
- 网关统一鉴权:登录态校验、token 解析、灰度/限流在网关完成;
- JWT 负责"身份"(无状态、便于水平扩展),Redis 负责"状态"(是否被踢下线、权限版本号、刷新令牌);
- 即"无状态为主 + 有状态兜底"——既能水平扩容,又能主动失效。
10. 分布式缓存(Redis)与数据库的一致性如何保证?
答: 本质是双写问题,且无法用分布式事务解决(性能不可接受),只能追求最终一致。四种组合的取舍:
| 策略 | 是否推荐 | 原因 |
|---|---|---|
| 先更新 DB,再更新缓存 | ❌ | 并发写时"后写的 DB 先更新缓存"会造成脏缓存;且更新缓存代价高(可能算了半天没人读,写多读少时纯浪费) |
| 先删缓存,再更新 DB | ❌ | 并发下:读线程在删除后、DB 更新前把旧值回填缓存,脏数据长期存在 |
| 先更新 DB,再删缓存(Cache-Aside) | ✅ 推荐 | 出现不一致的窗口极小(只有"删缓存失败"这一种);配合 TTL 兜底 |
| 删除缓存 + 延迟双删 / binlog 兜底 | ✅ | 在推荐方案上加固,应对"删缓存失败"与"主从延迟" |
推荐方案(Cache-Aside)的完整形态:
读:缓存命中 → 返回;未命中 → 查 DB → 回填缓存(设 TTL)
写:更新 DB → 删除缓存(失败则重试 / 走兜底)两个必须加固的点:
- 删缓存失败怎么办 → ① 重试(放进重试队列,类似本地消息表);② 靠 TTL 兜底(最终一定一致);③ 订阅 binlog(Canal)异步删缓存——把"删缓存"从业务代码里解耦,最稳。
- 主从延迟导致脏数据 → 更新 DB(主库)后删缓存,此时从库还没同步完;紧接着一个读请求走从库(读到旧值)并回填缓存 → 脏数据。对策:延迟双删(更新后立即删一次,延迟 500ms~1s 再删一次,把回填的旧值再清掉)或读关键数据时强制走主库。
为什么"删"而不是"更新"缓存(高频追问):
- 懒加载更高效:删掉后下次读时再回填,避免"写多读少"场景下反复无效更新;
- 避免并发覆盖:两个写请求并发时,"更新缓存"顺序可能与 DB 提交顺序不一致,而"删除"是幂等的(谁最后删都一样)。
一句话:缓存一致性的正确姿势是"先写库、再删缓存 + TTL 兜底 + binlog 加固";同时要接受"缓存本质上是用一致性换性能",强一致场景请直接读库或加分布式锁。
三、稳定性保障与高可用综合
11. 分布式系统如何做链路追踪(Tracing)?
答: 核心模型是 Trace + Span:一次请求 = 一条 Trace(TraceId 全局唯一);每次跨服务/跨组件调用 = 一个 Span(有 SpanId 与 ParentSpanId,记录起止时间与标签),从而还原调用链拓扑与耗时。
三个关键环节:
| 环节 | 做法 |
|---|---|
| 上下文透传 | 入口生成 TraceId → 通过 HTTP header(W3C traceparent) / RPC 附件(Dubbo attachment) / MQ 消息属性 透传给下游;异步与线程池要用 TransmittableThreadLocal 包装,否则链路断裂 |
| 采集与上报 | 字节码增强(Agent):SkyWalking、Pinpoint,无侵入;SDK 埋点:Jaeger、Zipkin、OpenTelemetry(当前事实标准),灵活但需改代码 |
| 存储与展示 | 存储常用 ES / Cassandra / ClickHouse;展示做调用拓扑、慢链路、错误率分析 |
采样策略(生产必备,否则成本失控):
- 头部采样:入口按比例(如 1%)决定是否采样——简单,但可能漏掉错误链路;
- 尾部采样:先采集全部,再根据结果(有错误 / 慢)决定保留——精准但需缓冲与内存成本;
- 折中:错误与慢请求 100% 采样 + 正常请求比例采样。
与其他可观测能力结合(可观测性三支柱):
- Metrics(监控):黄金指标 延迟 / 流量 / 错误 / 饱和度(USE / RED 方法);
- Logging:日志里 MDC 打入 TraceId,实现"从链路跳到日志";
- Tracing:链路串联。
两个必答的延伸点:
- 超时传递(Deadline Propagation):上游剩余超时要下发给下游(如上游剩 200ms,下游只能最多等 150ms),否则"上游已超时、下游还在傻跑",浪费资源;
- 重试放大:一次用户请求被多层重试(网关 × RPC × MQ)后,下游压力可能是 N^层数 倍 —— 所以重试必须限次、带退避、并配合熔断,这也是链路追踪能直观暴露的问题。
12. 如何保证分布式服务接口调用的顺序性?
答: 顺序性问题的来源:并发处理(多线程)、网络乱序(重试、多路径)、MQ 多分区。手段按"代价从小到大"排列:
| 手段 | 做法 | 代价 |
|---|---|---|
| 版本号 / 序号校验(首选) | 请求携带单调递增序号 seq,服务端只接受 seq > 已处理 seq 的请求,旧序号直接丢弃(也顺带防了重放) | 需存储"已处理序号"(一次 CAS 更新) |
| 状态机(最常用、最稳) | 只允许合法状态流转:UPDATE ... WHERE status = '待支付' → 已支付;逆序/重复操作影响行数为 0,直接拒绝 | 需要设计好状态机,但天然幂等 + 防逆序 |
| 分布式锁 + 业务主键 | 用 Redis/ZK 锁住业务 ID,临界区内串行处理并推进状态 | 加锁开销、并发下降 |
| 串行化 / 单线程处理 | 按业务 key 路由(如订单 ID 取模)到同一线程 / 同一内存队列 / 同一分区处理 | 牺牲并发;需处理队列积压与单点 |
| MQ 分区有序 | 同一业务 key 发到同一分区/队列,消费端单线程消费该分区(RocketMQ 顺序消息:MessageQueueSelector + 消费端加锁) | 只能保证分区内有序,无法全局有序;吞吐受限 |
三个必须说清的局限(答题深度所在):
- 顺序性 ≠ 全局串行:大多数业务只需要"同一业务实体(如同一订单)的操作保序",没必要全局串行——按 key 保序是性价比最高的做法;
- 顺序性通常意味着牺牲吞吐与可用性:单线程消费、加锁都会降低并发,扩容时还要保证同 key 路由不变(分区数变更会导致路由变化,需谨慎);
- 顺序不是刚需,"最终状态正确"才是:能用状态机 + 版本号兜底时,就不要强求消息严格有序——能幂等 + 能拒绝过期请求,比"严格有序"更健壮。
13. 消息队列在分布式系统中的作用是什么?如何保证不丢消息?
答:
作用(分布式视角):
| 作用 | 说明 |
|---|---|
| 异步解耦 | 下游故障/变更不影响上游主流程(下单不因积分服务挂而失败) |
| 削峰填谷 | 秒杀流量先进队列,下游按自身能力消费 |
| 最终一致(分布式事务核心组件) | 事务消息 / 本地消息表实现跨服务的最终一致(见《分布式(二)》) |
| 广播与数据分发 | 一写多读(缓存刷新、配置变更、binlog 同步) |
| 可观测与回溯 | 消息留存 → 可重放、可审计(如 Kafka 保留日志) |
代价(必须一起讲,否则答案不完整):一致性变弱(最终一致)、系统复杂度上升(重复消费、顺序、堆积)、运维成本(MQ 集群高可用)。
保证"不丢消息"需要三端齐备(详见中间件板块,此处给要点):
| 端 | 措施 |
|---|---|
| 生产端 | 开启确认机制(RabbitMQ confirm、Kafka acks=all、RocketMQ 同步发送)+ 失败重试;本地事务与发消息的原子性用事务消息 / 本地消息表保证 |
| Broker 端 | 持久化到磁盘 + 多副本(Kafka 多 ISR + min.insync.replicas、RocketMQ 主从/同步双写) |
| 消费端 | 先处理业务,成功后再提交 offset / ACK(顺序颠倒必丢);失败不 ACK 由 MQ 重投;配合 死信队列 处理反复失败的消息 |
核心结论:能做到的是"至少一次投递",因此必须配 消费幂等 才能达到"效果上的恰好一次"——"不丢"与"不重复"是两个独立问题,前者靠 MQ 的确认与持久化,后者靠业务幂等。
14. 如何设计一个高可用的分布式系统?(综合)
答: 高可用的三句心法:冗余消除单点 → 容错吸收故障 → 隔离限制爆炸半径。回答时按"分层 + 生命周期"组织:
(1)分层保障
| 层次 | 措施 |
|---|---|
| 接入层 | 多机房 / 多可用区 + DNS/GSLB 异地调度;LB 集群(LVS + Nginx)高可用(Keepalived VIP / 云 SLB) |
| 应用层 | 服务无状态 + 多实例;注册发现自动注册与摘除;优雅停机(先摘流量 → 等在途请求超时结束 → 停进程) |
| 服务间调用 | 超时(必设)+ 重试(限次、退避、幂等)+ 熔断 + 降级 + 隔离 + 限流 |
| 数据层 | 主从/多副本 + Quorum 选主;分库分表水平扩展;异地多活 / 单元化(要求高时);定期备份与恢复演练 |
| 依赖组件 | 缓存、MQ、注册中心、配置中心全部集群化,并准备降级预案(如 Redis 挂 → 走本地缓存 / 直连 DB) |
(2)容量与变更(最容易被忽略的可用性杀手)
- 容量评估 + 全链路压测:知道单机能扛多少,才能定限流阈值;
- 限流兜底:无论有没有故障,限流都是最后一道闸门;
- 变更管控:灰度发布(按机器 / 用户 / 流量比例)、可回滚、变更窗口避开高峰;
- 优雅停机:K8s 的
preStop+terminationGracePeriodSeconds,配合注册中心先摘除再关闭。
(3)可观测与故障处理
- 监控告警:黄金指标(延迟 / 流量 / 错误 / 饱和度)、业务指标(下单成功率、支付成功率);
- 链路追踪 + 日志聚合:5 分钟内定位到哪个服务 / 哪个节点 / 哪条 SQL;
- 预案与演练:限流/降级/切流预案要提前演练(混沌工程:主动注入故障);
- 故障处理原则:止损优先(先降级/限流/回滚恢复业务,再定位根因),事后复盘 + 改进项闭环。
(4)架构演进层级(面试加分,体现体系感)
单机 → 主备 → 集群(无状态多实例)
→ 读写分离 / 分库分表
→ 同城双活(同城双 AZ)
→ 异地多活 / 单元化(容灾 + 就近访问)(5)不要过度设计:高可用是有成本的(机器、复杂度、研发效率),应按业务等级分级投入——核心交易链路做到 99.99%,后台报表做到 99.9% 单机房就够。
总结一句话:高可用 = 冗余(没有单点)+ 容错(故障能自愈)+ 隔离(问题不扩散)+ 可观测(能快速发现)+ 可回滚(变更能撤回);而所有手段里,"限流 + 降级 + 幂等"是收益最高、最该先做的三件事。
