ES 开了游标后的数据,会实时变动吗?
简短结论:默认情况下,ES 不是数据库那种强一致 MVCC;但通过 scroll / PIT(search_after)拿到的是一个“尽量稳定的时间切片视图”——已经扫出来的结果不会因为你后来的写入就自动改掉,但不等于“整个遍历期间数据绝对静止”,而且越到后面越可能出现“新增/修改/删除导致重复或漏数据”的风险。
下面把关键点拆开说清楚。
1)ES 的“实时”其实是 NRT(近实时)
- 写入的数据要先经过 refresh(默认约 1s)才会进入新的 segment、变得可搜索。
- 所以就算你不用游标,刚写入也未必立刻被查到;不存在传统意义上“事务提交即全局可见”。
2)scroll 的本质:基于一个“搜索时的快照上下文”(point in time 思路)
当你发起第一次带 scroll 的请求时,ES 会建立一个 scroll context,它更像是在那一刻的某个 Lucene reader/snapshot 上做遍历:
- 已经返回给你的那一页/那一批文档:不会再“实时变内容”来追新写入
- 后续翻页:继续在同一个 scroll context 里沿索引的 shard-level 结构往前走
但要注意一个重要事实:
> scroll 并不是对整个索引做一次物理拷贝,也不是全量 MVCC 冻结。
它依赖于 segment 的生命周期和可搜索视图;如果在 scroll 生命周期内发生:
- refresh 产生新 segment
- merge 把旧 segment 合并/删除
- 文档更新/删除(新版本写入、旧版本被标记删除)
就可能出现边界情况:
- 新写入的文档:可能 不会被这次 scroll 扫描到(尤其当它们在 scroll 建立后才 refresh 出来)
- 被更新/删除的文档:你可能仍然看到 旧版本(因为当时 snapshot 还能见到它),也可能出现 漏掉(取决于何时合并/清理、以及你的排序与分片归并顺序)
所以更精确的说法是:
> scroll 给你的是“尽量一致的遍历视图”,适合做 导出/重建/迁移/批量处理,但不保证等价于“遍历开始时的一张完全隔离的数据库快照”。
3)search_after(以及推荐的 PIT + search_after)也是同理
从语义上看:
- **PIT(Point In Time)**会把“可搜索视图”固定下来(减少 scroll 的某些缺陷)
search_after用 排序值游标一直往后找下一行
即便如此:
- 已拿到的 page 不会再自己变成新数据
- 但在并发写入很重的系统里,新增文档可能不进结果集、更新/删除可能导致你看到旧值/或跳过一点边界(尤其是排序字段本身被更新时,更容易出现“游标点漂移”)
如果你用 search_after 但 没用 PIT,其实每次请求都会重新解析查询+时间线,那么“一致性”会更弱(更像多次普通搜索拼起来)。
4)什么时候你会误以为“数据实时变动了”?
常见错觉来源:
- refresh 后新段可见:你第二次/第三次翻页时,scroll/PIT 的内部视图不一定把后来才出现的新段纳入。
- 你对同一批 ID 做了 update/delete:你已经拉下来的那一批“内容”不会再自动刷新成新值,但你下一次按游标继续拉,可能遇到“那个 doc 的位置变了/被删了”带来的边缘重复或缺失。
- 排序字段被更新:如果你按
update_time/_id以外不稳定字段排序,更新会导致“游标锚点”世界发生变化——更容易出现怪现象。
5)实践建议(很重要)
A. 如果你的目标是「稳定导出 / 离线处理」
- 用 PIT + search_after(或仍可用 scroll 但要知道它被弱化/弃用趋势)
- 尽量选不变或单调递增的排序键:
_shard_doc、_id(至少唯一且不变)或你自己写的created_at+id - 不要在遍历期间对同一索引做大规模更新/删除(如果必须,接受“最终一致性/重复可接受”的设计:用唯一 ID 做幂等)
B. 如果你想“尽量接近快照”
ES 没有免费的真快照;更接近的做法通常是:
- 停止写入窗口(维护窗口/双写切换)
- 或在应用层把它当作 最终一致:只保证“每个 doc 至少被处理一次”,靠 idempotent worker(用 ES 的
_id/业务唯一键去重)兜底
如果你愿意,把你现在的用法贴一下:
- 用的是 scroll 还是 PIT+search_after?
- 排序字段是什么(
_id/ 时间字段 / 自定义?) - 遍历期间是否有 update/delete/大量写入?
我可以根据你的场景判断你最可能踩到的是“漏数据”还是“重复数据”,以及该怎么排雷。
什么意思?举例
好的,我们用一个完整的故事来演示“ES 游标(scroll)下的数据到底会不会实时变动”。
场景设定
你有一个订单索引 orders,里面目前有三条数据:
| order_id | status | create_time |
|---|---|---|
| 001 | pending | 2025-01-01 10:00:00 |
| 002 | pending | 2025-01-01 10:01:30 |
| 003 | completed | 2025-01-01 10:05:00 |
你想把所有 status = "pending" 的订单导出来做统计,于是你用 scroll 发起查询,每页返回 1 条,按 create_time 升序。
时间线:你做了什么,ES 内部发生了什么
T0:发起第一次 scroll 请求
- ES 收到请求,创建一个 scroll 上下文(相当于拍了一张“此刻符合条件的数据快照”)。
- 这个快照里包含了 001 和 002(因为 003 是 completed,不符合条件)。
- 返回第一页:001(
create_time最早)。 - 同时返回一个
scroll_id,有效期设为 1 分钟。
T0 + 10秒:外部系统发生了两件事
新增一条订单 004
- status = "pending",create_time = 2025-01-01 10:02:00
- 这条数据被写入 ES,但还没有 refresh(默认 1 秒后会 refresh 成为可搜索片段)。
修改订单 002
- 将 002 的 status 从 "pending" 改为 "cancelled"。
- ES 内部会:标记旧文档 002 为删除,写入一个新文档(版本号 +1),等待 refresh 后生效。
T0 + 20秒:你发起第二次 scroll 请求(用刚才的 scroll_id)
- ES 在 同一个 scroll 上下文 中继续遍历。
- 这个上下文仍然只认 T0 时刻的快照,所以:
- 看不到 004(因为它在快照之后才存在)
- 仍然认为 002 是 pending(因为快照里 002 还没被修改)
- 返回第二页:002(旧版本的 pending 状态)。
T0 + 30秒:你发起第三次 scroll 请求
- 快照中符合条件的文档已经全部返回完毕(只有 001 和 002)。
- 返回空结果,遍历结束。
你最终拿到的数据
001 (pending) ← 正确
002 (pending) ← 错误!实际上它已经被改成 cancelled 了
你丢失了:
- 004(新加的 pending 订单)—— 没被包含进来
- 002 的最新状态(cancelled)—— 你拿到的是旧值
对比:如果你不用 scroll,而是用普通 search(每次实时查)
同样从 T0 开始,每次查一页:
- 第一次查询(T0):返回 001(pending)
- 第二次查询(T0+20秒):此时 ES 已经 refresh,002 变成了 cancelled,004 变成了 pending。
查询条件 status=pending,按 create_time 排序 → 返回 004(因为它的 create_time 介于 001 和 002 之间,但 002 已被排除) - 第三次查询(T0+30秒):返回空(因为只剩下 001 和 004 都已被取走,但实际上你漏掉了 002 的 cancelled 记录,但这是符合预期的——你只想要 pending)
结果:
001 (pending)
004 (pending)
没有过期数据,也没有漏掉新数据(前提是排序字段稳定,且你能正确处理翻页偏移)。
但是注意:这种方式的翻页是不稳定的 —— 如果在两次查询之间又有新的 pending 插入,或者有 pending 被修改,你的分页位置可能会“跳”或“重复”。不过对于简单的一次性导出,通常能接受。
核心区别一句话总结
| 方式 | 遍历期间数据是否“实时变动”? | 适合场景 |
|---|---|---|
| scroll | 不实时。你看到的是发起 scroll 那一刻的快照。 | 离线导出、全量迁移、批量处理 |
| search_after(无 PIT) | 实时。每次请求都查最新数据,但跨页一致性差。 | 实时列表、增量滚动 |
如果你愿意,可以把你的具体场景(数据量、是否允许重复/遗漏、遍历期间是否有写入)告诉我,我可以帮你推荐最适合的方式,并指出可能的坑。
search(每次实时查), scroll, search_after(无 PIT) 查的区别?
下面说的“区别”,关键不在语法,而在它们各自承诺了哪一种 一致性模型(视图稳定性) 与 资源模型(stateful vs stateless)。
1)三句话先定性
- 普通 search(from/size 或每次带同一条件重新搜):完全实时的无状态查询;每一页都是“这一瞬间 ES 能看到什么就返回什么”。
- scroll:有状态的快照式遍历器(早期 ES 的“深翻页/导出”方案);一旦开启,尽量沿着一个固定上下文往下给数据,不追新写入。
- search_after(无 PIT):本质仍是多次实时 search,只是把“offset/页码”换成“上一页最后一条的排序值游标”,用来避免
from+size的深翻页成本;但每一页仍然是各自独立的实时查询,没有跨页的一致性保障。
2)一张对照表(记住这张就够了)
| 维度 | 普通 search(每次重新搜) | scroll | search_after(无 PIT) |
|---|---|---|---|
| 有没有“会话/上下文” | 无(完全无状态) | 有(scroll context 占用堆/资源,需要显式清除或等过期) | 无(每页是一次新 search;只是你用排序值当游标) |
| 每页看到的数据是不是“当前最新可搜索的” | ✅ 是(近实时 NRT) | ❌ 否(绑定到开启 scroll 那一刻的可搜索视图/reader) | ✅ 是(每页都是一次新查询,受 refresh 影响) |
| 并发写入时会不会“重复/漏数据” | 会(尤其排序字段不稳定时更明显) | 相对“更稳定”,但也不是强一致冻结:可能出现看到旧版、漏掉新增、边界重复/缺失 | 同样会(因为它就是多次独立 search 拼起来的) |
| 能不能安全地深翻页(e.g. 第 100 万条) | 不推荐用 from+size(O(from+size) 成本),浅翻页 OK |
设计目标之一就是深翻页/全量导出 | 可以(避开 from+size),但仍要解决一致性问题 |
| 开销/运维风险 | 最低(无服务端状态) | 较高:scroll 上下文多了会吃内存/文件句柄;默认保留时间别设太长 | 低(无上下文残留),但要控制单次 size、query 成本 |
| 最像什么 | SQL 每次 LIMIT offset, size 重新执行 |
“遍历器 / 导出游标”,尽量不动(但也不是 MVCC 克隆库) | 用“排序值指针”代替 offset 的无状态滚屏 |
3)分别展开:它们到底“怎么查”的
A. 普通 search(你说的“每次实时查”)
两种常见形态:
from + size翻页
{ "from": 0, "size": 10 }
{ "from": 10, "size": 10 }
代价随 from 变大而变差(很多文章叫它“深度分页问题”),而且每一次都是独立查询:
- 中间只要发生 refresh / 写入 / 删除 / 更新
- 同一
from=20两次执行可能对应到不同文档集合
所以它“最实时”,但也“最不连贯”。
- 不带 from/size、只靠时间窗口等业务约束重新搜(有时也更稳)
例如你按updated_at拉增量:updated_at > last_seen_max,这本质上仍是“每次实时”,但你靠业务字段把变动窗口切成可重入的小块。
B. scroll:给它起个名字——“带租期的遍历快照”
第一次:
GET idx/_search?scroll=1m
{ "size": 1000, "query": {...}, "sort": ["_doc"] }
ES 返回一个 _scroll_id。你后续只用 _scroll_id 往下翻。
要点:
- scroll 的核心是“保持某个可搜索视图别立刻失效/别被 merge 吃掉”,让你能一路线性读下去。
- 所以它天然适合:全量导出 / 重建索引 / 离线对账
- 不适合:给用户做“实时下一页”(因为 scroll 视图不追新数据,且占资源;另外 ES 官方也逐步引导用 PIT+search_after 替代 scroll 的定位)。
你能观察到的现象(结合你上一个问题里的例子):
- 遍历开始后新写入的 pending:大概率不在结果里
- 遍历期间被 update/delete 的文档:你可能仍看到旧版本;也可能因合并/位置变化出现“边缘漏/重复”(不是数据库级可重复读)
C. search_after(无 PIT):它是“更高效的实时翻页”,不是快照
典型写法(无 PIT 也很常用):
GET idx/_search
{
"size": 10,
"query": { "term": { "status": "pending" } },
"sort": [
{ "create_time": "asc" },
{ "_id": "asc" }
]
}
记下最后一条的 sort values:[ "2025-01-01T10:01:30Z", "002" ]
下一页:
{
"size": 10,
"query": { "term": { "status": "pending" } },
"sort": [ { "create_time": "asc" }, { "_id": "asc" } ],
"search_after": [ "2025-01-01T10:01:30Z", "002" ]
}
这里的真相:
- 每一页都是一次新 search(所以“实时”)
search_after只解决两件事:- 避免
from+size的深翻页成本 - 给你一个“游标锚点”,减少因为 offset 错位导致的部分混乱
- 避免
- 但它没有把“视图锁定”:如果
create_time会被更新,或者你在拉取期间有大量增删改,那么:- 仍然可能 漏 / 重复(尤其当锚点字段值发生变化,文档在排序空间里“跳位置”)
这也是为什么 ES 后来又补了一层:PIT(Point In Time)——把“可搜索视图”固定下来,再配合 search_after,一致性会更强一些(但仍然不等价于关系库的事务快照)。
4)用一句更直白的话把三者区分开
- 普通 search:每次都把枪举起来瞄当前靶子(最实时,也最容易被靶子移动影响)。
- scroll:你搭了个观察走廊,进去后尽量沿固定路线走;走廊外新发生的你不太关心,也不太想看(适合导出,不适合“活数据交互”)。
- search_after(无 PIT):你没建走廊,但你用“上一箭射中最末那只羊的编号”当作下次起点的线索;射箭本身是实时发生的(快,但羊群在动就会乱)。
5)我建议你这么选(工程上最省命)
- 用户前台列表页 / 实时滚动加载:
→ 用 search_after(无 PIT 也可)+ 稳定唯一排序键(例如created_at+_id或单调递增 id),控制 size 别大 - 后台任务:全量导出 / 迁移 / 离线对账:
→ 用 scroll(老系统)/ PIT+search_after(更现代),并在应用层做幂等(用_id/业务唯一键去重) - 千万别:把 scroll 当“用户在页面点下一页”的引擎(资源、时效性都不匹配)
如果你把你当前的场景补充两句(数据量级、遍历期间是否写入、是否允许重复/漏一条、你现在用的排序字段),我可以直接告诉你该用哪一种,以及排序键怎么选最稳。
相对“更稳定”,但也不是强一致冻结?
好问题。这句话是理解 scroll / PIT 最容易误以为它是数据库快照、但其实不是的关键。
下面把那句 "相对'更稳定',但也不是强一致冻结" 拆成两层讲:它"稳"在哪里 → 又为什么不够"硬"。
1)先说结论(用一句话接回官方定义)
ES 官方的说法是[citation:3][citation:13]:
> scroll 返回的是「初始搜索请求那一刻匹配到的文档集合」,后续对这些文档的变更(index / update / delete)会被忽略。结果反映的是初始请求时刻的索引状态,"like a snapshot in time"。
但注意——这个 "snapshot" 打引号是有原因的:它不是 MySQL 的 START TRANSACTION; SELECT ...; COMMIT; 那种 MVCC 冻结,它用的是一套更轻量的机制。
2)它"稳"在哪?(为什么比普通 search 更一致)
Lucene 的索引是由一堆 segment(段文件) 组成的,segment 一旦写出来就不可变[citation:1][citation:7]。
scroll 在第一次请求时,会给每个 shard 打开/持有一个 IndexReader(更准确说是 DirectoryReader 的视图),并且把这个视图里的那些旧 segment pin 住(引用计数+1),让后台的 merge 流程不能把它们物理删掉[citation:3][citation:13]。
这意味着:
| 行为 | 你能不能看到 | 原因 |
|---|---|---|
| 初始那一刻已经存在的文档 | ✅ 能看到 | 就在你 pin 住的 segment 集合里 |
| 之后新写入 / refresh 出来的新 segment | ❌ 看不到 | 你的 reader 视图不包含这些"后来者" |
| 之后发生的 delete | ❌ 不体现在你视图里 | 旧 segment 还在,但 .del 标记让它们从结果中排除(由 search context 跟踪"初始时刻哪些算 live") |
| 之后发生的 update(= 先标记旧版删除 + 写入新版) | ⚠️ 你很可能仍看到旧版本 | 旧版本还在你引用的旧 segment 里(还没被 merge 清掉),新版本去了新 segment(你不在视图里) |
所以你得到的体验是:
> 翻页过程不会"抖"——不会出现第1页看到某条、第2页因为 refresh 了突然丢了/跳了;也不会中途混入新数据。
> 这就是"相对更稳定"。
3)那为什么又说"不是强一致冻结"?(重点)
因为它并没有做下面这些事:
❌ 它没有"锁住索引 / 暂停写入"
写入照样发生、refresh 照样生成新 segment、merge 照样跑——只是你这个 scroll 的 reader 不看新 segment 而已。
所以别把它理解成"我把数据库冻住了"。
❌ 它没有给文档做"深拷贝 / 版本化克隆"
它只是让旧 segment 文件不被删(靠引用计数 / 打开的文件句柄),省的是"segment 级的引用存活",不是"行级 MVCC"[citation:7][citation:13]。
❌ 所以"忽略后续变更"≠"文档值被冻结成不可变照片"
最典型的反直觉场景——update 导致你看到"本不该有的旧值":
假设初始查询条件是:
{ "term": { "status": "pending" } }
初始时文档 X:status=pending,在旧 segment A 里 ✅ 匹配。
随后另一个线程执行了:
{ "update": { "status": "cancelled" } }
底层发生了什么:
- 旧版 X(pending) → 在 segment A 中被打
.del标记(还没 merge) - 新版 X(cancelled) → 写在新 segment B 里(B 不在你的 scroll reader 视图中)
你的 scroll 还在看 segment A → segment A 里 X 虽然标了 del,但 search context 是按"初始时刻匹配的文档"来管的,结果就是: > 你可能 仍然把 X 当作 pending 捞出来(你拿到了旧值)。
这就是"不是强一致冻结"的最实质含义:
它保证的是"视图范围稳定"(只看初始那组 segments),不是"值语义隔离"(每条文档的版本被固化为某一刻的提交)。
4)用一个最短的比喻
> scroll 不是给整座图书馆拍了一张照片并把新书拒之门外。
> 它是:你走进去时,手里拿了一份"当前开放的书架清单";之后图书管理员继续收新书、把旧书打废标签、甚至把几排旧书架合并到新书架——但只要还有人指着旧书架说'我还在用',那些旧书架就不能拆。你永远只看你进场时那份清单上的架子,新书区你不走进去。
所以:
- "稳定"= 你不会走着走着路突然塌(视图不跳变)
- "不冻结"= 架上某本书如果被管理员换了一版(update),你翻到的可能还是旧版复印件(因为旧架子还在、新版在另一区)
5)落到你工程上的含义
如果你用 scroll / PIT 做导出/迁移/离线对账,这句话提醒你的是:
- 不要指望它给你"事务级一致"(比如"遍历期间 status 从 pending→cancelled 就一定不出现")
- 正确姿势是:接受它是最终一致的导出视图,然后在消费端做幂等(用
_id/ 业务唯一键去重或覆盖写) - 如果你真的需要"严格等于某个时刻的全部 pending 且不带脏旧值"——要么在停止写入窗口做,要么用业务层 watermark + 重试对账
如果你把你用的排序字段和遍历期间到底有没有 update/delete 说一下(比如是不是按 _doc 还是 update_time),我可以直接告诉你:你这个场景里"旧值穿透"是真的可能发生,还是只是理论风险。