1.3 架构草图:后端只做协调层

场景:后端被文件流打通的那天

最早的一版架构特别朴素:客户端把文件 POST 给后端,后端存本地,再转存到 COS。我想着中间多一层,好做权限和审计,看起来也更像正规系统。上线第一天就出事了——三个人同时传 2GB 的视频,后端内存吃满,Goroutine 全卡在 IO 上,接口超时,连首页都打不开。那台机器配置不低,但内存再高也架不住三份 2GB 的字节流同时在缓冲区里打转,交换分区刚撑了几分钟就被写满。

那天我明白了一件事:文件流是世界上最不能拦的东西。拦在中间的服务器,要么加带宽加内存,要么被撑爆,没有第三条路。既然文件最终要落在 COS,为什么不让客户端直接传过去?省下的不只是服务器,还有一整条传输链路的时延——直传少了一跳,用户那边快了一倍,服务器也不用在内存里给每个文件存两份。这个决定改写的不只是上传,后面所有功能都是围绕它展开的。

多说一句为什么后端不能只做转发而要做协调。如果后端连权限和记账都不管,那它可以直接被 CDN 边缘脚本取代,甚至不需要存在。协调层之所以还有价值,是因为三件事离不开强一致的中心:谁能访问、谁的配额、谁的账。这三个问题的答案存在数据库里,由后端在签发凭证的那一刻写进密钥的权限范围里,云产品严格执行。后端管的不是字节,是字节背后的规则。理解这一点,就不会把协调层写成转发层,也不会稍有需求就往里面塞业务逻辑。

方案:后端退化为协调层

新架构里,后端只做三件事:发密钥、记账、转发地址。一次完整的上传与下载,各角色的动作我用时序串一遍,看不懂这张表就不算理解这个系统:

步骤 客户端 后端 COS
1 换密钥 请求上传凭证 校验权限,签发临时密钥 不参与
2 直传 分片直接发往 COS 不参与 收下分片
3 完成 通知后端 记录元数据与指纹 返回对象信息
4 下载 索要地址 校验权限,返回签名 URL 校验签名,输出文件

整条链路里,文件字节从不经过后端。后端像中介,只见过合同,没见过货。各层职责对应到代码模块上,还要立几条规矩防止有人手滑把数据流引进来:

层 职责 不许做的事
handler 解析参数、校验登录 不读文件内容
store 签发凭证、转发地址 不落地任何字节
客户端 分片、断点、加密 不直连数据库
云产品 存储与处理 不信赖前端传来的元数据

配置:把边界写进目录结构

Go 项目里我把这层边界写进了代码结构,接口定义和实现分开放,时刻提醒自己别把数据流引进来。handler 里绝对不允许出现文件句柄,这是 review 时的红线:

// internal/handler 只处理协议,不碰字节
func (h *Handler) UploadToken(c *gin.Context) {
    // 解析参数、校验登录,然后交给 Store 层
    up, err := h.store.SignedUpload(ctx, key, size)
    if err != nil {
        c.JSON(500, gin.H{"msg": "签发失败"})
        return
    }
    c.JSON(200, up)
}

// 同一个 Store 接口,CosStore 连云、LocalStore 自测用
// handler 完全不知道文件最后去了哪里
func (h *Handler) DownloadURL(c *gin.Context) {
    // 校验权限后转发签名地址,同样一个字节都不经过
    url, err := h.store.SignedDownload(ctx, key, 15*time.Minute)
    if err != nil {
        c.JSON(500, gin.H{"msg": "签发失败"})
        return
    }
    c.JSON(200, gin.H{"url": url})
}

代码之外还有一条不成文的规矩:任何新功能要进后端,先回答一个问题——这个功能能不能在客户端或云端做。答案经常是可以,于是后端继续瘦下去。这套分层上线后,后端部署在最低配的 1 核 2G 机器上,同时承载五十人传文件也毫无波澜,CPU 和带宽曲线像两条直线——因为流量根本不经过它。这层思路沿袭自 1.1 需求拆解 里定下的五条 API,接口少,职责自然就薄,两者互为因果。

踩坑:协调层不是不管事

第一个坑是跨域。客户端直连 COS 桶,浏览器有同源策略,需要在桶配置里打开 CORS,允许 PUT、GET 和必要头部透传。我第一次配置只加了 PUT,分片上传的初始化接口直接报错,排查了一下午才发现初始化用的是 POST。跨域配置要按实际用到的全部方法来写,不能想当然,最好打开配置后再用脚本批量验一遍所有接口。

