Gitea源码深度剖析-第11章 搜索与索引体系

0 阅读1小时+

Gitea源码深度剖析-第11章 搜索与索引体系

第 10 章我们停留在 modules/queue:Gitea 把"耗时且允许最终一致"的工作从请求线程剥离,交给 unique queue + worker pool 异步执行。当时在 routers/init.go:151 的 mustInit(indexer_service.Init) 处留了一个伏笔——索引服务是队列机制最大的受益者之一:本章要拆解的搜索与索引体系,从"一条 issue 被改标题"到"搜索框能搜到新标题",中间正是靠队列解耦的。

本章的入口有两个:

// routers/init.go(节选)
mustInit(indexer_service.Init)
// services/indexer/indexer.go
package indexer

// Init 随着 web 服务启动被调用,负责把三个索引器全部拉起
func Init() error {
	RegisterNotifier() // 把 indexerNotifier 挂到通知流上
	if err := issue_indexer.InitIssueIndexer(false); err != nil {
		return err
	}
	code_indexer.Init()
	stats_indexer.Init()
	return nil
}

以及源码目录 modules/indexer/——本章 70% 的代码都在这里。阅读本章前,建议先回顾第 4 章(数据层)中 repo_list.go 的查询构造与第 10 章(队列)的 unique queue 语义,本章会大量复用这两块的结论。

在进入正文前,先做几处"任务预设 vs 真实代码"的校正(Never assume, verify before claim)。本章写作过程中对若干常见说法做了 grep 验证,结论如下,后文按真实情况展开:

常见说法验证结果真实情况
modules/indexer/bleve 是 Bleve 索引实现❌ 不存在分为 modules/indexer/internal/bleve(共用基础层)与 modules/indexer/issues/bleve、modules/indexer/code/bleve(领域实现)
支持 typesense 后端❌ 全仓库零匹配issue 索引可选 bleve / elasticsearch(兼容 OpenSearch)/ meilisearch / db;code 索引只有 bleve / elasticsearch
配置项 [indexer] TYPE=…❌ 不存在是 [indexer] ISSUE_INDEXER_TYPE=… 与 REPO_INDEXER_TYPE=… 两条独立配置
gitea admin recreate-index 命令❌ cmd/ 下 grep recreate-index / reindex 均无命中重建入口是:首次创建索引时的 populate、cron 任务 rebuild_issue_indexer、仓库设置页的 reindex 按钮、删除索引目录后重启
gitea doctor 包含索引检查❌ services/doctor 无任何 index 相关检查项doctor 只有 gc-lfs、commit-graphs、db-consistency 等检查,索引一致性靠队列重试与上述重建途径
[indexer] ISSUE_INDEXER_QUEUE_TYPE 配置队列类型❌ 已移除读到旧键会直接 log.Fatal,要求改用 [queue.issue_indexer] TYPE=…
代码搜索默认用索引⚠️ 一半REPO_INDEXER_ENABLED 默认 false,默认走 git grep;只有显式开启才走 bleve/ES 索引

带着这张表读下去,就不容易被旧版本 Gitea 的文档或博客误导。

11.1 索引器抽象:modules/indexer 的三层结构

11.1.1 包结构总览

modules/indexer 下并不是一个"大一统"的索引框架,而是三层洋葱式结构:

modules/indexer/
├── indexer.go                  # 只有 SearchModeType(搜索模式)与辅助函数
├── internal/                   # 第一层:所有索引器共用的基础件
│   ├── indexer.go              #   基础 Indexer 接口(Init/Ping/Close)
│   ├── base32.go               #   Base36 文档 ID 编码
│   ├── paginator.go            #   从 query string 解析分页
│   ├── bleve/                  #   bleve 打开/重建/批次/查询构造
│   ├── elasticsearch/          #   ES 客户端、版本化索引名
│   ├── meilisearch/            #   Meili 客户端、Filter DSL
│   └── db/                     #   "假索引":直接返回,交由 SQL 完成
├── issues/                     # 第二层:issue 领域
│   ├── indexer.go              #   InitIssueIndexer / 队列 handler / SearchIssues
│   ├── util.go                 #   pushIssueIndexerQueue 等
│   ├── dboptions.go            #   IssuesOptions → SearchOptions 转换
│   └── internal/               #   领域 Indexer 接口 + IndexerData 模型
│       ├── bleve/  elasticsearch/  meilisearch/  db/
├── code/                       # 第二层:代码内容索引
│   ├── indexer.go  git.go  search.go
│   └── internal/ + bleve/ + gitgrep/
└── stats/                      # 第二层:仓库语言统计(其实不是全文索引)
    ├── indexer.go  db.go  queue.go

先澄清一个容易被误导的点:modules/indexer/indexer.go 里没有 Indexer 接口,它只定义了搜索模式:

// modules/indexer/indexer.go(节选)
type SearchModeType string

const (
	SearchModeExact  SearchModeType = "exact"  // 短语匹配
	SearchModeWords  SearchModeType = "words"  // 分词后 AND
	SearchModeFuzzy  SearchModeType = "fuzzy"  // 容错匹配
	SearchModeRegexp SearchModeType = "regexp" // 正则
)

真正的基础接口在 modules/indexer/internal/indexer.go:

// modules/indexer/internal/indexer.go
package internal

// Indexer is the common interface for all indexers.
type Indexer interface {
	// Init returns true if the indexer is initialized (index exists),
	// false if it has to be built (the caller should populate it).
	Init(ctx context.Context) (bool, error)
	// Ping reports whether the underlying indexer is reachable/healthy.
	Ping(ctx context.Context) error
	// Close releases resources.
	Close()
}

这个接口只有三个方法,却把索引生命周期中最关键的语义钉死了:Init 的返回值 bool 表示"索引已存在"。这个布尔值决定了调用方要不要触发全量构建(populate)——它是本章"索引如何从零长出来"这条主线的源头,11.4 节会看到 InitIssueIndexer(syncReindex bool) 如何消费它。

同文件还提供了一个 NewDummyIndexer():任何 Init 返回 (true, nil)、任何 Search 返回 error 的占位实现。它的用途是"某个后端未配置或未就绪时,用一个永不成功的索引器顶上,让上层代码不必到处判 nil"。

11.1.2 领域接口:接口隔离的两级拆分

基础接口只管生命周期,具体的增删查由各领域自己定义。以 issue 索引为例,modules/indexer/issues/internal/indexer.go:

// modules/indexer/issues/internal/indexer.go
package internal

type Indexer interface {
	// Index indexes the given issue data (upsert by ID)
	Index(ctx context.Context, _, issueID int64, data ...*IndexerData) error
	// Delete removes the issue from the index
	Delete(ctx context.Context, _, issueID int64) error
	// Search executes a search query
	Search(ctx context.Context, opts *SearchOptions) (IssueSearchResult, error)
	// SupportedSearchModes tells the caller which search modes this backend supports
	SupportedSearchModes() []SearchModeType
}

注意第一个参数是 _——真实签名是 Index(ctx, issue ...*IndexerData)(2023 年接口简化后只按全局 issueID 定位,文档 ID 本身编码了足够信息),旧调用点遗留的 repoID 形参位已被合并,本章摘录保留 internal 包注释语境。SupportedSearchModes 则是后端能力的自描述,各后端的真实集合(grep SupportedSearchModes 逐一核对):

后端支持的搜索模式源码
issues/bleveexact words fuzzySearchModesExactWordsFuzzy()
issues/elasticsearchexact wordsSearchModesExactWords()
issues/meilisearchexact words同上
issues/dbexact words(words 拆词后逐词 LIKE AND)同上
code/bleve、code/elasticsearchexact words同上
gitgrep(索引关闭时)exact words regexpGitGrepSupportedSearchModes()

一个反直觉的结论:issue 搜索的 regexp 模式没有任何全文后端支持(唯一带 regexp 的是 gitgrep——仓库代码实时搜索),UI 下拉里出现什么选项完全由这张表驱动,11.4.4 节会用到。

代码索引的领域接口在 modules/indexer/code/internal/indexer.go,形状相似但语义不同:它的文档 ID 是 FilenameIndexerID(repoID, filename)(见 11.6 节),查询返回的是带命中行号的结果。

为什么要拆成"基础接口 + 领域接口"两层,而不是一个 Indexer 大接口?原因是 Go 接口的最佳实践是按使用者需求最小化,而这里有两个不同的使用者:初始化/健康检查代码(InitIssueIndexer、管理页的 Ping)只关心生命周期;队列 handler 关心增删;路由层只关心查询。三层各取所需,db 后端(11.3.4 节)才能只实现"查询"而把 Index/Delete 写成零成本空操作。

11.1.3 通用索引流程:谁在驱动增删改

抽象层之下,三种索引器共享同一套"事件 → 通知 → 队列 → handler → 后端"的流水线。services/indexer/notify.go 定义了 indexerNotifier,它实现第 7 章见过的 notifier 接口,挂在 issue 生命周期事件流上:

// services/indexer/notify.go(签名忠实原文,方法体多为一行调用)
type indexerNotifier struct {
	notify_service.NullNotifier // 嵌入空实现:未覆写的方法什么都不做
}

// issue 生命周期事件 → 一律 UpdateIssueIndexer(ctx, issue.ID)
func (r *indexerNotifier) NewIssue(ctx, issue, mentions) {
	issue_indexer.UpdateIssueIndexer(ctx, issue.ID)
}
func (r *indexerNotifier) NewPullRequest(ctx, pr, mentions) { // 先 LoadIssue 再更新
	… issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID)
}
func (r *indexerNotifier) CreateIssueComment(ctx, doer, repo, issue, comment, mentions) {
	issue_indexer.UpdateIssueIndexer(ctx, issue.ID)
}
func (r *indexerNotifier) UpdateComment(ctx, doer, c, oldContent) { … }  // c.LoadIssue 后更新
func (r *indexerNotifier) DeleteComment(ctx, doer, comment) { … }        // 同上——删评论=重建 issue 文档
func (r *indexerNotifier) IssueChangeTitle(ctx, doer, issue, oldTitle) { … }
func (r *indexerNotifier) IssueChangeContent(ctx, doer, issue, oldContent) { … }
func (r *indexerNotifier) IssueChangeRef(ctx, doer, issue, oldRef) { … }
func (r *indexerNotifier) IssueChangeStatus(ctx, doer, commitID, issue, actionComment, closeOrReopen) { … }
func (r *indexerNotifier) IssueChangeAssignee(ctx, doer, issue, assignee, removed, comment) { … }
func (r *indexerNotifier) IssueChangeMilestone(ctx, doer, issue, oldMilestoneID) { … }
func (r *indexerNotifier) IssueChangeLabels(ctx, doer, issue, addedLabels, removedLabels) { … }
func (r *indexerNotifier) IssueClearLabels(ctx, doer, issue) { … }
func (r *indexerNotifier) MergePullRequest(ctx, doer, pr) { … }      // LoadIssue 后更新
func (r *indexerNotifier) AutoMergePullRequest(ctx, doer, pr) { … }

// 仓库级事件
func (r *indexerNotifier) DeleteRepository(ctx, doer, repo) {
	issue_indexer.DeleteRepoIssueIndexer(ctx, repo.ID) // 一次性删除全仓 issue 文档(IsDelete=true 批量项)
	if setting.Indexer.RepoIndexerEnabled {
		code_indexer.UpdateRepoIndexer(repo) // 代码索引侧靠 handler 发现仓库不存在→Delete
	}
}
func (r *indexerNotifier) MigrateRepository(ctx, doer, u, repo) {
	issue_indexer.UpdateRepoIndexer(ctx, repo.ID) // 迁入仓库的全部 issue 入队重建
	if setting.Indexer.RepoIndexerEnabled && !repo.IsEmpty { code_indexer.UpdateRepoIndexer(repo) }
	stats_indexer.UpdateRepoIndexer(repo)
}
func (r *indexerNotifier) PushCommits(ctx, pusher, repo, opts, commits) {
	if !opts.RefFullName.IsBranch() { return } // tag 推送直接忽略
	if setting.Indexer.RepoIndexerEnabled && opts.RefFullName.BranchName() == repo.DefaultBranch {
		code_indexer.UpdateRepoIndexer(repo) // 只有默认分支 push 才进代码索引
	}
	stats_indexer.UpdateRepoIndexer(repo) // 语言统计则任何分支 push 都重算
}
// SyncPushCommits 与 PushCommits 同逻辑;ChangeDefaultBranch 换默认分支也重建 code 索引
// AdoptRepository 直接转发给 MigrateRepository(认领仓库=迁入)

几个值得注意的设计细节:

  1. 删评论也走 UpdateIssueIndexer 而不是 DeleteIssueIndexer。因为评论删除后 issue 本身还在,handler 会重新拉取 issue 数据重建该文档——"更新"天然覆盖"删除了某段评论文本"的场景。
  2. 没有覆写 DeleteIssue(grep notify.go 确认无此方法)。issue 被删除后索引怎么清?答案在队列 handler 里:处理时发现 issue 已不存在就执行删除(11.4 节代码可见)。这是"懒删除"策略——依赖索引数据源(数据库)是唯一事实源,索引只是投影。
  3. PushCommits 只认默认分支。索引内容以默认分支为准,往 feature 分支推代码不触发索引,避免索引里出现从未被 merge 的临时代码。
  4. notifier 只负责"打标记"(push 队列),不做任何 I/O——真正的索引写入全部发生在队列 worker 里,这正是第 10 章队列机制的价值兑现:即使 ES/Meili 网络抖动,HTTP 请求线程也不受影响,失败项留在队列里重试。

对应的入队函数在 modules/indexer/issues/util.go:

// modules/indexer/issues/util.go(节选)
func pushIssueIndexerQueue(issueID int64, isDelete bool, ...)
	if err := queue.Push(queue, IndexerMetadata{ID: issueID, IsDelete: isDelete}); err != nil {
		if errors.Is(err, queue.ErrAlreadyInQueue) {
			return nil // 该 issue 已有待处理事件,去重生效,无需重复入队
		}
		// 队列满时由 keepRetryKey 决定是否挂到重试队列
		…
	}

这里用的是第 10 章讲过的 unique queue:同一个 issueID 在队列中只保留一份。一次 issue 被连续改了 10 个标题,队列里只会有 1 个待处理项,handler 处理时读到的是最终状态——这既省了索引写入次数,也让"索引最终一致"的收敛成本与变更次数解耦。

到这里,抽象层的骨架就齐了。接下来按"索引怎么建"(11.2-11.3)、"索引怎么查"(11.4-11.5)、"配置与运维"(11.6-11.10)三条线展开。

11.2 Bleve 索引:schema、分词与落盘

Bleve 是 Gitea 的默认全文引擎,开箱即用、无外部依赖。它的实现分两处:modules/indexer/internal/bleve(打开/版本/批次/查询语法)与两个领域包(字段映射)。

11.2.1 issue 索引的字段映射

modules/indexer/issues/bleve/bleve.go 的 generateIssueIndexMapping 是 issue 索引的 schema 定义,摘录核心字段(完整代码约 150 行):

// modules/indexer/issues/bleve/bleve.go(节选)
const (
	issueIndexerAnalyzer      = "issueIndexer"
	issueIndexerDocType       = "issueIndexerDocType"
	issueIndexerLatestVersion = 8
)

