用Java手寫Redis伺服器端,從設計者的角度聊一聊Redis本身

2021-12-31 21:00:01

用Java手寫Redis服務端,從設計者的角度聊一聊Redis本身

 

起因

我為什麼要造redis這個輪子?

  1,破除對redis神祕感。
  2,「基礎服務中臺」的同事們在開會討論redis雲,以及redis代理。
  3,開一個redis資源並不是容易事,為什麼不可以不可以寫成java直接推播到未來雲上,簡單方便。
   以這個思路我開始使用業餘時間研究了redis的tcp通訊原理與redis命令,出發點是寫一個redis雲代理之類的雲管理軟體,但是還是忍不住寫成了java版的redis,本文章主要分享redis的編寫心路歷程。
複製程式碼

一,redis通訊與Netty

1,tcp

連到Redis伺服器的使用者端建立了一個到6379埠的TCP連線。

雖然RESP在技術上不特定於TCP,但是在Redis的上下文中,該協定僅用於TCP連線(或類似的面向流的連線,如unix通訊端)。

使用netty作為通訊框架。

2,協定

Redis使用者端和伺服器端通訊使用名為 RESP (REdis Serialization Protocol) 的協定。雖然這個協定是專門為Redis設計的,它也可以用在其它 client-server 通訊模式的軟體上。 RESP 協定在Redis1.2被引入,直到Redis2.0才成為和Redis伺服器通訊的標準。這個協定需要在你的Redis使用者端實現。

RESP 是一個支援多種資料型別的序列化協定:簡單字串(Simple Strings),錯誤( Errors),整型( Integers), 大容量字串(Bulk Strings)和陣列(Arrays)。

RESP在Redis中作為一個請求-響應協定以如下方式使用:

使用者端以大容量字串RESP陣列的方式傳送命令給伺服器端。 伺服器端根據命令的具體實現返回某一種RESP資料型別。 在 RESP 中,資料的型別依賴於首位元組:

單行字串(Simple Strings): 響應的首位元組是 "+" 錯誤(Errors): 響應的首位元組是 "-" 整型(Integers): 響應的首位元組是 ":" 多行字串(Bulk Strings): 響應的首位元組是"$" 陣列(Arrays): 響應的首位元組是 "*" 另外,RESP可以使用大容量字串或者陣列型別的特殊變數表示空值,下面會具體解釋。RESP協定的不同部分總是以 "\r\n" (CRLF) 結束。 字串 "foobar" 編碼如下:

"$6\r\nfoobar\r\n"

複製程式碼

實際redis命令是什麼樣的,比如 SET lhjljh lhjkjhkh

*3\r\n$3\r\nSET\r\n$6\r\nlhjljh\r\n$8\r\nlhjkjhkh
複製程式碼

RESP協定中文詳情檔案

3,編解碼

由於RESP天然是面向處理命令的,所以沒辦法直接把redis訊息像grpc或者dubbo那樣直接序列化和反序列化訊息。並且每個內容限定了長度,很適合做成及時序列化、零拷貝,直接針對輸入流做反序列化和序列化,這一點與Protostuff序列化協定的設計很類似。 所以序列化直接將伺服器端接收的流直接轉成值。

image.png

編解碼的實體類直接加入redis server 的處理某一個長連線tcp使用者端的管道上。

image.png

如果有興趣研究可以看c語言原版的原始碼分析視訊:畢站redis原始碼分析視訊

4,命令處理

將訊息解碼成RESP,還需要將RESP轉為Command物件,這裡因為是java語言,方法與類繫結,編寫上和理解上會更加容易。但是會增加一些開銷。

image.png

二,redis 的資料結構

1,底層主結構

底層主樹使用跳錶ConcurrentSkipListMap實現,沒用hash類map的原因是伺服器端是叢集後,使用者端可能使用hash路由,會導致伺服器端嚴重的hash衝突,效能大打折扣

image.png

key為封裝的「String」,重寫了equals方法避免相同的key但是在jvm中指標不同

image.png

value是一個介面,實現類是redis的五大基本型別,所有資料型別都包含超時時間

image.png

2,key

用封裝的值做value的原因是方便統一管理

image.png

3,list

底層使用LinkedList的原因是LinkedList實現了多種介面,實現各種命令直接呼叫其現成實現的方法即可

image.png

image.png

4,set

底層使用HashSet,redis裡的set沒有多特殊

image.png

5,hash

