英文版地址: https://www.elastic.co/guide/en/elasticsearch/guide/current/concurrency-solutions.html
本书基于 Elasticsearch 2.x 版本,有些内容可能已经过时。
解决并发问题edit
当我们允许多个人 同时 重命名文件或目录时,问题就来了。
设想一下,你正在对一个包含了成百上千文件的目录 /clinton
进行重命名操作。
同时,另一个用户对这个目录下的单个文件 /clinton/projects/elasticsearch/README.txt
进行重命名操作。
这个用户的修改操作,尽管在你的操作后开始,但可能会更快的完成。
以下有两种情况可能出现:
-
你决定使用
version
(版本号),在这种情况下,当与README.txt
文件重命名的版本号产生冲突时,你的批量重命名操作将会失败。 - 你没有使用版本控制,你的变更将覆盖其他用户的变更。
问题的原因是 Elasticsearch 不支持 ACID 事务。 对单个文件的变更是 ACIDic 的,但包含多个文档的变更不支持。
如果你的主要数据存储是关系数据库,并且 Elasticsearch 仅仅作为一个搜索引擎 或一种提升性能的方法,可以首先在数据库中执行变更动作,然后在完成后将这些变更复制到 Elasticsearch。 通过这种方式,你将受益于数据库 ACID 事务支持,并且在 Elasticsearch 中以正确的顺序变更。 并发在关系数据库中得到了处理。
如果你不使用关系型存储,这些并发问题就需要在 Elasticsearch 的事务级别进行处理。 以下是三个切实可行的使用 Elasticsearch 的解决方案,它们都涉及某种形式的锁:
- 全局锁 (Global Locking)
- 文档锁 (Document Locking)
- 树锁 (Tree Locking)
当使用一个外部系统替代 Elasticsearch 时,本节中所描述的解决方案可以通过相同的原则来实现。
全局锁 (Global Locking)edit
通过在任何时间只允许一个进程来进行变更动作,我们可以完全避免并发问题。 大多数的变更只涉及少量文件,会很快完成。一个顶级目录的重命名操作会对其他变更造成较长时间的阻塞,但可能很少这样做。
因为在 Elasticsearch 文档级别的变更支持 ACIDic,我们可以使用一个文档是否存在的状态作为一个全局锁。
为了获得一个锁,我们尝试 创建(create
) 一个全局锁的文档:
PUT /fs/lock/global/_create {}
如果这个 create
请求因冲突异常而失败,说明另一个进程已被授予全局锁,我们将不得不稍后再试。
如果请求成功了,我们自豪的成为全局锁的主人,然后可以继续完成我们的变更。一旦完成,我们就必须通过删除全局锁文档来释放锁:
DELETE /fs/lock/global
根据变更的频繁程度以及时间消耗,一个全局锁能对系统造成大幅度的性能限制。 我们可以通过使用更细粒度的锁的方式来增加并行度。
文档锁 (Document Locking)edit
我们可以使用与前面描述的相同的技术来锁定单个文档,而不是锁定整个文件系统。 我们可以使用 游标查询(scrolled search) 检索所有会受到更新影响的文档,并为每一个文档都创建一个文件锁:
PUT /fs/lock/_bulk { "create": { "_id": 1}} { "process_id": 123 } { "create": { "_id": 2}} { "process_id": 123 }
如果一些文件已被锁定,bulk
请求的一部分操作会失败,我们将不得不再次尝试。
当然,如果我们试图再次锁定 所有 的文件, 我们前面使用的 create
语句将会失败,因为所有文件都已被我们锁定!
我们需要一个 update
请求带 upsert
参数以及下面这个 script
,而不是一个简单的 create
语句:
完整的 update
请求如下所示:
POST /fs/lock/1/_update { "upsert": { "process_id": 123 }, "script": "if ( ctx._source.process_id != process_id ) { assert false }; ctx.op = 'noop';" "params": { "process_id": 123 } }
如果文档并不存在, upsert
文档将会被插入 — 和前面的 create
请求相同。
但是,如果该文件 确实 存在,该脚本会查看存储在文档上的 process_id
。
如果 process_id
匹配,更新不会执行(把op修改为noop
)但脚本会返回成功。
如果两者并不匹配, assert false
抛出一个异常,你也知道了获取锁的尝试已经失败。
一旦所有锁已成功创建,你就可以继续进行你的变更。
之后,你必须释放所有的锁,通过检索所有的锁文档并进行批量删除,可以完成锁的释放:
POST /fs/_refresh GET /fs/lock/_search?scroll=1m { "sort" : ["_doc"], "query": { "match" : { "process_id" : 123 } } } PUT /fs/lock/_bulk { "delete": { "_id": 1}} { "delete": { "_id": 2}}
|
|
当你需要在单次搜索请求返回大量的检索结果集时,你可以使用 |
文档级锁可以实现细粒度的访问控制,但是为数百万文档创建锁文件开销也很大。 在某些情况下,你可以用少得多的工作量实现细粒度的锁定,如以下目录树场景中所示。
树锁 (Tree Locking)edit
在前面的例子中,我们可以锁定目录树的一部分,而不是锁定每一个涉及的文档。 我们将需要独占访问我们要重命名的文件或目录,它可以通过 排他锁(exclusive lock) 文档来实现:
{ "lock_type": "exclusive" }
同时我们需要共享锁定所有的父目录,通过 共享锁(shared lock) 文档:
对 /clinton/projects/elasticsearch/README.txt
进行重命名的进程需要在这个文件上有 排他锁 ,
以及在 /clinton
、 /clinton/projects
和 /clinton/projects/elasticsearch
目录有 共享锁 。
一个简单的 create
请求将满足排他锁的要求,但共享锁需要一个脚本化的更新来实现一些额外的逻辑:
这个脚本处理了 lock
文档已经存在的情况,但我们还需要一个用来处理的文档还不存在情况的 upsert
文档。
完整的更新请求如下:
POST /fs/lock/%2Fclinton/_update { "upsert": { "lock_type": "shared", "lock_count": 1 }, "script": "if (ctx._source.lock_type == 'exclusive') { assert false }; ctx._source.lock_count++" }
一旦我们成功地在所有的父目录中获得一个共享锁,我们尝试在文件本身 create
一个排他锁:
PUT /fs/lock/%2Fclinton%2fprojects%2felasticsearch%2fREADME.txt/_create { "lock_type": "exclusive" }
现在,如果有其他人想要重新命名 /clinton
目录,他们将不得不在这条路径上获得一个排他锁:
PUT /fs/lock/%2Fclinton/_create { "lock_type": "exclusive" }
这个请求将失败,因为一个具有相同 ID 的 lock
文档已经存在。
另一个用户将不得不等待我们的操作完成并释放锁。排他锁只能这样被删除:
DELETE /fs/lock/%2Fclinton%2fprojects%2felasticsearch%2fREADME.txt
共享锁需要另一个脚本对 lock_count
递减,如果计数下降到零,删除 lock
文档:
此更新请求需要为每个父目录以相反的顺序(由下到上)运行,从最长路径到最短路径:
POST /fs/lock/%2Fclinton%2fprojects%2felasticsearch/_update { "script": "if (--ctx._source.lock_count == 0) { ctx.op = 'delete' } " }
树锁用最小的代价提供了细粒度的并发控制。当然,它不适用于所有的情况 — 数据模型必须有类似于目录树的顺序访问路径才能使用。
这三个方案 — 全局、文档或树锁 — 都没有处理锁最棘手的问题:如果持有锁的进程死了怎么办?
一个进程的意外死亡给我们留下了2个问题:
- 如何获取可以释放的死亡进程所持有的锁?
- 如何清理死去的进程没有完成的变更?
这些主题超出了本书的范围,但是如果你决定使用锁,你需要对他们进行一些思考。
虽然去规范化成为很多项目的一个很好的选择,但是采用锁方案的需求会带来复杂的实现逻辑。 作为替代方案,Elasticsearch 提供两个模型帮助我们处理相关联的实体: 嵌套的对象 和 父子关系 。