ES修改数据的原理

用过ES的老铁都知道,ES中的文档是无法直接更新的,我们通常说的对ES中的文档进行更新,实际上是对指定的文档进行重新索引,也就是将原有的文档进行标记删除,然后再重新索引一个新的文档。虽然很多ES的使用者都清楚这个原理,但是在文档更新的过程中,不同的操作姿势,更新操作的性能却又很大的差异,使用不当甚至会产生线上事故,具体的使用方法,我们一起往下看。

全量更新

根据ES中文档更新原理:当文档内容需要更新的时候,我们只需要向索引中重新索引一个Id相同的文档即可,索引中原有的文档就会被标记删除掉,也就是用新的文档覆盖旧的文档。

为了方便后续的说明,我们先在索引中新增一个文档:

PUT my_index/_doc/1
{"interest":"sing,dance",
  "name":"nike",
  "address":"shanghai china"
}

向索引my_index中索引一个文档,文档id为1,文档内容为:
name:nike,
interest:sing,dance,
address:shanghai china。

如果此时我们需要把文档1中name修改为 tom,我们可以执行以下命令:

PUT my_index/_doc/1
{"interest":"sing,dance",
  "name":"tom",
  "address":"shanghai china"
}

此时可用通过

GET my_index/_doc/1

来查看修改后的结果:

{
  "_index" : "my_index",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 2,
  "_seq_no" : 11,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "interest" : "sing,dance",
    "name" : "tom",
    "address" : "shanghai china"
  }
}

虽然这种修改方式可以满足数据修改的需求,但是不足之处在于:我们只需要修改一个name字段的值,却要将不需要修改的其他字段,如address,interest,放到修改命令里。

这种数据修改的方式涉及两个操作:

1.从ES中取回文档数据

2.将修改后的文档数据写入ES

这两个操作涉及两次网络IO,当文档内容比较大的时候,IO操作对更新操作的性能影响比较大。尤其当被更新内容只有少量数据,大部分的内容是不变的情况下,这种操作就更浪费资源了。

部分更新

为了解决全量更新方式中的IO耗时和资源浪费的问题,ES提供了文档部分更新的能力:update API脚本更新

文档部分更新的方式,并没有改变ES更新文档的原理:删除旧文档,插入新文档。但是好处在于,相比全量更新,客户端在更新命令中只需要提交需要更新的内容,不变的内容不需要提交,站在客户端的角度来看,"好像"可以对字段单独进行更新了一样。

部分更新的方式中,客户端只提交了需要更新的内容,但是在ES的分片内部,仍然会根据文档id取出整个文档,然后将需要更新的内容更新到文档中,最后将新的文档索引到索引中,流程和全量更新是相同的,只是这个过程发生在分片内部,减少了一次网络IO,而且更新请求中只有需要更新的内容,数据量比较小,因此网络IO对性能影响比较小。

update api

使用update api可以实现文档的部分更新。如下,将文档1中name修改为tom,具体操作如下:

POST my_index/_update/1
{"doc":{"name":"tom"}}

通过查询命令查看更新结果:

GET my_index/_doc/1

结果如下:

{
  "_index" : "my_index",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 3,
  "_seq_no" : 12,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "interest" : "sing,dance",
    "name" : "tom",
    "address" : "shanghai china"
  }
}
动态脚本

除了update api外,ES提供了使用脚本语言更新文档的能力,我们仍然以将name修改为tom为例进行演示:

POST my_index/_update/1
{"script": "ctx._source.name=\"tome\""}

ES中脚本不仅可以实现对文档的更新,还可以实现文档删除,编写表达式等能力,脚本的介绍和使用不是本文的重点内容,这里不做过多介绍,有兴趣可以参考: https://www.elastic.co/guide/cn/elasticsearch/guide/current/partial-updates.html

参数化脚本

使用部分更新,可以很大程度上提升数据更新效率,作者在工作中主要使用脚本来完成文档的更新,看似不错的更新方案,去隐藏了一个"大坑"。

