大家好!今天我们来解决一个高并发场景下的经典陷阱,并掌握 Elasticsearch 内置的两种强大武器!
🔍 一、问题根源:为什么票数会丢?
让我们先重现那个“丢失的一票”:
- 用户 A 读取:
GET /tshirts/_doc/1→votes=1000 - 用户 B 也读取:
GET /tshirts/_doc/1→votes=1000 - 用户 A 更新:
PUT { "votes": 1001 }→ 成功! - 用户 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 }
}
}
✅ 为什么它能天然防并发?
- 锁定文档:在主分片上获取独占锁。
- 执行脚本:基于当前最新
_source执行 Painless 脚本。 - 写入新版本:释放锁。
整个过程是单线程、原子的,彻底避免了竞态条件。
✅ 优点:
- 无需版本校验!永不返回
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 == 5且primary_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 | 全量表单提交,需确保基于最新版本 |
💡 核心原则:
- 如果是数值增减 → 用 script。
- 如果是整文档替换 → 用 if_seq_no。
💬 最后总结
“并发不可怕,可怕的是用错工具。”
通过今天的学习,你已经掌握了:
- 并发写入导致数据丢失的根本原因。
- ES 如何用 Script Update 为计数类操作提供原子性保障。
- ES 如何用
_seq_no + _primary_term为文档编辑提供精确的乐观锁。 - 两种方案的明确边界和不同业务场景下的最佳选择。