特性评估运行时:缓存、默认值与故障模型
一次普通扩容把结算服务从十二个实例增加到二十个。老实例一直正常,新实例却在启动后的几十秒里把所有请求送进旧结算流程。监控只显示“开关值为 false”,没有人能看出这是规则明确返回 false,还是 SDK 尚未拿到配置时使用了 default。随后配置服务短暂断网,老实例继续使用缓存,新实例因没有缓存反复重启;同一批用户被两套规则处理,问题最终表现成业务数据不一致,而不是一个醒目的基础设施错误。
特性评估运行时不是简单的 if 语句。它至少包含配置获取、快照校验、缓存替换、上下文合并、规则执行、details 生成、事件缓冲和退出清理。故障发生在哪一层,决定了系统应继续使用 last-known-good、进入 stale、返回调用方 default,还是拒绝启动。把这些状态都压成一个布尔值,等于主动丢掉排障所需的证据。
先决定评估发生在哪里
本地评估不是“完全离线”。控制面或分发服务仍要把规则同步到进程、sidecar 或边缘代理,SDK 只是在业务请求到来时基于内存快照执行规则。它的优点是热路径没有远程往返,延迟稳定,控制面短暂不可达时还能使用 LKG;代价是每个实例都持有规则和缓存,配置传播存在延迟,复杂规则运行会消耗应用 CPU,并且服务端规则可能包含不应下发到浏览器的 segment 信息。
远程评估把 context 发给评估服务,再接收 variant。规则集中,客户端较薄,敏感规则不用完整下发;但每次业务评估都增加网络、TLS、认证、限流和超时。若一个请求评估十个 flag,串行远程调用会把尾延迟放大十次,批量接口又会改变一致性与缓存语义。评估服务抖动还会直接进入业务依赖图,因此必须有严格超时、并发上限和经过评审的 default。
常见生产形态可以这样判断:
| 形态 | 请求热路径 | 故障时可用状态 | 主要代价 |
|---|---|---|---|
| 进程内本地评估 | 内存规则 | LKG、bootstrap、default | 每实例同步、规则传播延迟、内存和 CPU |
| sidecar / 本机代理评估 | 本机网络 | sidecar 缓存或 default | 额外进程、版本协调、本机端口和资源 |
| 边缘代理 | 区域内网络 | 边缘缓存或 default | 区域一致性、代理容量、跨区恢复 |
| 中央远程评估 | 远程网络 | 客户端 default,或显式响应缓存 | 延迟、可用性、context 外发、调用成本 |
不能在每次请求中调用管理 REST API 读取 flag。管理 API 面向创建规则、审批和审计,凭证权限高,延迟与限流也没有为业务热路径设计。应用只能使用目标平台的评估 SDK、只读分发接口或 OFREP 等明确的评估协议。OFREP 的服务端动态上下文模式是逐次远程评估;客户端静态上下文模式是按共同 context 批量取得 flag 后在缓存上评估,二者不能共用同一套 LKG 和超时假设。
快照要成为不可变、可证明的运行对象
一个可恢复的本地评估器不应把新配置逐字段写进正在使用的 map。正确链路是:下载候选快照,验证 schema、类型、环境、版本和签名,在旁路编译规则,全部成功后用一次原子引用替换当前快照。正在执行的请求继续读取旧引用,新请求读取新引用,不会看见“一半新、一半旧”。
快照至少保留这些元数据:
{
"schemaVersion": 1,
"environment": "test",
"revision": 42,
"generatedAtEpochMs": 0,
"checksum": "sha256:<digest>",
"flags": {
"checkout-v2": {
"type": "boolean",
"defaultVariant": "off",
"variants": { "off": false, "on": true },
"requiredContext": ["targetingKey", "tenantTier"],
"rules": [
{ "field": "tenantTier", "equals": "beta", "variant": "on" }
]
}
}
}revision 用于拒绝回退到更旧配置,generatedAtEpochMs 用于计算配置年龄,checksum 用于判断内容损坏或传输截断,environment 防止测试快照污染生产。defaultVariant 是规则正常执行但没有命中时的 Provider 选择,不是 SDK 调用方 default。requiredContext 让“缺字段”成为可观察错误,而不是静默落入普通默认规则。
LKG 是最近一次完整通过校验并成功投入评估的快照,不是最后下载到磁盘的文件。写磁盘时先写临时文件、fsync(若持久性要求需要),再原子 rename;不能覆盖唯一缓存后才校验。进程内引用、磁盘 LKG 和 bootstrap 三者的可信顺序也要明确:通常远程新快照优先,其次磁盘 LKG,最后是随制品发布的 bootstrap。版本倒退、环境不符或签名失败的“新”数据不能覆盖任何一层。
bootstrap 解决的是冷启动,不是永久真相
bootstrap 是随镜像、部署制品或只读 volume 提供的最小规则快照。它让新实例在控制面不可达时仍有可解释起点,尤其适合本地评估。bootstrap 应只包含启动所需 flag,标明环境与 revision,并在 CI 中校验格式;包含完整生产 segment 的文件不应进入公开镜像仓库。
bootstrap 有三种常见策略:
严格启动:必须从远程拿到新快照,失败就不 ready。适合错误评估会破坏数据一致性,且调度系统能快速替换实例的服务。LKG 启动:远程失败后读取节点持久缓存,缓存年龄与版本在允许范围内才 ready。适合稳定节点和短暂控制面故障。制品 bootstrap 启动:节点没有 LKG 时使用随制品快照,同时标记 degraded。适合大规模扩容与灾难恢复,但必须评估快照陈旧的业务风险。
若三者都不可用,SDK 的 no-op/default 只能保证评估 API 有返回值,不能自动证明服务适合接流量。结算路径若 default 为旧实现且旧实现仍兼容,可以降级 ready;数据迁移开关若 default 会写入旧 schema,则必须阻止启动。readiness 决策属于业务故障模型,不是 SDK 的统一答案。
初始化屏障不要堵死整个进程
初始化屏障回答两个问题:Provider 何时可以评估,以及应用何时可以接收流量。OpenFeature 的 awaitable Provider 注册适合显式等待:
const initialization = OpenFeature.setProviderAndWait(provider);
await Promise.race([
initialization,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('feature provider init timeout')), 3000),
),
]);
await server.listen({ port: 3000, host: '0.0.0.0' });示例中的三秒只是演示值,生产值应来自启动 SLO、控制面延迟分布和调度器探针预算。Promise.race 只停止等待,不会取消仍在后台执行的 Provider 初始化;超时后若继续启动,迟到的初始化仍可能改变 Provider 状态。生产 Provider 应支持 AbortSignal 或在失败分支调用 OpenFeature.close() 并退出,不能遗留下载、流连接和定时器。超时后究竟退出、使用 bootstrap,还是以 degraded 状态启动,应由 Provider 在初始化中明确实现。不要先监听业务端口,再在后台无界等待规则;也不要让 liveness 因外部控制面短暂不可达而杀死一个仍能用 LKG 服务的进程。通常 liveness 只判断进程是否失去自我恢复能力,readiness 判断是否能按当前故障策略承接新请求。
初始化应采用单飞机制。多个并发请求发现 Provider 未 ready 时,不能各自触发一次下载;它们共享同一个 initialization promise。失败后重试采用带抖动的指数退避,并设最大间隔,防止整个集群在控制面恢复瞬间形成同步风暴。凭证无效、环境不存在和快照签名错误属于配置或安全错误,不应像网络超时一样无限快速重试。
一个可复制的最小运行时实验
下面用 Apache-2.0 许可的 OpenFeature Node.js SDK 和一个本地 HTTP 服务观察状态,不需要真实平台账号。当前 @openfeature/server-sdk 1.22.0 的包清单要求 Node.js 20 或更高版本。准备目录:
mkdir feature-runtime-lab
cd feature-runtime-lab
npm init -y
npm install @openfeature/server-sdk@1.22.0新建 control-plane.mjs。它模拟只读配置分发端点;CONTROL_MODE=broken 可返回损坏配置,CONTROL_DELAY_MS 可制造慢响应:
import { createServer } from 'node:http';
const revision = Number(process.env.CONTROL_REVISION ?? 1);
const delay = Number(process.env.CONTROL_DELAY_MS ?? 0);
const mode = process.env.CONTROL_MODE ?? 'ok';
const server = createServer((_req, res) => {
setTimeout(() => {
res.setHeader('content-type', 'application/json');
if (mode === 'broken') {
res.end('{"revision":');
return;
}
res.end(JSON.stringify({
environment: 'test',
revision,
generatedAtEpochMs: Date.now(),
flags: {
'checkout-v2': {
type: 'boolean',
defaultVariant: 'off',
variants: { off: false, on: true },
requiredContext: ['targetingKey', 'tenantTier'],
rules: [
{ field: 'tenantTier', equals: 'beta', variant: 'on' },
],
},
},
}));
}, delay);
});
server.listen(4100, '127.0.0.1', () => {
console.log(`control ready revision=${revision} mode=${mode}`);
});新建 runtime.mjs。这个实验 Provider 把远程成功结果保存为 LKG,刷新失败时继续使用旧快照,并把来源、revision、年龄和 stale 标记放进 flag metadata:
import { readFile, rename, writeFile } from 'node:fs/promises';
import { ErrorCode, OpenFeature } from '@openfeature/server-sdk';
const endpoint = process.env.FLAG_ENDPOINT ?? 'http://127.0.0.1:4100/flags';
const lkgPath = './flags.lkg.json';
const bootstrapPath = './flags.bootstrap.json';
const timeoutMs = Number(process.env.FLAG_TIMEOUT_MS ?? 500);
const staleAfterMs = Number(process.env.FLAG_STALE_AFTER_MS ?? 5000);
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
for (const child of Object.values(value)) deepFreeze(child);
return Object.freeze(value);
}
class SnapshotProvider {
runsOn = 'server';
metadata = { name: 'snapshot-lab-provider' };
snapshot;
source = 'none';
timer;
refreshing;
installedAtMonotonicMs = 0;
async initialize() {
for (const [path, source] of [
[lkgPath, 'lkg'],
[bootstrapPath, 'bootstrap'],
]) {
try {
this.install(JSON.parse(await readFile(path, 'utf8')), source);
break;
} catch {}
}
try {
await this.refreshSingleFlight();
} catch (remoteError) {
if (!this.snapshot) throw remoteError;
}
this.timer = setInterval(() => {
this.refreshSingleFlight().catch((error) => {
console.error(`refresh failed: ${error.message}`);
});
}, 1000);
this.timer.unref();
}
validate(candidate) {
if (candidate.environment !== 'test') throw new Error('environment mismatch');
if (!Number.isInteger(candidate.revision)) throw new Error('invalid revision');
if (!candidate.flags || typeof candidate.flags !== 'object') {
throw new Error('invalid flags');
}
if (this.snapshot && candidate.revision < this.snapshot.revision) {
throw new Error('revision rollback rejected');
}
}
install(candidate, source) {
this.validate(candidate);
this.snapshot = deepFreeze(structuredClone(candidate));
this.source = source;
this.installedAtMonotonicMs = performance.now();
}
refreshSingleFlight() {
if (!this.refreshing) {
this.refreshing = this.refresh().finally(() => {
this.refreshing = undefined;
});
}
return this.refreshing;
}
async refresh() {
const response = await fetch(endpoint, {
signal: AbortSignal.timeout(timeoutMs),
headers: { authorization: 'Bearer <read-only-sdk-token>' },
});
if (!response.ok) throw new Error(`control status ${response.status}`);
const candidate = await response.json();
this.validate(candidate);
await writeFile(`${lkgPath}.tmp`, JSON.stringify(candidate));
await rename(`${lkgPath}.tmp`, lkgPath);
this.install(candidate, 'remote');
}
resolve(flagKey, defaultValue, context, expectedType) {
const flag = this.snapshot?.flags?.[flagKey];
if (!flag) {
return {
value: defaultValue,
reason: 'ERROR',
errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: `flag not found: ${flagKey}`,
};
}
if (flag.type !== expectedType) {
return {
value: defaultValue,
reason: 'ERROR',
errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: `type mismatch: ${flagKey}`,
};
}
const missing = (flag.requiredContext ?? []).filter(
(key) => context[key] === undefined || context[key] === '',
);
if (missing.length) {
const errorCode = missing.includes('targetingKey')
? ErrorCode.TARGETING_KEY_MISSING
: ErrorCode.INVALID_CONTEXT;
return {
value: defaultValue,
reason: 'ERROR',
errorCode,
errorMessage: `missing context: ${missing.join(',')}`,
flagMetadata: this.metadataForSnapshot(),
};
}
const matched = (flag.rules ?? []).find(
(rule) => context[rule.field] === rule.equals,
);
const variant = matched?.variant ?? flag.defaultVariant;
return {
value: flag.variants[variant],
variant,
reason: matched ? 'TARGETING_MATCH' : 'DEFAULT',
flagMetadata: this.metadataForSnapshot(),
};
}
metadataForSnapshot() {
const ageMs = performance.now() - this.installedAtMonotonicMs;
return {
revision: this.snapshot.revision,
source: this.source,
ageMs,
stale: ageMs > staleAfterMs,
};
}
resolveBooleanEvaluation(key, value, context) {
return this.resolve(key, value, context, 'boolean');
}
resolveStringEvaluation(key, value, context) {
return this.resolve(key, value, context, 'string');
}
resolveNumberEvaluation(key, value, context) {
return this.resolve(key, value, context, 'number');
}
resolveObjectEvaluation(key, value, context) {
return this.resolve(key, value, context, 'object');
}
async onClose() {
clearInterval(this.timer);
}
}
const provider = new SnapshotProvider();
await OpenFeature.setProviderAndWait(provider);
const client = OpenFeature.getClient('runtime-lab');
const context = process.env.OMIT_CONTEXT === '1'
? {}
: { targetingKey: 'subject-demo-001', tenantTier: 'beta' };
for (let i = 0; i < 8; i += 1) {
const details = await client.getBooleanDetails('checkout-v2', false, context);
console.log(JSON.stringify(details));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await OpenFeature.close();代码中的 <read-only-sdk-token> 是占位符。本地模拟服务不验证它;接入真实服务时从 secret manager 或环境变量注入,绝不能提交到仓库。示例通过深冻结避免请求修改已安装快照,用单飞刷新避免慢请求造成并发下载,并用单调时钟计算“本进程多久没有成功安装配置”。它仍省略了完整 schema 编译、校验和/签名验证、目录 fsync 和 Provider events,不能直接复制为生产 Provider。生产实现还应发出 ready、error、stale 与 configuration changed events,并对日志限流。
先启动控制面,再在另一终端运行评估器:
node control-plane.mjs
node runtime.mjs正向结果应连续包含 value:true、variant:"on"、reason:"TARGETING_MATCH",metadata 中 source 为 remote、revision 为 1。这证明远程配置获取、上下文规则、本地评估和 details 证据链连通。结束后会留下 flags.lkg.json,它正是后续故障实验的恢复材料。
故障实验一:冷启动没有任何配置
先停止控制面,并删除实验缓存;确认当前目录也没有 bootstrap:
rm -f flags.lkg.json flags.lkg.json.tmp flags.bootstrap.json
node runtime.mjsPowerShell 可使用:
Remove-Item flags.lkg.json,flags.lkg.json.tmp,flags.bootstrap.json -ErrorAction SilentlyContinue
node runtime.mjs预期 setProviderAndWait 失败,程序不会进入评估循环。第一证据是初始化异常,而不是一次 FLAG_NOT_FOUND。这表示 Provider 从未获得可评估状态。修复可以是恢复配置端点、修正 DNS/CA/凭证,或提供经过校验的 bootstrap;若业务允许 default 启动,应把该决策写进 readiness,而不是吞掉初始化错误假装 ready。
要验证 bootstrap 路径,先启动控制面成功生成 flags.lkg.json,复制为 flags.bootstrap.json,再停控制面并删除 LKG:
cp flags.lkg.json flags.bootstrap.json
rm flags.lkg.json
node runtime.mjs结果应继续命中 on,metadata 的 source 为 bootstrap。这只是恢复能力证明;生产 bootstrap 必须随制品版本化并经过安全审查,不能在部署脚本里临时复制未知节点缓存。
故障实验二:运行中断网仍使用 LKG
启动控制面和 runtime.mjs,看到两三条成功输出后停止控制面。评估器每秒刷新一次,日志会出现 refresh failed,但业务 details 仍返回 value:true。随着 ageMs 增长,stale 从 false 变成 true。
这时系统处于“可评估但配置陈旧”,不是 ready 的同义词,也不是必须立刻返回 SDK default。修复后重新启动控制面,下一次刷新成功,配置年龄下降,状态恢复。生产验收要同时观察:业务错误率没有突增,refresh error 有告警,LKG revision 未倒退,恢复后配置更新时间推进。若只看最终布尔值,会错过整个故障。
远程评估型 SDK 不一定拥有可安全复用的 LKG,因为响应可能依赖每次 context,缓存单个结果还会混淆主体。除非 Provider 明确保证按 flag、context、版本正确缓存,否则断网时应直接在超时后返回调用方 default。不要把本地评估的缓存结论套到远程模式。
故障实验三:规则陈旧与回退配置
先以 CONTROL_REVISION=2 启动控制面并运行一次评估器,生成 revision 2 的 LKG;然后把控制面改成 revision 1:
CONTROL_REVISION=2 node control-plane.mjs
# 生成 LKG 后停止,再启动较旧 revision
CONTROL_REVISION=1 node control-plane.mjs
node runtime.mjs在 PowerShell 中写作:
$env:CONTROL_REVISION='1'; node control-plane.mjs预期刷新日志出现 revision rollback rejected,评估继续使用 revision 2。这个实验验证“收到 HTTP 200”不等于新配置可安装。真实系统还应拒绝环境错配、校验和错误、未知 schema 和规则编译失败,并分别计数。
stale 的判断不能只靠本机 Date.now() 与服务端时间相减;时钟漂移会制造假告警。更稳妥的指标包括“自本进程最后一次成功安装以来的单调时间”“当前 revision 与控制面宣告 revision 的差值”和“配置更新事件最后成功时间”。生产阈值按 flag 类型区分:临时发布开关可能要求分钟级传播,长期 ops flag 可容忍更长窗口,权限与数据迁移相关开关则可能完全不允许 stale。
故障实验四:上下文缺失不能伪装成未命中
控制面正常时运行:
OMIT_CONTEXT=1 node runtime.mjs预期 details 返回调用方 default false,reason:"ERROR",并标出 TARGETING_KEY_MISSING 与缺失字段。若只有 tenantTier 这类自定义字段缺失,示例返回 INVALID_CONTEXT;TARGETING_KEY_MISSING 只表示缺少评估主体。它们与 tenantTier 为 stable 后正常命中 defaultVariant:"off" 完全不同:前两者是数据管道错误,应统计和修复,后者才是合法规则结果。
上下文缺失常由鉴权中间件顺序错误、异步任务脱离请求作用域、消息消费者没有恢复 subject,或字段重命名造成。修复后要用两个并发主体做隔离测试,而不是只验证单请求:
const runInFeatureContext = (context, task) => new Promise((resolve, reject) => {
OpenFeature.setTransactionContext(context, () => {
Promise.resolve().then(task).then(resolve, reject);
});
});
await Promise.all([
runInFeatureContext(
{ targetingKey: 'subject-a', tenantTier: 'beta' },
() => service.handle(requestA),
),
runInFeatureContext(
{ targetingKey: 'subject-b', tenantTier: 'stable' },
() => service.handle(requestB),
),
]);Node.js 应使用 SDK 提供的 AsyncLocalStorage transaction context propagator。Node.js SDK 的 setTransactionContext 返回 void,所以示例用 Promise 包装实际异步任务;直接把两次调用的返回值交给 Promise.all 会立即结束等待,形成没有真正验证并发隔离的假实验。Java 通常使用线程/作用域上下文,Go 显式传递 context.Context。不要把当前用户写进 global context,也不要修改多个请求共享的可变对象。验收不变量是:主体 A 始终命中自己的 variant,主体 B 始终命中自己的 variant;提高并发轮次后也不交叉。
超时、重试与熔断要按链路位置设计
本地评估的热路径通常不需要网络超时,超时发生在后台同步;同步失败不应暂停现有请求。远程评估则必须让单次超时小于业务剩余预算,并在 SDK 内限制并发。一次请求已经接近截止时间时,不应再发新的评估调用。重试只适合幂等评估,且总尝试时间不能突破预算;大量实例同时重试要有随机抖动。
熔断器打开后直接返回业务 default,可以保护线程池和连接池,但也可能长期掩盖规则恢复。半开探测必须少量、独立计数,并在成功后恢复。按 Provider 端点熔断通常比按 flag 熔断更合理;若不同 flag 走不同 domain 或后端,则分别隔离。超时、限流、认证失败、解析失败和 Provider not ready 要保留不同 error code,不要统一改写成“false”。
请求内若多次评估同一 flag 与相同 context,可以做作用域内去重;跨请求缓存远程结果必须把所有影响规则的 context 与配置版本纳入 key,通常代价很高。错误缓存尤其危险:一次凭证抖动得到的 default 若被缓存,会在后端恢复后继续传播。除非 Provider 明确定义缓存语义,应用层不要自行缓存评估结果。
可观测性要能回答“为什么是这个值”
运行时至少输出以下低基数指标:
| 指标 | 能回答的问题 | 推荐标签 |
|---|---|---|
feature_evaluation_total | 评估量和各原因占比 | provider、flag、reason、variant |
feature_evaluation_error_total | 哪类失败在增长 | provider、error_code |
feature_default_total | default 是否异常放大 | provider、flag、error_code |
feature_config_install_total | 新快照成功或被拒绝 | provider、result、cause |
feature_config_age_seconds | 当前规则是否 stale | provider、environment |
feature_provider_status | ready、stale、error、fatal | provider、domain |
feature_event_queue_size | 事件是否堆积 | provider、event_type |
feature_flush_failure_total | 退出或批量发送是否丢失 | provider、cause |
不要把 targeting key、邮箱、租户 ID、完整 error message 或 revision 的无限集合放进指标标签。flag key 数量也要受治理;大量临时 flag 会增加指标基数和存储成本。单次排障可在采样 trace 中记录 flag key、variant、reason、provider、revision 和 stale,但敏感 context 只记录允许字段的散列或分类值。
日志应围绕状态转换,而不是每次成功评估。首次进入 stale、revision 被拒绝、凭证失效、恢复 ready、退出 flush 超时各记录一条结构化日志;相同错误按窗口聚合。告警也要看趋势和不变量:default 比例突然偏离基线、配置年龄持续增长、所有实例 revision 分裂、初始化失败与扩容同时发生,比“出现一次 refresh error”更有行动价值。
事件缓冲与退出 flush 是另一条故障链
评估成功不代表曝光或 tracking 事件已经送达。OpenFeature 规范把 track 定义为返回 void,不提供逐事件送达确认;没有实现 tracking 的 Provider 还必须 no-op。具体 Provider 可能在内存中批量缓冲事件,以降低每次评估的网络成本。队列要有最大容量、批量大小、发送间隔和丢弃策略;无界队列会在分析端点故障时耗尽内存,阻塞写入又会把非关键分析依赖变成业务故障。
退出顺序应是:实例从 readiness 摘除,停止接收新请求,等待在途请求,在限时预算内 flush 事件,停止 Provider 的流和刷新任务,最后结束进程。Kubernetes 的 terminationGracePeriodSeconds 要覆盖应用 drain 与 flush 预算,但不能把 Pod 永久卡在 terminating。OpenFeature.close() 只保证调用 Provider 的关闭钩子;是否 flush、等待多久、失败时如何暴露待发送数量,必须以具体 Provider 契约和故障实验为准。Provider shutdown 超时后记录待发送数量和丢弃原因,再按团队策略退出。
崩溃和 SIGKILL 没有 flush 机会。若实验计量要求近似不丢,事件应进入本地持久队列或独立采集代理;代价是磁盘、重放去重和隐私删除更复杂。大多数发布开关只需要运行观测,不值得让业务事务等待分析事件。团队必须明确 evaluation、exposure、impression、conversion 各自的交付保证,不能把它们统称为“埋点已上报”。
凭证与上下文数据决定故障半径
本地评估通常需要只读 SDK token 下载规则,远程评估 token 还允许提交 context;二者都不应拥有创建 flag、修改规则或读取审计配置的管理权限。每个环境、服务或受控 domain 使用独立凭证,保存在 secret manager 中,轮换时允许短时间双凭证重叠。认证失败要进入 fatal 或显式 degraded,不能无限用过期 LKG 而无人知晓。
context 中的 IP、邮箱、租户、角色、设备和实验主体可能构成个人或商业敏感数据。远程评估前画出数据流:字段从哪里产生,发送到哪个区域,是否进入 Provider 日志、事件和第三方分析,保留多久,谁能查询,如何删除。优先使用伪名 targeting key 和离散属性;规则不用的字段不传。客户端可篡改的属性不能决定 entitlement 或数据权限。
事件成本由评估 QPS、每请求 flag 数、曝光去重、采样、负载大小和保留周期共同决定。估算时使用 请求量 × 每请求评估数 × 事件产生率 × 平均事件字节,再叠加索引和副本成本。上线前在压测环境观察队列、CPU、网络和后端限流;不能用 SDK 调用耗时正常就推断事件链也有容量。
项目接入要把故障策略写成代码
应用层可以把 readiness、details 和业务 default 收口成一处:
type FeatureRuntimeState = 'bootstrapping' | 'ready' | 'stale' | 'fatal';
export class FeatureRuntime {
state: FeatureRuntimeState = 'bootstrapping';
constructor(private readonly client: Client) {}
async checkoutV2(context: EvaluationContext): Promise<boolean> {
const details = await this.client.getBooleanDetails(
'checkout-v2',
false,
sanitizeContext(context),
);
featureMetrics.record(details);
if (details.errorCode === 'TARGETING_KEY_MISSING') {
requestDiagnostics.mark('feature-context-invalid');
}
return details.value;
}
canServe(): boolean {
return this.state === 'ready' || this.state === 'stale';
}
}是否允许 stale 不能只由这一个通用 canServe 决定。更稳妥的实现把 flag 分为 release、experiment、ops、kill switch 与 migration,并为每类定义最大配置年龄和 default 方向。数据库双写迁移 flag 可能要求严格 revision 屏障;纯 UI 实验可以容忍短时 stale;kill switch 要有独立、高可用且经过演练的传播路径。
规则变更发布时保存 revision、操作者、审批、目标环境和预期 variant 分布。先在测试 context 集合做离线重放,再小范围安装并比较 resolution details。回滚不是简单把 revision 数字减一;若运行时拒绝 revision 倒退,应由控制面发布一个内容等同旧规则、但 revision 更高的新快照,这样单调性和审计链都不被破坏。
长期治理用演练证明恢复能力
每个季度或关键 Provider 升级前,至少重放四类故障:无远程、无缓存、无 bootstrap 的冷启动;ready 后断网;旧 revision 或损坏快照;并发请求缺失或串用 context。再增加慢响应、凭证撤销、事件端点不可达和退出超时,确认它们不会被同一个 fallback 指标掩盖。
演练通过的判据应是可观察不变量,而不是“进程没挂”:冷启动严格模式不接流量;LKG 模式 revision 不倒退;stale 状态能告警并在恢复后自动回到 ready;上下文错误可从 details 定位;事件队列有界;连续多轮故障后内存、句柄和定时器回到稳定基线。生产阈值由 SLO、发布频率和容量测量决定,实验中的毫秒值只用于快速复现。
清理本地实验时,停止两个 Node 进程,删除 feature-runtime-lab 目录即可。共享测试环境还要撤销只读 token、删除测试 flag 与 segment、清理事件数据和临时 bootstrap,确认配置端点不再接受旧凭证。生产退出某个 Provider 时,先停止新规则变更和事件双写,验证替代 Provider 的 revision 与 details,再撤销凭证和依赖;保留一条经过演练、受时限约束的回切路径。
一个成熟的评估运行时不会承诺控制面永不失败。它能准确区分“尚未有配置”“正在用有效快照”“快照已经陈旧”“上下文不可用”“事件尚未送达”,并为每种状态给出有限、可观测、可恢复的行为。这样 feature flag 才是渐进交付的隔离层,而不是把远程配置故障藏进业务布尔值的放大器。
