ES游标数据遍历是否实时变动解析

小豆丁 16小时前 ⋅ 7 阅读
聊天记录
头像

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)什么时候你会误以为“数据实时变动了”?

常见错觉来源:

  1. refresh 后新段可见:你第二次/第三次翻页时,scroll/PIT 的内部视图不一定把后来才出现的新段纳入。
  2. 你对同一批 ID 做了 update/delete:你已经拉下来的那一批“内容”不会再自动刷新成新值,但你下一次按游标继续拉,可能遇到“那个 doc 的位置变了/被删了”带来的边缘重复或缺失。
  3. 排序字段被更新:如果你按 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,不符合条件)。
  • 返回第一页:001create_time 最早)。
  • 同时返回一个 scroll_id,有效期设为 1 分钟。

T0 + 10秒:外部系统发生了两件事

  1. 新增一条订单 004

    • status = "pending",create_time = 2025-01-01 10:02:00
    • 这条数据被写入 ES,但还没有 refresh(默认 1 秒后会 refresh 成为可搜索片段)。
  2. 修改订单 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(你说的“每次实时查”)

两种常见形态:

  1. from + size 翻页
JSON
{ "from": 0,  "size": 10 }
{ "from": 10, "size": 10 }

代价随 from 变大而变差(很多文章叫它“深度分页问题”),而且每一次都是独立查询

  • 中间只要发生 refresh / 写入 / 删除 / 更新
  • 同一 from=20 两次执行可能对应到不同文档集合

所以它“最实时”,但也“最不连贯”。

  1. 不带 from/size、只靠时间窗口等业务约束重新搜(有时也更稳)
    例如你按 updated_at 拉增量:updated_at > last_seen_max,这本质上仍是“每次实时”,但你靠业务字段把变动窗口切成可重入的小块。

B. scroll:给它起个名字——“带租期的遍历快照”

第一次:

JSON
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 也很常用):

JSON
GET idx/_search
{
  "size": 10,
  "query": { "term": { "status": "pending" } },
  "sort": [
    { "create_time": "asc" },
    { "_id": "asc" }
  ]
}

记下最后一条的 sort values:[ "2025-01-01T10:01:30Z", "002" ]

下一页:

JSON
{
  "size": 10,
  "query": { "term": { "status": "pending" } },
  "sort": [ { "create_time": "asc" }, { "_id": "asc" } ],
  "search_after": [ "2025-01-01T10:01:30Z", "002" ]
}

这里的真相:

  • 每一页都是一次新 search(所以“实时”)
  • search_after 只解决两件事:
    1. 避免 from+size 的深翻页成本
    2. 给你一个“游标锚点”,减少因为 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 导致你看到"本不该有的旧值"

假设初始查询条件是:

JSON
{ "term": { "status": "pending" } }

初始时文档 X:status=pending,在旧 segment A 里 ✅ 匹配。

随后另一个线程执行了:

JSON
{ "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 做导出/迁移/离线对账,这句话提醒你的是:

  1. 不要指望它给你"事务级一致"(比如"遍历期间 status 从 pending→cancelled 就一定不出现")
  2. 正确姿势是:接受它是最终一致的导出视图,然后在消费端做幂等(用 _id / 业务唯一键去重或覆盖写)
  3. 如果你真的需要"严格等于某个时刻的全部 pending 且不带脏旧值"——要么在停止写入窗口做,要么用业务层 watermark + 重试对账

如果你把你用的排序字段和遍历期间到底有没有 update/delete 说一下(比如是不是按 _doc 还是 update_time),我可以直接告诉你:你这个场景里"旧值穿透"是真的可能发生,还是只是理论风险。

全部评论: 0

    我有话说: