本文是“Phorge 现代化改造实战”系列第十六篇。上一篇把工作队列和消费者迁入 Gorge;这一篇继续处理数据库诊断,把节点健康、复制状态、结构差异和升级状态查询交给独立的 Go 服务,并用跨仓契约避免 PHP 与 Go 在字段变化后各说各话。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递
  11. 升级 Gorge 的 HTTP 框架:接口没变,行为也不能变
  12. 兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构
  13. 联调六项外部服务:容器在运行,不代表业务已经切换
  14. 为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变
  15. 用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务
  16. 把数据库自省搬进 Gorge:跨仓契约不能靠手工同步;
  17. 恢复简体中文支持:用工具持续维护 2.7 万行翻译文件

写在前面

Phorge 的数据库管理页面会连接 MySQL,读取节点状态、复制延迟、表结构和升级记录。单节点环境里,这些查询通常很轻;到了多主库、多副本或应用分区环境,一次检查可能要依次访问多台机器。连接超时、权限不足和复制状态查询都会占用 PHP Web 进程。

gorge-db-api 将主要的数据库诊断收进独立服务。Phorge 仍负责呈现管理页面、判断缺少哪些升级补丁,以及执行少量依赖本地状态的 MySQL 检查;Go 服务负责采集节点、复制、结构和迁移状态。未启用服务时,Phorge 继续执行原来的 SQL 查询,现有安装无需立刻切换。

这类拆分有一个很隐蔽的风险:接口可以返回 200,PHP 却因为字段名或字段含义不一致生成错误对象。页面可能把复制延迟显示为空,把致命安装问题当成普通提示,或者错误判断数据库已经完成升级。HTTP 状态正常,反而让问题更晚暴露。

本文以 2026 年 9 月 9 日配套发布的 soulteary/gorge@2026.09.09-r3soulteary/phorge@2026.09.09-r3 为准。Gorge 标签指向 59f051e1,Phorge 标签指向 27ec67c9。r3 补齐了字段映射、契约握手、拓扑校验和鉴权限制,部署时应成对升级。

一、db-api 接管哪些数据库检查

迁移后的调用链很短:

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/dbproxydbapi 的生产路径只留下当前在用的读取能力,权限边界更容易审查。

所有 /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 只返回数据库中已经观察到的状态:initializedappliedPatchesclusterStatePresentclusterStateDigest。预期补丁列表继续由 Phorge 的 PhabricatorSQLPatchList 维护,PHP 用它计算缺失项。

clusterStatePresent 用来区分“记录不存在”和“记录存在但内容为空或异常”。clusterStateDigestcluster.databases 原始状态的 SHA-256 摘要,可用于比较多主库是否一致,同时避免把完整数据库拓扑放进接口响应。PHP 依赖这些新增语义,因此契约版本在 r3 提升到 1.1

三、PHP 先检查 /meta,再决定是否切换

只靠两个仓库约定“都使用 camelCase”仍会留下升级顺序问题。新版 PHP 可能连接旧版 Gorge,旧版 PHP 也可能读到自己不认识的新结构。r3 为此增加 /api/db/meta

{
  "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 当前依赖的 serversschema-diffsetup-issuescharset-infomigrations-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 连接两个仓库:

  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/PASSGORGE_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/metatopologySourcefile 表示拓扑文件已经生效,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_COLLATIONENGINE 允许为 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_datapatch_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,并将镜像标签固定为同一配对版本:

GORGE_IMAGE_TAG=2026.09.09-r3

Gorge 镜像采用统一格式:

ghcr.io/soulteary/gorge:<service>-<version>

db-api 对应 ghcr.io/soulteary/gorge:db-api-2026.09.09-r3

Compose 命令始终带上叠加文件

docker-compose.gorge.yml 是叠加编排。后续执行 upexeclogs 时都要带同一组 -f,可以先定义一个函数:

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 和其它应用密钥。多节点部署更适合准备一份只包含数据库前缀、节点、角色、分区和账号的拓扑文件,并以只读卷挂载:

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 配置变化。重建后可在容器内检查元数据:

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_statushoststate。生产环境应使用单独的只读账号,并在目标 MySQL 或 MariaDB 版本上验证 SHOW VIEWREPLICATION CLIENT 与结构自省所需权限。

最后

配置 db-api 后,Phorge 的数据库管理页面通过独立服务采集节点、复制、schema 和升级状态;没有配置服务时,原生 SQL 路径继续可用。数据库连接与兼容逻辑集中到一个可单独部署、单独审计的边界内。

这里需要重点防范的故障没有 5xx:Go 返回合法 JSON,PHP 却按另一套字段生成了错误对象。r3 用三项约束减少这类问题:/meta 在运行时检查版本和数据库前缀;两仓共享标准 JSON;跨仓测试比较 PHP 最终得到的对象。后续修改契约时,还应继续用真实 MySQL 对比 Gorge 与原生 SQL 两条路径,并把配对版本写进发布检查。

–EOF