缓存前缀删不掉:V8 缓存键归一化的三个分支与删除边界

架构解释图(确定性渲染)|一次删除调用在租户命名空间、模式串和返回码之间的落点
摘要| 业务前缀传给按模式删缓存,返回 0;补上通配符再调,一次清掉三个 Key。差别不在 Redis 的模式匹配,而在 V8 缓存代理把逻辑 Key 改写成租户命名空间的那一步,删除复用了同一套改写。当前实现里这段改写有三个分支:普通逻辑 Key 补前缀、本租户完整前缀被接受并规范化、其它租户前缀直接抛异常。
✦ 01|现象:传前缀返回 0,补一个通配符删掉三个
一个按分页缓存的商品列表,Key 形如 Product:List:1:20、Product:List:2:20、Product:List:3:20。后台改价以后想一次清干净,最自然的写法是把公共前缀交给按模式删除的接口。第一次调用返回 0。
把参数改成前缀加通配符的模式串,同一次调用清掉了三个 Key。同一个接口、同一个前缀,只差一个通配符。返回 0 不是失败——它说明调用成功,只是没有命中任何 Key。
- 模式删除需要一个模式串;不带通配符的前缀就是一个完整 Key,精确匹配不到就返回 0。
- V8 缓存代理在触及 Redis 之前会改写 Key,而改写规则对普通读写和模式删除完全一致。
- 跨租户的完整前缀不会被改写后继续执行,而是直接抛异常。
现象定位| 两次调用的差别只有参数本身,说明平台既没有偷偷补通配符,也没有把前缀当模式扩展。问题不在“功能没生效”,而在“前缀和模式是两件事”。
✦ 02|直觉方案为什么不够:前缀不等于模式
第一反应通常是平台删前缀的能力没生效。但如果是平台没生效,带通配符的第二次调用同样删不掉东西;两次走的是同一条代码路径,唯一的输入差异就是模式串。
第二反应是自己在业务侧维护一份 Key 索引,按索引逐个删。这条路的成本常被低估:索引本身也是需要并发安全、需要与 TTL 对齐、需要在多节点之间共享的状态,等于把一个 Redis 模式扫描问题换成一个分布式索引一致性问题。
- 通配符只决定“谁被命中”,不决定“什么时候删”;删除时机仍要和写入顺序对齐。
- 按模式删也不会阻止正在回源的请求把旧值重新写回缓存,删除与回填之间依然需要版本或条件更新兜底。
- 模式删除要在租户连接的全部端点上扫描,模式范围越大代价越高,而租户命名空间之外的模式根本传不进去。
结论| 业务前缀是命名约定,模式串才是删除指令。把前者当后者用,得到的就是一个成功返回 0 的调用。
✦ 03|调用链:一次 V8.Cache 调用穿过四层
接口引擎里拿到的 V8 缓存对象是一个安全代理,不是 Redis 客户端。当前实现里这个接口只暴露普通读写、Hash 系列、存在判断、TTL 调整、原子首次写入,以及按模式删除;连接管理和底层数据库句柄都不在其中。
一次 V8 缓存调用会依次穿过安全代理、租户级两级缓存和 Redis 实现。归一化发生在最外层,所以后面两层看到的已经是完整 Key。Microi吾码AI 把这一步固定在最外层,是为了让业务脚本永远不必自己拼租户段。