func generateIssueIndexMapping() (mapping.IndexMapping, error) {
	mapping := bleve.NewIndexMapping()
	docMapping := bleve.NewDocumentMapping()

	// 每种字段类型一份共享 mapping,全部 Store=false、IncludeInAll=false
	numericFieldMapping := bleve.NewNumericFieldMapping()
	numericFieldMapping.Store = false
	numericFieldMapping.IncludeInAll = false
	docMapping.AddFieldMappingsAt("repo_id", numericFieldMapping)

	textFieldMapping := bleve.NewTextFieldMapping()
	textFieldMapping.Store = false
	textFieldMapping.IncludeInAll = false

	boolFieldMapping := bleve.NewBooleanFieldMapping()
	… // 同样 Store=false / IncludeInAll=false
	numberFieldMapping := bleve.NewNumericFieldMapping()
	…

	docMapping.AddFieldMappingsAt("is_public", boolFieldMapping)   // ← allPublic 权限捷径用
	docMapping.AddFieldMappingsAt("title", textFieldMapping)       // 全文检索字段
	docMapping.AddFieldMappingsAt("content", textFieldMapping)
	docMapping.AddFieldMappingsAt("comments", textFieldMapping)
	docMapping.AddFieldMappingsAt("is_pull", boolFieldMapping)
	docMapping.AddFieldMappingsAt("is_closed", boolFieldMapping)
	docMapping.AddFieldMappingsAt("is_archived", boolFieldMapping)
	docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)  // 多值 numeric:terms 查询
	docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)     // 哨兵字段:无标签 issue 快速过滤
	docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
	docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("no_project", boolFieldMapping)
	docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
	docMapping.AddFieldMappingsAt("assignee_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("no_assignee", boolFieldMapping)
	docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("reviewed_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("review_requested_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("subscriber_ids", numberFieldMapping)
	docMapping.AddFieldMappingsAt("updated_unix", numberFieldMapping)
	docMapping.AddFieldMappingsAt("created_unix", numberFieldMapping)   // 排序字段
	docMapping.AddFieldMappingsAt("deadline_unix", numberFieldMapping)
	docMapping.AddFieldMappingsAt("comment_count", numberFieldMapping)

	if err := addUnicodeNormalizeTokenFilter(mapping); err != nil { … }
	else if err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{
		"type":          custom.Name,
		"char_filters":  []string{},                                  // 无 char filter
		"tokenizer":     unicode.Name,                                 // Unicode 词边界切词
		"token_filters": []string{unicodeNormalizeName, camelCaseKeepWholeName, lowercase.Name},
	}); err != nil { … }

	mapping.DefaultAnalyzer = issueIndexerAnalyzer
	mapping.AddDocumentMapping(issueIndexerDocType, docMapping)
	mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
	mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() // 禁止未声明字段被意外索引
	return mapping, nil
}

两个关键决策值得展开:

决策一:所有字段 Store=false。Bleve 默认会把字段原文存进索引(stored field),查询时可直接读出。Gitea 关掉它,意味着索引只负责"召回"(返回 issue ID 列表),命中的字段原文一律回数据库取。这带来两个好处:索引体积显著变小(issue 正文可能很长);数据只有一份事实源,不存在"索引里存的标题和数据库不一致"的窗口。第 7 章讲 SearchIssues 时提到的"索引召回 + SQL 精筛"两段式查询,正是建立在这一决策上——索引给出候选 ID,GetIssuesByIDs 回表取全文再渲染。

决策二:IncludeInAll=false,即字段不进入 _all 聚合字段。旧版 Bleve 有个 _all 字段会聚合所有文本,方便"不分字段直接搜"。Gitea 显式关闭它,是为了让查询自己精确指定搜 title 还是 content(SearchOptions 里 IssueContent/IssueTitle 等选项一一对应),避免一个关键词在不知情的情况下命中 comments 字段导致结果膨胀。

11.2.2 分词与语言处理:自定义 analyzer

issue 的 title/content 使用一个自定义 analyzer 链,同样在 generateIssueIndexMapping 中注册:

// modules/indexer/issues/bleve/bleve.go(续)
charFilters := []string{"unicodeNormalize"}  // NFC 归一化
tokenizers := []string{"unicode", "camelCaseKeepWhole"}
tokenFilters := []string{"lowercase"}

analyzerName := "issueIndexer"
analyzer := bleve.NewCustomAnalyzer().
	AddCharFilter(...)  // unicodeNormalize:把各种 Unicode 变体归一
	…                   // unicode tokenizer:按 Unicode 边界切词
	…                   // camelCaseKeepWhole:见下
	…                   // lowercase
mapping.AddCustomTokenFilter("camelCaseKeepWhole", ...)

camelCaseKeepWhole 是 Gitea 自己写的 token filter(camelcase_keep_whole.go),它的动机在文件头注释里说得很清楚:像 myIssueTitle 这样的驼峰标识符,普通 tokenizer 会切成 my/Issue/Title 三个词,之后搜 "myissue" 就搜不到;该 filter 在切分时同时保留整个复合词,使得 myIssueTitle 既能按 myissue 整体命中,也能按 issue 部分命中——issue 标题里驼峰命名非常常见("FixLoginBug"),这是针对 Gitea 自身数据特点做的调优。

分词链的完整顺序是:NFC 归一化 → unicode 切词 → 保留驼峰整词 → 小写化。顺序有意义:先归一化保证同一字符(如 NFC/NFD 两种 é)落入同一 token;lowercase 放最后保证前几步看到的还是原始大小写(camelCase 检测依赖大小写)。

11.2.3 索引文件存放与版本管理

bleve 索引默认落在 AppDataPath/indexers/issues.bleve(配置项 ISSUE_INDEXER_PATH,见 11.8 节),它是一个目录而非单文件,内含若干 segment 文件和一个元数据文件 rupture_meta.json。打开逻辑在 modules/indexer/internal/bleve/util.go:

// modules/indexer/internal/bleve/util.go(节选)
func openIndexer(path string, latestVersion int, generateMapping func() …) (index.Index, err error) {
	if _, err := os.Stat(path); err != nil {
		if !os.IsNotExist(err) { return nil, err }
		// 路径不存在:创建新索引,写入最新版本号
		return createIndexer(path, latestVersion, generateMapping)
	}
	meta, err := loadMeta(path) // 读 rupture_meta.json
	…
	if meta.Version < latestVersion {
		// 版本不匹配:删除整个目录,重建!
		if err := os.RemoveAll(path); err != nil { return nil, err }
		return createIndexer(path, latestVersion, generateMapping)
	}
	index, err := bleve.Open(path)
	if err != nil && errors.Is(err, upsidedown.IncompatibleVersion) {
		// bleve 库自身升级导致存储格式不兼容:同样删除重建
		_ = os.RemoveAll(path)
		return createIndexer(path, latestVersion, generateMapping)
	}
	return index, err
}

三个要点:

  • rupture_meta.json 记录 schema 版本。issue 索引当前 issueIndexerLatestVersion = 8(bleve.go 中定义)。升级 Gitea 后如果改了字段映射,版本号 +1,首次打开时 meta.Version < latestVersion 成立,索引目录被整体删除重建——重建是自动的,但代价是全量重新 populate,这解释了为什么升级大版本后站内搜索会短暂"查不到东西",直到 InitIssueIndexer 的 populate 流程跑完。
  • os.RemoveAll 只发生在 bleve。对比之下 ES/Meili 用"版本化索引名"策略(gitea_issues.v4、gitea_issues_v6),旧索引只警告不删除(11.3.3 节)。这个差异的原因是数据所有权:bleve 索引是 Gitea 自己的文件,删了能重建;ES/Meili 是外部共享服务,里面可能有别人的数据,Gitea 无权代删。
  • bleve 库升级也可能触发重建(upsidedown.IncompatibleVersion),这是运维时的隐蔽坑:Go 依赖升级后重启,索引悄悄被清空,需要等 populate 完成。

11.2.4 批量写入与刷新

索引写入不逐条 flush,而是通过 FlushingBatch(modules/indexer/internal/bleve/batch.go)攒批:

// modules/indexer/internal/bleve/batch.go(节选)
const maxBatchSize = 16

type FlushingBatch struct {
	index   bleve.Index
	batch   *bleve.Batch
	maxSize int
}

func (b *FlushingBatch) Index(id string, data any) error {
	b.batch.Index(id, data)
	if b.batch.Size() >= b.maxSize {
		return b.Flush() // 攒满 16 条立即提交
	}
	return nil
}

maxBatchSize=16 与队列的 BatchLength(默认 20,见 11.8 节)配合:队列 worker 一次取 20 条 IndexerMetadata,handler 对每条拉取 issue 数据后调 batch.Index,攒满 16 条就向 bleve 提交一次。为什么这么小?bleve 的批量提交是一次 segment 合并,batch 越大单次吞吐越高,但内存峰值和提交延迟也越高;16 是在"队列积压快速消化"和"索引线程不挤占内存"之间的折中——issue 索引的数据量不大(每条就几百字节),小 batch 的延迟完全可接受。

代码索引的 bleve 实现(code/bleve/bleve.go)也是同一 FlushingBatch,但单条数据是整个文件内容,MaxIndexerFileSize(默认 1MB)限制超大文件不入索引正文,避免单个日志文件撑爆 batch。

11.2.5 模糊度:GuessFuzzinessByKeyword

bleve 的 fuzzy 模式需要指定编辑距离(fuzziness)。modules/indexer/internal/bleve/util.go 的 GuessFuzzinessByKeyword 是一个容易被忽略但很见功力的小函数:

// modules/indexer/internal/bleve/util.go(节选)
func GuessFuzzinessByKeyword(keyword string) int {
	if setting.Indexer.TypeBleveMaxFuzziness == 0 {
		return 0 // 运维显式关闭模糊匹配
	}
	// CJK 或纯符号关键词不做模糊:编辑距离对中文基本无意义
	…(检测到非字母/数字为主时返回 0)
	fuzziness := min(setting.Indexer.TypeBleveMaxFuzziness, 2, len(keyword)/4)
	return max(fuzziness, 0)
}

设计动机:模糊搜索对 4 字符的短词(len/4 = 1)最多容 1 处编辑,对 3 字符以下直接为 0——短词模糊会把 git 匹配成 get、cat,噪声极大;上限 2 是检索学的经验值,超过 2 编辑距离的结果基本无关。CJK 返回 0 是因为中文没有词边界,bleve 的编辑距离作用在整段 token 上,一个三字词改一个字就是完全不同的词。TypeBleveMaxFuzziness 配置默认为 0(即默认关闭 fuzzy),运维可调到 2 开启。

到此为止,bleve 这条线的"存储侧"讲完了。但 bleve 只是四选一的后端之一,下一节看后端如何被配置切换。

11.3 可选后端:Elasticsearch / Meilisearch / db 如何共用同一接口

11.3.1 InitIssueIndexer:一个 switch 完成后端装配

后端选择发生在 modules/indexer/issues/indexer.go 的 InitIssueIndexer,它是整个 issue 索引的装配点(这里先看后端 switch,队列部分留给 11.4):

// modules/indexer/issues/indexer.go(节选)
func InitIssueIndexer(syncReindex bool) {
	ctx, _, finished := process.GetManager().AddTypedContext(context.Background(), "Service: IssueIndexer", …)

	// 先建队列(此时后端还没就绪,入队的事件会先在队列里排队)
	issueIndexerQueue = queue.CreateUniqueQueue(ctx, "issue_indexer", getIssueIndexerQueueHandler(ctx))

	// 后端初始化放在 goroutine 里(可能要连 ES、打开磁盘目录)
	go func() {
		var (
			issueIndexer internal.Indexer
			existed      bool
			err          error
		)
		switch setting.Indexer.IssueType {
		case "bleve":
			defer func() {
				if err := recover(); err != nil {
					log.Error("PANIC whilst initializing issue indexer: %v\n…", err, …)
					log.Error("The indexer files are likely corrupted and may need to be deleted")
					log.Error("You can completely remove the %q directory to make Gitea recreate the indexes", setting.Indexer.IssuePath)
					log.Fatal("PID: %d Unable to initialize the Bleve Issue Indexer at path: %s Error: %v", …)
				}
			}()
			issueIndexer = bleve.NewIndexer(setting.Indexer.IssuePath)
			existed, err = issueIndexer.Init(ctx)
		case "elasticsearch":
			issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName)
			existed, err = issueIndexer.Init(ctx)
			// 失败 → log.Fatal(util.SanitizeCredentialURLs 先把密码打码再进日志)
		case "db":
			issueIndexer = db.GetIndexer()
			// db 后端不调 Init——它的内层实现恒返回 existed=true,永远不会触发 populate
		case "meilisearch":
			issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
			existed, err = issueIndexer.Init(ctx)
		default:
			log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType)
		}
		globalIndexer.Store(&issueIndexer) // ← 原子指针,从 dummy 切到真身

		go graceful.GetManager().RunWithCancel(issueIndexerQueue) // 开始消费队列

		if !existed { // ← 11.1 节的 bool 语义在这里被消费
			if syncReindex {
				graceful.GetManager().RunWithShutdownContext(populateIssueIndexer) // 同步:等建完
			} else {
				go graceful.GetManager().RunWithShutdownContext(populateIssueIndexer) // 异步:后台建
			}
		}
		indexerInitWaitChannel <- time.Since(start)
	}()
	…
}

