Skip to content

乐观锁机制

要理解Elasticsearch(ES)的乐观锁机制,需要先从「乐观锁的核心思想」入手,再拆解ES的具体实现逻辑,最后讲清其解决的问题和原理细节。

一、乐观锁的基础逻辑:和悲观锁的本质区别

并发控制的核心是避免“脏写”(即多个线程同时修改同一数据,导致数据被覆盖或不一致)。乐观锁与悲观锁的核心差异在于:

  • 悲观锁:假设冲突一定会发生,因此在操作前先“加锁”(如数据库的FOR UPDATE),阻止其他线程修改,直到当前操作完成。
  • 乐观锁:假设冲突很少发生,不主动加锁,而是在提交修改时检查“版本一致性”——如果当前数据的版本与我操作前获取的版本一致,说明没人修改过,可以安全更新;否则,说明数据已被修改,返回冲突(由客户端决定如何处理)。

二、ES乐观锁的核心:版本标识

ES的乐观锁通过版本号实现,本质是对「CAS(Compare-And-Swap,比较并交换)」思想的落地。ES提供两种版本标识方式:

1. 旧版:_version字段(文档级版本)

  • 生成规则:每个文档创建时,_version初始化为1;每次对文档的修改/删除操作(如PUT/POST/DELETE),_version原子性递增1(即使修改的是相同内容,版本也会变)。
  • 使用方式:更新文档时,通过version参数指定“期望的版本号”,ES会检查当前文档的_version是否与请求中的一致:
    bash
    # 示例:更新文档1,要求当前版本必须是2
    PUT /my_index/_doc/1?version=2
    {
      "title": "更新后的标题"
    }
  • 结果
    • 如果当前_version=2:更新成功,_version变为3;
    • 如果当前_version≠2(如已被其他请求改成3):返回409 Conflict(冲突),提示“版本不匹配”。

2. 新版:seq_no + primary_term(全局唯一版本,ES 6.7+推荐)

_version的缺陷是文档级递增,无法解决「主分片切换」后的版本冲突问题(比如主分片挂了,副本升为主分片,旧主分片的_version可能与新主分片不一致)。因此ES引入了全局唯一的版本标识

  • seq_no全局递增的操作序列号(整个索引内唯一),每对索引的写操作(无论哪个分片)都会分配一个唯一的seq_no(从0开始)。
  • primary_term主分片的版本号(分片级唯一),当主分片发生切换(如故障转移)时,primary_term加1(比如原主分片primary_term=1,切换后新主分片primary_term=2)。

为什么要结合两者?
seq_no保证“操作的全局顺序”,primary_term保证“主分片的有效性”——即使旧主分片恢复,其primary_term已过期,无法再用旧的seq_no修改新主分片的数据。

使用方式:更新文档时,通过if_seq_noif_primary_term指定期望的版本:

bash
# 示例:更新文档1,要求当前seq_no=5且primary_term=1
PUT /my_index/_doc/1?if_seq_no=5&if_primary_term=1
{
  "title": "更新后的标题"
}

三、ES乐观锁的原理细节:CAS流程拆解

ES的乐观锁本质是**“版本检查+原子更新”**的组合操作,核心流程如下(以seq_no + primary_term为例):

1. 客户端获取当前版本

客户端先通过GET请求获取文档的当前版本:

bash
GET /my_index/_doc/1

返回结果中包含版本信息:

json
{
  "_index": "my_index",
  "_id": "1",
  "_version": 3,
  "_seq_no": 5,
  "_primary_term": 1,
  "found": true,
  "_source": { ... }
}

2. 客户端发起更新请求(携带版本)

客户端修改文档内容后,**携带之前获取的seq_noprimary_term**发起更新:

bash
PUT /my_index/_doc/1?if_seq_no=5&if_primary_term=1
{
  "title": "新标题"
}

3. ES执行“版本检查+原子更新”

ES收到请求后,会执行以下原子操作(不可分割):

  1. 定位文档:找到文档所在的主分片(ES的写操作只能由主分片处理)。
  2. 检查版本
    • 对比请求中的if_primary_term与当前主分片的primary_term(确保主分片未切换);
    • 对比请求中的if_seq_no与文档当前的seq_no(确保操作顺序未被插队)。
  3. 执行更新
    • 如果版本完全匹配:更新文档内容,同时递增seq_no(全局+1),并保留当前primary_term(除非主分片切换);
    • 如果版本不匹配:直接返回409 Conflict,不执行任何修改。

4. 客户端处理冲突

当收到409错误时,客户端需要:

  1. 重新获取最新版本:用GET请求获取文档的最新seq_noprimary_term
  2. 合并修改:将自己的修改与最新文档内容合并(比如用户修改了标题,而最新文档的内容已经更新了作者,需要合并两者);
  3. 重试更新:携带最新的seq_noprimary_term再次发起请求。

四、ES乐观锁的关键特性

  1. 原子性:版本检查与更新是原子操作(基于Lucene的事务日志),不会出现“检查通过但更新失败”的中间状态。
  2. 无锁开销:不需要像悲观锁那样维护锁的状态(如分布式锁的协调成本),性能更高。
  3. 全局唯一性seq_no + primary_term确保了跨分片、跨主分片切换的版本唯一性,解决了_version的缺陷。
  4. 客户端主导冲突处理:ES只负责检查版本,冲突后的处理逻辑(重试、提示用户等)由客户端决定,灵活性更高。

五、适用场景与局限性

适用场景

  • 读多写少:乐观锁假设冲突少,适合ES的典型场景(如日志检索、商品搜索,写操作远少于读操作);
  • 并发更新但冲突可控:比如用户资料更新、文章编辑(多个用户同时修改同一篇文章的概率低)。

局限性

  • 冲突处理成本:如果冲突频繁(如秒杀场景的库存扣减),客户端需要多次重试,反而影响性能(此时更适合悲观锁或分布式锁,但ES本身不支持悲观锁);
  • 非强一致性:乐观锁是“最终一致”的,无法保证“实时修改的绝对顺序”(但seq_no已保证全局顺序)。

六、总结:ES乐观锁的原理核心

ES的乐观锁是**“版本标识+CAS操作”**的组合,通过:

  1. 给每个文档分配全局唯一的版本号seq_no + primary_term);
  2. 在更新时原子检查版本一致性
  3. 冲突时返回错误,由客户端处理。

其本质是用“版本检查”替代“加锁”,在保证并发安全的同时,最大化ES的读写性能(这也是ES选择乐观锁的根本原因)。

如果需要更深入的细节(比如seq_no的生成逻辑、Lucene的事务日志如何保证原子性),可以再问我~