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 的匀速排队模式都基于这个思想。

graph LR subgraph 令牌桶 G[令牌生成器
恒定速率] -->|放入| 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 allowed

Java 侧调用:

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;
    }
}

生产环境的坑

  1. Redis 本身成为瓶颈:每次请求都要打一次 Redis,如果 QPS 上万,Redis 网络往返会拖慢网关。优化手段是用批量预取(一次取 100 个令牌缓存在本地,用完再取),代价是限流精度下降。
  2. 时钟漂移:多机之间 System.currentTimeMillis() 可能有偏差,导致令牌算多或算少。建议用 Redis 的 TIME 命令统一时间源,或者接受小幅偏差。
  3. Lua 脚本的 key 必须用 {tag} 保证同槽:集群模式下,HMGETHMSET 操作如果跨 slot 会报错。key 里带 {order} 这种 hash tag 能强制路由到同一节点。

二、熔断:从状态机到 Resilience4j 源码

2.1 熔断器的三种状态

熔断器的本质是一个有限状态机,只有三个状态:

stateDiagram-v2 [*] --> CLOSED CLOSED --> OPEN: 失败率/慢调用率超过阈值 OPEN --> HALF_OPEN: 等待窗口结束 HALF_OPEN --> CLOSED: 探测请求成功率达到阈值 HALF_OPEN --> OPEN: 探测请求失败
  • 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
}

几个源码级的洞察

  1. 滑动窗口有两种实现COUNT_BASED(基于调用次数,如最近 100 次)和 TIME_BASED(基于时间,如最近 60 秒)。高并发场景推荐 TIME_BASED,因为固定调用次数在低峰期会"记太久",把几小时前的失败也算进去。
  1. 原子性统计:Resilience4j 用 LongAdder 而不是 AtomicLong 做计数,在高并发下性能更好(分散热点,最后汇总)。
  1. 半开状态的探测数:默认 permittedNumberOfCallsInHalfOpenState = 10,意思是半开时最多放 10 个请求探测。如果下游恢复得慢,这 10 个也会超时失败,导致重新 OPEN。生产环境如果下游冷启动慢,可以适当调大这个值。

2.3 熔断 vs 限流:容易混淆的两个概念

很多人搞不清"熔断"和"限流"的区别。一句话:限流保护自己,熔断保护下游

  • 限流是"我知道我最多只能处理 1000 QPS,超过的别进来"——主动拒绝
  • 熔断是"我发现下游挂了我还一直调它,会把我自己也拖死,所以先停止调用"——被动隔离

两者常常配合使用:网关层做限流挡住超量入口,服务间调用做熔断防止级联失败。


三、降级:让核心链路在故障中依然能走通

3.1 降级的三个层次

降级不是简单的 try-catch 返回默认值,它有三个层次:

第一层:接口级降级——某个非核心接口(如推荐、评论)失败时,直接返回空或缓存数据,不影响主流程。

第二层:功能级降级——某个功能模块整体不可用时,关闭这个功能。比如大促期间关闭"商品评价",只保留"下单"。

第三层:业务级降级——整个系统进入"保命模式",只保留最核心的链路。比如支付系统故障时,先让订单进入"待支付"状态,异步补偿。

3.2 降级数据的来源

降级的核心是有东西可返回。常见的兜底数据来源:

  1. 本地缓存:热点数据(如商品基础信息)缓存在网关本地,下游挂了直接返回缓存。
  2. 静态兜底:预置的默认值,如"服务繁忙,请稍后重试"。
  3. 异步缓存:通过消息队列预热的副本数据。
  4. 限流降级:被限流的请求走降级逻辑,返回排队提示而非直接报错。

3.3 完整实战:基于 Spring Cloud Gateway + Sentinel 的降级

Sentinel 是阿里开源的流量治理组件,其 @SentinelResource 注解配合 blockHandlerfallback 能优雅实现降级。下面是一个完整的网关降级配置:

@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,通过 LoadBalancerClientFilterGATEWAY_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;
    }
}