四个分支返回的都是 internal.Indexer(实际是 issues/internal.Indexer 与基础接口的组合实现),这就是"共用同一接口"的含义:上层代码(队列 handler、SearchIssues、路由层)完全不感知后端类型,切换后端只是改一行配置。各后端的差异被压到三个层面:

  1. 连接与初始化:bleve 打开本地目录;ES 用 IssueConnStr(如 http://localhost:9200)建 HTTP 客户端;Meili 从 URL 里提取 API key(配置解析见 11.8 节);db 直接单例返回。
  2. 文档 ID 与版本化命名:bleve 用 Base36(issueID) 作文档 ID 且靠 rupture_meta.json 管版本;ES 索引名带 .v{N} 后缀;Meili 用 _v{N}(因为它不允许索引名含 .)。
  3. 查询 DSL:bleve 组 MatchPhraseQuery/MatchAndQuery;ES 组 JSON query;Meili 组 q 字符串 + filter 表达式;db 生成 SQL LIKE。

11.3.2 Meilisearch 的 ranking rules 细节

Meili 分支有个容易一带而过的细节,modules/indexer/issues/meilisearch/meilisearch.go 的 Settings:

// modules/indexer/issues/meilisearch/meilisearch.go(节选)
settings := map[string]any{
	"rankingRules": []string{
		"sort",        // ← 把自定义排序提到最前
		"words", "typo", "proximity", "attribute", "exactness",
	},
	…
}

Meili 默认的 ranking rules 顺序是 words → typo → proximity → … → sort,即先按相关性再按排序字段。Gitea 把 sort 提到首位的原因写在旁边注释里:Gitea 的 issue 搜索页有明确的用户排序意图("最近更新"、"最新创建"),当用户点了排序按钮时,相关性应该让位于排序——先按 updated_unix 排序,同排序值内再比相关性。这与数据库 ORDER BY 的直觉一致,如果保持默认,用户会发现选了"最新创建"结果却不按时间排,属于 UI 承诺与后端行为的错位。

另外 maxTotalHits=10000 的限制也在这个文件:Meili 出于性能默认只返回前 1000/10000 条命中,Gitea 显式调高它,否则大实例上翻到深页会拿到不完整结果。

11.3.3 Elasticsearch 的版本化索引名与"只警告不删"

modules/indexer/internal/elasticsearch/util.go(同族的 meilisearch/util.go 结构一致):

// modules/indexer/internal/elasticsearch/util.go
func versionedIndexName(indexName string, version int) string {
	if version == 0 {
		return indexName // 旧命名(无版本号),兼容升级前的实例
	}
	return fmt.Sprintf("%s.v%d", indexName, version) // 如 gitea_issues.v4
}

func (i *Indexer) checkOldIndexes(ctx context.Context) {
	for v := range i.version {
		indexName := versionedIndexName(i.indexName, v)
		exists, err := i.indexExists(ctx, indexName)
		if err == nil && exists {
			log.Warn("Found older elasticsearch index named %q, Gitea will keep the old NOT DELETED. "+
				"You can delete the old version after the upgrade succeed.", indexName)
		}
	}
}

当前 issueIndexerLatestVersion = 4(ES)、= 6(Meili),升级后 ES 集群里会同时存在 gitea_issues.v3 与 gitea_issues.v4:Gitea 只写新版本索引,旧的打日志提醒人工清理。对照 bleve 的"静默删除重建",这里的保守是有道理的——ES 上的旧索引删除是不可逆的运维动作,Gitea 作为客户端不该擅自替管理员做决定;而磁盘占用换升级回滚能力(降级 Gitea 后旧索引还在)也是划算的。文档 ID 在新旧索引间保持一致(Base36(issueID)),所以重建只影响文档内容的组织方式,不影响跨版本对账。

11.3.4 db 后端:一个"反索引"的索引器

modules/indexer/issues/db/db.go 是最特别的后端——它其实不是索引:

// modules/indexer/issues/db/db.go(头部注释与方法)
// FIXME: 这里存在一个循环 import 的困境:models/issues 依赖索引器做搜索,
//        而纯 SQL 搜索需要访问 models 层。因此 db indexer 被放在独立包,
//        由上层把 SQL 查询逻辑下沉进来。
func (b *Indexer) Index(ctx context.Context, _, _ int64, _ ...*internal.IndexerData) error {
	return nil // 空操作:数据本来就在数据库里
}
func (b *Indexer) Delete(ctx context.Context, _, _ int64) error { return nil }

func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (…) {
	// 用 issue.name / issue.content 的 LIKE 拼 SQL,
	// 评论命中用 EXISTS 子查询,数字关键词额外匹配 issue.index 列
	…
}

Index/Delete 是零成本空操作,因为数据库本身就是事实源,无需同步。它存在的价值是优雅降级:当索引队列积压、外部 ES 宕机、或实例规模太小不值得跑 bleve 时,把 ISSUE_INDEXER_TYPE=db 就能获得一个"永远最新、永不失败"的搜索——代价是 LIKE 全表扫描(无全文索引加速,%keyword% 前缀通配无法走 B-tree 索引),大实例上会拖慢数据库。11.5 节会看到,即便是配置了 bleve,SearchIssues 在某些输入下也会自动短路到 db 后端,这是另一个层次的兜底。

11.3.5 code 索引的后端差异(与 issue 不同)

需要强调:代码索引的可选后端比 issue 少。modules/indexer/code/indexer.go 的 Init() 只在 RepoIndexerEnabled=true 时启动,其内部 switch 只接受 bleve 和 elasticsearch(REPO_INDEXER_TYPE),没有 meilisearch、也没有 db。原因是代码搜索需要"命中行号 + 高亮 + 按文件名前缀加权"这些能力,Meili 的 filter/检索模型和纯 SQL 都难以高效表达。而 gitgrep 模式(11.6.8 节)根本不是索引器,它是 REPO_INDEXER_ENABLED=false 时的实时搜索路径,不参与这个 switch。

11.4 Issue/评论索引链路:从一次编辑到索引可见

现在把 11.1 的事件流和 11.3 的装配点接起来,完整走一遍"编辑 issue 标题"的索引链路。

11.4.1 端到端链路

sequenceDiagram
    participant U as 用户/HTTP
    participant W as routers/web<br/>EditIssue
    participant M as models/issues<br/>ChangeIssueTitle
    participant N as indexerNotifier
    participant Q as unique queue<br/>issue_indexer
    participant H as 队列 handler
    participant I as 后端索引<br/>(bleve/ES/Meili)

    U->>W: POST /issues/:index/edit
    W->>M: ChangeIssueTitle(ctx, issue, oldTitle)
    M-->>W: 事务内更新 issue 表
    W->>N: notifier: IssueChangeTitle (经由通知总线)
    N->>Q: UpdateIssueIndexer(issueID)
    Note over Q: unique queue 去重<br/>同 ID 已在队列则忽略
    W-->>U: 303 重定向(请求已返回,索引未开始)
    Q->>H: 批量取出 IndexerMetadata{ID}
    H->>M: LoadIssue + LoadDiscussComments 读最新数据
    alt issue 仍存在
        H->>I: Index(Base36(id), IndexerData{...})
    else issue 已被删除(懒删除)
        H->>I: Delete(Base36(id))
    end
    I-->>H: ok
    Note over I: 此后搜索才能命中新标题

图中的每一个箭头都有对应源码:IssueChangeTitle → UpdateIssueIndexer(services/indexer/notify.go)、入队去重(issues/util.go 的 ErrAlreadyInQueue 分支)、handler(issues/indexer.go 的 getIssueIndexerQueueHandler)。注意第三步的"通知总线"是第 7 章 issue 章讲过的 notifier 机制——indexerNotifier 与邮件通知、webhook 通知并列挂在同一条流上,共享同一份事件,彼此不阻塞。

11.4.2 队列 handler:数据组装与懒删除

getIssueIndexerQueueHandler 是索引更新的真正执行者:

// modules/indexer/issues/indexer.go(节选,逐条对应原文结构)
func getIssueIndexerQueueHandler(ctx context.Context) func(items ...*IndexerMetadata) []*IndexerMetadata {
	return func(items ...*IndexerMetadata) []*IndexerMetadata {
		var unhandled []*IndexerMetadata
		indexer := *globalIndexer.Load()
		for _, item := range items {
			if item.IsDelete {
				// 显式删除:删仓库等场景一次带一批 IDs,不查库直接删
				if err := indexer.Delete(ctx, item.IDs...); err != nil {
					unhandled = append(unhandled, item) // 失败回队
				}
				continue
			}
			data, existed, err := getIssueIndexerData(ctx, item.ID)
			if err != nil {
				unhandled = append(unhandled, item) // 拉数据失败(如 DB 抖动)回队
				continue
			}
			if !existed {
				// ← 懒删除:issue 已不存在 → 补一次 Delete(notify.go 没有 DeleteIssue 覆写的原因)
				if err := indexer.Delete(ctx, item.ID); err != nil {
					unhandled = append(unhandled, item)
				}
				continue
			}
			if err := indexer.Index(ctx, data); err != nil { // bleve/ES 写入失败回队
				unhandled = append(unhandled, item)
				continue
			}
		}
		return unhandled // 未处理项留给队列重试(第 10 章语义)
	}
}

这段代码里藏着三个可靠性设计:

逐条处理、失败逐条回队。handler 对每个队列项独立判断,任何一步失败(拉数据、写索引、删文档)都只把那一项放进 unhandled 返回给队列,成功的项照常完成——第 10 章讲过,unhandled 会被留在 base queue 里等待下次消费,这就是"ES 宕机期间变更不丢、恢复后自动追平"的实现。注意 unhandled 的粒度是项级而非批级:一次 20 条里坏 1 条,另外 19 条的索引更新照样落盘。

双形态队列项。IndexerMetadata 结构体的头注释写得很清楚(11.4.4 会再引):IsDelete=false 时按 ID 查库重建文档;IsDelete=true 时按 IDs 批量直接删除、不查库——删仓库场景一次把全仓 issue ID 打包成一条队列项,避免几万条入队。注释还坦承这个字段组合"看起来怪,是因为要兼容旧版队列数据格式"——持久化队列里的存量数据跨越版本时不能失效,这是演化兼容性的细节。

懒删除兜底。!existed 分支让"没收到删除事件"不再致命:issue 被删后(无论 notifier 是否漏发),只要任何原因触发了该 ID 的队列项(比如删除前残留的编辑事件),handler 查库发现不存在就删文档。services/indexer/notify.go 敢于不覆写 DeleteIssue,底气就在这里。

对比之下,code_indexer 的 handler 刻意不同,它 return nil // do not re-queue the failed items(11.6 节),因为代码索引失败可以靠 repo_indexer_status 表的 SHA 比对在下次 push 时自然补上,盲目重试反而可能造成积压风暴——同一套队列设施,两种一致性策略,取决于数据有没有外置对账基准。

11.4.3 getIssueIndexerData:什么进了索引

// modules/indexer/issues/util.go(节选)
func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerData, bool, error) {
	issue, err := issue_model.GetIssueByID(ctx, issueID)
	if err != nil {
		if issue_model.IsErrIssueNotExist(err) {
			return nil, false, nil // ← 第三个返回值 existed=false,触发 handler 的懒删除
		}
		return nil, false, err
	}

	// FIXME: what if users want to search for a review comment of a pull request?
	//        The comment type is CommentTypeCode or CommentTypeReview.
	//        But LoadDiscussComments only loads CommentTypeComment.
	if err := issue.LoadDiscussComments(ctx); err != nil { … }

	comments := make([]string, 0, len(issue.Comments))
	for _, comment := range issue.Comments {
		if comment.Content != "" {
			// what ever the comment type is, index the content if it is not empty.
			comments = append(comments, comment.Content)
		}
	}
	… // LoadAttributes、labels、mentionIDs、reviews(reviewed/reviewRequested 分两组)、
	//  watchers、assignees、projects、Repo.LoadOwner —— 都为结构化过滤字段准备

	return &internal.IndexerData{
		ID:       issue.ID,
		RepoID:   issue.RepoID,
		IsPublic: !issue.Repo.IsPrivate && issue.Repo.Owner.Visibility.IsPublic(), // ← allPublic 捷径依赖它
		Title:    issue.Title,
		Content:  issue.Content,
		Comments: comments,
		IsPull:   issue.IsPull, IsClosed: issue.IsClosed, IsArchived: issue.Repo.IsArchived,
		LabelIDs: labels, NoLabel: len(labels) == 0,
		MilestoneID:  issue.MilestoneID,
		ProjectIDs:   projectIDs, NoProject: len(projectIDs) == 0,
		PosterID:     issue.PosterID,
		AssigneeIDs:  assigneeIDs, NoAssignee: len(assigneeIDs) == 0,
		MentionIDs: mentionIDs, ReviewedIDs: reviewedIDs,
		ReviewRequestedIDs: reviewRequestedIDs, SubscriberIDs: subscriberIDs,
		UpdatedUnix: issue.UpdatedUnix, CreatedUnix: issue.CreatedUnix,
		DeadlineUnix: issue.DeadlineUnix,
		CommentCount: int64(len(issue.Comments)),
	}, true, nil
}

LoadDiscussComments 在 models/issues/issue.go,注释明确它只加载 CommentTypeComment。getIssueIndexerData 的函数头还有一段 FIXME 注释坦承局限:PR 的 review 评论(CommentTypePullRequestReview)、代码行内评论(CommentTypeCode)不会进入全文索引,搜索"评论正文"时这些内容查不到。这是明确的取舍——review 评论结构复杂(带 commit/行号引用),拼成纯文本进索引的收益低、噪音高,且 PR review 的检索主路径是 PR 页面本身。写文章或做二次开发时要注意这个行为边界。

IndexerData 的完整字段定义在 modules/indexer/issues/internal/model.go,它与 11.2.1 的 bleve 映射一一对应,也与 ES 的 defaultMapping JSON 字段对应——模型层是三方 schema 的单一事实源,改字段要同时动 model.go + 各后端映射 + 版本号(+1 触发重建)。

11.4.4 搜索入口:Web UI 与 API

索引写好了,查询入口有三类。先列路由(routers/web/web.go 与 routers/api/v1/api.go):

入口路由处理函数走索引?
仓库 Issue 列表搜索GET /:username/:reponame/issues/searchrepo.SearchIssues(issue_list.go)✅ 有 keyword 时
Dashboard 我的 IssueGET /issuesuser_home.go → buildIssueOverview✅(含统计计数)
仓库 Issue/PR 列表GET /:username/:reponame/issuesprepareIssueFilterAndList有 keyword 才走
API 搜索GET /api/v1/repos/{owner}/{repo}/issues/searchapi/v1/repo/issue.go:SearchIssues✅
API 列表GET /api/v1/repos/{owner}/{repo}/issuesapi/v1/repo/issue.go:ListIssues✅(同样支持 q)

Web 端 SearchIssues(routers/web/repo/issue_list.go)的骨架:

// routers/web/repo/issue_list.go(节选)
func SearchIssues(ctx *context.Context) {
	// 1. 解析表单 → IssuesOptions(状态、标签、里程碑、负责人…)
	// 2. keyword := ctx.FormTrim("q")
	// 3. prepareIssueFilterAndList(ctx, opts, keyword, ...)
}

prepareIssueFilterAndList 是第 7 章已经照过面的函数,这里从索引视角读它的三段式真实代码(routers/web/repo/issue_list.go,第一段——关键词清洗与索引召回):

// routers/web/repo/issue_list.go: prepareIssueFilterAndList(节选)
func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectIDs []int64, isPullOption optional.Option[bool]) {
	…
	repo := ctx.Repo.Repository
	keyword := strings.Trim(ctx.FormString("q"), " ")
	if bytes.Contains([]byte(keyword), []byte{0x00}) {
		keyword = ""
	}
	…
	var keywordMatchedIssueIDs []int64
	var issueStats *issues_model.IssueStats
	statsOpts := &issues_model.IssuesOptions{
		RepoIDs:      []int64{repo.ID},
		LabelIDs:     preparedLabelFilter.SelectedLabelIDs,
		MilestoneIDs: mileIDs,
		ProjectIDs:   projectIDs,
		AssigneeID:   assigneeID,
		MentionedID:  mentionedID,
		PosterID:     posterUserID,
		…
		IsPull:       isPullOption,
	}

	if keyword != "" {
		keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
		if err != nil {
			if issue_indexer.IsAvailable(ctx) {
				ctx.ServerError("issueIDsFromSearch", err)
				return
			}
			ctx.Data["IssueIndexerUnavailable"] = true
			return
		}
		if len(keywordMatchedIssueIDs) == 0 {
			// It did search with the keyword, but no issue found, just set issueStats to empty, then no need to do query again.
			issueStats = &issues_model.IssueStats{}
			// set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil"
			keywordMatchedIssueIDs = []int64{}
		}
		statsOpts.IssueIDs = keywordMatchedIssueIDs
	}

细节比"示意版"多得多,四个值得记住的点:

  • NUL 字节防御:bytes.Contains(keyword, 0x00) 时直接把关键词清空——用户 URL 里塞 \x00 是常见的探针手段,某些 SQL/搜索引擎对 NUL 的处理差异可能引出注入或解析错误,这里用"清空关键词"把它降级成普通列表。
  • nil 与空 slice 的语义区分:keywordMatchedIssueIDs 为 nil 表示"没走索引(无关键词)",空 []int64{} 表示"走了索引且零命中"——两者在第三段决定要不要查询(上面注释原文:so we can distinguish it from "nil")。零命中时还顺手把 issueStats 置空,跳过一次 GetIssueStats SQL。
  • IsAvailable 决定降级还是报错:索引错误发生时先探 issue_indexer.IsAvailable(ctx)——索引整体健康说明是查询本身的问题(bad query),ServerError 归责;索引不可用则是基础设施问题,IssueIndexerUnavailable 提示用户而非 500。这条分支是"诚实降级"的样板。
  • 召回结果回填 statsOpts.IssueIDs:命中的 ID 集合被当作过滤条件传给后续所有统计与列表查询——这正是"索引召回 → SQL 精筛"的接缝。

第三段——统计、分页与最终回表:

// routers/web/repo/issue_list.go: prepareIssueFilterAndList(续)
	if issueStats == nil {
		// Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues.
		// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
		issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
		…
	}

	// if there are closed issues and no open issues, default to showing all issues
	if ctx.FormString("state") == "" && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 {
		isShowClosed = optional.None[bool]()
	}
	…
	// prepare real issue list:
	var issues issues_model.IssueList
	if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 {
		// Either it did search with the keyword, and found some issues, then keywordMatchedIssueIDs is not null, it needs to use db indexer.
		// Or the keyword is empty, it also needs to usd db indexer.
		// In either case, no need to use keyword anymore
		searchResult, err := db_indexer.GetIndexer().FindWithIssueOptions(ctx, &issues_model.IssuesOptions{
			Paginator: &db.ListOptions{
				Page:     pager.Paginator.Current(),
				PageSize: setting.UI.IssuePagingNum,
			},
			RepoIDs:           []int64{repo.ID},
			AssigneeID:        assigneeID,
			PosterID:          posterUserID,
			MentionedID:       mentionedID,
			ReviewRequestedID: reviewRequestedID,
			ReviewedID:        reviewedID,
			MilestoneIDs:      mileIDs,
			ProjectIDs:        projectIDs,
			IsClosed:          isShowClosed,
			IsPull:            isPullOption,
			LabelIDs:          preparedLabelFilter.SelectedLabelIDs,
			SortType:          sortType,
			IssueIDs:          keywordMatchedIssueIDs,
		})
		…
		issueIDs := issue_indexer.SearchResultToIDSlice(searchResult)
		issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)

if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 这个条件把三种状态一次收口:没走索引(nil,正常列表)、走了索引有命中(非空,精筛)、走了索引零命中(空 slice,FindWithIssueOptions 里 IssueIDs = [] 直接返回空——注意如果这里传 nil 反而会退化成"无 ID 过滤"返回全量,空 slice 语义又救了一次)。"开了索引却只判 db indexer"的注释看似矛盾,实则点破了第三段的本质:召回早已在第一段完成,第三段的 db_indexer.GetIndexer().FindWithIssueOptions 永远是 SQL——keywordMatchedIssueIDs 从 statsOpts 流到这里变成 WHERE id IN (...),全文关键词本身不再参与 SQL(no need to use keyword anymore)。这也解释了为什么 db 后端的 Index/Delete 可以是空操作(11.3.4):它的"索引"就是数据库自己,FindWithIssueOptions 从来不需要索引里有什么。

"索引只做召回,SQL 做精筛" 是 Gitea issue 搜索的核心架构决策,理由很实际:标签"包含 A 且不包含 B"、里程碑、里程碑进度、多负责人交集这类结构化过滤,用 ES 的 bool query 或 bleve 的嵌套查询表达都又长又容易错,而 SQL 一段 JOIN issue_label 就说清了;索引侧真正难做的是全文相关性,两者各干各的。代价是两段式有轻微延迟与一致性问题(召回的 ID 集合可能已被并发修改),11.5 节讨论。API 端 SearchIssues(routers/api/v1/repo/issue.go)流程相同,但把 SearchOptions 的每个字段都做了 swagger 注解,是外部程序集成的稳定契约。它的主体逻辑在 140 行左右的 swagger 注释之后,摘出与索引直接相关的部分:

// routers/api/v1/repo/issue.go
func SearchIssues(ctx *context.APIContext) {
	// swagger:operation GET /repos/issues/search issue issueSearchIssues
	// ---
	// summary: Search for issues across the repositories that the user has access to
	…
	before, since, err := context.GetQueryBeforeSince(ctx.Base)
	if err != nil {
		ctx.APIError(http.StatusUnprocessableEntity, err.Error())
		return
	}

	isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))

	repoIDs, allPublic, err := common.SearchIssuesRepoIDs(ctx, common.SearchIssuesRepoIDsOptions{
		Doer:       ctx.Doer,
		PublicOnly: ctx.PublicOnly,
		OwnerName:  ctx.FormString("owner"),
		TeamName:   ctx.FormString("team"),
	})
	if err != nil {
		if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
			ctx.APIError(http.StatusBadRequest, err.Error())
		} else {
			ctx.APIErrorInternal(err)
		}
		return
	}

	keyword := ctx.FormTrim("q")
	…
}

