Node.js精进(12)——ElasticSearch

ElasticSearch(简称 ES)是一款基于 Lucene 的分布式、可扩展、RESTful 风格的全文检索和数据分析引擎,擅长实时处理 PB 级别的数据。

一、基本概念

1)Lucene

Lucene 是一款开源免费、成熟权威、高性能的全文检索库,是 ES 实现全文检索的核心基础,而检索的关键正是倒排索引。

2)倒排索引

索引的目的是加快查询速度,尽快查出符合条件的数据。

正排索引就像翻书一样,先查目录,然后锁定页码,再去看内容。而倒排索引正好与其相反,通过对内容的分词,建立内容到文档 ID 之间的映射关系,如下图所示(来源于elasticsearch原理及入门)。

倒排索引包括两部分: Term Dictionary(单词词典)和 Posting List(倒排列表)。

Term Dictionary 记录了文档单词,以及单词和倒排列表的关系。Posting List 则是记录了 Term 在文档中的位置以及其他信息,主要包括文档 ID、词频(Term 在文档中出现的次数,用来计算相关性评分),位置以及偏移(实现搜索高亮)。

3)压缩算法

为了搜索能高性能,需要将倒排列表放入内存中,但是海量的文档必然会增加表的尺寸,为了节约空间,Lucene 使用了两种压缩算法:FOR(Frame Of Reference)和 RBM(RoaringBitmap)。

FOR 算法的原理就是通过增量,将原来的大数变成小数,仅存储增量值,最后通过字节存储,具体分为 3 步:

  1. 将排序的整数列表转换成 Delta 列表,第二排的 227 是增量值(300 - 73),其余值依次计算。
  2. 切分成 blocks,每个 block 是 256 个 Delta,这里为了简化一下,搞成 3 个 Delta。
  3. 看下每个 block 最大的 Delta 是多少。下图的第一个,最大是 227,最接近的 2 次幂是 256(8bits),于是规定这个 block 里都用 8bits 来编码(绿色的 header 就是 8);第二个最大的是 30,最接近的 2 次幂是 32(5bits),于是规定这个 block 里都用 5bits 来编码。

FOR 压缩算法适用于间隔比较小稠密的文档 ID 列表,如1、2、3、5、8.......。假如遇到间隔较大稀疏的文档 ID 列表,如 1000、62101、131385、132052、191173、196658,就更适合通过 RBM 算法来压缩。

RBM 算法的核心就是把数据表示成 32 位的二进制,分为高 16 和低 16 进行分别存储,最大值就是 2 的 16 次方(即 65536)。下图描述了具体的压缩步骤(来源于elasticsearch原理及入门):

  1. 每个数字除以 65536 会得到一个商和余数。
  2. 用(商,余数)的组合表示每一组 ID,范围都在 0 ~ 65535 之内。
  3. 其中商为该数字(以 196658 为例)的二进制的前 16 位,余数为该数字的二进制的后 16 位。
  4. 再将商提取出来作为 short key,将关联的余数整合在一起,例如商是 0,则 1000 和 62101 重新组合。

4)FST

在数据写入的时候,Lucene 会为原始数据中的每个 Term 生成对应的倒排索引,这就会让倒排索引的数据量变得很大。而倒排索引对应的倒排列表文件又是存储在硬盘上的,如果每次查询都直接去磁盘中读取,那就会严重影响全文检索的效率。

因此需要一种方式可以快速定位到倒排索引中的 Term,Lucene 使用了 FST(Finite State Transducer)有限状态转换器来实现二级索引的设计,这是一种类似 Trie 树的算法。

Trie 树是一种树形结构,哈希树的变种,经常被搜索引擎系统用于文本词频统计。可利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。它有 3 个基本性质:

  1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  3. 每个节点的所有子节点包含的字符都不相同。

假设有两个 Term:school 和 cool,它们后面的字符一致,可以通过将原先的 Trie 树中的后缀字符进行合并来进一步的压缩空间。优化后的 trie 树就是 FST,如下图所示(来源于Elasticsearch核心概念):

5)术语

ES 是分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个实例。单个实例称为一个节点(node),一组节点构成一个集群(cluster)。

在上图中,包含三类节点:

  1. 主节点(Master Node),为确保一个集群的稳定,分离主节点和数据节点,主要职责是和集群操作相关的内容,如创建或删除索引,跟踪哪些节点是集群的一部分,并决定哪些分片分配给相关的节点。
  2. 数据节点(Data Node),存储索引数据的节点,主要对文档进行增删改查、聚合等操作。
  3. 协调节点(Coordinator Node),该节点只处理路由请求、分发索引等操作,相当于一个智能负载平衡器,协调节点将请求转发给存储数据的 Data Node。每个 Data Node 会将结果返回协调节点,协调节点收集完数据后,将每个 Data Node 的结果合并为单个全局结果。

