分页功能设计(解决数据重复问题)

前言

传统分页的话,一般只考虑传页数和每页数据条数这两个参数给后端,为了方便后面描述,我们给这个传参方式起个名字叫传统分页。这种传参方式对于静态数据(数据不会变动)的分页是没问题的,因为每条数据的顺序、数据的总量,都是不变的。

如果出现数据顺序变动或者数据总量变动的分页需求时,单纯的传page和limit已经不能解决了。

列表分类

不同的需求需要显示的列表也不一样。关于列表分页我认为主要关系到两个方面,总量(列表头插入了新数据) 和 排列顺序传统分页总量不变,排列顺序不变 的列表下是没有任何问题的,但只要这两个要素其中一个是变化的,传统分页 方式就会出现BUG(具体案例后面会讲到)。关于上面提到两个要素对应的需求举例:

  • 总量不变,排列顺序改变:排行榜
  • 总量改变,排列顺序不变:文章留言列表
  • 总量改变,排列顺序改变:评论列表(点赞数倒叙)

排行榜

现在有一个积分排行榜

用户 积分
A 1200
B 900
C 820
D 750
E 620

假定每页显示3条数据,在某一时刻拿第一页数据时,得到 A、B、C三条数据。就在此时,用户D突然增加了100积分,最新的排行榜情况变成了

用户 积分
A 1200
B 900
D 850
C 820
E 620

传统分页 的情况下,获取第二页数据时,即从当前排行榜第四条数据开始获取,得到 C、E,用户看到的数据就变成 A、B、C、C、E。这里C出现了2次,而且D消失了。这就是传统分页用在 数据排列顺序会改变的列表 时会出现的问题,因为列表顺序改变导致出现重复数据和丢失数据。

这种 总量不变,排列顺序改变 的分页问题我能想到的暂时有两种方案解决:一次性取出、排行榜快照、通过变动记录表拿数据。

一次性取出(针对特殊需求)

这里说的一次性取出是针对类似“top100”这种取有限条数的需求。在比较简单的列表数据结构下一次性取出100条数据对服务器性能来说问题不大,但是在复杂数据结构下(涉及关联多个表、数据格式化、数据处理等)一次性处理100或更多的数据肯定是糟糕的做法。

排行榜主要的分页问题是影响排名的字段的值在不断变化导致列表顺序不断改变,我们现在可以一次性取出整个列表但是又担心复杂的数据结构导致服务器性能问题。那如果我们把整个功能拆分一下,用异步的思想来做这个功能设计如何呢。

我们分两个接口来做这个功能:获取排行榜列表和获取用户排行榜数据。

获取排行榜列表接口 一次性取整个排名列表的用户ID和排名相关的字段数据,这样就保证了整个列表的排序是不变的同时,又不增大服务器性能。

获取用户排行榜数据接口 负责取排行榜要显示的用户的其他数据,这个接口接受多个用户ID的作为参数。这个接口做了类似分页的功能,前端每次从排行榜中按分页的方式按顺序取部分用户ID,然后通过这个接口获取具体数据显示给用户。

下面以例子的方式来做具体说明:

这是一个积分排行 top100

用户ID 昵称 胜率 …… 积分
5 B 85% …… 8042
12 C 71% …… 7112
60 D 67% …… 6242
2 A 66% …… 6175
77 E 60% …… 5422
……

这里的排行条件是 积分,那我们的 获取排行榜列表接口 只需要取“用户ID”和“积分”即可,剩下的 “昵称”、“胜率”等数据通过 获取用户排行榜数据接口 获取。

前端先请求 列表接口,获取到一下数据:

用户ID 积分
5 8042
12 7112
60 6242
2 6175
77 5422
……

然后根据这个列表数据,先取前10条的用户ID:5、12、60、2、77… 去请求 获取用户排行榜数据接口,把获得的用户数据填充到排行榜中。当用户下滑加载更多数据时再去列表取在11-20的用户ID重复上面的操作。

如果是 top100 的需求,这个方案是比较推荐的,因为没有性能和储存空间上的额外消耗。

排行榜快照(推荐)

因为考虑到主要问题出在排列顺序是变化的,而且通过其他APP也有看到过按时刷新的排行榜,所以想到了用快照的方式来解决。

