集群状态维护

我们都知道,ES 中的 master 跟一般 MySQL、Hadoop 的 master 是不一样的。它即不是写入流量的唯一入口,也不是所有数据的元信息的存放地点。所以,一般来说,ES 的 master 节点负载很轻,集群性能是可以近似认为随着 data 节点的扩展线性提升的。

但是,上面这句话并不是完全正确的。

ES 中有一件事情是只有 master 节点能管理的,这就是集群状态(cluster state)。

集群状态中包括以下信息:

  • 集群层面的设置
  • 集群内有哪些节点
  • 各索引的设置,映射,分析器和别名等
  • 索引内各分片所在的节点位置

这些信息在集群的任意节点上都存放着,你也可以通过 /_cluster/state 接口直接读取到其内容。注意这最后一项信息,之前我们已经讲过 ES 怎么通过简单地取余知道一条数据放在哪个分片里,加上现在集群状态里又记载了分片在哪个节点上,那么,整个集群里,任意节点都可以知道一条数据在哪个节点上存储了。所以,数据读写才可以发送给集群里任意节点。

至于修改,则只能由 master 节点完成!显然,集群状态里大部分内容是极少变动的,唯独有一样除外——索引的映射。因为 ES 的 schema-less 特性,我们可以任意写入 JSON 数据,所以索引中随时可能增加新的字段。这个时候,负责容纳这条数据的主分片所在的节点,会暂停写入操作,将字段的映射结果传递给 master 节点;master 节点合并这段修改到集群状态里,发送新版本的集群状态到集群的所有节点上。然后写入操作才会继续。一般来说,这个操作是在一二十毫秒内就可以完成,影响也不大。

但是也有一些情况会是例外。

批量新索引创建

在较大规模的 Elastic Stack 应用场景中,这是比较常见的一个情况。因为 Elastic Stack 建议采用日期时间作为索引的划分方式,所以定时(一般是每天),会统一产生一批新的索引。而前面已经讲过,ES 的集群状态每次更新都是阻塞式的发布到全部节点上以后,节点才能继续后续处理。

这就意味着,如果在集群负载较高的时候,批量新建新索引,可能会有一个显著的阻塞时间,无法写入任何数据。要等到全部节点同步完成集群状态以后,数据写入才能恢复。

不巧的是,中国使用的是北京时间,UTC +0800。也就是说,默认的 Elastic Stack 新建索引时间是在早上 8 点。这个时间点一般日志写入量已经上涨到一定水平了(当然,晚上 0 点的量其实也不低)。

对此,可以通过定时任务,每天在最低谷的早上三四点,提前通过 POST mapping 的方式,创建好之后几天的索引。就可以避免这个问题了。

如果你的日志是比较严重的非结构化数据,这个问题在 2.0 版本后会变得更加严重。 Elasticsearch 从 2.0 版本开始,对 mapping 更新做了重构。为了防止字段类型冲突和减少 master 定期下发全量 cluster state 导致的大流量压力,新的实现和旧实现的区别在:

  • 过去:每次 bulk 请求,本地生成索引后,将更新的 mapping,按照 _type 为单位构成 mapping 更新请求发给 master;
  • 现在:每次 bulk 请求,遍历每条数据,将每条数据要更新的 mapping,都单独发给 master,等到 master 通知完全集群,本地才能生成这一条数据的索引。

也就是说,一旦你日志中字段数量较多,在新创建索引的一段时间内,可能长达几十分钟一直被反复锁死!

过多字段持续更新

这是另一种常见的滥用。在使用 Elastic Stack 处理访问日志时,为了查询更方便,可能会采用 logstash-filter-kv 插件,将访问日志中的每个 URL 参数,都切分成单独的字段。比如一个 “/index.do?uid=1234567890&action=payload” 的 URL 会被转换成如下 JSON:

  1. "urlpath" : "/index.do",
  2. "urlargs" : {
  3. "uid" : "1234567890",
  4. "action" : "payload",
  5. ...
  6. }