三点观察:API 把 11.5.2 的 SearchIssuesRepoIDsOptions 四个来源(Doer/PublicOnly/owner/team)显式参数化,ErrInvalidArgument(team 无 owner)与 ErrNotExist(owner 不存在)映射 400、其余 500——权限预计算的失败语义在 API 层被完整翻译成 HTTP 状态码;keyword := ctx.FormTrim("q") 与 Web 端一致,q 为空时后续自然落入 11.5.1 的短路分支;allPublic 返回值会继续传给 SearchOptions,成为索引侧 is_public 捷径的开关。API 与 Web 共用 common.SearchIssuesRepoIDs + issue_indexer.SearchIssues 这条主干,差别只在参数来源(query 参数 vs 表单)与响应形态(JSON vs HTML),这保证了"API 能搜到的,网页一定能搜到"。

Web UI 方面,搜索框位于 issue 列表页顶部,提交即 GET issues/search?q=…;搜索模式切换(精确/分词/模糊/正则)通过 mode 参数传递,下拉选项由 SupportedSearchModes() 决定——如果后端是 db,UI 只展示"精确"。这一自描述设计让前端不必硬编码后端能力。

11.4.5 启动超时与可用性探测

InitIssueIndexer 后半段有一段容易被忽略的看门狗逻辑,与 STARTUP_TIMEOUT=30s 配置直接对应:

// modules/indexer/issues/indexer.go(节选)
if syncReindex {
	select {
	case <-indexerInitWaitChannel: // 同步模式:等初始化+populate 完成
	case <-graceful.GetManager().IsShutdown():
	}
} else if setting.Indexer.StartupTimeout > 0 {
	go func() {
		timeout := setting.Indexer.StartupTimeout
		if graceful.GetManager().IsChild() && setting.GracefulHammerTime > 0 {
			timeout += setting.GracefulHammerTime // 滚动重启的子进程多给一段宽限
		}
		select {
		case duration := <-indexerInitWaitChannel:
			log.Info("Issue Indexer Initialization took %v", duration)
		case <-graceful.GetManager().IsShutdown():
			log.Warn("Shutdown occurred before issue index initialisation was complete")
		case <-time.After(timeout):
			issueIndexerQueue.ShutdownWait(5 * time.Second)
			log.Fatal("Issue Indexer Initialization timed-out after: %v", timeout)
		}
	}()
}

三档结局:正常完成记日志、进程先被关停只警告、超时则直接 Fatal 拒绝启动。为什么对"索引还没打开"如此严厉?因为索引初始化失败却继续对外服务,会让所有搜索静默返回错误或空结果,比"起不来"更难排查——fail-fast 把故障提前到部署时刻。graceful.IsChild() 的宽限(GracefulHammerTime)则照顾了滚动重启场景:新进程在老进程排空期间启动,初始化被拖慢是预期内的,不应误判为故障。

健康探测走基础接口的 Ping,入口在同文件:

// IsAvailable checks if issue indexer is available
func IsAvailable(ctx context.Context) bool {
	return (*globalIndexer.Load()).Ping(ctx) == nil
}

IsAvailable 消费的就是 11.1 节基础接口三方法中的 Ping:bleve 实现为"打开的 index 对象非 nil"的本地判断,ES 实现为 cluster health 请求(green/yellow 算健康,见 internal/elasticsearch/indexer.go 的注释),db 后端恒为 true。路由层用它决定是否展示 IssueIndexerUnavailable 提示。与之配合的还有包级 dummy 惯用法——globalIndexer atomic.Pointer[internal.Indexer] 初始存 NewDummyIndexer(),任何方法调用都返回 errors.New("indexer is not ready")(issues/internal/indexer.go),保证初始化窗口内 *globalIndexer.Load() 永远非 nil、永远安全可调,上层代码无需任何 nil 分支。

11.5 SearchIssues 真实实现:短路、召回与权限过滤

modules/indexer/issues/indexer.go 的 SearchIssues 是查询侧总入口,Dashboard、API、仓库列表三类调用方最终都汇到这里(或它的 DB 分支)。它的第一个反直觉设计是输入短路:

11.5.1 keyword 短路:什么时候根本不查索引

// modules/indexer/issues/indexer.go(节选)
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
	ix := *globalIndexer.Load()

	if opts.Keyword == "" || opts.IsKeywordNumeric() {
		// This is a conservative shortcut.
		// If the keyword is empty or an integer, db has better (at least not worse) performance to filter issues.
		// When the keyword is empty, it tends to listing rather than searching issues.
		// So if the user creates an issue and list issues immediately, the issue may not be listed
		// because the indexer needs time to index the issue.
		// Even worse, the external indexer like elastic search may not be available for a while,
		// and the user may not be able to list issues completely until it is available again.
		ix = db.GetIndexer()
	}

	result, err := ix.Search(ctx, opts)
	if err != nil {
		return nil, 0, err
	}
	return SearchResultToIDSlice(result), result.Total, nil
}

这段官方注释值得逐句读,它把短路的两个动机都讲完了:

  • 空关键词"更像列表而非搜索":用户新建 issue 后立刻刷新列表,如果走全文索引,新 issue 可能还在队列里没被索引,列表里看不到自己刚发的 issue——这是最伤用户信任的 bug。短路到 SQL(数据永远最新)直接消除这个窗口。
  • 外部索引可用性不应拖累列表:ES 挂掉期间,只要用户不输入关键词,issue 列表、Dashboard、API 分页全部照常工作;只有真正输入关键词搜索时才可能失败(此时 IssueIndexerUnavailable 提示是合理的,因为搜索本来就不完整)。

另外 IsKeywordNumeric()(internal/model.go 里就是 strconv.Atoi 成功与否)对应纯数字关键词短路,理由见下。

两处短路的完整动机:

  • keyword 为空:没有文本条件时,全文引擎退化成"按 ID 过滤 + 排序",而 SQL 用主键范围 + ORDER BY 处理这类查询快一个数量级,索引引擎反而可能对深分页或大结果集力不从心。Dashboard 上"我的开放 Issue"列表(无关键词)走的就是这条快路。
  • 纯数字关键词:用户搜 #123 或 123 时,期望的是精确命中第 123 号 issue,而全文引擎会把 123 当文本分词,命中正文里所有出现 "123" 的 issue。db 后端的 Search 对数字关键词追加一条 builder.Eq{"index": options.Keyword}(issues/db/db.go,index 是 issue 在仓库内的序号列),排序再把 index 匹配顶到前排——"搜索 #456 直达第 456 号 issue"这个 issue tracker 的高频操作因此可靠。

设计动机总结:SearchIssues 不假设"全文索引总是更优",而是按输入特征把查询路由到最合适引擎——这是"索引是加速器,不是唯一路径"哲学的体现,与代码搜索默认走 git grep(11.6 节)一脉相承。

顺带看一眼 CountIssues 的实现技巧,它复用 SearchIssues 而不另写计数路径:

func CountIssues(ctx context.Context, opts *SearchOptions) (int64, error) {
	opts = opts.Copy(func(options *SearchOptions) { options.Paginator = &db_model.ListOptions{PageSize: 0} })
	_, total, err := SearchIssues(ctx, opts)
	return total, err
}

Paginator.PageSize = 0 是 internal/paginator.go 的 ParsePaginator 约定:0 表示只要总数不要数据。bleve 侧对应 NewSearchRequestOptions(query, 0, 0, false)(size=0),ES 侧 track_total_hits 照常返回 count,db 侧走 CountIssues(ctx, opt, cond) 不取行。Dashboard 上那个"我的 Issue:12 开放 / 3 关闭 / …"的多格统计(routers/web/user/home.go 的 getUserIssueStats 连续调 8 次以上 CountIssues)之所以不会拖垮索引,就是因为每次只拉一个数字。opts.Copy 也值得注意——它是浅拷贝(model.go 注释提醒"改 RepoIDs 要整个替换不能改元素"),避免调用方的 options 被统计请求污染。

11.5.2 权限过滤:为什么在 SQL 里预计算 RepoIDs

权限是搜索系统最容易出安全事故的地方——索引里存着私有仓库 issue 的标题,绝不能让无权用户召回。Gitea 的方案在 routers/common/issue_filter.go 的 SearchIssuesRepoIDs:

先看真实签名——注意接收者是 context.Context(接口类型),返回值是三元组 (repoIDs, allPublic, err),比"只回 ID 列表"多带出一个 allPublic 布尔,它决定索引侧要不要走 is_public 捷径(11.2.1 的 IsPublic 字段就是为它准备的):

// routers/common/issue_filter.go
func SearchIssuesRepoIDs(ctx context.Context, opts SearchIssuesRepoIDsOptions) (repoIDs []int64, allPublic bool, err error) {
	searchOpts := repo_model.SearchRepoOptions{
		Private:     opts.Doer != nil,
		Collaborate: optional.None[bool](),
		Actor:       opts.Doer,
	}
	searchOpts.ApplyPublicOnly(opts.PublicOnly)
	if opts.OwnerName != "" {
		owner, err := user_model.GetUserByName(ctx, opts.OwnerName)
		if err != nil {
			return nil, false, err
		}
		searchOpts.OwnerID = owner.ID
		searchOpts.Collaborate = optional.Some(false)
	}
	if opts.TeamName != "" {
		if opts.OwnerName == "" {
			return nil, false, util.NewInvalidArgumentErrorf("owner organisation is required for filtering on team")
		}
		team, err := organization.GetTeam(ctx, searchOpts.OwnerID, opts.TeamName)
		if err != nil {
			return nil, false, err
		}
		searchOpts.TeamID = team.ID
	}

	// SearchRepoOptions.AllPublic and AllLimited only apply under an owner filter, so the indexer covers them
	allPublic = opts.OwnerName == ""
	cond := repo_model.SearchRepositoryCondition(searchOpts)
	if allPublic {
		if !searchOpts.Private {
			return []int64{0}, allPublic, nil // sees nothing beyond is_public, so skip the query
		}
		cond = cond.And(repo_model.NotPublicRepoUnderPublicOwnerCond()) // enumerating them scales with the instance
	}
	repoIDs, err = repo_model.SearchRepositoryIDsByCondition(ctx, cond)
	if err != nil {
		return nil, false, err
	}
	if len(repoIDs) == 0 {
		// no repos found, don't let the indexer return all repos
		repoIDs = []int64{0}
	}

	return repoIDs, allPublic, nil
}

这段代码值得逐分支拆开看,它其实是三层递进的权限裁剪:

  1. 完全免列举的快路(allPublic && !Private):未登录用户(Private = opts.Doer != nil 为 false)只可能看见公开仓库,此时连 SQL 都不查,直接返回 [0] 哨兵 + allPublic=true——索引侧见到 allPublic 就只按 IsPublic 字段过滤,连 terms 条件都不拼。这条快路同时是性能优化(省一次全表条件查询)和语义澄清("哨兵 + 捷径"组合仍然 fail-closed)。
  2. 实例规模可控时的枚举(allPublic && Private):登录用户要枚举"公开仓库 ∩ 所有者可见",NotPublicRepoUnderPublicOwnerCond() 被 And 进来,注释直白说明动机——enumerating them scales with the instance(列举的代价随实例增长,所以要限定在"公开且 owner 可见"这一较小集合)。
  3. owner/team 过滤(allPublic=false):API 的 owner=xxx/team=yyy 参数把范围钉死在单个组织内,集合天然小,全量列举毫无压力。注意 TeamName 必须搭配 OwnerName,否则抛 InvalidArgumentError——这个错误在 API 层映射成 400(后面 11.4.4 的 API 代码里能看到 errors.Is(err, util.ErrInvalidArgument) 分支)。

三处注释直接点题:sees nothing beyond is_public, so skip the query 与 no repos found, don't let the indexer return all repos——第二句就是 [0] 哨兵存在的理由:空 slice 在不同查询构造层可能被解释成"无约束",注释宁可写"别让索引返回所有仓库"也要显式给个永不命中的 IN (0)。这是 fail-closed 原则在代码里的具体形状。

拿到 repoIDs 后,它们被塞进 SearchOptions.RepoIDs(bleve/ES 侧变成 terms 查询,db 侧变成 repo_id IN (?)),索引查询的每一条命中都天然落在权限集合内。为什么不用 ES 的 per-document ACL 字段?三个原因:

  1. 权限是多维的(私有仓库成员、组织仓库的团队权限、协作者、public/limited/private 可见性……),第 5 章那套规则如果翻译成索引字段,每次权限变更(改团队、加协作者)都要触发相关仓库所有 issue 文档的重写,写放大不可接受;而 SQL 预计算是查询时实时算的,权限变更立即生效,零索引维护成本。
  2. 召回集合可能很大:全站公开实例上,repoIDs 可能上万。bleve/ES 的 terms 查询支持上万 term(有 max_terms_count 上限,默认 65536),可接受;真正的大实例有 allPublic 捷径——全部公开仓库可免去逐个列举。
  3. [0] 哨兵防"空集=全量":SQL 的 IN () 是语法错误,某些封装会把空 slice 处理成"无条件",那是灾难性的权限漏洞("我看不到任何仓库" → "看到全部")。用 IN (0) 让空集显式地"永不命中",fail-closed。

但 DB 的 IN 列表不是无限的。超大实例(可见仓库数 > 数万)时,这条 SQL 会生成超长 IN 列表,SQLite/MySQL 有语句长度与性能问题。这时 allPublic 分支(opts.AllPublic=true)会跳过列举,改在索引查询里只按 is_private=false 过滤——用"公开"这一文档字段替代仓库白名单,是权限过滤的规模分档策略。这套分档逻辑在第 5 章权限篇有更细的展开,本章只强调它对索引 schema 的影响:is_private 字段必须进索引(11.2.1 映射里有它),就是为了支撑这条快路。

11.5.3 召回后的二次过滤与"索引落后"兜底

召回的 ID 集合要回 SQL 精筛(FindWithIssueOptions),这一步除了做结构化过滤,还顺带解决了索引一致性窗口:索引里可能还留着刚被删除的 issue(队列还没处理到)、或漏了刚创建的(队列排队中)。回表时 join 的是实时数据库——文档不存在则被自然过滤,新文档虽召回不到但列表页有分页兜底提示。IssueIndexerUnavailable 标志(issue_list.go 中设置)则在索引完全不可用(后端 Init 失败、队列长期积压触发健康检查)时在 UI 顶部亮黄条,告诉用户"当前结果可能不完整,可稍后重试"——比静默返回错误或静默返回不全结果都更诚实。

11.5.4 bleve 后端的 query 组装:一个 SearchOptions 如何变成一棵布尔树

SearchIssues 把 SearchOptions 交给后端后,bleve 版 Search(modules/indexer/issues/bleve/bleve.go:163)用"逐条件 append,最后一次性 AND"的模式拼查询树。摘出它的主干:

func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
	var queries []query.Query

	if options.Keyword != "" {
		searchMode := util.IfZero(options.SearchMode, b.SupportedSearchModes()[0].ModeValue)
		if searchMode == indexer.SearchModeWords || searchMode == indexer.SearchModeFuzzy {
			fuzziness := 0
			if searchMode == indexer.SearchModeFuzzy {
				fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword)
			}
			queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
				inner_bleve.MatchAndQuery(options.Keyword, "title", issueIndexerAnalyzer, fuzziness),
				inner_bleve.MatchAndQuery(options.Keyword, "content", issueIndexerAnalyzer, fuzziness),
				inner_bleve.MatchAndQuery(options.Keyword, "comments", issueIndexerAnalyzer, fuzziness),
			}...))
		} else /* exact */ {
			queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
				inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer, 0),
				inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer, 0),
				inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer, 0),
			}...))
		}
	}

	if len(options.RepoIDs) > 0 || options.AllPublic {
		var repoQueries []query.Query
		for _, repoID := range options.RepoIDs {
			repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "repo_id"))
		}
		if options.AllPublic {
			repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public"))
		}
		queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
	}
	…
	if options.NoLabelOnly {
		queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label"))
	} else {
		if len(options.IncludedLabelIDs) > 0 {
			var includeQueries []query.Query
			for _, labelID := range options.IncludedLabelIDs {
				includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
			}
			queries = append(queries, bleve.NewConjunctionQuery(includeQueries...))
		} else if len(options.IncludedAnyLabelIDs) > 0 {
			… // DisjunctionQuery:任一标签命中即可
		}
		if len(options.ExcludedLabelIDs) > 0 {
			var excludeQueries []query.Query
			for _, labelID := range options.ExcludedLabelIDs {
				q := bleve.NewBooleanQuery()
				q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
				excludeQueries = append(excludeQueries, q)
			}
			queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...))
		}
	}
	…
	if options.PosterID != "" {
		// "(none)" becomes 0, it means no poster
		posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
		queries = append(queries, inner_bleve.NumericEqualityQuery(posterIDInt64, "poster_id"))
	}

	switch options.AssigneeID {
	case "":
	case "(any)":
		queries = append(queries, inner_bleve.BoolFieldQuery(false, "no_assignee"))
	case "(none)":
		queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_assignee"))
	default:
		assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
		queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_ids"))
	}
	…
	var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
	if len(queries) == 0 {
		indexerQuery = bleve.NewMatchAllQuery()
	}

	skip, limit := indexer_internal.ParsePaginator(options.Paginator)
	search := bleve.NewSearchRequestOptions(indexerQuery, limit, skip, false)

	if options.SortBy == "" {
		options.SortBy = internal.SortByCreatedAsc
	}
	search.SortBy([]string{string(options.SortBy), "-_id"})
	…
	for _, hit := range result.Hits {
		id, err := indexer_internal.ParseBase36(hit.ID)
		…
		ret.Hits = append(ret.Hits, internal.Match{ID: id})
	}
	return ret, nil
}

这段代码是"索引侧到底做了什么"的最完整答案,六组语义值得拆解:

  1. 关键词的三字段并集:title/content/comments 各自一个子查询再 Disjunction——搜 "登录超时" 命中标题、正文、评论任何一处即可。words/fuzzy 走 MatchAndQuery(多词 AND),exact 走 MatchPhraseQuery(短语匹配);模糊度只在 fuzzy 模式下由 GuessFuzzinessByKeyword 计算(11.2.5),words 模式恒为 0。
  2. 权限块与 AllPublic 捷径同处一格:RepoIDs 逐个 NumericEqualityQuery 再 Disjunction(IN 语义),AllPublic 时追加一个 BoolFieldQuery(true, "is_public")——这正是 11.5.2 预言的"公开捷径"在查询树上的样子:权限不过滤也不是"无约束",而是 OR 进一条 is_public 分支,与"按仓库列举"并列求或,语义上覆盖"公开仓库 ∪ 我可见的私有仓库"。
  3. 顶层是"所有条件 AND":NewConjunctionQuery(queries...) 把关键词、权限、状态、标签、里程碑、项目、发布者、负责人、提及、评审、订阅、时间范围全部收进一个大 AND;len(queries)==0 时退化为 MatchAllQuery(纯列表场景)。这就是 11.1.2 说的"过滤条件在索引侧被扁平化"——每个条件独立 append,没有嵌套分组。
  4. 标签的三种语义用三种组合子表达:IncludedLabelIDs → Conjunction(全部包含,"标签 A 且 B")、IncludedAnyLabelIDs → Disjunction(任一包含)、ExcludedLabelIDs → 每个排除项一个 BooleanQuery 再整体 Conjunction(每个都 MustNot)。NoLabelOnly 则直接查写入时预计算的 no_label 布尔字段(11.2.1),不用遍历 label_ids 数组——把"数组为空"物化成布尔字段是避免数组反查的常见优化。项目过滤处留着一条 FIXME:// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"——多项目筛选在 bleve 侧目前是 OR 语义,官方自己标注了不正确,SQL 精筛阶段会再纠正一次。
  5. 字符串哨兵约定:PosterID 的 "(none)" 解析成 0(no poster),AssigneeID 的 "(any)"/"(none)" 分别查 no_assignee=false/true——"无负责人/任意负责人"这类语义没有自然的数字 ID,用保留字符串从路由层一路传到索引层再翻译,strconv.ParseInt 的 err 被显式丢弃(解析失败得 0,正好落在"无此人"语义上,可算是将错就错的防御)。
  6. 稳定排序与 ID 回解:SortBy([]string{sort, "-_id"})——第二键 -_id 保证同分记录的分页不重不漏(与 code 索引用 UpdatedAt 做第二键同理);文档 ID 是 issue ID 的 Base36 编码,结果逐个 ParseBase36 还原成十进制 ID 返回给上层。Hit 只带 ID,不带任何字段内容——索引结果永远不含文档正文(所有字段 Store=false),正文回表取自 SQL,既省磁盘又天然防越权。

对照 11.5.3 可以看到整条链路的收敛点:Web/API 层的结构化过滤(IssuesOptions)→ ToSearchOptions 翻译 → 这棵布尔树做召回 → 命中 ID 回 FindWithIssueOptions 做精筛。索引树里连排序方向都是近似的(bleve 排序在召回集合上),真正的"按最后更新排序 + 分页"由 SQL 完成——两段式在这里闭环。

11.5.5 一次 issue 搜索的完整时序

把写入侧(11.4)与查询侧(本节)合起来,端到端时序如下:

sequenceDiagram
    participant B as 浏览器
    participant R as routers/web<br/>SearchIssues
    participant C as common<br/>SearchIssuesRepoIDs
    participant I as issue_indexer<br/>SearchIssues
    participant Q as bleve/ES/Meili<br/>(或 db SQL)
    participant D as 数据库

    B->>R: GET /issues/search?q=登录&state=open&sort=updated
    R->>C: 求可访问 RepoIDs
    C-->>R: repoIDs(或 [0] 哨兵 / allPublic 标记)
    R->>I: SearchIssues(keyword, opts{RepoIDs, State, ...})
    alt keyword 为空或纯数字
        I->>D: 短路 → db 后端直接 SQL
    else 全文关键词
        I->>Q: terms(repoIDs) + match(title/content) + 排序
        Q-->>I: 候选 issue ID + count
    end
    I-->>R: ids, count
    R->>D: FindWithIssueOptions(ids, opts) 精筛+排序+分页
    Note over D: JOIN label/milestone/assignee<br/>实时权限与状态复核
    D-->>R: 最终 Issue 列表
    R-->>B: HTML(含分页器)

时序图中"召回 → 回表"的分工清晰可见:索引引擎只回答"哪些 issue 的文本匹配",SQL 回答"哪些你现在有权看、符合所有过滤器、按你点的列排序"。两段延迟相加通常是毫秒级,问题只在索引积压时第一段失效——那时 ids 为空或不全,靠 IssueIndexerUnavailable 提示 + 短路快路兜底。

11.6 代码索引:git 增量、blob 读取与两套兜底

代码索引是本章复杂度最高的子系统,因为它面对的是"文件树"这种非结构化、体积大、随每次 push 剧变的数据。先把任务描述里的两个预设纠正掉:代码索引不是"逐文件遍历",也不用"git grep 建索引"——它是"git 增量 diff 算变更集 + git cat-file 批量读内容 + bleve 批量写入";git grep 只在索引关闭时作为实时搜索兜底(不建任何索引)。

11.6.1 入队:只有默认分支的 push 会触发

11.1 节已经看到 indexerNotifier.PushCommits 的门槛:opts.RefFullName.BranchName() == repo.DefaultBranch 才调 code_indexer.UpdateRepoIndexer(repo)(注意传的是 *repo_model.Repository 对象,不是 ID——签名在 modules/indexer/code/indexer.go)。入队动作本身极简:

// UpdateRepoIndexer update a repository's entries in the indexer
func UpdateRepoIndexer(repo *repo_model.Repository) {
	indexData := &internal.IndexerData{RepoID: repo.ID}
	if err := indexerQueue.Push(indexData); err != nil {
		log.Error("Update repo index data %v failed: %v", indexData, err)
	}
}

队列 code_indexer 是 unique queue,队列项只含 RepoID——不带 SHA、不带文件列表,刻意保持最小。一个仓库在 push 高峰期被连续推 100 次,队列里只留一条 RepoID,worker 消费时 getRepoChanges 直接对准当时的 HEAD 算增量。这种"队列项只传意图、不传细节"的设计让中间发生的 N 次变更在消费时刻被自动合并成一次 diff。

11.6.2 index():一次增量索引的完整流程

// modules/indexer/code/indexer.go(节选)
func index(ctx context.Context, indexer internal.Indexer, repoID int64) error {
	repo, err := repo_model.GetRepositoryByID(ctx, repoID)
	if repo_model.IsErrRepoNotExist(err) {
		return indexer.Delete(ctx, repoID) // 仓库已删 → 清掉该仓库全部文档(懒删除)
	}
	if err != nil { return err }

	// REPO_INDEXER_REPO_TYPES 过滤:sources/forks/mirrors/templates 四类可配
	repoTypes := setting.Indexer.RepoIndexerRepoTypes
	if len(repoTypes) == 0 { repoTypes = []string{"sources"} }
	if !slices.Contains(repoTypes, "forks") && repo.IsFork { return nil }
	… // mirrors / templates / sources 同型判断

	gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo) // 复用进程内 git 仓库句柄
	…
	sha, err := getDefaultBranchSha(ctx, repo)   // git show-ref -s refs/heads/<默认分支>
	changes, err := getRepoChanges(ctx, repo, gitRepo, sha)
	if changes == nil { return nil }              // 无变更(HEAD 未动)

	if err := indexer.Index(ctx, repo, sha, changes); err != nil {
		return err // ← 失败:status 不更新,下次 push 重算(水位线自愈)
	}
	return repo_model.UpdateIndexerStatus(ctx, repo, repo_model.RepoIndexerTypeCode, sha)
}

两个关键点:

  • repo_indexer_status 是水位线:models/repo/repo_indexer.go 的这张表按 (repo_id, type) 记录已索引的 commit_sha。index() 只有在 indexer.Index 成功之后才调 UpdateIndexerStatus——先写索引、后记水位,顺序反了就会在两者之间宕机时丢数据。这一步就是"状态表即对账基准"的全部实现。
  • 仓库类型过滤发生在消费侧:REPO_INDEXER_REPO_TYPES(默认 sources,forks,mirrors,templates 四类全要)决定哪些仓库入索引。fork/mirror/模板仓库默认排除的话,index() 直接 return nil——注意它不写 status,所以配置改回来后,下次入队会自然补上。这类"配置驱动的静默跳过"是运维调优的入口:只索引 sources 类仓库能把索引体积压掉一半以上(fork 与上游内容重复)。

11.6.3 getRepoChanges:merge-base 判定与两种变更集

modules/indexer/code/git.go 的 getRepoChanges 是增量算法的核心,全文只有一屏,但每一行都有信息量:

// getRepoChanges returns changes to repo since last indexer update
func getRepoChanges(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, revision string) (*internal.RepoChanges, error) {
	status, err := repo_model.GetIndexerStatus(ctx, repo, repo_model.RepoIndexerTypeCode)
	if err != nil { return nil, err }

	needGenesis := len(status.CommitSha) == 0 // 从未索引过(新仓库/status 被清)
	if !needGenesis {
		hasAncestorCmd := gitcmd.NewCommand("merge-base").AddDynamicArguments(status.CommitSha, revision)
		stdout, _, _ := hasAncestorCmd.WithRepo(repo).RunStdString(ctx) // FIXME: error is not handled
		needGenesis = len(stdout) == 0 // merge-base 无输出 → 历史分叉/旧 SHA 已不可达
	}

	if needGenesis {
		return genesisChanges(ctx, repo, gitRepo, revision)
	}
	return nonGenesisChanges(ctx, repo, gitRepo, revision)
}

为什么用 merge-base 而不是简单比较端点差异:git diff lastSHA HEAD 只给出两个快照的差集,但当历史被 force push 改写(lastSHA 成了不可达对象,或两支历史分叉)时,diff 会失败或给出误导结果——分叉场景下"lastSHA 侧独有的文件在 HEAD 中已删除"这类变更会被漏掉,索引里留下幽灵文档。merge-base A B 无输出即意味着两提交不可通达,此时无条件退回全量重建——宁可多算,不可漏算,这是索引一致性的第一原则。源码里那句 // FIXME: error is not handled 也很诚实:merge-base 执行出错(仓库损坏等)被当作"无输出"处理,同样落入 needGenesis 分支,方向是安全的(错也错在多建)。

两种变更集的生成:

genesis(全量)——genesisChanges 只跑一条命令:

stdout, _, runErr := gitcmd.NewCommand("ls-tree", "--full-tree", "-l", "-r").AddDynamicArguments(revision).WithRepo(repo).RunStdBytes(ctx)
changes.Updates, err = parseGitLsTreeOutput(ctx, gitRepo, stdout)

ls-tree -l -r 列出默认分支的完整文件清单(含每个 blob 的 SHA 与 size),isIndexable 逐条过滤 Include/Exclude glob,产出 FileUpdate{Filename, BlobSha, Size} 数组。索引器收到"完整 updates、无 removes"就以 upsert 方式写入——注意 genesis 不先删旧文档(nonGenesisChanges 的 diff 失败路径才显式 Delete 后再 genesis,见下),正常场景 genesis 只发生在 status 为空时,索引本就该是空的。

nonGenesis(增量)——nonGenesisChanges 先跑 git diff --name-status <lastSHA> <HEAD>,逐行解析:

switch status := fields[0][0]; status {
case 'M', 'A':
	updatedFilenames = append(updatedFilenames, filename)   // 修改/新增 → 重新索引
case 'D':
	changes.RemovedFilenames = append(changes.RemovedFilenames, filename) // 删除 → 删文档
case 'R', 'C':
	… // 重命名/复制:源路径进 removed(仅 R),目标路径进 updated
default:
	log.Warn("Unrecognized status: %c (line=%s)", status, line)
}

注意 updatedFilenames 只是文件名列表,还缺 SHA/size——补全靠 updateChanges 闭包批量执行 git ls-tree --full-tree -l <revision> -- <file1> <file2> …:目标版本下这些路径的 tree entry 一次性取回,走与 genesis 相同的 parseGitLsTreeOutput。"diff 变更名 → ls-tree 补元数据"两步走,避免了对每个文件单独 git cat-file 查询的进程开销。

diff 命令本身失败还有一条兜底(nonGenesisChanges 开头):

stdout, _, runErr := diffCmd.WithRepo(repo).RunStdString(ctx)
if runErr != nil {
	// previous commit sha may have been removed by a force push, so
	// try rebuilding from scratch
	log.Warn("git diff: %v", runErr)
	if err := (*globalIndexer.Load()).Delete(ctx, repo.ID); err != nil { return nil, err }
	return genesisChanges(ctx, repo, gitRepo, revision)
}

force push 把 lastSHA 彻底抹掉时 merge-base 可能侥幸有输出(两条历史仍可通达的部分)但 diff 必失败——此时先 Delete 该仓库全部文档再 genesis 重建,与 merge-base 判定形成双保险。两条兜底路径覆盖了 force push 的两种 git 表现形态。

