前言
Hello~大家好。我是秋天的一阵风
在业务开发中,分页是一个再常见不过的需求。无论是电商平台的商品列表,还是社交媒体的动态流,分页都是提升用户体验的重要手段。然而,当数据量逐渐增大时,分页问题就会悄然演变成一个"隐形杀手"------深分页问题。今天,我们就来深入探讨这个让无数开发者头疼的问题,并详细分析几种解决方案,尤其是Elasticsearch(ES)
如何优雅地解决深分页问题。
一、什么是深分页问题?
深分页问题通常出现在需要查询大量数据的场景中。假设你有一个包含1000万条记录的表,用户想要查看第9999页的数据(每页10条)。传统的分页查询可能会使用类似以下的SQL语句:
sql
SELECT * FROM table ORDER BY id LIMIT 99990, 10;
这条语句的逻辑是跳过前99990条记录,然后返回接下来的10条。看起来很简单,对吧?但问题在于,数据库在执行这条语句时,实际上需要扫描前99990条记录,然后再返回10条。随着偏移量的增加,查询的性能会急剧下降,甚至可能导致数据库崩溃。
这就是深分页问题的核心:偏移量越大,查询效率越低。
二、深分页问题的解决方案
既然深分页问题如此棘手,那我们该如何应对呢?以下是几种常见的解决方案:
1. 游标分页(Cursor-based Pagination)
游标分页是一种基于唯一标识(如ID或时间戳)的分页方式。它的核心思想是记录上一次查询的最后一条记录的标识,下一次查询时直接从该标识之后开始查询。例如:
sql
SELECT * FROM table WHERE id > last_id ORDER BY id LIMIT 10;
这种方式避免了偏移量的计算,性能非常稳定。但它有一个限制:用户无法直接跳转到某一页,只能一页一页地往下翻。
2. 子查询优化
在某些数据库中,可以通过子查询的方式优化深分页。例如:
sql
SELECT * FROM table WHERE id >= (SELECT id FROM table ORDER BY id LIMIT 99990, 1) LIMIT 10;
这种方式通过子查询先定位到偏移量的起始位置,然后再查询数据,性能会比直接使用LIMIT offset, size
好一些。但它仍然无法完全解决深分页的性能问题。并且这种方法只适用于 ID 是正序的。在复杂分页场景,往往需要通过过滤条件,筛选到符合条件的 ID,此时的 ID 是离散且不连续的。
另一种基于子查询的优化写法(不依赖于id有序,通用性更强)
sql
select t1.* FROM account t1, (select id from account where update_time >= '2020-09-19' limit 100000, 10) t2 where t1.id = t2.id;
3. 延迟查询
延迟关联的优化思路,跟子查询的优化思路其实是一样的 :都是希望减少回表。不同点是,延迟关联使用了inner join代替子查询。
阿里巴巴《Java 开发手册》中也有对应的描述:
利用延迟关联或者子查询优化超多分页场景。
sql
SELECT t.*
FROM table AS t
INNER JOIN (
SELECT id
FROM table
ORDER BY id
LIMIT 99990, 10 -- 获取目标分页的主键范围
) AS sub ON t.id = sub.id;
-
子查询的作用:
- 子查询
(SELECT id FROM table ORDER BY id LIMIT 99990, 10)
的目的是快速定位到目标分页的主键范围(id
)。这里直接获取了目标分页的 10 条记录的id
,而不是像原始 SQL 那样只获取一个起始点。 - 这样可以避免主查询中因
id >= ...
导致的范围扫描,直接定位到目标记录。
- 子查询
-
主查询的作用:
- 主查询通过
INNER JOIN
将子查询返回的id
与原表进行关联,只返回这些id
对应的完整记录。 - 这种方式可以利用索引快速定位到目标记录,减少主查询的扫描范围。
- 主查询通过
4. 缓存分页数据
对于一些不经常变动的数据,可以将分页结果缓存起来。例如,使用Redis缓存前几页的数据,减少数据库的压力。但这种方式只适用于数据更新频率较低的场景。
5. 业务限制
以京东 web 端为例,根据关键词搜索历史订单,时间维度默认为近三个月 ,以年为单位允许用户手动切换,但不允许查询全量数据。
除此之外还有各大搜索网站在分页主件上做了限制:
百度
Github
4. Elasticsearch的Search After
接下来,我们重点介绍Elasticsearch如何解决深分页问题。
三、为什么ES可以解决深分页问题?
Elasticsearch(ES)是一个分布式的搜索引擎,天生适合处理海量数据的查询。它提供了多种分页方式,其中最适合解决深分页问题的是Search After。
「基本原理」
es维护一个实时游标,它以上一次查询的最后一条记录为游标,方便对下一页的查询,它是一个无状态的查询,因此每次查询的都是最新的数据。
由于它采用记录作为游标,因此 「SearchAfter要求doc中至少有一条全局唯一变量(每个文档具有一个唯一值的字段应该用作排序规范)」
ES的Search After机制与游标分页类似,但它更加强大。它的核心思想是:基于上一页的最后一条记录的排序值,作为下一页查询的起始点。这种方式完全避免了偏移量的计算,因此性能非常稳定。
举个例子,假设我们有一个索引存储了用户的订单数据,我们需要分页查询这些订单。使用Search After的方式如下:
-
第一次查询:
json{ "size": 10, "sort": [ {"order_date": "asc"}, {"_id": "asc"} ] }
返回的结果中,每条记录都会包含排序字段的值(如
order_date
和_id
)。 -
第二次查询时,使用上一页最后一条记录的排序值作为起始点:
json{ "size": 10, "sort": [ {"order_date": "asc"}, {"_id": "asc"} ], "search_after": [last_order_date, last_id] }
通过这种方式,ES可以高效地返回下一页的数据,而无需扫描前面的所有记录。
Search After的优势
- 性能稳定:无论查询第几页,性能都不会下降。
- 适合海量数据:ES的分布式架构可以轻松处理亿级甚至更大规模的数据。
- 灵活性高:支持多字段排序,适用于复杂的业务场景。
Search After的局限性
- 无法跳页:和游标分页一样,用户只能一页一页地往下翻。
- 依赖排序字段 :必须指定一个唯一的排序字段(如
_id
),否则可能会导致分页结果不准确。
总结
深分页问题是业务开发中一个常见的性能瓶颈,尤其是在数据量庞大的场景下。传统的LIMIT offset, size
方式虽然简单,但在深分页时性能极差。通过游标分页、子查询优化、缓存分页等方式,我们可以在一定程度上缓解这个问题。而Elasticsearch的Search After机制,则为我们提供了一种更加优雅和高效的解决方案。