底層使用HashMap,這裡和開頭說的HashMap不衝突。為什麼不用跳錶?壓縮列表很巧妙,大抵的意思就是將通訊收到的陣列直接填充到list中,將list直接按照次序直接當map使用,主要是0拷貝的思想,無需建立新資源,效能極高,但注意壓縮列表與壓縮無關。 感興趣可以檢視連線:redis 壓縮列表

image.png

6,zset

首先需要封裝一個帶有值和分值的物件

image.png

再用TreeMap重寫compare方法即可,使用TreeMap原因是他天然有良好的排序功能,很多hash一致路由的演演算法都用的TreeMap二開。

image.png

三,redis AOF 持久化

1,aof執行緒與tcp執行緒解耦,即寫緩衝

再解析redis命令時,將redis寫命令新增到寫aof紀錄檔的佇列中

image.png

這裡自己封裝了一個堵塞佇列,單執行緒吞吐量可以達到3000W /s是LinkedBlockingQueue的6到10倍,完全可以勝任此場景

image.png

image.png

RingBlockingQueue吞吐量非常高的原因是使用了記憶體連續頁的機制。

image.png

c語言原版的實現:redis原版的aof緩衝實現

2,aof持久化協定

aof協定一句話概括就是將寫命令,追加到紀錄檔中,開始時將命令讀取,當作收到網路的命令執行即可。由於協定過於簡單,這裡就不貼連結了。 aof之日格式如下圖:

aof_img.png

3,aof的載入與儲存實現

這裡讀寫記憶體都是用的記憶體檔案對映,好處是讀寫效能好,壞處是可能會出現記憶體漏失,偵錯期間比較麻煩。

image.png

4,記憶體檔案對映與物件導向

這裡儲存和載入aof檔案的程式碼都是程式導向的,看起來非常複雜。實際上之前是按照物件導向寫的,封裝成了行物件,呼叫落碟符和拾起方法就可以寫入和讀取aof中的命令,但是TPS僅為10w/s,後來權衡後改為程式導向,吞吐量提升到了100W的TPS以上。

四,redis 的叢集特性

1,主從

這裡很容易聯想到mysql的只從,很多場景下會使用基於mysql主從的讀寫分離,或者zk的主從。 但實際上redis的主從是不保證一致性的,個人認為redist的主從主要考慮的是cap的分散式容錯性。 因為redis主從不保證一致性,所以使用redis讀寫分離,可能造成一些不一致的問題,寫寫是一致的,但是讀是不一致的,可以根據專案需要做取捨。

2,主從複製

redis的主從複製這裡作者沒看懂(可能也是一致性上有坑沒動力去看),所以沒寫出來。

3,分片叢集

redis叢集主要分為幾個唯獨: 主從、分割區叢集、代理。 一般在redis使用者端的視角下,主要是分割區叢集,根據傳送給redis的key做hash、md5等操作,取一個所有使用者端的共識值,將key和value傳送,也就是使用者端路由 分散式軟體的叢集實現方式京東的redis叢集設計到redis具體一個分片。

五,redis 的壓測與調優

1,aof記憶體漏失

開啟aof壓測發現出現了記憶體漏失,後來發現是頻繁新建記憶體池而造成的,所以將記憶體池池化,即aof物件中僅存在一個bytebuff記憶體池。

2,記憶體複用提升效能

這裡編解碼沒有單獨開闢byte資料接收bytebuff的資料進行編解碼,編解碼直接讀取bytebuff進行編解碼,沒有出現記憶體拷貝,唯獨新建了BytesWrapper物件,但儲存的資料都是使用BytesWrapper物件,對記憶體新建/銷燬的開銷很少。

3,0.05%訊息延遲超200ms排查

下圖為c語言版的redis壓測資料:

cppredis.png

下圖為java語言版的redis壓測資料:

javaredis.png

發現java版的redis會出現小概率訊息延遲,為什麼那? 感興趣可以聯絡我,原始碼地址:github.com/wiqer/ef-re…

4,效能表現

redis原版的效能大概是E5系列CPU 4-5w左右,上圖中是使用amd晶片測試的資料。 使用redis自帶的壓測工具,維持100個使用者端連線,java版效能是c語言原版效能的75-90%左右,效能依然強悍。


作者:傷心的菜狗開發
連結:https://juejin.cn/post/7045544580309057572

如果你覺的本文對你有幫助,麻煩點贊關注支援一下