第二个坑是权限粒度。临时密钥如果配成全量权限,客户端拿到的密钥能干坏事——理论上可以删掉桶里其他文件,因为 COS 只认签名不认人。必须按操作和路径收敛,让客户端只能写指定前缀下的对象,具体在 2.2 临时密钥一节细写。

第三个坑是时序错位。客户端传完分片,COS 已完成,但后端没收到完成通知(用户关掉了页面),出现 COS 有文件、数据库无记录的脏数据。后来我加了两道兜底:完成即回调,同时用云函数监听 COS 上传事件异步对账,双写校验,漏掉的记录会被补上,多余的对象会被清理。

第四个坑是凭证缓存。客户端为了省一次请求,把临时密钥缓存起来重复用,结果某次密钥过期后上传全部失败,报的是签名错误,客户端没有重试机制。临时密钥的有效期和客户端缓存策略必须对齐:密钥设十五分钟,客户端就提前五分钟换新,过期就不许再用,宁可多一次请求也不能用过期凭证硬传。

第五个坑是后端的可用性变成了单点。协调层虽然不流文件,但它一挂,整个系统就全瘫了——没有凭证就传不了也下不了。所以协调层自己也要做兜底:至少部署两个实例,换密钥接口做本地短缓存,后端短暂抖动时客户端靠缓存还能撑一会。协调层瘦到极致之后,可靠性的责任就全压在这薄薄一层上,它反而成了最不能掉链子的组件,部署和监控都要按核心服务对待。

回头看踩坑清单,会发现一个规律:协调层的坑几乎都是信任问题。跨域是浏览器对桶的信任,权限是桶对客户端的信任,时序是后端对 COS 的信任,凭证缓存是客户端对密钥的信任。每一条信任链都要明确边界、设置兜底,任何一段含糊,整个系统就会在某个深夜给你打电话。

再补一个常被问到的细节:协调层到底要不要参与限流?要,但只在签发凭证这一步限。每个用户每分钟最多换多少次密钥、每天最多上传多少字节,都由后端在发凭证前判断,超了就不发。流量本身不经过你,你限不了字节,但你能限发起权——拿着旧凭证也传不动,因为分片上传初始化同样要凭证,凭证过期即刹车。这就实现了不流量的限流,非常优雅,代价是限流粒度只能到分钟级,做不到请求级。如果你需要请求级防护,那只能在桶的访问控制上想办法,比如配额或者 SCF 侧的计量,那是另一个故事。

架构图我们画了三版才定稿,每一版都贴在墙上让路过的人吐槽。最有价值的一次吐槽来自前端同学:他指出原图里上传成功的回调经过四个节点,任何一个节点抖动都会让用户等待。这一刀砍掉了中间两个节点,链路瞬间清爽。

网关层后来还承担了统一鉴权与限流。把它们放在协调层入口而不是各业务接口里,是新同事入职两周就能独立交付功能的原因。架构的价值不在图好看,而在于它让普通人也能做正确的事。

再补一个画图的方法论:时序图必须画到异常分支,而不是只画理想路径。我们的架构图有一半篇幅是失败处理:上传失败怎么提示、回调丢了怎么办、签名过期怎么续。把异常路径画清楚,开发时才不会临场编,这是架构图最朴素的用处,我们至今保持这个习惯。

小结

协调层架构的核心就一句话:让流量走云,让逻辑走代码。后端不碰文件,就不会被文件压垮。这个决定是整个项目最稳的一块基石,后续的分片、秒传、预览、分享,都是在这层薄薄的地基上长出来的,没有一处需要改动地基本身。还有一个反直觉的经验:协调层瘦下来之后,开发新功能反而更快了。以前加功能要改接收文件、转发文件、清理临时文件三处,现在只需要改凭证签发逻辑一处,客户端和云端各自独立演进。后端代码行数从最初的两千多行降到不足八百行,PR review 从半天缩短到二十分钟。少即是多这句话,在一个真实运行的系统上是成立的。

最后单独强调一句:最开始那次服务器被打穿,反而是整个项目最好的事情。如果它发生在用户更多的时候,损失的不只是机器,还有信任。小规模时出丑,是系统给你最便宜的提醒。后来每次出故障我都按这个心态处理——先恢复,再复盘,最后才是追责甚至不追责,因为故障从来不是某一个人的错,是整个设计留了缝。

延伸阅读