「ES Scroll 深潜」系列 中篇 / 共 3 篇:上·原理与首搜 → 中·续查机制 → 下·细节与总结
上篇搞清楚了两件事:scroll_id 是自包含的路由描述符 ,首搜时每个分片会建立并保留 SearchContext。
但有个问题一直悬着:scroll_id 从头到尾都一样(源码注释写得明明白白------"the scroll ID never changes"),那 ES 到底靠什么知道"下一批该从哪条开始"?
直觉答案是"进度记在 scroll_id 里"------错了。scroll_id 里只有"去哪取",没有"取到哪"。
这篇揭晓 Scroll 最核心、也最反直觉的一环:进度游标存在哪、怎么定位、怎么在节点之间流转成闭环。
一、先给结论
- 进度不在 scroll_id:scroll_id 全程不变,只负责路由到分片。
- 进度在分片上下文 :
ScrollContext.lastEmittedDoc(核心)+context.from(辅助)。 - 从哪条开始 :上一批最后返回的那条文档之后 ,用 searchAfter 定位,不是从头重搜。
- 源码甚至直说:scroll 场景忽略 from (
from is always zero as when we use scroll, we ignore from)。
二、续查整体流程
持有上下文的节点 任意节点(新协调) 客户端 持有上下文的节点 任意节点(新协调) 客户端 #mermaid-svg-Tu7Npxo3bmFtHDOW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tu7Npxo3bmFtHDOW .error-icon{fill:#552222;}#mermaid-svg-Tu7Npxo3bmFtHDOW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tu7Npxo3bmFtHDOW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tu7Npxo3bmFtHDOW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tu7Npxo3bmFtHDOW .marker.cross{stroke:#333333;}#mermaid-svg-Tu7Npxo3bmFtHDOW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tu7Npxo3bmFtHDOW p{margin:0;}#mermaid-svg-Tu7Npxo3bmFtHDOW .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Tu7Npxo3bmFtHDOW text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Tu7Npxo3bmFtHDOW .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Tu7Npxo3bmFtHDOW .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Tu7Npxo3bmFtHDOW #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Tu7Npxo3bmFtHDOW .sequenceNumber{fill:white;}#mermaid-svg-Tu7Npxo3bmFtHDOW #sequencenumber{fill:#333;}#mermaid-svg-Tu7Npxo3bmFtHDOW #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Tu7Npxo3bmFtHDOW .messageText{fill:#333;stroke:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Tu7Npxo3bmFtHDOW .labelText,#mermaid-svg-Tu7Npxo3bmFtHDOW .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .loopText,#mermaid-svg-Tu7Npxo3bmFtHDOW .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Tu7Npxo3bmFtHDOW .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Tu7Npxo3bmFtHDOW .noteText,#mermaid-svg-Tu7Npxo3bmFtHDOW .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Tu7Npxo3bmFtHDOW .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Tu7Npxo3bmFtHDOW .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Tu7Npxo3bmFtHDOW .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Tu7Npxo3bmFtHDOW .actorPopupMenu{position:absolute;}#mermaid-svg-Tu7Npxo3bmFtHDOW .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Tu7Npxo3bmFtHDOW .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Tu7Npxo3bmFtHDOW .actor-man circle,#mermaid-svg-Tu7Npxo3bmFtHDOW line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Tu7Npxo3bmFtHDOW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /_search/scroll {scroll_id, scroll}1TransportSearchScrollAction.doExecuteparseScrollId 解码2得到 ScrollIdForNode\[\](contextId, nodeId)...3按 nodeId 转发 InternalScrollSearchRequest4findContext(contextId)从本节点 activeContexts 取出5processScroll: from+=sizequeryPhase.execute(searchAfter)6新一批 QuerySearchResult7reduce + 重算 lastEmittedDoc8fetch(带新 lastEmittedDoc)9更新 scrollContext.lastEmittedDoc10新一批 FetchSearchResult11合并 + 返回同一 scroll id12下一批 hits13
阶段 A:接收续查请求并解码
任意节点收到 /_search/scroll 后,TransportSearchScrollAction.doExecute 解码 scroll id:
java
// TransportSearchScrollAction.doExecute
protected void doExecute(Task task, SearchScrollRequest request, ActionListener<SearchResponse> listener) {
try {
ParsedScrollId scrollId = parseScrollId(request.scrollId());
Runnable action;
switch (scrollId.getType()) {
case QUERY_THEN_FETCH_TYPE:
action = new SearchScrollQueryThenFetchAsyncAction(...);
break;
case QUERY_AND_FETCH_TYPE:
action = new SearchScrollQueryAndFetchAsyncAction(...);
break;
default:
throw new IllegalArgumentException("Scroll id type [" + scrollId.getType() + "] unrecognized");
}
action.run();
} catch (Exception e) {
listener.onFailure(e);
}
}
阶段 B:路由转发到上下文所在节点
SearchScrollAsyncAction.run 遍历 ScrollIdForNode[],解析出目标节点并直接发请求:
java
// SearchScrollAsyncAction.run
private void run(BiFunction<String, String, DiscoveryNode> clusterNodeLookup, final ScrollIdForNode[] context) {
final CountDown counter = new CountDown(scrollId.getContext().length);
for (int i = 0; i < context.length; i++) {
ScrollIdForNode target = context[i];
...
DiscoveryNode node = clusterNodeLookup.apply(target.getClusterAlias(), target.getNode());
if (node == null) {
throw new IllegalStateException("node [" + target.getNode() + "] is not available");
}
connection = getConnection(target.getClusterAlias(), node);
...
final InternalScrollSearchRequest internalRequest = internalScrollSearchRequest(target.getScrollId(), request);
executeInitialPhase(connection, internalRequest, searchActionListener);
}
}
阶段 C:目标节点按 contextId 定位上下文
持有上下文的节点收到 InternalScrollSearchRequest 后,用 contextId 从本节点 的 activeContexts 里查找:
java
// SearchService.executeQueryPhase(InternalScrollSearchRequest ...)
public void executeQueryPhase(InternalScrollSearchRequest request, SearchTask task, ActionListener<ScrollQuerySearchResult> listener) {
runAsync(request.id(), () -> {
final SearchContext context = findContext(request.id(), request);
...
processScroll(request, context); // 推进游标
queryPhase.execute(context); // searchAfter 定位新一批
...
});
}
java
// SearchService.findContext
private SearchContext findContext(long id, TransportRequest request) throws SearchContextMissingException {
SearchContext context = activeContexts.get(id);
if (context == null) {
throw new SearchContextMissingException(id);
}
...
}
这里最能说明问题:续查是按 id 在本节点本地查找上下文,而不是去别的节点找。上下文从没被复制过。
阶段 D:scroll id 保持不变
每次续查返回的 scroll id 和首次一样,不会变:
java
// SearchScrollAsyncAction.sendResponse
// the scroll ID never changes we always return the same ID. This ID contains all the shards and their context ids
// such that we can talk to them again in the next roundtrip.
String scrollId = null;
if (request.scroll() != null) {
scrollId = request.scrollId();
}
三、进度游标:两个字段,谁是主角?
接着用上篇"图书馆取书"的类比。书签上其实有两种记号:
- 「累计取了 N 本」 (
context.from):账本上的累加计数,scroll 场景下被忽略; - 「上次取走的最后一本是《XX》」 (
scrollContext.lastEmittedDoc):这才是管理员下次拿书的真正依据。
| 游标 | 位置 | 作用 |
|---|---|---|
context.from |
SearchContext |
每次续查 from = from + size(辅助/记录) |
scrollContext.lastEmittedDoc |
ScrollContext |
上一批最后返回的文档,searchAfter 定位(核心) |
java
// SearchService.processScroll
private void processScroll(InternalScrollSearchRequest request, SearchContext context) {
context.from(context.from() + context.size()); // from 累加 size
context.scrollContext().scroll = request.scroll();
if (request.scroll() != null && request.scroll().keepAlive() != null) {
contextScrollKeepAlive(context, request.scroll().keepAlive().millis()); // 续期 keepAlive
}
}
敲黑板:
from += size只是"辅助累加"。真正定位靠 searchAfter------这也是为什么你给 scroll 请求传from不会起作用。
四、从哪条开始:searchAfter 机制
首轮和续查走的是不同分支:
- 首轮 :
totalHits == -1、lastEmittedDoc == null,先把全部命中收集一遍拿到总数,返回 top[0, size); - 续查 :
after = lastEmittedDoc,在原 query 上追加一个过滤条件,跳过已返回的文档。
按排序类型分两种武器:
- index order (按索引顺序)→
MinDocQuery(after.doc + 1):直接跳到那个 docId 之后; - 有排序字段 →
SearchAfterSortedDocQuery:按排序字段定位 after 之后。
java
// QueryPhase.execute(scroll 分支)
final ScrollContext scrollContext = searchContext.scrollContext();
if (scrollContext != null) {
if (scrollContext.totalHits == -1) {
// 首轮:收集全部以得到总命中数
assert scrollContext.lastEmittedDoc == null;
} else {
final ScoreDoc after = scrollContext.lastEmittedDoc; // 上一批最后一条
if (returnsDocsInOrder(query, searchContext.sort())) {
// index order:直接跳到 after.doc+1 之后
if (after != null) {
BooleanQuery bq = new BooleanQuery.Builder()
.add(query, BooleanClause.Occur.MUST)
.add(new MinDocQuery(after.doc + 1), BooleanClause.Occur.FILTER)
.build();
query = bq;
}
searchContext.terminateAfter(searchContext.size());
} else if (canEarlyTerminate(reader, searchContext.sort())) {
// searchAfter:按排序字段定位 after 之后
BooleanQuery bq = new BooleanQuery.Builder()
.add(query, BooleanClause.Occur.MUST)
.add(new SearchAfterSortedDocQuery(searchContext.sort().sort, (FieldDoc) after), ...)
.build();
query = bq;
}
}
}
#mermaid-svg-SjLQCgbvadc3XRpv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SjLQCgbvadc3XRpv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SjLQCgbvadc3XRpv .error-icon{fill:#552222;}#mermaid-svg-SjLQCgbvadc3XRpv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SjLQCgbvadc3XRpv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SjLQCgbvadc3XRpv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SjLQCgbvadc3XRpv .marker.cross{stroke:#333333;}#mermaid-svg-SjLQCgbvadc3XRpv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SjLQCgbvadc3XRpv p{margin:0;}#mermaid-svg-SjLQCgbvadc3XRpv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SjLQCgbvadc3XRpv .cluster-label text{fill:#333;}#mermaid-svg-SjLQCgbvadc3XRpv .cluster-label span{color:#333;}#mermaid-svg-SjLQCgbvadc3XRpv .cluster-label span p{background-color:transparent;}#mermaid-svg-SjLQCgbvadc3XRpv .label text,#mermaid-svg-SjLQCgbvadc3XRpv span{fill:#333;color:#333;}#mermaid-svg-SjLQCgbvadc3XRpv .node rect,#mermaid-svg-SjLQCgbvadc3XRpv .node circle,#mermaid-svg-SjLQCgbvadc3XRpv .node ellipse,#mermaid-svg-SjLQCgbvadc3XRpv .node polygon,#mermaid-svg-SjLQCgbvadc3XRpv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SjLQCgbvadc3XRpv .rough-node .label text,#mermaid-svg-SjLQCgbvadc3XRpv .node .label text,#mermaid-svg-SjLQCgbvadc3XRpv .image-shape .label,#mermaid-svg-SjLQCgbvadc3XRpv .icon-shape .label{text-anchor:middle;}#mermaid-svg-SjLQCgbvadc3XRpv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SjLQCgbvadc3XRpv .rough-node .label,#mermaid-svg-SjLQCgbvadc3XRpv .node .label,#mermaid-svg-SjLQCgbvadc3XRpv .image-shape .label,#mermaid-svg-SjLQCgbvadc3XRpv .icon-shape .label{text-align:center;}#mermaid-svg-SjLQCgbvadc3XRpv .node.clickable{cursor:pointer;}#mermaid-svg-SjLQCgbvadc3XRpv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SjLQCgbvadc3XRpv .arrowheadPath{fill:#333333;}#mermaid-svg-SjLQCgbvadc3XRpv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SjLQCgbvadc3XRpv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SjLQCgbvadc3XRpv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SjLQCgbvadc3XRpv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SjLQCgbvadc3XRpv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SjLQCgbvadc3XRpv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SjLQCgbvadc3XRpv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SjLQCgbvadc3XRpv .cluster text{fill:#333;}#mermaid-svg-SjLQCgbvadc3XRpv .cluster span{color:#333;}#mermaid-svg-SjLQCgbvadc3XRpv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SjLQCgbvadc3XRpv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SjLQCgbvadc3XRpv rect.text{fill:none;stroke-width:0;}#mermaid-svg-SjLQCgbvadc3XRpv .icon-shape,#mermaid-svg-SjLQCgbvadc3XRpv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SjLQCgbvadc3XRpv .icon-shape p,#mermaid-svg-SjLQCgbvadc3XRpv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SjLQCgbvadc3XRpv .icon-shape .label rect,#mermaid-svg-SjLQCgbvadc3XRpv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SjLQCgbvadc3XRpv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SjLQCgbvadc3XRpv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SjLQCgbvadc3XRpv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} index order
searchAfter sort
首轮: totalHits==-1
lastEmittedDoc==null
收集全部得总数
返回 top[0..size)
协调节点 reduce
记录每分片 lastEmittedDoc
续查: after=lastEmittedDoc
排序类型?
MinDocQuery(after.doc+1)
SearchAfterSortedDocQuery(after)
跳过已返回文档,收集新一批
重点:不是从头重搜。每轮都在原 query 上追加一个"跳过已取走部分"的过滤,所以成本不随翻页深度线性增长------这正是 Scroll 抗深度分页的真正原因。
五、lastEmittedDoc 传递链:一个闭环
这是全篇最精妙的地方。游标不是"算出来就完事",而是在协调节点和分片之间来回倒一手:
- 协调节点 reduce 时,记下"这一轮每个分片最后取走的是哪条";
- fetch 请求把这个
lastEmittedDoc带回对应分片; - 分片在
executeFetchPhase里把它写回自己的scrollContext.lastEmittedDoc; - 下一轮 query 用它做 searchAfter。
第 1 步(协调节点计算):
java
// SearchPhaseController.getLastEmittedDocPerShard
public ScoreDoc[] getLastEmittedDocPerShard(ReducedQueryPhase reducedQueryPhase, int numShards) {
final ScoreDoc[] lastEmittedDocPerShard = new ScoreDoc[numShards];
if (reducedQueryPhase.isEmptyResult == false) {
final ScoreDoc[] sortedScoreDocs = reducedQueryPhase.sortedTopDocs.scoreDocs;
// from is always zero as when we use scroll, we ignore from
long size = Math.min(reducedQueryPhase.fetchHits, reducedQueryPhase.size);
size = Math.min(sortedScoreDocs.length, size);
for (int sortedDocsIndex = 0; sortedDocsIndex < size; sortedDocsIndex++) {
ScoreDoc scoreDoc = sortedScoreDocs[sortedDocsIndex];
lastEmittedDocPerShard[scoreDoc.shardIndex] = scoreDoc; // 记录每分片最后一条
}
}
return lastEmittedDocPerShard;
}
注释明确:「from is always zero as when we use scroll, we ignore from」------scroll 场景忽略 from,靠 searchAfter。
第 2 步(fetch 携带):
java
// SearchScrollQueryThenFetchAsyncAction / FetchSearchPhase
ScoreDoc lastEmittedDoc = lastEmittedDocPerShard[index];
ShardFetchRequest shardFetchRequest = new ShardFetchRequest(querySearchResult.getRequestId(), docIds, lastEmittedDoc);
第 3 步(分片写回):
java
// SearchService.executeFetchPhase(ShardFetchRequest)
if (request.lastEmittedDoc() != null) {
context.scrollContext().lastEmittedDoc = request.lastEmittedDoc(); // 更新游标
}
#mermaid-svg-hB9STnithxogAzZY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hB9STnithxogAzZY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hB9STnithxogAzZY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hB9STnithxogAzZY .error-icon{fill:#552222;}#mermaid-svg-hB9STnithxogAzZY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hB9STnithxogAzZY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hB9STnithxogAzZY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hB9STnithxogAzZY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hB9STnithxogAzZY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hB9STnithxogAzZY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hB9STnithxogAzZY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hB9STnithxogAzZY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hB9STnithxogAzZY .marker.cross{stroke:#333333;}#mermaid-svg-hB9STnithxogAzZY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hB9STnithxogAzZY p{margin:0;}#mermaid-svg-hB9STnithxogAzZY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hB9STnithxogAzZY .cluster-label text{fill:#333;}#mermaid-svg-hB9STnithxogAzZY .cluster-label span{color:#333;}#mermaid-svg-hB9STnithxogAzZY .cluster-label span p{background-color:transparent;}#mermaid-svg-hB9STnithxogAzZY .label text,#mermaid-svg-hB9STnithxogAzZY span{fill:#333;color:#333;}#mermaid-svg-hB9STnithxogAzZY .node rect,#mermaid-svg-hB9STnithxogAzZY .node circle,#mermaid-svg-hB9STnithxogAzZY .node ellipse,#mermaid-svg-hB9STnithxogAzZY .node polygon,#mermaid-svg-hB9STnithxogAzZY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hB9STnithxogAzZY .rough-node .label text,#mermaid-svg-hB9STnithxogAzZY .node .label text,#mermaid-svg-hB9STnithxogAzZY .image-shape .label,#mermaid-svg-hB9STnithxogAzZY .icon-shape .label{text-anchor:middle;}#mermaid-svg-hB9STnithxogAzZY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hB9STnithxogAzZY .rough-node .label,#mermaid-svg-hB9STnithxogAzZY .node .label,#mermaid-svg-hB9STnithxogAzZY .image-shape .label,#mermaid-svg-hB9STnithxogAzZY .icon-shape .label{text-align:center;}#mermaid-svg-hB9STnithxogAzZY .node.clickable{cursor:pointer;}#mermaid-svg-hB9STnithxogAzZY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hB9STnithxogAzZY .arrowheadPath{fill:#333333;}#mermaid-svg-hB9STnithxogAzZY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hB9STnithxogAzZY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hB9STnithxogAzZY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hB9STnithxogAzZY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hB9STnithxogAzZY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hB9STnithxogAzZY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hB9STnithxogAzZY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hB9STnithxogAzZY .cluster text{fill:#333;}#mermaid-svg-hB9STnithxogAzZY .cluster span{color:#333;}#mermaid-svg-hB9STnithxogAzZY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hB9STnithxogAzZY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hB9STnithxogAzZY rect.text{fill:none;stroke-width:0;}#mermaid-svg-hB9STnithxogAzZY .icon-shape,#mermaid-svg-hB9STnithxogAzZY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hB9STnithxogAzZY .icon-shape p,#mermaid-svg-hB9STnithxogAzZY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hB9STnithxogAzZY .icon-shape .label rect,#mermaid-svg-hB9STnithxogAzZY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hB9STnithxogAzZY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hB9STnithxogAzZY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hB9STnithxogAzZY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 协调节点 reduce
全局排序 topDocs
getLastEmittedDocPerShard
记录每分片最后一条
fetch 请求携带 lastEmittedDoc
分片 executeFetchPhase
更新 scrollContext.lastEmittedDoc
下一轮 query
用 lastEmittedDoc 做 searchAfter
注意第 1 步的粒度:是每个分片各自记一条 (
ScoreDoc[],下标 = shardIndex),不是全局一条。因为每个分片各自的进度本来就不同------这再次呼应上篇的"上下文是分布式的、每分片一份"。
六、结束条件
scroll 自然结束的判定:
- 某轮 reduce 后
isEmptyResult或返回文档数 <size→ 没有更多数据; - 协调节点返回空批次,客户端停止调用;
- 或 scroll 上下文到期被
Reaper清理、clear scroll主动释放,此时再续查会抛SearchContextMissingException。
七、为什么续查可以从任意节点发起
最后把这个"反直觉"补全------总结为一句话:scroll id 是自包含路由信息,不是上下文数据。
#mermaid-svg-7GkGE8MgScSiX6Y8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7GkGE8MgScSiX6Y8 .error-icon{fill:#552222;}#mermaid-svg-7GkGE8MgScSiX6Y8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7GkGE8MgScSiX6Y8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .marker.cross{stroke:#333333;}#mermaid-svg-7GkGE8MgScSiX6Y8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7GkGE8MgScSiX6Y8 p{margin:0;}#mermaid-svg-7GkGE8MgScSiX6Y8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster-label text{fill:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster-label span{color:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster-label span p{background-color:transparent;}#mermaid-svg-7GkGE8MgScSiX6Y8 .label text,#mermaid-svg-7GkGE8MgScSiX6Y8 span{fill:#333;color:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .node rect,#mermaid-svg-7GkGE8MgScSiX6Y8 .node circle,#mermaid-svg-7GkGE8MgScSiX6Y8 .node ellipse,#mermaid-svg-7GkGE8MgScSiX6Y8 .node polygon,#mermaid-svg-7GkGE8MgScSiX6Y8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .rough-node .label text,#mermaid-svg-7GkGE8MgScSiX6Y8 .node .label text,#mermaid-svg-7GkGE8MgScSiX6Y8 .image-shape .label,#mermaid-svg-7GkGE8MgScSiX6Y8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7GkGE8MgScSiX6Y8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .rough-node .label,#mermaid-svg-7GkGE8MgScSiX6Y8 .node .label,#mermaid-svg-7GkGE8MgScSiX6Y8 .image-shape .label,#mermaid-svg-7GkGE8MgScSiX6Y8 .icon-shape .label{text-align:center;}#mermaid-svg-7GkGE8MgScSiX6Y8 .node.clickable{cursor:pointer;}#mermaid-svg-7GkGE8MgScSiX6Y8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .arrowheadPath{fill:#333333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7GkGE8MgScSiX6Y8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7GkGE8MgScSiX6Y8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7GkGE8MgScSiX6Y8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster text{fill:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 .cluster span{color:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7GkGE8MgScSiX6Y8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7GkGE8MgScSiX6Y8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7GkGE8MgScSiX6Y8 .icon-shape,#mermaid-svg-7GkGE8MgScSiX6Y8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7GkGE8MgScSiX6Y8 .icon-shape p,#mermaid-svg-7GkGE8MgScSiX6Y8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7GkGE8MgScSiX6Y8 .icon-shape .label rect,#mermaid-svg-7GkGE8MgScSiX6Y8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7GkGE8MgScSiX6Y8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7GkGE8MgScSiX6Y8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7GkGE8MgScSiX6Y8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
客户端持有 _scroll_id
任意节点 decode
ScrollIdForNode\[\]
(contextId, nodeId, clusterAlias)
nodeId 是否本节点?
本地 activeContexts.get(contextId)
transport 转发到 nodeId
该节点 activeContexts.get(contextId)
执行下一批 query/fetch
- 客户端只需把
_scroll_id发给任何一个节点(REST 走任意 HTTP 节点,TransportClient 走任意已连接节点)。 - 该节点作为新协调节点 ,解码出
(contextId, nodeId)列表后,逐条转发到真正持有上下文的分片节点。 - 上下文本身仍然只在最初执行 query 的分片节点上。