導讀:知乎存儲平臺團隊基於開源Redis 組件打造的知乎 Redis 平臺,通過不斷的研發迭代,目前已經造成了一整套完整自動化運維服務體系,提供不少強大的功能。本文做者是是該系統的負責人,文章深刻介紹了該系統的方方面面,做爲後端程序員值得仔細研究。git
做者簡介:陳鵬,現知乎存儲平臺組 Redis 平臺技術負責人,2014 年加入知乎技術平臺組從事基礎架構相關係統的開發與運維,從無到有創建了知乎 Redis 平臺,承載了知乎高速增加的業務流量。程序員
知乎做爲知名中文知識內容平臺,每日處理的訪問量巨大,如何更好的承載這樣巨大的訪問量,同時提供穩定低時延的服務保證,是知乎技術平臺同窗須要面對的一大挑戰。github
知乎存儲平臺團隊基於開源Redis 組件打造的 Redis 平臺管理系統,通過不斷的研發迭代,目前已經造成了一整套完整自動化運維服務體系,提供一鍵部署集羣,一鍵自動擴縮容, Redis 超細粒度監控,旁路流量分析等輔助功能。redis
目前,Redis 在知乎規模以下:算法
● 機器內存總量約70TB,實際使用內存約40TB;後端
● 平均每秒處理約1500萬次請求,峯值每秒約2000萬次請求;緩存
● 天天處理約1萬億餘次請求;安全
● 單集羣每秒處理最高每秒約400萬次請求;網絡
● 集羣實例與單機實例總共約800個;架構
● 實際運行約16000個Redis 實例;
● Redis 使用官方3.0.7版本,少部分實例採用4.0.11版本。
根據業務的需求,咱們將實例區分爲單機(Standalone)和集羣(Cluster)兩種類型,單機實例一般用於容量與性能要求不高的小型存儲,而集羣則用來應對對性能和容量要求較高的場景。
對於單機實例,咱們採用原生主從(Master-Slave)模式實現高可用,常規模式下對外僅暴露 Master 節點。因爲使用原生 Redis,因此單機實例支持全部 Redis 指令。
對於單機實例,咱們使用Redis 自帶的哨兵(Sentinel)集羣對實例進行狀態監控與 Failover。Sentinel 是 Redis 自帶的高可用組件,將 Redis 註冊到由多個 Sentinel 組成的 Sentinel 集羣后,Sentinel 會對 Redis 實例進行健康檢查,當 Redis 發生故障後,Sentinel 會經過 Gossip 協議進行故障檢測,確認宕機後會經過一個簡化的 Raft 協議來提高 Slave 成爲新的 Master。
一般狀況咱們僅使用1 個 Slave 節點進行冷備,若是有讀寫分離請求,能夠創建多個Read only slave 來進行讀寫分離。
如圖所示,經過向Sentinel 集羣註冊 Master 節點實現實例的高可用,當提交 Master 實例的鏈接信息後,Sentinel 會主動探測全部的 Slave 實例並創建鏈接,按期檢查健康狀態。客戶端經過多種資源發現策略如簡單的 DNS 發現 Master 節點,未來有計劃遷移到如 Consul 或 etcd 等資源發現組件 。
當Master 節點發生宕機時,Sentinel 集羣會提高 Slave 節點爲新的 Master,同時在自身的 pubsub channel +switch-master 廣播切換的消息,具體消息格式爲:
switch-master <master name> <oldip> <oldport> <newip> <newport>
watcher 監聽到消息後,會去主動更新資源發現策略,將客戶端鏈接指向新的 Master 節點,完成 Failover,具體 Failover 切換過程詳見 Redis 官方文檔。
Redis Sentinel Documentation [1]
實際使用中須要注意如下幾點:
● 只讀Slave 節點能夠按照需求設置 slave-priority 參數爲0,防止故障切換時選擇了只讀節點而不是熱備 Slave 節點;
● Sentinel 進行故障切換後會執行 CONFIG REWRITE 命令將SLAVEOF 配置落地,若是 Redis 配置中禁用了 CONFIG 命令,切換時會發生錯誤,能夠經過修改 Sentinel 代碼來替換 CONFIG 命令;
● Sentinel Group 監控的節點不宜過多,實測超過 500 個切換過程偶爾會進入 TILT 模式,致使Sentinel 工做不正常,推薦部署多個 Sentinel 集羣並保證每一個集羣監控的實例數量小於 300 個;
● Master 節點應與 Slave 節點跨機器部署,有能力的使用方能夠跨機架部署,不推薦跨機房部署 Redis 主從實例;
● Sentinel 切換功能主要依賴 down-after-milliseconds 和failover-timeout 兩個參數,down-after-milliseconds 決定了Sentinel 判斷 Redis 節點宕機的超時,知乎使用 30000 做爲閾值。而 failover-timeout 則決定了兩次切換之間的最短等待時間,若是對於切換成功率要求較高,能夠適當縮短failover-timeout 到秒級保證切換成功,具體詳見Redis 官方文檔[2];
● 單機網絡故障等同於機器宕機,但若是機房全網發生大規模故障會形成主從屢次切換,此時資源發現服務可能更新不夠及時,須要人工介入。
當實例須要的容量超過20G 或要求的吞吐量超過 20萬請求每秒時,咱們會使用集羣(Cluster)實例來承擔流量。集羣是經過中間件(客戶端或中間代理等)將流量分散到多個 Redis 實例上的解決方案。
知乎的Redis 集羣方案經歷了兩個階段:客戶端分片與 Twemproxy 代理
早期知乎使用redis-shard 進行客戶端分片,redis-shard 庫內部實現了 CRC3二、MD五、SHA1三種哈希算法,支持絕大部分Redis 命令。使用者只需把 redis-shard 當成原生客戶端使用便可,無需關注底層分片。
基於客戶端的分片模式具備以下優勢:
● 基於客戶端分片的方案是集羣方案中最快的,沒有中間件,僅須要客戶端進行一次哈希計算,不須要通過代理,沒有官方集羣方案的MOVED/ASK 轉向;
● 不須要多餘的Proxy 機器,不用考慮 Proxy 部署與維護;
● 能夠自定義更適合生產環境的哈希算法。
可是也存在以下問題:
● 須要每種語言都實現一遍客戶端邏輯,早期知乎全站使用Python 進行開發,可是後來業務線增多,使用的語言增長至 Python,Golang,Lua,C/C++,JVM 系(Java,Scala,Kotlin)等,維護成本太高;
● 沒法正常使用MSET、MGET 等多種同時操做多個Key 的命令,須要使用 Hash tag 來保證多個 Key 在同一個分片上;
● 升級麻煩,升級客戶端須要全部業務升級更新重啓,業務規模變大後沒法推進;
● 擴容困難,存儲須要停機使用腳本Scan 全部的 Key 進行遷移,緩存只能經過傳統的翻倍取模方式進行擴容;
● 因爲每一個客戶端都要與全部的分片創建池化鏈接,客戶端基數過大時會形成Redis 端鏈接數過多,Redis 分片過多時會形成 Python 客戶端負載升高。
具體特色詳見zhihu/redis-shard[3]。早期知乎大部分業務由Python 構建,Redis 使用的容量波動較小, redis-shard 很好地應對了這個時期的業務需求,在當時是一個較爲不錯解決方案。
2015 年開始,業務上漲迅猛,Redis 需求暴增,原有的 redis-shard 模式已經沒法知足日益增加的擴容需求,咱們開始調研多種集羣方案,最終選擇了簡單高效的 Twemproxy 做爲咱們的集羣方案。
由Twitter 開源的 Twemproxy 具備以下優勢:
● 性能很好且足夠穩定,自建內存池實現Buffer 複用,代碼質量很高;
● 支持fnv1a_6四、murmur、md5 等多種哈希算法;
● 支持一致性哈希(ketama),取模哈希(modula)和隨機(random)三種分佈式算法。
具體特色詳見twitter/twemproxygithub.com[4]
可是缺點也很明顯:
● 單核模型形成性能瓶頸;
● 傳統擴容模式僅支持停機擴容。
對此,咱們將集羣實例分紅兩種模式,即緩存(Cache)和存儲(Storage):
若是使用方能夠接收經過損失一部分少許數據來保證可用性,或使用方能夠從其他存儲恢復實例中的數據,這種實例即爲緩存,其他狀況均爲存儲。
咱們對緩存和存儲採用了不一樣的策略:
對於存儲咱們使用fnv1a_64 算法結合modula 模式即取模哈希對Key 進行分片,底層 Redis 使用單機模式結合 Sentinel 集羣實現高可用,默認使用 1 個 Master 節點和 1 個 Slave 節點提供服務,若是業務有更高的可用性要求,能夠拓展 Slave 節點。
當集羣中Master 節點宕機,按照單機模式下的高可用流程進行切換,Twemproxy 在鏈接斷開後會進行重連,對於存儲模式下的集羣,咱們不會設置 auto_eject_hosts, 不會剔除節點。
同時,對於存儲實例,咱們默認使用noeviction 策略,在內存使用超過規定的額度時直接返回OOM 錯誤,不會主動進行 Key 的刪除,保證數據的完整性。
因爲Twemproxy 僅進行高性能的命令轉發,不進行讀寫分離,因此默認沒有讀寫分離功能,而在實際使用過程當中,咱們也沒有遇到集羣讀寫分離的需求,若是要進行讀寫分離,可使用資源發現策略在 Slave 節點上架設 Twemproxy 集羣,由客戶端進行讀寫分離的路由。
考慮到對於後端(MySQL/HBase/RPC 等)的壓力,知乎絕大部分業務都沒有針對緩存進行降級,這種狀況下對緩存的可用性要求較數據的一致性要求更高,可是若是按照存儲的主從模式實現高可用,1 個 Slave 節點的部署策略在線上環境只能容忍 1 臺物理節點宕機,N 臺物理節點宕機高可用就須要至少 N 個 Slave 節點,這無疑是種資源的浪費。
因此咱們採用了Twemproxy 一致性哈希(Consistent Hashing)策略來配合 auto_eject_hosts 自動彈出策略組建Redis 緩存集羣。
對於緩存咱們仍然使用使用fnv1a_64 算法進行哈希計算,可是分佈算法咱們使用了ketama 即一致性哈希進行Key 分佈。緩存節點沒有主從,每一個分片僅有 1 個 Master 節點承載流量。
Twemproxy 配置 auto_eject_hosts 會在實例鏈接失敗超過server_failure_limit 次的狀況下剔除節點,並在server_retry_timeout 超時以後進行重試,剔除後配合ketama 一致性哈希算法從新計算哈希環,恢復正常使用,這樣即便一次宕機多個物理節點仍然能保持服務。
在實際的生產環境中須要注意如下幾點:
● 剔除節點後,會形成短期的命中率降低,後端存儲如MySQL、HBase 等須要作好流量監測;
● 線上環境緩存後端分片不宜過大,建議維持在20G 之內,同時分片調度應儘量分散,這樣即便宕機一部分節點,對後端形成的額外的壓力也不會太多;
● 機器宕機重啓後,緩存實例須要清空數據以後啓動,不然原有的緩存數據和新創建的緩存數據會衝突致使髒緩存。直接不啓動緩存也是一種方法,可是在分片宕機期間會致使週期性server_failure_limit 次數的鏈接失敗;
● server_retry_timeout 和server_failure_limit 須要仔細敲定確認,知乎使用10min 和 3 次做爲配置,即鏈接失敗 3 次後剔除節點,10 分鐘後從新進行鏈接。
在方案早期咱們使用數量固定的物理機部署Twemproxy,經過物理機上的 Agent 啓動實例,Agent 在運行期間會對 Twemproxy 進行健康檢查與故障恢復,因爲 Twemproxy 僅提供全量的使用計數,因此 Agent 運行時還會進行定時的差值計算來計算 Twemproxy 的 requests_per_second 等指標。
後來爲了更好地故障檢測和資源調度,咱們引入了Kubernetes,將 Twemproxy 和 Agent 放入同一個 Pod 的兩個容器內,底層 Docker 網段的配置使每一個 Pod 都能得到獨立的 IP,方便管理。
最開始,本着簡單易用的原則,咱們使用DNS A Record 來進行客戶端的資源發現,每一個 Twemproxy 採用相同的端口號,一個 DNS A Record 後面掛接多個 IP 地址對應多個 Twemproxy 實例。
初期,這種方案簡單易用,可是到了後期流量日益上漲,單集羣Twemproxy 實例個數很快就超過了 20 個。因爲 DNS 採用的 UDP 協議有 512 字節的包大小限制,單個 A Record 只能掛接 20 個左右的 IP 地址,超過這個數字就會轉換爲 TCP 協議,客戶端不作處理就會報錯,致使客戶端啓動失敗。
當時因爲狀況緊急,只能創建多個Twemproxy Group,提供多個 DNS A Record 給客戶端,客戶端進行輪詢或者隨機選擇,該方案可用,可是不夠優雅。
以後咱們修改了Twemproxy 源碼, 加入 SO_REUSEPORT 支持。
Twemproxy with SO_REUSEPORT on Kubernetes
同一個容器內由Starter 啓動多個 Twemproxy 實例並綁定到同一個端口,由操做系統進行負載均衡,對外仍然暴露一個端口,可是內部已經由系統均攤到了多個 Twemproxy 上。
同時Starter 會定時去每一個 Twemproxy 的 stats 端口獲取 Twemproxy 運行狀態進行聚合,此外 Starter 還承載了信號轉發的職責。
原有的Agent 不須要用來啓動 Twemproxy 實例,因此 Monitor 調用 Starter 獲取聚合後的 stats 信息進行差值計算,最終對外界暴露出實時的運行狀態信息。
咱們在2015 年調研過多種集羣方案,綜合評估多種方案後,最終選擇了看起來較爲陳舊的 Twemproxy 而不是官方 Redis 集羣方案與 Codis,具體緣由以下:
● MIGRATE 形成的阻塞問題
Redis 官方集羣方案使用 CRC16 算法計算哈希值並將 Key 分散到 16384 個 Slot 中,由使用方自行分配 Slot 對應到每一個分片中,擴容時由使用方自行選擇 Slot 並對其進行遍歷,對 Slot 中每個 Key 執行 MIGRATE 命令進行遷移。
調研後發現,MIGRATE 命令實現分爲三個階段:
1. DUMP 階段:由源實例遍歷對應 Key 的內存空間,將 Key 對應的 Redis Object 序列化,序列化協議跟 Redis RDB 過程一致;
2. RESTORE 階段:由源實例創建 TCP 鏈接到對端實例,並將 DUMP 出來的內容使用RESTORE 命令到對端進行重建,新版本的 Redis 會緩存對端實例的鏈接;
3. DEL 階段(可選):若是發生遷移失敗,可能會形成同名的 Key 同時存在於兩個節點,
此時 MIGRATE 的REPLACE 參數決定是是否覆蓋對端的同名Key,若是覆蓋,對端的 Key 會進行一次刪除操做,4.0 版本以後刪除能夠異步進行,不會阻塞主進程。
通過調研,咱們認爲這種模式並不適合知乎的生產環境。Redis 爲了保證遷移的一致性, MIGRATE 全部操做都是同步操做,執行MIGRATE 時,兩端的Redis 均會進入時長不等的 BLOCK 狀態。
對於小Key,該時間能夠忽略不計,但若是一旦 Key 的內存使用過大,一個 MIGRATE 命令輕則致使 P95 尖刺,重則直接觸發集羣內的 Failover,形成沒必要要的切換
同時,遷移過程當中訪問處處於遷移中間狀態的Slot 的 Key 時,根據進度可能會產生 ASK 轉向,此時須要客戶端發送 ASKING 命令到Slot 所在的另外一個分片從新請求,請求時延則會變爲原來的兩倍。
一樣,方案初期時的Codis 採用的是相同的 MIGRATE 方案,可是使用 Proxy 控制 Redis 進行遷移操做而非第三方腳本(如 redis-trib.rb),基於同步的相似 MIGRATE 的命令,實際跟 Redis 官方集羣方案存在一樣的問題。
對於這種Huge Key 問題決定權徹底在於業務方,有時業務須要不得不產生 Huge Key 時會十分尷尬,如關注列表。一旦業務使用不當出現超過 1MB 以上的大 Key 便會致使數十毫秒的延遲,遠高於平時 Redis 亞毫秒級的延遲。有時,在 slot 遷移過程當中業務不慎同時寫入了多個巨大的 Key 到 slot 遷移的源節點和目標節點,除非寫腳本刪除這些 Key ,不然遷移會進入進退兩難的地步。
對此,Redis 做者在 Redis 4.2 的 roadmap[5] 中提到了Non blocking MIGRATE 可是截至目前,Redis 5.0 即將正式發佈,仍未看到有關改動,社區中已經有相關的 Pull Request [6],該功能可能會在5.2 或者 6.0 以後併入 master 分支,對此咱們將持續觀望。
● 緩存模式下高可用方案不夠靈活
還有,官方集羣方案的高可用策略僅有主從一種,高可用級別跟Slave 的數量成正相關,若是隻有一個 Slave,則只能容許一臺物理機器宕機, Redis 4.2 roadmap 提到了 cache-only mode,提供相似於Twemproxy 的自動剔除後重分片策略,可是截至目前仍未實現。
● 內置Sentinel 形成額外流量負載
另外,官方Redis 集羣方案將 Sentinel 功能內置到 Redis 內,這致使在節點數較多(大於 100)時在 Gossip 階段會產生大量的 PING/INFO/CLUSTER INFO 流量,根據 issue 中提到的狀況,200 個使用 3.2.8 版本節點搭建的 Redis 集羣,在沒有任何客戶端請求的狀況下,每一個節點仍然會產生 40Mb/s 的流量,雖然到後期 Redis 官方嘗試對其進行壓縮修復,但按照 Redis 集羣機制,節點較多的狀況下不管如何都會產生這部分流量,對於使用大內存機器可是使用千兆網卡的用戶這是一個值得注意的地方。
● slot 存儲開銷
最後,每一個Key 對應的 Slot 的存儲開銷,在規模較大的時候會佔用較多內存,4.x 版本之前甚至會達到實際使用內存的數倍,雖然 4.x 版本使用 rax 結構進行存儲,可是仍然佔據了大量內存,從非官方集羣方案遷移到官方集羣方案時,須要注意這部分多出來的內存。
總之,官方Redis 集羣方案與 Codis 方案對於絕大多數場景來講都是很是優秀的解決方案,可是咱們仔細調研發現並非很適合集羣數量較多且使用方式多樣化的咱們,場景不一樣側重點也會不同,但在此仍然要感謝開發這些組件的開發者們,感謝大家對 Redis 社區的貢獻。
對於單機實例,若是經過調度器觀察到對應的機器仍然有空閒的內存,咱們僅需直接調整實例的maxmemory 配置與報警便可。一樣,對於集羣實例,咱們經過調度器觀察每一個節點所在的機器,若是全部節點所在機器均有空閒內存,咱們會像擴容單機實例同樣直接更新maxmemory 與報警。
可是當機器空閒內存不夠,或單機實例與集羣的後端實例過大時,沒法直接擴容,須要進行動態擴容:
● 對於單機實例,若是單實例超過30GB 且沒有如 sinterstore 之類的多Key 操做咱們會將其擴容爲集羣實例;
● 對於集羣實例,咱們會進行橫向的重分片,咱們稱之爲Resharding 過程。
Resharding 過程
原生Twemproxy 集羣方案並不支持擴容,咱們開發了數據遷移工具來進行 Twemproxy 的擴容,遷移工具本質上是一個上下游之間的代理,將數據從上游按照新的分片方式搬運到下游。
原生Redis 主從同步使用 SYNC/PSYNC 命令創建主從鏈接,收到SYNC 命令的Master 會 fork 出一個進程遍歷內存空間生成 RDB 文件併發送給 Slave,期間全部發送至 Master 的寫命令在執行的同時都會被緩存到內存的緩衝區內,當 RDB 發送完成後,Master 會將緩衝區內的命令及以後的寫命令轉發給 Slave 節點。
咱們開發的遷移代理會向上遊發送SYNC 命令模擬上游實例的Slave,代理收到 RDB 後進行解析,因爲 RDB 中每一個 Key 的格式與 RESTORE 命令的格式相同,因此咱們使用生成 RESTORE 命令按照下游的Key 從新計算哈希並使用 Pipeline 批量發送給下游。
等待RDB 轉發完成後,咱們按照新的後端生成新的 Twemproxy 配置,並按照新的 Twemproxy 配置創建 Canary 實例,從上游的 Redis 後端中取 Key 進行測試,測試 Resharding 過程是否正確,測試過程當中的 Key 按照大小,類型,TTL 進行比較。
測試經過後,對於集羣實例,咱們使用生成好的配置替代原有Twemproxy 配置並 restart/reload Twemproxy 代理,咱們修改了 Twemproxy 代碼,加入了 config reload 功能,可是實際使用中發現直接重啓實例更加可控。而對於單機實例,因爲單機實例和集羣實例對於命令的支持不一樣,一般須要和業務方肯定後手動重啓切換。
因爲Twemproxy 部署於 Kubernetes ,咱們能夠實現細粒度的灰度,若是客戶端接入了讀寫分離,咱們能夠先將讀流量接入新集羣,最終接入所有流量。
這樣相對於Redis 官方集羣方案,除在上游進行 BGSAVE 時的fork 複製頁表時形成的尖刺以及重啓時形成的鏈接閃斷,其他對於 Redis 上游形成的影響微乎其微。
這樣擴容存在的問題:
對上游發送SYNC 後,上游fork 時會形成尖刺;
對於存儲實例,咱們使用Slave 進行數據同步,不會影響到接收請求的 Master 節點;
對於緩存實例,因爲沒有Slave 實例,該尖刺沒法避免,若是對於尖刺過於敏感,咱們能夠跳過 RDB 階段,直接經過 PSYNC 使用最新的SET 消息創建下游的緩存。
切換過程當中有可能寫到下游,而讀在上游;
對於接入了讀寫分離的客戶端,咱們會先切換讀流量到下游實例,再切換寫流量。
一致性問題,兩條具備前後順序的寫同一個Key 命令在切換代理後端時會經過 1)寫上游同步到下游 2)直接寫到下游兩種方式寫到下游,此時,可能存在應先執行的命令卻經過 1)執行落後於經過 2)執行,致使命令前後順序倒置。
這個問題在切換過程當中沒法避免,好在絕大部分應用沒有這種問題,若是沒法接受,只能經過上游停寫排空Resharding 代理保證前後順序;
官方Redis 集羣方案和 Codis 會經過 blocking 的 migrate 命令來保證一致性,不存在這種問題。
實際使用過程當中,若是上游分片安排合理,可實現數千萬次每秒的遷移速度,1TB 的實例 Resharding 只須要半小時左右。另外,對於實際生產環境來講,提早作好預期規劃比遇到問題緊急擴容要快且安全得多。
因爲生產環境調試須要,有時會須要監控線上Redis 實例的訪問狀況,Redis 提供了多種監控手段,如 MONITOR 命令。
但因爲Redis 單線程的限制,致使自帶的 MONITOR 命令在負載太高的狀況下會再次跑高 CPU,對於生產環境來講過於危險,而其他方式如 Keyspace Notify 只有寫事件,沒有讀事件,沒法作到細緻的觀察。
對此咱們開發了基於libpcap 的旁路分析工具,系統層面複製流量,對應用層流量進行協議分析,實現旁路 MONITOR,實測對於運行中的實例影響微乎其微。
同時對於沒有MONITOR 命令的 Twemproxy,旁路分析工具仍能進行分析,因爲生產環境中絕大部分業務都使用 Kubernetes 部署於 Docker 內 ,每一個容器都有對應的獨立 IP,因此可使用旁路分析工具反向解析找出客戶端所在的應用,分析業務方的使用模式,防止不正常的使用。
因爲Redis 5.0 發佈在即,4.0 版本趨於穩定,咱們將逐步升級實例到 4.0 版本,由此帶來的如 MEMORY 命令、Redis Module 、新的 LFU 算法等特性不管對運維方仍是業務方都有極大的幫助。
知乎架構平臺團隊是支撐整個知乎業務的基礎技術團隊,開發和維護着知乎幾乎全量的核心基礎組件,包括容器、Redis、MySQL、Kafka、LB、HBase 等核心基礎設施,團隊小而精,每一個同窗都獨當一面負責上面提到的某個核心系統。
隨着知乎業務規模的快速增加,以及業務複雜度的持續增長,咱們團隊面臨的技術挑戰也愈來愈大,歡迎對技術感興趣、渴望技術挑戰的小夥伴加入咱們,一塊兒建設穩定高效的知乎雲平臺。有意向可移步知乎網站招聘頁投遞簡歷。
1. Redis Official site https://redis.io/
2. Twemproxy Github Page twitter/twemproxy
3. Codis Github Page CodisLabs/codis
4. SO_REUSEPORT Man Page socket(7) - Linux manual page
5. Kubernetes Production-Grade Container Orchestration