前言
众所周知,ElasticSearch 中的 Mapping 决定了字段的索引方式。当一个字段的索引类型确定后就无法修改。然而在日常开发中,忘记更新 mapping 是偶尔会遇到的,但修改 mapping 却是一件不容易的事情。这篇文章不仅详细讨论了 reindex 这种常见解决方法,包括 reindex 的停机和数据丢失问题、以及在各种情况下规避上述问题的思路和方法,还介绍了另外两种更加低成本的解决方法。
一个常见的错误
“发布测试环境前我竟然忘记修改 mapping 了?”
{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"Fielddata is disabled on text fields by default. Set fielddata=true on [player_type] in order to load fielddata in memory by uninverting the inverted index......
所以我要如何快速解决这个问题呢?
所以我要如何快速修改 mapping 中一个字段的索引类型呢?
Reindex
“即使对 reindex 烂熟于心,也需要留意停机时间和数据丢失……”
Reindex 几乎是这类问题的标准答案,其中一个常见思路是:创建一个新的 index-tmp,然后把数据 reindex 到这个新 index-tmp,再把原来的 index 删除重建、设置正确的 mapping,最后把数据重新从 index-tmp 中 reindex 回来。很明显这个思路除了操作危险外,还会有较长的停机时间。
ElasticSearch 官方博客里曾介绍过另一种方法,只要后端业务以 alias 的方式访问 index,就可以做到完全无停机时间(Zero-Downtime)。具体思路是:首先创建一个 mapping 正确的新 index-v2,把数据 reindex 到这个新 index-v2,然后直接把 alias 重命名到这个index-v2。这样可以让后端业务直接访问到新的 index-v2,达到后端业务对 reindex 操作无感知的效果。
然而这种方法也有不足,官方博客里完全没有提及数据丢失的问题。如果线上写入操作频繁,在 reindex 和更新 alias 步骤中有一段时间窗口,新数据依然会写入到即将弃用的旧 index,造成新 index-v2 丢失该部分数据的问题。即使采用官博推荐的 bulk API,也无法在原理上根本地规避问题。
因此需要结合实际的业务情况,做一些因地制宜的权衡。
1. 那就丢亿点点数据
有时候,丢失一些临时数据也是可以接受的,比如测试环境的PV上报、用来时间窗口统计的热词记录……对于这些可丢失数据,当我们发出那句灵魂拷问,“数据重要还是我重要下班重要!”,相信产品经理也是会认真考虑的。
2. 找个夜黑风高的时候停机
防止新数据丢失最简单的办法就是停机了。只要没有写入,哪来数据丢失。在我常常遇到的 ES 使用场景里,新数据总是通过消费者从其他数据源里同步过来,只要先让消费者暂停就可以解决问题。后端业务还是可以正常读取 ES,只是停止了消费者的写入。当一切结束后,消费者可以直接从消息队列里获取堆积的写入任务,轻松地恢复工作。
3. 让新数据先走
能不能在不停机的情况下,也能做到不丢失数据?完全可以。创建新 index 后,立即重新设置 alias,让新 index 先开始接受新数据,然后再把老数据 reindex 过来。这样数据要么在新 index、要么在旧 index,但最终都会写入在新 index,解决了新文档丢失的问题。
但我也想到了这种方法的几个不足和局限:
- 中间会有很长一段时间(当新 index 开始接客、旧数据还没有全部 reindex 过来时),后端业务将无法访问到旧数据。这可能会对业务造成影响,比如用户突然无法搜索到以前的文章。
- 这个方法只能保证新文档插入不丢失,对旧文档的更新和删除操作依然可能丢失。当然,如果业务里 ES 的使用场景是“只写不改”,那也就不是问题了。不过说起来,把 ES 用来作为 CRUD 数据库确实是很奇怪的技术选型~
4. 两步 reindex
我还想到了一个方法,可以在不停机的情况下,既让后端业务尽可能正常地访问老数据,又不丢失新文档的创建。这个方法要求文档创建时附带一个 created_at
字段。
- 创建一个新的 index-v2,设置好需要的 mapping。
PUT /index-v2/_doc/_mapping
{
"properties": <all_you_need>
}
- 先“悄悄”准备老数据,比如先 reindex 某个时间点之前的老数据(比如一小时前)。
POST _reindex
{
"source": {
"index": "index-v1",
"query": {
"range": {
"created_at": {
"lt": <一个时间点>
}
}
}
},
"dest": {
"index": "index-v2"
}
}
- 当老数据准备好后,立即修改 alias,让新 index-v2 开始接受新数据。
POST /_aliases
{
"actions": [
{
"remove": {
"alias": "index",
"index": "index-v1"
}
},
{
"add": {
"alias": "index",
"index": "index-v2"
}
}
]
}
- 再将这个时间点之后的数据 reindex 到新 index-v2
POST _reindex
{
"source": {
"index": "index-v1",
"query": {
"range": {
"created_at": {
"gte": <一个时间点>
}
}
}
},
"dest": {
"index": "index-v2"
}
}
这样在迁移过程中,既不需要停机,也不会丢失新数据,也不会出现长时间无法访问历史数据的问题。后端业务几乎可以访问到所有的数据,只有在第二次 reindex 期间会有极小部分数据在短时间内无法访问(不可见数据范围大约是 created_at: [the_timestamp, now)
),但也会很快地恢复。因为数据量非常少,第二次 reindex 速度很快,等到结束后,后端业务就可以完全正常地访问到所有的数据。
这个方法可以保证新文档插入不丢失、大部分旧文档更新删除不丢失,但在第二次 reindex 期间那些不可见文档的更新、删除操作依然有丢失的可能。这个方法在不停机的前提下大幅地缩小了负面影响的范围。
小结
以上只是尽可能地讨论了 reindex 在实际业务中可能遇到的问题,以及可行的方法与思路。虽然在实际应用中,大多数情况都可以用简单直接的方法解决,比如遗弃数据和停机,但认识更多情况可以规避潜在的决策风险。
对了,这里没有讨论 reindex 优化,我感觉这是另外一个话题了。我看到有些高赞文章提到通过配置来增大 reindex 单次索引的文档数量,或者通过 scroll 并发来加快索引。我直观的感觉,这些方法在停机情况下应该是不错的做法,但在不停机时可能会给集群带来更多的压力,尤其是 index 数据量很大、读写频繁的场景,可能会影响到正常的业务。在不停机的情况,也许我们应该更多考虑的不是如何加快 reindex 过程,而是尽可能降低 reindex 对正常业务的影响。
除了 reindex,还有其他一些方法可以“修改”已有字段的索引类型。
新字段替换
“既然不能直接修改原来字段的索引类型,那我重新建个字段好吧……”
很多时候,为了一点点错误索引的数据而 reindex 整个 index,也许是一件大动干戈的事情。利用其他技巧可以更加低成本的解决问题,比如建个新字段。
- 在 mapping 添加新字段 new_field,设置好需要的索引方式
PUT /my-index/_doc/_mapping
{
"properties": {
"new_field": {
"type": "keyword"
}
}
}
- 将原来文档的旧字段的值重新赋予到新字段
POST /my-index/_update_by_query?conflicts=proceed
{
"script": {
"source": "ctx._source.new_field = ctx._source.old_field",
"lang": "painless"
},
"query": {
"exists": { "field": "old_field" }
}
}
- 在业务代码中弃用原来的字段
小结
这个方法非常适合低成本解决少量数据被错误索引的情况,比如在发布前忘记修改 mapping。但是如果需要修改索引类型的数据很多,这个方法需要更新大量已有数据,可能会对集群带来一定压力。毕竟在 ElasticSearch 里,所谓的文档修改就是先标记删除、然后重新插入,如果要更新整个 index 的所有文档,也许还不如 reindex 来得痛快……
采用 multi-field
“作为一个成熟的 DB,总是可以为一个字段建立多种索引……"
ElasticSearch 的 multi-field
特性,让同一个字段可以有多种不同的索引类型。我们可以利用这个特性在 mapping 中为老字段追加新的索引类型。比如这个例子:
PUT /my-index/_doc/_mapping
{
"properties": {
"company": {
"type": "keyword",
"fields": {
"name": {
"type": "text"
}
}
}
}
}
原本 company 字段采用了 keyword
的索引类型,只能用于数值匹配。在上面的例子中,为 company 字段追加了另一种支持全文搜索的索引类型 text
,然后把这个全文搜索版本取名叫 company.name。这样查询条件 { "company.name": "腾讯" }
就可以匹配到 { "company": "腾讯科技有限公司" }
的数据了。
因为本质上只是对同一个字段添加不同的索引,实际文档(documents)中并不会真的多出来一个叫 company.name 的字段,插入数据时也不需要为 company.name 赋值。一切索引工作都由 ElasticSearch 自动完成。
然而用这种方法修改 mapping 后,只有新的写入才会让该文档的新索引生效。也就是说,只有插入新的文档、或者更新旧文档时,ElasticSearch 才会给该文档重新索引、将该文档写入新索引。除非老数据有更新,用 company.name 无法搜索到老数据,因为老数据根本不在这个索引里!
但问题总是可以解决,不就是还差一次全局更新嘛~(坏笑脸
// 这个更新没有任何意义,纯粹是为了触发老数据的重新索引
POST /my-index/_update_by_query?conflicts=proceed
{
"script": {
"source": "ctx",
"lang": "painless"
},
"query": {
"exists": { "field": "company" }
}
}
小结
这个方法不仅适合低成本解决少量数据被错误索引的情况,而且在同个字段需要多种索引方式(尤其需要是搭配不同的分析器)时这几乎是唯一的做法,因为 multi-field
就是为了这种场景准备的。但这个方法同样需要注意更新数据的规模,以及对性能的影响……
最后
我在中英互联网上搜索时,发现很难找到详细讨论这个话题的文章。其中我找到最好的资料是 ElasticSearch 官博的一篇文章,里面简要介绍了这三种方法,但是很少介绍各个方法的局限、已知问题和适用场景,比如几乎没有提到 reindex 的数据丢失问题。我还找到了很多只言片语,它们大多只是简单提供了一个解决方法,却很少介绍方法的用意和局限。
所以我尝试写篇文章,想着结合自己的认识和理解,试着更加全面地讨论一下各个方法的利弊权衡,看看能不能从这个常见的小问题出发,窥探到一点点原理和设计的影子,填补一点点底层技术与实际业务的缝隙,引发一些可能的讨论和思路。
最后不得不感叹:方法常有,银弹难寻!只有根据实际情况,在业务可接受的影响范围内,找到最简单方法,往往那就是最佳实践。