单机应用里调用就是一次函数调用,失败立即抛异常。服务拆开后调用跨了网络,故障从”失败”扩展成”慢””超时””半死不活”三种新形态,而且往往先慢后挂、局部扩散。注册发现、熔断、限流处理的分别是”调用谁””要不要继续调””允许多少进来”。
注册发现与健康检查
服务实例的 IP 和端口不是固定的:扩缩容、故障迁移、滚动发布都会让实例列表变化。注册中心维护的就是这份动态清单。
健康检查决定一个实例什么时候被摘除,常见两种:
心跳由实例主动向注册中心汇报,连续 N 个周期没收到就标记不健康。实现简单,但只能证明进程还在,不能证明它能正常服务——线程池打满、连接池耗尽时心跳照发。
主动探测由注册中心发起 TCP 连接或 HTTP 探活请求。能反映真实服务能力,代价是探测本身有成本,实例规模大时探测链路会成为负担,且探测失败可能是网络抖动而非实例故障。实践里通常两者结合:心跳做主体,健康检查接口做辅助,摘除前要求多个探测节点同时判定失败,避免单点误判。
摘除策略要留缓冲。常见状态机是:不健康先降权减少流量,持续失败才摘除,恢复时先进预热期逐步放量。直接摘除的风险是雪崩——网络抖动导致大量实例同时被摘,流量全压到少数实例上。
推拉模型的差别在于实例列表变更如何到达调用方:
| 模型 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 推 | 注册中心变更时主动推送 | 变更秒级生效 | 连接维护复杂,推送失败要补偿 |
| 拉 | 调用方定时轮询拉取 | 实现简单,容错好 | 有延迟,轮询间隔要权衡 |
| 长轮询 | 拉取请求挂起等变更 | 接近实时且容错 | 实现复杂度居中 |
延迟与实时性是主要权衡点。故障摘除要求秒级生效,纯拉取(例如 30 秒一次)在故障场景下会多损失几十秒的请求量。
客户端发现与服务端发现
客户端发现是调用方从注册中心拿到实例列表,自己在本地做负载均衡,然后直连目标实例。少一跳网络,性能好,负载策略可以按需定制。代价是每个语言都要实现一套逻辑,SDK 版本很难统一升级。
服务端发现是调用方固定访问某个入口(负载均衡器或网格 sidecar),由它负责选实例转发。调用方零感知,语言无关,策略集中升级。代价是多一跳带来的延迟与资源占用,入口本身也成了故障域。
选择依据通常是技术栈统一程度:单一语言栈适合客户端发现,多语言混合、且已有基础设施团队时,服务端发现或 Service Mesh 更省事。
熔断器的三态机
熔断器的目的是快速失败:下游已经不可用时,与其让每个请求都卡到超时,不如立刻返回,把资源留给有可能成功的调用。它是一个状态机:
失败率超过阈值
Closed ──────────────> Open
^ |
| | 熔断时长到点,放行少量探针
| v
|___ 探针成功率达标 ___ Half-Open
| |
|_____________________| 探针失败,立即回到 Open
Closed(关闭):请求正常放行,同时统计最近一段时间的结果。统计窗口的选择决定灵敏度:计数窗口(固定时间 buckets)实现简单但有边界效应,滑动窗口把时间切成小片滚动统计,能更平滑地反映趋势。常用的衡量口径是”最近 N 个请求或最近 T 秒内,失败率超过 X%”——两个条件要同时限制,避免低流量时单个失败就把服务整段切断。
Open(打开):请求直接失败,不再发起调用。这里的关键参数是熔断时长,通常从几十秒起,配合指数退避递增。被调用方返回降级响应(缓存值、默认值、空列表),而不是把错误抛给上游。
Half-Open(半开):熔断时间到之后不是全量恢复,而是放行少量探针请求。成功达到阈值才回到 Closed,任意失败立刻回到 Open。这一步防止了下游刚恢复就被全量流量再次打垮。
// 关键参数:窗口长度、最小请求数、失败率阈值、熔断时长、探针数量
CircuitBreakerConfig cfg = CircuitBreakerConfig.custom()
.slidingWindow(SlidingWindowType.COUNT_BASED, 100) // 统计最近 100 次调用
.minimumNumberOfCalls(20) // 低于 20 次不参与判断
.failureRateThreshold(50) // 失败率超 50% 才跳闸
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedNumberOfCallsInHalfOpenState(5)
.build();
minimumNumberOfCalls 常被忽略,却是低流量场景下防止误熔断的主要防线。
熔断器还要与超时配合:下游超时明显大于熔断窗口时长时,统计严重滞后;超时过长(例如 30 秒)会让线程池在熔断触发前就耗尽。经验做法是让下游超时逐层小于上游超时,形成收敛的超时栈。
限流算法与突发流量
限流回答的是”允许多少进来”。四种常见算法的差别集中在如何处理突发:
| 算法 | 实现 | 突发容忍 | 主要代价 |
|---|---|---|---|
| 固定计数器 | 每段时间计数,超限拒绝 | 边界处可放行两倍配额 | 临界突刺,精度差 |
| 滑动窗口 | 窗口切片滚动统计 | 平滑,精度取决于切片 | 内存随切片数增长 |
| 令牌桶 | 恒定速率生成令牌,取不到则拒绝 | 允许突发,突发量等于桶容量 | 桶容量要按业务调 |
| 漏桶 | 请求入桶,恒定速率出水 | 不允许突发,超出排队或丢弃 | 突发场景响应变慢 |
固定计数器的临界问题是:配额 100/分钟,59 秒时来了 100 个,下一秒又来了 100 个,两秒内实际通过 200 个。滑动窗口通过切片解决但消耗内存。令牌桶的工程平衡最好——桶容量允许一定程度的突发(这对秒杀开头的瞬时流量是必要的),均值仍被令牌生成速率约束。
参数不该拍脑袋:先用压测找到单机容量拐点(延迟开始明显上升时的 QPS),再乘以实例数留出余量,经验上把阈值设在拐点的七到八成。
限流放在哪里
网关层限流集中管理,配置统一,能拦住入口的超额流量。它看不到业务维度——无法区分同一个大客户的批量操作与普通浏览,也感知不到具体租户的异常。
服务内部限流贴近业务,可以按租户、接口、用户等级分别设配额,保护的是自己。
两者通常并存:网关做来源维度的粗粒度保护,服务内部做业务维度的细粒度保护。只做网关限流的隐患是内部流量——重试放大、批处理、内部调用——完全不受约束,更容易打垮脆弱的下游。
被拒绝的请求怎么处理同样要设计。直接返回错误会诱发上游重试从而放大流量,更好的做法是附带退避提示,告诉调用方多久后再试。
降级与恢复
降级是在资源不足或依赖不可用时,主动降低服务质量以保住核心链路。触发条件通常三类:熔断打开、限流命中、资源水位(CPU、连接池、线程池队列)超过阈值。
降级策略要提前按接口分级:核心链路(下单、支付)只做扩容与限流,不降级;边缘能力(推荐、评价、积分)返回空值或默认值;可延迟能力(通知、统计)转为异步或丢弃。这份分级表要在平时定好,而不是故障现场临时决定。
恢复比触发更难。稳妥做法是阶梯放量:先恢复少量流量观察一段时间指标,正常再逐步放开,并把自动恢复与手动开关结合——自动恢复应对短暂抖动,长时间依赖故障需要人来判断。没有指标支撑的自动恢复,容易在”恢复—再次打垮—再降级”的循环里反复。
评论(2)
想问下你实际遇到过最大的坑是哪个?
我不太理解为什么大家都默认要用这个方案,感觉成本被低估了。
请 登录 后参与评论。