核心思想:在分布式系统中,并发写操作必须有控制机制。Elasticsearch 提供了两种互补方案:
- 原子脚本更新(用于计数、累加等简单操作)
- 序列号乐观锁 (用于全量文档替换,如表单编辑)
本实验将带你亲手验证两者的适用边界。
▶ 实验目标
- 创建用于实验的索引。
- 插入初始数据。
- 对比演示:
-
- 无并发控制时的数据丢失问题;
- 计数类操作的正确实现方式(
update+ 脚本); - 复杂文档编辑的正确实现方式(
PUT+if_seq_no)。
- 理解何时该用哪种机制。
3.1 创建索引(简化环境)
bash
PUT /demo_index
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "1s"
},
"mappings": {
"properties": {
"design": { "type": "keyword" },
"votes": { "type": "integer" },
"title": { "type": "text" },
"bio": { "type": "text" }
}
}
}
📌 说明:单分片 + 无副本,排除分片/副本干扰,专注并发逻辑。
3.2 测试数据
| 文档 ID | 类型 | 内容示例 |
|---|---|---|
| 1 | T 恤投票 | { "design": "Galaxy Print" } |
| 2 | 用户资料 | { "title": "张三", "bio": "学生" } |
3.3 插入初始数据
json
// 投票文档(votes 字段可不存在)
PUT /demo_index/_doc/1
{ "design": "Galaxy Print" }
// 用户资料
PUT /demo_index/_doc/2
{ "title": "张三", "bio": "学生" }
3.4 问题演示:无并发控制的危险操作
场景:两人同时给 T 恤投票(错误做法)
json
// 两人同时读取(都看到 votes 不存在或为 null)
GET /demo_index/_doc/1
// 各自计算 votes = 1,并全量写回
PUT /demo_index/_doc/1
{ "design": "Galaxy Print", "votes": 1 }
✅ 结果 :最终 votes = 1(应为 2),一次投票丢失 。
💥 结论:直接"先读再全量写"在并发下必然出错!
⚠️ 注意:此演示仅用于说明问题本质 ,绝非推荐做法!
3.5 ✅ 正确方案一:计数类操作 → 用 update + 脚本
适用场景
- 投票、点赞、收藏、库存扣减、访问量统计等基于当前值的简单算术操作。
操作方式
bash
POST /demo_index/_update/1
{
"script": {
"source": "ctx._source.votes = (ctx._source.votes ?: 0) + params.inc",
"params": { "inc": 1 }
}
}
优势
- 原子性:ES 在主分片上串行执行脚本,天然防并发冲突。
- 高效:单次请求完成,无需先 GET。
- 安全 :即使 1000 并发,结果也精确为
初始值 + 1000。 - 免重试 :永不返回
409 Conflict。
高并发测试(复制执行 5 次)
bash
POST /demo_index/_update/1
{ "script": { "source": "ctx._source.votes = (ctx._source.votes ?: 0) + 1" } }
✅ 最终结果 :votes = 5,无丢失!
📌 最佳实践 :
对于所有计数类操作,请永远优先使用 update + 脚本,而不是"先读再全量写"。
3.6 ✅ 正确方案二:复杂文档编辑 → 用 PUT + if_seq_no
适用场景
- 用户资料、商品详情、文章草稿、配置项等结构复杂、需全量提交的表单数据。
- 多人可能同时编辑同一文档,且不能覆盖他人修改。
操作流程
步骤 1:加载当前文档(获取元数据)
json
GET /demo_index/_doc/2
// 返回:
{
"_seq_no": 0,
"_primary_term": 1,
"_source": { "title": "张三", "bio": "学生" }
}
步骤 2:用户修改后提交(带版本校验)
json
PUT /demo_index/_doc/2?if_seq_no=0&if_primary_term=1
{
"title": "张三",
"bio": "研究生" // ← 修改点
}
步骤 3:结果判断
- ✅ 若期间无人修改 → 更新成功,
_seq_no变为 1。 - ❌ 若另一用户已保存(
_seq_no变为 1)→ 返回409 Conflict。
步骤 4:冲突处理
- 提示用户:"资料已被更新,请刷新后重新编辑。"
- 前端重新加载最新数据,让用户决定是否合并更改。
为什么这里必须用版本校验?
- 更新的是整个
_source,不是单个字段。 - 无法预知用户改了哪些字段 → 不能用
doc局部更新(会清空未传字段)。 - 修改内容是任意文本 → 无法用固定脚本表达。
- 必须确保"所见即所存" 。
📌 最佳实践 :
只有当你需要替换整个文档时,才使用 if_seq_no + if_primary_term 。
3.7 综合实验:构建两个防并发系统
实验 A:高可靠投票系统(用脚本)
ini
# 后端 API
def vote(item_id):
es.update(
index="demo_index",
id=item_id,
body={
"script": {
"source": "ctx._source.votes = (ctx._source.votes ?: 0) + 1"
}
}
)
return "success"
✅ 无需版本管理,天然并发安全。
实验 B:协同编辑用户资料(用版本校验)
ini
# 后端 API
def update_profile(user_id, new_data):
current = es.get(index="demo_index", id=user_id)
try:
es.index(
index="demo_index",
id=user_id,
body=new_data, # 全量表单数据
if_seq_no=current["_seq_no"],
if_primary_term=current["_primary_term"]
)
return "success"
except VersionConflict:
return "conflict: 请刷新页面后重试"
✅ 防止多人编辑互相覆盖。
❓ 思考题
1. 为什么 ES 7.0+ 推荐 if_seq_no + if_primary_term 而非旧版 version?
答 :_seq_no 是严格递增的操作日志序号,_primary_term 能识别主分片切换,两者组合可精准判断文档状态,避免 _version 在分片恢复时的歧义。
2. 如果投票系统要求"每人只能投一次",还能用脚本吗?
答:可以,但需结合外部去重(如 Redis 记录用户ID)。ES 脚本本身不解决业务幂等,只解决并发原子性。
🧭 决策指南:如何选择?
| 问题 | 选 update + 脚本 |
选 PUT + if_seq_no |
|---|---|---|
| 操作是数值增减(+1, -1, ×2)? | ✅ | ❌ |
| 更新整个复杂文档(如表单)? | ❌ | ✅ |
| 无法预知修改了哪些字段? | ❌ | ✅ |
| 追求极致写入吞吐? | ✅ | ❌ |
| 必须防止任何人覆盖我的修改? | ❌(脚本无此语义) | ✅ |
✅ 总结
- 计数类操作 → 用
POST /_update+ 脚本(原子、高效、免版本)。 - 全量文档替换 → 用
PUT /_doc+if_seq_no+if_primary_term(防覆盖、保一致)。 - 不要混淆两者:用解决"文档编辑"的方案去做"计数",是典型的反模式。
记住 :
"在分布式系统中,没有并发控制的写操作,都是在赌博。"
但更重要的是------用对工具,才能赢得漂亮。
❌ 什么情况下绝对不要用 ES 做库存?
- 涉及金钱交易:如支付、转账、余额变动。
- 法律/合规要求强一致:如金融、医疗记录。
- 无法承受任何超卖:如限量版球鞋、演唱会门票。
- 需要复杂业务规则:如"买 A 送 B"、"满减叠加",这些逻辑在 ES 脚本中难以维护。
简单来说:对于核心业务(如金融、电商订单、真实库存),强烈推荐"业务层实现分布式锁或事务控制";对于非核心、可容忍轻微误差的场景(如点赞、浏览量),可以使用"ES Script Update"。
下面从多个维度进行详细对比和分析:
1. 数据一致性模型
- ES Script 方案:
-
- Elasticsearch 不是强一致性数据库 ,它是一个近实时(NRT)的搜索引擎。
- 脚本执行虽然是原子的,但不提供跨文档、跨索引的事务。
- 如果你的库存扣减需要同时更新订单表、用户积分表,ES 无法保证这些操作的原子性。
- 风险:极端情况下(如节点故障、脚本 bug),可能出现数据不一致。
- DB 事务方案:
-
- 关系型数据库(如 MySQL, PostgreSQL)提供 ACID 事务。
- 可以在一个事务中完成:
扣库存 + 创建订单 + 记录流水。 - 保证:要么全部成功,要么全部回滚,数据绝对一致。
✅ 结论:涉及资金、核心资产的场景,必须用 DB 事务。
2. 可靠性与可维护性
- ES Script 方案:
-
- Painless 脚本调试困难,错误日志不友好。
- 脚本逻辑散落在各个
update请求中,难以统一管理和版本控制。 - 如果业务逻辑变更(如增加风控规则),需要修改脚本并重新部署。
- DB 事务方案:
-
- 业务逻辑集中在应用代码或存储过程中,易于测试、调试和维护。
- 可以利用成熟的 ORM、事务管理器(如 Spring
@Transactional)。 - 审计、回滚、补偿机制更完善。
✅ 结论:长期可维护性,DB 方案胜出。
🛠 混合架构:最佳实践(推荐!)
在真实的大型系统中,两者并非互斥,而是互补。最健壮的架构通常是:
scss
[用户请求]
│
▼
[应用服务] ------(1)------> [MySQL: 扣真实库存 + 创建订单] (强一致)
│
▼
(2) 异步消息 (Kafka/RabbitMQ)
│
▼
[ES 同步服务] ------(3)------> [Elasticsearch: 更新搜索库存] (最终一致)
工作流程:
- 写操作 :所有核心写入(扣库存、下单)都在 MySQL 事务中完成。
- 读操作 :搜索、列表页等读多写少 的场景,从 ES 读取(包含一个"搜索库存"字段)。
- 同步 :通过 Binlog 监听(如 Canal)或 MQ,将 DB 的变更异步同步到 ES。
优势:
- 强一致性:核心数据由 DB 保证。
- 高性能:ES 承担高并发读,DB 压力小。
- 解耦:ES 出现问题不影响下单主流程。
- 容错 :即使 ES 同步延迟,用户看到的"搜索库存"可能略高于真实库存,但下单时会以 DB 为准,不会超卖。
💡 例如:你在淘宝看到某商品"库存 100+",点进去下单时才真正校验库存。这就是混合架构的体现。
最佳实践建议
- 新手/中小项目 :优先用 DB 事务,简单、可靠、不易出错。
- 高并发非核心场景 :可以用 ES Script 提升性能。
- 大型系统 :采用 "DB 为主 + ES 为辅" 的混合架构,兼顾一致性与性能。
记住:技术选型的本质,是在"一致性"、"可用性"和"复杂性"之间做权衡。 永远把核心资产的安全放在第一位。