可以通过写一个定时脚本,每5分钟生成一次排行榜的快照信息并存下来。接口请求时直接从快照中取数据,这一定程度上解决了列表排序一直在变化问题。这里之所以说只解决了一定程度,是因为在每次刷新快照数据的时候,可能有用户刚好卡在这个时间点之间去请求(刷新快照前用户请求了第一页数据,刷新快照后用户请求第二页,这就出现传统分页同样的问题了)。

可以通过在快照中加上 版本号 来解决问题。例如在生成快照的时候以当前时间戳作为版本号跟快照数据一起保存,同时需要系统保存多份快照数据以便用户获取旧快照数据。请求接口时默认拿最新版本的快照,如果接口传入了版本号就拿对应版本号的快照数据。

优点:

  1. 通俗易懂,传参方式跟 传统分页 类似。
  2. 请求处理效率高,生成快照时可以把数据进行处理再保存(例如日期格式转换、类型key值转类型名字等),使得请求到来时获取的数据可以直接返回给用户,无需再做处理。
  3. 易于测试和排查,在生成快照那一刻已经决定了整个列表的数据展示,测试和错误排查很方便。

缺点:

  1. 实时性比较差,用户拿到的数据不是最新的。
  2. 需要额外存储空间,需要额外的地方存储多个版本的快照数据。
  3. 需要定时器,对于本来存在定时器的系统架构,这一点不算缺点。

通过变动记录表拿数据

每个完备的系统都会有数据变动的记录表,用于追踪数据变动和操作明细。记录变记录着数据每次变动前后的变化和变动时间,这一特性为使得数据的每次变动都有迹可循,我们就是利用这一点来做排行榜的分页。

我们分页出问题的地方就是因为数据在不断变化导致排序不停改变。上面说到每次数据变动都会有记录,那我们只需要根据某一时刻之前用户的数据来做排名,是不是就解决数据不断变动这个问题。文字表达可能不太直观,看下面的数据演示应该能比较好理解。

假定用户 A、B、C 初始默认都是100积分

表:score_log

id 用户
user
变动数量
change_num
变动后积分
after_num
创建时间
create_time
1 A 10 110 2019-01-15 00:00:00
2 B 20 120 2019-01-15 00:01:00
3 C 25 125 2019-01-15 00:02:00
4 A 100 210 2019-01-15 00:03:00
5 B 10 130 2019-01-15 00:04:00

表格中为了方便查看,用了varchar类型表示时间,在实际应用中应该使用int型来存储,因为需要加索引。

假定在03分的时候请求了数据,通过下面的SQL语句就可以拿到03分之前的数据排行。

SELECT max(`id`) id,`user`,`after_num` FROM `score_log` WHERE `create_time`<="2019-01-15 00:03:00" GROUP BY `user` ORDER BY `after_num` DESC LIMIT 0,2;

得到第一页数据:

id 用户
user
变动后积分
after_num
创建时间
create_time
1 A 210 2019-01-15 00:03:00
3 C 125 2019-01-15 00:02:00
SELECT max(`id`) id,`user`,`after_num` FROM `score_log` WHERE `create_time`<="2019-01-15 00:03:00" GROUP BY `user` ORDER BY `after_num` DESC LIMIT 2,2;

第二页数据:

id 用户
user
变动后积分
after_num
创建时间
create_time
1 B 120 2019-01-15 00:01:00

关于这种方式的请求,前端需要记录发起第一次请求时的时间,以后每页的请求都带着这个时间。

优点:

  1. 无需额外存储数据,利用系统原有数据结构来解决数据变动问题,也无需做多版本控制。
  2. 数据相对实时,每次拿到的排行榜数据都是请求第一页那一刻最新的数据。

缺点:

  1. 效率相对较差,由于数据需要实时排序和获取,效率相比排行榜要低。而且上面例子只取了记录表中最基础的数据,实际需求中一般需要关联更多的表去取信息,所以效率将随着需求负责度增大而降低。
  2. 只适用于用户量不大的情况,由于数据变动记录表的数据量随着用户量的递增是呈倍数递增的,所以用户量达到一定程度的情况下,这个方式效率会变得相当低。

文章评论列表