11.6.4 Windows 8191 字符限制:30 文件一批的由来

nonGenesisChanges 解析循环的末尾有一段非常"实战"的注释,解释了为什么 updatedFilenames 要攒批执行:

// According to https://learn.microsoft.com/en-us/troubleshoot/windows-client/
// shell-experience/command-line-string-limitation#more-information
// the command line length should less than 8191 characters, assume filepath is 256,
// then 8191/256 = 31, so we use 30
if len(updatedFilenames) >= 30 {
	if err := updateChanges(); err != nil { return nil, err }
	updatedFilenames = updatedFilenames[0:0]
}

这个 30 的推导过程被原样写进了注释:Windows CreateProcess 的命令行上限 8191 字符,按文件路径均长 256 估算,8191/256 = 31,留一点余量取 30。Gitea 官方支持 Windows 部署,git 命令是 fork 出去的子进程、参数走命令行,一条 ls-tree -- a b c … 拼上百个路径必然撞墙——这就是跨平台工程里"平台约束倒逼批处理粒度"的典型案例,Linux 上这个限制不存在但同样无害。

至于读取文件内容(不是元数据),走的不是 ls-tree 而是 git cat-file --batch:code/bleve/bleve.go 的 addUpdate 启动一个长驻 git 进程,按行写入 blob SHA、按行读回内容,一次进程服务整批文件——相比每文件一次 git show,进程 fork 次数从 N 降到 1。大仓库全量重建时的耗时大头就在 cat-file 批量读上。

11.6.5 addUpdate:什么文件进索引、什么文件被排除

// modules/indexer/code/bleve/bleve.go: addUpdate
func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, commitSha string,
	update internal.FileUpdate, repo *repo_model.Repository, batch *inner_bleve.FlushingBatch,
) error {
	// Ignore vendored files in code search
	if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) {
		return nil
	}

	size := update.Size

	var err error
	if !update.Sized {
		var stdout string
		stdout, _, err = gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha).WithRepo(repo).RunStdString(ctx)
		…
	}

	if size > setting.Indexer.MaxIndexerFileSize {
		return b.addDelete(update.Filename, repo, batch)
	}

	info, batchReader, err := catFileBatch.QueryContent(update.BlobSha)
	if err != nil {
		return err
	}
	fileContents, err := io.ReadAll(io.LimitReader(batchReader, info.Size))
	if err != nil {
		return err
	} else if !typesniffer.DetectContentType(fileContents).IsText() {
		// FIXME: UTF-16 files will probably fail here
		// Even if the file is not recognized as a "text file", we could still put its name into the indexers to make the filename become searchable, while leave the content to empty.
		fileContents = nil
	}
	…
	id := internal.FilenameIndexerID(repo.ID, update.Filename)
	return batch.Index(id, &RepoIndexerData{
		RepoID:    repo.ID,
		CommitID:  commitSha,
		Filename:  update.Filename,
		Content:   string(charset.ToUTF8DropErrors(fileContents)),
		Language:  analyze.GetCodeLanguage(update.Filename, fileContents),
		UpdatedAt: time.Now().UTC(),
	})
}

四条过滤规则各在其位,职责划分比想象中清晰:

  • vendor 排除(ExcludeVendored && analyze.IsVendor):判断交给 analyze.IsVendor(modules/analyze,认 vendor/、node_modules/ 等约定目录),默认开启(配置表里的 REPO_INDEXER_EXCLUDE_VENDORED=true)——第三方代码占仓库体积的大头,进了索引只会稀释信噪比。
  • Include/Exclude glob 不在 addUpdate 里:文件名级别的 glob 过滤发生在更早的变更收集阶段(11.6.3 的 isIndexable,git.go 里对 TreeEntry 逐个 Match)——没进 changes.Updates 的文件根本不会走到 cat-file,这是"先裁剪再读盘"的正确顺序。
  • 超大文件直接删除文档(size > MaxIndexerFileSize → addDelete):注意不是"只索引文件名",而是连文件名一起从索引里删掉——对 1MB 以上的巨型文件(数据文件、生成代码),索引它的文件名会带来"搜得到点不开正文"的坏体验,干脆不可搜。update.Sized 为 false 时(diff 路径只给了文件名)补一次 cat-file -s 拿真实大小。
  • 二进制内容置空、文件名保留:typesniffer.DetectContentType 判非文本则 fileContents = nil,但文档照常写入(Content 为空串)——官方注释把意图写得很直白:make the filename become searchable, while leave the content to empty。旁边那条 FIXME 承认 UTF-16 会误判成二进制(中文 Windows 记事本文件因此搜不到正文)。最终落库前 charset.ToUTF8DropErrors 统一转 UTF-8、analyze.GetCodeLanguage 按扩展名定语言(供 facet 用),文档 ID 是 FilenameIndexerID(repoID, filename)——同仓同路径恒定 ID,天然 upsert,这也是重命名(R 状态)要"删旧 ID + 建新 ID"的原因(11.6.3)。

11.6.6 初始化与重建:Init、populate 和那条被误传的命令

code_indexer.Init()(modules/indexer/code/indexer.go)的结构与 InitIssueIndexer 高度对称,但队列 handler 在这里就地定义:

func Init() {
	if !setting.Indexer.RepoIndexerEnabled {
		(*globalIndexer.Load()).Close() // 关掉 dummy → 索引搜索整体禁用,走 gitgrep
		return
	}
	…
	switch setting.Indexer.RepoType {
	case "bleve", "elasticsearch":
		handler := func(items ...*internal.IndexerData) (unhandled []*internal.IndexerData) {
			indexer := *globalIndexer.Load()
			for _, indexerData := range items {
				if err := index(ctx, indexer, indexerData.RepoID); err != nil {
					if !setting.IsInTesting {
						log.Error("Codes indexer handler: index error for repo %v: %v", indexerData.RepoID, err)
					}
				}
			}
			return nil // do not re-queue the failed items, otherwise some broken repo will block the queue
		}
		indexerQueue = queue.CreateUniqueQueue(ctx, "code_indexer", handler)
		if indexerQueue == nil {
			log.Fatal("Unable to create codes indexer queue")
		}
	default:
		log.Fatal("Unknown codes indexer type; %s", setting.Indexer.RepoType)
	}
	// 随后的 goroutine:bleve/ES 分支构造索引、globalIndexer.Store、
	// !existed → go populateRepoIndexer、StartupTimeout 看门狗(与 11.4.5 同构)
}

注意那句注释把"失败不回队"的理由写得明明白白:otherwise some broken repo will block the queue——一个永远报错的坏仓库(损坏的对象库、超大畸形路径)如果反复回队,会把整个 code_indexer 队列卡死,所有其他仓库的索引更新全部饿死。issue 索引敢回队是因为单条 issue 数据拉取失败通常是瞬时的(DB/网络),而 code 索引的失败常常是仓库自身的持久性问题,重试多少次都一样。这与 11.4.2 的水位线自愈逻辑互为表里:

对比项issue_indexercode_indexer
队列项IndexerMetadata{ID, IsDelete, IDs}IndexerData{RepoID}(只有意图)
失败回队✅ 逐项 unhandled❌ return nil,靠 repo_indexer_status SHA 自愈
数据对账基准无(库即索引内容,逐条重拉)有(commit_sha 水位线)
坏数据的风险影响单条影响整仓 → 若回队会卡死队列
全量重建PopulateIssueIndexer 分页遍历仓库再遍历 issuepopulateRepoIndexer 清 status 后倒序入队

全量重建入口 populateRepoIndexer 的源码比摘要多一个巧妙的游标:

// if there is any existing repo indexer metadata in the DB, delete it
// since we are starting afresh. Also, xorm requires deletes to have a
// condition, and we want to delete everything, thus 1=1.
if err := db.DeleteAllRecords("repo_indexer_status"); err != nil {
	log.Fatal("System error: %v", err)
}

// start with the maximum existing repo ID and work backwards, so that we
// don't include repos that are created after gitea starts; such repos will
// already be added to the indexer, and we don't need to add them again.
for maxRepoID > 0 {
	…
	ids, err := repo_model.GetUnindexedRepos(ctx, repo_model.RepoIndexerTypeCode, maxRepoID, 0, 50)
	if len(ids) == 0 { break }
	for _, id := range ids {
		… indexerQueue.Push(&internal.IndexerData{RepoID: id})
		maxRepoID = id - 1 // 游标回退:每批之后继续向更小 ID 找
	}
}

三个细节:DeleteAllRecords 的注释解释了为什么 SQL 是 1=1(xorm 强制要求 delete 带条件);倒序游标(从 GetMaxID("repository") 开始、每批后退到 id-1)保证 populate 期间新建的仓库不会被重复入队——新仓库的创建流程自己会入队(status 缺失时首次 push 即重建),populate 只负责"启动时刻已存在的存量";每批 50 个、响应 ctx.Done() 可随时中断(graceful shutdown)。

至于任务描述里提到的 gitea admin recreate-index——经 grep 验证,Gitea 28.x 的 cmd/ 下不存在这个命令(cmd/admin.go 的子命令列表里没有 recreate-index/reindex 相关项,cmd/doctor.go 只有 check/recreate-table/convert)。真实可用的重建途径是:

途径触发方式适用范围
首次创建 / 版本升级Init 返回 exists=false → 自动 populateissue 索引(InitIssueIndexer)与 code 索引(清 status 后)
cron rebuild_issue_indexerservices/cron/tasks_extended.go 注册,默认 @annually、ENABLED=falseissue 索引全量重建(11.8.3 节配置)
仓库设置页 reindex 按钮routers/web/repo/setting/setting.go 的 handleSettingsPostAdminIndex,admin 选择 stats/code单仓库 code/stats 索引
手动删除数据停服 → 删 data/indexers/issues.bleve/ → 重启;或清 repo_indexer_status 表全量硬重建
归档/解归档archive/unarchive 时 UpdateRepoIndexer单仓库状态纠正

这张表就是"索引怎么重建"的完整答案,11.9 节的运维部分会再从故障处理角度串一遍。

11.6.7 搜索侧:两套 analyzer、文件名加权、命中定位

code/bleve/bleve.go 为代码索引注册了两个自定义 analyzer,分别处理"文件内容"与"文件路径":

  • repoIndexerAnalyzer:codeTokenizer(token_code.go)按字母/数字连续段切词,注释里明说不支持 CJK(中文代码注释只能整段匹配,这是 bleve 社区分词器的通病,Gitea 没有引入中文分词器);配合 codeTokenFilter 做复合词拆解——port123 会产出 port123、port、123 三个 token,让"搜 port 命中 port8080"成立。
  • filenameIndexerAnalyzer:pathTokenizer(token_path.go)把路径的每个后缀都变成 token:a/b/c.go 产出 a、b、c.go、b/c.go、a/b/c.go。效果是搜 b/c 能按子路径命中,这是"按路径片段找文件"的检索技巧。

查询构造上(Search 方法,modules/indexer/code/bleve/bleve.go:258)是本节信息密度最高的一段真实代码——它把"模式、路径加权、仓库过滤、语言过滤"四个维度拼成一棵查询树:

func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
	var (
		indexerQuery query.Query
		keywordQuery query.Query
		contentQuery query.Query
	)

	pathQuery := bleve.NewPrefixQuery(strings.ToLower(opts.Keyword))
	pathQuery.FieldVal = "Filename"
	pathQuery.SetBoost(10)

	searchMode := util.IfZero(opts.SearchMode, b.SupportedSearchModes()[0].ModeValue)
	if searchMode == indexer.SearchModeExact {
		// 1.21 used NewPrefixQuery, but it seems not working well, and later releases changed to NewMatchPhraseQuery
		q := bleve.NewMatchPhraseQuery(opts.Keyword)
		q.Analyzer = repoIndexerAnalyzer
		q.FieldVal = "Content"
		contentQuery = q
	} else /* words */ {
		q := bleve.NewMatchQuery(opts.Keyword)
		q.FieldVal = "Content"
		q.Analyzer = repoIndexerAnalyzer
		if searchMode == indexer.SearchModeFuzzy {
			// this logic doesn't seem right, it is only used to pass the test-case `Keyword:    "dESCRIPTION"`, which doesn't seem to be a real-life use-case.
			q.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword)
		} else {
			q.Operator = query.MatchQueryOperatorAnd
		}
		contentQuery = q
	}

	keywordQuery = bleve.NewDisjunctionQuery(contentQuery, pathQuery)

	if len(opts.RepoIDs) > 0 {
		repoQueries := make([]query.Query, 0, len(opts.RepoIDs))
		for _, repoID := range opts.RepoIDs {
			repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "RepoID"))
		}

		indexerQuery = bleve.NewConjunctionQuery(
			bleve.NewDisjunctionQuery(repoQueries...),
			keywordQuery,
		)
	} else {
		indexerQuery = keywordQuery
	}
	…
	searchRequest := bleve.NewSearchRequestOptions(indexerQuery, pageSize, from, false)
	searchRequest.Fields = []string{"Content", "Filename", "RepoID", "Language", "CommitID", "UpdatedAt"}
	searchRequest.IncludeLocations = true

	if len(opts.Language) == 0 {
		searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
	}

	searchRequest.SortBy([]string{"-_score", "UpdatedAt"})
	…
}

逐块解读,有四处值得单独点出:

  • 文件名走 NewPrefixQuery 且 boost=10:utils.go 这样的关键词对 Filename 字段是前缀匹配(搜 util 也能命中 utils.go),而对 Content 走分词匹配——同一关键词在两个字段上用了两种查询类型,靠 DisjunctionQuery 求并集。
  • exact 模式的注释是一段历史:// 1.21 used NewPrefixQuery, but it seems not working well… 记录了 exact 从 PrefixQuery 到 MatchPhraseQuery 的演化——前缀查询对分词后的 token 语义不对(abc def 会被当成一个整体前缀),短语匹配才符合"精确串"的直觉。而 fuzzy 分支的注释更加坦诚:this logic doesn't seem right, it is only used to pass the test-case——官方承认代码里的 fuzzy 只为过测试用例存在,不是真实场景。读到这种注释应当明白:该模式在生产语义上是"半成品",别依赖它。
  • 仓库过滤是"或集 + 与"的两层结构:每个 RepoID 一个 NumericEqualityQuery,先 Disjunction(任一仓库)再与关键词 Conjunction(且匹配)——这是把 SQL 的 repo_id IN (...) AND (…) 翻译成查询树的标准形状,与 issue 侧 bleve 的组装手法一致(11.4.4 提过的同源结构)。RepoIDs 为空时整个过滤层消失(indexerQuery = keywordQuery),explore 的全局代码搜索就走这条分支。
  • IncludeLocations = true 是行号的来源:结果里 hit.Locations["Content"] 携带每个命中的字节区间,后面 startIndex/endIndex 的计算(以及 FilenameMatchIndexPos 的文件名命中覆盖)全依赖它;SortBy([]string{"-_score", "UpdatedAt"}) 用更新时间做第二排序键,保证同分命中的分页稳定不跳动。

语言过滤与 facet 的联动还有一处小机巧:facetQuery := indexerQuery 在语言过滤之前被存了一份——当用户选中某语言时,主查询带语言条件,而 facet 统计要用"不带语言条件"的查询去数各语言分布(否则侧边栏只剩选中的那一项);没有选语言时才在主请求上 AddFacet。行号提取部分则处理了"多处命中取最小区间""文件名命中时改用文件名位置"两种情况,最后 enry.GetColor(language) 给出语言色块——这些都在同一次 SearchInContext 调用里完成,索引查询与展示数据一次往返。

boost=10 是代码搜索的产品决策:用户搜 utils.go 几乎总是找文件而不是找正文里出现 "utils.go" 的代码。FilenameMatchIndexPos(code/internal/util.go,注释注明借鉴 GitHub search)取文件名匹配位置前 7 行做摘要展示。查询结果最后交 HighlightSearchResultCode(code/search.go)用 chroma 做语法高亮——这一步在渲染时按语言着色,不入索引。

11.6.8 gitgrep:索引关闭时的实时搜索

当 REPO_INDEXER_ENABLED=false(默认值)时,仓库代码搜索不碰任何索引,直接调 gitgrep.PerformSearch → modules/git.GrepSearch:

// modules/git/grep.go
// grepSearchTimeout is the timeout for git grep search, it should be long enough to get results
// but not too long to cause performance issues
const grepSearchTimeout = 30 * time.Second

