本文是“Phorge 现代化改造实战”系列第九篇。上一篇检查了搜索服务中必须成对出现的写入、查询、配置和迁移;这一篇转向文件存储,重点处理二进制传输、存量 handle、后端选择、写入下沉和首次启动依赖。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递

写在前面

上一篇迁移搜索服务时,我遇到的核心问题是:一份文档能写进索引,不代表查询真的会走到那份数据。文件存储也有一组很像的错觉:服务能启动,不代表它有地方存文件;引擎出现在列表里,不代表新文件会交给它;S3 配置完整,也不代表对象最终会落进 S3。

Gorge 2026.09.07-r3 把原来的 gorge-file-storage 迁进单仓,增加独立的 gorge-file-storage 二进制、三个存储后端、四条 HTTP 路由、14 份契约固件和一份端到端测试,具体变化可以从 Gorge r2…r3 的代码差异 中查看;Phorge 2026.09.07-r3 则增加 Gorge 存储引擎、HTTP 客户端和配置检查,并补齐容器编排与启用文档,对应的宿主改动集中在 Phorge r2…r3 的代码差异 中。

生产代码并不算多:一千多行 Go,加三个 PHP 类。真正花时间的地方,是重新划清 Phorge 与 Gorge 各自负责什么,以及把那些“配置合法、请求成功、结果却不是你以为的那样”的状态找出来。

其中最反直觉的一处,是没有把新引擎声明成“无文件大小限制”。看起来少了一个能力,实际却保住了 Phorge 自己的大文件分块与断点续传。

先划清边界:Phorge 管文件,Gorge 只管字节

Phorge 的文件记录不只有内容。文件名、MIME 类型、大小、所属对象、权限、去重信息,以及存储引擎和 handle,都在 Phorge 自己的数据库里。

gorge-file-storage 不认识这些业务概念。它只处理三件事:

  1. 收到一段字节,把它交给某个后端,返回后端名和 handle;
  2. 根据 (engine, handle) 读回字节;
  3. 根据同一组信息删除字节。

它不负责文件元数据,不判断访问权限,也不做引用计数、垃圾回收和分块。尤其是分块:大文件到达 Gorge 之前,已经被 PhabricatorChunkedFileStorageEngine 切成若干块,每一块再作为一份普通文件写入。

这个边界决定了服务可以独立演进,但也带来一个硬约束:Gorge 不能根据 handle 猜后端。 不同后端的 handle 可能重叠,猜错最坏的结果不是报错,而是读到另一个对象。因此每次读取和删除都必须同时给出 enginehandle

file-storage 也因此独占一个进程和 :8100 端口。它要挂持久卷、连接 MySQL 或访问 S3,是 Gorge 单仓里第一个真正持有持久化资源、也是第一个打开数据库连接池的服务。把它并进纯计算的 gorge-render,只会让磁盘、数据库或对象存储的故障一起影响代码高亮和 diff。

文件字节不再塞进 JSON

独立服务原来的接口把文件做 base64,再放进 JSON:

{
  "data": "AAECAwQFBgcICQ=="
}

这种做法对小配置很方便,对文件却很浪费。一个 N 字节的文件经过 base64 后大约变成 1.33N;PHP 要先把完整文件编码成字符串,Go 再把完整字符串解码回来,读取时反方向再做一遍。网络体积、内存峰值和复制次数都被放大。

r3 改成直接传输 application/octet-stream

方法 路径 成功响应
POST /api/file/blob JSON 信封,返回 handleenginesize
GET /api/file/blob 原始文件字节
DELETE /api/file/blob JSON 信封,返回删除结果
GET /api/file/engines JSON 信封,返回后端列表

元数据通过 query string 传递。handle 不放在路径段里,因为本地磁盘的 handle 类似 ab/cd/{28 hex},S3 key 类似 phabricator/ab/cd/{16 hex},两者都带斜杠。做成路径段后,每个调用方都要正确转义,漏掉一次就会得到一个很难解释的 404。

上传还要求请求必须带 Content-Length。这不是为了形式完整,而是两个实现条件:

  • S3 流式上传需要预先知道长度,否则 SDK 只能先缓冲请求体;
  • 各后端必须在读取第一个字节之前判断文件是否超过自己的上限。

真实调用方并不受影响。Phorge 的 HTTPSFuture 和 curl 在发送内存或磁盘中的确定内容时都会带上这个头。与其在服务端悄悄缓冲未知长度的请求,不如明确返回 400。

成功是二进制,失败仍然是 JSON

