ElasticStack系列之十四 & ElasticSearch5.x bulk update 中重复 id 性能骤降
目前在绝对多数公司在使用 ElasticSearch 将其当做数据库使用,将多个数据库中的数据同步到 ElasticSearch 索引是非常常见的应用场景。那么自然而然就会涉及到数据频繁的新增和更新,而官方的文档并没有对 update 的底层机制做特别说明,而当我们从 2.x 版本升级到 5.x 发现反而比之前的性能差很多,那这到底是怎么回事呢?
问题描述
在 ElasticSearch5.x 里通过 bulk update 将数据从数据库同步到 ElasticSearch,如果短时间更新的一批数据里存在相同的 id,例如一个 bulk update 里大量写入下面类型的数据:
{id:1,name:aaa} {id:1,name:bbb} {id:1,name:ccc} {id:2,name:aaa} {id:2,name:bbb} {id:2,name:ccc} .......
则更新的速度会变的非常的慢。而在 ElasticSearch1.x 和 ElasticSearch2.x 里同样的操作会快的多。
根源追溯
update 操作时分为两个步骤进行的,即先根据文档 id 做一次 GET 操作,得到旧版本的文档,然后在其基础上做更新再写回去,问题就出在这个 GET 上面。
在 core/src/main/java/org/elasticsearch/index/engine/InternalEngine.java 这个类里,get 函数会根据一个 realtime 参数(默认是 true),决定如何拿到文档。
public GetResult get(Get get, Function<String, Searcher> searcherFactory, LongConsumer onRefresh) throws EngineException { assert Objects.equals(get.uid().field(), uidField) : get.uid().field(); try (ReleasableLock lock = readLock.acquire()) { ensureOpen(); if (get.realtime()) { VersionValue versionValue = versionMap.getUnderLock(get.uid()); if (versionValue != null) { if (versionValue.isDelete()) { return GetResult.NOT_EXISTS; } if (get.versionType().isVersionConflictForReads(versionValue.getVersion(), get.version())) { throw new VersionConflictEngineException(shardId, get.type(), get.id(), get.versionType().explainConflictForReads(versionValue.getVersion(), get.version())); } long time = System.nanoTime(); refresh("realtime_get"); onRefresh.accept(System.nanoTime() - time); } } // no version, get the version from the index, we know that we refresh on flush return getFromSearcher(get, searcherFactory); }
可以看到 realtime 参数决定了 GET 到的数据的实时性。如果设置为 false,则直接从 searcher 里面拿,而 searcher 只能访问 refresh 过的数据,意味着刚写入的数据由于存在于 index writer buffer 里还未 refresh,暂时无法搜索到,所以这种方式拿到的数据是准实时的。而默认的 realtime=true,则决定了获取到的数据必须是实时的,也就是说 index writer buffer 里的数据也要能被检索到。从代码里可以看到,其中存在一个 refresh("realtime_get"); 的函数调用,这个函数会检查 GET 的 doc id 是否都是可以被搜索到的。如果已经写入了但是无法搜索到,也就是刚刚写入到 index writer buffer 里还未 refresh 这种情况,就会强制执行一次 refresh 操作,让数据对 searcher 可见,保证最后的 getFromSearcher 方法调用拿的是完全实时的数据。
实际上测试下来,也是这样的,关闭自动刷新,写入一条文档,然后对该文档 id 执行一次 GET 操作,就会看到有一个新的 segment 生成。
查了下文档,GET API 调用时,可以选择实时或者非实时,只需要在 url 里带上可选参数 realtime=[true/false]。
然而不幸的是,UPDATE API 的文档和源码都没有提供一个 “禁用” 实时性的参数。update 对 GET 的调用,传入的 realtime 参数是写死的即为 true,意味着 update 的时候,强制执行 realtime GET。
至于为什么 update 一定要需要实时 GET,想一下其实也可以理解的。因为 update 允许对文档进行局部更新,如果有两个请求分别更新了同一个文档的不同字段,可能先更新的数据还在 index writer buffer 里,没来得及 refresh,因为对 searcher 不可见。如果不做refresh,那后面的更新还是在老版本上做的,前面的更新就会丢失掉。
另外一个问题,为啥 ElasticSearch5.x 之前的版本没有这个性能问题呢?
看了 ElasticSearch2.4 的 GET 方法源码,其没有采用 refresh 的方式来保障数据的实时性,而是通过访问 translog 来达到同样的目的。ElasticSearch5.x 将机制从 translog 改为了 refresh,理由是之前 ElasticSearch 里有很多地方利用 translog 来位数数据的位置,使得很多操作变得很慢,去掉对 translog 的依赖可以全面提高性能。
很遗憾,这个更改对于短时间反复大量更新相同的 doc id 的操作,会因为过于频繁的强制 refresh,短时间生成很多小的 segment,继而不断触发 segment 合并,产生性能损耗。官方认为,在提升大多数应用场景性能的前提下,对于这种较少见的场景下的性能损耗是值得付出的。所以建议从应用层面去解决该问题。
因此,如果实际应用场景里遇到类似的数据更新问题,只能优化应用数据架构,在应用层面合并相同的 doc id 的数据更新后再写入到 ElasticSearch 索引中;或者 只能使用 ElasticSearch2.x 这样的老版本来间接的解决这类问题。