本文是“Phorge 现代化改造实战”系列第十二篇。上一篇更换了 Gorge 的 HTTP 底座,处理的是框架迁移中容易丢失的兼容语义;这一篇回到搜索服务,记录一次只有接上真实 Elasticsearch 和 Meilisearch 才暴露出来的故障。

系列导航

  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 工作队列:如何避免新旧消费者互相抢任务

写在前面

第八篇迁移搜索服务时,我把重点放在一条完整链路上:Phorge 把任务、评审、提交、用户等对象交给 Gorge,Gorge 翻译成 Elasticsearch 或 Meilisearch 的索引与查询协议;搜索后端只返回 PHID,最终对象加载和权限判断仍由 Phorge 完成。

当时已经补了字段名、mapping、中文分析器、后端角色、读写扇出和索引重建等测试。从这些测试看,两个真实后端都有自己的覆盖,搜索服务也能通过契约固件。

然后,我把它们接进了完整的联调环境。

Elasticsearch 7.17 在创建索引时直接返回 mapper_parsing_exception;Meilisearch 则在第一条带 exclude 的查询上返回 400:

Attribute `id` is not filterable.

两边的单元测试当时都是绿的。

本篇延续第八篇的结论,并将“写进索引不等于搜得到”再向前推进一层:测试证明程序发出了自认为正确的请求,真实后端仍可能拒绝。

这次修复内容已经保存在 Gorge 2026.09.08-r1Phorge 2026.09.08-r1。两个同名版本分别承担不同的工作。

Gorge 的 r1 同时包含 CI 调整、上一篇的 Fiber 迁移,以及本文对应的 PR #12。其中搜索修复提交 17df59e 改动 11 个文件,增加 862 行、删除 80 行;Phorge 的 r1 则把 ES_VERSION 的真实含义补进 .env.example 和 Compose 配置,避免继续把它误解成一个只影响日志显示的数字。同一版本里的本地调试配置和 mailer 校验修正与本文主线无关,不在这里展开。

搜索服务为什么会碰到 mapping type

Phorge 的搜索对象不止一种。任务是 TASK,代码评审是 DREV,提交是 CMIT,用户是 USER,此外还有其他文档类型。

在 Phorge 原来的 Elasticsearch 布局中,这些四字符常量既是业务字段,也直接参与索引结构。早期 Elasticsearch 允许一个索引拥有多个 mapping type,于是同一个 phabricator 索引可以同时拥有 TASKDREVCMIT 等 mapping:

phabricator
├── TASK
├── DREV
├── CMIT
└── USER

每种类型使用的 properties 基本相同,只是 mapping type 的名字不同。搜索时还可以把类型写进 URL,只查询指定的几类对象:

/phabricator/TASK,DREV/_search

Gorge 最初照着 Phorge 自带引擎复现了这套结构。对于 Elasticsearch 5.x,这样做是对的:两边可以使用相同的索引名、字段常量和 mapping 形状,IndexIsSane 也能得到一致判断。

问题在于,这个假设没有跨过 Elasticsearch 后来的结构变化。

关键变化:文档类型换了位置

Elasticsearch 对 mapping type 的移除经历了多个版本。

按照 Elastic 的官方迁移说明,6.x 创建的新索引只允许一个 mapping type;7.x 默认使用 typeless mapping,带类型的 API 进入弃用阶段;到 8.x,类型参数才完全不再支持。

理解这段版本史的关键,是确认一份文档的类型究竟位于索引结构中,还是文档内容中。

Elasticsearch 5 及更早版本选择前者。从 6 开始,一个索引不可能再为 Phorge 的多种文档各建一份 type;到了 7 的 typeless mapping,properties 上方连那一层 type 名也不再需要。

因此,Gorge 要为三种结构分别生成完整协议,改一个字段名解决不了问题:

