本文基于的项目:https://github.com/meirongdev/shop(私有)。相关实现主要在 services/api-gateway

从一个具体问题开始#

buyer-bff 要升级一版,改动涉及订单接口的返回结构。上线前我们想让两三个内部测试账号先跑一段时间,其他人继续走旧版。出问题时能立刻回切,整个过程不要重启任何服务。

前提:环境里没有 Istio、没有 Linkerd,也没有 Argo Rollouts。Kubernetes 只有原生的 Service 和 Ingress。

在这种约束下,灰度发布能放在哪一层?

  • Kubernetes Service 权重:原生 Service 不支持按权重或按用户分流,它只是 round-robin。
  • 应用内开关:让每个服务各自根据用户属性判断走新旧逻辑。问题是"谁是灰度用户"的判断会散落到每个服务里重复实现,也没法在入口做统一观测。代码层面的 feature flag 不是不能用,它和 Gateway 路由解的是不同层次的问题——本文末尾会讨论两者如何互补。
  • Service Mesh:能干净地做这件事,但引入一整套 sidecar/control plane,对这个项目太重。
  • API Gateway:请求进入系统的第一跳,比较自然地成为流量决策点。只要 Gateway 能按请求属性选择 upstream,就够了。

这个项目选了第四条。下面把每一个设计选择拆开讲。

Spring Cloud Gateway 在这里担任什么角色#

