从提示改版后旧口径继续返回说起
「回声」的搜索摘要提示在周三下午改了一版。旧版要求「三条以内,不超过八十个字」,新版改成「五条以内,先结论后依据」。发布流程没有问题:模板版本从 p3 升到 p4,评测集通过,灰度十分钟,全量。
周四上午,运营发现一部分搜索结果还是三条。排查后确认模型调用正常、新提示已经生效,问题在缓存。缓存的键里只有租户、能力和查询文本,没有提示版本。旧版写入的结果仍然躺在缓存里,TTL 还有十几个小时。用户看到的不是新提示的输出,而是昨天那版提示的残留。
这不是孤例。同一个季度里还有两次:
- 模型从
mock-standard切到mock-frontier,部分租户的摘要仍由旧模型生成,因为键里没有模型。 - 一位用户注销账号两周后,另一位同租户用户搜索了相似的句子,语义缓存命中并返回了包含前一位用户订单信息的摘要。
第一次是版本失效问题,第二次是删除权传播问题。两者表面上都属于「缓存脏了」,但传播范围完全不同:提示换版影响同一租户同一能力的全部结果;用户删除只影响包含该用户数据的那部分结果,而且必须跨能力传播。
如果失效逻辑只会「清空全部缓存」,它确实能解决这两个问题,但代价是所有租户、所有能力同时冷启动。真实系统需要的是按范围失效,并且要挡住失效之后迟到的旧写回。
失效不是删除,而是版本推进
把失效理解成「删掉旧键」,会漏掉一个关键场景:删除发生的那一刻,可能还有请求在处理中。它读的是旧版本,写回时用的是旧作用域。删除完成后,这个迟到的写回会把刚清掉的数据重新放回去。
所以失效要做两件事,顺序不能反:
- 推进作用域纪元,让旧作用域的写入从此刻起被拒绝。
- 清理已经存在的旧结果。
纪元是「当前有效的版本组合」,至少包含提示版本、模型标识与权限分区。写入前先检查请求携带的作用域是否与当前纪元一致;不一致就丢弃写入。这样即使旧请求晚到,也无法污染新一轮缓存。
清理则是按范围遍历并删除。删除必须同时清理精确索引与语义索引,否则精确键没了,语义桶里还留着一个指向已删除条目的键,下一次相似查询仍可能命中。
四类失效与它们的传播范围
| 触发源 | 传播范围 | 是否推进纪元 | 典型错误 |
|---|---|---|---|
| 提示换版 | 同租户、同能力的全部结果 | 是,提示版本 | 只改模板不换键,旧结果继续命中 |
| 模型切换 | 同租户、同能力的全部结果 | 是,模型标识 | 只对新请求生效,旧结果留在缓存 |
| 权限调整 | 同租户、同能力的全部分区 | 是,权限分区 | 只清当前分区,其他分区仍按旧边界返回 |
| 用户删除 | 包含该用户数据的结果,跨能力 | 否,改用墓碑 | 只清当前能力,其他能力仍有残留 |
提示换版与模型切换的范围相同,但语义不同。提示换版改变的是「怎么回答」,模型切换改变的是「谁来回答」。两者都要求同租户同能力的结果全部重算,因为无法判断哪一条旧结果仍然适用。
权限调整的范围更大。权限边界变化后,同一个租户内不同分区的可见内容可能重新划分,旧分区里的结果不能简单按新标签继续使用。保守做法是把该能力的全部分区一起清掉,让新边界下的第一次请求重新计算。
用户删除的范围最特殊。它不按能力划分,而按「结果里是否包含这个用户的数据」划分。一条摘要可能同时包含多个用户的信息,删除其中一个人,整条结果都必须作废,不能只改其中一段。
提示换版:让旧作用域写不回来
提示换版的实现可以很短:把新版本号写进作用域纪元,然后删除该租户该能力下所有提示版本不等于新版本的条目。
这里有一个容易写错的细节:清理条件不能用「旧版本号」,而要用「不等于新版本号」。因为线上可能同时存在多个历史版本,灰度期间也可能有多个版本并行。用「不等于」可以一次清掉所有非当前版本,避免逐个枚举。
清理之后,旧请求仍可能带着旧版本号到达。写入前的纪元检查会拒绝它。拒绝应当是静默的:请求本身可以正常返回模型结果,只是不写缓存。对一个正在服务的请求来说,缓存写不进去不是错误,只是一次成本损失。
换模型:只影响该能力
模型切换常常被当成全局事件,实际影响范围是「租户 × 能力」。同一个租户的搜索摘要换了模型,不代表私信回复也要换。失效逻辑应当按能力切割,否则一次模型灰度会让全站缓存冷启动。
模型标识要进作用域键,这一点在 9.1 已经说过。失效时则要按「不等于新模型」清理,理由与提示版本相同:可能同时存在多个旧模型。
如果新模型只是候选,尚未全量,正确的做法不是失效,而是给新模型一个独立的作用域。灰度流量写新作用域,存量流量继续读旧作用域,两者互不干扰。等全量后再统一失效旧作用域。
权限调整:同能力全分区重算
权限调整的难点在于「哪些结果仍然有效」无法从缓存条目本身判断。一条摘要可能引用了某个文档,而该文档的可见范围刚刚改变。缓存只知道结果属于哪个权限分区,不知道结果内部引用了哪些资源。
因此保守策略是:权限边界一变,该租户该能力的全部分区一起作废。这会产生一次冷启动,但避免了「按旧边界生成、按新边界展示」的越权风险。
如果权限系统能提供「受影响资源列表」,可以做得更细:只清理引用了这些资源的结果。但这要求缓存记录结果与资源的依赖关系,复杂度显著上升。多数团队应当先做保守策略,把越权风险降到零,再考虑优化。
用户删除权:从结果传到索引
用户删除权要求「用户数据被删除后,不再出现在任何派生结果里」。缓存是派生结果的一种,而且是最容易被忽略的一种,因为它不在数据库里,也不在备份里,看起来像临时数据。
传播路径至少有三段:
- 精确缓存:删除所有
subjectIds包含该用户的结果。 - 语义索引:删除条目时必须同步删除索引键,否则相似查询仍会命中。
- 派生记录:日志、评测样本、离线特征、导出文件。缓存代码只能负责前两段,第三段要靠数据地图与删除流程。
subjectIds 是写入时记录的「这条结果里出现过谁的数据」。它不参与缓存键,因为一条结果可能包含多个用户;它只参与删除传播。记录粒度可以是用户 ID,也可以是更粗的主体标识,取决于产品的数据模型。
删除还要防「迟到写回」。用户删除后,一个正在处理的请求可能带着该用户的数据写回缓存。解决办法是维护墓碑集合:被删除的用户 ID 进入集合,写入时检查 subjectIds 是否与墓碑相交,相交就拒绝。
墓碑不需要永久保存。它只需要覆盖「最长请求处理时间 + 安全余量」的窗口。超过窗口后,不可能还有合法请求携带该用户的数据。窗口长度应当由请求超时预算决定,而不是拍一个「七天」。
删除报告只应记录数量与作用域,不应记录查询文本与缓存值。查询文本本身可能就是个人信息,把「删除了哪条查询」写进日志,等于把刚删掉的数据又复制了一份。
租户隔离的三种破法
多租户隔离的漏洞通常不在数据库,而在缓存。三种常见破法值得单独列出来。
第一种是键漏租户。查询文本相同就复用,单租户测试完全正常,多租户上线后串数据。修复不是加一个 if,而是把租户放进作用域键,让「跨租户命中」在结构上不可能发生。
第二种是语义索引全局共享。精确键带了租户,语义桶却是全局的,相似比较时仍然会跨租户。修复是让语义桶的键与精确键的前半段完全一致,任何一次比较都先经过作用域过滤。
第三种是前缀拼接歧义。用 : 拼接字段,租户 a、能力 b:c 与租户 a:b、能力 c 会拼成同一个字符串。修复是使用不会出现在字段里的分隔符,或者在拼接前对字段做转义。前者更简单,后者更容易出错。
隔离的验证不能只靠代码评审。测试里应当显式构造「同文本、不同租户」「同文本、不同权限分区」「同文本、不同提示版本」三组用例,并断言它们全部未命中。这些用例很便宜,但能挡住最贵的一类事故。
代码:失效器与作用域纪元
下面的实现把「推进纪元」与「按范围清理」放在同一个类里,并提供带守卫的写入。缓存本体来自 9.1 的 ResultCache,这里不重复实现存储。
// file: src/cache/invalidation.ts
// tier: L1
// verify: node --test test/cache/invalidation.test.ts
import type { CapabilityId, TenantId } from "../core/types.ts";
import type { CacheEntry, CacheScope, CacheWriteOptions, ResultCache } from "./cache.ts";
export type InvalidationReason =
| "prompt-version"
| "model-version"
| "permission-change"
| "user-deletion"
| "tenant-purge"
| "manual";
/** 作用域当前有效的版本组合,旧版本写入会被拒绝。 */
export interface ScopeEpoch {
promptVersion: string;
modelId: string;
permissionScope: string;
}
export interface InvalidationReport {
reason: InvalidationReason;
removed: number;
remaining: number;
/** 只记录作用域标签,不记录键原文与缓存值。 */
scopes: string[];
at: number;
}
export interface InvalidatorOptions {
now?: () => number;
}
function epochKey(tenantId: TenantId, capability: CapabilityId): string {
return `${tenantId}\u0000${capability}`;
}
export class ScopeGuard {
private epochs = new Map<string, ScopeEpoch>();
seed(scope: CacheScope): ScopeEpoch {
const key = epochKey(scope.tenantId, scope.capability);
const existing = this.epochs.get(key);
if (existing) {
return existing;
}
const epoch: ScopeEpoch = {
promptVersion: scope.promptVersion,
modelId: scope.modelId,
permissionScope: scope.permissionScope,
};
this.epochs.set(key, epoch);
return epoch;
}
current(tenantId: TenantId, capability: CapabilityId): ScopeEpoch | undefined {
return this.epochs.get(epochKey(tenantId, capability));
}
/** 没有登记过的作用域放行,登记过则三项必须完全一致。 */
admit(scope: CacheScope): boolean {
const epoch = this.current(scope.tenantId, scope.capability);
if (!epoch) {
return true;
}
return (
epoch.promptVersion === scope.promptVersion &&
epoch.modelId === scope.modelId &&
epoch.permissionScope === scope.permissionScope
);
}
advance(scope: CacheScope, patch: Partial<ScopeEpoch>): ScopeEpoch {
const next: ScopeEpoch = { ...this.seed(scope), ...patch };
this.epochs.set(epochKey(scope.tenantId, scope.capability), next);
return next;
}
}
export class CacheInvalidator<T> {
private cache: ResultCache<T>;
private guard: ScopeGuard;
private now: () => number;
private reports: InvalidationReport[] = [];
/** 删除过的用户不能通过迟到的请求把数据写回缓存。 */
private deletedUsers = new Map<TenantId, Set<string>>();
constructor(cache: ResultCache<T>, options: InvalidatorOptions = {}) {
this.cache = cache;
this.guard = new ScopeGuard();
this.now = options.now ?? (() => Date.now());
}
write(
scope: CacheScope,
input: string,
value: T,
options: CacheWriteOptions = {},
): CacheEntry<T> | undefined {
if (!this.guard.admit(scope)) {
return undefined;
}
const deleted = this.deletedUsers.get(scope.tenantId);
if (deleted && (options.subjectIds ?? []).some((id) => deleted.has(id))) {
return undefined;
}
return this.cache.set(scope, input, value, options);
}
/** 提示换版:同一租户同一能力下,旧提示版本的结果全部作废。 */
invalidatePrompt(scope: CacheScope, nextPromptVersion: string): InvalidationReport {
this.guard.advance(scope, { promptVersion: nextPromptVersion });
return this.purge(
(entry) =>
entry.scope.tenantId === scope.tenantId &&
entry.scope.capability === scope.capability &&
entry.scope.promptVersion !== nextPromptVersion,
"prompt-version",
);
}
/** 换模型:只作废该能力的结果,不牵连其他能力。 */
invalidateModel(scope: CacheScope, nextModelId: string): InvalidationReport {
this.guard.advance(scope, { modelId: nextModelId });
return this.purge(
(entry) =>
entry.scope.tenantId === scope.tenantId &&
entry.scope.capability === scope.capability &&
entry.scope.modelId !== nextModelId,
"model-version",
);
}
/** 权限边界调整后,该能力的全部分区都要重算,不能只换一个标签。 */
invalidatePermission(scope: CacheScope, nextPermissionScope: string): InvalidationReport {
this.guard.advance(scope, { permissionScope: nextPermissionScope });
return this.purge(
(entry) =>
entry.scope.tenantId === scope.tenantId &&
entry.scope.capability === scope.capability,
"permission-change",
);
}
/** 用户删除权:按结果里出现过的 subjectIds 传播,跨能力生效。 */
invalidateUser(tenantId: TenantId, userId: string): InvalidationReport {
let deleted = this.deletedUsers.get(tenantId);
if (!deleted) {
deleted = new Set();
this.deletedUsers.set(tenantId, deleted);
}
deleted.add(userId);
return this.purge(
(entry) => entry.scope.tenantId === tenantId && entry.subjectIds.includes(userId),
"user-deletion",
);
}
invalidateTenant(tenantId: TenantId): InvalidationReport {
return this.purge((entry) => entry.scope.tenantId === tenantId, "tenant-purge");
}
invalidateCapability(tenantId: TenantId, capability: CapabilityId): InvalidationReport {
return this.purge(
(entry) =>
entry.scope.tenantId === tenantId && entry.scope.capability === capability,
"manual",
);
}
history(): InvalidationReport[] {
return [...this.reports];
}
private purge(
predicate: (entry: CacheEntry<T>) => boolean,
reason: InvalidationReason,
): InvalidationReport {
const matched = this.cache.list().filter(predicate);
for (const entry of matched) {
this.cache.remove(entry.key);
}
const scopes = [
...new Set(matched.map((entry) => `${entry.scope.tenantId}/${entry.scope.capability}`)),
];
const report: InvalidationReport = {
reason,
removed: matched.length,
remaining: this.cache.size,
scopes,
at: this.now(),
};
this.reports.push(report);
return report;
}
}
几个设计选择需要解释。
第一,纪元按「租户 × 能力」登记,而不是按精确作用域。提示版本与模型标识是租户级、能力级的配置,不是每个权限分区各有一份。按更细的粒度登记会让失效逻辑复杂,却不会带来额外收益。
第二,invalidatePrompt 的清理条件是 promptVersion !== next,而不是 === previous。这样能一次清掉所有历史版本,包括灰度期间并行存在的版本。
第三,用户删除的墓碑检查发生在写入前,而不是清理时。清理只能处理已经存在的结果,墓碑处理的是「删除之后还想写回来」的请求。两者缺一不可。
第四,报告里的 scopes 是「租户/能力」标签,不是缓存键。缓存键包含规范化后的查询文本,可能含有个人信息;把它写进失效报告,等于把删除做成了复制。
失效的顺序与可观测性
一次完整的失效应当按下面的顺序执行:
- 推进纪元,先挡住新写入。
- 执行清理,删除旧条目与语义索引。
- 发出失效事件,通知多实例与其他区域。
- 抽样验证,确认旧作用域不再命中。
顺序不能反。如果先清理再推进纪元,清理与推进之间到达的旧请求仍可能写回;如果只在本实例推进纪元,其他实例仍会接受旧写入,所以失效事件必须广播。
可观测性至少要回答三个问题:这次失效删了多少条、覆盖了哪些作用域、清理后是否还有旧版本命中。前两个问题由失效报告回答,第三个问题要靠命中日志里的版本字段。如果日志里没有版本字段,失效是否成功就只能靠猜。
失效也需要限流。一次权限调整可能触发几十万条删除,同步遍历会阻塞请求线程。生产实现应当把清理放进后台任务,按作用域分批删除,并在批次之间让出执行权。本节代码为了可测试,使用的是同步遍历;这个差别在接入真实存储时必须补上。
小结
失效不是「删掉旧键」,而是「先推进版本,再清理存量」。只清理不推进,迟到的旧请求会把数据写回来;只推进不清理,旧条目会继续占用内存,语义索引也可能继续命中。
四类失效的传播范围不同:提示换版与模型切换影响同租户同能力,权限调整影响同能力的全部分区,用户删除按结果里出现过的用户传播,并且要跨能力。
多租户隔离要写进键的结构,而不是靠调用方记得传租户。精确键、语义桶、拼接分隔符,任何一处漏掉租户,都会在真实流量里变成串数据事故。
阅读导航:上一节:9.1 精确缓存、语义缓存与结果复用 · 下一节:9.3 命中率的度量与陷阱 。
常见错误与边界
- 只清精确缓存,不清语义索引。精确键没了,相似查询仍会命中已删除的条目。
- 失效只在本实例执行。多实例部署下,其他实例继续用旧版本提供服务。
- 先清理再推进纪元。清理与推进之间到达的旧请求会把数据写回。
- 用旧版本号做清理条件。灰度期间存在多个历史版本时,只能清掉其中一个。
- 用户删除只清当前能力。同一条用户数据可能出现在搜索、私信、推荐等多个能力里。
- 删除报告记录查询原文。刚删掉的数据又被写进日志,等于没有删除。
- 墓碑永久保存。用户 ID 可能被回收,永久墓碑会误伤新用户。
- 权限调整只清当前分区。其他分区仍按旧边界生成的结果继续被使用。
- 把缓存当事实来源。缓存不可用时请求应当继续走模型,而不是直接失败。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。