3.3 文件存储:每户一个 COS 前缀

场景:文件是数据之外的第二条河

数据隔离做完,我们以为太平了,直到有一天客户 A 的客服截图过来:他们的订单导出里夹着几张陌生人上传的图片。查了一圈,问题出在文件列表接口:分页查询时按上传时间倒序,忘了带租户号过滤,数据库里的文件元数据表是共享的,于是全平台的最新文件都轮流露了个脸。数据本身没串,但文件元数据串了。

这件事给我们上了一课:多租户系统里有两条数据河,一条是业务记录,一条是文件与附件。业务记录大家天然警惕,建表就会想到租户号;文件则经常被当成纯存储问题,丢给 COS 就不管了。事实上文件的隔离比记录更难——它横跨上传、存储、访问、下载、导出五个环节,任何一个环节漏掉租户约束都会串。 这条认知后来被我们写进新人培训的第一课:不要只看数据流向,还要看文件流向,后者往往不在代码 review 的视线范围内。

我们的文件量级也值得说一下:一百户左右的租户,日均上传三万件,大头是订单附件与商品图,存储总量十几个 TB。这个量级用 COS 正合适,成本也低,真正的挑战从来不在存储本身,而在「谁能拿到谁的链接」这五个字上。

文件隔离方案我们对比过三种,最后选了前缀方案:

方案 隔离强度 改造成本 适用阶段
共享存储加元数据表 低 低 早期 Demo
每户一个 COS 前缀 中 中 我们的选择
每户一个 COS 存储桶 高 高 金融类客户

选前缀方案的理由:存储桶数量有配额上限、跨桶的批量操作成本高、而且桶是按名字全局唯一的,租户自定义域名时还要做额外映射。前缀方案一个桶装天下,隔离靠 key 规则与签名权限共同保证,缺点是物理上不分家,必须靠规则守住——但它换来的运维简单,对小团队是真金白银的好处。后来金融类客户要求物理隔离时,我们的做法是给他单独开专属桶,等于在架构不变的前提下叠加了一套部署,这就是分层隔离的弹性所在。

配置:上传走签名,下载也走签名

文件的所有操作都经过一个 FileService,租户号在入口就织进 key:

class FileService
{
    // key 永远由租户号开头,业务侧无法绕过
    public function upload(string $biz, UploadedFile $file): string
    {
        $ctx = app(TenantContext::class)->id();
        $key = sprintf('tenants/%d/%s/%s.%s',
            $ctx, $biz, uniqid('', true), $file->getClientOriginalExtension());

        // 存储与访问都带租户校验
        return $this->cos->putObject($key, $file->get());
    }

    // 下载链接签名时校验当前用户所属租户
    public function url(string $key, int $ttl = 900): string
    {
        $owner = $this->metaOf($key)->tenant_id;
        if ($owner !== app(TenantContext::class)->id()) {
            throw new ForbiddenException('文件不属于当前租户');
        }
        return $this->cos->presignedUrl($key, $ttl);
    }
}

这段代码里有三处约束值得记住。第一,key 的拼装只发生在 FileService 内部,业务代码拿不到拼 key 的自由,想自己拼也拼不出——这是「把规则收敛进唯一入口」的典型做法。第二,下载链接必须签名且短时效,默认十五分钟,且签名前校验文件归属。第三,元数据表(files)里冗余存了 tenant_id,所有查询走这条索引,不能依赖从 key 里拆。

存储桶层面的权限也做了收口:COS 的存储桶默认私有,应用服务器通过永久密钥签发临时 URL,前端永远拿不到直连。灾备场景下另开了一个备份桶,权限策略里明确写死只允许从主桶复制,杜绝双向误操作。

再补一个关于 CDN 的细节。静态资源(平台自己的 JS、CSS、图标)走 CDN 是合理的,但租户上传的业务文件一开始也挂了 CDN,结果某户把一批内部合同传了上去,缓存被边缘节点留了七天,删除请求到了源站却下不来。之后业务文件一律不走 CDN 缓存,只走源站签名链接;确实需要加速的公开素材(比如营销图)由后台打标走独立域名。这次的教训直接写进了我们团队的上岗培训文档:缓存不是加速度,是延迟删除器。

量化:文件隔离的日常巡检

文件串号是静默事故,我们靠一套日巡检兜底,运行半年效果显著:

