揭秘 ES 的乐观并发控制(OCC)的核心机制

9 阅读5分钟

大家好!今天我们来解决一个高并发场景下的经典陷阱,并掌握 Elasticsearch 内置的两种强大武器!

视频教程: Elasticsearch8.X实战速学

🔍 一、问题根源:为什么票数会丢?

让我们先重现那个“丢失的一票”:

  1. 用户 A 读取GET /tshirts/_doc/1votes=1000
  2. 用户 B 也读取GET /tshirts/_doc/1votes=1000
  3. 用户 A 更新PUT { "votes": 1001 } → 成功!
  4. 用户 B 更新PUT { "votes": 1001 } → 覆盖了 A 的结果!

😱 关键点:B 在更新时,根本不知道 A 已经改过数据了!这就是典型的 “写覆盖”(Lost Update) 问题。

⚠️ 重要提示:虽然这个例子很直观,但在真实系统中,投票这类计数操作绝不应使用“先读再全量写”的方式!我们将在后文揭晓更优解。


🛡️ 二、ES 的两大解法:针对不同场景的精准打击

Elasticsearch 提供了两种互补的并发控制机制,适用于截然不同的业务场景。

💡 解法一:原子脚本更新(Script Update)— 计数类操作的首选

适用场景:投票、点赞、库存扣减、访问量统计等基于当前值进行简单算术运算的操作。

核心思想不要读,直接改! 利用 ES 内部的原子执行能力。

POST /tshirts/_update/1
{
  "script": {
    "source": "ctx._source.votes = (ctx._source.votes ?: 0) + params.inc",
    "params": { "inc": 1 }
  }
}

为什么它能天然防并发?

  1. 锁定文档:在主分片上获取独占锁。
  2. 执行脚本:基于当前最新 _source 执行 Painless 脚本。
  3. 写入新版本:释放锁。
    整个过程是单线程、原子的,彻底避免了竞态条件。

优点

  • 无需版本校验!永不返回 409 Conflict
  • 高效:单次请求完成,无需先 GET。
  • 安全:即使高并发,结果也绝对精确。

📌 最佳实践口诀
“数值增减用脚本,天然并发不用愁!”


💡 解法二:序列号乐观锁(_seq_no + _primary_term)— 精确版本校验(推荐用于全量替换)

适用场景:用户资料编辑、商品详情管理、文章协同写作等需要提交完整表单数据的场景。

核心思想“你只能覆盖你看到的那个版本” 。通过轻量级校验代替重量级锁。

1. 为什么需要两个字段?

字段作用示例
_seq_no分片内操作的递增序列号0, 1, 2, ...
_primary_term主分片的任期编号,主分片切换时递增1 → 2 → 3

🔑 二者组合构成全局唯一操作指纹,防止主分片故障切换后的“脑裂覆盖”。

场景说明:

  • 主分片 A(term=1)执行 seq_no=5 后宕机;
  • 副本 B 成为主分片(term=2),从 seq_no=0 开始;
  • 若只校验 if_seq_no=5,新主分片会误认为这是旧数据而覆盖;
  • 加上 if_primary_term=1 后,新主分片(term=2)拒绝请求,返回 409 Conflict

2. 如何使用?

// 1. 先获取文档当前状态
GET /profiles/_doc/user123
// 响应包含: "_seq_no": 5, "_primary_term": 1

// 2. 更新时带上这两个值
PUT /profiles/_doc/user123?if_seq_no=5&if_primary_term=1
{
  "name": "新名字",
  "bio": "新简介"
  // ...其他所有字段(全量)
}
  • 成功:仅当 ES 中文档的 seq_no == 5primary_term == 1 时才更新。
  • 失败:若期间有人修改过,seq_no 会变化(如变成6),返回 409 Conflict

📌 最佳实践口诀
“整文替换带校验,_seq_no + _primary_term 保平安!”


🧪 三、动手实验:对比两种方案

实验 1:用脚本安全投票(推荐!)

// 初始化
PUT /tshirts/_doc/1
{ "design": "Galaxy Print" }

// 并发投票5次
POST /tshirts/_update/1
{ "script": { "source": "ctx._source.votes = (ctx._source.votes ?: 0) + 1" } }

结果votes = 5,完美无误!

实验 2:用乐观锁编辑用户资料(必须!)

// 1. 创建用户
PUT /profiles/_doc/user123
{ "name": "张三", "bio": "学生" }

// 2. 运营A加载(得到 seq_no=0)
GET /profiles/_doc/user123

// 3. 运营B加载(也得到 seq_no=0)

// 4. 运营A先保存(成功,seq_no变为1)
PUT /profiles/_doc/user123?if_seq_no=0&if_primary_term=1
{ "name": "张三", "bio": "研究生" }

// 5. 运营B用旧 seq_no=0 保存 → 失败!
PUT /profiles/_doc/user123?if_seq_no=0&if_primary_term=1
{ "name": "李四", "bio": "学生" }
// → 返回 409 Conflict

结果:成功阻止了危险的覆盖操作!


🛠️ 四、最佳实践:如何在代码中处理?

对于脚本更新(计数类)

  • 无需特殊处理!直接调用,ES 保证原子性。
  • 伪代码:
def vote(item_id):
    es.update(index="tshirts", id=item_id, body={"script": "..."})
    return "success"

对于乐观锁更新(文档编辑)

拿到 409 Conflict 怎么办?有两种主流策略:

方式一:自动重试(最多 N 次)

for attempt in range(3):
    try:
        current = es.get(...)
        es.index(..., if_seq_no=current["_seq_no"], ...)
        break
    except VersionConflict:
        time.sleep(0.1)
        continue
else:
    raise Exception("Too many conflicts")

适用:冲突不频繁,且操作幂等。

方式二:业务层提示用户

  • 向前端返回 409 错误。
  • 前端提示:“内容已被他人修改,请刷新页面后重新编辑。”
    适用:强一致性要求高的协作场景(如文档、配置)。

💼 五、业务启示:一张表看懂如何选择

场景是否需要版本控制推荐方案核心原因
投票、点赞、计数器❌ 否Script Update原子操作,天然防并发
库存扣减⚠️ 部分Script Update + 余额检查需在脚本中判断库存是否充足
文章编辑、配置管理✅ 是if_seq_no + if_primary_term必须防止多人协作时互相覆盖
日志追加❌ 否直接 POST 新文档每条日志都是独立事件,无状态
用户资料、商品详情更新✅ 是if_seq_no + if_primary_term全量表单提交,需确保基于最新版本

💡 核心原则

  1. 如果是数值增减 → 用 script
  2. 如果是整文档替换 → 用 if_seq_no

💬 最后总结

“并发不可怕,可怕的是用错工具。”

通过今天的学习,你已经掌握了:

  1. 并发写入导致数据丢失的根本原因
  2. ES 如何用 Script Update 为计数类操作提供原子性保障
  3. ES 如何用 _seq_no + _primary_term 为文档编辑提供精确的乐观锁
  4. 两种方案的明确边界和不同业务场景下的最佳选择

视频教程