分片(shared)是底层的工作单元,文档(document)保存在分片内,分片又被分配到集群内的各个节点里,每个分片仅保存全部数据的一部分。注意,分片不是随意进行设定的,而是需要根据实际的生产环境提前进行数据存储的容量规划,若设置的过大或过小都会影响 ES 集群的整体性能。

索引(index)是一类文档的集合,而文档是具体的一条数据,注意,从 ElasticSearch 8 开始,彻底移除了 Type 的概念。

为了便于理解,相关概念与关系型数据库(MySQL)的对比如下:

|-----------|-------------------|
| MySQL | ElasticSearch |
| Table | Index |
| Row | Doucment |
| Column | Field |
| Schema | Mapping |
| SQL | DSL |

二、实战应用

1)安装

在官网可以下载各种操作系统版本的 ES,当进入下载页面时会自动切换成当前电脑的系统。

下载完成后,就可以执行第二步,运行 bin 目录中 elasticsearch 可执行文件,简单点就是将其拖到命令行窗口中。

在安装成功后,保存给出的密码和 token。

2)Kibana

官方提供了一套可视化操作 ES 的系统:Kibana,在下载完成后,运行 bin 目录中的 kibana 文件。

耐心等待,安装成功后,在命令窗口会给出一条地址。

在初始化时会要求填入之前保存的 token,点击 Configure 按钮,若弹出验证码,则将上图中的 code 参数复制过来,配置完成后进入登录页面。

在登录时会用到默认账号 elastic,上一节保存的密码,点击确定进入主页,在左侧菜单中找到 Dev Tools。

点击后就能进入可运行 ES RESTful API 的操作界面。

若 Kibana 启动不了,报错如下:

复制代码
Kibana server is not ready yet.

此时可以打开 config/kibana.yml 中的配置文件,翻到最后,很可能是 hosts 中的 IP 地址有问题,因为电脑重新联网时,IP 地址很有可能变换了,将其改成 localhost 问题就能迎刃而解。

复制代码
elasticsearch.hosts: ['https://172.21.10.10:9200']
elasticsearch.serviceAccountToken: AAEAAWVsYXN0aWMva2liYW5
elasticsearch.ssl.certificateAuthorities: [/Users/pwstrick/code/kibana/data/ca_1699243503862.crt]
xpack.fleet.outputs: [{id: fleet-default-output, name: default, is_default: true, 
is_default_monitoring: true, type: elasticsearch, hosts: ['https://172.21.10.10:9200'], 
ca_trusted_fingerprint: 1b6c0b97e18f22efdd4925a95a4a0dc898de5072e3d6c45938b8d2f0a7f920fb}]

3)RESTful API

ES 提供了对 Document 进行增删改查的常规接口,例如使用 Bulk 接口插入一条数据,_index 就相当于数据库表,第三行就是具体的字段名称和值。

复制代码
POST _bulk
{"index": {"_id": 862024079,"_index": "web_monitor_2023.11"}}
{"id":862024079,"project":"game","project_subdir":"chat","category":"ajax",
"message":"{\"type\":\"GET\",\"url\":\"https://static.xxx.me/xxx.json\",\"status\":200,\"endBytes\":\"80.43KB\",\"interval\":\"9ms\"}",
"key":"80c89d32b27f8f7d43fa8470aeba3f3a","source":"","identity":"xe990bhs4j","referer":"https://www.xxx.me/chat.html",
"message_type":"get","message_status":200,"message_path":"xxx.json","day":"20231103","hour":15,"minute":29,"ctime":1698996585,
"ip":"0.0.0.0","os_name":"iOS","os_version":"15.4.1","app_version":"5.36.1","author":"张三",
"fingerprint":"38eab40b373220bea1bab2933649c","country":"中国","province":"广东省","city":"佛山市","isp":"电信","digit":1}

若要更新或删除一条记录,也可以在 Bulk 接口完成,格式参考如下,更新语句需要包含待更新的数据。

复制代码
POST _bulk
{ "delete" : {"_id" : "2", "_index" : "web_monitor_2023.11" } }

{ "update" : {"_id" : "1", "_index" : "web_monitor_2023.11"} }
{ "doc" : {"field" : "value"} }