说"大坑"是开玩笑的说法,说到底还是对ES的架构设计理解不够。在ES中使用脚本相对其他操作来说,是一种开销比较大的操作,因为每个脚本在执行前,需要进行编译。类似我们使用的java语言一样,只有编译成jvm能"看懂"的字节码后,才可以执行,ES中的脚本也是类似的。编译过程中,会消耗cpu和内存资源,为了防止脚本编译操作消耗过多ES集群资源,ES通过参数 script.max_compilations_rate 来限制 单位时间内可以执行编译的次数,超过了阈值限制,就会触发熔断,脚本执行失败,抛出异常信息:

[{"type":"circuit_breaking_exception",
"reason":"[script] Too many dynamic script compilations within, max: [175/5m]; please use indexed, or scripts with parameters instead; this limit can be changed by the [script.max_compilations_rate] setting","bytes_wanted":0,"bytes_limit":0,"durability":"TRANSIENT"}]

默认情况下该参数的值为:75/5min,也就是5分钟内,最多可以编译75次。

所以当更新比较频繁时,很容易超出该参数的阈值限制,也就导致更新失败。但是不是因为存在该参数限制,ES脚本能力就成为鸡肋了呢?其实也不然,这里我先卖个关子,我们先了解一下script.max_compilations_rate 是如何起作用的。

在ES中,每次编译脚本前,都会判断一下,当前编译脚本个数是否超过限制,超过的话,就会触发熔断,但是这需要注意的是,并不是每个脚本都会触发编译,也就是说,不是每个脚本都会累加当前时间窗口已编译脚本数,比如说当一个脚本之前已经编译过了,当再次执行该脚本时,就无需再进行编译了,可以直接使用之前已经变好的脚本使用即可。

那怎么判断一个脚本是否已经编译过了呢?ES会将已经编译过的脚本进行缓存,那么在缓存前,我们需要明确在ES中,是如何标识一个脚本的唯一性呢?在ES中,脚本语言类型,配置参数和脚本内容 三个属性来标识一个脚本的唯一性。

这里我们可以参考ES的源码:

<FactoryType> FactoryType compile(
        ScriptContext<FactoryType> context,
        ScriptEngine scriptEngine,
        String id,
        String idOrCode,
        ScriptType type,
        Map<String, String> options
    ) {
        String lang = scriptEngine.getType();
        CacheKey cacheKey = new CacheKey(lang, idOrCode, context.name, options); // 缓存已经变化以的脚本

        try {
            return context.factoryClazz.cast(cache.computeIfAbsent(cacheKey, key -> {
               
                if (logger.isTraceEnabled()) {
                    logger.trace("context [{}]: compiling script, type: [{}], lang: [{}], options: [{}]", context.name, type,
                        lang, options);
                }
       
                checkCompilationLimit(); // 阈值检查
                Object compiledScript = scriptEngine.compile(id, idOrCode, context, options);
                
scriptMetrics.onCompilation();
                return compiledScript;
            }));
        } catch (ExecutionException executionException) {
           xxxx
        }
    }

脚本的语言类型有两种:painlessmustache语言类型我们一般选择默认即可,也就是 painless ,脚本的配置参数一般情况下也是不变的。那么影响脚本唯一性的主要属性就是脚本的内容了。

而在使用脚本进行数据更新时,很多时候更新的逻辑是固定的,只不过被更新的字段的值是变化的。基于这个场景,ES提供了参数化脚本的概念,也就是在脚本内容中使用变量来对真实的值进行占位,而在脚本执行的时候,再从参数列表中取出真实的值。这样,只要脚本逻辑不变,那么脚本内容就不变,也就不会在触发脚本的编译,进而也就不会超过 max_compilations_rate阈值,触发熔断。

脚本参数的使用方式如下:

POST my_index/_update/1
{
  "script": {
    "source": "ctx._source.name = params.name",
    "params": {
      "name": "tom"
    }
  }
}

此时脚本的内容为"ctx._source.name = params.name" 不会因为被修改的内容不同,而发生变化,极大的减少了编译的次数,提升脚本执行效率。其实ES对脚本编译的优化方案,和jdbc中使用PrepareStatement对Statement进行优化类似,有兴趣的读者可以研究一下。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