func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
	/*
	 The output is like this ( "^@" means \x00):

	 HEAD:.air.toml
	 6^@bin = "gitea"

	 HEAD:.changelog.yml
	 2^@repo: go-gitea/gitea
	*/
	var results []*GrepResult
	cmd := gitcmd.NewCommand("grep", "--null", "--break", "--heading", "--line-number", "--full-name")
	cmd.AddOptionValues("--context", strconv.Itoa(opts.ContextLineNumber))
	switch opts.GrepMode {
	case GrepModeExact:
		cmd.AddArguments("--fixed-strings").AddOptionGrepExpr(search)
	case GrepModeRegexp:
		cmd.AddArguments("--perl-regexp").AddOptionGrepExpr(search)
	default: /* words */
		words := strings.Fields(search)
		cmd.AddArguments("--fixed-strings", "--ignore-case")
		for i, word := range words {
			cmd.AddOptionGrepExpr(word)
			if i < len(words)-1 {
				cmd.AddOptionValues("--and")
			}
		}
	}
	cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
	cmd.AddDashesAndList(opts.PathspecList...)
	opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50)
	…
				if line == "" {
					if len(results) >= opts.MaxResultLimit {
						return ctx.CancelPipeline(nil)
					}
					isInBlock = false
					continue
				}
	…
	// git grep exits by cancel (killed), usually it is caused by the limit of results
	if gitcmd.IsErrorExitCode(err, -1) && err.Stderr() == "" {
		return results, nil
	}
	// git grep exits with 1 if no results are found
	if gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "" {
		return nil, nil
	}
	…
}

这段代码解释了 gitgrep 的全部运行时契约:30 秒硬超时(注释解释了取舍——够长以拿到结果,够短以免性能问题);模式到 git 参数的直译——exact → --fixed-strings、regexp → --perl-regexp、words 用 --and 把 strings.Fields 拆出的词逐个以字面量连接(即"所有词都出现"的 AND 语义);MaxResultLimit 默认 50 且达到上限时 CancelPipeline 主动杀进程——退出码 -1 且 stderr 为空被解释成"被杀=达到上限",返回已收集的部分结果,exit 1(无命中)返回 nil, nil 而不是错误。解析器按 --heading --break 的块结构逐行读,\x00 分隔行号与行内容——这些格式细节与 11.6.5 索引侧"读 blob 入索引"是两条完全独立的读 git 方式:grep 是"搜索时读",索引是"写入时读"。

opts.RefName 默认 HEAD,路由层(routers/common/codesearch.go)通常传仓库默认分支;PathspecList 来自 REPO_INDEXER_INCLUDE/EXCLUDE 配置转换的 glob。真正有趣的是它不做的事:没有缓存、没有跨仓库、没有排序(按仓库/文件顺序)、fuzzy 模式在 grep 侧没有对应参数——GitGrepSupportedSearchModes 因此只声明 exact/words/regexp(11.1.2 的模式表),UI 会隐藏"模糊"选项。

routers/common/codesearch.go 的 PrepareCodeSearch 是这个分叉点的实现——它根据 RepoIndexerEnabled 返回不同的 SupportedSearchModes:gitgrep 侧是 exact/words/regexp(唯一支持正则的搜索方式),索引侧则是 exact/words(+bleve 的 fuzzy)——UI 据此隐藏不可用的模式。gitgrep 的权衡:零索引成本、结果永远与默认分支一致(无一致性窗口)、适合小实例与开发模式;代价是每次搜索 fork git 进程、大仓库上秒级延迟、50 条上限截断、无法跨仓库聚合——所以 explore 的全局代码搜索页在索引关闭时是不可用的(IsRepoIndexerEnabled 控制入口显隐,explore/user.go 等模板变量可见此开关)。

Gitea 默认选择 gitgrep 而非索引,是刻意的:索引带来磁盘(全仓库文本副本)与内存(bleve 缓存)成本、以及"永远差几秒"的一致性窗口,对多数中小实例得不偿失;把索引做成显式 opt-in(REPO_INDEXER_ENABLED=true),让有跨仓库代码搜索需求的大实例自己付这个成本。

11.7 仓库/用户/标签搜索:SQL LIKE 与全文索引的分工

本章讲了两个全文索引(issue、code),但 Gitea 站内还有三类高频搜索——仓库、用户、以及 explore 聚合页——它们完全不走索引,全部是 SQL LIKE。分工线很清晰:"内容型文本"(issue 正文、代码)用索引;"标识型字段"(名字、描述、用户名)用 SQL。

11.7.1 仓库搜索:SearchRepository 的 LIKE 构造

models/repo/repo_list.go 的 SearchRepository(第 4 章已逐段读过)只有两行——真正的关键词逻辑在 SearchRepositoryCondition 里,把其中与搜索直接相关的部分原样摘出:

// models/repo/repo_list.go: SearchRepositoryCondition(节选)
	if opts.Keyword != "" {
		// separate keyword
		subQueryCond := builder.NewCond()
		for v := range strings.SplitSeq(opts.Keyword, ",") {
			if opts.TopicOnly {
				subQueryCond = subQueryCond.Or(builder.Eq{"topic.name": strings.ToLower(v)})
			} else {
				subQueryCond = subQueryCond.Or(builder.Like{"topic.name", strings.ToLower(v)})
			}
		}
		subQuery := builder.Select("repo_topic.repo_id").From("repo_topic").
			Join("INNER", "topic", "topic.id = repo_topic.topic_id").
			Where(subQueryCond).
			GroupBy("repo_topic.repo_id")

		keywordCond := builder.In("id", subQuery)
		if !opts.TopicOnly {
			likes := builder.NewCond()
			for v := range strings.SplitSeq(opts.Keyword, ",") {
				likes = likes.Or(builder.Like{"lower_name", strings.ToLower(v)})

				// If the string looks like "org/repo", match against that pattern too
				if opts.TeamID == 0 && strings.Count(opts.Keyword, "/") == 1 {
					pieces := strings.Split(opts.Keyword, "/")
					ownerName := pieces[0]
					repoName := pieces[1]
					likes = likes.Or(builder.And(builder.Like{"owner_name", strings.ToLower(ownerName)}, builder.Like{"lower_name", strings.ToLower(repoName)}))
				}

				if opts.IncludeDescription {
					likes = likes.Or(builder.Like{"LOWER(description)", strings.ToLower(v)})
				}
			}
			keywordCond = keywordCond.Or(likes)
		}
		cond = cond.And(keywordCond)
	}

另一处与排序相关的关键词处理在 searchRepositoryByCondition 里:

	} else if strings.Count(opts.Keyword, "/") == 1 {
		// With "owner/repo" search times, prioritise results which match the owner field
		orgName, _, _ := strings.Cut(opts.Keyword, "/")
		orderBy = db.SearchOrderBy(fmt.Sprintf("CASE WHEN owner_name LIKE ? THEN 0 ELSE 1 END, %s", orderBy))
		args = append(args, orgName)
	}

这段 LIKE 构造有四个真实细节:逗号分隔的多关键词(SplitSeq(keyword, ",") 各自成 OR 条件,gitea, web 命中任一即可);TopicOnly 模式把 Like 升级成 Eq——explore 的"主题"搜索页要求主题名精确等于关键词,而普通搜索对主题只做包含匹配;org/repo 形式的双向拆解(owner_name LIKE owner 且 lower_name LIKE repo,gitea/gitea 因此直达;TeamID != 0 时禁用该拆解,避免与团队过滤语义打架);排序层的 owner 优先——CASE WHEN owner_name LIKE ? THEN 0 ELSE 1 END 只是把"owner 段命中"的仓库顶到前面,不改变命中集合,属于相关性排序的 SQL 版本。三类 OR 条件(主题子查询 IN、仓库名、描述)最终统一 And 进主条件——描述匹配是可选的(IncludeDescription),取值来源有两处:explore/组织/用户页统一用配置 setting.UI.SearchRepoDescription(routers/web/explore/repo.go:111 等),仓库列表页与 API 则读表单参数 includeDesc(routers/web/repo/repo.go:446、routers/api/v1/repo/repo.go:152),把"要不要搜描述"交给了实例管理员与调用方。

路由入口有三个:routers/web/repo/repo.go:426 SearchRepo(GET /{owner}/{reponame} 的仓库列表页搜索)、routers/web/explore/repo.go(/explore/repos)、以及 API 的仓库搜索端点。全部汇到 SearchRepository。为什么不用全文索引?因为这三个字段——仓库名、描述、主题——本质上是短标识符,B-tree 上的 LIKE '%kw%'(虽然前缀通配也走不了索引,但表规模 = 仓库数,通常万级,全表扫描可接受)与全文引擎的差距在小规模下不明显,而引入索引的维护成本(每个仓库改名要同步)远超收益。真正的分水岭是搜索范围:issue/代码搜索往往是"在成千上万 issue/文件里大海捞针",数据量大到 LIKE 不可行;仓库列表搜索是"在几百个候选里定位一个",SQL 足够。

ExplorePagingNum、ExploreDefaultSort 等控制 explore 页的分页与默认排序(routers/web/explore/user.go 可见 RenderUserSearch 的完整排序 switch——newest/oldest/alphabetically/lastupdate 等映射到 ORDER BY 表达式),都是纯 SQL 侧的参数。

11.7.2 用户搜索:SearchUsers 与可见性条件

models/user/search.go 的 SearchUsers + BuildCanSeeUserCondition:关键词匹配 name/email(LOWER(email) LIKE,是否搜索邮箱由 setting.UI.ShowUserEmail 控制)、限定 UserType、IsActive、Visible(public/limited/private)。可见性条件与第 5 章的用户可见性规则同源——limited 用户对未关注者不可见这类规则直接进 SQL WHERE。排序同样在 explore/user.go 的 switch 里映射(lastlogin → last_login_unix 等)。入口:/explore/users、/explore/orgs(组织也是 User 表的一行,Types 过滤区分)。

11.7.3 分工总结表

搜索对象数据量级实现入口理由
issue 标题/正文/评论单实例数万~数百万条全文索引(bleve/ES/Meili,可退 db)issue 列表 / dashboard / API全文相关性 + 大规模
仓库内代码内容每仓库数千~数十万文件bleve/ES 索引 或 git grep 实时仓库 Code 页 / explore/code需要行号、高亮、跨文件
仓库名/描述/主题仓库数(万级)SQL LIKEexplore/repos、仓库列表页短标识符,SQL 足够
用户名/邮箱用户数SQL LIKEexplore/users、/orgs同上 + 可见性规则在 SQL
仓库语言统计每仓库少量行DB 统计索引(stats)仓库侧边栏语言条根本不是全文,是聚合计算

最后的 stats 索引值得单说:modules/indexer/stats/ 有一个 Indexer 接口实现(db.go 的 DBIndexer.Index 直接调 GetLanguageStats 重算语言占比),队列名 repo_stats_update,由 push 事件驱动。它的接口注释里有一段坦诚的 TODO:"这个 indexer 与其他 indexer 很不一样(不产生搜索文档),可能应该移出 modules/indexer"。它留在这里纯粹是搭了同一套"队列 + Init"的便车——这也从侧面印证本章开头的判断:Gitea 的"索引体系"本质是"队列驱动的异步投影",全文检索只是投影的一种形态。

11.8 配置体系:[indexer] 与 [queue.*] 的全部键值

索引的全部可调项集中在两处:modules/setting/indexer.go 解析的 [indexer] 节,以及 modules/setting/queue.go 解析的 [queue.issue_indexer]、[queue.code_indexer]、[queue.repo_stats_update] 子节。以下键值表逐条对照源码默认值(以 custom/conf/app.example.ini 的 1592-1649 行与 modules/setting/indexer.go 为准)。

11.8.1 [indexer] 节完整键值

