1. 为什么 from+size 翻到第 100 页就崩了
Elasticsearch 的from + size分页在浅层翻页时很舒服,写起来也直观,但它的代价是每个分片都要取出from + size条文档,再在协调节点归并排序后丢掉前from条。也就是说,你翻到第 100 页、每页 20 条时,每个分片实际要捞 2000 条,5 个分片就是 10000 条文档参与排序,最后只返回 20 条。翻得越深,浪费越大,这就是深分页的性能塌陷。
更麻烦的是,index.max_result_window默认只给到 10000。超过这个窗口,ES 会直接抛Result window is too large,你连翻都翻不动。很多同学第一反应是把max_result_window调大,但这只是把墙往后挪,内存和 CPU 的消耗并没有消失,反而更容易把集群拖垮。
Search After 解决的就是这个问题。它不按「第几页」来定位,而是按「上一页最后一条的排序值」来定位,相当于给你一个 live cursor。ES 只需要从游标位置往后取size条,不需要跳过前面所有数据,深分页的代价从 O(from+size) 降到接近 O(size)。代价是它只能顺序向后翻,不能随机跳到第 N 页,也不能直接回退,这在对「下一页」体验要求高的场景里完全够用。
这篇就聚焦一件事:在本地用 TaoToken 统一 Key 打通请求通道,配好settings.json骨架,然后跑一次可复现的 Search After 分页验证,把 sort 字段、search_after 游标、PIT 参数这三样东西真正用起来。
2. TaoToken 前置:统一 Key 与 API 通道准备
Search After 的验证本身不复杂,麻烦的是请求通道。如果你同时要调 ES、调模型做结果摘要、再跑个 coding agent 写脚本,每个服务一套 Key、一套地址,配置散落在各处,排查问题时根本不知道是哪一层挂了。我习惯把这类外部调用统一收口到 TaoToken,一个 Key 走 API 通道,settings.json里只维护一份配置。
TaoToken 在这里扮演的是统一接入层:你拿到一个 Key,就能通过它的 API 通道访问模型对话、coding plan 等能力,地址是https://taotoken.net/api。注意 API 地址不带任何查询参数,保持干净。官网入口是https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,第一次接触可以先从官网了解整体能力。
具体到操作,你需要先拿到 Key。打开控制台创建 API Key,路径是https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。创建时建议按用途命名,比如es-search-after-demo,方便后面在settings.json里对应。Key 只在创建时完整显示一次,复制后立刻存到本地环境变量或配置文件,别贴在聊天窗口里。
如果你后面想用模型对话来辅助分析分页结果,可以走https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite;如果打算长期跑编码或 Agent 任务,Coding Plan 在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,遇到参数不确定时以文档为准。
注意:Key 属于敏感凭据,不要写进会提交到 Git 的明文文件。下面
settings.json里我用占位符,你替换成自己的值即可。
3. 可复制的 settings.json 配置骨架
下面这份骨架把 TaoToken 的 Key、API 地址,以及 ES 的连接信息、分页参数放在一起。你可以直接复制,替换YOUR_TAOTOKEN_KEY、YOUR_ES_HOST等占位符。结构上分成taotoken、elasticsearch、paging三块,职责清晰,后面排障时一眼能看出是哪一层的问题。
{ "taotoken": { "api_base": "https://taotoken.net/api", "api_key": "YOUR_TAOTOKEN_KEY", "timeout_ms": 30000, "default_model": "gpt-4o-mini" }, "elasticsearch": { "host": "http://localhost:9200", "index": "bank", "username": "", "password": "", "verify_certs": false }, "paging": { "mode": "search_after", "size": 10, "keep_alive": "5m", "sort": [ { "balance": "asc" }, { "_id": "desc" } ], "max_pages": 50 } }几个字段值得展开说。paging.mode固定为search_after,方便你在代码里做分支判断,未来要切回from+size只改这一处。paging.size是每页条数,Search After 场景下建议不要设太大,10 到 50 之间比较稳。paging.keep_alive是 PIT 的存活时间,只有用 PIT 时才生效,5m表示 5 分钟内游标有效,超时后 PIT 会被回收,需要重新开。
paging.sort是 Search After 的核心。它必须和请求里的sort完全一致,而且排序字段组合要能唯一确定一条文档。只用balance排序是不够的,因为余额可能重复,重复值会导致游标定位歧义,翻页时可能漏数据或重复数据。所以我在balance后面补了_id作为 tiebreaker,_id在单个索引内唯一,这样排序结果稳定,游标才可靠。
max_pages是我加的保护字段,防止脚本因为游标没推进而无限循环。真实项目里这个值按业务上限设,比如最多翻 50 页就停。
4. 验证请求:sort、search_after 游标与 PIT 参数
配置就绪后,先确认 ES 能通。用 curl 打一个最简单的请求:
curl -XGET "http://localhost:9200/bank/_search" \ -H 'Content-Type: application/json' \ -d '{ "size": 2, "query": { "match_all": {} }, "sort": [ { "balance": "asc" }, { "_id": "desc" } ] }'预期返回里,hits.hits有 2 条文档,每条都带一个sort数组,比如[1000, "1001"],这就是这一页最后一条的游标值。注意_id是字符串,balance是数值,游标数组里的类型要和sort定义一致,顺序也不能乱。
拿到游标后,发第二页请求,把上一页最后一条的sort值填进search_after:
curl -XGET "http://localhost:9200/bank/_search" \ -H 'Content-Type: application/json' \ -d '{ "size": 2, "query": { "match_all": {} }, "search_after": [1000, "1001"], "sort": [ { "balance": "asc" }, { "_id": "desc" } ] }'这里有个硬性约束:用search_after时,from必须为 0 或不传。如果你同时写了from: 10和search_after,ES 会报错。预期返回是紧接着上一页之后的 2 条,hits.hits[0].sort应该大于上一页的游标值,且两页之间没有重叠。
上面是不带 PIT 的写法,适合索引数据不频繁变更的场景。如果翻页过程中有写入或删除,游标可能错位,这时候用 PIT 更稳。先开一个 PIT:
curl -XPOST "http://localhost:9200/bank/_pit?keep_alive=5m"返回里会有id字段,这就是 PIT ID。然后带着它查询,注意此时请求路径不再带索引名,索引信息由 PIT 承载:
curl -XGET "http://localhost:9200/_search" \ -H 'Content-Type: application/json' \ -d '{ "size": 2, "query": { "match_all": {} }, "pit": { "id": "YOUR_PIT_ID", "keep_alive": "5m" }, "sort": [ { "balance": "asc" }, { "_id": "desc" } ] }'翻下一页时,search_after照旧填上一页最后一条的sort值,pit.id保持不变。全部翻完后,主动关闭 PIT 释放资源:
curl -XDELETE "http://localhost:9200/_pit" -H 'Content-Type: application/json' -d '{"id": "YOUR_PIT_ID"}'实测下来,PIT 的价值在于给整个翻页过程一个一致的数据视图,游标不会因为中途的写入而漂移。代价是 PIT 会占用资源,keep_alive别设太长,翻完就关。
5. 本篇常见错排查
第一个高频错误是Result window is too large。如果你在 Search After 请求里不小心带了from,或者代码里沿用了旧的分页逻辑,就会撞上这个。检查请求体,确保from为 0 或不存在,search_after和from不要同时出现。
第二个是游标类型不匹配。比如sort里balance是数值,你传search_after: ["1000", "1001"],把数值写成了字符串,ES 会报解析错误或返回空结果。对照上一页返回的sort数组,逐位检查类型和顺序。
第三个是排序字段不唯一导致翻页重复或漏数据。只按balance排序时,余额相同的文档顺序不稳定,游标可能定位到错误位置。解决办法就是像配置里那样,补一个唯一性字段做 tiebreaker,_id是最省事的选择。
第四个是 PIT 过期。keep_alive设了5m,但你的脚本跑得慢,超过 5 分钟再翻页,PIT 已被回收,请求会报search_phase_execution_exception或提示 PIT 不存在。要么调大keep_alive,要么在脚本里捕获异常后重新开 PIT 并重置游标。
第五个是 Key 或地址配错。如果请求根本没到 ES,先看settings.json里elasticsearch.host是否可达,再看 TaoToken 的api_base是否写成了带路径的形式。API 地址就是https://taotoken.net/api,不要自己拼/v1之类的后缀。Key 无效时通常返回 401,对照控制台里创建的 Key 重新复制一次。
6. 把通道和分页都收口到一处
走到这里,你手上应该有一份能跑的settings.json、一组验证过的 curl 请求,以及一套排障清单。Search After 的关键就三样:稳定的sort组合、正确的search_after游标、需要一致性时加 PIT。把这三样固定进配置,深分页就不再是性能黑洞。
通道层面,我建议把 Key 和地址统一交给 TaoToken 管理,settings.json里只留一份taotoken配置。需要新建或轮换 Key 时,去https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite操作;参数细节查接入文档https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite;想先用模型对话验证分页结果的分析逻辑,走https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite;如果要把这套分页脚本纳入长期编码或 Agent 工作流,Coding Plan 在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。一个 Key、一份配置,翻页和调用都不用来回切换。