version mapping 形状 写入路径 类型收窄方式
< 6 每种 Phorge 文档类型一份 mapping /{index}/{TYPE}/{phid} 类型写进搜索 URL
6 只保留一份,嵌在 _doc /{index}/_doc/{phid} 查询体中的 docType 过滤
>= 7 properties 直接位于 mappings 根下 /{index}/_doc/{phid} 查询体中的 docType 过滤

这里的 >= 7 也覆盖 Elasticsearch 8。_doc 在 7.x 以后仍然保留在文档 API 的路径里,但它是固定的 endpoint 名,不再表示一类业务文档。

最后,整个判断浓缩成了一个方法:

// usesMappingTypes 报告集群是否仍然按文档类型组织 mapping。
func (b *Backend) usesMappingTypes() bool {
	return b.version < 6
}

代码很短,影响却分散在建索引、写文档和查文档三条链路上。只修其中一处,服务仍然不能工作。

第一个改动:建索引时生成三种 mapping

原来的 buildIndexConfig 会遍历 Phorge 的文档类型,为每种类型生成一份 properties:

mappings := map[string]any{}
for _, docType := range docTypes {
	mappings[docType] = map[string]any{
		"properties": properties,
	}
}

这正是 Elasticsearch 7 拒绝索引创建的原因。修正后,只有 < 6 保留这套布局:

if b.usesMappingTypes() {
	mappings := map[string]any{}
	for _, docType := range docTypes {
		mappings[docType] = map[string]any{
			"properties": b.buildProperties(),
		}
	}
	data["mappings"] = mappings
	return data
}

版本 6 只允许一个 type,使用官方建议的 _doc

{
  "mappings": {
    "_doc": {
      "properties": {}
    }
  }
}

版本 7 及以后则把 properties 直接放在 mapping 根下:

{
  "mappings": {
    "properties": {}
  }
}

实现中用 singleMappingType() 区分后两种形状:6 返回 _doc,7 及以后返回空字符串。

body := map[string]any{
	"properties": b.buildProperties(),
}
if mappingType := b.singleMappingType(); mappingType != "" {
	body = map[string]any{mappingType: body}
}
data["mappings"] = body

原来在文档类型循环里反复构造的字段定义,也顺手抽成了 buildProperties()。这既减少了重复,也体现出一项约束:当 doc type 不再塑造索引结构以后,传入一个类型或七个类型,生成的 mapping 理应完全相同。

第二个改动:写入路径统一使用 _doc

mapping 变成 typeless 以后,写入路径不能继续使用 Phorge 的业务类型:

/phabricator/TASK/PHID-TASK-...

继续使用旧式 typed endpoint,会与刚刚创建的无类型 mapping 形成两套协议。

修正后的路径选择与 mapping 判断使用同一个条件:

segment := doc.Type
if !b.usesMappingTypes() {
	segment = docEndpointType // "_doc"
}

url := fmt.Sprintf(
	"%s/%s/%s",
	b.baseURL(host),
	segment,
	doc.PHID,
)

于是同一份任务文档,在两个时代会走不同的路径:

ES 5: /phabricator/TASK/PHID-TASK-...
ES 7: /phabricator/_doc/PHID-TASK-...

路径里的 TASK 消失以后,查询结果还要保留业务类型,才能区分任务、评审和提交。

第三个改动:把类型降成文档字段

没有 mapping type 以后,Gorge 在文档体中增加 docType

if !b.usesMappingTypes() {
	spec[fieldDocType] = doc.Type
}

一份 Elasticsearch 7 文档因此会带上:

{
  "docType": "TASK",
  "titl": ["修复搜索兼容性"]
}

docType 被声明为 keyword。它的值是用于精确判断的四字符常量,不需要分词,也不应该参与相关性评分。

这个名字还特意与 Meilisearch 后端保持一致。两个后端不共享索引,但它们描述一份 Phorge 文档的基本方式可以相同:id 是 PHID,docType 是业务类型。