原始字节带来了单仓平台层的第一个例外:GET /api/file/blob 成功时不再返回统一的 {data, error} 信封,而是直接流出文件内容;失败时仍然返回 JSON 信封。

200              application/octet-stream
非 200           application/json,{data, error}

PHP 客户端必须按状态码分支,不能检查 body 是否为空。原因很简单:零字节文件是合法文件,它的正确响应就是 200 加空 body。按内容判断会把正常的空文件误认为读取失败,而且这种问题通常只影响少数附件,很难复现。

长度已知时,服务会把 Content-Length 原样带回去;未知时则不猜,直接让响应走 chunked。猜错的长度会让客户端无法区分完整文件和被截断的文件,比不提供更危险。

平台层不需要为这个例外改造。handler 成功时直接 c.Stream(),出错仍交给全局错误处理器生成信封。于是规则可以保持得很窄:只有这一条读取接口的成功响应是二进制,其余 API 继续使用统一信封。

最反直觉的决定:保留 8MB 上限

PhabricatorFileStorageEngine 基类默认限制单次写入不超过 8MB:

public function hasFilesizeLimit() {
  return true;
}

public function getFilesizeLimit() {
  return (1024 * 1024 * 8);
}

早期实现曾把 hasFilesizeLimit() 覆写成 false。直觉上很合理:后端可能是 S3,Go 侧又是流式传输,为什么还要限制 8MB?

问题是,这个上限并不只约束存储引擎。它还参与 Phorge 对分块引擎的选择。

PhabricatorChunkedFileStorageEngine 只在没有普通引擎能直接接下文件时介入。一个宣称“无上限”的 Gorge 引擎会接受任何大小,于是 Phorge 不再分块:2GB 文件会变成一次 2GB HTTP 请求,断点续传、请求体上限和两侧有界内存一起失效。

分块引擎寻找块存储后端时还有一条检查:

if ($engine->hasFilesizeLimit()) {
  if ($engine->getFilesizeLimit() < $this->getChunkSize()) {
    continue;
  }
}

默认块大小是 4MB,基类的 8MB 正好既能触发大文件分块,又能让 Gorge 有资格存放每一个 4MB 块:

文件 <= 8MB   -> Gorge 单次写入
文件 >  8MB   -> Phorge 切成 4MB 块 -> 每块单独写入 Gorge

因此 r3 什么都没有覆写。Go 服务的传输层 BodyLimit 设为 16M,给合法的 8MB 请求留出余量;真正的大文件仍然全部存进 Gorge,只是由 Phorge 负责切块。

这次最好的实现恰好是不写代码。前提是先看懂上游默认值为什么存在,而不是只看新后端理论上能接多大的文件。

迁进单仓,主要工作是删掉重复外壳

独立仓自己维护健康路由、token 比较、响应结构和 Echo 启动逻辑。单仓已经有平台层,因此迁移时主要是替换:

独立仓实现 单仓实现
healthPing() 与手写健康路由 health.Register,同时得到 /healthz/readyz
tokenAuth() 字符串比较 auth.Token(),使用 constant-time 比较
apiResponserespondOK()respondErr() httpx.OK()httpx.Fail()
Echo Logger slog、Request ID 与统一访问日志
e.Start() srv.Run() 与 SIGTERM 优雅关闭

独立仓原来把 configenginehttpapi 分成多个包。迁入后拍平成 internal/filestorage 一个域包,再按文件区分三个 adapter。它和已经迁入的 mailer 本质相同:平台层管进程和 HTTP 外壳,域包管理自己的后端。

数据库连接池暂时也留在 internal/filestorage/db.go,没有因为单仓第一次引入数据库驱动就提前造一套通用 DB 平台。当前只有这个域连接数据库,上提只会把 Phorge 的库名规则与表结构泄漏进平台层。如果以后第二个域也需要连接池,再判断哪些能力真正通用。

这次还删掉了一个读取后从未使用的 STORAGE_ROOT 配置项,并把 WriteParams.MimeType 真正接到 S3 PutObjectContentType 上。

不过这里有一层容易误读的区别:Go API 已经支持 mimeType,Phorge 的 Gorge 引擎却刻意不发送它。传进存储引擎的字节已经经过 storage format;启用加密后,它并不是原 MIME 类型对应的内容。把原文件的 image/png 写到一段密文字节上,比不写 Content-Type 更错误。这个字段只对明确知道自己传入的是原始内容的其他客户端有效。

三个后端不是可以随便改名的内部实现

服务保留了独立版本的三个后端:

后端 identifier 优先级 默认限制 handle
MySQL blob blob 1 1,000,000 字节 自增行 ID
本地磁盘 local-disk 5 ab/cd/{28 hex}
S3 amazon-s3 100 phabricator[/{instance}]/ab/cd/{16 hex}

优先级从小到大。默认同时启用 blob 与本地磁盘时,小文件先进入数据库,超过 blob 上限的文件进入磁盘。

这里还有一个常被配置界面掩盖的事实:配好 S3 不代表新文件会进入 S3。 只要本地磁盘仍然启用且写入成功,优先级 5 的本地盘会先接下所有 blob 不收的文件,优先级 100 的 S3 根本没有机会。想让大文件进入 S3,需要关掉本地磁盘;想让所有文件都进入 S3,还要同时关掉 blob。

三个 identifier 与三种 handle 形态也不是显示名称,而是存量数据契约。Phorge 会把 Gorge 返回的两个字段拼成一个复合 handle:

blob/12345
local-disk/ab/cd/0123456789abcdef0123456789ab
amazon-s3/phabricator/ab/cd/0123456789abcdef

读取时必须按第一个斜杠拆分。按最后一个斜杠切,会把 local-disk/ab/cd 当成引擎名;直接 explode('/') 取两段,又会把真正的 handle 截成 ab

更麻烦的是,这两种错误都可能通过“新写一个文件再读回来”的测试,因为写与读共享同一套错误规则。真正坏掉的是数据库里按照旧格式保存的所有文件。

blob 也不能顺手改名成看起来更明确的 mysql,S3 key 前缀也不能从历史上的 phabricator 改成 gorge。这些字符串与 Phorge 原生引擎保持一致,使已有目录、表和对象布局能够继续读取;改名之后,新文件仍然自洽,存量文件却会在同一时刻全部不可达。

本地磁盘 handle 的格式校验还兼有安全作用。请求参数来自网络,直接执行 filepath.Join(root, handle) 会接受 ../../..。限制两级十六进制目录和固定长度文件名,是防止读取或删除存储根目录之外文件的边界,不能为了“兼容更多 handle”随意放宽。

写失败可以下沉,但前提是字节还能重放

迁入前的 Router 按优先级找到第一个能接下文件的引擎,写失败就直接返回错误。r3 改为继续尝试下一个后端。

这不是另造一套语义。Phorge 自己的 buildFromFileData() 本来就会在首选引擎抛异常后尝试次选引擎。对应到这次迁移,还有一个真实启动窗口:file_storageblob 表由 Phorge 的 bin/storage upgrade 创建,在它完成之前,blob 后端能连上数据库却无法写表。此时上传应该下沉到本地磁盘,而不是让整个文件上传失败。

但 HTTP 请求体只有一份。第一个引擎读过以后,第二个引擎怎样从头再读?

r3 增加了一个有界的 rewindReader。它只记录允许回放的那一段字节,预算不是新配置项,而是从所有带大小上限的后端中推导出最大值。默认情况下就是 blob 上限:

func (r *Router) rewindBudget() int64 {
  var budget int64
  for _, eng := range r.engines {
    if eng.HasSizeLimit() && eng.MaxFileSize() > budget {
      budget = eng.MaxFileSize()
    }
  }
  return budget
}

blob 本来就会把不超过自身上限的内容读进内存,因此保留这部分字节没有额外改变它的内存级别。若失败的后端消耗超过预算,Router 会停止下沉;继续把剩余内容交给下一个后端,只会写出一个截断文件,并把数据损坏伪装成成功。

这使“失败继续尝试”有了明确边界:

  • blob 在限额内读完后失败,可以完整回放给本地磁盘;
  • 零字节文件同样可以回放;
  • 本地盘或 S3 流式写到一半失败,通常无法再换后端;
  • 没有带大小限制的后端时,预算为 0,Router 退化成一次尝试。

如果请求显式指定 ?engine=...,则无论失败原因是什么都不下沉。Phorge 只会在“这个文件原本就记录在某个引擎,现在要重写它”时指定后端。把重写内容悄悄放到另一个引擎,会让数据库里的旧记录指向不存在的数据。

错误码也区分了两个状态:503 ERR_NO_ENGINE 表示一次都没有尝试——没有配置后端,或者文件对所有已配置后端都太大;500 ERR_INTERNAL 表示确实尝试过,但所有候选都失败。前者去检查配置,后者去看服务日志。

删除幂等,读失败则统一收成 404

