首先,某頭條的文章量、使用者量都是很大的,點選量那就更恐怖了。 請問,如果實時展現熱門文章,比如近8小時點選量最大的文章前100名。 如果是你來開發這個功能,你怎麼做?
這個好辦啊,redis一個sortedset搞定啊,score計數,key是文章ID,不就ok了麼?
回答的不錯,你可以走了!
要聽清題目,說好的8小時動態時間視窗,計數是會過期的。還有,頭條的量有這麼小麼,一個redis就搞定了?同學啊,我告訴你,文章的量你起碼得估計個幾十萬,使用者你得估計幾個億,點選量你至少得估計個1M/s吧。
資料接收
1M/s的點選併發量,肯定是需要分散式了。客戶端可能會為了減輕伺服器的壓力而選擇延遲合併點選請求進行批量傳送。簡單起見,這裡就使用HTTP協議吧。我們先不考慮惡意使用者刷點選的行為。
伺服器肯定會有多臺機器多程式部署來接受點選請求,接收到的請求在進行引數解析後,被髮送到儲存單元。為了減輕儲存的壓力,每個程式可能會使用小視窗聚合資料,每隔一小段時間將視窗內的資料聚合起來一起發給儲存單元。
資料儲存
點選資料是很重要的資料,使用者的興趣偏好就靠它了。這麼大的點選資料如果全部用記憶體裝的話,成本太高。所以別指望完全使用redis了。
拿kafka存是一個好辦法,ZeroCopy機制併發量很高,資料持久化在磁碟裡成本低。不過kafka的資料一般是有過期時間的,如果想完全記住使用者的點選以便做長期的資料分析,少不了要使用hdfs了。
但是因為要做準實時統計,hdfs可不適合幹這個,hdfs適合做離線統計的資料來源。所以還得靠kafka接資料,然後消費者一邊入hdfs,一邊做實時統計。
實時統計可以使用spark stream、storm接受kafka的輸入,也可以自己手寫。
分散式TopN演算法
使用者太多,使用者表按使用者ID雜湊分成了1024張子表。使用者表裡有一個欄位score,表示這個使用者的積分數。現在我們要計算前100名積分最多的使用者以及積分數,該怎麼查詢?
如果是單個表,一個SQL也就搞定了
select id, score from user order by score desc limit 100
複製程式碼
如果是多個子表,你得在每個子表上都進行一次TopN查詢,然後聚合結果再做一次TopN查詢。下面是虛擬碼
candidates = []
for k in range(1024):
# 每個表都取topn
rows = select id, score from user_${k} order by score desc limit 100
# 聚合結果
candidates.extend(rows)
# 根據score倒排
candidates = sorted(candidates, key=lambda t: t[1], reverse=True)
# 再取topn
candidates[:100]
複製程式碼
子表查詢可以多執行緒並行,提高聚合效率。
滑動視窗
8小時的滑動視窗,意味著新的資料來源源不斷的進來,舊的資料時時刻刻在淘汰。嚴格來說,精準的8小時滑動視窗要求每條資料要嚴格的過期,差了1秒都不行,到點了就立即被淘汰。
精準的代價是我們要為每條點選記錄都設定過期時間,過期時間本身也是需要儲存的,而且過期策略還需要定時掃描時間堆來確認哪些記錄過期了。量大的時候這些都是不容小噓的負擔。
但是在業務上來講,排行版沒有必要做到如此的精準,偏差個幾分鐘這都不是事。
業務上的折中給服務的資源優化帶來了機遇。我們對時間片進行了切分,一分鐘一個槽來進行計數。下面是虛擬碼
class HitSlot {
long timestamp; # earlies timestamp
map[int]int hits; # post_id => hits
void onHit(int postId, int hits) {
this.hits[postId] += hits;
}
}
class WindowSlots {
HitSlot currentSlot; # current active slots
LinkedList<HitSlot> historySlots; # history unactive slots
map[int]int topHits; # topn posts
void onHit(int postId, int hits) { # 因為上游有合併點選,所以有了hits引數
long ts = System.currentTimeMillis();
if(this.currentSlot == null) { # 建立第一個槽
this.currentSlot == new HitSlot(ts);
} elif(ts - this.currentSlot.timestamp > 60 * 1000) { # 建立下一個槽,一分鐘一個槽
this.historySlots.add(this.currentSlot);
this.currentSlot = new HitSlot(ts);
}
this.currentSlot.onHit(postId, hits);
}
void onBeat() { # 維護視窗,移除過期的槽,然後統計topn,30s~60s呼叫一次
if(historySlots.isEmpty()) {
return;
}
HitSlot slot = historySlots[0];
long ts = System.currentTimeMillis();
if(ts - slot.timestamp > 8 * 60 * 60 * 1000) { # 過期了8小時,移掉第一個
historySlots.remove(0);
topHits = topn(aggregateSlots(historySlots)); # 計算topn的帖子
}
}
}
複製程式碼
上面的程式碼代表著每個分散式子節點的邏輯,因為是虛擬碼,所以加鎖問題就不細寫了。 它的目標就是定時維持一個8小時的統計視窗,並匯聚topn的熱帖放在記憶體裡。 這個topn的資料並不是特別實時,有一個大約1分鐘的短暫的時間視窗。
定時任務
每個子節點都會有一個定時任務去負責維持統計視窗,過期失效的統計資料,計算區域性的topn熱帖。
現在每個子節點都有了各自的區域性topn熱帖,那麼還需要一個主節點去彙總這些區域性熱點,然後計算去全域性熱帖。
主節點也沒必要特別實時,定期從子節點拉取topn資料即可,也可以讓位元組點主動彙報。
class HotPostsAggregator {
map[int]map[int]int localTopnPosts; # nodeId => topn posts
map[int]int globalTopnPosts;
void onBeat() {
// do aggregate
// save globalTopnPosts to redis
}
void onLocalReport(int nodeId, map[int]int topnPosts) {
// 子節點上報區域性熱帖
}
}
複製程式碼
雜湊
按照頭條的文章至少幾十萬篇,如果每個子節點都要對所有的文章統計點選數,似乎也會佔用不少記憶體,聚合和排序熱帖也會有不少計算量。最好的想法是每個子節點只負責一部分文章的統計,這樣可以明顯節省計算資源。
我們將kafka的分割槽數設定為位元組點的數量,這樣每個節點負責消費一個分割槽的資料。在kafka生產端,對點選記錄的帖子ID進行雜湊,保證相同文章ID的點選流進入相同的分割槽,最終流向同一個統計子節點。
消費者掛了
當機器增多時,節點掛掉的概率也會增大。硬體可能損壞,電源可能掉電,人為操作失誤。如果沒有做任何防範措施,當一個位元組點掛掉時,該節點上8個小時時間視窗的統計資料將會丟失。該節點所管理的區域性熱點文章就喪失了進入全域性熱帖的機會。
這可能不會對產品和體驗上帶來很大的傷害,節點重啟8小時之後也就完全恢復了。而且這8小時之內,喪失了部分文章的熱點投票權也不會對整體業務帶來巨大影響。
但是我們都希望系統可以更加完美一點不是麼?當節點掛掉時,我們希望可以快速恢復狀態,這也是可以做到的,難度也不是很大,不過是定時做一下checkpoint,將當前的狀態持久化到本地檔案或者資料庫中。因為每個子節點管理的文章不會太多,所以需要序列化的內容也不會太大。當節點重啟時,從持久化的checkpoint中將之前的狀態恢復出來,然後繼續進行消費和統計。
如果你使用的是spark-stream,它內建的checkpoint功能會讓你實現備份和恢復會更加簡單,更加安全。
如果你不想做checkpoint,辦法還是有的,就是可能耗時舊一點。那就是對hdfs中的儲存的所有的點選流資料進行一次mapreduce,將8小時視窗內的點選流的點選量統計出來,然後想辦法匯入到位元組點程式中去。
這要求hdfs的資料也是雜湊儲存的,和kafka對應,這樣可以快速圈出需要統計的資料範圍。也許會因為mapreduce本身會耗時一點時間,最終導致恢復的資料沒有那麼準確,不過這關係也不大,我們用這樣粗糙的方法,能對得起那9.5成的資料已經做的很不錯了。
點選去重
上面講了一堆堆,程式碼敲了不少圖畫了不少,似乎很有道理。但是還有個重要的沒提到,那就是點選去重。如果一個使用者反覆點選了很多次,那該如何計數比較合理。
一篇好的文章如果它不是太短的話,一般會吸引讀者反覆閱讀很多次。這個計數如果完全去重了記為一次似乎也不太合理。但是如果是故意被人反覆點選而被記了太多次明顯也不好。那該如何選擇呢?
首先要從客戶端下手,客戶端本身可以過濾一部分無效點選。同一篇文章在太短的時間內被當前使用者反覆點選,這個模式還是很好發現的。如果間隔時間比較長,那就是讀者的回味點選,屬於文章的正向反饋,應該記錄下來。
客戶端做好了,然後再從伺服器端下手,伺服器端下手就比較困難了。要探測使用者的行為模式意味著要對使用者的行為狀態化,這樣就會大量加重伺服器的儲存負擔。
伺服器還需要防止使用者的防刷行為。如果缺失防刷控制,一個頭條號可以通過這種漏洞來使得自己的文章非法獲得大量點選,進入熱門文章列表,打上熱門標籤,被海量的使用者看到,就會獲得較大的經濟效益,即使這篇文章內容本身吸引力並不足夠。
當使用者發現這樣差勁的內容也能上熱門榜單時,無疑會對產品產生一定的質疑。如果這種行為氾濫開來,那就可能對產品造成比較致命的負面影響。
防刷是一門大型課題,本篇內容就不做詳細講解了,筆者在這方面也不是什麼專家。簡單點說放刷本質上就是提取惡意行為的特徵。常見的策略就是同一篇文章被來自於同一個IP或者有限的幾個IP的頻繁點選請求,這時就可以使用封禁IP的招數來搞定。還可以使用使用者反饋機制來識別非正常的熱門內容,然後人工干預等。業界還有一些更高階的如機器學習深度學習等方法來防刷,這些讀者都可以自行搜尋研究。
閱讀相關文章,關注公眾號【碼洞】