旧版 Elasticsearch 不增加这个字段。对于 ES 5,类型已经存在于 mapping 和 URL 里,再写一份 docType 不但多余,还会让 Gorge 生成的结构偏离 Phorge 自带引擎。

第四个改动:类型限制从 URL 移进查询体

Phorge 发起搜索时,可以只查一部分对象。例如某个页面只需要任务和评审,就会传入 TASKDREV

在 Elasticsearch 5 上,这个限制通过 URL 表达:

/phabricator/TASK,DREV/_search

在 typeless mapping 下,搜索路径只能落到整个索引:

/phabricator/_search

如果只是删掉 URL 里的类型,查询就会从“任务与评审”悄悄扩大成“索引里的所有对象”。服务仍然返回 200,结果也大多看起来合理,这比直接报错更难发现。

所以类型限制没有消失,只是移动到了 bool query 的过滤条件里:

if !b.usesMappingTypes() && len(q.Types) > 0 {
	bq.AddTerms(fieldDocType, q.Types)
}

最终得到类似下面的查询:

{
  "query": {
    "bool": {
      "filter": [
        {
          "terms": {
            "docType": ["TASK", "DREV"]
          }
        }
      ]
    }
  }
}

这里使用 filter,因为文档类型只决定能否进入结果集,不应该影响相关性分数。

mapping、写入路径、文档体和查询过滤必须作为一组修改。它们共同完成了一次位置迁移:文档类型从 Elasticsearch 的索引结构,变成 Gorge 明确维护的普通字段。

ES_VERSION 填错以后,两个方向的代价不一样

Gorge 通过 ES_VERSION 选择协议,不会在每次请求前自动探测集群版本。r1 继续保留这项显式配置,并将它从日志展示信息升级为兼容开关。

ES_HOST=elasticsearch:9200
ES_INDEX=phabricator
ES_VERSION=7

这个数字必须与目标集群的主版本一致,而且填错方向不同,故障表现也不同。

对着 Elasticsearch 7 填 5

Gorge 会生成多 type mapping。Elasticsearch 7 按默认的 typeless 结构解析索引创建请求,随后返回 mapper_parsing_exception

索引没有创建成功,后面的写入与查询自然都失败。这种错误影响很大,但至少是响的:POST /api/search/init 会直接返回失败,日志也能看到后端的错误响应。

对着 Elasticsearch 5 填 7

这个方向更麻烦。

_doc 在 Elasticsearch 5 里可以作为一个普通 type 名使用,所以索引可能建得出来,文档也可能写得进去。Gorge 还会把业务类型写进 docType,并依赖这个字段收窄查询。

问题是,Phorge 自带的 Elasticsearch 引擎不认识这套布局。两边如果试图复用同一份索引,sanity check、字段结构和类型过滤都会出现分歧。单次“建索引成功”还不足以证明兼容。

所以 ES_VERSION 不能大概填写,也不能因为“5 和 7 都使用 text 字段”就让它们走同一条分支。它决定整份索引的结构,影响远超一两个字段名。

当前实现也有明确边界:r1 没有自动阻止“配置版本与真实集群不一致”。这次先通过 Phorge 的 .env.example、Compose 注释和 Gorge 模块文档把要求写清。以后如果加入版本探测,仍然需要考虑代理、兼容实现和混合版本集群,不能把一次根路径响应简单当作所有后端的真相。

修 mapping 时带出来的第一颗地雷:include_in_all

旧 mapping 里,relationship 字段会带上:

{
  "include_in_all": false
}

这个参数与 Elasticsearch 的 _all 字段绑定。对于 6.0 及以后创建的索引,include_in_all 已不允许继续出现在 mapping 中;留下它会直接导致 mapping 解析失败。

因此,它现在只在 < 6 时生成:

if b.supportsIncludeInAll() {
	asMap(props[rel])["include_in_all"] = false
	asMap(props[rel+"_ts"])["include_in_all"] = false
}