架构解释图(确定性渲染)|逻辑 Key → 归一化 → 两级缓存 → 租户 Redis 连接 → 模式扫描
易混点| 归一化是整条链上唯一一次改写 Key 的地方。读写、删除、TTL 调整和模式删除都从这里进来,所以它们对同一个逻辑 Key 的看法必须一致。
✦ 04|归一化的三个分支:改写、兼容、拒绝
归一化本身只有几行判断,但分支之间的差别决定了脚本能不能跑通。当前实现按输入形态分成四类处理,其中只有前三类会正常返回。
- 普通逻辑 Key:补上 Microi:{OsClient}:,business:key 变成 Microi:tenant_a:business:key。
- 已经带本租户完整前缀:保留租户段之后的内容,再用规范化的租户标识重新拼回去,租户段大小写不一致的历史写法被收敛到同一个 Key。
- 历史兼容写法 SysConfig:{OsClient}:映射成 Microi:{OsClient}:SysConfig,不会再二次拼接租户段。
- 其它租户的 Microi: 前缀:直接抛无效操作异常,不做改写。
- 空 Key、只有租户前缀而没有业务名、含控制字符:三种都抛参数异常。
租户标识本身也有格式约束:只允许字母、数字、下划线和中划线,长度 1 到 50,不满足即失败关闭。这条约束把租户段变成了一个不可能与业务段混淆的定长语境。
// 推荐写法:只传逻辑 Key,租户段交给服务端
var k1 = 'Product:List:' + V8.Param.PageIndex + ':' + V8.Param.PageSize;
V8.Cache.Set(k1, JSON.stringify(listResult), 300);
// 兼容写法:本租户完整前缀会被接受并规范化
var k2 = 'Microi:' + V8.OsClient + ':Product:Detail:' + V8.Param.Id;
var cached = V8.Cache.Get(k2);
// 下面这行会直接抛异常,不会返回 null 或 0
// V8.Cache.Get('Microi:other-tenant:Product:Detail:1');
一致性| 归一化是同一个函数,读写和删除共用它。这意味着“写入时用什么 Key,读取和删除时就必须用同一种形态”,否则会得到两个不同的 Redis Key,且都不会报错。
✦ 05|删除边界:同一个改写,不同的动词
按模式删除的参数同样先过归一化。传业务前缀时,实际落到 Redis 上的是一个精确 Key;Redis 找不到它,于是返回 0。想删一组,必须自己把模式写出来,归一化之后模式仍然是模式。
还有一个容易忽略的接口形状问题:按模式删除在安全接口上被声明着,但官方 V8 常用方法表并没有把它列出来。也就是说,它在类型层面对脚本可见,在文档层面不属于推荐入口。使用它之前,先确认当前版本确实暴露了它。
// 删除一组分页缓存:前缀必须写成模式
var removed = V8.Cache.RemoveParentAsync('Product:List:*');
// 归一化后实际扫描的是 Microi:{OsClient}:Product:List:*
// 只删一个精确 Key 时,普通删除更便宜,也不会触发扫描
V8.Cache.Remove('Product:Detail:' + V8.Param.Id);
// 批量删除必须等待完成,禁止 fire-and-forget
// await(或同步调用)之后才继续下一次导入写入
代价| 模式删除要在租户连接的全部端点上枚举匹配 Key。当前实现里平台自己的批量写入路径会显式等待这类调用,注释写明把数千个扫描同时压进同一个连接会导致连接被关闭,并让其它节点继续读到旧缓存。
同一份实现还记了一条约束:当某项计数缓存实际关闭时,平台会跳过对应的按模式失效,因为那只会产生无效扫描,并在批量导入时造成超时。删除的代价是真实的,所以能精确删就不要用模式删。
✦ 06|异常边界:哪些输入不是返回 0,而是抛错
归一化里有一半分支不是改写,而是拒绝。排障时这两类必须分开看:返回 0 说明调用成功但没命中;抛异常说明参数在触及 Redis 之前就被拦下了,此时缓存里什么都没发生。
- 空 Key:抛参数异常,缓存 Key 不能为空。
- 只有租户前缀、没有业务名:抛参数异常,明确提示缺少业务名称。
- 其它租户的 Microi: 前缀:抛无效操作异常,禁止访问本租户命名空间之外的缓存 Key。
- 过期秒数为 NaN、无穷或小于等于 0:涉及 TTL 的三个方法都抛范围异常,而不是静默改成永久。
- 参数含控制字符:抛参数异常,避免不可见字符参与 Key 比较。

架构解释图(确定性渲染)|四类输入的落点:补前缀、规范化、映射、拒绝
排障口径| 调用抛异常时不要先去查 Redis,先看参数形态;返回 0 时才去查模式是否写成了通配符。这两条路径的证据完全不同。
✦ 07|聚焦验证与生产取舍
上面的分支不是从文档推断的。当前测试工程里有一组针对性用例,直接断言三条归一化结果与跨租户拒绝,另外还断言 V8 缓存接口不暴露连接和底层数据库句柄。
本地跑一遍过滤测试:56 个用例全部通过、0 失败、退出码 0,总耗时约 3.0 秒。这组测试不连接 Redis,验证的是归一化纯函数、参数校验和接口形状。

本地验证截图|聚焦过滤测试真实运行输出:56 项通过、0 失败、退出码 0
- 业务脚本一律只传逻辑 Key,不要自己拼租户段,也不要引用其它租户的命名空间。
- 需要成组失效时显式写模式,并把模式收窄到业务前缀加通配符;能精确删就不要扫描。
- 批量导入或循环内的异步删除必须等待完成,否则会压垮共享连接并让其它节点继续读到旧缓存。
取舍| 归一化把跨租户访问从“可能出错”变成“直接失败”,代价是所有成组删除都必须自己写清模式。这两件事是同一次设计选择的结果,只能一起接受。
文中已标注的概念图由AI生成;源码与实测证据均来自当前工作区。
更多推荐




所有评论(0)