深入讨论几种 ES Mapping 修改方法和局限

前言

众所周知,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,解决了新文档丢失的问题。

但我也想到了这种方法的几个不足和局限:

  1. 中间会有很长一段时间(当新 index 开始接客、旧数据还没有全部 reindex 过来时),后端业务将无法访问到旧数据。这可能会对业务造成影响,比如用户突然无法搜索到以前的文章。
  2. 这个方法只能保证新文档插入不丢失,对旧文档的更新和删除操作依然可能丢失。当然,如果业务里 ES 的使用场景是“只写不改”,那也就不是问题了。不过说起来,把 ES 用来作为 CRUD 数据库确实是很奇怪的技术选型~

4. 两步 reindex

我还想到了一个方法,可以在不停机的情况下,既让后端业务尽可能正常地访问老数据,又不丢失新文档的创建。这个方法要求文档创建时附带一个 created_at 字段。

  1. 创建一个新的 index-v2,设置好需要的 mapping。
PUT /index-v2/_doc/_mapping
{
	"properties": <all_you_need>
}
  1. 先“悄悄”准备老数据,比如先 reindex 某个时间点之前的老数据(比如一小时前)。
POST _reindex
{
	"source": {
		"index": "index-v1",
		"query": {
			"range": {
				"created_at": {
					"lt": <一个时间点>
				}
			}
		}
	},
	"dest": {
		"index": "index-v2"
	}
}
  1. 当老数据准备好后,立即修改 alias,让新 index-v2 开始接受新数据。
POST /_aliases
{
	"actions": [
		{
			"remove": {
				"alias": "index",
				"index": "index-v1"
			}
		},
		{
			"add": {
				"alias": "index",
				"index": "index-v2"
			}
		}
	]
}
  1. 再将这个时间点之后的数据 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,也许是一件大动干戈的事情。利用其他技巧可以更加低成本的解决问题,比如建个新字段。

  1. 在 mapping 添加新字段 new_field,设置好需要的索引方式
PUT /my-index/_doc/_mapping
{
  "properties": {
    "new_field": {
      "type": "keyword"
    }
  }
}
  1. 将原来文档的旧字段的值重新赋予到新字段
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" }
  }
}
  1. 在业务代码中弃用原来的字段

小结

这个方法非常适合低成本解决少量数据被错误索引的情况,比如在发布前忘记修改 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 的数据丢失问题。我还找到了很多只言片语,它们大多只是简单提供了一个解决方法,却很少介绍方法的用意和局限。

所以我尝试写篇文章,想着结合自己的认识和理解,试着更加全面地讨论一下各个方法的利弊权衡,看看能不能从这个常见的小问题出发,窥探到一点点原理和设计的影子,填补一点点底层技术与实际业务的缝隙,引发一些可能的讨论和思路。

最后不得不感叹:方法常有,银弹难寻!只有根据实际情况,在业务可接受的影响范围内,找到最简单方法,往往那就是最佳实践。

参考

comments powered by Disqus