当您的内容排名可以快速变化时分页很难,而且当这些排名因用户而异时更难。 (让我们将无限卷轴视为一种链接不可见的分页。)有两个难题:顶部新添加的内容和重新排列的内容。
让我们忘记新添加的内容,并接受您必须刷新第1页才能看到它。我们也假装我们正在做纯ORDER BY position
;如果您按其他方式订购,则可能必须使用窗口功能。我们的页面每页有4行动物。他们开始了:
+----+----------+-----------+
| id | position^| animal |
+----+----------+-----------+
| 1 | 1 | Alpacas |
| 2 | 2 | Bats |
| 3 | 3 | Cows |
| 4 | 4 | Dogs |
| 5 | 5 | Elephants |
| 6 | 6 | Foxes |
| 7 | 7 | Giraffes |
| 8 | 8 | Horses |
+----+----------+-----------+
在我们获取第1页之后,在我们获取第2页之前,很多项目都会移动。 DB现在是:
+----+----------+-----------+
| id | position^| animal |
+----+----------+-----------+
| 4 | 1 | Dogs |
| 2 | 2 | Bats |
| 1 | 3 | Alpacas |
| 5 | 4 | Elephants |
| 6 | 5 | Foxes |
| 7 | 6 | Giraffes |
| 3 | 7 | Cows |
| 8 | 8 | Horses |
+----+----------+-----------+
有三种常见的方法:
偏移/限制方法
这是典型的天真方法;在Rails中,这是will_paginate和Kaminari的工作方式。如果我想获取第2页,我会做
SELECT * FROM animals
ORDER BY animals.position
OFFSET ((:page_num - 1) * :page_size)
LIMIT :page_size;
获取行5-8。我永远不会看到大象,我会两次看到奶牛。
上次看到的ID方法
Reddit采用了不同的方法。客户端不是根据页面大小计算第一行,而是跟踪您看到的最后一项的ID,如书签。点击“下一步”时,他们会从该书签开始查看:
SELECT * FROM animals
WHERE position > (
SELECT position FROM animals
WHERE id = :last_seen_id
)
ORDER BY position
LIMIT :page_size;
在某些情况下,这比页面/偏移更好。但在我们的案例中,最后看到的帖子Dogs缩小到#1。所以客户端发送?last_seen_id=4
,我的第2页是Bats,Alpacas,Elephants和Foxes。我没有错过任何动物,但我两次看到了Bats和Alpacas。
服务器端状态
HackerNews(以及我们的网站,现在)通过服务器端延续来解决这个问题;他们为您存储整个结果集(或至少提前几页?),“更多”链接引用该延续。当我获取第2页时,我要求“我的原始查询的第2页”。它使用相同的偏移/限制计算,但由于它与原始查询相反,我根本不关心事情现在已经移动了。我看到大象,狐狸,长颈鹿和马。没有重复,没有遗漏物品。
缺点是我们必须在服务器上存储很多状态。在HN上,它存储在RAM中,实际上这些延续通常会在您按“更多”按钮之前到期,迫使您一直返回到第1页以查找有效链接。在大多数应用程序中,您可以将其存储在memcached中,甚至存储在数据库本身中(使用您自己的表,或者使用可保持的游标在Oracle或PostgreSQL中)。根据您的应用程序,可能会有性能损失;至少在PostgreSQL中,你必须找到一种方法再次点击正确的数据库连接,这需要大量的粘性状态或一些聪明的后端路由。
这些是唯一可能的三种方法吗?如果没有,是否有计算机科学概念可以让我阅读这篇文章?有没有方法可以在不存储整个结果集的情况下逼近延续方法?从长远来看,存在复杂的事件流/时间点系统,其中“我获取第1页时的结果集”是永久可导出的。没错......?
答案 0 :(得分:6)
Oracle处理得很好。只要光标处于打开状态,您就可以根据需要多次获取,结果将始终反映光标打开的时间点。它使用撤消日志中的数据虚拟回滚光标打开后提交的更改。
只要所需的回滚数据仍然可用,它就会起作用。最终日志被回收并且回滚数据不再可用,因此存在一些限制,具体取决于日志空间,系统活动等。
不幸的是(IMO),我不知道任何其他像这样工作的数据库。我使用的其他数据库使用锁来确保读取一致性,如果您希望读取一致性超过很短的持续时间,则会出现问题。
答案 1 :(得分:6)
解决方案1:“ hacky解决方案”
解决方案可能包括您的客户端跟踪已经看到的内容,例如ID列表。每次需要其他页面时,都会将此ID列表添加到服务器调用的参数中。然后,您的服务器可以订购内容,删除已经看过的内容并应用偏移量以获得正确的页面。
我不推荐它,但我坚持 hacky 。我只是把它写在这里,因为它很快,可以满足一些需求。这是我能想到的坏事:
1)在客户端需要做一些工作才能做到正确(上面的句子中“已经看到”意味着什么,如果我去上一页怎么办?)
2)生成的订单并不反映您的真实订购政策。内容可以显示在第2页中,尽管该策略应该将其放在第1页上。这可能会导致用户误解。让我们以堆栈溢出为例,使用其以前的排序策略,这意味着首先是最受欢迎的答案。我们可以在第2页中提出6个upvotes的问题,而在第1页有4个upvotes的问题。当用户仍在第1页时发生2个或更多的upvotes时会发生这种情况。 - >对于用户来说可能会令人惊讶。
解决方案2 :“客户端解决方案”
它基本上是客户端等效的解决方案,你称之为“服务器端状态”。只有在服务器端跟踪完整订单不够方便时才有用。如果项目列表不是无限的,它就可以工作。
答案 2 :(得分:4)
我们现在使用服务器端状态方法,在第一个查询上缓存整个结果,因此我们总是返回一致的列表。只要我们的查询已经返回所有行,这将起作用;最终我们需要使用最近邻法并且不会起作用。
但我认为只要有第四种可能性,它可以很好地扩展:
解决方案是“最后见过的ID”解决方案的变体:让客户端保留一个,但不是5个或10个或20个书签 - 足够少,您可以有效地存储它们。查询最终看起来像:
SELECT * FROM posts
WHERE id > :bookmark_1
AND id > :bookmark_2
...
ORDER BY id
随着书签数量的增加,你可能会迅速减少(a)从所有n个书签开始,但是(b)看到重复的内容,因为它们都被重新排列。
如果将来有漏洞或更好的答案,我很乐意接受这个答案。
答案 3 :(得分:1)
聚会很晚,但这是我们尝试过的东西。我们使用的是连续加载,而不是用户之间来回切换的页面。
客户端构建了它显示的所有ID的列表,因此在第一次设置之后它可能是: 4,7,19,2,1,72,3
当我们加载更多内容时,我们使用相同的排序执行相同的查询,但是将其添加到它: 在哪里不是(4,7,19,2,1,72,3)
NOT IN列表可以快速增长。对我们来说,这不是一个问题,因为我们的内部工具通常没有大量的结果。
我想补充一点想法。也许服务器端添加可以应用于此。当用户搜索时,将他们获得的所有ID添加到包含搜索链接的表中。当客户想要更多时,它只需提供搜索ID(或使用服务器端状态),查询可以加入他们的搜索数据。
答案 4 :(得分:0)
如果行包含创建时间戳记,则查询可以包含“之前”过滤器。这样可确保不包括在时间戳之后创建的任何行,因此分页是一致的(前提是这些行在恒定列上排序)。这是一个示例SQL查询,它假设animals.position
列中的值是恒定的。
SELECT
a.*
FROM
animals a
WHERE
a.creation < :before
ORDER BY
a.position
OFFSET ((:page_num - 1) * :page_size)
LIMIT :page_size
当客户端发出初始请求时(例如http://some.server.com/animals
),服务器将:before
设置为当前时间,将:page_num
设置为1,将:page_size
设置为20。服务器的响应包括一个链接,用于请求设置了所有3个参数(例如http://some.server.com/animals?before=2020-04-08T10:40:34.833Z&page_num=2&page_size=20
)的下一页。因此,客户端保留了请求下一页所需的所有状态,并且服务器在分页方面可以保持无状态。
注意:如果用户刷新的URL不带before
参数(即http://some.server.com/animals
),他们将看到新数据。如果用户使用before
参数(即http://some.server.com/animals?before=2020-04-08T10:40:34.833Z&page_num=2&page_size=20
)刷新URL,则他们将看到相同的数据。用户可以随时更改或删除before
参数以查看新数据。