一次「50% 灰度」切出了两种账单
某 SaaS 客户端把账单页从旧渲染层迁到新渲染层。计划写的是 50% 灰度,实现却是在每次请求里执行 Math.random() < 0.5。上线当天客服收到投诉:同一个账号刷新三次,两次看到新账单、一次看到旧账单,金额口径还不同。
问题不在 50%,而在分桶键选错了。按请求随机,等于按「这一次」分桶;用户需要的是连续性,应该按「这个用户」分桶。更进一步,同一个用户在不同端、不同功能上可以处于不同阶段,所以分桶维度不能只有一个。
绞杀者模式(Strangler Fig)的原始比喻是让新藤蔓绕着老树生长,等新结构能独立支撑后再移除老树。工程上它不是一个开关,而是一张三段式的路由表:谁能进新路径、进多少、出问题怎么退。这张表如果只写在发布脚本里,就没有人能解释某一次请求为什么被切走。
路由是一张优先级表,不是一个百分比
把「50%」当成唯一输入,会忽略三个更具体的问题:谁必须留下、谁必须先进、先按哪个维度分。一个可执行的路由顺序应该是:
| 优先级 | 匹配 | 结果 | 用途 |
|---|---|---|---|
| 1 | holdback | 旧路径 | 保护名单,任何比例下都不切 |
| 2 | allowlist | 新路径 | 内部验证、客户试点 |
| 3 | rules 声明顺序 | 强制目标或规则比例 | 按用户、端、功能分桶 |
| 4 | 全局比例 | 哈希分桶 | 兜底放量 |
holdback 放在最前面,是因为它承担的是保护职责:只要命中,就不应该被任何后续规则覆盖。也正因为如此,同一个分桶同时出现在 allowlist 和 holdback 时不能靠顺序决定,而应该在计划校验阶段直接报冲突。
规则按声明顺序首个命中生效。这意味着规则表的顺序本身是契约的一部分:把「所有功能」的兜底规则写在最前面,后面的端规则永远不会生效。代码不能替团队猜意图,只能把顺序固定下来并要求评审。
分桶必须确定,且键要带维度
百分比分桶最常见的错误是使用随机数。随机数保证的是「每次请求有 50% 概率」,不是「50% 的用户稳定进入」。正确做法是对分桶键做确定性哈希,再映射到 0 到 99:
| 决定 | 输入 | 关键约束 |
|---|---|---|
| 分桶键 | kind:key | 同一用户、端、功能必须稳定 |
| 哈希 | FNV-1a | 不依赖进程、时间与随机种子 |
| 比例 | 0 到 100 的整数 | 0 全旧、100 全新,边界可测 |
| 覆盖 | force / allowlist / holdback | 显式意图优先于比例 |
键里必须带维度前缀。如果用户 ID 和端 ID 恰好都是 ios,不带前缀就会让两个维度共享同一个桶,端规则和用户规则互相污染。代码里用 user:ios、endpoint:ios 这样的形式区分。
把计划写成代码
下面的模块把计划校验、路由决策、接管统计、推进与回退分开实现。所有函数都是纯函数,不读时钟、不读随机数,因此同一份计划对同一批请求永远给出同一组结果。
// file: src/migration/strangler.ts
// tier: L1
// verify: node --test test/migration/strangler.test.ts
export type BucketKind = "user" | "endpoint" | "feature";
export type RouteTarget = "legacy" | "new";
export type BucketRef = {
kind: BucketKind;
key: string;
};
export type BucketRule = {
kind: BucketKind;
keys: string[] | null;
percent: number | null;
force: RouteTarget | null;
};
export type MigrationPlan = {
feature: string;
percent: number;
rules: BucketRule[];
allowlist: BucketRef[];
holdback: BucketRef[];
};
export type RouteRequest = {
user: string;
endpoint: string;
feature: string;
};
export type RouteMatcher =
| "allowlist"
| "holdback"
| "rule:user"
| "rule:endpoint"
| "rule:feature"
| "global";
export type RouteDecision = {
target: RouteTarget;
matchedBy: RouteMatcher;
percent: number;
};
export type TakeoverReport = {
total: number;
newCount: number;
legacyCount: number;
newPercent: number;
legacyPercent: number;
complete: boolean;
};
export type PromoteResult =
| { ok: true; plan: MigrationPlan }
| { ok: false; reason: "range" | "regression" };
const KINDS: BucketKind[] = ["user", "endpoint", "feature"];
function roundPercent(value: number): number {
return Math.round(value * 100) / 100;
}
function clonePlan(plan: MigrationPlan): MigrationPlan {
return {
feature: plan.feature,
percent: plan.percent,
rules: plan.rules.map((rule) => ({
kind: rule.kind,
keys: rule.keys === null ? null : [...rule.keys],
percent: rule.percent,
force: rule.force,
})),
allowlist: plan.allowlist.map((ref) => ({ kind: ref.kind, key: ref.key })),
holdback: plan.holdback.map((ref) => ({ kind: ref.kind, key: ref.key })),
};
}
export function bucketPercent(key: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i += 1) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % 100;
}
export function bucketKeyOf(request: RouteRequest, kind: BucketKind): string {
if (kind === "user") return request.user;
if (kind === "endpoint") return request.endpoint;
return request.feature;
}
export function sameBucket(ref: BucketRef, request: RouteRequest): boolean {
return ref.key === bucketKeyOf(request, ref.kind);
}
export function validatePlan(plan: MigrationPlan): string[] {
const errors: string[] = [];
if (plan.feature.trim() === "") errors.push("feature");
if (!Number.isInteger(plan.percent) || plan.percent < 0 || plan.percent > 100) {
errors.push("percent");
}
plan.rules.forEach((rule, index) => {
if (!KINDS.includes(rule.kind)) errors.push(`rule:${index}:kind`);
if (rule.keys !== null && rule.keys.length === 0) {
errors.push(`rule:${index}:keys`);
}
if (
rule.percent !== null &&
(!Number.isInteger(rule.percent) || rule.percent < 0 || rule.percent > 100)
) {
errors.push(`rule:${index}:percent`);
}
});
for (const held of plan.holdback) {
const conflict = plan.allowlist.some(
(allowed) => allowed.kind === held.kind && allowed.key === held.key,
);
if (conflict) {
errors.push(`conflict:${held.kind}:${held.key}`);
}
}
return errors;
}
export function decideRoute(
plan: MigrationPlan,
request: RouteRequest,
): RouteDecision {
if (plan.holdback.some((ref) => sameBucket(ref, request))) {
return { target: "legacy", matchedBy: "holdback", percent: 0 };
}
if (plan.allowlist.some((ref) => sameBucket(ref, request))) {
return { target: "new", matchedBy: "allowlist", percent: 100 };
}
for (const rule of plan.rules) {
const key = bucketKeyOf(request, rule.kind);
if (rule.keys !== null && !rule.keys.includes(key)) continue;
const matchedBy = `rule:${rule.kind}` as RouteMatcher;
if (rule.force !== null) {
return {
target: rule.force,
matchedBy,
percent: rule.force === "new" ? 100 : 0,
};
}
const percent = rule.percent ?? plan.percent;
const hash = bucketPercent(`${rule.kind}:${key}`);
return {
target: hash < percent ? "new" : "legacy",
matchedBy,
percent,
};
}
const hash = bucketPercent(`user:${request.user}`);
return {
target: hash < plan.percent ? "new" : "legacy",
matchedBy: "global",
percent: plan.percent,
};
}
export function takeoverReport(decisions: RouteDecision[]): TakeoverReport {
const total = decisions.length;
const newCount = decisions.filter((item) => item.target === "new").length;
const legacyCount = total - newCount;
return {
total,
newCount,
legacyCount,
newPercent: total === 0 ? 0 : roundPercent((newCount * 100) / total),
legacyPercent: total === 0 ? 0 : roundPercent((legacyCount * 100) / total),
complete: total > 0 && legacyCount === 0,
};
}
export function promotePlan(
plan: MigrationPlan,
nextPercent: number,
): PromoteResult {
if (
!Number.isInteger(nextPercent) ||
nextPercent < 0 ||
nextPercent > 100
) {
return { ok: false, reason: "range" };
}
if (nextPercent < plan.percent) {
return { ok: false, reason: "regression" };
}
const next = clonePlan(plan);
next.percent = nextPercent;
return { ok: true, plan: next };
}
export function rollbackPlan(plan: MigrationPlan): MigrationPlan {
const next = clonePlan(plan);
next.percent = 0;
next.allowlist = [];
next.rules = next.rules.map((rule) => ({
kind: rule.kind,
keys: rule.keys === null ? null : [...rule.keys],
percent: null,
force: rule.force === "new" ? "legacy" : rule.force,
}));
return next;
}
判据与反例
判断这套路由是否可信,不需要看线上大盘,先看五条可复现的判据:
- 比例为 0 时,任意样本都走旧路径;比例为 100 时,任意样本都走新路径。
- 同一个用户连续请求二十次,落点必须完全一致。
takeoverReport只有在样本非空且旧路径计数为 0 时才标记完成。- 回退后,即使规则里写过
force: "new",样本也必须全部回到旧路径。 - 白名单与 holdback 重叠时,校验必须报错,而不是靠优先级静默处理。
对应的反例同样具体:
用随机数或请求 ID 分桶。 前者让同一用户来回跳,后者让同一会话跨路径。两种都会造成「一半数据在新库、一半在旧库」的读写错位。
只按用户分桶。 端和功能无法独立推进,一个小程序上的问题会逼你把所有端一起回退。
把 100% 当成迁移完成。 计划里可能仍有 holdback 或强制旧路径的规则。完成与否要看真实流量统计,不看配置里的百分比。
回退只改 percent。 如果不清空 allowlist、不翻转 force: "new",回退后仍有用户被规则送进新路径。回退必须是一份新计划,而不是一个数字。
小结
绞杀者替换的核心是并存,不是删除。
- 路由是一张优先级表:holdback、allowlist、分桶规则、全局比例,顺序要固定并经过评审。
- 分桶必须确定:用带维度前缀的键做哈希,不能用随机数或请求 ID。
- 完成要看流量证据:样本非空且旧路径计数为零,才能宣布接管完成。
- 回退是一等公民:它要清空覆盖规则并翻转强制目标,而不只是把比例调回 0。
当一张计划能解释每一次请求的去向,迁移才从「发版玄学」变成可运营的过程。
阅读导航:上一节:11.3 技术债台账与治理节奏 · 下一节:12.2 双写双读与兼容窗口 。
常见错误与边界
- 把 holdback 当永久白名单。 holdback 是临时保护,必须有到期时间与责任人;否则它会变成永远不迁移的暗账。
- 规则表没有兜底。 所有规则都不命中时会落到全局比例,这是刻意设计;如果团队期望「未声明就走旧路径」,应显式加一条
percent: 0的兜底规则。 - 用端 ID 当用户 ID。 分桶键必须带维度前缀,否则不同维度的同名值会互相污染。
- 推进时跳档。
promotePlan只拒绝倒退与越界,不拒绝跳档;1% 直接到 100% 在代码上合法,在流程上仍然危险,需要发布门禁另行约束。 - 忽略空样本。 空数组的接管比例是 0,且不会被标记为完成;把「没有流量」当成「迁移完成」是最常见的误判。
- 回退后不复核。 回退计划生成后应重新跑一批样本,确认全部落到旧路径,而不是只看
percent字段。 - 只记录比例,不记录命中规则。 排查时最有用的是
matchedBy:它告诉你是 holdback、白名单、哪条规则还是全局比例做的决定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。