Elasticsearch Scroll :分页进度到底记在哪?

「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 传递链:一个闭环

这是全篇最精妙的地方。游标不是"算出来就完事",而是在协调节点和分片之间来回倒一手:

  1. 协调节点 reduce 时,记下"这一轮每个分片最后取走的是哪条";
  2. fetch 请求把这个 lastEmittedDoc 带回对应分片;
  3. 分片在 executeFetchPhase 里把它写回自己的 scrollContext.lastEmittedDoc;
  4. 下一轮 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 的分片节点上。
相关推荐
用户3610588626121 小时前
Flink SQL 之 Flink SQL 企业级案例分析:从实时大屏到用户画像与风控告警的生产实践
大数据·flink
probably1211 小时前
基于水利部技术标准的山洪灾害科学防治体系建设探析
大数据·人工智能
用户3610588626121 小时前
Flink SQL 之 Table Window 原理及代码实现:从滚动窗口到窗口 TopN 与窗口 Join 实战
大数据·flink
ha_lydms1 小时前
St Functions(空间函数)
大数据·hadoop·阿里云·spark·数据处理·dataworks·maxcompute
W***25922 小时前
2026企业AI办公工具选型指南:适合团队使用的AI办公产品有哪些
大数据·人工智能
2601_962966642 小时前
2027校招:数据科学与大数据技术专业转增长运营,从SQL到业务技能与证书准备
大数据
Smoothcloud润云3 小时前
GPU算力平台推荐:新手租GPU时,镜像环境和显存应该怎么选?
大数据·人工智能·pytorch·分布式·深度学习·gpu算力
猴子15516813 小时前
问卷系统集成到底该选哪一家?2026先想清要接什么系统再横评
大数据·经验分享
科创致远3 小时前
2026轻MES行业数字化转型痛点与ROI报告 ——面向中小制造企业的轻量化生产管理与价值评估
大数据·人工智能·制造