工程

Elasticsearch 版本控制支持

Elasticsearch 背后的一个关键原则是,让您可以充分利用您的数据。最初,搜索是一种只读业务,搜索引擎从单一源加载数据。随着使用量的增加,Elasticsearch 在应用程序中的重要性与日俱增,数据需要由多个组件更新。多个组件会导致并发,而并发又导致了冲突。Elasticsearch 的版本控制系统就是帮助解决这些冲突的。

举例说明版本控制的必要性

为了说明情况,我们假设有一个网站,人们用它来评价 T 恤的设计。这个网站很简单。它列出了所有设计,用户可以对设计竖起大拇指,也可以使用拇指向下图标对设计投反对票。对于每件 T 恤,网站都会显示当前的赞成票与反对票的对比情况。

每个搜索引擎的记录如下:

curl -XPOST 'http://localhost:9200/designs/shirt/1' -d'
{
"name": "elasticsearch",
"votes": 999
}'

可以看到,每件 T 恤的设计都有一个名称和一个票数计数器,用于跟踪当前的对比情况。

为了保持简单化和可扩展性,网站是完全无状态的。当有人浏览页面并点击赞成票按钮时,系统会向服务器发送一个 AJAX 请求,指示 Elasticsearch 更新计数器。为此,一个简单的实现方法是,获取当前的票数值,将其递增 1,然后发送给 Elasticsearch:

curl -XPOST 'http://localhost:9200/designs/shirt/1' -d'
{
"name": "elasticsearch",
"votes": 1000
}'

这种做法有一个严重的缺陷,那就是可能会丢失票数。假设 Adam 和 Eve 同时在查看同一个页面。此时该页面显示票数为 999。由于这两个人都是支持者,他们都点击了赞成票按钮。现在,Elasticsearch 收到了上述请求的两个相同副本来更新文档,它很乐意这样做。这意味着总票数不再是 1001 票,而是现在的 1000 票。糟糕!

当然, 使用更新 API 可以变得更加智能,并传达这样一个事实:投票数可以递增,而不是设置为特定值:

curl -XPOST 'http://localhost:9200/designs/shirt/1/_update' -d'
{
"script" : "ctx._source.votes += 1"
}'

这样做的逻辑是,Elasticsearch 首先在内部检索文档,执行更新,然后再次执行索引。虽然这样做更有可能成功,但仍然存在与先前相同的潜在问题。在检索文档和再次执行索引之间的一个短暂的时间段内,可能会出现错误。

为了处理上述情况,帮助解决更复杂的问题,Elasticsearch 提供了内置的版本控制系统。

Elasticsearch 的版本控制系统

您存储在 Elasticsearch 中的每一个文档都有一个相关的版本号。这个版本号是介于 1 和 2 63-1(包含本数)之间的一个正数。当您第一次对文档进行索引时,它会得到版本号 1,您可以在 Elasticsearch 返回的响应中看到这一点。例如,以下是本博文中第一个 cURL 命令的结果:

{
"ok": true,
"_index": "designs",
"_type": "shirt",
"_id": "1",
"_version": 1
}

每次对这个文档执行写入操作(无论是 索引、更新还是删除),Elasticsearch 都会将版本号递增 1。这种递增是原子性的,并且保证会在操作成功返回时进行递增。

Elasticsearch 还会在 get 操作的响应中返回文档的当前版本(请注意,这些都是实时的),此外,还会 收到指示,在每次搜索结果中都返回当前版本。

乐观锁定

回到我们的示例中,我们需要解决两个用户同时尝试更新同一文档的问题。传统的解决方法是锁定:在更新文档之前,一个人将获取文档锁,完成更新后再释放锁。对文档执行锁定后,就可以保证没有人能更改该文档。

在许多应用程序中,这也意味着如果有人正在修改文档,则在修改完成之前,其他人都无法读取该文档。这种锁定方式虽然有效,但也需要付出代价。在高吞吐量的系统环境下,它有两个主要缺点:

  • 在许多情况下,根本不需要这样做。如果操作得当,极少会发生冲突。当然,冲突也会发生,但只占系统操作的一小部分。
  • 锁定的前提是您真的需要这样做。如果您只是想要呈现一个网页,可以获取一些略微过时但一致的值,即使系统知道这些值即刻会发生变化也没关系。读取操作并不总是需要等到正在进行的写入操作完成。

借助 Elasticsearch 的版本控制系统,可以轻松地使用另一种模式,即乐观锁定。不需要每次都获取锁,而是让 Elasticsearch 知道您希望找到哪个版本的文档。如果文档在此期间没有发生变化,则操作成功,不会锁定。如果文档中的某些内容发生了变化,有了更新的版本,Elasticsearch 会向您发出信号,让您适当地进行处理。

回到上面的搜索引擎投票示例,来看看它的工作原理。当我们呈现一个关于衬衫设计的页面时,会记下文档的当前版本。这会随我们对页面发起的 get 请求的响应一起返回:

curl -XGET 'http://localhost:9200/designs/shirt/1'
{
"_index": "designs",
"_type": "shirt",
"_id": "1",
"_version": 4,
"exists": true,
"_source": {
"name": "elasticsearch",
"votes": 1002
}
}