巡检项 方式 发现过的问题
key 前缀合规率 扫描最近写入 两处老旧任务漏带前缀
越权下载拦截量 签名接口日志 第三方导出工具用旧链接
元数据与存储一致性 抽样比对 开通脚本的残留孤儿文件
存储增长异常 按租户日环比 某户脚本失控上传垃圾

第四项讲一个具体案例:某租户的存储量一天涨了平时三十倍,巡检报警,定位发现是他们自己写的同步脚本把同一批图反复上传,因为我们的去重 key 用的是业务 ID 而非内容哈希。这事之后我们去重策略改成了内容哈希加业务 ID 双 key,存储量当天回落。租户的坑最后总要平台来填,这就是 SaaS 的宿命,也是它有价值的地方——填坑的经验沉淀成平台能力,下一户就不会再踩同一个坑。

演进:从能传到传得稳

我们的文件体系也迭代过三代,每一代的痛点都不同:

代际 形态 主要痛点
第一代 共享前缀加元数据 元数据漏过滤导致串号
第二代 租户前缀加签名 大文件上传超时
第三代 分片上传加回调 回调失败的孤儿分片

第三代引入分片上传后,独立的问题冒出来:分片上传到一半用户关页面,已经传上去的分片留在桶里成了孤儿。我们的办法是给每个上传会话一个上传 ID,后台任务每天清理超过二十四小时未完成的分片集,并在清理前给租户管理员发通知。COS 的分片管理 API 本身支持按上传 ID 列出与删除,这段逻辑写起来不复杂,复杂的是定下「谁来擦屁股」这个责任——开发和运维都不愿意揽,最后落在了平台侧。

踩坑:文件路上的四个雷

第一个是列表接口漏过滤。就是开头那个故事,修复之后我们把所有文件相关查询统一收进 FileService,控制器不允许直接 new 查询构造器查 files 表,从根上断掉漏加条件的可能。

第二个是直传配置泄露。早期为了性能,把临时密钥的有效期设成了两小时,结果某客户的爬虫脚本拿着过期前的密钥把手伸进了别人的前缀——还好只是读到了公开图。之后临时密钥一律十分钟、且权限范围锁死到本户前缀,连列出其他前缀的能力都不给。

第三个是导出文件的清理。账单与订单导出会生成大文件,下载几次后就该删除,否则既占成本又留泄露面。我们设了二十四小时自动清理,清理动作会写进操作日志,偶尔会有客户来问怎么昨天的导出链接失效了,客服话术统一解释为安全策略——不道歉,因为这就是对的策略。

第四个是跨租户的文件转发。协作场景里 A 户想把文件转给 B 户,直接给链接是不行的(签名带了租户校验)。我们的做法是生成转存副本:B 户接收后,文件被复制到 B 户前缀下并写入 B 户元数据,从此与 A 户脱钩。既满足了协作,又保住了隔离边界,这扇门的开法与 3.1 的协作令牌如出一辙。

小结

文件是多租户的第二条数据河,隔离不能只靠存储层,要靠 key 规则、签名校验、元数据三重约束一起上。记住一条朴素的经验:凡是横跨多个环节的资源(文件、缓存、消息、日志),都要问一句「每个环节都带租户号了吗」,少一个环节就多一种串号姿势。本节与 3.4 缓存隔离是同一模板的两道题,建议连读;文件上传的接口设计可以参考 PHP API 实战,文件下载的审计配合见 4.3 安全边界一节。再补两个文件治理的实战细节。第一个是成本治理:某天账单里存储费涨了三倍,排查发现是三个大户把存储当备份盘用,上传了整个公司的历史档案。之后我们给每户加了存储配额(与套餐档位绑定),超额触发告警并同步客户成功经理。文件存储在多租户里不光是隔离问题,还是预算问题——客户不会主动帮你省自己的垃圾。第二个是前缀纪律:所有前缀必须由代码生成(租户号加业务目录),严禁人工在控制台开目录。曾有同事为了图方便手动建了个临时前缀传文件,结果没被任何清理脚本覆盖,文件躺了一年。前缀体系是自动化的前提,人工开的口子就是自动化的盲区。两件事合起来看:配额管住增长,纪律管住秩序,文件这条河才不会漫出河床。

延伸阅读