滑动分页列表数据重复或丢失问题
滑动分页列表数据重复或丢失问题
背景
在某CMS系统中,由于后台管理高频的操作新增、删除、调整了数据顺序,导致APP用户在滑动分页获取数据时返回重复或丢失数据。主要出现场景:APP用户上滑加载更多数据,在上一页下一页时间间隔过程中,后台新增了数据,可能导致新获取到的一页数据和上一次上滑加载拉取到的数据产生重复;也可能因为期间后台删除操作后,分页查询的数据“丢失”了一些;甚至后台可以改变数据查询排序的顺序,导致数据混乱(包括重复和“丢失”)。
分页类型
- 页码分页
用户只能同时看到1页的数据,点击页码或翻页,不会出现重复或丢失问题。
- 滑动分页
用户可以在不重新请求的情况下,再下滑返回去看到历史分页数据,同时可以看到多页数据。
分页设计的两大难点
- 数据变动导致的数据重复或丢失
- 一次性加载大量数据导致查询缓慢
常见问题场景
- 排行榜列表
- 新闻列表
- 评论列表
- 商品列表
问题复现
情景一:新增数据,导致重复(单排序字段)
1、正常情况:两次查询期间,数据未发生新增、删除等操作
2、当查询了第 1 页数据后,新增了 2 条数据:
3、在原有已经查看了第 1 页数据后,然后APP用户上滑加载第 2 页:
4、APP下拉刷新,重新获取 新增后的 第 1 页、第 2 页 数据:
情景二:删除数据,导致“丢失”(单排序字段)
1、获取第 1 页数据后,删除了一条数据:
2、然后APP上滑加载第 2 页,发现丢失了一条 id = 78 的数据
3、下拉刷新,重新拉取删除后的 最新的第 1 页、第 2 页 数据:
情景三:修改数据排序,重复或者“丢失”(多排序字段)
1、增加排序字段:
2、获取第 1 页、第 2 页数据,优先按照 news_order 倒序,再按照 主键ID 倒序,正常情况:
3、在已经获取第 1 页数据后,修改数据排序 news_order 的值(id=79和id=80):
4、然后,APP上滑加载第 2 页,发现重复数据 2 条,丢失数据 2 条:
5、下拉刷新,重新获取第 1 页、第 2 页 数据,发现上一步重复的数据已经从第 1 页抹掉,丢失的两条也放在了第 1 页:
问题分析
传统的数据分页接口,只是简单地使用 page number (页码) 和 page size (每页数量) 计算 offset 进行分页查询返回数据。
MySQL分页查询语句原型:
SELECT
column1,column2,...
FROM
table
LIMIT offset,count;
- offset:偏移量
- count:数量
注意,
offset 并不是指向的主键ID,而是具体的数据行号,从 0 开始,先通过条件查询出来全量数据列表,排序规则排好序后,再从 offset 处开始扫描,取得 count 行的数据。所以说,分页查询实际扫描行数,不仅和 count 成正比,而且跟 offset 是成正比的,也就是页码越靠后,查询扫描的行数也就越多。
如上图,我们从全部条件列表中查询 第 2 页 数据,分页语句是:
limit 10,10
实际去数据库执行的时候,数据指针会将 offset 从 0 移动到 10 的位置,再从这个位置开始取出 10 条数据,当然,前面的 10 条只是扫一遍索引,不会取数据。但是当数据量特别大的时候,页码越靠后,查询就会越卡,就是因为,始终是要去扫描一遍这个分页以前的行,例如:
limit 10, 100
limit 100000, 100
同样 count 都是取 100 条 数据,但是第2个 limit 就要比第1个慢很多很多。(附:此时可以用子查询优化,让偏移量的扫描命中索引)
重复数据分析
注:图片来源于知乎文章(zhuanlan.zhihu.com/p/392019706…
查询第一页完后,获取到的数据是6 ——— 10,随后新增11和12两条数据,进行查询第二页,获取到的数据是3 —— 7,此时6和7被重复拉取了。
“丢失”数据分析
注:图片来源于知乎文章(zhuanlan.zhihu.com/p/392019706…
查询第一页完后,获取到的数据是6 —— 10,随后删除6这条数据,进行查询第二页,获取到的数据是0 —— 4,此时 5 既没有在第一页,也没在第二页,丢失了。
数据顺序变更分析
上述两种情况均有可能发生。
总结
局部数据列表总数、数据位置的变动,但对分页定位的偏移指针未更新,导致数据查询错误问题。
解决方案演进
对于 新增、删除 数据 导致的重复或丢失问题,解决办法比较多,相对容易。
主键ID切分法
描述:记录 上一页 最后一条记录的主键ID,作为参数传递给接口,作为查询条件过滤,以保证相对位置不变;
适用于:新增 导致的数据重复和数据丢失;
先决条件:
- 存在无重复的自增主键ID字段
- 查询仅根据主键排序
实施细节
定义参数:last_id (上一页最后一条数据主键ID, -1 即此次请求为查询第1页)
1、客户端查询第1页数据,last_id = -1 、page_num = 1、page_size = 2,接口正常分页查询返回数据;
SELECT
id,
create_time,
news_title
FROM
news
ORDER BY
id DESC
LIMIT 0,2;
2、用户 上拉加载 请求 第 2 页数据,last_id = 10 、page_num = 2、page_size = 2,接口查询为:
select * from table_name where id > [last_id] order by id asc limit 2
select * from table_name where id < [last_id] order by id desc limit 2
请求时间&创建时间法
描述:记录第一页请求给出数据时的时间,下一页请求数据时作为查询参数带上,后台根据第一页请求时间筛选创建时间在那之前的数据,再分页扫描输出给客户端。
适用于:新增导致的数据重复和数据丢失;
先决条件:
- 存在一个无重复的记录创建时间的字段(带毫秒时间戳)
- 接口增加查询时间字段返回,增加第一页查询时间戳的参数
实施细节:
增加时间戳字段 ( t_data ):
ALTER TABLE news
ADD COLUMN `t_data` bigint(0) UNSIGNED NOT NULL COMMENT '数据写入时间';
定义参数:
select_time(后台查询数据时刻的毫秒时间戳)
first_time(第一页接口返回的select_time,第一页传null)
1、客户端 查询第 1 页数据,参数【first_time = null、page_num = 1、page_size = 2】,接口正常分页查询返回数据,附加一个 select_time 字段;
{
"code": 0,
"msg":"success",
"data":{
"current":1,
"page_size":2,
"list":[{},{}],
"total":10,
"select_time": "1646790837138"
}
}
第 1 页查询,无需根据 t_data 字段进行条件筛选,
2、此时,在后台添加了新数据,sql模拟:
t_data 是添加数据时刻的毫秒时间戳 1646792018138
INSERT INTO news ( `create_time`, `update_time`, `delete_time`, `news_title`, `news_from`, `news_type_id`, `position_ids`, `news_cover`, `news_content`, `author`, `news_order`, `t_data` )
VALUES
( '2022-03-09 09:53:57', '2022-03-09 09:53:57', 0, '测试插入新数据', '测试添加数据', 7, '[1,2]', '', '', 1, 0, 1646792018138 );
3、客户端 上滑加载 第 2 页 数据时,取 第一次请求得到的 select_time 作为 first_time 参数值,参数【first_time = 1646790837138、page_num = 2、page_size = 2】,后台查询数据,
使用 第一次查询时间 作为条件,筛选查询然后分页得出正确的 第2页数据:
上一页最后位置标记法
描述:后台查询得到某页数据后,给客户端返回当前数据页查询的位置标记 curPosition( 无重复的主键ID | 创建毫秒时间戳 ),客户端进行下次请求的时候,带上这个当前位置参数 last_position,后端再根据上次查询位置标记,进行筛选查询分页数据。
适用于:新增|删除 导致的数据重复和丢失
先决条件:
- 无重复的排序字段
实施细节:
参数定义
- last_position 上一页的最后一行数据位置标记字段
- page_size 每页数量
选取数据表现已有的 t_data 毫秒时间戳作为位置标记字段 ( 主键ID也可,但最好是排序字段或者跟排序字段变化正相关的字段 )
1、客户端 首次请求第 1页 数据, 参数【 last_position= null、page_size = 2 】,返回当前查询页最后一条数据的毫秒时间戳作为位置标记,结果如下,
{
"code": 0,
"msg":"success",
"data":{
"current":1,
"page_size":2,
"list":[
{ "id": 91, "t_data": 1646792018138 },
{ "id": 85, "t_data": 1646790684998 }
],
"total":10,
"curPosition": "1646790684998"
}
}
2、然后,后台新增了一行数据,添加时间戳为 1646810886139 ,如下,
INSERT INTO news ( `create_time`, `update_time`, `delete_time`, `news_title`, `news_from`, `news_type_id`, `position_ids`, `news_cover`, `news_content`, `author`, `news_order`, `t_data` )
VALUES
( '2022-03-09 15:28:06', '2022-03-09 15:28:06', 0, '插入新数据', '数据', 7, '[1,2]', '', '', 1, 0, 1646810886139 );
3、使用上一页最后一行记录位置的标记字段,进行筛选,请求第 2 页数据,参数【 last_position= 1646790684998、page_size = 2 】,查询得到数据,
{
"code": 0,
"msg":"success",
"data":{
"current":2,
"page_size":2,
"list":[
{ "id": 84, "t_data": 1646790684997 },
{ "id": 83, "t_data": 1646790684996 }
],
"total":10,
"curPosition": "1646790684996"
}
}
4、删除一行欲获取分页前面的数据,在上一步已经获取了第 2 页数据,此时删除 第 2 页上 id为 83 的数据,
delete from news where id = 83;
5、客户端上拉加载 第 3 页 数据,参数 【 last_position= 1646790684996、page_size = 2 】,查询数据,
小结:
使用,分页末尾标记法,基本可以解决 新增或删除 导致的数据重复和丢失问题。但是前提是数据有序,并且排序顺序跟标记字段相关(比如 order by ID,位置标记字段是 t_data 写入时间戳是跟随自增主键ID大小增长的),如果数据排序字段是多个或随机,此方法失效。
数据总数&偏移量法
描述:查询第 1 页 数据时,返回当前时刻的数据总数,存储总数 first_total,每次查询下一页的时候 带上 第一页 时的数据总数,后台通过新增数量,进行偏移量的计算和后续的分页查询工作。当用户下拉刷新时,重新请求第一页,更新总数 first_total 值。
适用于:排序头部 新增 导致数据重复
先决条件:
- 单字段且根据ID或者创建时间 倒序 排序
实施细节:
参数定义
- total 当前页查询时刻数据总数
- first_total 第一页查询时刻的数据总数
- page_size 每页数
- page_num 页码
当前数据列表10条,
1、客户端 下拉刷新 获取 第 1 页数据,参数【 first_total = 0 、page_num = 1,page_size = 2 】,
后台分页计算偏移位置公式
// 新增条数 = 当前数据总数 - 第一页查询数据总数
// 如果 first_total = 0 那么 new_num = 0
// 否则
new_num = total - pre_total
// 偏移量
offset = (page_num - 1) * page_size + new_num
// 分页查询
limit offset, page_size
// 1 ( new_num = 0 first_total = 10 total = 10)
limit 0,2
// 2 ( new_num = 1 first_total = 10 total = 11)
limit 3,2
// 3 ( new_num = 1 first_total = 10 total = 11)
limit 5,2
// 4 ( new_num = 3 first_total = 10 total = 13)
limit 11,2
返回结果,
{
"code": 0,
"msg":"success",
"data":{
"current":1,
"page_size":2,
"list":[
{ "id": 92, "t_data": 1646810886139 },
{ "id": 91, "t_data": 1646792018138 }
],
"total":10,
}
}
第 1 页,客户端存储 first_total 值 为 10 ,
2、后台插入一条数据,现在是 11 条了,
3、用户 上拉加载 第 2 页 数据,参数【first_total = 10 、page_num = 2,page_size = 2 】,返回结果,
{
"code": 0,
"msg":"success",
"data":{
"current":2,
"page_size":2,
"list":[
{ "id": 85, "t_data": 1646790684998 },
{ "id": 84, "t_data": 1646790684997 }
],
"total":11,
}
}
此时,数据总数增加了 1 条,分页偏移量已经在后端处理了,得到的数据依然是正确的,并且在这里可以拿最新的 total 和客户端加载第 1 页 数据时的 first_total 作对比,提示用户新增了多少数据。
依次类推分页偏移。
缓存更新+数据快照法 (终极大招)
描述:一般情况下,用户访问量较大,都会将热点数据添加到缓存数据库,如 redis 中,实现系统的高并发,新闻类APP 更是如此,将列表数据存入缓存,客户端直接从缓存中获取分页列表的数据,可以提供快速的列表数据响应。这样以来,我们可以在数据变动(增删改)后,把分页列表按照修改时间打一个快照标签,存入到缓存。
适用于:任何 新增、删除、修改顺序 导致的数据重复、“丢失”、混乱问题。
先决条件:
- 使用异步处理写入缓存
- 存储以修改时间维度的数据快照
- 数据ID和信息分开存储
- 有序集合 + 哈希
缺点:
- 数据冗余
- 更新缓存快照耗费资源
- 要防止缓存击穿和雪崩
实施细节:
参数定义
- first_snapshot 第一页请求查询的快照时间戳(有序集合key)
- page_size 每页数
- page_num 页码
1、后台 新增、删除、编辑 数据,写入mysql同时,将当前时间戳作为有序集合的key,把更新后的数据ID全部写入缓存的有序集合,内容写入哈希表中。
2、客户端查询第1页数据,参数【 page_num = 1,page_size = 2 】,后台从当前最新的一个有序集合内进行分页查询。
3、客户端上滑加载,参数【 first_snapshot = 1646810886139,page_num = 2,page_size = 2 】
4、后台从 first_snapshot 缓存快照(有序集合)中取分页数据ID,再去哈希表关联取出数据内容,返回到客户端。
总结,每一次新增、删除或修改,都需要同步生成一个缓存快照,保证原来时间线上的数据缓存不变动,以供在该时间点查询第一页的用户,正常获取当时的分页数据内容。数据ID列表和具体内容信息要分开存储,至于具体的 redis 数据结构、redis 分页存储以及排序,这里不做详解。
代码实践
待开发...
参考链接:
- 分页出现数据重复或丢失的问题,一文搞定!
- MySQL 使用 order by limit 分页排序会导致数据丢失和重复!
- 大数据量下的分页查询优化
- 初学redis分页缓存方法实现
- 如何使用redis进行分页和排序
结束语
雄关漫道真如铁, 而今迈步从头越。从头越, 苍山如海, 残阳如血。
我是 darifo ,一名兴趣广泛的全栈工程师,欢迎私信一起交流学习~
原创文章,掘金独发,转载请注明出处,谢谢!
以上,如有错误,敬请批评指正!