删除不存在的对象在 r3 中返回成功。这个行为相对独立服务发生了变化,但更符合 Phorge 的清理流程:Phorge 要先删除字节,再删除指向字节的记录。如果字节已经不在了却返回错误,那条元数据记录会永远留在数据库里,后续每次清理都继续失败。

因此第二次删除同一个 handle 仍然得到:

{
  "data": {
    "status": "deleted"
  },
  "error": null
}

格式非法的 handle 不属于“已经删除”,DELETE 会返回 400;后端真正没有完成删除则返回 500,提醒 Phorge 不要退掉元数据记录。

读取采用了另一套取舍:handle 非法、对象不存在、后端读取失败,目前都统一映射为 404 ERR_NOT_FOUND,详细原因只写服务日志。对 PHP 调用方来说,它们都没有第二个后端可以尝试;但对运维来说,这会降低从 HTTP 状态码区分数据缺失和后端故障的能力。排查批量附件 404 时,不能只看 Phorge 页面,必须同时检查 Gorge 日志与后端状态。

/readyz 不能检查表是否存在

file-storage 的就绪条件看起来应该很严格:至少配置一个后端;如果启用 blob,数据库能连接;最好再确认 file_storageblob 表确实存在。

最后这一项恰好会让全新部署死锁。

Phorge app
  等待 gorge-file-storage healthy
          |
          v
gorge-file-storage /readyz
  等待 file_storageblob 表存在
          |
          v
Phorge bin/storage upgrade
  只有 Phorge app 启动后才会执行

表由 Phorge 的 storage upgrade 创建,而 Phorge 容器又通过 depends_on: service_healthy 等待文件服务。两边互相等待,谁也无法完成第一次启动。

因此 /readyz 只检查两件事:至少注册了一个引擎,以及持有连接的引擎能否连上。当前只有 MySQL blob 实现额外检查,执行一次带 5 秒超时的 PingContext。它不查询表。

数据库连接也没有在 OpenDB() 阶段主动 ping。sql.Open() 保持惰性,让服务先启动并回答 /healthz,再由 /readyz 表达数据库尚未可用。否则一个启动稍慢的数据库会把服务推入反复重启。

这一版 Phorge 编排让 file-storage 的容器探测 /readyz,而 mailer 和 search 只探 /healthz。差别是有意的:编排默认已经给 file-storage 挂载本地卷,它开箱就有可写后端;文件又是头像、附件和粘贴图片的同步依赖,完全不可写时让站点等待,比让用户上传后再发现更合适。

代价也很明确:如果主动关掉三个后端,Phorge 会一直等在启动依赖上。这时应当一起移除 file-storage service 和对应依赖,或者临时把探针改回 /healthz,不能只清空后端配置。

其他

配置前缀不是为了好看

单仓新配置统一使用 GORGE_FILE_*

GORGE_FILE_MYSQL_HOST
GORGE_FILE_MYSQL_PORT
GORGE_FILE_MYSQL_USER
GORGE_FILE_MYSQL_PASS
GORGE_FILE_NAMESPACE
GORGE_FILE_MYSQL_BLOB_MAX_SIZE
GORGE_FILE_LOCAL_DISK_PATH
GORGE_FILE_S3_BUCKET
GORGE_FILE_S3_REGION
GORGE_FILE_S3_ENDPOINT
GORGE_FILE_S3_ACCESS_KEY
GORGE_FILE_S3_SECRET_KEY

无前缀的 MYSQL_HOSTS3_BUCKETSTORAGE_NAMESPACE 仍作为旧部署兜底,但新编排不再使用。原因不是命名整齐,而是同一份 .env 同时服务多个容器:MYSQL_HOST 在 Phorge 容器和文件服务里可能表达不同含义,继续复用会让同名变量在两个进程中指向不同拓扑。

GORGE_FILE_NAMESPACE 尤其不能依赖服务默认值。Gorge 代码默认是 phorge,而当前 Phorge 编排使用 phabricator,最终表名是:

phabricator_file.file_storageblob

填错 namespace 的表现通常不是上传失败。blob 写入报错后,Router 会把文件下沉到本地磁盘,用户仍然看到上传成功,只有查看文件记录的实际后端才会发现数据库从未接到数据。

S3 则要求 bucket、region、endpoint、access key、secret key 五项全部非空才注册。半套配置若也生成 client,只会增加一个每次请求都失败的候选后端。一个后端都没有配置仍允许进程启动,由 /readyz 报 503,把“服务程序坏了”和“配置尚未完成”分开。

