3.4 缓存隔离:Redis 键前缀与防串号

场景:最安静的故障,最响的投诉

3.3 讲文件串号好歹还有图片能看见,缓存串号是真正意义上的隐形事故——页面不变样,接口不报错,数据悄悄就不是你家的了。我们吃过一次这样的亏:某天下午客服炸了,十几个租户同时反馈看板数据对不上,财务对账对的还是上个月的数。排查了两小时才定位到缓存层:一个新的报表服务上线时,缓存键忘了加租户前缀,所有租户共用一个 key,谁先写入就显示谁的数据,后面进来的人看到的永远是第一个人的报表。

事故的善后成本极高:要逐个租户核对数据、写情况说明、给两个受影响的大客户免了一个月费用,还在管理层例会上做了完整复盘,那场复盘的幻灯片标题我至今记得——「静默故障的两小时」。更难受的是客户问的那句话——「你们是不是把我们的数据漏给别人看了」,这句话是 SaaS 公司最怕听到的问题,因为它质疑的不是技术而是信任。

从此缓存键前缀成了我们代码规范里的红线之一,还被写进了 CI 检查脚本:所有调用 Redis 的地方,key 必须以 t:{tenant_id} 开头,机器扫描不合规调用直接阻断发布。无偿的小检查脚本,拦住的可能是下个月的一封律师函。

缓存隔离的可选方案我们也过了一遍:

方案 隔离强度 运维成本 适用
共享实例加键前缀 中 低 我们的选择
每租户一个 Redis 库 中高 中 中等规模
每租户一个实例 最高 高 金融与隔离强需求

共享实例加键前缀是性价比最高的方案,前提是规则被严格执行。它还有一个隐性好处:大户的热点 key 与小户的冷 key 混在一起,整体命中率反而更稳定;缺点也对应存在——某个租户的突发流量会挤占所有人,这个问题的解法在 4.4 限流分级里。我们还做过一个折中:把健忘型的大户单独迁到一个独立库,理由是他们一个租户就占了两成内存,跟进成本远高于隔离收益,中间方案并不丢人。这个商定的分界线是单租户内存超过总量的百分之二十,达到就迁,没有谈判空间。

配置:键前缀中间件与会话加固

缓存访问全部收进 CacheService,键的拼装是内部细节,业务代码只传业务名与参数:

class CacheService
{
    public function remember(string $name, array $args, callable $loader, int $ttl)
    {
        $ctx  = app(TenantContext::class);
        // 业务 key 永远以租户前缀开头
        $key  = sprintf('t:%d:%s:%s', $ctx->id(), $name, md5(json_encode($args)));

        return $this->redis->remember($key, $ttl, $loader);
    }
}

别小看这一层薄封装,它把「键前缀」从口头规范变成了编译期的硬约束——业务侧想拼 key 就必须经过这个方法,参数再花哨也会被包进前缀之内。出了那次的报表事故后,整改动作就是全面替换裸 Redis 调用,一共改了两百多处,都是机械替换加功能验证,前后用了一个周末。

会话存储是另一个重灾区。多租户系统里会话往往共享一个 Redis 库,如果会话 key 只按用户 ID 拼,理论上不会串(ID 全局唯一),但隐患在别处:一个用户属于多个租户切换登录时,旧的租户上下文可能残留。我们会话 key 里同时存用户 ID 与租户 ID,校验时两者必须都匹配,切换租户强制重新换发会话(见 3.1 权限体系的令牌设计)。

会话超时策略也做了分级:普通员工两小时无操作失效,管理员一小时,超管十五分钟。分级不是为了为难谁,而是按风险敞口分配安全预算——管理员的会话被劫持造成的损失远大于普通员工,这个梯度后来被安全审计的公司写进了他们的评估报告,算是少有的一次被外部表扬的细节。

再补充一个「热转冷」的处理。租户注销或长期不活跃时,其缓存不必立即清空,而是先转冷:前缀统计里单独标记,物理清理放到凌晨低峰批量执行。这样做的原因是注销动作往往伴随数据导出请求,客户要最后核对一次数据,此时缓存还在的话页面响应是热的,体感更好。事办完了,再连缓存带数据一起归档,流程上有先后,体验上不反目,客户的最后一段使用记忆是顺畅的。

演进:三代缓存隔离的对照

我们走完的缓存隔离三代,与文件隔离的节奏几乎一致:

代际 做法 事故或痛点
第一代 裸 key 加业务名 报表串号事故
第二代 统一前缀中间件 旧代码整改窗口期
第三代 CI 强制检查加回源对账 人工 review 疲劳