反过来,在 Elasticsearch 5 上又不能顺手把它删掉。Gorge 的 mapping 需要与 Phorge 自带引擎保持一致,否则同一份索引会被 IndexIsSane 判断为结构不匹配。

这里需要同时维护两个事实:新集群会拒绝这个过时字段,旧集群的兼容检查又需要它。

第二颗地雷:not 查询已经不能用了

SearchQuery.Exclude 用来把当前对象从结果中排除。例如在相似任务查询里,不应该把任务自己再列一遍。

旧实现生成的是:

{
  "not": {
    "ids": {
      "values": ["PHID-TASK-..."]
    }
  }
}

not query 在 Elasticsearch 2.0 进入弃用,并在 5.0 被移除。对于本文要兼容的 ES 5、6、7 及以后版本,这份查询会直接返回解析错误。

修正时无需再加版本分支,直接使用这些目标版本都支持的 bool.must_not

bq.AddMustNot(map[string]any{
	"ids": map[string]any{
		"values": []string{q.Exclude},
	},
})

这里要警惕的是,这条旧逻辑早在 Elasticsearch 5 上就已经失效。Gorge 默认配置正是版本 5,而 not 在 5.0 已被移除。只要查询带上 Exclude,这条路径在默认目标版本上就无法正常工作。

如果没有真实后端去解析 Query DSL,一个只检查 JSON 能不能生成的测试永远看不出问题。

Meilisearch 的问题小,容易被忽略

Elasticsearch 的兼容问题涉及三代 mapping,看起来足够复杂。Meilisearch 这边最后只改了一个属性:

func (b *Backend) filterableAttributes() []string {
	rels := esquery.AllRelationships()
	attrs := make([]string, 0, len(rels)+2)
	attrs = append(attrs, "id", "docType")
	attrs = append(attrs, rels...)
	return attrs
}

增加的是 id

buildFilters() 会把 Exclude 翻译成:

id != PHID-TASK-...

但 Meilisearch 要求:凡是出现在过滤表达式里的属性,都必须提前放进 filterableAttributes。它不会因为 id 恰好是主键就放宽规则。官方过滤文档也明确要求先声明可过滤属性。

原来的设置里只有 docType 和 relationship,没有 id。所以每一条带 Exclude 的查询都会返回 400。

这类 bug 很容易被经验误导:主键天然可以定位文档,却未必能参与任意过滤。按 ID 获取文档和在搜索表达式里使用 id != ...,属于两套能力。

为什么两条旧测试都没抓到这个 bug

Meilisearch 当时并非完全没有相关测试。

TestBuildFiltersExclude 会检查过滤表达式是否为:

id != PHID-TASK-9

这条测试是绿的,因为过滤字符串本身确实写对了。

TestIndexIsSane 还会检查远端设置是否符合本地预期。它同样是绿的,因为测试使用 filterableAttributes() 生成期望值,再拿远端数据与这份期望比较。id 在函数里漏了,期望值里自然也漏了。

两个测试分别证明:

  • 过滤器认为自己可以使用 id
  • 索引设置与 filterableAttributes() 返回值一致。

但没有任何测试问过:过滤器实际使用的每个属性,是否都出现在可过滤属性声明里?

这就是“同一份误解的两侧”。如果实现和期望来自同一个函数或同一份手写清单,它们可以长期保持一致,同时一起出错。

新测试直接检查两个函数的关系

修复后的 TestEveryFilterableAttributeIsDeclared 没有再手写一份“正确的 filterable attributes 应该有哪些”。

它先运行几组真实 SearchQuery,覆盖文档类型、排除对象、作者、订阅者、项目、仓库、状态和所有者等分支;然后读取 buildFilters() 实际生成的每条表达式,从第一个 token 提取属性名,逐一确认它已被 filterableAttributes() 声明。

大致逻辑如下:

declared := map[string]bool{}
for _, attr := range b.filterableAttributes() {
	declared[attr] = true
}

