Elasticsearch 聚合不支持 from/size 分页,因 terms 等聚合仅截断返回前 N 个桶;composite 聚合通过 after 游标实现真分页,需指定 sources 和排序,Laravel 中需正确传递 after_key 并避免拼写错误。

为什么 Laravel + ES 聚合结果不能直接分页?
因为 Elasticsearch 的 terms、composite 等聚合本身不支持 from/size 分页,返回的桶(bucket)是“全量聚合后截断”的结果——你只能控制最多返回多少个桶(通过 size 参数),但没法跳过前 N 个桶再取下一页。比如设 "size": 100,ES 就返回评分最高或词频最高的前 100 个 packageId 桶;设 "size": 200,它返回前 200 个,但你无法拿到第 101–200 个“作为第二页”。这不是 Laravel 的限制,而是 ES 聚合设计使然。
用 composite aggregation 实现真正的聚合分页
composite 是唯一原生支持聚合分页的聚合类型,它基于“游标”(after)而非偏移量,适合处理海量去重键值(如课程包 ID、用户 ID、订单状态等)。它要求你显式指定排序字段,并在首次响应中提取 after_key 用于下一页请求。
- 首次查询必须带
sources和size,例如按packageId字段升序分页:{"aggs":{"packages":{"composite":{"sources":[{"packageId":{"terms":{"field":"packageId.keyword"}}}],"size":50}}}} - 响应体里会返回
"after_key":{"packageId":"pkg_12345"},下一页请求必须带上:{"aggs":{"packages":{"composite":{"sources":[{"packageId":{"terms":{"field":"packageId.keyword"}}}],"size":50,"after":{"packageId":"pkg_12345"}}}}} - Laravel 中调用时,别把
after拼错成search_after(那是普通查询用的);也别漏掉sources数组结构,否则报parse_exception -
composite不支持sort: "desc"单字段倒序分页——必须用正向排序 + 反向读取逻辑,或组合多字段(如[{"packageId":{"terms":{...}}},{"timestamp":{"date_histogram":{...}}}])来构造唯一排序上下文
聚合后查详情:Laravel 侧分两步走
当业务需要“展示课程包列表 + 每个包下最新视频标题”这类需求时,不要试图在聚合里嵌套 top_hits 并分页——top_hits 的 size 是每个桶内部的条数,不是全局分页。正确做法是:
- 第一步:用
composite或terms(加足够大的size)拿到当前页的packageId列表(比如 20 个 ID) - 第二步:用 Laravel 的
whereIn('package_id', [...])去 MySQL 或主库批量查出这些课程包的完整信息(含封面、名称、创建时间等) - 第三步:对每个
packageId单独发一条 ES 查询(带size: 1和sort: [{"upload_time":"desc"}]),获取其最新一个视频的标题/ID —— 这比在聚合里做top_hits更可控、更易 debug - 避免在 ES 聚合里做
sum、avg等指标计算后再分页,这类指标聚合天然无序,composite不适用;应先分桶,再对桶 ID 做二次聚合或数据库关联
别碰 index.max_result_window,除非你真要导出全部桶
有人想绕过聚合分页限制,直接把索引的 index.max_result_window 调高到 100 万,然后用 terms + 极大 size 拿全量桶——这非常危险。ES 对 terms 聚合的内存消耗是 O(N),N 是唯一值数量;10 万个 packageId 就可能吃光分片 JVM 堆内存,触发频繁 GC 甚至节点退出。真正需要全量桶导出时,应该用 composite 配合循环请求,每次取 1000 个,主动控制节奏。


















