承接的问题
12.1 让 v1 和 v2 能同时跑在 9093 上,但「能同时跑」不等于「敢放量」。直接把网关的 v2 stub 打开,等于让 100% 流量立刻承担新代码的风险;只靠人工在发布窗口盯着,又很难在错误率抬头时秒级切回。
灰度路由要解决三个具体问题。第一,切多少:1%、5%、50% 的流量怎么稳定地落到 v2,而不是每次请求随机跳。第二,切谁:内部测试账号、白名单租户要能强制进 v2,普通用户不能靠一个 header 就绕过灰度策略。第三,怎么退:发现异常时,不重新打包、不重启进程,把新流量切回 v1,并保留现场。
本节给出 linkd-v3 的实现:一个不可变的 CanaryPolicy、一个基于 SHA-256 的确定性分桶器、一个只做标记不做业务判断的服务端拦截器,以及一套「先看分母再看比值」的放量门槛。
核心机制一:切流维度与确定性
灰度策略首先是一个路由函数:
route(requestId, tenantId, explicitCanary, policy) -> "v1" | "v2"
输入必须包含一个稳定的键。用 ThreadLocalRandom 每次掷骰子,同一个用户会在 v1 和 v2 之间反复横跳,体验和数据都不可解释;用进程内的 AtomicInteger 轮询,多实例之间无法复现「为什么这个请求进了 v2」。确定性分桶是唯一能同时满足「比例可控」和「可复现」的做法。
三种维度各有用途:
| 维度 | 键 | 优点 | 缺点 |
|---|---|---|---|
| 按请求 | x-request-id | 统计收敛快,几分钟就能积累样本 | 同一用户可能看到两个版本 |
| 按租户 | x-tenant-id | 同一租户体验一致,便于客服解释 | 大租户会主导样本,收敛慢 |
| 显式声明 | x-canary | 内部账号、测试租户可强制进入 | 绝不能对普通租户生效 |
linkd-v3 的默认策略是按租户粘性:面向读者的 GetBook、SearchBooks 用 tenantId 分桶,保证一个租户在一次灰度周期内只看到一个版本;内部批处理、压测流量用 requestId 分桶,让样本更快接近目标比例。策略里用一个布尔值切换,不需要改调用方。
比例用基点(basis point)表示,500 就是 5%。整数比较避免浮点误差,也避免 0.1 + 0.2 这类问题进入路由判断。
分桶函数必须满足两个条件:同样的键在任何实例、任何 JVM 上得到同样的结果;输出在 [0, 10000) 上足够均匀。String.hashCode() 虽然规范稳定,但对 req-000001、req-000002 这类顺序 ID 的低位分布很差,取模后容易连续扎堆。这里用 SHA-256 取前 4 个字节:
package com.plume.linkd.v3.gateway;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
public record CanaryPolicy(int percentBasisPoints, Set<String> tenantAllowlist,
boolean tenantSticky) {
public static final CanaryPolicy OFF = new CanaryPolicy(0, Set.of(), true);
public static final CanaryPolicy FULL = new CanaryPolicy(10_000, Set.of(), true);
public CanaryPolicy {
if (percentBasisPoints < 0 || percentBasisPoints > 10_000) {
throw new IllegalArgumentException("percent must be within 0..10000");
}
tenantAllowlist = Set.copyOf(tenantAllowlist);
}
}
final class CanaryRouter {
private final AtomicReference<CanaryPolicy> policy;
public CanaryRouter(CanaryPolicy initial) {
this.policy = new AtomicReference<>(initial);
}
public void update(CanaryPolicy next) {
policy.set(next);
}
public CanaryPolicy snapshot() {
return policy.get();
}
public String select(String requestId, String tenantId, String explicit) {
CanaryPolicy p = policy.get();
boolean allowlisted = p.tenantAllowlist().contains(tenantId);
if (allowlisted && "v1".equals(explicit)) {
return "v1";
}
if (allowlisted && "v2".equals(explicit)) {
return "v2";
}
if (allowlisted) {
return "v2";
}
if (p.percentBasisPoints() == 0) {
return "v1";
}
String key = p.tenantSticky() ? tenantId : requestId;
return bucket(key) < p.percentBasisPoints() ? "v2" : "v1";
}
static int bucket(String key) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(key.getBytes(StandardCharsets.UTF_8));
long value = ((long) (digest[0] & 0xff) << 24)
| ((long) (digest[1] & 0xff) << 16)
| ((long) (digest[2] & 0xff) << 8)
| (digest[3] & 0xff);
return (int) (value % 10_000);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
两个顺序细节值得强调。白名单判断放在比例判断之前,否则白名单租户会被 percentBasisPoints == 0 挡住;但完全回滚时必须把白名单一起清空,否则内部账号仍然进 v2。CanaryPolicy.OFF 就是「比例 0 + 空白名单」,回滚用整对象替换,不做字段级修改,避免漏清。
explicit 只在租户命中白名单时才被采纳。普通租户在 header 里写 x-canary: v2 会被忽略,但仍然可以作为指标标签记录下来,用于发现「有人在探测灰度入口」。如果直接把 header 当开关,任何外部调用方都能把自己放进未验证的版本里。
核心机制二:服务端只标记,不决策
灰度决策发生在网关或客户端负载均衡层,服务端不应该再根据 x-canary 改变业务行为。原因很简单:一旦服务端也按 header 分支,v1 进程里就会跑 v2 逻辑,回滚时无法通过「切流量」把行为切回去,只能重新部署。
服务端对 x-canary 的唯一用途是打标签,让指标能按版本切开。下面的拦截器在调用结束时记录 method、canary、状态码三个维度:
package com.plume.linkd.v3.server;
import com.plume.linkd.v3.core.MetricsRegistry;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import java.util.Map;
public final class CanaryTelemetryInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> CANARY =
Metadata.Key.of("x-canary", Metadata.ASCII_STRING_MARSHALLER);
private final MetricsRegistry metrics;
public CanaryTelemetryInterceptor(MetricsRegistry metrics) {
this.metrics = metrics;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String declared = headers.get(CANARY);
String canary = declared == null || declared.isBlank() ? "baseline"
: ("v1".equals(declared) || "v2".equals(declared)) ? declared : "other";
String fullMethod = call.getMethodDescriptor().getFullMethodName();
String method = fullMethod.substring(fullMethod.lastIndexOf('/') + 1);
ServerCall<ReqT, RespT> wrapped =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(Status status, Metadata trailers) {
metrics.counter("rpc_requests_total", Map.of(
"method", method,
"canary", canary,
"code", status.getCode().name())).inc();
super.close(status, trailers);
}
};
return next.startCall(wrapped, headers);
}
}
method 取 getFullMethodName() 的最后一段,而不是整个路径。否则 v1 的 /plume.linkd.v3.Library/GetBook 和 v2 的 /plume.linkd.v3.LibraryV2/GetBook 会成为两个不同的 method 标签,比较错误率时口径就歪了。canary 标签来自 header,只能取有限枚举:baseline、v1、v2,未知值统一归到 other,避免外部输入把标签基数撑爆。
如果服务端还要记录延迟,用同一个 canary 标签调用 histogram(...).observeNanos(...),桶边界与 11.1 保持一致。不要把 requestId、tenantId 放进标签,它们适合放日志和 trace,不适合放指标。
核心机制三:放量与回滚
灰度不是「发一个百分比就完事」,而是一个带门槛的状态机:
关闭 -> 内部白名单 -> 1% -> 5% -> 25% -> 50% -> 100%
\__________________________/
任一步异常则回滚
每一步至少观察一个完整窗口,窗口内要满足样本量和指标门槛。下面是假设的示例值,用于说明比较口径,不是本机压测结果:
| 指标 | v1 基线 | v2 灰度 | 放量门槛 |
|---|---|---|---|
| 错误率 | 0.40% | 0.52% | 不超过基线 1.5 倍 |
| p99 延迟 | 82 ms | 88 ms | 不超过基线 1.2 倍 |
| QPS 比例 | 95% | 5% | 与策略比例偏差小于 10% |
| 业务成功率 | 99.1% | 99.0% | 不低于基线 0.5 个百分点 |
| 超时率 | 0.08% | 0.10% | 不超过基线 1.5 倍 |
看这张表时先看分母。v2 只有 5% 流量,错误数天然少;如果直接比较绝对错误数,会得出「v2 更稳」的错误结论。所有比率都要在各自版本的请求数上算,且 method 分布要一致——如果 v2 只承接了 GetBook,却拿它和 v1 的全量 method 比,延迟差异可能只是 method 构成不同。
回滚要满足三个条件:秒级生效、不依赖重新部署、不丢现场。实现上就是把 CanaryPolicy 换成 OFF:
public final class CanaryAdmin {
private final CanaryRouter router;
public CanaryAdmin(CanaryRouter router) {
this.router = router;
}
public void rollback() {
router.update(CanaryPolicy.OFF);
}
public void setPercent(int basisPoints) {
router.update(new CanaryPolicy(basisPoints, Set.of(), true));
}
public void allowTenants(int basisPoints, Set<String> tenants) {
router.update(new CanaryPolicy(basisPoints, tenants, true));
}
}
rollback() 只影响新请求。已经在 v2 上执行的调用会继续跑完,最长受 800 ms 总 deadline 约束;之后连接池里的 v2 连接可以保留,等确认稳定后再收缩。回滚不需要重启,也不需要摘节点,网关下一次 select 就会返回 v1。注意 setPercent() 会同时清空白名单,只想调比例又保留白名单时用 allowTenants()。
自动回滚可以再加一层保护:每 10 秒读一次两个版本的计数,样本数超过 200 且错误率超过基线 2 倍时调用 rollback(),同时打告警。自动回滚要设冷却时间,避免指标抖动导致反复开关;恢复灰度必须人工确认,不能让控制器在两个状态之间震荡。
在 linkd-v3 里落地
网关把路由结果写进 Context,客户端拦截器负责把 x-canary 放进 metadata。12.1 已经有 ApiVersionClientInterceptor,这里复用同一个模式,只增加一个 Context.Key:
public BookView getBook(String isbn, String requestId,
String tenantId, String canaryHeader) {
String version = router.select(requestId, tenantId, canaryHeader);
Context previous = CanaryContext.with(version).attach();
try {
if ("v2".equals(version)) {
BookV2 book = v2Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
return BookView.from(book);
}
Book book = v1Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
return BookView.from(book);
} finally {
previous.attach();
}
}
CanaryContext.with(version) 同时写入 x-canary 和 x-api-version 两个键:前者给指标,后者给 12.1 的版本一致性校验。两个值必须来自同一次 select,不能一个读 header、一个读策略,否则会出现「header 说 v1、实际调了 v2」的错位。
管理端口 9095 暴露一个只读快照和一个受控写入口:
GET /admin/canary 返回当前 percent、白名单、命中计数
POST /admin/canary?percent=500&tenants=t-1001,t-1002
POST /admin/canary/rollback 等价于 percent=0 且清空白名单
写入口必须限制在内网或带管理鉴权,不能和业务 9093 共用公网入口。GET 返回的命中计数用于核对「策略说 5%、实际是不是 5%」,如果偏差持续超过 10%,先查哈希键是不是取错了字段,再查网关是不是有多套策略实例。
常见错误与边界
错误一:每个请求独立随机。 同一个租户在 v1/v2 之间跳,缓存、会话、前端状态都可能不一致;出了问题也无法用「这个租户在灰度组」解释。用稳定键做哈希。
错误二:用 Math.abs(key.hashCode()) 分桶。 Integer.MIN_VALUE 取绝对值仍是负数,取模得到负桶;顺序 ID 的低位分布也不均匀。用 SHA-256 或至少用 Integer.toUnsignedLong 加混合函数。
错误三:把 x-canary 当授权。 外部调用方可以伪造任何 header。灰度入口只对白名单租户生效,且白名单由服务端配置,不由请求方声明。
错误四:只比绝对错误数。 v2 流量少,错误数自然少;只比绝对值和只比 QPS 都会误判。必须用比率,并确认 method 构成一致。
边界一:哈希分桶的比例是长期比例。 5% 策略在 100 个请求里可能落到 3 个或 8 个,样本越小波动越大。放量门槛要按样本量设最小请求数,不要用几十个请求的比率做决策。
边界二:租户粘性会放大单租户风险。 按租户分桶时,一个大租户可能独自占满 v2 样本,它的业务特征会掩盖其他租户的问题。放量前先看租户分布,必要时对大租户单独限流或排除。
边界三:回滚不等于数据回滚。 v2 如果写了新格式的数据,切回 v1 后旧代码可能读不懂。灰度期间的新写路径要么双写兼容格式,要么只读不写,要么准备好数据修复脚本。
边界四:网关多实例的策略要一致。 策略存在每个网关进程的 AtomicReference 里,管理端要广播到所有实例;只改一台会让同一租户在不同网关得到不同版本。策略版本号要进日志,排查时才能对上。
小结
- 灰度路由是确定性函数:稳定键 + SHA-256 分桶 + 基点比例;按租户粘性保证体验一致,按请求分桶加快样本收敛。
- 白名单优先于比例,但完全回滚要连白名单一起清空;
x-canary只对白名单租户生效,服务端只标记不决策。 - 放量看比率不看绝对值,先确认分母和 method 构成;回滚是替换整个策略对象,秒级生效且不影响在途请求。
- 自动回滚要有最小样本、冷却时间和人工恢复,避免指标抖动导致状态震荡。
下一节把「兼容」变成可执行的 CI 门禁:用 descriptor 快照比对字段号、类型和标签,任何破坏性变更在合并前就失败。
阅读导航:上一节 12.1 API 版本策略 | 下一节 12.3 契约测试与回滚
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。