seen := 0
for _, query := range queries {
	for _, filter := range b.buildFilters(query) {
		for _, expression := range expressionsOf(filter) {
			attribute := strings.Fields(expression)[0]
			seen++
			if !declared[attribute] {
				t.Errorf(
					"filter %q uses undeclared attribute %q",
					expression,
					attribute,
				)
			}
		}
	}
}

if seen == 0 {
	t.Fatal("no filters were rendered")
}

最后的 seen == 0 用来守卫测试本身。如果将来 buildFilters() 因另一个错误什么都不生成,没有这句,循环会执行零次,测试仍会得到一个没有意义的 PASS。

相比快照,这种关系测试更贴近实际约束:过滤器可以继续增加新属性,无需同步修改另一份人工清单;但每个新增属性都必须同时进入可过滤声明。

Elasticsearch 测试也要钉住“位置变化”

Elasticsearch 一侧没有只保存三份完整 JSON 快照。完整快照会把字段顺序、无关配置和未来合法改动一起锁死,最后很容易退化成“失败以后重新录一份”。

这次主要断言的是结构关系:

  • TestVersion5KeepsAMappingPerDocumentType:ES 5 中每个文档类型各有一份 mapping,同时不能再出现 docType 字段;
  • TestVersion6NestsASingleMappingType:ES 6 恰好只有一个 mapping type,并嵌在 _doc 下;
  • TestVersion7HasNoMappingTypes:ES 7 的 properties 位于根部,TASKDREV_doc 等名字都不能作为 mapping type 混回来;
  • TestVersion7MappingIgnoresTheDocumentTypeList:传一个类型或四个类型,生成的 mapping 必须完全相同;
  • TestVersion7IndexesThroughTheDocEndpoint:真实 httptest 服务收到的写入路径必须是 /phabricator/_doc/{phid}
  • TestVersion7DocumentCarriesItsType:类型离开路径以后,必须出现在文档的 docType 字段中;
  • TestVersion7ScopesTypesThroughTheQuery:类型不能只从 URL 消失,还必须进入查询过滤;
  • TestExcludeUsesMustNot:整棵查询树中不能再出现 not,被排除的 PHID 必须位于 must_not
  • TestIncludeInAllIsOmittedFromVersion6Onwards:6、7、8 都不能出现 include_in_all,5 则必须保留。

为了断言“某个 key 在整棵树里都不存在”,测试增加了 findKeyfindValue 两个递归辅助函数。它们返回第一次命中的点分路径;一旦失败,日志不仅告诉你“找到了”,还会指出它从哪一层漏进来。

这些测试主要锁定文档类型在不同版本中的位置,并确认它从一个位置移走后完整落到另一个位置。

单元测试全绿,为什么真实后端仍然一接就坏

Elasticsearch 的后端测试使用 httptest.Server 模拟集群。它很适合验证:

  • 请求发到了哪个路径;
  • 请求方法是否正确;
  • JSON 中包含哪些字段;
  • 5xx 是否触发主机摘除;
  • failover 是否会尝试下一个主机。

但假服务通常不会解析 Elasticsearch 的 mapping 和 Query DSL。只要测试约定某个请求返回 200,它就会返回 200。

于是两类错误都可能漏过去:

程序生成了合法 JSON
Elasticsearch 接受这份 mapping 或 query

Meilisearch 也是一样。单元测试能确认请求里出现 id != ...,却不会自动知道 id 还必须出现在另一条设置 API 中。

因此,真实后端联调补上了另一类验证:由协议的最终解释者判断这份请求是否合法。

其他

demo 环境为什么要同时拉起两个搜索后端

Gorge 的生产 Compose 刻意不内置 Elasticsearch 或 Meilisearch。搜索索引拥有自己的数据卷、资源配额、备份和升级路径,不应该被悄悄塞进应用服务的生命周期里。

