一、什么是文档

文档是索引中数据的基本单位,类似于关系型数据库中的一条记录,文档的在ES中以json的数据格式存储。

当一条记录存储到ES中后,ES会为每个文档添加一些除文档内容之外的其他属性信息,用来描述该文档。常用的以用来描述文档的属性有一下这些:

_index:表示文档存放在哪个索引中。索引我们大家都知道,它是一类结构相同的文档集合。看过前面文章"Elasticsearch学习-ES中的一些组件介绍"的老铁都知道,文档其实是被存储在分片中的,而索引仅仅是逻辑上的命名空间, 这个命名空间由一个或者多个分片组合在一起。 然而,这是一个内部细节,我们的应用程序根本不应该关心分片,对于应用程序而言,只需知道文档位于一个索引内即可, ES会帮我们处理这些所有的细节问题。

_type:文档所属的类型,类型是索引对文档的分类,是一种更细粒度的划分。在一个索引中的文档可能会有多种类型,不过在高版本中的ES中,已经弱化了类型的概念,在7.x的版本中,文档只有一种类型:_doc。

_id:ID 是一个字符串,当它和 _index 以及 _type 组合在一起,就可以唯一确定 ES 中的一个文档,当你创建一个新的文档,要么业务自定义id ,要么让ES自动生成。

_version:是文档在ES中的版本,第一次索引一个文档中,版本号是1,后面每次对文档进行更新时,版本号都会递增,文档的版本号,主要用来实现并发更新文档时保证数据的一致性,类似于乐观锁的作用。

_source:是被索引文档的内容。

二、文档的基本操作

下面介绍一下对文档的一些基本操作,对于存储类中间件的操作,通常包括如下四种:增,删,改和差,也就是常说的crud。

新增文档:

上面说过,在索引一个文档时,可以指定id,也可以使用ES来自动生id。

如果使用业务指定的文档id的话,可以使用下面的方式:

PUT /{index}/{type}/{id}
{
  "field": "value",
  ...
}

使用PUT命令,如果文档不存在,就插入,如果文档存在就更新,判断文档是否存在的依据是,index,type和id三个字段的组合是否存在。其中index表示文档要写入到哪个索引,type表示文档是哪种类型,id是业务指定的id。

举个例子,索引称为 website ,类型称为 blog ,并且选择 1 作为 ID ,那么索引请求应该是下面这样:

PUT /website/blog/1
{
  "title": "My first blog entry",
  "text":  "Just trying this out...",
  "date":  "2022/01/01"
}

此时ES的响应如下:

{
   "_index":    "website",
   "_type":     "blog",
   "_id":       "1",
   "_version":  1,
   "created":   true
}

其中_id为业务指定的1,_version为文档的当前版本1,created:true表示此次操作为新增。

如果使用ES自动生成文档id的话,可以使用下面这种方式:

POST /website/blog/
{
  "title": "My second blog entry",
  "text":  "Still trying this out...",
  "date":  "2022/01/01"
}

和上面使用PUT命令不同的是,使用POST命令,不用指定ID,其余参数相同。不过也需要指定文档要写入索引和文档的内容。此时ES的响应如下:

{
   "_index":    "website",
   "_type":     "blog",
   "_id":       "AVFgSgVHUP18jI2wRx0w",
   "_version":  1,
   "created":   true
}

其中id为AVFgSgVHUP18jI2wRx0w,是ES自动帮我们生成的。

自动生成的 ID 是 URL-safe、 基于 Base64 编码且长度为20个字符的 GUID 字符串。 这些 GUID 字符串由可修改的 FlakeID 模式生成,这种模式允许多个节点并行生成唯一 ID ,且互相之间的冲突概率几乎为零

查询文档:

查询文档的方式有多种,可以按照id直接查询,也可以按照指定的其他条件查询,本文先演示基于id查询,其他条件的查询方式,后面用单独文章介绍。和插入类似,查询文档也需要指定index和type,表示从哪个索引中,找那种类型的文档。类似下面这种形式:

GET /{index}/{type}/{id}

查找id=1的文档:

GET /website/blog/1

ES的响应如下:

{
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "123",
  "_version" : 1,
  "found" :    true,
  "_source" :  {
      "title": "My first blog entry",
      "text":  "Just trying this out...",
      "date":  "2014/01/01"
  }
}

在返回的结果中,found表示结果是否找到,如果found值为true,表示找到了目标文档,如果目标文档不存在,那么返回的结果,仍然是一个json结果,只不过found为false。

很多时候,我们可能只需要返回结果的中文档内容,也就是_source字段,此时查询语句可以如下:

GET /website/blog/1/_source

此时返回的结果只有文档内容,结果如下:

{
   "title": "My first blog entry",
   "text":  "Just trying this out...",
   "date":  "2014/01/01"
}

如果我们只需要文档内容的某些字段的话,此时可以在查询语句中指定要返回的字段名称即可,比如我们只需要title字段,查询语句如下:

GET /website/blog/123?_source=title

ES的返回结果如下:

{
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "123",
  "_version" : 1,
  "found" :   true,
  "_source" : {
      "title": "My first blog entry"
  }
}
修改文档:

修改文档通常是对指定某个文档的内容做修改,此时同样可以使用PUT来完成,比如,将id=1的文档title修改“My first blog entry2”,可以指定如下命令:

PUT /website/blog/1
{
  "title": "My first blog entry2",
  "text":  "Just trying this out...",
  "date":  "2022/01/01"
}

此时ES的返回结果如下:

{
   "_index":    "website",
   "_type":     "blog",
   "_id":       "1",
   "_version":  2,
   "created":   false
}

和第一次执行结果不同的是,本次返回结果中_version为2,created的值变为false。

此时在查看文档,文档的title已经发生了变化:

GET /website/blog/1

ES的响应如下:

{
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "123",
  "_version" : 1,
  "found" :    true,
  "_source" :  {
      "title": "My first blog entry2",
      "text":  "Just trying this out...",
      "date":  "2014/01/01"
  }
}
删除文档

使用DELETE命令可以删除指定id的文档,具体命令如下:

DELETE /website/blog/123

如果找到该文档,ES会返回一个 200 ok 的 HTTP 响应码,和一个类似以下结构的响应体。注意,字段 _version 值已经增加:

{
  "found" :    true,
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "123",
  "_version" : 3
}

如果文档没有找到,我们将得到 404 Not Found 的响应码:

{
  "found" :    false,
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "123",
  "_version" : 4
}

即使文档不存在( found 是 false ), _version 值仍然会增加。这是 ES内部记录本的一部分,用来确保这些改变在跨多节点时以正确的顺序执行。

三、如何解决并发更新问题

数据冲突

当使用ES api对文档进行并发更新时,可能会导致部分更新丢失,这对某些业务来说是不可接受,以电商商品库存量为例:在ES索引中的每一个文档记录着每个商品的信息和该商品的库存量。每当有用户下单,存库就会减1。当多个用户同时下单时,可能就会导致库更新错误,导致商品超卖。具体如下图:
在这里插入图片描述
用户1和用户2同时下单,下单后,库存量正常应该是98,但是由于并发的原因,导致存库量仍然是99,导致了库存量错误。

因为在整个下单的过程中,会涉及多个独立操作:获取当前库存量库存量减1更新库存量。当变更越频繁读数据和更新数据的间隙越长,也就越可能丢失变更。

这种并发更新导致数据不一致的问题,在关系型数据库中的解决方案有两种:悲观锁和乐观锁。

悲观锁并发控制:这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。 一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。

乐观锁并发控制:该方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。 然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。

关于悲观锁和乐观锁的详细内容,可以参考“悲观锁和乐观锁的原理与实践

乐观锁解决方案

在ES中,并发更新情况下数据冲突的解决方案是乐观锁。而在每个文档中都有一个_version属性,每次更新操作后,_version的值都会增加,ES就是利用该字段实现乐观锁的。

使用乐观锁解决并发情况下数据一致性的核心逻辑是保证请求处理顺序性。在ES中利用_version来保证请求处理顺序性:相同版本的请求,只处理先到达的,后到达请求会被拒绝处理。为了方便理解,我们看一下下面这个例子:

向文档中插入一条文档:

PUT /website/blog/1/_create
{
  "title": "My first blog entry",
  "text":  "Just trying this out..."
}

查看文档的版本:

GET /website/blog/1

{
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "1",
  "_version" : 1,
  "found" :    true,
  "_source" :  {
      "title": "My first blog entry",
      "text":  "Just trying this out..."
  }
}

此时文档的_version=1。

此时我们我们修改文档内容,并指定版本号为1:

PUT /website/blog/1?version=1 
{
  "title": "My first blog entry",
  "text":  "Starting to get the hang of this..."
}

更新请求的响应为:

{
  "_index":   "website",
  "_type":    "blog",
  "_id":      "1",
  "_version": 2
  "created":  false
}

此时_version=2。

假如在上面执行更新操作的同时,还有另外一个客户端也在执行更新,更新请求的版本号同样是1,那么此时ES就会拒绝更新,返回结果如下:

{
   "error": {
      "root_cause": [
         {
            "type": "version_conflict_engine_exception",
            "reason": "[blog][1]: version conflict, current [2], provided [1]",
            "index": "website",
            "shard": "3"
         }
      ],
      "type": "version_conflict_engine_exception",
      "reason": "[blog][1]: version conflict, current [2], provided [1]",
      "index": "website",
      "shard": "3"
   },
   "status": 409
}

返回的结果的状态码为409,失败原因为版本冲突。

这里需要注意一下,在高版本的ES中,不再使用_version作为判断数据冲突的依据,而是利用 _seq_no和**_primary_term**两个字段作为判断依据,但是原理是相通的,这里不再赘述。

除了使用内部_version作为并发控制外,ES中还有一种使用外部版本号的方式,这里的外部主要是强调版本号是业务自己指定的,而非ES自动生成的。

外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同, ES不是检查当前 _version 和请求中指定的版本号是否相同, 而是检查当前 _version 是否 小于 指定的版本号。 如果请求成功,外部的版本号作为文档的新 _version 进行存储。

如下,我们使用外部版本号进行文档更新:

PUT /website/blog/2?version=5&version_type=external
{
  "title": "My first external blog entry",
  "text":  "Starting to get the hang of this..."
}

从请求的响应中,我们可以看到此时_version=5。

{
  "_index":   "website",
  "_type":    "blog",
  "_id":      "2",
  "_version": 5,
  "created":  true
}

如果我们想让接下来对该文档更新成功的话,必须将_version设置为大于5的数值。如将_version设置为10:

PUT /website/blog/2?version=10&version_type=external
{
  "title": "My first external blog entry",
  "text":  "This is a piece of cake..."
}

响应的结果标识_version为10.

{
  "_index":   "website",
  "_type":    "blog",
  "_id":      "2",
  "_version": 10,
  "created":  true
}

而如果制定_version为小于等于5的数字是,更新将会失败。

四、总结

看到使用乐观锁来解决并发问题,你有没有发现,很多知识都是相同的,乐观锁不仅可以在ES中使用,在关系型数据库中使用的也很广泛,很多基础性原理知识,不会随着新框架的迭代而淘汰,类似于之前学习的数据结构,操作系统等课程,所以,在日常的学习过程中,一定要学习基础性知识,只有这些知识学好了,学习其他新的框架才能更快。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