es局部更新与版本控制

更新文档中的一部分
在《更新》一章中,我们讲到了要是想更新一个文档,那么就需要去取回数据,更改数据然后将整个文档进行重新索引。当然,你还可以通过使用更新API来做部分更新,比如增加一个计数器。

正如我们提到的,文档不能被修改,它们只能被替换掉。更新API也必须遵循这一法则。从表面看来,貌似是文档被替换了。对内而言,它必须按照找回-修改-索引的流程来进行操作与管理。不同之处在于这个流程是在一个片(shard) 中完成的,因此可以节省多个请求所带来的网络开销。除了节省了步骤,同时我们也能减少多个进程造成冲突的可能性。

使用更新请求最简单的一种用途就是添加新数据。新的数据会被合并到现有数据中,而如果存在相同的字段,就会被新的数据所替换。例如我们可以为我们的博客添加tags和views字段:

POST /website/blog/1/_update
{
   "doc" : {
      "tags" : [ "testing" ],
      "views": 0
   }
}
如果请求成功,我们就会收到一个类似于索引时返回的内容:

{
   "_index" :   "website",
   "_id" :      "1",
   "_type" :    "blog",
   "_version" : 3
}
再次取回数据,你可以在_source中看到更新的结果:

{
   "_index":    "website",
   "_type":     "blog",
   "_id":       "1",
   "_version":  3,
   "found":     true,
   "_source": {
      "title":  "My first blog entry",
      "text":   "Starting to get the hang of this...",
      "tags": [ "testing" ], <1>
      "views":  0 <1>
   }
}
新的数据已经添加到了字段_source中。
使用脚本进行更新

我们将会在《脚本》一章中学习更详细的内容,我们现在只需要了解一些在Elasticsearch中使用API无法直接完成的自定义行为。默认的脚本语言叫做MVEL,但是Elasticsearch也支持JavaScript, Groovy 以及 Python。

MVEL是一个简单高效的JAVA基础动态脚本语言,它的语法类似于Javascript。你可以在Elasticsearch scripting docs 以及 MVEL website了解更多关于MVEL的信息。

脚本语言可以在更新API中被用来修改_source中的内容,而它在脚本中被称为ctx._source。例如,我们可以使用脚本来增加博文中views的数字:

POST /website/blog/1/_update
{
   "script" : "ctx._source.views+=1"
}
我们同样可以使用脚本在tags数组中添加新的tag。在这个例子中,我们把新的tag声明为一个变量,而不是将他写死在脚本中。这样Elasticsearch就可以重新使用这个脚本进行tag的添加,而不用再次重新编写脚本了:

POST /website/blog/1/_update
{
   "script" : "ctx._source.tags+=new_tag",
   "params" : {
      "new_tag" : "search"
   }
}
获取文档,后两项发生了变化:

{
   "_index":    "website",
   "_type":     "blog",
   "_id":       "1",
   "_version":  5,
   "found":     true,
   "_source": {
      "title":  "My first blog entry",
      "text":   "Starting to get the hang of this...",
      "tags":  ["testing", "search"], <1>
      "views":  1 <2>
   }
}
tags数组中出现了search。
views字段增加了。
我们甚至可以使用ctx.op来根据内容选择是否删除一个文档:

POST /website/blog/1/_update
{
   "script" : "ctx.op = ctx._source.views == count ? 'delete' : 'none'",
    "params" : {
        "count": 1
    }
}
更新一篇可能不存在的文档

想象一下,我们可能需要在Elasticsearch中存储一个页面计数器。每次用户访问这个页面,我们就增加一下当前页面的计数器。但是如果这是个新的页面,我们不能确保这个计数器已经存在。如果我们试着去更新一个不存在的文档,更新操作就会失败。

为了防止上述情况的发生,我们可以使用upsert参数来设定文档不存在时,它应该被创建:

POST /website/pageviews/1/_update
{
   "script" : "ctx._source.views+=1",
   "upsert": {
       "views": 1
   }
}
首次运行这个请求时,upsert的内容会被索引成新的文档,它将views字段初始化为1。当之后再请求时,文档已经存在,所以脚本更新就会被执行,views计数器就会增加。

更新和冲突

在本节的开篇我们提到了当取回与重新索引两个步骤间的时间越少,发生改变冲突的可能性就越小。但它并不能被完全消除,在更新的过程中还可能存在另一个进程进行重新索引的可能性。

为了避免丢失数据,更新API会在获取步骤中获取当前文档中的_version,然后将其传递给重新索引步骤中的索引请求。如果其他的进程在这两部之间修改了这个文档,那么_version就会不同,这样更新就会失败。

对于很多的局部更新来说,文档有没有发生变化实际上是不重要的。例如,两个进程都要增加页面浏览的计数器,谁先谁后其实并不重要 —— 发生冲突时只需要重新来过即可。

你可以通过设定retry_on_conflict参数来设置自动完成这项请求的次数,它的默认值是0。

POST /website/pageviews/1/_update?retry_on_conflict=5 <1>
{
   "script" : "ctx._source.views+=1",
   "upsert": {
       "views": 0
   }
}
失败前重新尝试5次
这个参数非常适用于类似于增加计数器这种无关顺序的请求,但是还有些情况的顺序就是很重要的。例如上一节提到的情况,你可以参考乐观并发控制以及悲观并发控制来设定文档的版本号。



当你使用索引API来更新一个文档时,我们先看到了原始文档,然后修改它,最后一次性地将整个新文档进行再次索引处理。Elasticsearch会根据请求发出的顺序来选择出最新的一个文档进行保存。但是,如果在你修改文档的同时其他人也发出了指令,那么他们的修改将会丢失。

