Java手寫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

3,編解碼

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

image.jpg

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

image.jpg

4,命令處理

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

image.jpg

二,redis 的數據結構

1,底層主結構

底層主樹使用跳表ConcurrentSkipListMap實現,沒用hash類map的原因是服務端是集群後,客戶端可能使用hash路由,會導致服務端嚴重的hash沖突,性能大打折扣

image.jpg

key為封裝的“String”,重寫瞭equals方法避免相同的key但是在jvm中指針不同

image.jpg

value是一個接口,實現類是redis的五大基本類型,所有數據類型都包含超時時間

image.jpg

2,key

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

image.jpg

3,list

底層使用LinkedList的原因是LinkedList實現瞭多種接口,實現各種命令直接調用其現成實現的方法即可

image.jpg

image.jpg

4,set

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

image.jpg

5,hash

底層使用HashMap,這裡和開頭說的HashMap不沖突。為什麼不用跳表?壓縮列表很巧妙,大抵的意思就是將通信收到的數組直接填充到list中,將list直接按照次序直接當map使用,主要是0拷貝的思想,無需創建新資源,性能極高,但註意壓縮列表與壓縮無關。

image.jpg

6,zset

首先需要封裝一個帶有值和分值的對象

image.jpg

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

image.jpg

三,redis AOF 持久化

1,aof線程與tcp線程解耦,即寫緩沖

再解析redis命令時,將redis寫命令添加到寫aof日志的隊列中

image.jpg

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

image.jpg

image.jpg

RingBlockingQueue吞吐量非常高的原因是使用瞭內存連續頁的機制。

image.jpg

2,aof持久化協議

aof協議一句話概括就是將寫命令,追加到日志中,開始時將命令讀取,當作收到網絡的命令執行即可。由於協議過於簡單,這裡就不貼鏈接瞭。 aof之日格式如下圖:

aof_img.jpg

3,aof的加載與存儲實現

這裡讀寫內存都是用的內存文件映射,好處是讀寫性能好,壞處是可能會出現內存泄漏,調試期間比較麻煩。

image.jpg

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.jpg

下圖為java語言版的redis壓測數據:

javaredis.jpg

4,性能表現

redis原版的性能大概是E5系列CPU 4-5w左右,上圖中是使用amd芯片測試的數據。 使用redis自帶的壓測工具,維持100個客戶端連接,java版性能是c語言原版性能的75-90%左右,性能依然強悍。

到此這篇關於Java手寫Redis服務端的實現的文章就介紹到這瞭,更多相關Java手寫Redis服務端內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: