1. 程式人生 > >基於Redis的Feed推送系統

基於Redis的Feed推送系統

轉載於:http://littlexiang.me/architecture/4.html

之前我們的Feed聚合是基於純資料庫IN查詢, 條件多還要加上排序, 當資料超過1kw之後, 就開始有慢語句產生. 做了索引優化拆分成兩條語句, 第一句只取id, 保證查詢是index only, 然後再迴圈取單條feed. 儘管如此不久以後還是漸漸不行, 某些語句會掃描30w行, 大概在2s左右.於是開始構思如何模仿新浪微博的推送體系.找了很多blog都沒有找到稍微是具體的實現細節,苦逼的只能自己半猜想半模擬了.

終於某天在夢中, 得到了粗略的思路.

再具體細化一些後的思路

分為推和拉兩部分, 姑且稱為Push和Pull.

使用者初始化上線的時候, 第一次重新整理動態列表, 此時inbox是空白的. 此時Pull負責動態聚合第一頁資料返回, 同時online標誌位標記為1, 當下觸發Pull取20條x50頁資料填充inbox, 同時Push開始對此使用者推送新feed. 此情況只發生在 offline->online 狀態切換的時候.

使用者上線完成後只需要讀取inbox的內容, Push負責持續向online使用者推送.

online標誌位快取時間72小時, 同時inbox也保持一致的快取ttl.

刪除feed的情況由前端輸出時過濾, 系統本身不處理. 一是此情況很少, 二是代價太高, 沒有必要.

當發生關注/取消關注時, 同步將本人的online標誌清除, 下一次使用者重新整理動態列表時會自動rebuild.

P.S. 該系統目前已經構建完成, 基於gearman+php+redis, 具體的實現細節之後再分篇闡述.

動態聚合的部分全部基於redis的sorted set, 採用mapreduce計算. 這就是前文中的Pull, 主要用於初始化和rebuild.

每個使用者都有個人feed的佇列, 使用者得到的動態就是把所有關注者的feed佇列內容聚合起來. 這裡採用了mapreduce的計算方式, 先取出每個人的Top 20, 得到N個關注者的20xN條feed, 再進行排序得到最終的Top 20.

對效能做測試的時候, 按照關注上限3000人, 每人佇列10000條做了模擬. 迴圈取3000次15s, 改用pipeline後0.5s, 最後變態地使用了邪惡的eval, 用lua指令碼一次性在服務端做完再返回, 結果是0.2s.

複製程式碼
do
    local ret = {}
    local t = loadstring('return ' .. ARGV[1])()
    for k,v in pairs(t) do
        local key = 'u:' .. v .. ':photos'
        local tmp = redis.call('ZREVRANGEBYSCORE', key, '(' .. ARGV[2], '-inf', 'WITHSCORES', 'LIMIT', '0', ARGV[3])
        table.insert(ret, tmp)
    end
    return ret
end
複製程式碼

悲催的是redis的單執行緒的, 所以這個操作的併發效能很差. 根據redis文件描述 Time complexity: O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. 耗時主要取決於每頁的feed條數和關注的人數.

當然可以通過多個read slave來提高併發, 也可以將個人feed佇列分佈儲存在多個redis中, 通過二次mapreduce平行計算.

需要翻頁時提供上一頁最後一條的score, 取小於score的20條, 才可以保證mapreduce翻頁結果正確. 實際中簡單地使用了精度到毫秒的時間戳作為score.

intval((microtime(true) - $epoch_ts) * 1000)

分發器負責向所有線上的使用者推送feed, 即前文中的Push.

使用者產生一條新feed, 先同步插入自己的inbox, 然後交給Distributor分發給所有粉絲.

這裡worker有2層, worker1負責接收任務, 按照每3000個粉絲一組分拆成很多子任務, 交給下一級worker2處理. worker2並行處理完成後彙報給worker1, 同時做一些計時和延誤告警的工作. 

worker2負責具體的分發流程, 獲取粉絲id->判定online->推送並未讀數++. 為了追求速度, online是一次性批量獲取的, 這裡還碰到過一個坑, 參見 http://littlexiang.me/php/3.html .

同時worker2也有大小兩個池, 初步設定threshold是100w, 超過100w粉絲的任務會交給large pool處理, 避免某些大佬發了照片來不及處理導致任務積壓, 影響了大部分普通使用者的分發.

關於效能, 模擬了100w粉絲全體offline和online的情況. 100個worker+2個redis在普通pc機上的分別是1~2s和10~11s.這裡扯一個別的問題, 之前測試1個redis的時候, 分發速度是6~7w/s, 分佈到2個redis之後居然還是6~7w/s, 最後發現是計算分佈寫的太羅嗦了, 後來把floor(crc32(id) % 360 / (360 / N))改成crc32(id) & (N-1), 達到了10w+/s, 缺點就是shard數量只能是2^n. 每次擴容必須翻倍, 不過不一定得加物理機, 直接多開一組redis例項就可以了, 因為是全快取, 完全不用aof和rdb, 一臺物理機上開個4個總是可以的.

關於inbox的分佈和擴容, 直接新增配置就可以了. 因為online標誌和inbox在一起, 所以不需要人工干預遷移, 失效的部分會在下一次使用者操作時自動rebuild.


擴充套件性/效能/高可用

gearman大概每秒可以接收4k個任務, 單臺的能力都是足夠了,配置成LVS backup-backup模式切換. 任務通過gearman的擴充套件寫入mysql持久化, 後端的mysql也是Master-Master+LVS切換, 杜絕單點.

pull的效能可以通過單組多slave和多組分佈+2次mapreduce擴充套件

push的效能可以通過增加worker機和inbox的redis例項提高. 

個人feed的redis需要持久化, 每個節點都有3臺: 主->備->持久, 通過LVS切換, 主和備都是純記憶體, 持久機不參與工作,只負責寫aof.

inbox快取的redis全部都是純記憶體, 可以有多臺互相作為備機.

worker管理/健康檢查

一開始是用pcntl接管了kill訊號, 保證不會在任務進行中被kill掉, 不過strace發現有很多system call比較浪費, 於是改成從資料庫讀訊號.worker做完一個任務後去資料庫更新狀態, 然後檢查kill訊號並記log.

start/stop/restart都統一由Manager指令碼控制, 除非掛了, 正常情況下不許直接kill. Manager還帶有watch功能, 每分鐘檢查一次所有程序的狀態.

Monitor指令碼迴圈跑一個空任務, 做一個sleep一會新增一個, 檢查gearman server的狀態.

吐槽

尼馬gearman 1.1.4是坑爹的啊!mysql斷了不會重連, 編譯drizzle怎麼也搞不上, 換0.41啥事都沒有啊!

尼馬php的gearman client是坑爹的啊! addServers兩臺有一臺掛了直接就連不上啊!說好的自動failover呢!

尼馬php的redis擴充套件是坑爹的啊!沒有連線池吃CPU又高, 這是鬧那樣啊!

雖然php5.3換5.4效能有很大提高, 不過還是準備改寫成python的, 畢竟後臺worker嘛...