API网关设计:限流、熔断、降级、灰度发布
引言
凌晨两点,手机告警群炸了。某电商平台大促开始后的第 37 秒,订单服务 QPS 从 8000 飙升到 42000,三个数据库连接池瞬间打满,紧接着商品详情、购物车、支付服务像多米诺骨牌一样接连超时。运维同学手忙脚乱地重启服务,但重启后 10 秒内又被新流量打挂——典型的雪崩效应。
事后复盘发现:真正的问题不在于订单服务扛不住,而在于没有任何一道防线去拦截、削减、隔离这股洪流。如果当时有一个设计良好的 API 网关,第一层限流会直接拒绝超出配额的请求,第二层熔断会在订单服务延迟升高时快速失败,第三层降级会给用户返回兜底数据,第四层灰度能力甚至能让新版本只承接 1% 的流量做验证——这场事故根本不会发生。
API 网关不是简单的"反向代理 + 转发",它是整个微服务体系的总闸门、保险丝、泄压阀和分流器。本文从一位全栈架构师的视角,把限流、熔断、降级、灰度发布这四件事从算法原理讲到源码实现,再到生产环境的避坑指南,一次性讲透。
核心概念:把网关想象成一家餐厅的"前台"
先抛开技术,我们用一个餐厅类比把四个概念串起来:
| 网关能力 | 餐厅类比 | 技术本质 |
|---|---|---|
| 限流 | 门口保安控制入场人数,超过承载量就让顾客排队或劝返 | 控制单位时间内的请求速率 |
| 熔断 | 后厨某道菜老是做糊,前台就不再下单这道菜,避免厨房累垮 | 下游服务故障时快速失败,不再调用 |
| 降级 | 招牌菜没了,就给顾客上一份"今日推荐"替代品 | 主逻辑失败时返回兜底结果 |
| 灰度发布 | 新菜品先给 1% 的老顾客试吃,反馈好再全面上架 | 按规则将部分流量路由到新版本 |
这四件事有明确的职责边界:
- 限流面向入口流量,是"准入控制",保护的是整个系统不被压垮;
- 熔断面向下游依赖,是"故障隔离",保护的是调用方不被拖死;
- 降级面向业务可用性,是"体验兜底",保证核心链路在异常时仍能走通;
- 灰度发布面向变更风险,是"流量调度",让新版本以最小代价被验证。
它们组合在一起,才构成一个真正高可用的网关。下面我们逐个深入。
一、限流:从计数器到漏桶,算法与源码级实现
1.1 四种主流限流算法对比
限流算法看似简单,但选错了会在边界场景翻车。
计数器(固定窗口):最简单,把时间切成固定窗口(如每秒),窗口内计数超过阈值就拒绝。问题是临界突变——比如限制 100 QPS,在 0.9s 到 1.1s 之间可能通过 200 个请求,因为跨了两个窗口。
滑动窗口:把固定窗口再细分成多个小格(如 1s 分成 10 个 100ms 的格子),统计最近 1s 内所有格子的总和。解决了临界问题,代价是需要维护更多计数状态。Sentinel 的 LeapArray 就是这个思路。
漏桶(Leaky Bucket):请求先进入桶,桶以恒定速率漏出(处理)。桶满了就拒绝。流出速率恒定,适合做流量整形(traffic shaping),但无法应对突发流量。
令牌桶(Token Bucket):以恒定速率往桶里放令牌,请求来的时候拿走一个令牌,没令牌就拒绝。允许一定程度的突发(桶里攒的令牌可以一次用掉),这是绝大多数网关的选择——Guava RateLimiter、Nginx limit_req、Sentinel 的匀速排队模式都基于这个思想。
恒定速率] -->|放入| B[令牌桶
容量 N] R[请求] -->|取令牌| B B -->|有令牌| P[放行] B -->|无令牌| D[拒绝/排队] end
1.2 源码深度:Guava RateLimiter 的 SmoothBursty
很多人以为令牌桶就是"一个定时任务不停往桶里加令牌",实际上 Guava 用的是惰性计算——不启动任何定时器,而是在每次请求时根据时间差算出应该有多少令牌。看核心源码(SmoothRateLimiter):
// Guava SmoothRateLimiter#reserveEarliestAvailable 核心逻辑(简化)
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 1. 根据距离上次的时间差,补充令牌
resync(nowMicros);
long returnValue = nextFreeTicketMicros;
// 2. 计算本次能直接拿到的令牌数(storedPermits 和 requiredPermits 取小)
double storedPermitsToSpend = Math.min(requiredPermits, this.storedPermits);
// 3. 不够的部分需要"预支",计算要等多长时间
double freshPermits = requiredPermits - storedPermitsToSpend;
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
// 4. 更新下一次可用的时间点(关键:预支的时间会累加到下一次)
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
this.storedPermits -= storedPermitsToSpend;
return returnValue;
}
// 惰性补令牌:根据时间差计算新增令牌
void resync(long nowMicros) {
if (nowMicros > nextFreeTicketMicros) {
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
storedPermits = Math.min(maxPermits, storedPermits + newPermits);
nextFreeTicketMicros = nowMicros;
}
}这里有个精妙的设计值得注意:nextFreeTicketMicros 会"预支"未来时间。也就是说,如果现在令牌不够,Guava 不是拒绝请求,而是让它等——并且这个等待时间会累加到下一次请求上。这就是为什么 SmoothBursty 能实现平滑的突发:桶里攒的令牌可以立刻用掉,但用超了要还债。
SmoothBursty 还有一个隐藏坑:桶容量默认只够存 1 秒的令牌(maxBurstSeconds = 1.0)。如果你的服务 QPS 限制是 1000,那桶里最多攒 1000 个令牌,突发流量最多一次打 1000 个。生产环境如果想让突发更大,需要通过 RateLimiter.create(permitsPerSecond) 后没有直接 API 调整,得用反射或改用 Sentinel。
1.3 分布式限流:单机限流为什么不够
单机 Guava RateLimiter 只能限制单实例。假设你有 10 个网关实例,每个限 1000 QPS,集群实际能放 10000 QPS 进来——如果后端只能扛 5000,照样被打挂。
分布式限流必须有一个共享的计数器,通常是 Redis + Lua 脚本保证原子性。下面是完整的生产级 Redis 令牌桶实现:
-- distributed_rate_limiter.lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 桶容量 capacity
-- ARGV[2]: 令牌生成速率 rate(个/秒)
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 本次请求令牌数
-- 返回: 1 表示放行,0 表示拒绝
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 获取当前桶状态:tokens 剩余令牌,last_time 上次刷新时间
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
-- 初始化
if tokens == nil then
tokens = capacity
last_time = now
end
-- 惰性补充令牌:按时间差计算
local delta = math.max(0, now - last_time)
local filled = delta * rate / 1000.0
tokens = math.min(capacity, tokens + filled)
-- 判断是否够用
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 写回状态,设置过期时间防止冷 key 堆积
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
return allowedJava 侧调用:
public class RedisTokenBucketLimiter {
private final RedissonClient redisson;
private final String luaSha;
public RedisTokenBucketLimiter(RedissonClient redisson) throws IOException {
this.redisson = redisson;
// 预加载 Lua 脚本,返回 SHA1,后续用 evalsha 减少网络传输
String script = loadScriptFromClasspath("distributed_rate_limiter.lua");
this.luaSha = redisson.getScript().scriptLoad(script);
}
/**
* @param key 限流维度,如 "order:create:userId:123"
* @param capacity 桶容量
* @param rate 每秒补充令牌数
* @return true 放行
*/
public boolean tryAcquire(String key, int capacity, double rate) {
long now = System.currentTimeMillis();
List<Object> keys = Collections.singletonList(key);
Object[] args = {capacity, rate, now, 1};
Long result = redisson.getScript().evalSha(
RScript.Mode.READ_WRITE, luaSha,
RScript.ReturnType.INTEGER, keys, args);
return result != null && result == 1L;
}
}生产环境的坑:
- Redis 本身成为瓶颈:每次请求都要打一次 Redis,如果 QPS 上万,Redis 网络往返会拖慢网关。优化手段是用批量预取(一次取 100 个令牌缓存在本地,用完再取),代价是限流精度下降。
- 时钟漂移:多机之间
System.currentTimeMillis()可能有偏差,导致令牌算多或算少。建议用 Redis 的TIME命令统一时间源,或者接受小幅偏差。 - Lua 脚本的 key 必须用
{tag}保证同槽:集群模式下,HMGET和HMSET操作如果跨 slot 会报错。key 里带{order}这种 hash tag 能强制路由到同一节点。
二、熔断:从状态机到 Resilience4j 源码
2.1 熔断器的三种状态
熔断器的本质是一个有限状态机,只有三个状态:
- CLOSED(关闭):正常放行所有请求,同时统计成功/失败/慢调用。
- OPEN(打开):直接快速失败,不调用下游,给下游喘息时间。
- HALF_OPEN(半开):放行少量探测请求,如果成功则恢复 CLOSED,失败则回到 OPEN。
关键参数:
- 失败率阈值(如 50%):窗口内失败比例超过它才触发熔断,避免偶发失败误伤。
- 最小请求数(如 20):窗口内请求太少时不熔断,防止"1 个请求失败 = 100% 失败率"。
- 慢调用阈值(如 500ms):超过这个时间的请求算"慢调用",也计入失败统计——这点非常重要,熔断不只看异常,更要看延迟。
- 熔断时长(如 10s):OPEN 状态持续多久后进入 HALF_OPEN。
2.2 源码深度:Resilience4j CircuitBreaker 状态转换
Resilience4j 是当前 Java 生态最主流的熔断实现(Hystrix 已停止维护)。看它的核心状态转换逻辑(CircuitBreakerStateMachine):
// Resilience4j CircuitBreakerStateMachine 核心片段(简化)
private void evaluateCircuitBreakerState(CircuitBreakerState currentState) {
if (currentState == CLOSED_STATE) {
// 关闭状态:判断是否要打开
Metrics metrics = this.metrics.get();
float failureRate = metrics.getFailureRate();
int bufferedCalls = metrics.getNumberOfBufferedCalls();
// 触发条件:调用数达到最小阈值 AND 失败率超限
if (bufferedCalls >= config.getMinimumNumberOfCalls()) {
boolean failureRateExceeded = failureRate >= config.getFailureRateThreshold();
boolean slowCallRateExceeded = metrics.getSlowCallRate()
>= config.getSlowCallRateThreshold();
if (failureRateExceeded || slowCallRateExceeded) {
transitionToOpenState();
}
}
} else if (currentState == HALF_OPEN_STATE) {
// 半开状态:根据探测结果决定回到 CLOSED 还是 OPEN
Metrics metrics = this.metrics.get();
int bufferedCalls = metrics.getNumberOfBufferedCalls();
if (bufferedCalls >= config.getPermittedNumberOfCallsInHalfOpenState()) {
if (metrics.getFailureRate() >= config.getFailureRateThreshold()) {
transitionToOpenState(); // 探测失败,继续熔断
} else {
transitionToClosedState(); // 探测成功,恢复
}
}
}
// OPEN 状态不在这里处理,由定时器 afterWaitDuration 触发转 HALF_OPEN
}几个源码级的洞察:
- 滑动窗口有两种实现:
COUNT_BASED(基于调用次数,如最近 100 次)和TIME_BASED(基于时间,如最近 60 秒)。高并发场景推荐TIME_BASED,因为固定调用次数在低峰期会"记太久",把几小时前的失败也算进去。
- 原子性统计:Resilience4j 用
LongAdder而不是AtomicLong做计数,在高并发下性能更好(分散热点,最后汇总)。
- 半开状态的探测数:默认
permittedNumberOfCallsInHalfOpenState = 10,意思是半开时最多放 10 个请求探测。如果下游恢复得慢,这 10 个也会超时失败,导致重新 OPEN。生产环境如果下游冷启动慢,可以适当调大这个值。
2.3 熔断 vs 限流:容易混淆的两个概念
很多人搞不清"熔断"和"限流"的区别。一句话:限流保护自己,熔断保护下游。
- 限流是"我知道我最多只能处理 1000 QPS,超过的别进来"——主动拒绝。
- 熔断是"我发现下游挂了我还一直调它,会把我自己也拖死,所以先停止调用"——被动隔离。
两者常常配合使用:网关层做限流挡住超量入口,服务间调用做熔断防止级联失败。
三、降级:让核心链路在故障中依然能走通
3.1 降级的三个层次
降级不是简单的 try-catch 返回默认值,它有三个层次:
第一层:接口级降级——某个非核心接口(如推荐、评论)失败时,直接返回空或缓存数据,不影响主流程。
第二层:功能级降级——某个功能模块整体不可用时,关闭这个功能。比如大促期间关闭"商品评价",只保留"下单"。
第三层:业务级降级——整个系统进入"保命模式",只保留最核心的链路。比如支付系统故障时,先让订单进入"待支付"状态,异步补偿。
3.2 降级数据的来源
降级的核心是有东西可返回。常见的兜底数据来源:
- 本地缓存:热点数据(如商品基础信息)缓存在网关本地,下游挂了直接返回缓存。
- 静态兜底:预置的默认值,如"服务繁忙,请稍后重试"。
- 异步缓存:通过消息队列预热的副本数据。
- 限流降级:被限流的请求走降级逻辑,返回排队提示而非直接报错。
3.3 完整实战:基于 Spring Cloud Gateway + Sentinel 的降级
Sentinel 是阿里开源的流量治理组件,其 @SentinelResource 注解配合 blockHandler 和 fallback 能优雅实现降级。下面是一个完整的网关降级配置:
@Configuration
public class GatewayDegradeConfig {
/**
* 自定义降级响应:根据异常类型返回不同内容
* - BlockException:被限流/熔断,返回 429
* - 其他异常:业务失败,返回兜底数据
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public GlobalFilter degradeFilter() {
return (exchange, chain) -> chain.filter(exchange)
.onErrorResume(throwable -> {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body;
if (throwable instanceof BlockException) {
// 被 Sentinel 拦截:限流或熔断
body = "{\"code\":429,\"msg\":\"当前请求过于频繁,请稍后重试\","
+ "\"data\":null,\"degraded\":true}";
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
} else if (throwable instanceof TimeoutException) {
// 下游超时:返回缓存或兜底
body = "{\"code\":200,\"msg\":\"success\","
+ "\"data\":{\"cached\":true},\"degraded\":true}";
} else {
body = "{\"code\":500,\"msg\":\"服务暂时不可用\","
+ "\"data\":null,\"degraded\":true}";
}
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
});
}
}配合 Sentinel 的规则配置:
@Component
public class SentinelRuleInitializer implements CommandLineRunner {
@Override
public void run(String... args) {
// 1. 限流规则:订单接口 1000 QPS
FlowRule flowRule = new FlowRule();
flowRule.setResource("order_create");
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
flowRule.setCount(1000);
// 关联限流:订单接口被限流时,同时限制商品详情接口
flowRule.setRefResource("product_detail");
flowRule.setStrategy(RuleConstant.STRATEGY_ASSOCIATE);
// 2. 熔断规则:慢调用比例 > 50% 且 RT > 500ms 时熔断 10s
DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("order_create");
degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_RT);
degradeRule.setCount(500); // RT 阈值 500ms
degradeRule.setSlowRatioThreshold(0.5); // 慢调用比例阈值
degradeRule.setMinRequestAmount(20); // 最小请求数
degradeRule.setStatIntervalMs(10000); // 统计窗口 10s
degradeRule.setTimeWindow(10); // 熔断时长 10s
// 3. 系统自适应保护:整体 Load 超过阈值时全局限流
SystemRule systemRule = new SystemRule();
systemRule.setHighestSystemLoad(8.0); // 1 分钟平均 load 超过 8 触发
systemRule.setAvgRt(200); // 所有入口平均 RT 超过 200ms 触发
systemRule.setMaxThread(500); // 并发线程数超过 500 触发
FlowRuleManager.loadRules(Collections.singletonList(flowRule));
DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));
SystemRuleManager.loadRules(Collections.singletonList(systemRule));
}
}降级的最佳实践:
- 降级开关要能动态调整:通过配置中心(Nacos/Apollo)实时推送,不要重启服务。Sentinel 的 Dashboard 支持动态改规则。
- 降级要有监控:每次降级都要上报指标,否则你会不知道系统正在"带病运行"。
- 降级要分级:区分"返回缓存"、"返回空"、"返回错误"三种降级级别,优先用体验最好的。
- 降级要有恢复机制:不能降级了就忘了恢复,要配合熔断的半开探测自动恢复。
四、灰度发布:让新版本只承接 1% 的流量
4.1 灰度发布的四种流量切分策略
| 策略 | 切分依据 | 适用场景 |
|---|---|---|
| 按比例 | 随机 1%、5%、50% | 通用,最简单 |
| 按用户 | userId 取模、白名单 | 定向验证,同用户始终走同版本 |
| 按 Header | 自定义 header(如 x-gray: true) |
内部测试、A/B 测试 |
| 按地域/机房 | IP 段、机房标签 | 就近验证,降低跨地域影响 |
4.2 源码深度:Spring Cloud Gateway 的灰度路由
Spring Cloud Gateway 的灰度核心是自定义 GatewayFilter,通过 LoadBalancerClientFilter 的 GATEWAY_REQUEST_URL_ATTR 属性改写目标服务实例。下面是一个完整的按用户灰度实现:
@Component
public class GrayReleaseFilter implements GlobalFilter, Ordered {
private final DiscoveryClient discoveryClient;
// 灰度版本权重:1% 流量走新版本
private static final int GRAY_PERCENT = 1;
public GrayReleaseFilter(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String serviceId = exchange.getAttribute(
ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR) != null
? exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR).getHost()
: null;
// 1. 判断是否走灰度
boolean gray = shouldGray(request);
// 2. 获取对应版本的服务实例列表
List<ServiceInstance> instances = discoveryClient.getInstances(serviceId);
List<ServiceInstance> candidates = instances.stream()
.filter(i -> gray
? "v2".equals(i.getMetadata().get("version"))
: "v1".equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
if (candidates.isEmpty()) {
// 灰度版本无实例,回退到稳定版本
candidates = instances.stream()
.filter(i -> "v1".equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
}
// 3. 负载均衡选出一个实例
ServiceInstance target = loadBalance(candidates);
URI uri = UriComponentsBuilder.fromUri(target.getUri())
.path(request.getURI().getPath())
.query(request.getURI().getQuery())
.build(true).toUri();
exchange.getAttributes().put(
ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, uri);
return chain.filter(exchange);
}
/**
* 灰度判定:多种策略组合
* 1. 白名单用户直接走灰度(内部测试)
* 2. Header 显式指定走灰度
* 3. 按 userId 哈希取模,稳定命中同一版本
*/
private boolean shouldGray(ServerHttpRequest request) {
// 策略 1:白名单
String userId = request.getHeaders().getFirst("x-user-id");
if (userId != null && isGrayWhitelist(userId)) {
return true;
}
// 策略 2:Header 显式指定
if ("true".equals(request.getHeaders().getFirst("x-gray-enabled"))) {
return true;
}
// 策略 3:按 userId 哈希取模(保证同一用户稳定走同一版本)
if (userId != null) {
int hash = Math.abs(userId.hashCode());
return hash % 100 < GRAY_PERCENT;
}
// 无 userId 时按比例随机
return ThreadLocalRandom.current().nextInt(100) < GRAY_PERCENT;
}
private ServiceInstance loadBalance(List<ServiceInstance> instances) {
return instances.get(ThreadLocalRandom.current().nextInt(instances.size()));
}
@Override
public int getOrder() {
// 必须在 LoadBalancerClientFilter 之前执行
return Ordered.LOWEST_PRECEDENCE - 1;
}
}灰度发布的完整流程:
权重降为 0] E -->|正常| G[逐步扩大灰度比例
1% → 5% → 50% → 100%] D --> H[稳定服务]
灰度的关键坑:
- 同一用户必须稳定命中同一版本:如果用户刷新一次就看到不同版本,体验会很差,也可能导致数据不一致。所以按 userId 哈希取模比随机更合适。
- 数据库兼容性:新版本如果改了表结构,必须保证旧版本也能读(通常是加字段不加约束,双写过渡)。
- 灰度回滚要快:通过配置中心动态调整灰度权重,从 1% 到 0% 应该秒级生效,不要靠重启。
- 监控要跟上:灰度版本必须有独立的监控面板,对比 v1 和 v2 的异常率、RT、业务指标。
五、方案对比:自研 vs 开源 vs 云原生
| 方案 | 代表产品 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 自研网关 | 基于 Netty 手写 | 完全可控,可深度定制 | 开发成本高,稳定性风险大 | 超大规模、有特殊需求 |
| Java 生态 | Spring Cloud Gateway + Sentinel | 生态成熟,注解式接入 | 性能一般,JVM 内存占用高 | 中小规模 Java 体系 |
| Go 生态 | APISIX、Kong | 高性能,插件丰富 | 生态偏弱,二次开发门槛高 | 高性能、多语言体系 |
| Service Mesh | Istio + Envoy | 与业务解耦,Sidecar 模式 | 运维复杂,延迟增加 | 大型企业、K8s 深度使用 |
| 云厂商网关 | 阿里云 API 网关、AWS API Gateway | 免运维,弹性伸缩 | 成本高,厂商锁定 | 快速上线、中小团队 |
选型建议:
- 10 个服务以内:Spring Cloud Gateway 足够,配合 Sentinel 一站式解决。
- 50+ 服务、多语言:APISIX 或 Istio,前者轻量,后者功能全。
- 已有 K8s 且有专职 SRE:Istio + Envoy,把流量治理下沉到基础设施层。
- 追求极致性能:APISIX(基于 OpenResty,单机可达 10 万 QPS)或自研。
一个常见误区:很多团队一开始就上 Istio,结果发现运维成本远超收益。从简单方案起步,遇到瓶颈再演进才是正道。
六、最佳实践与避坑指南
6.1 限流的坑
- 限流阈值拍脑袋定:必须基于压测数据。用 JMeter/Gatling 压出 P99 RT 拐点,取拐点的 80% 作为阈值。
- 只做单机限流:集群总量失控。生产环境必须用 Redis 或 Sentinel 集群流控。
- 限流维度太粗:只按接口限流,导致某个恶意用户打满配额。应该按
IP + userId + 接口多维度组合。 - 被限流后返回 500:应该是 429 Too Many Requests,并带上
Retry-After头告诉客户端何时重试。
6.2 熔断的坑
- 最小请求数设太小:20 个请求里有 1 个失败就是 5% 失败率,容易误熔断。生产建议最小 50~100。
- 忽略慢调用:只看异常率,不看 RT。下游"不报错但很慢"同样会拖垮系统,必须把慢调用纳入熔断统计。
- 熔断时长设太短:下游还没恢复就重新放量,导致反复熔断。建议 10~30s 起步。
- 没有降级配合:熔断后直接返回错误,用户体验差。应该配合降级返回缓存数据。
6.3 降级的坑
- 降级逻辑本身有 bug:降级代码路径平时不走,一旦触发才发现有 NPE。降级逻辑必须定期演练。
- 降级数据过期:缓存兜底数据没有 TTL,返回的是几小时前的脏数据。必须设置合理的过期时间。
- 降级没有开关:出问题时无法快速关闭某个降级逻辑,只能重启。必须动态可配。
6.4 灰度的坑
- 灰度实例和稳定实例共用数据库连接池:新版本慢查询会拖垮整个池。建议灰度实例使用独立的资源池。
- 灰度期间改配置:灰度发布时配置中心推错配置,会导致灰度版本异常,误判为代码问题。灰度期间配置变更要审批。
- 灰度没有回滚预案:出问题手忙脚乱。必须提前准备好"一键切回 v1"的脚本或配置。
6.5 综合最佳实践
系统 Load/线程数] B --> C[第二层:接口限流
QPS/并发数] C --> D[第三层:熔断
异常率/慢调用率] D --> E[第四层:降级
缓存/兜底] E --> F[第五层:灰度路由
按用户/比例] F --> G[后端服务] G --> H[监控告警
Prometheus + Grafana] H -.反馈.-> B
核心原则:
- 分层防御:不要指望单一手段,限流、熔断、降级、灰度层层配合。
- 动态可调:所有阈值必须能通过配置中心实时调整,不要硬编码。
- 可观测:每次限流、熔断、降级都要打点上报,否则你不知道系统在发生什么。
- 定期演练:混沌工程(Chaos Mesh)模拟下游故障,验证熔断降级是否生效。
- 渐进式灰度:1% → 5% → 20% → 50% → 100%,每一步观察至少 30 分钟。
总结
回到开头那场事故。如果当时有这套体系:
- 限流会在流量超过 8000 QPS 时直接拒绝多余请求,数据库连接池不会被打满;
- 熔断会在订单服务 RT 超过 500ms 时快速失败,不再拖垮商品、购物车服务;
- 降级会让被拒绝的用户看到"当前排队人数较多"的友好提示,而不是 500 错误页;
- 灰度发布甚至能让大促前的新版本只承接 1% 流量,提前发现问题。
API 网关的这四种能力,本质上是用工程手段把"不确定性"变成"确定性"。流量是不确定的,限流让它确定;下游故障是不确定的,熔断让它确定;业务异常是不确定的,降级让它确定;版本变更是不确定的,灰度让它确定。
延伸思考:
- 当服务规模到几千个,集中式网关会成为瓶颈,此时需要考虑多级网关(边缘网关 + 服务网格 Sidecar)。
- 限流、熔断的规则如果靠人工配置,规模大了根本管不过来,未来趋势是基于 AI 的自适应限流(如 Sentinel 的系统自适应保护)。
- Service Mesh 正在把网关能力下沉到 Sidecar,但网关作为"南北向流量"入口,仍有不可替代的价值(认证、协议转换、API 管理)。
技术没有银弹,只有在正确的场景用正确的工具。希望这篇文章能帮你在设计网关时少踩几个坑。
参考资料:
- Guava RateLimiter 源码(
com.google.common.util.concurrent.SmoothRateLimiter)
- Resilience4j CircuitBreaker 源码(
io.github.resilience4j.circuitbreaker.internal.CircuitBreakerStateMachine)
- Alibaba Sentinel 源码(
com.alibaba.csp.sentinel.slots.block.flow)
- Spring Cloud Gateway 官方文档
- 《分布式服务架构:原理、设计与实战》