緣起:來自於我在近期一個項目上遇到的問題,在Segmentfault上發表了提問
知識背景:
對不是很熟悉MongoDB和Redis的同學做一下介紹。
1.MongoDB數組查詢:MongoDB自帶List,可以存放類似這樣的結構 List = [1, 2, 3, 4, 5, 6, 7, 8, 9].
如果我們有一個 l = [2, 3, 8], 則可以進行這樣的查詢:spce = { 'List' : { '$in' : l }, 這里spce就是一個查詢條件,代表 l 是 List的一個子集。
2.Redis隊列: Redis提供基本的List(普通鏈表),set(集合),Zset(有序集合) 類型的結構,將List的 lpush, rpop操作運用起來,可以做一個普通的隊列,運用Zset 可以做一個帶權值的最小堆排序的隊列(可以看做優先級)。
整體架構如下圖所示:
生產者產生任務,通過LVS與RPC服務器將任務記錄到MongoDB,消費者同樣通過RPC服務獲取任務,這是個很簡單的架構,一般服務可能去掉集群都是這樣的。
整個業務架構需要一個前提,任務不能丟失,也就是說任務即使失敗也需要重新加入到隊列,至少若干次后任然失敗也要知道為什么失敗(非記錄日志形式)。
很多人問為什么不直接用RabbitMQ或者Redis,因為這類消息隊列無法做到管理任務超時等情況,因為業務需要,也需要做一些簡單的查詢,這類隊列是不支持某些稍復雜的查詢的,而且一開始我們的任務量估計在5KW/Day這樣,擔心Redis扛不住,后來我發現這是個錯誤的假設。
問題內容如下:
問題背景: 近期在重構公司內部一個重要的任務系統,由於原來的任務系統使用了MongoDB來保存任務,客戶端從MongoDB來取,至於為什么用MongoDB,是一個歷史問題,也是因為如果使用到MongoDB的數組查詢可以減少任務數量很多次,假設這樣的情況,一個md5(看做一條記錄的唯一標識)需要針對N種情況做任務處理,如果用到MongoDB的數組,只需要將一個md5作為一條任務,其中包含一個長度為N的待處理任務列表,可以使用到MongoDB的數組(只有N個子任務都處理完后整個任務才算處理完畢),這樣整個任務系統的數量級就變為原來的 1/N(如果需要用到普通的關系型數據庫,可能需要創建 m*n 個任務,這樣算下來我們的任務數量將可能達到一個很大的值,主要是因為處理任務的進程由於某些不確定因素無法控制,所以比較慢)
細節描述: 1.當MongoDB的任務數量增多的時候,數組查詢相當的慢(已經做索引),任務數達到5K就已經不能容忍了,和我們每天的任務數不在一個數量級。
2.任務處理每個md5對應的N個子任務必須要全部完成才從MongoDB中刪除
3.任務有相應的優先級(保證高優先級優先處理),任務在超時后可以重置。
改進方案如下: 由於原有代碼的耦合,不能完全拋棄MongoDB,所以決定加一個Redis緩存。一個md5對應的N個子任務分發到N個Redis隊列中(拆分子任務)。一個單獨的進程從MongoDB中向Redis中將任務同步,客戶端不再從MongoDB取任務。這樣做的好處是拋棄了原有的MongoDB的數組查詢,同步進程從MongoDB中取任務是按照任務的優先級偏移(已做索引)來取,所以速度比數組查詢要快。這樣客戶端向Redis的N個隊列中取子任務,把任務結果返回原來的MongoDB任務記錄中(根據md5返回子任務)。
改進過程遇到的問題: 由於任務處理端向MongoDB返回時候會有一個update操作,如果N個子任務都完成,就將任務從MongoDB中刪除。這樣的一個問題就是,經過測試后發現MongoDB在高並發寫的情況下性能很低下,整個任務系統任務處理速度最大為200/s(16核, 16G, CentOS, 內核2.6.32-358.6.3.el6.x86_64),原因大致為在頻繁寫情況下,MongoDB的性能會由於鎖表操作急劇下降(鎖表時間可以達到60%-70%,熟悉MongoDB的人都知道這是多么恐怖的數字)。
具體問題: (Think out of the Box)能否提出一個好的解決方案,能夠保存任務狀態(子任務狀態),速度至少超過MongoDB的?
提出這個問題后,很感謝官方將問題發到微博首頁,有一個回答我覺得可以采納:
初步的思考了一下,僅供參考:
首先,提一下索引,相信這個你應該加了索引。
有個問題確認一下,mongodb最新版本中的鎖粒度還是Database級別吧,不知道你用的哪個版本,還沒到鎖表(Collection)這個粒度,所以寫並發大的情況下比較糟糕,不過應該性能也不至於糟到像你描述的那樣啊?不解,建議考慮任務分庫的可能性?
能否考慮把子任務的狀態和主任務的狀態分開保存。子任務的狀態,可以放到redis,主任務只負責自己本身的狀態,這樣每個主任務更新頻率降為1/N,可大大減少mongodb中主任務表的壓力。
子任務完成或超時后,可否考慮后台異步單線程順序同步mongodb的主任務狀態?
上面這個Answer可以考慮,但是在做同步過程中發現很多問題。
在開發過程中發現,由單一進程從MongoDB向Redis同步數據,可以采取兩種可參考的方案:
1.模擬MongoDB replication機制,一個進程模擬slave向master請求oplog,然后自己解析數據格式存放到Redis.
2.一個進程從MongoDB中按照優先級取數據然后同步到Redis.
兩種參考方案各有優劣,我最終選擇了第二種。
第一種方案
優點:
1.主MongoDB查詢壓力變小
2.以后業務擴展很方便(可以運用到查詢緩存啊,讀寫分離什么的)
缺點:
1.可參考文檔較少,需要模擬MongoDB replication的機制較為復雜
2.同步實時性無法估計確切時間
第二種方案:
優點:
1.編碼相對簡單,按照優先級做索引后查詢不影響原有邏輯
2.開發較為靈活(似乎和第一點是一樣的)
缺點:
1.(項目完成后測試不理想,具體原因會做說明)
2.同步進程單點,如果進程卡死或者機器崩潰會造成系統卡死
方案確定:由單一進程從MongoDB同步任務到Redis.
架構變遷到這樣:
加上Redis,做到MongoDB的讀寫分離,單一進程從MongoDB及時把任務同步到Redis中。
看起來很完美,但是上線后出現了各種各樣的問題,列舉一下:
1.Redis隊列長度為多少合適?
2.同步進程根據優先級從MongoDB向Redis同步過程中,一次取多少任務合適?太大導致很多無謂的開銷,太小又會頻繁操作MongoDB
3.當某一個子任務處理較慢的時候,會導致MongoDB的前面優先級較高的任務沒有結束,而優先級較低的確得不到處理,造成消費者空閑
最終方案:
在生產者產生一個任務的同時,向Redis同步任務,Redis sort set(有序集合,保證優先級順序不變),消費者通過RPC調用時候,RPC服務器從Redis中取出任務,然后結束任務后從MongoDB中刪除。
測試結果,Redis插入效率。Redis-benchmark 並發150,32byte一個任務,一共100W個,插入效率7.3W(不使用持久化)
在這之前我們的擔心都是沒必要的,Redis的性能非常的好。
目前此套系統可以勝任每天5KW量的任務,我相信可以更多。后面有文章可能會講到Redis的事務操作