使用 Search 接口做查询,格式参考 GET /<target>/_search,其中 target 可以理解为 Index(相当于数据库表的名称)。

复制代码
GET web_monitor_2023.11/_search

响应的 JSON 结构字段包含众多(如下所示),took 是搜索耗费的毫秒数;_shards 中的 total 代表本次搜索一共使用的分片数量;hits 中的 total 代表本次搜索得到的结果数,默认最大值为 1W,max_score 指搜索结果中相关度得分的最大值,默认搜索结果会按照相关度得分降序排列,hits 就是命中的数据列表,而其中的 _score 是单个文档的相关度得分,_source 就是原始数据的 JSON 内容。

复制代码
{
  "took": 6,                // 搜索耗费的毫秒数
  "timed_out": false,
  "_shards": {
    "total": 1,            // 本次搜索一共使用的分片数量
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,          // 本次搜索得到的结果数,默认最大值为 1W
      "relation": "eq"
    },
    "max_score": 1,        // 搜索结果中相关度得分的最大值
    "hits": [
      {
        "_index": "web_monitor_2023.11",
        "_id": "862024079",
        "_score": 1,        // 单个文档的相关度得分
        "_source": {        // 原始数据的 JSON 内容
          "id": 862024079,
          "project": "game",
          "project_subdir": "chat",
          "category": "ajax",
          "fingerprint": "38eab40b373220bea1baee7b2933649c",
          "country": "中国",
          "province": "广东省",
          "city": "佛山市",
          "isp": "电信",
          "digit": 1
        }
      }
    ]
  }
}

如果要计算搜索结果真实的数据量,可以参考 Count 接口,格式为 GET /<target>/_count。

4)索引模板

索引模板(Index Template)允许用户在创建索引时,引用已保存的模板来减少配置项,在 MySQL 中就相当于创建表结构。

Elasticsearch 的索引模板功能以 7.8 版本为界,两个版本的主要区别是模板之间复用方式。

  • 老版本:使用优先级(order)关键字实现,当创建索引匹配到多个索引模板时,高优先级会继承并覆盖低优先级的模板配置,最终多个模板共同起作用。
  • 新版本:删除了 order 关键字,引入了组件模板(Component Template)的概念。在声明索引模板时可以引用多个组件模板,当创建索引匹配到多个索引模板时,选最高权重的那个。

老版本会造成用户在创建索引时,不能明确知道自己到底用了多少模板,索引配置在继承覆盖的过程中容易出错。

创建或更新一个老版索引模板,需要向 /_template 发送 PUT 请求,配置包括 aliases、settings、mappings、order 等字段。

复制代码
PUT _template/web_monitor
{
  order: 0,
  index_patterns: ["web_monitor_*"],
  settings: {
    index: {
      number_of_shards: 1
    }
  },
  mappings: {
    dynamic: "strict",
    properties: {
      app_version: {
        type: "keyword"
      },
      ctime: {
        format: "strict_date_optional_time||epoch_second",
        type: "date"
      },
      digit: {
        type: "keyword",
        fields: {
          num: {
            type: "integer"
          }
        }
      },
      author: {
        type: "keyword"
      },
      ip: {
        type: "ip"
      }
    }
  },
  aliases: {
    web_monitor: {}
  }
}

新版本索引自动配置功能,需要通过组件模板和索引模板来完成。

在组件模板中可配置的字段包括:aliases、settings 和 mappings,组件模板只有在被索引模板引用时,才会发挥作用。当需要创建或更新一个组件模板时,向 /_component_template 发送 PUT 请求即可。

复制代码
PUT /_component_template/ct1
{
  "template": {
    "settings": {
      "index.number_of_shards": 2
    }
  }
}
PUT /_component_template/ct2
{
  "template": {
    "settings": {
      "index.number_of_replicas": 0
    },
    "mappings": {
      "properties": {
        "@timestamp": {
          "type": "date"
        }
      }
    }
  }
}

创建或更新一个索引模板的方式都是向 /_index_template 发送 1 个 POST 请求。

复制代码
POST /_index_template/_simulate
{
  "index_patterns": ["my*"],
  "template": {
    "settings" : {
        "index.number_of_shards" : 3
    }
  },
  "composed_of": ["ct1", "ct2"]
}

5)搜索

下面是一组查询条件,query、from、size 和 sort 平级,分别表示查询条件、页码、页数和排序规则。

复制代码
{
  query: {
    bool: {        // 布尔查询
      must: [
        [
          {
            multi_match: {
              query: "精确",
              fields: ["message", "title"],
              type: "best_fields"
            }
          }
        ]
      ],
      filter: [
        {
          term: {
            category: "error"
          }
        },
        {
          term: {
            project: "backend-app"
          }
        },
        {
          term: {
            message_type: "runtime"
          }
        },
        {
          range: {
            ctime: {
              gte: 1699286400,
              lt: 1699372800
            }
          }
        }
      ]
    }
  },
  from: 0,
  size: 10,
  sort: [
    {
      id: {
        order: "DESC"
      }
    }
  ]
}

布尔查询(bool),只有符合整个布尔条件的文档才会被搜索出来,支持 4 种组合类型:

  1. must:可包含多个查询条件,每个条件都被满足才能命中,每次查询需要计算相关度得分。
  2. should:可包含多个查询条件,只要满足一个条件就能命中,匹配到结果越多,相关度得分也越高。
  3. filter:与 must 作用类似,但是不计算相关度得分,结果在一定条件下会被缓存。
  4. must_not:与 must 作用相反,并且也不计算相关度得分,结果在一定条件下会被缓存。

多字段匹配(multi_match)允许用同一段文本检索多个字段,其中 best_fields 是默认的搜索方式,搜索文本与哪个字段相关度最高,就使用最佳字段中的 _score。

ES 内置了 8 种文本分析器,但对于中文的支持并不友好,无法准确的反映中文文本的语义,所以对于中文需要安装另一款分析器:ik

除了常规的全文检索和精准查询之外,ES 还支持经纬度搜索,包括圆形、矩形和多边形范围内的搜索。

6)聚合

当需要对数据做分析时,就需要对数据进行聚合。在 MySQL 中常用的就是 sum()、group by 等语法。

ES 提供的聚合分为 3 大类:

  1. 度量聚合:计算搜索结果在某个字段上的数量统计指标,包括平均值、最大值、最小值、求和、基数(唯一值)、百分比、头部命中等。
  2. 桶聚合:在某个字段上划定一些区间,每个区间是一个桶,统计结果能明确每个桶中的文档数量。桶聚合还能嵌套其他的桶聚合或度量聚合来进行更为复杂的指标计算,例如词条、直方图、缺失等聚合。
  3. 管道聚合:把桶聚合统计的结果作为输入来继续做聚合统计,在结果中追加一些额外的统计数据。

下面是一个桶聚合的例子,在查询条件中使用了 ES 特有的时间范围语法糖(now-7d/d)。

聚合部分要使用 aggs 属性包裹,其子属性 date 自定义的聚合名称(在搜索结果中也会包含这个自定义的名称),date_histogram 是聚合类型,以天为间隔,计算每天符合条件的数量。

复制代码
{
  query: {
    bool: {
      filter: [
        {
          term: {
            category: "error"
          }
        },
        {
          range: {
            ctime: {
              gt: "now-7d/d",        // 当前时间减去 7 天
              lte: "now/d"
            }
          }
        }
      ]
    }
  },
  aggs: {
    date: {
      date_histogram: {
        field: "ctime",             // 字段名称
        interval: "day",            // 以天为间隔
        time_zone: "+08:00"
      }
    }
  }
}

聚合结果与查询结果类似,也会包含符合查询条件的文档列表,但是还会多一个 aggregations 属性。

其 date 属性就是之前自定义的聚合名称,buckets 中就是聚合结果,key 是聚合的字段值,doc_count 是计算的结果值,key_as_string 是格式化后的日期值,可在查询时指定格式。

复制代码
{
  took: 245,
  timed_out: false,
  _shards: {
    total: 2,
    successful: 2,
    skipped: 0,
    failed: 0
  },
  hits: {
    total: {
      value: 3799,
      relation: "eq"
    },
    max_score: 0,
    hits: [{}, {}]
  },
  aggregations: {
    date: {
      buckets: [
        {
          key_as_string: "2023-11-02T00:00:00.000+08:00",
          key: 1698854400000,
          doc_count: 451
        },
        {
          key_as_string: "2023-11-03T00:00:00.000+08:00",
          key: 1698940800000,
          doc_count: 594
        },
        {
          key_as_string: "2023-11-04T00:00:00.000+08:00",
          key: 1699027200000,
          doc_count: 612
        }
      ]
    }
  }
}

参考资料:

elasticsearch 原理及入门

Elasticsearch-基础介绍及索引原理分析

10 张图理解 Elasticsearch 核心概念

Frame of Reference 和 Roaring Bitmaps

RBM压缩算法

全文搜索引擎 Elasticsearch 入门教程

elasticsearch-Index template 索引模板