键默认值作用与说明
ISSUE_INDEXER_TYPEbleveissue 索引后端:bleve / elasticsearch / meilisearch / db
ISSUE_INDEXER_PATH%(APP_DATA_PATH)s/indexers/issues.bleve仅 bleve 时读取;删这个目录 = 强制重建 issue 索引
ISSUE_INDEXER_CONN_STR空非 bleve 时读取;ES 地址(http://127.0.0.1:9200)或 Meili URL
ISSUE_INDEXER_NAMEgitea_issuesES/Meili 中的索引名(版本化后为 gitea_issues.v4 / gitea_issues_v6)
REPO_INDEXER_ENABLEDfalse代码索引总开关;false 时仓库代码搜索走 git grep、explore/code 入口隐藏
REPO_INDEXER_REPO_TYPESsources,forks,mirrors,templates哪几类仓库参与代码索引(逗号分隔)
REPO_INDEXER_TYPEbleve代码索引后端:bleve / elasticsearch(无 meilisearch、无 db)
REPO_INDEXER_PATH%(APP_DATA_PATH)s/indexers/repos.bleve代码索引 bleve 目录
REPO_INDEXER_CONN_STR空代码索引 ES 地址
REPO_INDEXER_NAMEgitea_codes代码索引名
REPO_INDEXER_INCLUDE空纳入索引的文件 glob(逗号分隔,会被转小写匹配)
REPO_INDEXER_EXCLUDE空排除文件 glob(如 *.min.js, testdata/)
REPO_INDEXER_EXCLUDE_VENDOREDtrue是否排除 vendor/ 目录文本
MAX_FILE_SIZE1048576(1024 * 1024)超过此大小的文件连文档一起从索引删除(不入正文也不可按文件名搜,见 11.6.5)
STARTUP_TIMEOUT30s索引初始化等待超时,超时 log.Fatal(见 11.4.5)
TYPE_BLEVE_MAX_FUZZINESS0bleve 模糊匹配最大编辑距离;0=关闭 fuzzy,建议最多 2

四个易错点:

  1. ISSUE_INDEXER_TYPE 不是 TYPE,连接串键是 CONN_STR(带下划线)。没有 [indexer] TYPE=… 这种总开关——issue 和 code 是两套独立配置,这也是任务预设中被纠正的说法。
  2. 非 bleve 后端才有 CONN_STR:loadIndexerFrom 里 ISSUE_INDEXER_PATH 只在 IssueType == "bleve" 时解析,ISSUE_INDEXER_CONN_STR 只在非 bleve 时读取——配错组合不会报错,只是静默用默认值。
  3. Meili 的 API key 藏在 URL 里:IssueType == "meilisearch" 时,loadIndexerFrom 解析 ISSUE_INDEXER_CONN_STR 后执行 Indexer.IssueConnAuth, _ = u.User.Password(),把 userinfo 中的密码摘出来存进 IssueConnAuth,再把 URL 清洗成不含凭据的形式(形如 http://API_KEY@127.0.0.1:7700)。ES 分支还会用 util.SanitizeCredentialURLs 把连接串打码后才写日志——凭据不进日志是硬约束,这也让 app.ini 的文件权限(0600)变得更重要。
  4. 字段名有个源码拼写彩蛋:Go 字段是 TypeBleveMaxFuzzniess(Fuzziness 少个 i),配置键 TYPE_BLEVE_MAX_FUZZINESS 却是正确的——读代码时别被字段名误导。

旧版 [indexer].ISSUE_INDEXER_QUEUE_TYPE/QUEUE_DATADIR/… 已移除:modules/setting/queue.go 读到这些键会 log.Fatal,错误信息明确要求迁移——" please use [queue.issue_indexer] TYPE=level etc."。从 Gitea 1.19 之后的版本升级时,改 ini 的第一个动作就是把队列配置搬到 [queue.issue_indexer]。

11.8.2 队列子节:[queue.issue_indexer] 等

索引队列是普通队列的三个实例,键与第 10 章的队列配置完全一致(modules/setting/queue.go 的 GetQueueSettings 默认值):

键默认值说明
TYPElevel底层队列:level(leveldb 持久化)/ channel / redis / persistable-channel / immediate(仅测试)
DATADIR%(APP_DATA_PATH)s/queues/issue_indexerlevel 队列落盘目录
LEVEL_NAME空level 队列的子目录名
QUEUE_NAMEinternal / persistable-channel 用redis 的 stream 名
CONN_STR空redis 连接串
BATCH_LENGTH20worker 单次取多少条(与 bleve 的 16 批次配合)
MAX_WORKERSCPU 数 × 2,裁剪到 [1, 10]索引 worker 并发
MAX_ACTIVE_WORKERS同上活跃 worker 上限
KEEP_ALIVE / MAX_RETRY / RETENTION 等见第 10 章通用队列参数

推荐配置(按实例规模):

; 小实例(默认即可,bleve + gitgrep)
[indexer]
ISSUE_INDEXER_TYPE = bleve
REPO_INDEXER_ENABLED = false

; 中型实例(万级 issue,想开代码索引)
[indexer]
REPO_INDEXER_ENABLED = true
REPO_INDEXER_EXCLUDE = *.min.js, *.map, testdata/, vendor/
[queue.issue_indexer]
TYPE = level            ; 持久化,重启不丢待索引变更
MAX_WORKERS = 4

; 大实例 / 多 worker(ES 后端)
[indexer]
ISSUE_INDEXER_TYPE = elasticsearch
ISSUE_INDEXER_CONNSTR = http://es-host:9200
ISSUE_INDEXER_NAME = gitea_issues
REPO_INDEXER_ENABLED = true
REPO_INDEXER_TYPE = elasticsearch
[queue.issue_indexer]
TYPE = redis            ; 跨重启、可水平扩容的共享队列
CONN_STR = redis://:pass@host:6379/0

11.8.3 cron 配置:rebuild_issue_indexer

custom/conf/app.example.ini:2610 附近定义了索引重建的 cron:

[cron.rebuild_issue_indexer]
ENABLED = false
SCHEDULE = @annually
; PULL_LIMIT / PUSH_LIMIT 等批量参数见源码

对应注册代码在 services/cron/tasks_extended.go:230 的 registerRebuildIssueIndexer——任务体就是 issue_indexer.PopulateIssueIndexer(全量遍历仓库、逐 issue 入队)。默认 ENABLED=false、@annually:这是一条"保底"任务,日常增量由 notifier 队列维护,只有出现历史数据迁移、手动清库、或怀疑索引漂移时才需要打开它跑一次。cron 任务本身是第 10 章队列的另一类消费者(cron 模块把任务丢进 admin 队列执行)。

11.9 索引一致性与运维:落后、重建与多实例

11.9.1 索引落后的三种形态与对策

"索引落后"在 Gitea 里有三种不同的病因,对策各异:

形态一:队列积压(瞬时落后)。push/编辑高峰或 ES 短暂不可用时,issue_indexer 队列长度增长,变更在 base queue(level 持久化)里排队。对策:无需干预——ES 恢复后 handler 的 unhandled 回队机制自动重试追平;运维可从管理页监控队列长度(第 10 章的队列监控),持续增长才需查 ES。bleve 后端下这种形态几乎不存在(本地磁盘写,失败即异常)。

形态二:索引丢失/损坏(硬落后)。bleve 目录损坏、误删、版本升级触发 os.RemoveAll 重建——InitIssueIndexer 发现 exists=false,自动 go PopulateIssueIndexer(ctx) 全量重建。期间搜索结果不全,IssueIndexerUnavailable 提示会亮。重建耗时取决于 issue 总量(分页遍历 + 每批入队),万级 issue 通常分钟级。运维要点:重建期间不要关服,populate 是可重入的(重复跑只是重复入队,unique queue 去重)。

形态三:逻辑漂移(隐性落后)。数据在索引外被改动——直接改数据库(绕过 Gitea API)、恢复备份、从别的实例导入数据——队列从未收到事件,索引与库永久不一致。对策就是 11.6.6 那张重建表:跑一次 rebuild_issue_indexer cron(issue 侧)、对每个仓库点 reindex 或清 repo_indexer_status(code 侧)。doctor 帮不上忙:services/doctor 注册的检查项(gc-lfs、delete-orphaned-repos、hooks、recalculate-stars-number、check-git-daemon-export-ok、check-commit-graphs、storages、check-db-consistency)全部与索引无关——grep 验证 services/doctor 下无任何 index 检查。索引一致性被设计为"由机制保证"(unique queue + 水位线 + 懒删除),而不是"由巡检发现"。

11.9.2 重建流程速查

按影响范围从大到小:

flowchart TD
    A[需要重建索引] --> B{范围?}
    B -->|全站 issue| C[停服或直接启用<br/>cron.rebuild_issue_indexer<br/>跑一次 PopulateIssueIndexer]
    B -->|全站 issue 硬重建| D[停服 → 删 data/indexers/<br/>issues.bleve/ → 重启<br/>Init 自动 populate]
    B -->|全站 code| E[清空 repo_indexer_status 表<br/>→ 入队自动 genesis 全量]
    B -->|单仓库 code/stats| F[仓库 Settings → admin 区<br/>handleSettingsPostAdminIndex<br/>选择 code 或 stats]
    C --> G[unique queue 去重<br/>增量事件自动合并]
    D --> G
    E --> G
    F --> G

一个常见疑问:"重建期间站内搜索还能用吗?"——能,且是安全的。populate 是往索引里补文档,不是先清空:bleve/ES 的 Index 是 upsert 语义,重建过程中查询持续可服务,只是新文档陆续加入、结果逐渐变全;真正清空的瞬间只在"删除目录后重启"这种硬重建里,此时 Init 返回 false,populate 在后台跑,UI 用 IssueIndexerUnavailable 如实告知。

11.9.3 多实例共享索引:能与不能

Gitea 支持多 web 实例(第 10 章的水平扩容场景),索引配置随实例的 app.ini 走,由此产生三种拓扑:

拓扑一:共享外部索引(推荐)。所有实例配同一个 ISSUE_INDEXER_CONNSTR(ES/Meili 集群)+ 同一个 ISSUE_INDEXER_NAME。索引天然共享,任一实例收到的编辑事件都会更新共享索引,查询也落在同一份数据上。前提是队列也必须共享:[queue.issue_indexer] TYPE=redis——否则 A 实例的编辑入了 A 的本地 level 队列,B 实例的搜索查共享索引却等不到更新,一致性边界错位。TYPE=redis 的队列(第 10 章)让事件只被消费一次,多实例安全。

拓扑二:每个实例本地 bleve(不可行,勿配)。两个实例各写各的 issues.bleve:A 实例的编辑事件进 A 的本地队列只更新 A 的索引,B 实例搜索永远查不到新数据;更糟的是两个进程同时写同一个共享磁盘上的 bleve 目录会直接损坏索引。bleve 后端本质是单实例设计(数据在本地文件系统),横向扩容时要么切 ES/Meili,要么让搜索粘滞性路由(同一用户始终打到同一实例——但 Gitea 没有实现这种路由,所以实际不可用)。

拓扑三:共享磁盘上的 bleve(理论可行,不推荐)。NFS 类共享存储上放同一个 ISSUE_INDEXER_PATH:bleve 不保证跨进程文件锁的正确性,多写者场景存在 segment 写坏的风险;即便只读副本 + 单写者,网络文件系统的锁延迟也会拖慢 FlushingBatch.Flush。结论与官方立场一致:多实例请用 ES/Meili + redis 队列。

把三要素归纳成一条运维法则:索引后端、队列存储、实例数量三者必须"同分"——单实例 + 本地队列 + bleve 是默认甜点;多实例必须外部索引 + 共享队列;任何"外部索引 + 本地队列"或"本地索引 + 多实例"的组合都是隐性 bug。

11.9.4 一致性机制清单(源码级核对表)

把本章散落的机制收拢成一张核对表,方便做二次开发时逐项验证:

机制源码位置解决的问题
unique queue 去重issues/util.go:ErrAlreadyInQueue 忽略高频编辑同一 issue 时队列爆炸
notifier 懒删除notify.go 无 DeleteIssue + handler 查不到即 Delete删除事件与更新事件的竞态
handler 失败回队getIssueIndexerQueueHandler 返回 unhandledES 宕机期间变更不丢
code 水位线repo_indexer_status.commit_sha + handler return nilcode 索引失败自愈、防重试风暴
merge-base 全量回退getRepoChanges 的 needGenesisforce push/回滚留下幽灵文档
默认分支门槛PushCommits 只认 DefaultBranchfeature 分支代码污染索引
版本号重建rupture_meta.json / .v{N} 索引名schema 变更后旧数据误命中
[0] 哨兵SearchIssuesRepoIDs空权限集被解释成"全部可见"
SQL 精筛回表FindWithIssueOptions索引一致性窗口 + 结构化过滤
短路快路SearchIssues 的空/数字 keyword 分支无文本条件时绕开全文引擎
StartupTimeoutSTARTUP_TIMEOUT=30s启动期索引未就绪时的 UI 降级

11.10 架构总览与源码级问答

11.10.1 索引架构全景图

flowchart LR
    subgraph 写入侧
        E1[issue 编辑/评论/状态变更] --> N[indexerNotifier<br/>services/indexer/notify.go]
        E2[git push 默认分支] --> N
        E3[push/删除仓库] --> N
        N -->|UpdateIssueIndexer| Q1[(unique queue<br/>issue_indexer)]
        N -->|UpdateRepoIndexer| Q2[(unique queue<br/>code_indexer)]
        N -->|repo_stats_update| Q3[(queue<br/>repo_stats_update)]
        Q1 --> H1[handler: 批量拉 issue<br/>getIssueIndexerData<br/>失败回队 unhandled]
        Q2 --> H2[handler: getRepoChanges<br/>ls-tree/diff/cat-file<br/>失败不回队, 水位线兜底]
        Q3 --> H3[DBIndexer: 重算语言占比]
    end

    subgraph 索引后端
        H1 --> B1{ISSUE_INDEXER_TYPE}
        B1 -->|bleve| I1[本地目录<br/>issues.bleve + rupture_meta.json]
        B1 -->|elasticsearch| I2[&#34;gitea_issues.v4&#34;]
        B1 -->|meilisearch| I3[&#34;gitea_issues_v6&#34;]
        B1 -->|db| I4[(数据库本身<br/>Index/Delete 空操作)]
        H2 --> B2{REPO_INDEXER_TYPE}
        B2 -->|bleve| I5[repos.bleve]
        B2 -->|elasticsearch| I6[&#34;gitea_codes.v…&#34;]
        H3 --> I7[(language_stat 表)]
    end

    subgraph 查询侧
        UI1[issue 列表/搜索页<br/>API issues/search] --> S1[SearchIssues<br/>空/数字keyword短路db]
        UI1 --> S2[SearchIssuesRepoIDs<br/>SQL预计算权限 RepoIDs]
        S1 --> R1[召回 IDs]
        R1 --> R2[FindWithIssueOptions<br/>SQL 精筛/排序/分页]
        UI2[仓库 Code 页<br/>explore/code] --> C1{REPO_INDEXER_ENABLED?}
        C1 -->|true| C2[code_indexer.Search<br/>boost 文件名/facet/行号]
        C1 -->|false 默认| C3[gitgrep → git.GrepSearch<br/>30s超时/50条上限]
        UI3[explore repos/users<br/>仓库列表页] --> L[SQL LIKE<br/>SearchRepository/SearchUsers]
    end

11.10.2 源码级问答

Q1:为什么 issue 索引要四种后端,代码索引只有两种?

issue 文档是"结构化字段 + 短文本",任何全文引擎都能表达,db 后端甚至用 LIKE 就够——它的本质是"标签集合上的召回问题"。代码搜索则要求三项能力:按命中位置返回行号(bleve Locations / ES highlight)、文件名字段独立加权(boost=10)、语言 facet 聚合——Meili 的检索模型(filter + q 字符串)难以表达行级位置,SQL 更无从谈起。接口一致(都实现 code/internal.Indexer)不代表能力等价,SupportedSearchModes 就是能力自描述的出口。

Q2:SearchIssues 为什么先查权限 RepoIDs 再查索引,而不是给每个文档存 is_private + repo_id 让索引自己过滤?

11.5.2 节给了写放大、terms 规模、哨兵三个理由,再补一个代码层的:SearchOptions 里的过滤条件(标签、里程碑、负责人)本来就是索引召回 + SQL 精筛的两段式,权限放在 SQL 预计算这一步,使所有过滤条件都收敛在同一处(dboptions.go 的 ToSearchOptions 把 IssuesOptions 统一翻译成 SearchOptions),后端无关。若权限塞进索引文档,四种后端就要各自实现一遍第 5 章的 ACL 语义(bleve 的 bool query、ES 的 terms、Meili 的 filter、SQL 的 JOIN),漂移风险翻四倍。

Q3:为什么编辑后搜索偶尔还是旧结果?多久追平?

正常情况下毫秒级:HTTP 响应返回前 notifier 只完成了入队,索引更新在后台,level 队列默认 worker 立即消费。变慢的三个排查点:(1) [queue.issue_indexer] 的 worker 数(MAX_WORKERS 默认 CPU×2 裁到 10)被其他队列任务挤占——注意 issue/code/stats 三个队列共享 worker 池的调度;(2) ES 后端网络故障——队列会增长,管理页看队列长度;(3) 搜索词是新词而 analyz er 正在重建(版本升级后 populate 未完成)。若长期不追平,按 11.9.1 的三形态对号入座。

Q4:gitea doctor 能检查索引吗?

不能。cmd/doctor.go 只注册 check、recreate-table、convert 三个子命令,services/doctor 的检查项清单(gc-lfs、delete-orphaned-repos、hooks、recalculate-stars-number、check-git-daemon-export-ok、check-commit-graphs、storages、check-db-consistency)无一涉及索引。这是设计取舍:索引状态是"最终一致"的,瞬时不一致不算故障,doctor 的二值检查(正常/异常)无法表达"落后 N 条、追平中";真正的自愈靠 unique queue 重试与水位线。判断索引健康请看管理页队列监控与搜索结果完整性,而不是 doctor。

Q5:搜索一条 issue,权限过滤和全文匹配的执行顺序是什么?

顺序是"权限先行,全文后行":SearchIssuesRepoIDs(SQL,第 5 章规则)算出可见 repoIDs → 作为 terms 条件进入索引查询 → 索引只在权限集合内做文本匹配 → 命中 IDs 回 SQL 精筛(此时再次 JOIN repository 天然是二次权限复核)。这个顺序不能倒——若先全文匹配再过滤,索引要把全库命中(可能十万级)吐给 SQL 才裁剪,深分页时 count 也不准;权限先行则返回的 count 直接就是"你可见的命中数",分页器总数才正确。

Q6:为什么代码索引失败不回队,issue 索引失败必须回队?

数据形态决定策略。issue 索引的队列项是"某条 issue 的最终状态",没有外置的对账基准,丢了就永久丢——必须留在队列里重试(unhandled)。code 索引的队列项只是"某仓库该更新了"这个提示,真正的状态在 repo_indexer_status.commit_sha:处理失败则 SHA 不动,仓库下次任何 push 都会重新触发 getRepoChanges 并覆盖失败区间——状态表就是天然的重试队列,且重算的是"当前应然状态"而非"失败时的旧状态",比盲目重放旧队列项更正确。若给 code 也做 unhandled 回队,一个坏仓库(比如含畸形路径触发 panic)会卡死整个队列反复重试,这正是 handler 里 return nil // do not re-queue the failed items 注释的用意。

Q7:把 [indexer] ISSUE_INDEXER_TYPE=db 后,还有队列吗?

有,且依然必要。db 后端的 Index/Delete 是空操作,但 handler 的执行链(拉 issue、组装数据、失败回队)不变;更重要的是 CountIssues、Dashboard 统计、SearchIssues 短路都统一走 internal.Indexer 接口——保留队列让"以后切回 bleve"只是改配置重启,不需要任何数据迁移。db 模式下队列的开销接近零(handler 主要是无效的 DB 读),可忽略。

11.10.3 本章小结

回到 11.1 的三层结构,用三句话收束全章:

  1. 抽象层(internal.Indexer + 领域接口)把"索引存在与否"(Init 的 bool)和"搜什么/怎么搜"(SupportedSearchModes)自描述给上层,四种 issue 后端、两种 code 后端对上层完全透明;
  2. 更新层是第 10 章队列的最大受益者——notifier 只入队、unique queue 去重、handler 批量消费、失败回队或靠水位线自愈,所有一致性设计都建立在"索引是数据库的异步投影"这一不变式上;
  3. 查询层坚持"索引召回 + SQL 精筛"的两段式,权限在 SQL 预计算([0] 哨兵 fail-closed),空/数字关键词短路——全文引擎始终被当作加速器而非唯一真理来源,这正是代码搜索默认用 git grep、仓库/用户搜索干脆不建索引的同一哲学。

下一章(第 12 章)将进入通知与 Webhook 体系——它与本章的 indexerNotifier 挂在同一条事件流上,读过本章的 notifier 分发表后,第 12 章的订阅模型会非常眼熟。