很长时间以来,这其实都不是什么大问题。或许我们的主要数据还是存储在一个关系数据库中,而我们只是将为了可以搜索,才将这些数据拷贝到Elasticsearch中。或许发生多个人同时修改一个文件的概率很小,又或者这些偶然的数据丢失并不会影响到我们的正常使用。

但是有些时候如果我们丢失了数据就会出大问题。想象一下,如果我们使用Elasticsearch来存储一个网店的商品数量。每当我们卖出一件,我们就会将这个数量减少一个。

突然有一天,老板决定来个大促销。瞬间,每秒就产生了多笔交易。并行处理,多个进程来处理交易:

无并发控制的后果

web_1中库存量的变化丢失的原因是web_2并不知道它所得到的库存量数据是是过期的。这样就会导致我们误认为还有很多货存,最终顾客就会对我们的行为感到失望。

当我们对数据修改得越频繁,或者在读取和更新数据间有越长的空闲时间,我们就越容易丢失掉我们的数据。

以下是两种能避免在并发更新时丢失数据的方法:

悲观并发控制(PCC)

这一点在关系数据库中被广泛使用。假设这种情况很容易发生,我们就可以阻止对这一资源的访问。典型的例子就是当我们在读取一个数据前先锁定这一行,然后确保只有读取到数据的这个线程可以修改这一行数据。

乐观并发控制(OCC)

Elasticsearch所使用的。假设这种情况并不会经常发生,也不会去阻止某一数据的访问。然而,如果基础数据在我们读取和写入的间隔中发生了变化,更新就会失败。这时候就由程序来决定如何处理这个冲突。例如,它可以重新读取新数据来进行更新,又或者它可以将这一情况直接反馈给用户。

乐观并发控制

Elasticsearch是分布式的。当文档被创建、更新或者删除时,新版本的文档就会被复制到集群中的其他节点上。Elasticsearch即是同步的又是异步的,也就是说复制的请求被平行发送出去,然后可能会混乱地到达目的地。这就需要一种方法能够保证新的数据不会被旧数据所覆盖。

我们在上文提到每当有索引、put和删除的操作时,无论文档有没有变化,它的_version都会增加。Elasticsearch使用_version来确保所有的改变操作都被正确排序。如果一个旧的版本出现在新版本之后,它就会被忽略掉。

我们可以利用_version的优点来确保我们程序修改的数据冲突不会造成数据丢失。我们可以按照我们的想法来指定_version的数字。如果数字错误,请求就是失败。

我们来创建一个新的博文:

PUT /website/blog/1/_create
{
  "title": "My first blog entry",
  "text":  "Just trying this out..."
}
反馈告诉我们这是一个新建的文档,它的_version是1。假设我们要编辑它,把这个数据加载到网页表单中,修改完毕然后保存新版本。

首先我们先要得到文档:

GET /website/blog/1
返回结果显示_version为1:

{
  "_index" :   "website",
  "_type" :    "blog",
  "_id" :      "1",
  "_version" : 1,
  "found" :    true,
  "_source" :  {
      "title": "My first blog entry",
      "text":  "Just trying this out..."
  }
}
现在,我们试着重新索引文档以保存变化,我们这样指定了version的数字:

PUT /website/blog/1?version=1 <1>
{
  "title": "My first blog entry",
  "text":  "Starting to get the hang of this..."
}
我们只希望当索引中文档的_version是1时,更新才生效。
请求成功相应,返回内容告诉我们_version已经变成了2:

{
  "_index":   "website",
  "_type":    "blog",
  "_id":      "1",
  "_version": 2
  "created":  false
}
然而,当我们再执行同样的索引请求,并依旧指定version=1时,Elasticsearch就会返回一个409 Conflict的响应码,返回内容如下:

{
  "error" : "VersionConflictEngineException[[website][2] [blog][1]:
             version conflict, current [2], provided [1]]",
  "status" : 409
}
这里面指出了文档当前的_version数字是2,而我们要求的数字是1。

我们需要做什么取决于我们程序的需求。比如我们可以告知用户已经有其它人修改了这个文档,你应该再保存之前看一下变化。而对于上文提到的库存量问题,我们可能需要重新读取一下最新的文档,然后显示新的数据。

所有的有关于更新或者删除文档的API都支持version这个参数,有了它你就通过修改你的程序来使用乐观并发控制。

使用外部系统的版本

还有一种常见的情况就是我们还是使用其他的数据库来存储数据,而Elasticsearch只是帮我们检索数据。这也就意味着主数据库只要发生的变更,就需要将其拷贝到Elasticsearch中。如果多个进程同时发生,就会产生上文提到的那些并发问题。

如果你的数据库已经存在了版本号码,或者也可以代表版本的时间戳。这是你就可以在Elasticsearch的查询字符串后面添加version_type=external来使用这些号码。版本号码必须要是大于零小于9.2e+18(Java中long的最大正值)的整数。

Elasticsearch在处理外部版本号时会与对内部版本号的处理有些不同。它不再是检查_version是否与请求中指定的数值相同,而是检查当前的_version是否比指定的数值小。如果请求成功,那么外部的版本号就会被存储到文档中的_version中。

外部版本号不仅可以在索引和删除请求时使用,还可以在创建时使用。

例如,创建一篇使用外部版本号为5的博文,我们可以这样操作:

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为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":  false
}
如果你再次执行这个命令,你会得到之前的错误提示信息,因为你所指定的版本号并没有大于当前Elasticsearch中的版本号。

猜你喜欢

转载自oaibf.iteye.com/blog/2354453
今日推荐