Redis集群最佳實踐
今天我們來聊一聊Redis集群。先看看集群的特點,我對它的理解是要需要同時滿足高可用性以及可擴展性,即任何時候對外的接口都要是基本可用的并具備一定的災備能力,同時節點的數量能夠根據業務量級的大小動態的伸縮。那么我們一般如何實現呢?
集群的實現方式
說到集群的實現,我會想到兩種方式
- 第一種是去中心化的集群
- 整個集群是由一組水平的節點構建
- 通過給各個節點分配不同的角色實現相互配合,并同步各自狀態
- 對外提供單調的接口
- 整個集群依靠內部的同步機制來進行伸縮和容錯
- 實現復雜 </ul> </li> </ul>
- 第二種集群是基于Proxy的集群(反向代理)
- 引入一個Proxy中間件來管理整個集群,托管后端節點
- 通過Zookeeper這種第三方組件實現集群的數據和狀態同步
- Proxy本身能夠水平擴展,并方便實現auto-balance
- 實現簡單
- 需要保證Proxy本身的高可用性 </ul> </li> </ul>
- 相比Twemproxy,Codis能夠提供動態的sharding,即無縫擴展redis的節點,省去了人工數據遷移的成本以及down掉服務帶來的風險。另外,這二者都是基于Proxy模式的集群構建,性能上并不存在太大的差別。
- 相比Redis Cluster,Codis的優勢在于:首先它基于Proxy的代理模式能夠無縫兼容所有的redis client;而Redis Cluster則需要使用配套的client sdk來替換我們之前的程序,因為這種模式下客戶端需要維護集群相關的信息。其次,基于Proxy的集群,使得我們可以更加清晰的掌握整個集群的狀態,因 為Codis將所有的操作命令和集群拓撲結構都同步在Zookeeper中,而Zookeeper的集群也是我們常用并熟悉的,這也是我非常青睞 Codis的一個原因。 </ul>
- 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>
- 紅色箭頭代表集群和外部的連接
- 黑色箭頭屬于內部連接
- 藍色模塊代表go routine
- 綠色模塊代表程序中的模塊和函數
- 紫色模塊代表監聽的事件 </ul>
好了,下面我們來看Codis Proxy的代碼。
Codis Proxy代碼結構比較清晰,整個程序基本上就是在不同的go routine之間同步各種數據和狀態,只要抓住幾個關鍵的go routine流程,再結合Proxy的架構就能夠很清晰的明白了。
下面我對Codis Proxy 2.0的程序架構做了模塊化的展示。結合以上的架構圖,我們可以很清晰的知道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>
- 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
<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。
這種方式有人會稱之為偽集群,畢竟需要依靠第三方組件來實現集群化,但從整體架構上來看,這種實現方式確實可以滿足集群的標準,即滿足高可用性和可 擴展性,它的優點是實現簡單,并且通過Proxy去維護集群的狀態要比去中心化的方式更加方便,這也是為什么我選擇Codis而不是官方Cluster的 原因。下面會詳細介紹。
Redis集群——Codis2.0
那么具體到Redis的集群實現,目前最流行的應該是推ter開源的Twemproxy,再就是近年官方推出的Redis 3.0 Cluster。今天我要介紹的是來自豌豆莢開源的Codis,它是一套基于Proxy模式的Redis集群服務,Codis目前的版本是2.0。
Codis主要的關鍵技術我認為有三點:
這種集群,常見的有Zookeeper以及以MongoDB為代表的大部分NoSQL服務,包括Redis官方集群3.0 Cluster也是屬于這種類型。