Shop Platform 的 api-gateway 是 Spring Cloud Gateway Server MVC(不是 WebFlux 那套),运行在虚拟线程上。它在系统里承担的事情是常规的那几件:

  • 路径路由(/api/buyer/**buyer-bff/auth/**auth-server……)
  • 鉴权(解析 JWT、注入 X-Buyer-Id 等可信 Header)
  • 限流(基于 Redis 的令牌桶)
  • CORS、OpenAPI 聚合
  • 按用户做灰度路由 ← 本文主题

灰度在这里不是外挂的新组件,而是复用 Gateway 的路由匹配能力加一个自定义谓词。

为什么用 Predicate 而不是 Filter#

SCG 的请求处理分两个阶段:

  • Predicate:决定「这个请求匹配哪条路由」。
  • Filter:对已匹配的路由做修改(改 Header、改 Path、限流、熔断……)。

灰度的本质是选择不同的 upstream,这恰好是 Predicate 要解的问题。用 Filter 也能做(比如在一个 Filter 里动态改 uri),但那样会把路由决策藏在 Filter 内部,YAML 里看不出来有两个上游。Predicate 的好处是,一条灰度路由和一条稳定路由在配置上是并列的:

- id: buyer-api-canary
  uri: ${BUYER_BFF_V2_URI:http://buyer-bff-v2:8080}
  predicates:
    - Path=/api/buyer/**
    - name: Canary
      args:
        routeId: buyer-api
  filters:
    - StripPrefix=1

- id: buyer-api
  uri: ${BUYER_BFF_URI:http://buyer-bff:8080}
  predicates:
    - Path=/api/buyer/**
  filters:
    - StripPrefix=1

灰度路由在稳定路由之前。Gateway 按顺序做第一次匹配即停,所以顺序换反了,灰度就不会生效。这一点在 YAML 里用注释强调:

# Canary routes must stay before stable routes so the first match can divert traffic.

配置节选自 services/api-gateway/src/main/resources/application.yml

为什么用 Redis Set 做白名单#

灰度名单要满足几个要求:

  1. 最好不重启服务就能改:增减用户最好能尽快生效。
  2. 多副本一致:Gateway 跑两三个 Pod,不能每个 Pod 各有一份名单。
  3. 运维友好:最好能直接用 redis-cli 或者任何脚手架改。

Redis Set 是这里相对轻量的选项:

  • SADD gateway:canary:buyer-api buyer-123 把用户加进灰度。
  • SREM 移除。
  • DEL 清空整个名单(等于紧急回滚)。
  • SMEMBERS 查看当前名单。

没有额外的数据库、没有新服务。Gateway 原本就有 Redis 依赖(限流也用)。

数据库方案被否掉的原因:buyer-bff 的数据库不能被 api-gateway 直接读(违背服务边界),新建一张只给 Gateway 读的表又多一份维护。配置中心(Nacos/Consul)能做,但那是强一致 + 订阅模型,操作灰度名单需要写一套管理端,对这个规模过重。

Redis Set 够用。

为什么键的形状是 gateway:canary:{routeId}#

每条灰度路由独立一个 Set:

路由 ID Redis Key
buyer-api gateway:canary:buyer-api
seller-api gateway:canary:seller-api

routeId 做后缀,而不是 userId 做前缀,是因为「谁在哪条路由上灰度」的维度落在路由这边。一个 buyerId 可能同时在 buyer-api 的灰度里、不在 seller-api 的灰度里,两个 Set 互不影响。

自定义 Predicate 的完整代码#

services/api-gateway/src/main/java/dev/meirong/shop/gateway/predicate/CanaryRequestPredicates.java

@Component
public class CanaryRequestPredicates implements PredicateSupplier {

    private static final String BUYER_ID = "X-Buyer-Id";
    private static final String KEY_PREFIX = "gateway:canary:";
    private static final int CACHE_TTL_SECONDS = 10;

    private static final AtomicReference<RedissonClient> redissonRef = new AtomicReference<>();
    static final Cache<String, Boolean> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
            .build();

    public CanaryRequestPredicates(RedissonClient redissonClient) {
        CanaryRequestPredicates.redissonRef.set(redissonClient);
    }

    public static RequestPredicate canary(String routeId) {
        return request -> {
            String buyerId = request.servletRequest().getHeader(BUYER_ID);
            if (buyerId == null || buyerId.isBlank() || redissonRef.get() == null) {
                return false;
            }
            String cacheKey = routeId + ':' + buyerId;
            Boolean cached = localCache.getIfPresent(cacheKey);
            if (cached != null) {
                return cached;
            }
            try {
                RSet<String> set = redissonRef.get()
                        .getSet(KEY_PREFIX + routeId, StringCodec.INSTANCE);
                boolean member = set.contains(buyerId);
                localCache.put(cacheKey, member);
                return member;
            } catch (RedisException exception) {
                log.warn("Canary lookup failed for route {} and buyer {}, routing to stable: {}",
                        routeId, buyerId, exception.getMessage());
                return false;
            }
        };
    }

    @Override
    public Collection<Method> get() {
        return Arrays.stream(CanaryRequestPredicates.class.getMethods())
                .filter(method -> method.getReturnType().equals(RequestPredicate.class))
                .toList();
    }
}

接下来逐条解释里面每个看起来别扭的选择。

为什么用 static 方法 + AtomicReference<RedissonClient>#

SCG MVC 的 property-based 路由配置(就是上面那段 YAML)是按方法名反射发现谓词工厂的,Gateway 在路由解析阶段以静态方法的形式调用 canary(routeId)。按我目前对这套扩展方式的理解,canary 这里需要是 static

RedissonClient 是 Spring 管理的 bean,要通过构造函数注入。于是就有了「实例构造函数拿到 bean → 塞进 static 字段」这种略显别扭的写法。用 AtomicReference 而不是裸 static 字段,是为了在单测里能替换/重置;@Component 主要是为了让构造函数在应用启动时被调一次。

这不是特别优雅的设计,但它是我在 SCG MVC 当前扩展模型下接受的一个折中。

为什么要加 Caffeine 本地缓存#

canary() 每次请求都会被调用一次。如果热点用户 QPS 高,每次都去 Redis 做 SISMEMBER 等效操作,会带来:

  • 额外的网络往返(跨 AZ 时尤其明显)。
  • Redis QPS 被放大(每个 Gateway 请求 ≥ 1 次 Redis 调用)。

灰度名单的数据特性是「变化慢、读得多」:加一个用户进灰度可能几小时甚至一天内不再动,而这个用户每秒可能发几十个请求。把 (routeId, buyerId) → boolean 结果缓存 10 秒,Redis 压力通常就能降一个数量级。

10 秒这个数字的取舍:

  • 足够短,让「把用户从灰度里移除」的感知延迟控制在一次心跳内。
  • 足够长,让同一用户 burst 请求几乎都命中本地缓存。

maximumSize(10_000) 是个经验值,够装下同时活跃的灰度 + 非灰度用户判定结果。

为什么 buyerId 为空直接返回 false#

游客、未登录请求没有 X-Buyer-Id Header。灰度只针对「特定已登录用户」,所以没这个 Header 就会回到稳定版。

注意:X-Buyer-Id 是 Gateway 里的鉴权过滤器从 JWT 里解出来再写进 Header 的,不是客户端自己传的(客户端传的会被 TrustedHeadersFilter 剥掉)。所以这个判断可以信任。

为什么 Redis 故障时返回 false 而不是 true#

} catch (RedisException exception) {
    log.warn("Canary lookup failed ..., routing to stable: {}", exception.getMessage());
    return false;
}

两种降级方向:

  • 失败 → 走稳定版(return false
  • 失败 → 走灰度版(return true

我选前者的考虑比较简单:稳定版是已经验证过的,灰度版还在验证中。Redis 挂掉本身已经是个异常状态,这时候把所有流量打到未验证的版本上,等于把两个故障叠到一起。相反,让灰度用户在 Redis 不可用期间暂时退回稳定版,用户体验只是「我感觉不到有 v2 了」,而不是「v2 出问题了」。

运行时操作#

加一个用户:

kubectl exec -n shop deploy/redis -- redis-cli \
    SADD gateway:canary:buyer-api buyer-123

移除:

kubectl exec -n shop deploy/redis -- redis-cli \
    SREM gateway:canary:buyer-api buyer-123

紧急回滚(清空名单,所有用户回到稳定版):

kubectl exec -n shop deploy/redis -- redis-cli \
    DEL gateway:canary:buyer-api

最坏情况下本地缓存还有 10 秒 TTL,所以「清空名单」到「所有 Gateway 实例都切回稳定版」之间最多 10 秒间隔。对灰度这种场景是可接受的。

当前边界:灰度只到 Gateway 这一跳#

命中灰度的请求会打到 buyer-bff-v2,但 v2 调的下游(order-serviceloyalty-service……)全都是 v1。如果 v2 的改动只影响自己内部逻辑,这不是问题;一旦涉及跨服务契约(新字段、新消息格式、新行为),当前方案只做了"半条链路"。

要把"哪些用户是灰度用户"和"这些用户在每一跳跑哪段代码"两件事都解决,又不上 Service Mesh,下面两条路径互补,覆盖不同类型的变更。

演进路径一:用 OpenFeature 做代码路径灰度#

很多业务变更(新业务规则、新接口字段、替换算法)的灰度对象其实不是“哪个实例”,而是“哪段代码分支”。这种变更用 Feature Toggle 比用版本化部署合适得多:

  • 集群里只有一个版本,不需要每个下游都跑双副本。
  • 一个服务里可以同时灰度 A 功能的新逻辑、保留 B 功能的旧逻辑,两条变更独立解耦。
  • 回退只是改 flag 值,不涉及 Pod 调度,分钟级可撤。

Shop Platform 的技术栈本来就选了 OpenFeature(shop-common-bom 里已经声明依赖),这条路不是新引一套基建,而是复用已有能力。组合模型很直接:

  • Gateway 继续管"人群判断":Redis Set 白名单不变,命中后把 X-Canary: true(或 buyerId,已经在 OTel baggage 里)透传到下游。
  • 下游服务管"分支决策":用 OpenFeature 的 EvaluationContext,在代码里 client.getBooleanValue("new-order-flow", false, ctx) 决定走哪段。
  • Flag provider 的 targeting rule 和 Redis 名单天然同构:flagd、Unleash 这类 provider 都支持按用户列表命中,两边的"灰度人群"定义可以共享配置甚至共享数据源。

这样用户在一次请求里,从 Gateway 到最深的下游,通常都能拿到同一侧代码——而集群部署形态完全没变。

演进路径二:保留版本化部署,处理 Feature Toggle 覆盖不了的变更#

有几类改动 Feature Toggle 不太能覆盖,更适合走真实的双版本部署:

  • JVM / 依赖 / 框架大版本升级:flag 在代码里,改变不了运行时。
  • Schema 迁移、消息格式演进:消费者得能同时吃两种格式,flag 控制不了协议层。
  • 启动参数、内存 / GC profile 调整:这些是部署属性。

当前 YAML 里 buyer-bff-v2 / seller-bff-v2 两条占位 URI 的价值就在这里:日常业务迭代走 OpenFeature,碰到上面这类"部署形态的变更"再起一套 v2 Deployment,让 Gateway 按白名单分流过去。

两条路径各司其职:OpenFeature 处理高频的业务代码灰度,Gateway + v2 部署处理低频的部署形态灰度。在我这里,Gateway 的白名单仍然是两种场景下"谁是灰度用户"的主要事实源。

其他还没做但值得做的#

  • 没有百分比灰度:白名单只支持「在/不在」。要按 1%、5% 渐进放流量,得换成 Hash 取模(同一 buyerId 用一致性 Hash 决定走哪边,保证体验一致)。这条改动不难,值得接着做。
  • 没有灰度维度的专用指标:Gateway 接了 Micrometer + OTLP,但没有单独埋"灰度命中率 / 灰度侧错误率"这类指标。现在判断 v2 有没有问题,靠的是按 upstream label 过滤通用 HTTP 指标。规模起来后值得把匹配次数和分支决策结果单独打点。

等这套组合也顶不住了(比如要做跨租户全链路隔离、流量镜像、shadow 测试),再考虑 Service Mesh 不迟。先把眼前的问题收敛住。

参考实现入口#

  • services/api-gateway/src/main/java/dev/meirong/shop/gateway/predicate/CanaryRequestPredicates.java — Predicate 实现
  • services/api-gateway/src/main/resources/application.yml — 路由配置
  • services/api-gateway/src/test/java/dev/meirong/shop/gateway/predicate/CanaryRequestPredicatesTest.java — 单测(Redis 故障降级、缓存命中等场景)
  • Spring Cloud Gateway Server MVC 文档:https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway-server-webmvc.html