第三代最大的变化是把防线前移到 CI:每次提交自动扫描 Redis 调用,键不以租户前缀开头的直接不让合并。这种把规范变成工具的做法,比贴在墙上的制度好使一百倍,开发不用记规范,规范自己就在流水线里。这套思路后来也复用到了文件前缀与日志字段的检查上,成了平台的通用治理手段,熟悉了这套流水线的开发去了别的项目也照着搭,算是一次方法论的外溢。

量化:缓存串号的检测线

缓存层静默,所以检测手段必须自动化,我们设了三条告警线:

告警线 采集方式 阈值
命中内容与源库不一致 定时抽样回源比对 千分之五
键前缀合规率 CI 静态扫描 百分之百
单租户内存占比 按前缀统计内存 百分之四十

第一条线值得展开:每天抽一百个热点 key,回源库取真实值比对,超过千分之五的不一致就告警。这条线在三个月内响过两次,一次是过期时间设置不当导致旧数据被读,一次是业务写了缓存没写库。如果没有这条比对线,两次都会演变成客户工单。它的实现成本很低——一个定时任务加一段比对脚本,我们在一个下午就写完了,属于投入产出比最高的监控之一,后来全公司四个产品线都推广了这个模式。后来我们给它起了个内部名字叫「说实话检查」,因为它只回答一个问题:缓存此刻说的,是不是真的。

第三条线也帮过我们:某租户的报表缓存设置成不过期,一个月后占了全部缓存内存的一半。设了单租户占比上限后,超限的租户会触发强制过期与告警,没有再出现一家独吃的情况。

踩坑:缓存里的四个雷

第一个是键前缀只加了一半。报表事故之后我们加了规范,但早期有些遗留代码的清理是分批做的,清理旧版本时漏了一处,结果同一个系统里新旧两种写法并存了两周,期间新代码与前缀、旧代码裸 key,等于规范上了一半,事故概率反而更高。教训是整改要一次到位,分批整改的窗口期本身就是新的风险期,要么别改,要改就一个周末连轴改完。

第二个是缓存过期时间一刀切。所有缓存统一设一小时,结果某户的报表在月底需要实时性,业务方以为缓存是实时的,对着旧数据做了周会汇报后来才发现。之后过期时间变成可配置项,敏感数据默认五分钟,配置中心可以按租户调整(见 2.3 配置中心)。

第三个是缓存击穿与串号的叠加风险。热点 key 失效瞬间大量请求穿透回库,多个租户的请求同时重建同一份缓存时,写回的内容可能来自错的租户。我们的防护是重建用 singleflight 加互斥锁:同一 key 只允许一个请求回源其他请求等待,且锁与回源都在租户上下文里执行。

第四个是批量删除时的前缀误伤。清理某租户缓存用 KEYS t:{id}:* 扫描,ID 传错了一位就把隔壁租户清空了——幸好只是缓存,回源重建即可,但那十分钟里隔壁客户的所有页面都在打数据库。之后批量删除命令统一走工具且必须二次确认,线上禁止裸 KEYS,改用 SCAN 迭代,慢一点但不会把 Redis 打卡顿。

小结

缓存隔离的要点不在 Redis 有多强大的功能,而在三条纪律:键前缀必须强制、写入必须回源对账、删除必须二次确认。它也是整本书方法论的一个缩影:多租户问题的难处不在单点技术,而在于让规则在每一个调用点都生效,一处分心就是一次事故。到这一节为止,第三篇功能共用的四条线(权限、模板、文件、缓存)讲完了,地基之上的业务已经可以在所有租户身上跑——接下来的第四篇,讲这套系统怎么升级、怎么计量、怎么守边界。再分享一个排查缓存串号的固定套路,后来成了团队的标准动作:任何数据疑似串号,先问三个问题——缓存键拼了没有、键过期了没有、回源时带租户号了没有。三问能命中九成案例,剩下的一成去查第三方组件(比如共享的会话处理器),那是键前缀约束不到的地方。这套问诊清单贴在每个人显示器边上,比任何事后文档都好用。还有一个架构层面的提醒:缓存隔离做到位之后,团队会自然而然地把更多东西放进缓存省性能,缓存逐渐从加速器变成了相依存储。我们的对策是给每个缓存键标注回源成本标签,回源贵的键监控命中率,跌破阈值就告警——缓存不只是越快越好,它的存在必须能被证明确实必要。这三条纪律加一个问诊清单,就是多租户缓存隔离的全部家当,简单但必须钉死在每个调用点上。

Redis 的实操与原理,在 腾讯云实践 里有对应章节,键设计与容量规划都能直接参考。

延伸阅读