但只靠使用者各自准备环境,很容易让真实后端测试长期处于“以后再跑”。2026.09.08-r1 因此增加了一套独立的 demo 编排,把 Elasticsearch 7.17、Meilisearch、OwlMail、MySQL 和 Webhook 接收端放在一起,只用于本地联调。

搜索链路会同时配置 Meilisearch 与 Elasticsearch,写入时双写,查询时按角色读取。启动以后执行:

BASE_URL=http://127.0.0.1:8120 \
  bash tests/e2e/search.sh

脚本会运行 18 个场景,包括索引初始化、写入、查询、中文分析、状态筛选、exclude、统计和错误响应。

这里必须再次提醒:search.sh 会调用 /api/search/init,删除并重新创建索引。它只能指向一次性的联调环境,不能拿生产地址“顺手验一下”。

新增的 deploy/compose/demo/INTEGRATION-TESTING.md 也把启动、判断就绪、运行脚本和排查方法放到了一起。README 负责“怎么把 demo 拉起来”,这份具体的文档要负责“起来以后怎样判断真的能工作”。

升级 r1 以后,还需要做什么

这次发布修正的是 Gorge 生成 mapping、文档和查询的方式,不会替使用者自动修改已有索引。

首先要把 ES_VERSION 设置成真实集群主版本:

ES_VERSION=7

然后重新创建并填充索引:

./bin/search init
./bin/search index --all --force

这两步不能省。新版本增加了 docType 字段,mapping 结构也可能从 typed 变成 typeless;旧索引不会因为更换 Gorge 镜像自动长出这些内容。

bin/search init 会重建索引,执行前应确认当前使用的是可重建的搜索索引,并按照实际部署做好备份或切换准备。搜索索引通常可以从 Phorge 数据库重新生成,但重建期间的查询可用性仍然需要按环境安排。

此外,升级 Gorge 服务只代表新后端代码已经就绪。Phorge 是否通过 Gorge 搜索,仍取决于 cluster.search 中启用的引擎、地址、端口与角色配置。镜像升级、索引重建和流量切换是三件事,“容器已经是 r1”无法代替后两项验证。

修复结果

PR #12 合并时,测试函数从 453 个增加到 464 个,总覆盖率保持 74.9%。两个后端的结果:

指标 修复后
Elasticsearch 后端覆盖率 83.5%
Meilisearch 后端覆盖率 91.5%
测试函数总数 464
总覆盖率 74.9%
真实搜索 e2e 18 个场景通过

总覆盖率没有明显变化,这与新增测试并不冲突。新增测试集中在两个搜索后端,而仓库总量还包括多个二进制入口、MySQL 存储和其他域。关键结果是,新测试开始覆盖三代 mapping 的结构关系,真实 e2e 则让 Elasticsearch 和 Meilisearch 亲自解析生成的协议。

对外的 Gorge Search API 没有变化。Phorge 仍然调用原来的七条 /api/search/* 路由,传入相同的文档和查询,拿回相同的 PHID 列表。变化全部发生在 Gorge 与搜索后端之间。

兼容层需要稳定 Phorge 一侧的契约。Phorge 无需了解 Elasticsearch 的 mapping type 经历了什么,Gorge 负责把同一份业务契约翻译成不同年代的后端协议。

最后

这次修复的入口只是“让 Elasticsearch 7 能建索引”,最后却翻出了四个互相关联的问题:mapping type 的位置变了,include_in_all 会被新索引拒绝,not query 已经退出目标版本,Meilisearch 的主键也不能未经声明就参与过滤。

Elasticsearch 5、6、7 分别使用哪种 JSON,以后还会继续变化。更值得保留的经验有两点:版本差异要区分“名字变化”和“结构变化”;测试也不能只让同一份理解左右互证。

第八篇文章提到“写进索引不等于搜得到”。这次遇到的问题更靠前:有时 JSON 已经生成、测试也已经通过,索引仍然建不出来。识别三个版本号只是起点,可靠的兼容还要求每一代后端都对同一份业务契约给出可以验证的回答。

—EOF