前言
通过分析Gitlab的站内搜索设计,借鉴其设计经验,来改进自己的站内搜索方案,包括领域对象划分,索引设计,权限控制设计。
这可能是国内第一篇详细解剖Gitlab站内搜索设计实现的文章。
基础背景
Gitlab的免费版本采用的是Postgresql的FTS(full text search)进行搜索。
Gitlab的白金版本才支持基于Elasticsearch的高级搜索(可以申请30天的试用license体验)
Gitlab的领域对象关系
Gitlab的索引设计
gitlab的ES索引结构
gitlab会在ES内部建立如下索引
- gitlab-production
- gitlab-production-commits
- gitlab-production-issues
- gitlab-production-merge_requests
- gitlab-production-migrations
- gitlab-production-notes
- gitlab-production-users
gitlab-production是Project的索引
整个索引的mapping采用大宽表的设计,不同领域对象的字段都会平铺在一起,具有相同的document-type (ES6/Lucene7已经解决大宽表下sparse field所导致的空间浪费问题 Space Saving Improvements in Elasticsearch 6.0 | Elastic Blog)
(该索引有 project, blob, milestone, snippet, wiki_blob 领域对象)(blob是Binary Large Object的意思,这里特指Code)
它通过join的方式,将project和它的子对象建立父子关系,以便于做到权限控制
"join_field" : {
"type" : "join",
"eager_global_ordinals" : true,
"relations" : {
"project" : [
"note",
"blob",
"issue",
"milestone",
"wiki_blob",
"commit",
"merge_request"
]
}
}
关于索引的设计取舍,参考2019年gitlab的分享。
https://download.csdn.net/download/yyw794/88646899
注意:
根据Gitlab的官方描述,他们是更倾向不同领域对象采用不同的索引设计,因为搜索速度更快,重建索引更快等(也是ES官方推荐的方式)。
Gitlab采用不同领域对象放在1个索引里,仅仅是为了解决权限控制的问题。
子索引分析
索引字段一般分为3部分:
- 对象id信息(用于唯一定位)
- 对象基本信息(用于搜索和显示)
- 对象父信息(用于权限控制)
以issue的索引gitlab-production-issues 为例:
对象id信息部分
issue对象包含子对象task(work_item)
索引采用的是平铺的设计层次。
每个对象包含id和iid(子对象id)和type(对象类型)3个值,来唯一定义这个对象。
对象基本信息
(略)
对象父信息
每个子索引的对象都有3个字段进行权限控制:
- visibility_level(project的权限,父父对象权限)
- xxx_access_level(父对象的权限 )
- 父对象id
备注:
子对象不一定都有id,例如commit对象,就采用唯一的sha作为id(_id=${project_id}_${sha})
merge_request索引虽然有iid,但是没有发现其有子对象
merge_request有hashed_root_namespace_id字段
数据同步
gitlab的insert, update, delete操作会推送到redis的zset中(作为queue使用)。
redis的zset是有序集合,可以有效防止减少重复消息,提高ES的写效率。
sidekiq(ruby领域的异步框架)周期采用bulk api批量写ES,提高ES的写性能和保障ES集群的整体性能。(参考:Keeping Elasticsearch in Sync | Elastic Blog)
搜索分析
子对象的搜索
{
"from": 0,
"size": 20,
"timeout": "30s",
"query": {
"bool": {
"must": [
{
"simple_query_string": {
"query": "领域驱动",
"fields": [
"title^2.0",
"description^1.0"
],
"flags": -1,
"default_operator": "and",
"lenient": true,
"analyze_wildcard": false,
"auto_generate_synonyms_phrase_query": true,
"fuzzy_prefix_length": 0,
"fuzzy_max_expansions": 50,
"fuzzy_transpositions": true,
"boost": 1.0,
"_name": "milestone:match:search_terms"
}
}
],
"filter": [
{
"term": {
"type": {
"value": "milestone",
"boost": 1.0,
"_name": "doc:is_a:milestone"
}
}
},
{
"has_parent": {
"query": {
"bool": {
"should": [
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 0,
"boost": 1.0,
"_name": "milestone:related:project:any"
}
}
},
{
"terms": {
"issues_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:issues:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0
}
},
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 0,
"boost": 1.0,
"_name": "milestone:related:project:any"
}
}
},
{
"terms": {
"merge_requests_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:merge_requests:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0
}
},
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 10,
"boost": 1.0,
"_name": "milestone:related:project:visibility:10"
}
}
},
{
"terms": {
"issues_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:visibility:10:issues:access_level:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0,
"_name": "milestone:related:project:visibility:10:issues:access_level"
}
},
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 10,
"boost": 1.0,
"_name": "milestone:related:project:visibility:10"
}
}
},
{
"terms": {
"merge_requests_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:visibility:10:merge_requests:access_level:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0,
"_name": "milestone:related:project:visibility:10:merge_requests:access_level"
}
},
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 20,
"boost": 1.0,
"_name": "milestone:related:project:visibility:20"
}
}
},
{
"terms": {
"issues_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:visibility:20:issues:access_level:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0,
"_name": "milestone:related:project:visibility:20:issues:access_level"
}
},
{
"bool": {
"filter": [
{
"term": {
"visibility_level": {
"value": 20,
"boost": 1.0,
"_name": "milestone:related:project:visibility:20"
}
}
},
{
"terms": {
"merge_requests_access_level": [
20,
10
],
"boost": 1.0,
"_name": "milestone:related:project:visibility:20:merge_requests:access_level:enabled_or_private"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0,
"_name": "milestone:related:project:visibility:20:merge_requests:access_level"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0
}
},
"parent_type": "project",
"score": false,
"ignore_unmapped": false,
"boost": 1.0,
"_name": "milestone:related:project"
}
}
],
"adjust_pure_negative": true,
"boost": 1.0
}
},
"highlight": {
"pre_tags": [
"gitlabelasticsearch→"
],
"post_tags": [
"←gitlabelasticsearch"
],
"number_of_fragments": 0,
"fields": {
"title": {},
"description": {}
}
}
}
搜索分为2部分:
- 搜索关键字逻辑(采用simple_query_string,AND逻辑,开启模糊查询)
- 过滤逻辑(类型为子对象类型(如milestone),且project(父对象)在权限范围内)
子对象issue搜索示例
"_source" : {
"id" : 2,
"iid" : 1,
"title" : "搜索支持相似问",
"description" : "打开FAQ搜索时,需要加入相似问字段的搜索",
"created_at" : "2023-04-14T08:28:38.119Z",
"updated_at" : "2023-04-14T08:28:38.119Z",
"state" : "opened",
"project_id" : 3,
"author_id" : 3,
"confidential" : false,
"schema_version" : 2302,
"assignee_id" : [
3
],
"hidden" : false,
"visibility_level" : 0,
"issues_access_level" : 10,
"upvotes" : 1,
"namespace_ancestry_ids" : "8-",
"label_ids" : [
"2"
],
"type" : "issue"
}
凡是涉及其他对象的字段,全部使用引用对象的id (例如,标签label_ids,存储的是标签的id,而不是具体的值)
但这样有一个问题,导致了无法做到标签搜索。(例如,给一个issue添加知识库的标签,搜索 知识库,并不能搜到 这个issue)
iid为issue的子对象。
issue本身的iid为1,添加其他子对象,iid依次递增的分配。(例如,新建task,task的iid为2,task的type为work_item)
并不是所有的issue属性都会同步到es中,例如,issue的评论,虽然包含文字,但是没有同步到es索引中。(评论的重要性低,加入搜索范围,可能会加大搜索结果噪音)
visibility_level 代表project的可见性
issues_access_level 代表issue的可访问性
权限过滤逻辑为(或的关系):
- 查询有权限的project 且 issue的权限为可被访问
- 项目为登录用户可见 且 issue可被所有人访问
- 项目可被所有人可见 且 issue可被所有人访问
(作者注:可以优化为 issue可被所有人访问(20) 或 (issue需要有权限才能访问(10) 且 具有该项目的权限)
权限控制设计
权限分为
- private(0)
- internal(10)
- public(20)
后面的数字代表ES里的对应权限的值(不存字符串,而是存数字,且数字采用了10的间隔,猜测为了考虑未来在中间插入的拓展)
project和它子对象都有自己独立的权限值。
project在gitlab-production中的结构为:
"_source" : {
"id" : 3,
"name" : "eim-search",
"path" : "eim-search",
"description" : null,
"namespace_id" : 8,
"created_at" : "2023-04-14T02:53:42.747Z",
"updated_at" : "2023-04-14T02:53:44.471Z",
"archived" : false,
"visibility_level" : 0,
"last_activity_at" : "2023-04-14T02:53:42.747Z",
"name_with_namespace" : "platform / eim-search",
"path_with_namespace" : "platform/eim-search",
"join_field" : "project",
"type" : "project",
"schema_version" : 2301,
"traversal_ids" : "8-p3-",
"issues_access_level" : 20,
"merge_requests_access_level" : 20,
"snippets_access_level" : 20,
"wiki_access_level" : 20,
"repository_access_level" : 20
}
namespace_id 就是group的id(namespace=group)
traversal_ids 通过namespace_id和project_id 拼接而成
join_field 和 type 虽然都被赋予了相同的值,但是作用不一样。
join_field 是用于has parent query,在这个query里,充当parent_type的值
type只是本身的对象属性
user在gitlab-production-users的结构为:
"_source" : {
"id" : 3,
"username" : "yanyongwen",
"email" : "yyw794@126.com",
"public_email" : null,
"name" : "yyw794",
"created_at" : "2023-04-14T02:34:14.040Z",
"updated_at" : "2023-04-14T02:53:04.822Z",
"admin" : false,
"state" : "active",
"organization" : "",
"timezone" : null,
"external" : false,
"in_forbidden_state" : false,
"status" : null,
"status_emoji" : null,
"busy" : false,
"namespace_ancestry_ids" : [
"2-p2-",
"8-"
],
"schema_version" : 2210,
"type" : "user"
}
用户的权限通过namespace_ancestry_ids进行存储
通过namespace-project的id拼接方便进行权限控制。
基于父子数据建模的权限控制设计
父子数据建模使用了ES的Has Parent Query
为什么Gitlab不使用ES官方推荐的大宽表数据建模?
gitlab的搜索对象存在父子关系,且子对象也需要被独立搜索出来,因此,ES内部的子对象是独立对象存储的。
每个project下面有多种类型的子对象,每种子对象都可能数量众多。
缺点:写入操作变得繁琐
- 如果采用大宽表设计,当project的权限改变时,该project的全部子对象的project权限属性都要同步更新,涉及面很广。
- project每新增一个子对象时,需要查询project的属性后,再填入子对象中。
采用父子关系的数据建模
写入过程,需要额外增加route属性,保证父子对象(同一个project)都在同一个shard中。
由于是project级别的路由,因此_route值为"project-${project_id}"
父子关系的建模在什么数据量下的性能变得不可接受?(gitlab的搜索不是一个高频操作,每个project下子对象总数也不会太高)
父子关系的数据建模适合:
- 整体的父对象不多,但是父对象内部的子对象较多的场景
- 搜索性能要求不高
索引版本管理
具有schema_version字段,Format is YYMM (如2303),当schema改变时,这个值需要变更。
ES ID设计
ES ID设计的核心是唯一性。(_Id 字段)
ES的文档_id
两种设计思路:
- ProjectID_项目内唯一识别字符 (用于对象的唯一识别是项目内唯一的)
- 对象类型_对象ID (用于对象的ID是全局唯一的)
ProjectID_项目内唯一识别字符
blob的ID设计为:
${project_id}_${blob_path}
(wiki_blob采用和blob一样的设计)
commit:
${project_id_${sha}
对象类型_对象ID
project的ID设计为:
project_${project_id}
milestone的ID设计为:
milestone_${milestone_id}
snippet的ID设计为:
snippet_${snippet_id}
source内部的ID仍然使用对象自己的业务ID(_source内部的id和ES的_id的不一样,如何做到的?TODO:)
子对象ID
通过例如repository的id名为rid (有较好的可读性)
rid: repository id
一个project下面会有2个repository
1个为代码仓库,id和project一致
1个为wiki仓库,id为wiki_${project_id}
oid: blob id / wiki_blob id
附录
通过查看ES的日志,来获取gitlab实际的搜索query。
基于admin的query json
https://download.csdn.net/download/yyw794/88646878
gitlab在es中碰过的坑:
Lessons from our journey to enable global code search with Elasticsearch on GitLab.com
Update: The challenge of enabling Elasticsearch on GitLab.com
Update: Elasticsearch lessons learnt for Advanced Global Search 2020-04-28
减少索引体积
由于ES的delete是软删除,gitlab最初采用forcemerge来强制硬删除(merge segment的过程会最终硬删除文档),但是forcemerge是一个阻塞操作,会严重影响ES的整体性能,因此只能放弃forcemerge。文章来源:https://www.toymoban.com/news/detail-775067.html
不同领域对象在一个大索引 还是不同领域对象在不同索引的问题。文章来源地址https://www.toymoban.com/news/detail-775067.html
- 不存在空间占用区别。ES6引入了sparse field功能,使得1个大索引的稀疏字段并不会浪费额外的空间
- 独立索引具有重建索引时的速度优势 (Elasticsearch: return to using a separate index per document type (#3217) · Issues · GitLab.org / GitLab · GitLab)
到了这里,关于【独家深度】Gitlab基于Elasticsearch的站内搜索设计的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!