评论列表一般按照倒叙排列,而且顺序不变。因为是倒叙排列,所以最新的用户评论会放在最顶部,这就会导致问题了。我们还是用实际例子来说。

评论ID 内容
5 我是第一
4 好看
3 地板
2 板凳
1 沙发

假定每页拿3条数据,此时请求第一页,得到ID分别5、4、3的评论。在请求第二页之前,突然又来了一条留言,此时列表变成:

评论ID 内容
6 现在我是第一了
5 我是第一
4 好看
3 地板
2 板凳
1 沙发

传统分页方式,此时获取第二页会得到ID 3、2、1,这里ID 3 就重复取出来了。

这个问题的解决方案相比排行榜列表分页问题简单而且易懂。评论ID是一个自增的int字段,新的评论ID总是比旧评论ID要大,利用这一点我们可以很好的解决问题。

接口传参:

字段 类型 必填 说明
lastid int 上一页最后一条数据的ID
limit int 取多少条数据

limit 就不用作解释,说一下lastid。当获取第一页数据时,因为没有上一页所以 lastid 传空或者不传,此时服务器取最新的数据即可。获取第二页数据时,lastid 传第一页最后一条数据的ID,此时服务器取 ID < lastid 的数据,这就保证最新的评论不会影响到当前用户的分页。

这里做一个扩展,我们有时候看到有的页面在刷新的时候,会提示有多少条新的未查看评论(即列表头新的数据),这个功能的实现原理跟我们上面分页的原理差不多。在获取第一页数据时,把第一页的第一条数据ID保存下来,后面请求每一页时都把第一条ID(firstid)带上,服务器每次查 ID > firstid 的数据条数,如果大于0即表示有新的评论。

评论列表(点赞数倒叙)

首先说一下,下面提供的方法我自己也不满意(如果有什么想法欢迎大家留言交流)。参考了微博的评论排序也存在上面说到的分页bug,感觉要完美解决这个需求的分页问题花费的代价(实现时间、服务器性能、存储空间等)大于功能本身,所以建议读者选择比较折中的方式来处理(与产品或上级沟通实现的难度)。

这个需求相比评论列表,多了点赞的功能,列表按点赞数量倒叙排列。先说一下不严谨情况下这个分页的实现方式:

## 优先对点赞数量倒叙,再对评论ID倒叙
SELECT * FROM article_comment WHERE articleid=1 AND status=1 ORDER BY like_num DESC,commentid DESC LIMIT 0,10;

这种方式会有两个问题:

  1. 评论点赞数的变化导致列表排序不断改变
  2. 新写的评论会影响列表的总量

我们可以沿用上面讲到的两个需求的解决方案。在解决列表排序问题上,我们可以沿用排行榜的通过变动记录表拿数据方式,增加一个表去记录评论的点赞变动记录(用空间换效率)。

表结构:

CREATE TABLE `article_comment` (
  `commentid` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `articleid` int(11) DEFAULT NULL,
  `content` varchar(255) CHARACTER SET latin1 DEFAULT NULL,
  `like_num` int(11) DEFAULT NULL,
  `status` tinyint(4) DEFAULT NULL,
  PRIMARY KEY (`commentid`),
  KEY `INDEX_ARTICLEID` (`articleid`),
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT '文章评论表';

CREATE TABLE `article_comment_log` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `commentid` int(11) DEFAULT NULL,
  `change_num` int(11) DEFAULT NULL,
  `change_type` tinyint(4) DEFAULT NULL,
  `after_num` int(11) DEFAULT NULL,
  `create_time` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `INDEX_COMMENTID` (`commentid`),
  KEY `INDEX_CREATETIME` (`create_time`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT '文章评论点赞数变动记录表';

分页用到的查询语句:

SELECT
    ac.content,
    ac.commentid,
    tmp.after_num
FROM
    article_comment ac
LEFT JOIN (
    SELECT
        max(after_num) AS after_num,
        commentid
    FROM
        article_comment_log
    WHERE
        create_time < 1547960636
    GROUP BY
        commentid
    LIMIT 0,
    10
) tmp ON tmp.commentid = ac.commentid
WHERE
    ac. STATUS = 1
ORDER BY
    after_num DESC;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342