1. 从一次翻页超时说起:start 深翻到底慢在哪
如果你维护过基于 Solr 的搜索服务,大概率遇到过这种反馈:前几页秒开,翻到几百页之后接口开始变慢,上千页直接超时。问题往往不在数据量本身,而在start这个参数的工作方式。Solr 深度分页的典型场景是后台导出、全量巡检、数据对账这类需要遍历海量结果集的业务,用户不会只看前 10 条,而是要把几百万条命中结果一条条走完。
start=1000000&rows=10这种查询,Solr 并不是「跳到第 100 万条」再取 10 条,而是要先在内部把前 1000010 条记录排好序,然后丢掉前面 1000000 条,只返回最后 10 条。排序字段的检索开销可以优化,但前 100 万条的排序过程省不掉。更麻烦的是分布式模式:每个分片都要返回自己前 1000010 条的排序字段,汇聚节点再合并排序,网络传输和内存聚合的代价随start线性上涨。这就是为什么单机还能忍,集群一上深度分页就崩。
cursorMark换了个思路。它不记录「第几条」,而是记录「上一页最后一条的位置」,下次查询从这个位置继续往后取。服务端不保存任何游标状态,游标本身就是一个编码后的排序位置标记,客户端拿着它就能接着翻。这样每次查询都只需要在索引里定位到游标位置,再顺序取rows条,代价和翻第几页无关。本文就围绕cursorMark的配置骨架、查询写法,以及用 curl 对比start与cursorMark的实测动作展开,帮你把这套机制真正跑通。
2. 前置准备:TaoToken 接入与 Solr 环境确认
在动手改查询之前,先把两件事理清楚:一是 Solr 侧要确认版本和字段,二是如果你打算用统一的模型/编码辅助来生成和调试这些查询脚本,可以先把 TaoToken 的接入配好。TaoToken 是一个聚合多家大模型能力的 API 平台,适合在写 Solr 排障脚本、生成对比测试代码时当辅助工具用,官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。
Solr 侧的前置条件其实就三条,缺一条cursorMark都会报错:
第一,排序必须包含唯一键字段,通常是id,并且是严格升序或降序。原因是 Lucene 内部文档 id 在分布式环境下不保证全局唯一顺序,如果排序字段有大量相等值,游标定位会漂移,导致漏数据或重复。所以排序里必须带一个能唯一定位的字段。
第二,任何带cursorMark的请求,start必须为 0。这不是建议,是硬性要求,传了非 0 的start会直接抛异常。
第三,第一次请求传cursorMark=*,之后每次用上一次返回的nextCursorMark作为新的cursorMark。
你可以先用一条简单查询确认环境正常,比如访问http://localhost:8983/solr/你的core/select?q=*:*&rows=1&sort=id+asc,能返回结果说明 Solr 在跑。接下来我们进入配置和查询写法。
3. 可复制配置:solrconfig.xml 骨架与查询参数写法
3.1 solrconfig.xml 里的 requestHandler 骨架
cursorMark本身不需要在solrconfig.xml里开开关,它是/select请求处理器原生支持的参数。但为了控制深度分页的行为,建议显式配置一个专用的 requestHandler,把默认rows、sort和超时约束固定下来,避免客户端乱传。下面是一个可复制的骨架:
<requestHandler name="/deepPage" class="solr.SearchHandler"> <lst name="defaults"> <str name="echoParams">explicit</str> <int name="rows">1000</int> <str name="sort">id asc</str> <str name="wt">json</str> <int name="timeAllowed">30000</int> </lst> <lst name="invariants"> <str name="start">0</str> </lst> </requestHandler>这里有几个点值得说明。invariants里把start固定为 0,客户端就算传了别的值也会被覆盖,从配置层面杜绝误用。defaults里的sort给了默认排序id asc,但客户端仍可覆盖,只要保证排序里含唯一键即可。timeAllowed设 30 秒,防止某个分页请求卡死拖垮整个节点。rows默认 1000,深度遍历时单页大一点能减少请求次数,但别超过内存承受范围。
3.2 查询参数:sort 与 cursorMark 的配套写法
查询的核心是sort和cursorMark必须配套。排序字段要写成字段名 asc或desc,多个字段用逗号分隔,最后一个字段建议是id。比如按时间倒序再按 id 升序:
sort=create_time desc,id asc注意id asc放在最后,保证整体排序唯一。如果只写create_time desc,同一秒内的大量记录顺序不确定,游标就会出问题。
第一次请求:
q=*:*&sort=id asc&rows=1000&cursorMark=*返回结果里会多出一个nextCursorMark字段,形如AoEjRGV...这样的一串编码。下一次请求把它原样带上:
q=*:*&sort=id asc&rows=1000&cursorMark=AoEjRGV...如此循环,直到返回的nextCursorMark和传入的cursorMark相同,或者返回行数小于rows,说明已经到底。这里有个容易忽略的细节:cursorMark的值里可能包含+、/、=这些字符,放进 URL 时必须做 URL 编码,否则会被截断或解析错误。用 curl 时建议用--data-urlencode交给它处理。
4. 验证请求:curl 对比 start 与 cursorMark 耗时
光看文档不够,得实测。下面用 curl 做两组对比,一组用start深翻,一组用cursorMark顺序遍历,观察耗时差异。
4.1 start 深翻的耗时测量
先测一个较大的start,比如 500000,取 10 条:
curl -s -o /dev/null -w "start深翻耗时: %{time_total}s\n" \ "http://localhost:8983/solr/你的core/select?q=*:*&start=500000&rows=10&sort=id+asc&wt=json"多跑几次取平均。你会看到耗时明显高于首页查询,而且随着start增大,耗时基本线性上升。如果换成分布式集群,这个数字会更夸张,因为汇聚节点要合并各分片返回的大量排序字段。
4.2 cursorMark 遍历的耗时测量
cursorMark没法直接「跳到第 50 万条」,它的优势在于顺序遍历时每页耗时稳定。写一个循环脚本,连续翻 500 页,每页 1000 条,记录总耗时:
#!/bin/bash CORE="你的core" ROWS=1000 CURSOR="*" TOTAL=0 PAGES=500 for ((i=1; i<=PAGES; i++)); do RESP=$(curl -s "http://localhost:8983/solr/$CORE/select" \ --data-urlencode "q=*:*" \ --data-urlencode "sort=id asc" \ --data-urlencode "rows=$ROWS" \ --data-urlencode "cursorMark=$CURSOR" \ --data-urlencode "wt=json") NEXT=$(echo "$RESP" | grep -o '"nextCursorMark":"[^"]*"' | cut -d'"' -f4) if [ "$NEXT" = "$CURSOR" ]; then echo "已到末尾,共 $i 页" break fi CURSOR="$NEXT" done实测下来,cursorMark每页耗时基本持平,不会因为翻到后面而变慢。这就是无状态游标的价值:每次查询的代价只和rows有关,和已经翻了多少页无关。
4.3 校验结果不重不漏
性能之外,正确性更重要。校验方法是:用cursorMark遍历时,把每页返回的id收集起来,最后统计总数和去重后的数量是否一致。如果两者相等,说明没有重复;再和q=*:*的numFound对比,如果相等,说明没有遗漏。
# 收集所有 id 到文件 > all_ids.txt CURSOR="*" while true; do RESP=$(curl -s "http://localhost:8983/solr/$CORE/select" \ --data-urlencode "q=*:*" \ --data-urlencode "sort=id asc" \ --data-urlencode "rows=1000" \ --data-urlencode "fl=id" \ --data-urlencode "cursorMark=$CURSOR" \ --data-urlencode "wt=json") echo "$RESP" | grep -o '"id":"[^"]*"' | cut -d'"' -f4 >> all_ids.txt NEXT=$(echo "$RESP" | grep -o '"nextCursorMark":"[^"]*"' | cut -d'"' -f4) [ "$NEXT" = "$CURSOR" ] && break CURSOR="$NEXT" done echo "总条数: $(wc -l < all_ids.txt)" echo "去重后: $(sort -u all_ids.txt | wc -l)"两个数字一致,且等于numFound,就说明这套游标遍历是可靠的。如果去重后变少,通常是排序字段不唯一导致的;如果总数少于numFound,检查是不是中途cursorMark编码出错或提前终止。
5. 本篇常见错排查
5.1 报错 "CursorMark requires a sort with a unique key"
这是最常见的报错,原因是sort里没有包含唯一键字段。Solr 要求排序能唯一定位每条记录,否则游标无法确定「下一条」在哪。解决办法是在sort末尾加上id asc,确保整体排序唯一。如果你的业务字段本身就能唯一,比如订单号,也可以用它,但建议还是带上id兜底。
5.2 报错 "start parameter must be 0 when using cursorMark"
带cursorMark的请求里start必须为 0。很多客户端框架默认会带上start,或者分页组件自动计算了start,导致冲突。检查请求参数,把start去掉或显式设为 0。前面在solrconfig.xml的invariants里固定start=0就是为了防这个。
5.3 翻页结果重复或遗漏
如果校验时发现 id 有重复,八成是排序字段存在大量相等值,且没有唯一键兜底。比如只按create_time desc排序,同一毫秒的记录顺序在两次查询间可能变化,游标定位就会漂移。加上id作为最后排序字段即可解决。另外,如果遍历过程中索引有写入,结果集本身在变,也可能导致重复或遗漏,深度遍历建议在索引相对稳定的时段做。
5.4 cursorMark 值被 URL 截断
cursorMark是 Base64 编码的字符串,含+、/、=。直接拼进 URL 时,+会被解析成空格,=可能被截断。用 curl 的--data-urlencode或者客户端做 URL 编码。如果手动拼接,记得对游标值做 encodeURIComponent 处理。
5.5 分布式环境下 nextCursorMark 为空
正常情况下每页都会返回nextCursorMark。如果为空,检查是不是查询本身没有命中结果,或者rows设成了 0。还有一种情况是请求被路由到了错误的 core,确认 URL 里的 core 名称正确。
6. 把游标遍历接进你的业务链路
cursorMark的价值不只是单次查询快,而是它让「全量遍历」这件事变得可预期。你可以把它接进数据导出任务、对账脚本、离线巡检流程,每次拿nextCursorMark继续,任务中断了也能从上次的游标恢复,因为服务端不存状态,游标本身就是断点。
如果你在写这些脚本时需要辅助生成代码或排查报错,可以用 TaoToken 的模型对话能力快速验证思路,入口在 https://taotoken.net/api ,配合 API Keys 页面 https://taotoken.net/api-keys 拿到密钥即可调用。对于长期跑编码任务和 Agent 流程的场景,Coding Plan https://taotoken.net/coding-plan 会更合适,接入文档在 https://taotoken.net/doc 可以查到具体参数。
回到 Solr 本身,最后给一个实用建议:深度遍历时把rows设大一点,比如 1000 到 5000,减少请求次数;同时用fl只返回必要字段,降低单页传输量。排序字段尽量选索引良好的字段,避免在游标遍历时触发昂贵的排序计算。这套组合下来,几百万条结果的遍历可以从「不敢跑」变成「定时跑」。