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/bleve | exact words fuzzy | SearchModesExactWordsFuzzy() |
| issues/elasticsearch | exact words | SearchModesExactWords() |
| issues/meilisearch | exact words | 同上 |
| issues/db | exact words(words 拆词后逐词 LIKE AND) | 同上 |
| code/bleve、code/elasticsearch | exact words | 同上 |
| gitgrep(索引关闭时) | exact words regexp | GitGrepSupportedSearchModes() |
一个反直觉的结论: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(认领仓库=迁入)
几个值得注意的设计细节:
- 删评论也走
UpdateIssueIndexer而不是DeleteIssueIndexer。因为评论删除后 issue 本身还在,handler 会重新拉取 issue 数据重建该文档——"更新"天然覆盖"删除了某段评论文本"的场景。 - 没有覆写
DeleteIssue(grepnotify.go确认无此方法)。issue 被删除后索引怎么清?答案在队列 handler 里:处理时发现 issue 已不存在就执行删除(11.4 节代码可见)。这是"懒删除"策略——依赖索引数据源(数据库)是唯一事实源,索引只是投影。 PushCommits只认默认分支。索引内容以默认分支为准,往 feature 分支推代码不触发索引,避免索引里出现从未被 merge 的临时代码。- 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、路由层)完全不感知后端类型,切换后端只是改一行配置。各后端的差异被压到三个层面:
- 连接与初始化:bleve 打开本地目录;ES 用
IssueConnStr(如http://localhost:9200)建 HTTP 客户端;Meili 从 URL 里提取 API key(配置解析见 11.8 节);db 直接单例返回。 - 文档 ID 与版本化命名:bleve 用
Base36(issueID)作文档 ID 且靠rupture_meta.json管版本;ES 索引名带.v{N}后缀;Meili 用_v{N}(因为它不允许索引名含.)。 - 查询 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/search | repo.SearchIssues(issue_list.go) | ✅ 有 keyword 时 |
| Dashboard 我的 Issue | GET /issues | user_home.go → buildIssueOverview | ✅(含统计计数) |
| 仓库 Issue/PR 列表 | GET /:username/:reponame/issues | prepareIssueFilterAndList | 有 keyword 才走 |
| API 搜索 | GET /api/v1/repos/{owner}/{repo}/issues/search | api/v1/repo/issue.go:SearchIssues | ✅ |
| API 列表 | GET /api/v1/repos/{owner}/{repo}/issues | api/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置空,跳过一次GetIssueStatsSQL。 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
}
这段代码值得逐分支拆开看,它其实是三层递进的权限裁剪:
- 完全免列举的快路(
allPublic && !Private):未登录用户(Private = opts.Doer != nil为 false)只可能看见公开仓库,此时连 SQL 都不查,直接返回[0]哨兵 +allPublic=true——索引侧见到allPublic就只按IsPublic字段过滤,连terms条件都不拼。这条快路同时是性能优化(省一次全表条件查询)和语义澄清("哨兵 + 捷径"组合仍然 fail-closed)。 - 实例规模可控时的枚举(
allPublic && Private):登录用户要枚举"公开仓库 ∩ 所有者可见",NotPublicRepoUnderPublicOwnerCond()被 And 进来,注释直白说明动机——enumerating them scales with the instance(列举的代价随实例增长,所以要限定在"公开且 owner 可见"这一较小集合)。 - 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 字段?三个原因:
- 权限是多维的(私有仓库成员、组织仓库的团队权限、协作者、public/limited/private 可见性……),第 5 章那套规则如果翻译成索引字段,每次权限变更(改团队、加协作者)都要触发相关仓库所有 issue 文档的重写,写放大不可接受;而 SQL 预计算是查询时实时算的,权限变更立即生效,零索引维护成本。
- 召回集合可能很大:全站公开实例上,
repoIDs可能上万。bleve/ES 的 terms 查询支持上万 term(有max_terms_count上限,默认 65536),可接受;真正的大实例有allPublic捷径——全部公开仓库可免去逐个列举。 [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
}
这段代码是"索引侧到底做了什么"的最完整答案,六组语义值得拆解:
- 关键词的三字段并集:
title/content/comments各自一个子查询再Disjunction——搜 "登录超时" 命中标题、正文、评论任何一处即可。words/fuzzy 走MatchAndQuery(多词 AND),exact 走MatchPhraseQuery(短语匹配);模糊度只在 fuzzy 模式下由GuessFuzzinessByKeyword计算(11.2.5),words 模式恒为 0。 - 权限块与
AllPublic捷径同处一格:RepoIDs逐个NumericEqualityQuery再 Disjunction(IN语义),AllPublic时追加一个BoolFieldQuery(true, "is_public")——这正是 11.5.2 预言的"公开捷径"在查询树上的样子:权限不过滤也不是"无约束",而是 OR 进一条is_public分支,与"按仓库列举"并列求或,语义上覆盖"公开仓库 ∪ 我可见的私有仓库"。 - 顶层是"所有条件 AND":
NewConjunctionQuery(queries...)把关键词、权限、状态、标签、里程碑、项目、发布者、负责人、提及、评审、订阅、时间范围全部收进一个大 AND;len(queries)==0时退化为MatchAllQuery(纯列表场景)。这就是 11.1.2 说的"过滤条件在索引侧被扁平化"——每个条件独立 append,没有嵌套分组。 - 标签的三种语义用三种组合子表达:
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 精筛阶段会再纠正一次。 - 字符串哨兵约定:
PosterID的"(none)"解析成 0(no poster),AssigneeID的"(any)"/"(none)"分别查no_assignee=false/true——"无负责人/任意负责人"这类语义没有自然的数字 ID,用保留字符串从路由层一路传到索引层再翻译,strconv.ParseInt的err被显式丢弃(解析失败得 0,正好落在"无此人"语义上,可算是将错就错的防御)。 - 稳定排序与 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_indexer | code_indexer |
|---|---|---|
| 队列项 | IndexerMetadata{ID, IsDelete, IDs} | IndexerData{RepoID}(只有意图) |
| 失败回队 | ✅ 逐项 unhandled | ❌ return nil,靠 repo_indexer_status SHA 自愈 |
| 数据对账基准 | 无(库即索引内容,逐条重拉) | 有(commit_sha 水位线) |
| 坏数据的风险 | 影响单条 | 影响整仓 → 若回队会卡死队列 |
| 全量重建 | PopulateIssueIndexer 分页遍历仓库再遍历 issue | populateRepoIndexer 清 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 → 自动 populate | issue 索引(InitIssueIndexer)与 code 索引(清 status 后) |
cron rebuild_issue_indexer | services/cron/tasks_extended.go 注册,默认 @annually、ENABLED=false | issue 索引全量重建(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 LIKE | explore/repos、仓库列表页 | 短标识符,SQL 足够 |
| 用户名/邮箱 | 用户数 | SQL LIKE | explore/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_TYPE | bleve | issue 索引后端: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_NAME | gitea_issues | ES/Meili 中的索引名(版本化后为 gitea_issues.v4 / gitea_issues_v6) |
REPO_INDEXER_ENABLED | false | 代码索引总开关;false 时仓库代码搜索走 git grep、explore/code 入口隐藏 |
REPO_INDEXER_REPO_TYPES | sources,forks,mirrors,templates | 哪几类仓库参与代码索引(逗号分隔) |
REPO_INDEXER_TYPE | bleve | 代码索引后端:bleve / elasticsearch(无 meilisearch、无 db) |
REPO_INDEXER_PATH | %(APP_DATA_PATH)s/indexers/repos.bleve | 代码索引 bleve 目录 |
REPO_INDEXER_CONN_STR | 空 | 代码索引 ES 地址 |
REPO_INDEXER_NAME | gitea_codes | 代码索引名 |
REPO_INDEXER_INCLUDE | 空 | 纳入索引的文件 glob(逗号分隔,会被转小写匹配) |
REPO_INDEXER_EXCLUDE | 空 | 排除文件 glob(如 *.min.js, testdata/) |
REPO_INDEXER_EXCLUDE_VENDORED | true | 是否排除 vendor/ 目录文本 |
MAX_FILE_SIZE | 1048576(1024 * 1024) | 超过此大小的文件连文档一起从索引删除(不入正文也不可按文件名搜,见 11.6.5) |
STARTUP_TIMEOUT | 30s | 索引初始化等待超时,超时 log.Fatal(见 11.4.5) |
TYPE_BLEVE_MAX_FUZZINESS | 0 | bleve 模糊匹配最大编辑距离;0=关闭 fuzzy,建议最多 2 |
四个易错点:
ISSUE_INDEXER_TYPE不是TYPE,连接串键是CONN_STR(带下划线)。没有[indexer] TYPE=…这种总开关——issue 和 code 是两套独立配置,这也是任务预设中被纠正的说法。- 非 bleve 后端才有
CONN_STR:loadIndexerFrom里ISSUE_INDEXER_PATH只在IssueType == "bleve"时解析,ISSUE_INDEXER_CONN_STR只在非 bleve 时读取——配错组合不会报错,只是静默用默认值。 - 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)变得更重要。 - 字段名有个源码拼写彩蛋: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 默认值):
| 键 | 默认值 | 说明 |
|---|---|---|
TYPE | level | 底层队列:level(leveldb 持久化)/ channel / redis / persistable-channel / immediate(仅测试) |
DATADIR | %(APP_DATA_PATH)s/queues/issue_indexer | level 队列落盘目录 |
LEVEL_NAME | 空 | level 队列的子目录名 |
QUEUE_NAME | internal / persistable-channel 用 | redis 的 stream 名 |
CONN_STR | 空 | redis 连接串 |
BATCH_LENGTH | 20 | worker 单次取多少条(与 bleve 的 16 批次配合) |
MAX_WORKERS | CPU 数 × 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 返回 unhandled | ES 宕机期间变更不丢 |
| code 水位线 | repo_indexer_status.commit_sha + handler return nil | code 索引失败自愈、防重试风暴 |
| merge-base 全量回退 | getRepoChanges 的 needGenesis | force push/回滚留下幽灵文档 |
| 默认分支门槛 | PushCommits 只认 DefaultBranch | feature 分支代码污染索引 |
| 版本号重建 | rupture_meta.json / .v{N} 索引名 | schema 变更后旧数据误命中 |
[0] 哨兵 | SearchIssuesRepoIDs | 空权限集被解释成"全部可见" |
| SQL 精筛回表 | FindWithIssueOptions | 索引一致性窗口 + 结构化过滤 |
| 短路快路 | SearchIssues 的空/数字 keyword 分支 | 无文本条件时绕开全文引擎 |
| StartupTimeout | STARTUP_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["gitea_issues.v4"]
B1 -->|meilisearch| I3["gitea_issues_v6"]
B1 -->|db| I4[(数据库本身<br/>Index/Delete 空操作)]
H2 --> B2{REPO_INDEXER_TYPE}
B2 -->|bleve| I5[repos.bleve]
B2 -->|elasticsearch| I6["gitea_codes.v…"]
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 的三层结构,用三句话收束全章:
- 抽象层(
internal.Indexer+ 领域接口)把"索引存在与否"(Init的 bool)和"搜什么/怎么搜"(SupportedSearchModes)自描述给上层,四种 issue 后端、两种 code 后端对上层完全透明; - 更新层是第 10 章队列的最大受益者——notifier 只入队、unique queue 去重、handler 批量消费、失败回队或靠水位线自愈,所有一致性设计都建立在"索引是数据库的异步投影"这一不变式上;
- 查询层坚持"索引召回 + SQL 精筛"的两段式,权限在 SQL 预计算(
[0]哨兵 fail-closed),空/数字关键词短路——全文引擎始终被当作加速器而非唯一真理来源,这正是代码搜索默认用git grep、仓库/用户搜索干脆不建索引的同一哲学。
下一章(第 12 章)将进入通知与 Webhook 体系——它与本章的 indexerNotifier 挂在同一条事件流上,读过本章的 notifier 分发表后,第 12 章的订阅模型会非常眼熟。