但是,因为集群状态是存在所有节点的内存里的,一旦 URL 参数过多,ES 节点的内存就被大量用于存储字段映射内容。这是一个极大的浪费。如果碰上 URL 参数的键内容本身一直在变动,直接撑爆 ES 内存都是有可能的!

以上是真实发生的事件,开发人员莫名的选择将一个 UUID 结果作为 key 放在 URL 参数里。直接导致 ES 集群 master 节点全部 OOM。

如果你在 ES 日志中一直看到有新的 updating mapping [logstash-2015.06.01] 字样出现的话,请郑重考虑一下自己是不是用的上如此细分的字段列表吧。

好,三秒钟过去,如果你确定一定以及肯定还要这么做,下面是一个变通的解决办法。

nested object

用 nested object 来存放 URL 参数的方法稍微复杂,但还可以接受。单从 JSON 数据层面看,新方式的数据结构如下:

  1. "urlargs": [
  2. { "key": "uid", "value": "1234567890" },
  3. { "key": "action", "value": "payload" },
  4. ...
  5. ]

没错,看起来就是一个数组。但是 JSON 数组在 ES 里是有两种处理方式的。

如果直接写入数组,ES 在实际索引过程中,会把所有内容都平铺开,变成 Arrays of Inner Objects。整条数据实际类似这样的结构:

  1. {
  2. "urlpath" : ["/index.do"],
  3. "urlargs.key" : ["uid", "action", ...],
  4. "urlargs.value" : ["1234567890", "payload", ...]

这种方式最大的问题是,当你采用 urlargs.key:"uid" AND urlargs.value:"0987654321" 语句意图搜索一个 uid=0987654321 的请求时,实际是整个 URL 参数中任意一处 value 为 0987654321 的,都会命中。

要想达到正确搜索的目的,需要在写入数据之前,指定 urlargs 字段的映射类型为 nested object。命令如下:

  1. curl -XPOST http://127.0.0.1:9200/logstash-2015.06.01/_mapping -d '{
  2. "accesslog" : {
  3. "properties" : {
  4. "urlargs" : {
  5. "type" : "nested",
  6. "properties" : {
  7. "key" : { "type" : "string", "index" : "not_analyzed", "doc_values" : true },
  8. "value" : { "type" : "string", "index" : "not_analyzed", "doc_values" : true }
  9. }
  10. }
  11. }
  12. }
  13. }'

这样,数据实际是类似这样的结构:

  1. {
  2. "urlpath" : ["/index.do"],
  3. },
  4. {
  5. "urlargs.key" : ["uid"],
  6. "urlargs.value" : ["1234567890"],
  7. },
  8. {
  9. "urlargs.key" : ["action"],
  10. "urlargs.value" : ["payload"],
  11. }

当然,nested object 节省字段映射的优势对应的是它在使用的复杂。Query 和 Aggs 都必须使用专门的 nested query 和 nested aggs 才能正确读取到它。

nested query 语法如下:

  1. curl -XPOST http://127.0.0.1:9200/logstash-2015.06.01/accesslog/_search -d '
  2. {
  3. "query": {
  4. "bool": {
  5. "must": [
  6. { "match": { "urlpath" : "/index.do" }},
  7. {
  8. "nested": {
  9. "path": "urlargs",
  10. "query": {
  11. "bool": {
  12. "must": [
  13. { "match": { "urlargs.key": "uid" }},
  14. { "match": { "urlargs.value": "1234567890" }}
  15. ]
  16. }}}}
  17. ]
  18. }}}'

nested aggs 语法如下:

  1. curl -XPOST http://127.0.0.1:9200/logstash-2015.06.01/accesslog/_search -d '
  2. {
  3. "aggs": {
  4. "topnuid": {
  5. "nested": {
  6. "path": "urlargs"
  7. },
  8. "aggs": {
  9. "uid": {
  10. "filter": {
  11. "term": {
  12. "urlargs.key": "uid",
  13. }
  14. },
  15. "aggs": {
  16. "topn": {
  17. "terms": {
  18. "field": "urlargs.value"
  19. }
  20. }
  21. }
  22. }
  23. }
  24. }
  25. }
  26. }'