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 安全边界一节。再补两个文件治理的实战细节。第一个是成本治理:某天账单里存储费涨了三倍,排查发现是三个大户把存储当备份盘用,上传了整个公司的历史档案。之后我们给每户加了存储配额(与套餐档位绑定),超额触发告警并同步客户成功经理。文件存储在多租户里不光是隔离问题,还是预算问题——客户不会主动帮你省自己的垃圾。第二个是前缀纪律:所有前缀必须由代码生成(租户号加业务目录),严禁人工在控制台开目录。曾有同事为了图方便手动建了个临时前缀传文件,结果没被任何清理脚本覆盖,文件躺了一年。前缀体系是自动化的前提,人工开的口子就是自动化的盲区。两件事合起来看:配额管住增长,纪律管住秩序,文件这条河才不会漫出河床。
