“AI 技术+人才”如何成为企业增长新引擎?戳此了解>>> 了解详情
写点什么

海量数据的分页怎么破?

  • 2020-01-15
  • 本文字数:3161 字

    阅读完需:约 10 分钟

海量数据的分页怎么破?

一、背景

分页应该是极为常见的数据展现方式了,一般在数据集较大而无法在单个页面中呈现时会采用分页的方法。


各种前端 UI 组件在实现上也都会支持分页的功能,而数据交互呈现所相应的后端系统、数据库都对数据查询的分页提供了良好的支持。


以几个流行的数据库为例:


查询表 t_data 第 2 页的数据(假定每页 5 条)


  • MySQL 的做法:

  • select * from t_data limit 5,5

  • ostGreSQL 的做法:

  • select * from t_data limit 5 offset 5

  • MongoDB 的做法:

  • db.t_data.find().limit(5).skip(5);

  • 尽管每种数据库的语法不尽相同,通过一些开发框架封装的接口,我们可以不需要熟悉这些差异。如 SpringData 提供的分页接口:


public interface PagingAndSortingRepository  extends CrudRepository {   Page findAll(Pageable pageable);}
复制代码


这样看来,开发一个分页的查询功能是非常简单的。


然而万事皆不可能尽全尽美,尽管上述的数据库、开发框架提供了基础的分页能力,在面对日益增长的海量数据时却难以应对,一个明显的问题就是查询性能低下!


那么,面对千万级、亿级甚至更多的数据集时,分页功能该怎么实现?


下面,我以 MongoDB 作为背景来探讨几种不同的做法。

二、传统方案

就是最常规的方案,假设 我们需要对文章 articles 这个表(集合) 进行分页展示,一般前端会需要传递两个参数:


  • 页码(当前是第几页)

  • 页大小(每页展示的数据个数)

  • 按照这个做法的查询方式,如下图所示:

  • 因为是希望最后创建的文章显示在前面,这里使用了_id 做降序排序。

  • 其中红色部分语句的执行计划如下:


{  "queryPlanner" : {    "plannerVersion" : 1,    "namespace" : "appdb.articles",    "indexFilterSet" : false,    "parsedQuery" : {      "$and" : []    },    "winningPlan" : {      "stage" : "SKIP",      "skipAmount" : 19960,      "inputStage" : {        "stage" : "FETCH",        "inputStage" : {          "stage" : "IXSCAN",          "keyPattern" : {            "_id" : 1          },          "indexName" : "_id_",          "isMultiKey" : false,          "direction" : "backward",          "indexBounds" : {            "_id" : [               "[MaxKey, MinKey]"            ]         ...}
复制代码


可以看到随着页码的增大,skip 跳过的条目也会随之变大,而这个操作是通过 cursor 的迭代器来实现的,对于 cpu 的消耗会比较明显。


而当需要查询的数据达到千万级及以上时,会发现响应时间非常的长,可能会让你几乎无法接受!


或许,假如你的机器性能很差,在数十万、百万数据量时已经会出现瓶颈

三、改良做法

既然传统的分页方案会产生 skip 大量数据的问题,那么能否避免呢?答案是可以的。


改良的做法为:


  1. 选取一个唯一有序的关键字段,比如 _id,作为翻页的排序字段;

  2. 每次翻页时以当前页的最后一条数据_id 值作为起点,将此并入查询条件中。


如下图所示:



修改后的语句执行计划如下:


{  "queryPlanner" : {    "plannerVersion" : 1,    "namespace" : "appdb.articles",    "indexFilterSet" : false,    "parsedQuery" : {      "_id" : {        "$lt" : ObjectId("5c38291bd4c0c68658ba98c7")      }    },    "winningPlan" : {      "stage" : "FETCH",      "inputStage" : {        "stage" : "IXSCAN",        "keyPattern" : {          "_id" : 1        },        "indexName" : "_id_",        "isMultiKey" : false,        "direction" : "backward",        "indexBounds" : {          "_id" : [             "(ObjectId('5c38291bd4c0c68658ba98c7'), ObjectId('000000000000000000000000')]"          ]      ...}
复制代码


可以看到,改良后的查询操作直接避免了昂贵的 skip 阶段,索引命中及扫描范围也是非常合理的!


性能对比


为了对比这两种方案的性能差异,下面准备了一组测试数据。


测试方案


准备 10W 条数据,以每页 20 条的参数从前往后翻页,对比总体翻页的时间消耗


db.articles.remove({});var count = 100000; var items = [];for(var i=1; i<=count; i++){   var item = {    "title": "论年轻人思想建设的重要性-" + i,    "author" : "王小兵-" + Math.round(Math.random() * 50),    "type" : "杂文-" + Math.round(Math.random() * 10) ,    "publishDate" : new Date(),  } ;  items.push(item);    if(i%1000==0){    db.test.insertMany(items);    print("insert", i);     items = [];  }}
复制代码


传统翻页脚本


function turnPages(pageSize, pageTotal){   print("pageSize:", pageSize, "pageTotal", pageTotal)   var t1 = new Date();  var dl = [];   var currentPage = 0;  //轮询翻页  while(currentPage &lt; pageTotal){      var list = db.articles.find({}, {_id:1}).sort({_id: -1}).skip(currentPage*pageSize).limit(pageSize);     dl = list.toArray();      //没有更多记录     if(dl.length == 0){         break;     }     currentPage ++;     //printjson(dl)  }   var t2 = new Date();   var spendSeconds = Number((t2-t1)/1000).toFixed(2)  print("turn pages: ", currentPage, "spend ", spendSeconds, ".")   }
复制代码


改良翻页脚本


function turnPageById(pageSize, pageTotal){   print("pageSize:", pageSize, "pageTotal", pageTotal)   var t1 = new Date();   var dl = [];  var currentId = 0;  var currentPage = 0;   while(currentPage ++ &lt; pageTotal){       //以上一页的ID值作为起始值     var condition = currentId? {_id: {$lt: currentId}}: {};     var list = db.articles.find(condition, {_id:1}).sort({_id: -1}).limit(pageSize);     dl = list.toArray();      //没有更多记录     if(dl.length == 0){         break;     }      //记录最后一条数据的ID     currentId = dl[dl.length-1]._id;  }   var t2 = new Date();   var spendSeconds = Number((t2-t1)/1000).toFixed(2)  print("turn pages: ", currentPage, "spend ", spendSeconds, ".")    }
复制代码


以 100、500、1000、3000 页数的样本进行实测,结果如下:


可见,当页数越大(数据量越大)时,改良的翻页效果提升越明显!


这种分页方案其实采用的就是时间轴(TImeLine)的模式,实际应用场景也非常的广,比如 Twitter、微博、朋友圈动态都可采用这样的方式。


而同时除了上述的数据库之外,HBase、ElasticSearch 在 Range Query 的实现上也支持这种模式。

四、完美的分页

时间轴(TimeLine)的模式通常是做成“加载更多”、上下翻页这样的形式,但无法自由的选择某个页码。


那么为了实现页码分页,同时也避免传统方案带来的 skip 性能问题,我们可以采取一种折中的方案。


这里参考 Google 搜索结果页作为说明:


通常在数据量非常大的情况下,页码也会有很多,于是可以采用页码分组的方式。


以一段页码作为一组,每一组内数据的翻页采用 ID 偏移量 + 少量的 skip 操作实现


具体的操作如下图所示:



实现步骤


  1. 对页码进行分组(groupSize=8, pageSize=20),每组为 8 个页码;

  2. 提前查询 end_offset,同时获得本组页码数量:

  3. db.articles.find({ _id: { $lt: start_offset } }).sort({_id: -1}).skip(20*8).limit(1)

  4. 分页数据查询以本页组 start_offset 作为起点,在有限的页码上翻页(skip),由于一个分组的数据量通常很小(8*20=160),在分组内进行 skip 产生的代价会非常小,因此性能上可以得到保证。

小结

随着物联网,大数据业务的白热化,一般企业级系统的数据量也会呈现出快速的增长。而传统的数据库分页方案在海量数据场景下很难满足性能的要求。


在本文的探讨中,主要为海量数据的分页提供了几种常见的优化方案(以 MongoDB 作为实例),并在性能上做了一些对比,旨在提供一些参考。


本文转载自华为云开发者社区。


2020-01-15 15:35958

评论

发布
暂无评论
发现更多内容

架构师训练营第一周(总结)

任鉴非

【架构】— 一个简单系统的UML模型

不二架构

极客大学架构师训练营 UML 架构总结

食堂就餐卡系统架构设计

dj_cd

极客大学架构师训练营

食堂就餐卡系统设计

LEAF

低调的网易又要上市了

池建强

创业 网易 慢公司

架构师训练营-学习总结-第一周

ashuai1106

学习 架构师 极客大学架构师训练营

架构师训练营作业一:食堂就餐卡系统设计

sunnywhy

老当益壮的 Servlet

侯树成

Java Java 25 周年 Servlet

为什么建立自己的规则很重要

Neco.W

自我管理 行动派 执行力

架构师训练营第1周_学习总结

chinsun1

架构总结

第二章.软件架构设计

西柚

一篇文章快速搞懂 Atomic(原子整数/原子引用/原子数组/LongAdder)

学习Java的小姐姐

Java 并发编程 并发 synchronized Atomic

Week1命题作业

星河寒水

部署图 时序图 组件图 用例图

剖析Golang Context:从使用场景到源码分析

伴鱼技术团队

源码分析 并发编程 程序语言 Context Go 语言

食堂就餐系统设计

Hugo

架构师作业一:食堂就餐卡系统设计

李锦

第1周 - 学习总结

大海

架构师训练营第一课

Coder的技术之路

架构师训练营 No.1 周作业

连增申

架构建模总结

任鉴非

第一周总结

LEAF

聊聊Java中的Thread类

geekymv

线程 Java25周年 Thread Runnable

UML作业

王志祥

极客大学架构师训练营

8000字长文让你彻底了解 Java 8 的 Lambda、函数式接口、Stream 用法和原理

古时的风筝

函数式接口 Lambda stream Java 25 周年

食堂就餐卡系统设计

Kiroro

架构师训练营Week1总结

sunnywhy

架构师训练营第一周 - 学习总结

Eric

极客大学架构师训练营

Hello World!

东哥

极客大学架构师训练营

week01 UML 学习总结

李锦

架构师训练营第一周总结

Kiroro

架构师训练营-作业-第一讲

吕浩

极客大学架构师训练营

海量数据的分页怎么破?_行业深度_华为云开发者联盟_InfoQ精选文章