灰度发布的完整流程

graph TD A[客户端请求] --> B{灰度判定} B -->|白名单/Header| C[路由到 v2 新版本] B -->|userId 取模 < 1| C B -->|其他| D[路由到 v1 稳定版本] C --> E{异常率/RT 监控} E -->|异常率 > 1%| F[自动回滚
权重降为 0] E -->|正常| G[逐步扩大灰度比例
1% → 5% → 50% → 100%] D --> H[稳定服务]

灰度的关键坑

  1. 同一用户必须稳定命中同一版本:如果用户刷新一次就看到不同版本,体验会很差,也可能导致数据不一致。所以按 userId 哈希取模比随机更合适。
  2. 数据库兼容性:新版本如果改了表结构,必须保证旧版本也能读(通常是加字段不加约束,双写过渡)。
  3. 灰度回滚要快:通过配置中心动态调整灰度权重,从 1% 到 0% 应该秒级生效,不要靠重启。
  4. 监控要跟上:灰度版本必须有独立的监控面板,对比 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 限流的坑

  1. 限流阈值拍脑袋定:必须基于压测数据。用 JMeter/Gatling 压出 P99 RT 拐点,取拐点的 80% 作为阈值。
  2. 只做单机限流:集群总量失控。生产环境必须用 Redis 或 Sentinel 集群流控。
  3. 限流维度太粗:只按接口限流,导致某个恶意用户打满配额。应该按 IP + userId + 接口 多维度组合。
  4. 被限流后返回 500:应该是 429 Too Many Requests,并带上 Retry-After 头告诉客户端何时重试。

6.2 熔断的坑

  1. 最小请求数设太小:20 个请求里有 1 个失败就是 5% 失败率,容易误熔断。生产建议最小 50~100。
  2. 忽略慢调用:只看异常率,不看 RT。下游"不报错但很慢"同样会拖垮系统,必须把慢调用纳入熔断统计。
  3. 熔断时长设太短:下游还没恢复就重新放量,导致反复熔断。建议 10~30s 起步。
  4. 没有降级配合:熔断后直接返回错误,用户体验差。应该配合降级返回缓存数据。

6.3 降级的坑

  1. 降级逻辑本身有 bug:降级代码路径平时不走,一旦触发才发现有 NPE。降级逻辑必须定期演练
  2. 降级数据过期:缓存兜底数据没有 TTL,返回的是几小时前的脏数据。必须设置合理的过期时间。
  3. 降级没有开关:出问题时无法快速关闭某个降级逻辑,只能重启。必须动态可配。

6.4 灰度的坑

  1. 灰度实例和稳定实例共用数据库连接池:新版本慢查询会拖垮整个池。建议灰度实例使用独立的资源池。
  2. 灰度期间改配置:灰度发布时配置中心推错配置,会导致灰度版本异常,误判为代码问题。灰度期间配置变更要审批。
  3. 灰度没有回滚预案:出问题手忙脚乱。必须提前准备好"一键切回 v1"的脚本或配置。

6.5 综合最佳实践

graph TD A[入口流量] --> B[第一层:全局限流
系统 Load/线程数] B --> C[第二层:接口限流
QPS/并发数] C --> D[第三层:熔断
异常率/慢调用率] D --> E[第四层:降级
缓存/兜底] E --> F[第五层:灰度路由
按用户/比例] F --> G[后端服务] G --> H[监控告警
Prometheus + Grafana] H -.反馈.-> B

核心原则

  1. 分层防御:不要指望单一手段,限流、熔断、降级、灰度层层配合。
  2. 动态可调:所有阈值必须能通过配置中心实时调整,不要硬编码。
  3. 可观测:每次限流、熔断、降级都要打点上报,否则你不知道系统在发生什么。
  4. 定期演练:混沌工程(Chaos Mesh)模拟下游故障,验证熔断降级是否生效。
  5. 渐进式灰度: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 官方文档
  • 《分布式服务架构:原理、设计与实战》