在用户投票后,如果在此期间没有发生任何更改,我们可以指示 Elasticsearch 仅索引新值 (1003):(注意额外的 version 查询字符串参数)

curl -XPOST 'http://localhost:9200/designs/shirt/1?version=4' -d'
{
"name": "elasticsearch",
"votes": 1003
}'

在内部,Elasticsearch 要做的是比较两个版本号。这比获取锁和释放锁要简单很多。如果没有人更改文档,操作将成功,状态代码为 200 OK。不过,如果有人更改了文档(导致内部版本号增大),操作将失败,状态代码为 409 Conflict。现在,我们的网站可以做出正确的响应。它会检索新文档,增加投票数,然后使用新版本值重试。这很有可能会成功。如果不成功,我们只需要重复执行上述过程。

这种模式非常常见,Elasticsearch 的 更新终端都可以为您执行此操作。您可以设置 retry_on_conflict 参数,让它在版本发生冲突的情况下重试操作。在与脚本更新结合使用时,尤其方便。例如,下面这个 cURL 将告诉 Elasticsearch 在失败之前尝试更新文档最多 5 次:

curl -XPOST 'http://localhost:9200/designs/shirt/1/_update?retry_on_conflict=5' -d'
{
"script" : "ctx._source.votes += 1"
}'

请注意,版本控制检查是一个完全可选的功能。您可以选择在更新某些字段(比如 votes)时强制执行版本控制,而在更新其他字段(通常是文本字段,比如 name)时忽略版本控制。这完全取决于应用程序的需求和您的权衡。

已经有版本控制系统了吗?

除了内部支持外,Elasticsearch 还可以很好地与其他系统维护的文档版本配合使用。例如,您可能将数据存储在另一个数据库中,它会为您维护版本控制,或者可能有一些应用程序特定的逻辑来决定版本控制的行为方式。在这种情况下,您仍然可以使用 Elasticsearch 的版本控制支持,指示它使用 external 版本类型。Elasticsearch 可以与任何数值的版本控制系统(1:263-1 范围内)配合使用,前提是保证每次对文档进行更改时,版本号的数值都会增大。

若要让 Elasticssearch 使用外部版本控制,请在 每个更改数据的请求中添加 version_type 参数和 version 参数。例如:

curl -XPOST 'http://localhost:9200/designs/shirt/1?version=526&version_type=external' -d'
{
"name": "elasticsearch",
"votes": 1003
}'

在其他平台维护版本控制意味着 Elasticsearch 不一定知道其中的每一次更改。这对版本控制的实现有细微的影响。

我们来看看上面的索引命令。使用 internal 版本控制,意味着“仅在文档的当前版本等于 526 时,才会对该文档更新进行索引”。如果版本匹配,Elasticsearch 会将版本号增加 1,并存储该文档。但是,如果使用外部版本控制系统,就无法强制执行此要求。版本控制系统可能并不是每次都递增 1。它或许会使用任意数字跳级增加(想想基于时间的版本控制)。或者,可能很难将每一次版本变更都传达给 Elasticsearch。鉴于所有这些原因,external 版本控制支持的行为会略有不同。

将 version_type 设置为 external 后,Elasticsearch 将按照指定的版本号存储,不会递增版本号。此外,Elasticsearch 不会检查是否完全匹配,只会在当前存储的版本大于或等于索引命令中的版本时返回版本冲突错误。这实际上意味着“只有在没有其他人同时提供相同或更新版本的情况下,才会存储此信息”。具体来说,如果存储的版本号小于 526,上述请求就会成功。如果大于或等于 526,将导致请求失败。

重要提示:使用 external 版本控制时,请确保始终将当前 version(和 version_type)添加到任何索引、更新或删除调用中。如果您忘记这样做,Elasticsearch 将使用它的内部系统来处理该请求,这会导致错误地递增版本号。

最后关于删除的一些内容。

删除数据可能会导致版本控制系统出现问题。一旦删除了数据,系统就无法正确地知道新请求是否已经过期,或者是否实际上包含了新信息。例如,假设我们运行以下代码来删除一条记录:

curl -XDELETE 'http://localhost:9200/designs/shirt/1?version=1000&version_type=external'

这个删除操作是文档的第 1000 个版本。如果我们将知道的所有信息都删除,未同步的后续请求就会出错:

curl -XPOST 'http://localhost:9200/designs/shirt/1/?version=999&version_type=external' -d'
{
"name": "elasticsearch",
"votes": 3001
}'

如果我们忘记了该文档曾经存在过,就会接受这个调用,并创建一个新文档。但是,实际上,该操作的版本 (999) 指示这是旧版本,应继续删除该文档。

您可能会说,这很简单,并不是真的删除所有内容,而是要记住删除操作、它们所引用的文档 ID 及其版本。虽然这确实解决了这个问题,但也有代价。如果有人重复执行索引文档再删除文档的操作,资源很快就会耗尽。

Elasticsearch 搜索在两者之间实现了平衡。它会保留删除记录,但一分钟后就会忘记这些记录。这就是删除垃圾收集。对于大多数实际用例来说,60 秒足以让系统跟上进度,并让延迟的请求到达。如果您认为不够,可以进行更改,将索引的 index.gc_deletes 设置为其他时间范围。