没有 Service Mesh,用 API Gateway 做用户级灰度
目录
本文基于的项目: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 做白名单#
灰度名单要满足几个要求:
- 最好不重启服务就能改:增减用户最好能尽快生效。
- 多副本一致:Gateway 跑两三个 Pod,不能每个 Pod 各有一份名单。
- 运维友好:最好能直接用
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-service、loyalty-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