前言
傳統(tǒng)分頁的話,一般只考慮傳頁數(shù)和每頁數(shù)據(jù)條數(shù)這兩個(gè)參數(shù)給后端,為了方便后面描述,我們給這個(gè)傳參方式起個(gè)名字叫傳統(tǒng)分頁。這種傳參方式對(duì)于靜態(tài)數(shù)據(jù)(數(shù)據(jù)不會(huì)變動(dòng))的分頁是沒問題的,因?yàn)槊織l數(shù)據(jù)的順序、數(shù)據(jù)的總量,都是不變的。
如果出現(xiàn)數(shù)據(jù)順序變動(dòng)或者數(shù)據(jù)總量變動(dòng)的分頁需求時(shí),單純的傳page和limit已經(jīng)不能解決了。
列表分類
不同的需求需要顯示的列表也不一樣。關(guān)于列表分頁我認(rèn)為主要關(guān)系到兩個(gè)方面,總量(列表頭插入了新數(shù)據(jù)) 和 排列順序。傳統(tǒng)分頁 在 總量不變,排列順序不變 的列表下是沒有任何問題的,但只要這兩個(gè)要素其中一個(gè)是變化的,傳統(tǒng)分頁 方式就會(huì)出現(xiàn)BUG(具體案例后面會(huì)講到)。關(guān)于上面提到兩個(gè)要素對(duì)應(yīng)的需求舉例:
- 總量不變,排列順序改變:排行榜
- 總量改變,排列順序不變:文章留言列表
- 總量改變,排列順序改變:評(píng)論列表(點(diǎn)贊數(shù)倒敘)
排行榜
現(xiàn)在有一個(gè)積分排行榜
| 用戶 | 積分 |
|---|---|
| A | 1200 |
| B | 900 |
| C | 820 |
| D | 750 |
| E | 620 |
假定每頁顯示3條數(shù)據(jù),在某一時(shí)刻拿第一頁數(shù)據(jù)時(shí),得到 A、B、C三條數(shù)據(jù)。就在此時(shí),用戶D突然增加了100積分,最新的排行榜情況變成了
| 用戶 | 積分 |
|---|---|
| A | 1200 |
| B | 900 |
| D | 850 |
| C | 820 |
| E | 620 |
傳統(tǒng)分頁 的情況下,獲取第二頁數(shù)據(jù)時(shí),即從當(dāng)前排行榜第四條數(shù)據(jù)開始獲取,得到 C、E,用戶看到的數(shù)據(jù)就變成 A、B、C、C、E。這里C出現(xiàn)了2次,而且D消失了。這就是傳統(tǒng)分頁用在 數(shù)據(jù)排列順序會(huì)改變的列表 時(shí)會(huì)出現(xiàn)的問題,因?yàn)榱斜眄樞蚋淖儗?dǎo)致出現(xiàn)重復(fù)數(shù)據(jù)和丟失數(shù)據(jù)。
這種 總量不變,排列順序改變 的分頁問題我能想到的暫時(shí)有兩種方案解決:一次性取出、排行榜快照、通過變動(dòng)記錄表拿數(shù)據(jù)。
一次性取出(針對(duì)特殊需求)
這里說的一次性取出是針對(duì)類似“top100”這種取有限條數(shù)的需求。在比較簡(jiǎn)單的列表數(shù)據(jù)結(jié)構(gòu)下一次性取出100條數(shù)據(jù)對(duì)服務(wù)器性能來說問題不大,但是在復(fù)雜數(shù)據(jù)結(jié)構(gòu)下(涉及關(guān)聯(lián)多個(gè)表、數(shù)據(jù)格式化、數(shù)據(jù)處理等)一次性處理100或更多的數(shù)據(jù)肯定是糟糕的做法。
排行榜主要的分頁問題是影響排名的字段的值在不斷變化導(dǎo)致列表順序不斷改變,我們現(xiàn)在可以一次性取出整個(gè)列表但是又擔(dān)心復(fù)雜的數(shù)據(jù)結(jié)構(gòu)導(dǎo)致服務(wù)器性能問題。那如果我們把整個(gè)功能拆分一下,用異步的思想來做這個(gè)功能設(shè)計(jì)如何呢。
我們分兩個(gè)接口來做這個(gè)功能:獲取排行榜列表和獲取用戶排行榜數(shù)據(jù)。
獲取排行榜列表接口 一次性取整個(gè)排名列表的用戶ID和排名相關(guān)的字段數(shù)據(jù),這樣就保證了整個(gè)列表的排序是不變的同時(shí),又不增大服務(wù)器性能。
獲取用戶排行榜數(shù)據(jù)接口 負(fù)責(zé)取排行榜要顯示的用戶的其他數(shù)據(jù),這個(gè)接口接受多個(gè)用戶ID的作為參數(shù)。這個(gè)接口做了類似分頁的功能,前端每次從排行榜中按分頁的方式按順序取部分用戶ID,然后通過這個(gè)接口獲取具體數(shù)據(jù)顯示給用戶。
下面以例子的方式來做具體說明:
這是一個(gè)積分排行 top100
| 用戶ID | 昵稱 | 勝率 | …… | 積分 |
|---|---|---|---|---|
| 5 | B | 85% | …… | 8042 |
| 12 | C | 71% | …… | 7112 |
| 60 | D | 67% | …… | 6242 |
| 2 | A | 66% | …… | 6175 |
| 77 | E | 60% | …… | 5422 |
| …… |
這里的排行條件是 積分,那我們的 獲取排行榜列表接口 只需要取“用戶ID”和“積分”即可,剩下的 “昵稱”、“勝率”等數(shù)據(jù)通過 獲取用戶排行榜數(shù)據(jù)接口 獲取。
前端先請(qǐng)求 列表接口,獲取到一下數(shù)據(jù):
| 用戶ID | 積分 |
|---|---|
| 5 | 8042 |
| 12 | 7112 |
| 60 | 6242 |
| 2 | 6175 |
| 77 | 5422 |
| …… |
然后根據(jù)這個(gè)列表數(shù)據(jù),先取前10條的用戶ID:5、12、60、2、77… 去請(qǐng)求 獲取用戶排行榜數(shù)據(jù)接口,把獲得的用戶數(shù)據(jù)填充到排行榜中。當(dāng)用戶下滑加載更多數(shù)據(jù)時(shí)再去列表取在11-20的用戶ID重復(fù)上面的操作。
如果是 top100 的需求,這個(gè)方案是比較推薦的,因?yàn)闆]有性能和儲(chǔ)存空間上的額外消耗。
排行榜快照(推薦)
因?yàn)榭紤]到主要問題出在排列順序是變化的,而且通過其他APP也有看到過按時(shí)刷新的排行榜,所以想到了用快照的方式來解決。
可以通過寫一個(gè)定時(shí)腳本,每5分鐘生成一次排行榜的快照信息并存下來。接口請(qǐng)求時(shí)直接從快照中取數(shù)據(jù),這一定程度上解決了列表排序一直在變化問題。這里之所以說只解決了一定程度,是因?yàn)樵诿看嗡⑿驴煺諗?shù)據(jù)的時(shí)候,可能有用戶剛好卡在這個(gè)時(shí)間點(diǎn)之間去請(qǐng)求(刷新快照前用戶請(qǐng)求了第一頁數(shù)據(jù),刷新快照后用戶請(qǐng)求第二頁,這就出現(xiàn)傳統(tǒng)分頁同樣的問題了)。
可以通過在快照中加上 版本號(hào) 來解決問題。例如在生成快照的時(shí)候以當(dāng)前時(shí)間戳作為版本號(hào)跟快照數(shù)據(jù)一起保存,同時(shí)需要系統(tǒng)保存多份快照數(shù)據(jù)以便用戶獲取舊快照數(shù)據(jù)。請(qǐng)求接口時(shí)默認(rèn)拿最新版本的快照,如果接口傳入了版本號(hào)就拿對(duì)應(yīng)版本號(hào)的快照數(shù)據(jù)。
優(yōu)點(diǎn):
- 通俗易懂,傳參方式跟 傳統(tǒng)分頁 類似。
- 請(qǐng)求處理效率高,生成快照時(shí)可以把數(shù)據(jù)進(jìn)行處理再保存(例如日期格式轉(zhuǎn)換、類型key值轉(zhuǎn)類型名字等),使得請(qǐng)求到來時(shí)獲取的數(shù)據(jù)可以直接返回給用戶,無需再做處理。
- 易于測(cè)試和排查,在生成快照那一刻已經(jīng)決定了整個(gè)列表的數(shù)據(jù)展示,測(cè)試和錯(cuò)誤排查很方便。
缺點(diǎn):
- 實(shí)時(shí)性比較差,用戶拿到的數(shù)據(jù)不是最新的。
- 需要額外存儲(chǔ)空間,需要額外的地方存儲(chǔ)多個(gè)版本的快照數(shù)據(jù)。
- 需要定時(shí)器,對(duì)于本來存在定時(shí)器的系統(tǒng)架構(gòu),這一點(diǎn)不算缺點(diǎn)。
通過變動(dòng)記錄表拿數(shù)據(jù)
每個(gè)完備的系統(tǒng)都會(huì)有數(shù)據(jù)變動(dòng)的記錄表,用于追蹤數(shù)據(jù)變動(dòng)和操作明細(xì)。記錄變記錄著數(shù)據(jù)每次變動(dòng)前后的變化和變動(dòng)時(shí)間,這一特性為使得數(shù)據(jù)的每次變動(dòng)都有跡可循,我們就是利用這一點(diǎn)來做排行榜的分頁。
我們分頁出問題的地方就是因?yàn)閿?shù)據(jù)在不斷變化導(dǎo)致排序不停改變。上面說到每次數(shù)據(jù)變動(dòng)都會(huì)有記錄,那我們只需要根據(jù)某一時(shí)刻之前用戶的數(shù)據(jù)來做排名,是不是就解決數(shù)據(jù)不斷變動(dòng)這個(gè)問題。文字表達(dá)可能不太直觀,看下面的數(shù)據(jù)演示應(yīng)該能比較好理解。
假定用戶 A、B、C 初始默認(rèn)都是100積分
表:score_log
| id | 用戶 user |
變動(dòng)數(shù)量 change_num |
變動(dòng)后積分 after_num |
創(chuàng)建時(shí)間 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類型表示時(shí)間,在實(shí)際應(yīng)用中應(yīng)該使用int型來存儲(chǔ),因?yàn)樾枰铀饕?/p>
假定在03分的時(shí)候請(qǐng)求了數(shù)據(jù),通過下面的SQL語句就可以拿到03分之前的數(shù)據(jù)排行。
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;
得到第一頁數(shù)據(jù):
| id | 用戶 user |
變動(dòng)后積分 after_num |
創(chuàng)建時(shí)間 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;
第二頁數(shù)據(jù):
| id | 用戶 user |
變動(dòng)后積分 after_num |
創(chuàng)建時(shí)間 create_time |
|---|---|---|---|
| 1 | B | 120 | 2019-01-15 00:01:00 |
關(guān)于這種方式的請(qǐng)求,前端需要記錄發(fā)起第一次請(qǐng)求時(shí)的時(shí)間,以后每頁的請(qǐng)求都帶著這個(gè)時(shí)間。
優(yōu)點(diǎn):
- 無需額外存儲(chǔ)數(shù)據(jù),利用系統(tǒng)原有數(shù)據(jù)結(jié)構(gòu)來解決數(shù)據(jù)變動(dòng)問題,也無需做多版本控制。
- 數(shù)據(jù)相對(duì)實(shí)時(shí),每次拿到的排行榜數(shù)據(jù)都是請(qǐng)求第一頁那一刻最新的數(shù)據(jù)。
缺點(diǎn):
- 效率相對(duì)較差,由于數(shù)據(jù)需要實(shí)時(shí)排序和獲取,效率相比排行榜要低。而且上面例子只取了記錄表中最基礎(chǔ)的數(shù)據(jù),實(shí)際需求中一般需要關(guān)聯(lián)更多的表去取信息,所以效率將隨著需求負(fù)責(zé)度增大而降低。
- 只適用于用戶量不大的情況,由于數(shù)據(jù)變動(dòng)記錄表的數(shù)據(jù)量隨著用戶量的遞增是呈倍數(shù)遞增的,所以用戶量達(dá)到一定程度的情況下,這個(gè)方式效率會(huì)變得相當(dāng)?shù)汀?/li>
文章評(píng)論列表
評(píng)論列表一般按照倒敘排列,而且順序不變。因?yàn)槭堑箶⑴帕校宰钚碌挠脩粼u(píng)論會(huì)放在最頂部,這就會(huì)導(dǎo)致問題了。我們還是用實(shí)際例子來說。
| 評(píng)論ID | 內(nèi)容 |
|---|---|
| 5 | 我是第一 |
| 4 | 好看 |
| 3 | 地板 |
| 2 | 板凳 |
| 1 | 沙發(fā) |
假定每頁拿3條數(shù)據(jù),此時(shí)請(qǐng)求第一頁,得到ID分別5、4、3的評(píng)論。在請(qǐng)求第二頁之前,突然又來了一條留言,此時(shí)列表變成:
| 評(píng)論ID | 內(nèi)容 |
|---|---|
| 6 | 現(xiàn)在我是第一了 |
| 5 | 我是第一 |
| 4 | 好看 |
| 3 | 地板 |
| 2 | 板凳 |
| 1 | 沙發(fā) |
用傳統(tǒng)分頁方式,此時(shí)獲取第二頁會(huì)得到ID 3、2、1,這里ID 3 就重復(fù)取出來了。
這個(gè)問題的解決方案相比排行榜列表分頁問題簡(jiǎn)單而且易懂。評(píng)論ID是一個(gè)自增的int字段,新的評(píng)論ID總是比舊評(píng)論ID要大,利用這一點(diǎn)我們可以很好的解決問題。
接口傳參:
| 字段 | 類型 | 必填 | 說明 |
|---|---|---|---|
| lastid | int | 否 | 上一頁最后一條數(shù)據(jù)的ID |
| limit | int | 是 | 取多少條數(shù)據(jù) |
limit 就不用作解釋,說一下lastid。當(dāng)獲取第一頁數(shù)據(jù)時(shí),因?yàn)闆]有上一頁所以 lastid 傳空或者不傳,此時(shí)服務(wù)器取最新的數(shù)據(jù)即可。獲取第二頁數(shù)據(jù)時(shí),lastid 傳第一頁最后一條數(shù)據(jù)的ID,此時(shí)服務(wù)器取 ID < lastid 的數(shù)據(jù),這就保證最新的評(píng)論不會(huì)影響到當(dāng)前用戶的分頁。
這里做一個(gè)擴(kuò)展,我們有時(shí)候看到有的頁面在刷新的時(shí)候,會(huì)提示有多少條新的未查看評(píng)論(即列表頭新的數(shù)據(jù)),這個(gè)功能的實(shí)現(xiàn)原理跟我們上面分頁的原理差不多。在獲取第一頁數(shù)據(jù)時(shí),把第一頁的第一條數(shù)據(jù)ID保存下來,后面請(qǐng)求每一頁時(shí)都把第一條ID(firstid)帶上,服務(wù)器每次查 ID > firstid 的數(shù)據(jù)條數(shù),如果大于0即表示有新的評(píng)論。
評(píng)論列表(點(diǎn)贊數(shù)倒敘)
首先說一下,下面提供的方法我自己也不滿意(如果有什么想法歡迎大家留言交流)。參考了微博的評(píng)論排序也存在上面說到的分頁bug,感覺要完美解決這個(gè)需求的分頁問題花費(fèi)的代價(jià)(實(shí)現(xiàn)時(shí)間、服務(wù)器性能、存儲(chǔ)空間等)大于功能本身,所以建議讀者選擇比較折中的方式來處理(與產(chǎn)品或上級(jí)溝通實(shí)現(xiàn)的難度)。
這個(gè)需求相比評(píng)論列表,多了點(diǎn)贊的功能,列表按點(diǎn)贊數(shù)量倒敘排列。先說一下不嚴(yán)謹(jǐn)情況下這個(gè)分頁的實(shí)現(xiàn)方式:
## 優(yōu)先對(duì)點(diǎn)贊數(shù)量倒敘,再對(duì)評(píng)論ID倒敘
SELECT * FROM article_comment WHERE articleid=1 AND status=1 ORDER BY like_num DESC,commentid DESC LIMIT 0,10;
這種方式會(huì)有兩個(gè)問題:
- 評(píng)論點(diǎn)贊數(shù)的變化導(dǎo)致列表排序不斷改變
- 新寫的評(píng)論會(huì)影響列表的總量
我們可以沿用上面講到的兩個(gè)需求的解決方案。在解決列表排序問題上,我們可以沿用排行榜的通過變動(dòng)記錄表拿數(shù)據(jù)方式,增加一個(gè)表去記錄評(píng)論的點(diǎn)贊變動(dòng)記錄(用空間換效率)。
表結(jié)構(gòu):
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 '文章評(píng)論表';
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 '文章評(píng)論點(diǎn)贊數(shù)變動(dòng)記錄表';
分頁用到的查詢語句:
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;