核对 r3 时还有一个值得修正文档措辞的小差异:服务代码的 blob 默认上限是 1,000,000 字节,Phorge 原生配置默认也是 1,000,000;但 docker-compose.gorge.yml 显式传入的是 1,048,576 字节,也就是 1MiB。两者相差 48,576 字节,不影响 4MB 分块逻辑,却不应该再描述成“逐字节一致”。如果希望两个边界完全相同,应在后续版本统一这个值。

启用需要两步,第一步单独做几乎没有效果

Phorge 通过 PhutilClassMapQuery 自动发现文件存储引擎。只要 gorge.file.uri 有值,PhabricatorGorgeFileStorageEngine 就进入可写引擎列表,priority 是 2。

但是 Phorge 原生 MySQL blob 引擎的 priority 是 1,会先拿到所有自己能接下的小文件。只配置 Gorge 地址时,默认结果是:

文件大小 实际处理者
<= 1,000,000 字节 Phorge 原生 blob 引擎
1,000,000 字节到 8MB Gorge 引擎
> 8MB Phorge 先切成 4MB 块,每块重新参与上面的选择

服务已经运行,配置页也能看到 Gorge,但文件会按大小散落在两套入口中。这不是错误,所以没有异常可看。

真正切换需要第二步:关闭 Phorge 原生的三个后端,让 Gorge 成为优先级最高的可写引擎。

docker compose exec phorge \
  /opt/phorge/phorge/bin/config set storage.mysql-engine.max-size 0

docker compose exec phorge \
  /opt/phorge/phorge/bin/config set storage.local-disk.path null

docker compose exec phorge \
  /opt/phorge/phorge/bin/config set storage.s3.bucket null

启动脚本刻意不自动执行这一步。它会改变新文件实际落在哪里,应该由操作者显式决定。这样可以先部署服务但不接流量,也可以用一条配置命令回滚。

PhabricatorGorgeFileStorageSetupCheck 会把三个状态分开报告:服务不可达、服务可达但没有后端、服务已经就绪但前面仍有更高优先级的原生引擎。最后一个检查专门把“配了但没真正接上”从沉默状态变成 Config 页面上的明确提示。

切换不需要搬迁存量数据。Phorge 按文件保存 storageEnginestorageHandle,读取时仍然使用当初写入它的引擎。回滚也只影响之后的新写入,已经进入 Gorge 的文件继续由 Gorge 读取。

因此“无需迁移”不等于“服务可以随时删除”。只要有一个文件已经记录为 gorge,移除服务或删除本地卷就会让它不可达。尤其要避免 docker compose down -vphorge-files 卷保存的是文件本体,Phorge 数据库里只有 handle。

两边独立实现之后,测试要守什么

Go 与 PHP 分别实现时,编译器无法检查两边是否说的是同一种协议。r3 合流后需要逐项核对:

  • 四条路由和 HTTP 方法;
  • enginehandlenamemimeType 四个 query 参数名;
  • X-Service-Token 请求头;
  • 成功二进制与失败 JSON 的分支;
  • bloblocal-diskamazon-s3 三个 identifier;
  • 复合 handle 的拼接与拆分;
  • S3 key 前缀和本地磁盘目录布局;
  • 8MB 单次上限、4MB 分块与 16M 传输上限;
  • /healthz/readyz 的不同语义;
  • Compose 中的端口、namespace 与配置前缀。

Go 侧的 14 份契约固件覆盖路由、鉴权、缺失参数、空文件、指定后端、未知后端、读取、删除和引擎列表;端到端脚本则对一个真实运行实例完成写入、读取、删除、重复删除与零字节文件回环。

测试里还有一条很实用的防线:所有故障路径都会检查日志中是否出现 PANIC_RECOVERED。原因是 Recover 中间件会把 panic 转成 500,单看状态码,测试可能以为 handler 正常返回了预期错误。曾经有一次 nil engine 进入读取路径,所有 HTTP 断言都是绿色,真正的证据只有没人看的堆栈。让测试在日志出现 panic 标记时失败,才把“返回了 500”和“代码崩了以后被包装成 500”区分开。

仍然有两条约定主要依赖 PHP 与兼容文档:复合 handle 必须按第一个斜杠切,以及 /api/file/engines 返回的字段目前没有 PHP 消费者。后一个端点现在只用于诊断;如果以后 setup check 用它判断 Gorge 内部究竟会选 blob、本地盘还是 S3,就必须先给响应字段补跨语言断言。

最后

文件存储的麻烦从来不在“写进去”这一刻,而在几个月后还能不能按原来的 engine 和 handle 找回来。对这类迁移来说,真正需要保护的是存量数据的可达性,而不是刚写完的那份 smoke 测试。

–EOF