本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月09日 统计字数: 11458字 阅读时间: 23分钟阅读 本文链接: https://soulteary.com/2026/09/09/phorge-modernization-part-16-database-introspection-in-gorge.html ----- # Phorge 现代化改造实战(十六):把数据库自省搬进 Gorge,跨仓契约不能靠手工同步 本文是“Phorge 现代化改造实战”系列第十六篇。上一篇把工作队列和消费者迁入 Gorge;这一篇继续处理数据库诊断,把节点健康、复制状态、结构差异和升级状态查询交给独立的 Go 服务,并用跨仓契约避免 PHP 与 Go 在字段变化后各说各话。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html); 11. [升级 Gorge 的 HTTP 框架:接口没变,行为也不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-11-upgrade-http-framework.html); 12. [兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构](https://soulteary.com/2026/09/08/phorge-modernization-part-12-elasticsearch-version-compatibility.html); 13. [联调六项外部服务:容器在运行,不代表业务已经切换](https://soulteary.com/2026/09/08/phorge-modernization-part-13-integrate-six-external-services.html); 14. [为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-14-unified-conduit-api-gateway.html); 15. [用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务](https://soulteary.com/2026/09/08/phorge-modernization-part-15-take-over-work-queue-with-go.html); 16. **把数据库自省搬进 Gorge:跨仓契约不能靠手工同步;** 17. [恢复简体中文支持:用工具持续维护 2.7 万行翻译文件](https://soulteary.com/2026/09/09/phorge-modernization-part-17-restore-simplified-chinese.html)。 ## 写在前面 Phorge 的数据库管理页面会连接 MySQL,读取节点状态、复制延迟、表结构和升级记录。单节点环境里,这些查询通常很轻;到了多主库、多副本或应用分区环境,一次检查可能要依次访问多台机器。连接超时、权限不足和复制状态查询都会占用 PHP Web 进程。 `gorge-db-api` 将主要的数据库诊断收进独立服务。Phorge 仍负责呈现管理页面、判断缺少哪些升级补丁,以及执行少量依赖本地状态的 MySQL 检查;Go 服务负责采集节点、复制、结构和迁移状态。未启用服务时,Phorge 继续执行原来的 SQL 查询,现有安装无需立刻切换。 这类拆分有一个很隐蔽的风险:接口可以返回 200,PHP 却因为字段名或字段含义不一致生成错误对象。页面可能把复制延迟显示为空,把致命安装问题当成普通提示,或者错误判断数据库已经完成升级。HTTP 状态正常,反而让问题更晚暴露。 本文以 2026 年 9 月 9 日配套发布的 [`soulteary/gorge@2026.09.09-r3`](https://github.com/soulteary/gorge/releases/tag/2026.09.09-r3) 和 [`soulteary/phorge@2026.09.09-r3`](https://github.com/soulteary/phorge/releases/tag/2026.09.09-r3) 为准。Gorge 标签指向 [`59f051e1`](https://github.com/soulteary/gorge/commit/59f051e10938255da782170e1d617ea4af402e89),Phorge 标签指向 [`27ec67c9`](https://github.com/soulteary/phorge/commit/27ec67c9ba80f0ea9f5e7cb630e5ea2d56b7ebd2)。r3 补齐了字段映射、契约握手、拓扑校验和鉴权限制,部署时应成对升级。 ## 一、db-api 接管哪些数据库检查 迁移后的调用链很短: ```mermaid flowchart LR U["Phorge 管理页面"] --> P["PHP 适配器"] P -->|"/api/db/*"| G["gorge-db-api :8080"] G --> M[("MySQL master")] G --> R[("MySQL replica")] ``` r3 提供八个只读接口: | 路径 | 用途 | | ----------------------------- | ------------------------- | | `/api/db/servers` | 返回全部配置节点的连接与复制状态 | | `/api/db/servers/:ref/health` | 检查一个 `host:port` 节点 | | `/api/db/schema-diff` | 返回服务器、数据库、表、字段和索引组成的实际结构树 | | `/api/db/schema-issues` | 返回扁平的结构问题列表 | | `/api/db/setup-issues` | 检查 MySQL 版本、存储引擎、参数与初始化状态 | | `/api/db/charset-info` | 返回字符集与排序规则能力 | | `/api/db/migrations/status` | 返回各主库已经应用的升级补丁和集群状态摘要 | | `/api/db/meta` | 返回契约版本、数据库前缀、拓扑来源和能力列表 | 前七个接口实时查询数据库;`/api/db/meta` 只读取启动时已经确定的配置。服务不建库、不改表、不执行升级,也不缓存健康和复制结果。每个请求使用短连接完成只读查询,用完立即关闭。 r3 还把迁入初版中未被这些接口调用的路由、事务和写重试代码移到 [`internal/dbproxy`](https://github.com/soulteary/gorge/blob/2026.09.09-r3/docs/adr/0001-isolate-db-proxy.md)。`dbapi` 的生产路径只留下当前在用的读取能力,权限边界更容易审查。 所有 `/api/db/**` 路由共用 `X-Service-Token` 鉴权。token 留空时关闭鉴权;生产环境应明确配置。`/healthz`、`/readyz` 和根路径探针不需要 token,容器编排可以直接访问。 ## 二、接口返回 200,字段仍可能已经错了 r2 的 Go 输出和 PHP 客户端使用了不同的字段名: | 数据 | Go 输出 | r2 PHP 读取 | | ------ | --------------------- | ----------------- | | 复制状态 | `replicationStatus` | `replicaStatus` | | 复制延迟 | `secondsBehindMaster` | `replicaDelaySec` | | 数据库名 | `databaseName` | `database` | | 表名 | `tableName` | `table` | | 字段名 | `columnName` | `column` | | 安装问题标识 | `issueKey` | `key` | JSON 可以正常解析,PHP 的 `idx()` 也会为缺少的键返回默认值,所以这些错误不会自然变成 5xx。服务器列表、安装检查和结构对比仍能渲染,内容却已经失真。 r3 将线协议集中到 Gorge 的 `internal/contracts/dbapi.go`,PHP 按同一组 camelCase 字段恢复原有对象。数据库结构转换也补齐了之前遗漏的属性: - 数据库的字符集、排序规则与 `accessDenied`; - 表的排序规则、存储引擎与索引; - 字段的完整类型、字符集、可空性与自增属性; - 索引的字段顺序、前缀长度、唯一性与类型。 升级状态的职责也重新划清。db-api 只返回数据库中已经观察到的状态:`initialized`、`appliedPatches`、`clusterStatePresent` 和 `clusterStateDigest`。预期补丁列表继续由 Phorge 的 `PhabricatorSQLPatchList` 维护,PHP 用它计算缺失项。 `clusterStatePresent` 用来区分“记录不存在”和“记录存在但内容为空或异常”。`clusterStateDigest` 是 `cluster.databases` 原始状态的 SHA-256 摘要,可用于比较多主库是否一致,同时避免把完整数据库拓扑放进接口响应。PHP 依赖这些新增语义,因此契约版本在 r3 提升到 `1.1`。 ## 三、PHP 先检查 `/meta`,再决定是否切换 只靠两个仓库约定“都使用 camelCase”仍会留下升级顺序问题。新版 PHP 可能连接旧版 Gorge,旧版 PHP 也可能读到自己不认识的新结构。r3 为此增加 `/api/db/meta`: ```json { "data": { "contractVersion": "1.1", "namespace": "phorge", "topologySource": "file", "capabilities": [ "servers", "server-health", "schema-diff", "schema-issues", "setup-issues", "charset-info", "migrations-status" ] } } ``` `PhabricatorGorgeDBClient::shouldUseService()` 会检查四项内容: 1. 契约主版本必须是 `1`,次版本至少为 `1`; 2. `namespace` 必须等于 Phorge 的 `storage.default-namespace`; 3. `capabilities` 必须包含 PHP 当前依赖的 `servers`、`schema-diff`、`setup-issues`、`charset-info` 和 `migrations-status`; 4. `topologySource` 会显示服务正在使用拓扑文件还是单节点变量,便于部署检查。 切换结果分成三类: | 情况 | Phorge 的行为 | | -------------------------- | -------------------- | | 没有配置 `gorge.db.uri` | 直接使用原生 SQL | | `/meta` 可读,但版本、数据库前缀或能力不兼容 | 当前请求使用原生 SQL,并记录安装问题 | | 超时、401、5xx、非法 JSON 或错误信封 | 抛出异常,保留实际故障 | 网络和鉴权故障不会触发静默回落。否则页面虽然还能打开,运维人员却无法判断请求访问了 Gorge 还是 MySQL。兼容性结果按服务 URI 和数据库前缀存进 PHP 的请求级缓存,同一个请求只握手一次。 ## 四、同一份标准 JSON 同时约束 Go 和 PHP 运行时握手可以阻止已知的不兼容版本组合,开发阶段仍要及时发现字段变化。r3 增加了三层检查。 第一层放在 Gorge。`tests/contract/dbapi/canonical/` 保存 servers、schema-diff、setup-issues、charset-info 和 migrations-status 的标准 JSON;Go 测试使用真实契约类型生成数据,再逐字段比较。字段名、类型或省略规则发生变化时,生产端测试会失败。 第二层放在 Phorge。PHP 仓库保存同样的 JSON,`PhabricatorGorgeDBContractTestCase` 将它们转换成 `PhabricatorDatabaseRef`、数据库结构对象和安装问题,再检查最终属性。这样可以发现 PHP 读错键、漏设索引或混淆集群状态等问题。 第三层由 Gorge 的 [`db-api-cross-repo.yml`](https://github.com/soulteary/gorge/blob/2026.09.09-r3/.github/workflows/db-api-cross-repo.yml) 连接两个仓库: 1. 检出明确指定的 Gorge 与 Phorge 版本; 2. 检查两边保存的标准 JSON 是否逐字一致; 3. 启动真实 MySQL,并执行 `bin/storage upgrade`; 4. 先让 Phorge 直接查询数据库,再让它通过 db-api 查询同一实例; 5. 精确比较稳定字段,对连接耗时等易变值检查类型和范围。 这条比较检查的是 PHP 最终得到的对象,能够同时覆盖 Go 输出、PHP 映射和原生 SQL 路径。以后修改 db-api 契约时,两个仓库的配对版本与真实 MySQL 对比结果都应进入发布检查,避免各自 CI 通过、组合运行却出错。 ## 五、多节点配置必须选对主库和副本 db-api 支持两种拓扑来源: | 模式 | 配置方式 | 适用场景 | | --- | ----------------------------------------------------------------- | ------------------ | | 单节点 | `GORGE_DB_MYSQL_HOST/PORT/USER/PASS` 与 `GORGE_DB_NAMESPACE` | 默认 Compose 和普通单机安装 | | 多节点 | `GORGE_DB_CONFIG_FILE` 指向 Phorge 风格 JSON,从 `cluster.databases` 读取 | 主从、分区和多主库环境 | 设置了 `GORGE_DB_CONFIG_FILE` 后,文件配置拥有更高优先级。解析器需要处理默认分区、应用分区、主库与副本角色、节点级账号,以及标量或列表形式的 `partition`。节点密码覆盖全局密码,显式空密码也要保留。 r2 及更早版本在文件缺失或 JSON 非法时会退回单节点变量。这可能让所有应用都落到默认主机,并让节点报告看起来仍然正常。r3 改为启动失败:文件不可读、字段类型错误或没有任何节点被解析为主库角色时,都会直接返回配置错误。只有完全没有设置 `GORGE_DB_CONFIG_FILE` 时,服务才建立单节点拓扑。若文件里只有被禁用的主库,进程可以启动,但 `/readyz` 会保持失败。 分区路由遵循 Phorge 的选择顺序:先找显式服务该应用的节点,再找默认分区,最后兼容旧配置中的未分区节点。副本使用自己的分区属性参与选择,避免把某个应用的查询发给另一个分区的副本。 启动后可以查看 `/api/db/meta` 的 `topologySource`。`file` 表示拓扑文件已经生效,`single-node` 表示服务正在使用标量环境变量。这个字段能快速发现卷未挂载或环境变量未生效的问题。 ## 六、复制状态和 schema 查询要兼容真实数据库 数据库连通只完成了第一步。管理页面还要知道副本线程是否运行、复制延迟是否异常,以及实际表结构能否完整读取。 ### 兼容新旧复制术语 MySQL 逐步替换了复制相关术语: | 较旧名称 | 新名称 | | ---------------------------------------- | -------------------------------------------- | | `SHOW SLAVE STATUS` | `SHOW REPLICA STATUS` | | `Slave_IO_Running` / `Slave_SQL_Running` | `Replica_IO_Running` / `Replica_SQL_Running` | | `Seconds_Behind_Master` | `Seconds_Behind_Source` | 服务优先执行 `SHOW REPLICA STATUS`;服务器返回 1064 语法错误时,再尝试旧语句。权限错误会原样进入权限分类,不会被当成语法兼容问题。即使复制延迟是 0,也要同时确认接收线程和执行线程都在运行。 每次探测都会创建新的结果对象。节点本次连接失败时,旧的复制状态会被清空,响应中不会同时出现“连接失败”和“复制正常”。 ### schema 查询只返回完整结果 结构查询按 Server → Database → Table → Column 遍历,并额外读取表索引: | 层级 | 保留的主要信息 | | -------- | ------------------------- | | Database | 名称、字符集、排序规则、访问被拒标记 | | Table | 名称、排序规则、存储引擎、索引 | | Column | 名称、完整类型、字符集、排序规则、可空性、自增属性 | | Index | 名称、字段顺序、前缀长度、唯一性、索引类型 | 实现还处理了几处常见边界:数据库前缀进入 `LIKE` 条件前先转义 `%` 和 `_`;view 的 `TABLE_COLLATION` 与 `ENGINE` 允许为 `NULL`;索引按 `SEQ_IN_INDEX` 排序;遍历结束后检查 `rows.Err()`。 连接中断、扫描失败或 `INFORMATION_SCHEMA` 查询报错时,整次接口直接失败。返回半棵结构树会让 PHP 把“没有读到”误判成“数据库里不存在”,因此这里不保留部分成功结果。调用方通过 `databases=` 显式请求一个存在但无权查看的库时,服务会保留该节点并标记 `accessDenied: true`,让 Phorge 区分“库不存在”和“账号看不到”。 ## 七、健康、就绪和错误分别说明什么 db-api 的几个检查入口含义不同: | 检查 | 能确认的事情 | | ----------------------------------------------- | ----------------------- | | `/healthz` 200 | HTTP 进程已经启动 | | `/readyz` 200 | 至少一台启用的主库可以连接并完成 ping | | `/api/db/meta` 正常 | token、契约版本、数据库前缀和拓扑来源可读 | | `/api/db/servers` 正常 | 节点探测和复制状态查询可以执行 | | `/api/db/schema-diff`、`/api/db/setup-issues` 正常 | 数据库、权限和自省查询链路可用 | | 管理页面与原生路径结果一致 | PHP 字段映射和业务语义已经完成接管 | `/readyz` 只检查启用的主库,副本单独连通不会让服务进入就绪状态。探针 DSN 不带数据库名,也不检查 `{namespace}_meta_data` 和 `patch_status`。因此 MySQL 已启动但 Phorge 还没执行 `bin/storage upgrade` 时,db-api 可以先进入就绪;更深一层的问题交给诊断接口报告。 Compose 中 Phorge 对 db-api 使用 `service_started`,避免可选的诊断服务阻塞 Phorge 首次启动和数据库升级。容器自己的 healthcheck 访问 `/healthz`,数据库暂时不可达时进程仍可接受请求并通过 `/readyz` 明确报告未就绪。 数据库错误会映射成可供调用方区分的状态: | 错误码 | HTTP 状态 | 含义 | | ---------------------- | ------: | ----------- | | `ERR_DB_UNREACHABLE` | 503 | 数据库不可达或连接中断 | | `ERR_DB_ACCESS_DENIED` | 403 | 数据库账号缺少权限 | | `ERR_READONLY` | 409 | 只读连接拒绝写操作 | 错误信封只写通用说明,不会直接返回原始 SQL、数据库名或主机名;无法分类的内部错误交给平台日志记录。`/api/db/servers` 属于管理报告,会在 200 响应中返回节点地址、账号、状态和 `connectionMessage`。它把“某个节点连不上”当成被观察到的状态,因此不能只看 HTTP 状态判断集群健康。通过 token 的调用方仍需位于可信网络中。 `ERR_READONLY` 是只读连接层保留的保护。当前公开路由都是 GET,不会主动触发写入。 r3 关闭了查询参数传 token 的兼容方式,只接受 `X-Service-Token` 请求头。URL 中的 token 很容易进入代理日志、浏览器历史和监控标签,db-api 没有保留这种传递方式的需要。 ## 八、部署 r3 时需要确认的几件事 ### 两个仓库成对升级 契约 1.1、`/api/db/meta`、完整字段转换和新的切流判断都在 r3 中。新版 PHP 连接契约 1.0 的 Gorge 时会继续使用原生 SQL;旧版 PHP 无法读取 r3 新增的字段语义。部署时应同时升级 Phorge 与 Gorge,并将镜像标签固定为同一配对版本: ```bash GORGE_IMAGE_TAG=2026.09.09-r3 ``` Gorge 镜像采用统一格式: ```text ghcr.io/soulteary/gorge:- ``` db-api 对应 `ghcr.io/soulteary/gorge:db-api-2026.09.09-r3`。 ### Compose 命令始终带上叠加文件 `docker-compose.gorge.yml` 是叠加编排。后续执行 `up`、`exec` 和 `logs` 时都要带同一组 `-f`,可以先定义一个函数: ```bash dc() { docker compose \ -f docker-compose.yml \ -f docker-compose.gorge.yml \ "$@" } dc up -d --build dc exec gorge-db-api wget -qO- http://127.0.0.1:8080/healthz dc exec gorge-db-api wget -qO- http://127.0.0.1:8080/readyz dc exec phorge /opt/phorge/phorge/bin/config get gorge.db.uri ``` 默认编排不会把 db-api 的 8080 端口发布到宿主机,只有 Compose 网络内的 Phorge 可以访问。`.env` 中显式设置 `GORGE_DB_URL=` 可以阻止 entrypoint 再写入服务地址;如果 `gorge.db.uri` 已经存在于 `local.json`,还要手工清除旧值。 ### 多节点只挂载 db-api 需要的配置 完整的 Phorge `local.json` 可能包含邮件、OAuth 和其它应用密钥。多节点部署更适合准备一份只包含数据库前缀、节点、角色、分区和账号的拓扑文件,并以只读卷挂载: ```yaml services: gorge-db-api: environment: GORGE_DB_CONFIG_FILE: /etc/gorge/gorge-db-cluster.json GORGE_DB_MYSQL_PASS: ${GORGE_DB_RO_PASSWORD:-} volumes: - ./deploy/gorge-db-cluster.json:/etc/gorge/gorge-db-cluster.json:ro ``` 修改 environment 或 volume 后,要重新创建容器,单纯 `restart` 不会应用 Compose 配置变化。重建后可在容器内检查元数据: ```bash dc up -d --force-recreate gorge-db-api phorge dc exec gorge-db-api wget -qO- \ --header="X-Service-Token: $GORGE_DB_TOKEN" \ http://127.0.0.1:8080/api/db/meta ``` 返回的 `namespace` 应与 `storage.default-namespace` 一致,`topologySource` 应符合当前部署方式,`contractVersion` 应为 `1.1` 或 PHP 能接受的更高 1.x 版本。 db-api 只需要读取 `INFORMATION_SCHEMA`、复制状态、`patch_status` 和 `hoststate`。生产环境应使用单独的只读账号,并在目标 MySQL 或 MariaDB 版本上验证 `SHOW VIEW`、`REPLICATION CLIENT` 与结构自省所需权限。 ## 最后 配置 db-api 后,Phorge 的数据库管理页面通过独立服务采集节点、复制、schema 和升级状态;没有配置服务时,原生 SQL 路径继续可用。数据库连接与兼容逻辑集中到一个可单独部署、单独审计的边界内。 这里需要重点防范的故障没有 5xx:Go 返回合法 JSON,PHP 却按另一套字段生成了错误对象。r3 用三项约束减少这类问题:`/meta` 在运行时检查版本和数据库前缀;两仓共享标准 JSON;跨仓测试比较 PHP 最终得到的对象。后续修改契约时,还应继续用真实 MySQL 对比 Gorge 与原生 SQL 两条路径,并把配对版本写进发布检查。 –EOF