在构建基于 Elasticsearch (ES) 的搜索服务时,深度分页往往是绕不开的性能挑战。 当需要展示大量结果集,例如从第 1000 页开始获取数据,传统的 from + size 方式会扫描大量的文档,即使最终只返回少量结果,也会消耗大量的 CPU 和内存资源。这对于用户体验来说是致命的,特别是当你的服务部署在生产环境,使用了诸如 Nginx 进行反向代理和负载均衡,并发连接数较高时,ES 节点的压力会迅速飙升。
from + size 深度分页原理及缺陷
from + size 是 ES 最基础的分页方式,其中 from 指定起始文档的偏移量,size 指定返回的文档数量。 比如 from=10000&size=10,ES 需要先找到前 10010 个文档,然后丢弃前 10000 个,最后返回后 10 个。 这意味着每个分片都需要加载和排序大量的文档,即使这些文档最终不会被返回。当 from 值很大时,这个过程的开销会变得非常巨大。
Scroll API 的使用与局限
为了解决深度分页的问题,ES 提供了 Scroll API。 Scroll API 允许你创建一个游标(scroll_id),然后通过这个游标来遍历整个结果集。它有点像数据库的游标,可以避免每次都重新计算排序,从而提高性能。
代码示例:
from elasticsearch import Elasticsearch
# 初始化 Elasticsearch 客户端
es = Elasticsearch(['http://localhost:9200'])
# 首次查询,创建 scroll_id
resp = es.search(
index='my_index',
scroll='5m', # scroll_id 有效期 5 分钟
size=1000, # 每次返回 1000 条数据
body={
'query': {'match_all': {}}
}
)
scroll_id = resp['_scroll_id']
print(f"Initial scroll_id: {scroll_id}")
# 循环获取后续数据
while True:
resp = es.scroll(scroll_id=scroll_id, scroll='5m') # 保持 scroll_id 存活
results = resp['hits']['hits']
if not results:
break # 没有更多数据了
for hit in results:
print(hit['_source'])
# 更新 scroll_id,避免过期
scroll_id = resp['_scroll_id']
print(f"Updated scroll_id: {scroll_id}")
# 清理 scroll_id,释放资源
es.clear_scroll(scroll_id=scroll_id)
print("Scroll cleared.")
Scroll API 的局限性:
- 非实时性: Scroll API 适用于非实时的数据导出,因为它在创建游标时会创建一个快照,后续的修改不会反映在结果中。
- 资源消耗: 尽管比
from + size效率高,但 Scroll API 仍然需要占用一定的服务器资源来维护游标,长时间不使用会导致资源浪费。使用宝塔面板等服务器管理工具可以监控ES服务器资源使用情况。 - 无法跳页: Scroll API 只能顺序遍历,无法直接跳到指定的页码。
Search After 的实现与优化
Search After 是另一种深度分页的方案,它基于上次排序的结果来获取下一页的数据。 Search After 需要指定一个唯一的排序字段,比如文档的 _id 或者一个时间戳字段。 它通过 search_after 参数告诉 ES 从上次结果的哪个位置开始获取数据。
代码示例:
from elasticsearch import Elasticsearch
# 初始化 Elasticsearch 客户端
es = Elasticsearch(['http://localhost:9200'])
# 首次查询
resp = es.search(
index='my_index',
size=10,
sort=[{'timestamp': 'asc'}], # 必须指定排序字段
body={
'query': {'match_all': {}}
}
)
results = resp['hits']['hits']
# 获取最后一个文档的排序值
last_sort_value = results[-1]['sort']
# 第二次查询,使用 search_after
resp = es.search(
index='my_index',
size=10,
sort=[{'timestamp': 'asc'}],
search_after=last_sort_value,
body={
'query': {'match_all': {}}
}
)
# 打印结果
for hit in resp['hits']['hits']:
print(hit['_source'])
Search After 的优点:
- 实时性: Search After 获取的是实时数据,对数据的修改会立即反映在结果中。
- 效率较高: 避免了
from + size的深度扫描,只获取指定位置之后的数据。
Search After 的缺点:
- 必须指定排序字段: 需要一个唯一的排序字段,如果排序字段不是唯一的,可能会出现重复数据。
- 无法跳页: 和 Scroll API 一样,Search After 也只能顺序遍历,无法直接跳到指定的页码。 使用 Redis 缓存可以实现跳页功能,将每次查询结果存储在 Redis 中,下次查询时直接从 Redis 获取。
分页优化策略与实战避坑
- 避免无意义的深度分页: 很多时候,用户并不需要浏览到非常靠后的页面。 可以通过限制最大页数或者提供更精确的搜索条件来减少深度分页的发生。
- 使用合适的缓存策略: 对于不经常变化的数据,可以使用 Redis 或者 Memcached 等缓存系统来缓存分页结果,提高响应速度。 对于更新频繁的数据,可以设置较短的缓存过期时间。
- 优化查询语句: 慢查询是深度分页的罪魁祸首之一。 使用 ES 的 Profile API 分析慢查询,优化查询语句,例如使用更精确的 Filter Context,避免全表扫描。
- 监控 ES 集群状态: 使用 Elasticsearch Head 或者 Cerebro 等工具监控 ES 集群的 CPU、内存、磁盘 I/O 等指标,及时发现和解决性能问题。 还可以使用 Grafana 和 Prometheus 对ES集群进行监控和报警。
- 合理设置分片和副本数: 合理的分片和副本数可以提高 ES 的查询性能和可用性。 过多的分片会增加管理的复杂性,过少的副本数会降低可用性。 需要根据实际情况进行权衡。
- 避免在排序字段中使用 analyzed 字段: analyzed 字段会导致排序性能下降,尽量使用 not_analyzed 字段或者 keyword 字段进行排序。
- 使用冷热数据分离: 将不经常访问的历史数据迁移到低成本的存储介质上,可以减轻 ES 集群的压力。
- 数据建模优化: 针对具体的业务场景,合理设计索引的 mapping,避免过度索引和冗余字段。合理的数据建模能够显著提升查询效率。
通过综合运用以上策略,可以有效地解决 ES 深度分页带来的性能问题,提升用户体验。
冠军资讯
代码一只喵