Redis集群最佳實踐

jopen 9年前發布 | 48K 次閱讀 Redis NoSQL數據庫

今天我們來聊一聊Redis集群。先看看集群的特點,我對它的理解是要需要同時滿足高可用性以及可擴展性,即任何時候對外的接口都要是基本可用的并具備一定的災備能力,同時節點的數量能夠根據業務量級的大小動態的伸縮。那么我們一般如何實現呢?
Redis集群最佳實踐

集群的實現方式

說到集群的實現,我會想到兩種方式

  • 第一種是去中心化的集群

    • 整個集群是由一組水平的節點構建
    • 通過給各個節點分配不同的角色實現相互配合,并同步各自狀態
    • 對外提供單調的接口
    • 整個集群依靠內部的同步機制來進行伸縮和容錯
    • 實現復雜
    • </ul> </li> </ul>

      這種集群,常見的有Zookeeper以及以MongoDB為代表的大部分NoSQL服務,包括Redis官方集群3.0 Cluster也是屬于這種類型。

      • 第二種集群是基于Proxy的集群(反向代理)

        • 引入一個Proxy中間件來管理整個集群,托管后端節點
        • 通過Zookeeper這種第三方組件實現集群的數據和狀態同步
        • Proxy本身能夠水平擴展,并方便實現auto-balance
        • 實現簡單
        • 需要保證Proxy本身的高可用性
        • </ul> </li> </ul>

          這種方式有人會稱之為偽集群,畢竟需要依靠第三方組件來實現集群化,但從整體架構上來看,這種實現方式確實可以滿足集群的標準,即滿足高可用性和可 擴展性,它的優點是實現簡單,并且通過Proxy去維護集群的狀態要比去中心化的方式更加方便,這也是為什么我選擇Codis而不是官方Cluster的 原因。下面會詳細介紹。

          Redis集群——Codis2.0

          那么具體到Redis的集群實現,目前最流行的應該是推ter開源的Twemproxy,再就是近年官方推出的Redis 3.0 Cluster。今天我要介紹的是來自豌豆莢開源的Codis,它是一套基于Proxy模式的Redis集群服務,Codis目前的版本是2.0。

          • 相比Twemproxy,Codis能夠提供動態的sharding,即無縫擴展redis的節點,省去了人工數據遷移的成本以及down掉服務帶來的風險。另外,這二者都是基于Proxy模式的集群構建,性能上并不存在太大的差別。
          • 相比Redis Cluster,Codis的優勢在于:首先它基于Proxy的代理模式能夠無縫兼容所有的redis client;而Redis Cluster則需要使用配套的client sdk來替換我們之前的程序,因為這種模式下客戶端需要維護集群相關的信息。其次,基于Proxy的集群,使得我們可以更加清晰的掌握整個集群的狀態,因 為Codis將所有的操作命令和集群拓撲結構都同步在Zookeeper中,而Zookeeper的集群也是我們常用并熟悉的,這也是我非常青睞 Codis的一個原因。
          • </ul>

            Codis主要的關鍵技術我認為有三點:

            • Pre-sharding

              • Codis Proxy在Zookeeper維護了1024個slots,并建立了每個slot和后端redis group的路由表
              • 一個redis group包括一組master/slave的redis實例
              • 通過crc32(key)%1024計算出每個key對應的slot編號,然后查詢路由表即可得到每個key對應的具體redis實例,從而打通數據代理
              • 這樣redis實例可以從一開始的單臺節點擴展到最大1024個節點,按照目前機器的配置基本可達到無限擴展
              • </ul> </li>

              • Zookeeper

                • Codis中所有的運維操作命令都通通過zookeeper同步到每一個Proxy中,包括slot的遷移、Group的變化等。此外,pre-sharding的路由信息也存放在zookeeper中。這些使得Codis Proxy能夠水平擴展并協同工作
                • zookeeper還能夠被用來做Proxy的服務發現和負載均衡。后面我們會再講到
                • </ul> </li>

                • 動態遷移

                  • 動態遷移是指不用down掉服務也能夠將redis key平滑的遷移到另一個group上
                  • 傳統的redis遷移,我們可能會想到通過對先有Redis掛載slave,將存量數據熱備到新增節點,然后改變slot和group的路由表, 將一部分數據切到新機器上。然而這種方式很難保證節點切換中的數據一致性,如果要保證這一點只能做通過停服來做靜態sharding,之前的 Twemproxy就只能這么做
                  • 實際上Codis實現的動態擴容是通過在官方的Redis Server中植入MIGRATE命令來實現的,并確保了該操作的原子性
                  • 由于真正執行遷移的是通過額外的工具codis-config來實現,所以不用擔心會影響Proxy正常處理請求的性能
                  • 遷移slot的操作會通過zookeeper同步給Proxy用于快速感知,即如果在slot遷移中,對應的key發生了操作,Proxy會強制執行一次SLOTSMGRTTAGONE命令將這個key數據單獨做一次遷移
                  • </ul> </li> </ul>

                    以上的三條保證了Codis能夠滿足高可用性和可擴展性的標準。

                    關于Codis的使用和性能測試,請轉到他們的主頁——https://github.com/wandoulabs/codis。本文主要從架構和源碼上對Codis進行介紹。

                    Codis Proxy 2.0源碼解析

                    下面我們一起解讀一下Codis Proxy 2.0的源碼。

                    以下內容推薦在電腦上閱讀。

                    由于Codis是由Go語言編寫的,這也是非常吸引我了解的一點,Go語言天生的高并發特性非常適合寫這種高并發的接入層/中間件服務,codis 代碼正是運用了go routine的簡潔高效,再配合channel做數據同步,sync做狀態同步,整體代碼還是比較簡單明了的。

                    具體的Go語法可以參考https://golang.org,很值得去學習,特別是用慣了C/C++和python的同學,Go語言在開發和運行效率上的兼顧一定會讓你覺得心曠神怡。

                    在了解Codis代碼之前,我還是先解釋一下go routine這個概念。我們了解以下幾點:

                    • routine可以解釋為協程,類似于python中的greenlet(https://greenlet.readthedocs.org)
                    • 協程可以看做是微小的線程,內存開銷極小且由程序自己來進行調度,從而能最大化的利用CPU時間
                    • 而標準的線程是由操作系統來統一調度,線程棧消耗一般在1-8M,這樣在高并發的情況下二者的性能差異可想而知。

                      go routine也是被先天植入go語言之中,因此用它來編寫并發程序再適合不過了。

                    • </ul>

                      好了,下面我們來看Codis Proxy的代碼。

                      Codis Proxy代碼結構比較清晰,整個程序基本上就是在不同的go routine之間同步各種數據和狀態,只要抓住幾個關鍵的go routine流程,再結合Proxy的架構就能夠很清晰的明白了。
                      下面我對Codis Proxy 2.0的程序架構做了模塊化的展示。

                      • 紅色箭頭代表集群和外部的連接
                      • 黑色箭頭屬于內部連接
                      • 藍色模塊代表go routine
                      • 綠色模塊代表程序中的模塊和函數
                      • 紫色模塊代表監聽的事件
                      • </ul>

                        Redis集群最佳實踐

                        結合以上的架構圖,我們可以很清晰的知道Codis Proxy的工作流程。
                        下面對關鍵代碼做進一步的講解。

                        1、初始化Proxy Server對象

                        Codis Proxy在初始化時會構建一個Server的對象,并第一時間向zookeeper注冊自己。

                        type Server struct {
                        conf *Config //Proxy配置,包括proxy id、name、zk的地址、timeout參數、redis授權信息等
                        topo *Topology //用于訪問ZooKeeper的對象,顧名思義,能夠從zk獲取整個集群的拓撲結構
                        info models.ProxyInfo //封裝Proxy的基本信息,包括id、addr等
                        groups map[int]int //存放slot和group的映射,index表示slot id,當slot對應group發生變化時,
                        proxy會根據此映射對slot做reset,即調用fillSlot
                        lastActionSeq int //同步序列號,這個類似于版本號同步協議,用于同步zookeeper中的操作命令,比如slot遷移
                        evtbus chan interface{} //這個channel用于從zookeeper獲取最新的操作指令
                        router *router.Router //路由對象,1、設置并維護slots的后端連接 2、dispatch客戶端請求到后端redis
                        listener net.Listener //tcp socket listener,用于監聽并accept客戶端的連接請求
                        kill chan interface{} //Proxy收到SIGTERM信號時會激活該channel,然后清理zk的狀態并正常退出
                        wait sync.WaitGroup //go routine的同步對象,用于主線程同步go routine的完成狀態
                        stop sync.Once // Proxy Close時一次性清理所有資源,包括client以及slot的后端連接
                        }

                        之后主線程通過go routine創建第一個協程G1,開始工作。
                        而主線程會調用wait.Wait(),等待G1的完成,只有在Proxy意外退出或是主動發送mark_offline時整個程序才會結束。G1在調用 Serve方法之后,首先會check自己在zk的狀態是否是online,然后才能開始工作。注意,在Codis2.0中,主線程會自動調用 Codis-config來使自己上線,不再需要手動的去markonline。check成功之后,G1會向zookeeper注冊actions節點 的watch,這樣就可以用來實時感知zookeeper中的操作命令了,包括slot遷移,group的變化等。之后G1會初始化各個slot的后端連 接,緊接著再創建一個routineG2,用于handle客戶端的連接,即承擔接入redis客戶端的工作。而G1自己會調用loopEvent,通過 select監聽zookeeper中的操作命令以及kill命令。

                        注意,Go中的select要比Unix的select調用強大很多,只是名字一樣罷了,我想底層應該是采用epoll的實現方式

                        2、handleConns處理客戶端連接

                        好了,現在G1和G2都進入了各自的Loop中高效的運轉了。我們看一下G2的代碼。

                        <br />func (s *Server) handleConns() {
                        ch := make(chan net.Conn, 4096)
                        defer close(ch)

                        go func() {
                        for c := range ch {
                        x := router.NewSessionSize(c, s.conf.passwd, s.conf.maxBufSize, s.conf.maxTimeout)
                        go x.Serve(s.router, s.conf.maxPipeline)
                        }
                        }()

                        for {
                        c, err := s.listener.Accept()
                        if err != nil {
                        return
                        } else {
                        ch <- c
                        }
                        }
                        }

                        這段代碼用于處理客戶端的接入請求,想起我們之前用C寫的epoll單線程回調,這個看起來是不是很簡潔呢^_^這就是go routine的魅力,可以拋棄繁瑣的回調。

                        OK,下面我們繼續進入G2這個協程,如代碼所示。

                        • 這里會實時的accept客戶端的redis連接,并為每一個連接N單獨創建一個協程G2N用于request/response(參考上面的架構圖)。
                        • G2N會運行在 loopReader中,實時的從socket讀取client的請求,并按照RESP(Redis Protocol)的協議進行解碼,接著調用 handleRequest進行請求分發。對于部分命令比如MSET/MGET,codis是做了特殊處理的,原因在于批量處理的key可能分布在不同的 redis實例上,所以在codis這里需要將不同的key dispatch到不同的后端,得到響應之后再統一打包成Redis Array返回給客戶端。
                          此外,G2N會額外再創建一個routine G2NW,用于向client回寫請求的數據結果,并運行在loopWriter中。參考G2N的主要邏輯代碼如下。
                        • </ul>

                          <br />func (s *Session) Serve(d Dispatcher, maxPipeline int) {
                          var errlist errors.ErrorList
                          defer func() {
                          if err := errlist.First(); err != nil {
                          log.Infof("session [%p] closed: %s, error = %s", s, s, err)
                          } else {
                          log.Infof("session [%p] closed: %s, quit", s, s)
                          }
                          }()

                          tasks := make(chan *Request, maxPipeline)
                          go func() {
                          defer func() {
                          s.Close()
                          for _ = range tasks {
                          }
                          }()
                          if err := s.loopWriter(tasks); err != nil {
                          errlist.PushBack(err)
                          }
                          }()

                          defer close(tasks)
                          if err := s.loopReader(tasks, d); err != nil {
                          errlist.PushBack(err)
                          }
                          }

                          其中loopWriter即為G2NW協程所運行的函數棧。

                          3、命令分發(反向代理/Dispatch)

                          下面說一下Dispatch

                          • G2N在收到客戶端請求的key時,會查看key相關的slot信息,通過查詢路由表來獲取對應后端redis實例的連接,后端連接的托管是放在backend.go這個模塊中的。
                          • router模塊在初始化slots的時候,維護了一個backend的連接池,當有redis key的請求過來時,會將請求打包成Request對象然后再分發給該slot對應的后端連接,要注意的是歸屬同一個redis group的slot就會復用同一個后端連接。
                          • router通過將Request對象dispatch到后端連接監聽的同步隊列channel中,以此來解決并發控制的問題。
                          • 具體的每一個BackendConnection都會各自運行兩個routine:

                            • 一個執行loopWrite,不斷的獲取從router dispatch過來的Request對象,然后Encode成RESP格式發送給后端的redis實例
                            • 另一個routine用于Decode從Redis實例返回的結果,并通過調用setResponse方法來告知前端的G2NW,這里setResponse代碼如下:
                            • </ul> </li> </ul>

                              <br /> func (bc *BackendConn) setResponse(r *Request, resp *redis.Resp, err error) error {
                              r.Response.Resp, r.Response.Err = resp, err
                              if err != nil && r.Failed != nil {
                              r.Failed.Set(true)
                              }
                              if r.Wait != nil {
                              r.Wait.Done()
                              }
                              if r.slot != nil {
                              r.slot.Done()
                              }
                              return err
                              }

                              可以看到這里會調用wait.Done和slot.Done來通知前端的routine。這兩個Done的區別在于,wait.Done用于同步請 求的處理完畢狀態,而slot.Done用于同步該slot的狀態,因為當Codis在收到slot遷移指令時需要調用fillSlot對slot進行重 置,而此操作需要等待對應slot上的所有代理請求處理完畢之后才能進行。

                              這里涉及到Go語言sync模塊的內容,具體可以參考https://golang.org/pkg/sync/#WaitGroup

                              由于篇幅有限,整個Codis Proxy2.0的代碼先介紹到這里,讀者可以結合上面的架構圖對代碼做進一步的了解。我們不難發現,整個代碼都是由go routine、channel、sync來構建,這也是go語言并發編程的核心概念。

                              Codis Proxy Auto-balance

                              因為Codis是基于Proxy模式構建的集群,這就要求我們必須保證Proxy組件的高可用性,換句話說,我們需要做好Proxy組件的auto-balance和服務發現。推薦一個解決方案如下:

                              • 我們可以搭建N個Codis Proxy來分擔負載,每個Proxy的id和addr不同即可。
                              • Codis Proxy的服務發現,可以通過監聽zookeeper來完成。
                              • 對于pytho開發者,我實現了一個pycodis的組件,用于python連接codis proxy,大致原理是監聽zookeeper中proxy節點的狀態,如果某臺proxy掛掉了,可以及時的調整連接池,保證client每次獲取到的 都是最新并可用的連接,不需要修改client配置和重啟,同時也能夠保證每臺Proxy的負載均衡。這主要得益于Codis使用Zookeeper來進 行狀態同步,也就天生具備了服務發現的優勢。
                              • </ul>

                                以上對了Redis集群——Codis2.0做了大致的介紹,也是我認為目前最可靠的redis集群方案之一,并且這種集群的實現架構也是值得我們 其他系統借鑒的。當然這種Proxy的方式還是存在一些先天缺陷,比如很難支持事務和批量操作,但我想對于大部分應用場景來說它的支持已經足夠了。

                                好了,期待Codis下一次的更新吧。另外,如果各位看官有更好的實踐,歡迎賜教,期待和大家一起交流和探討。


                                來自: http://jimhuang.cn/?p=402#rd&sukey=6bdd2d01817422c5d43d51bb6f35586e9f7613b3a58a6b2856be46612954526a11dd5b1e6086348a648cf90a1e08db66

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!