如何使Codis儲存成本降低90%?個推使用Pika做到了!

個推發表於2022-03-22

作為一家資料智慧公司,個推不僅擁有海量的關係型資料,也積累了豐富的key-value等非關係型資料資源。個推採用Codis儲存大規模的key-value資料,隨著公司kv型別資料的不斷增加,使用原生的Codis搭建的叢集所花費的成本越來越高。 在一些對效能響應要求不高的場景中,個推計劃採用新的儲存和管理方案以有效兼顧成本與效能。經過選型,個推引入了360開源的儲存系統Pika作為Codis的底層儲存,以替換成本較高的codis-server,管理分散式kv資料叢集。 將Pika接入到Codis的過程並非一帆風順,為了更好地滿足業務場景需求,個推進行了系列設計和改造工作。

本文是“大資料降本提效”專題的第四篇,為大家分享個推如何完美結合Pika和Codis,最終節省90%大資料儲存成本的實戰經驗。

Codis的四大元件

在瞭解具體的遷移實戰之前,需要先初步認識下Codis的基本架構。Codis 是一個分散式 Redis解決方案,由codis-fe、codis-dashboard、codis-proxy、codis-server等四個元件構成。

  • 其中,codis-server是Codis中最核心和基礎的元件。基於Redis 3版本,codis-server進行了功能擴充套件,但其本質上還是依賴於高效能的Redis提供服務。codis-server擴充套件了基於slot的key儲存功能(為了實現slot這個功能,codis-server會額外佔用超出儲存資料所需的記憶體),並能夠在Codis叢集的不同Group之間進行slot資料熱遷移。
  • codis-fe則提供對運維比較友好的管理介面,方便統一管理多套的codis-dashboard。
  • codis-dashboard負責管理slot、codis-proxy和ZooKeeper(或者etcd)等元件的資料一致性,整個叢集的運維狀態,資料的擴容縮容和元件的高可用,類似於k8s的api-server功能。
  • codis-proxy主要提供給業務層面使用的訪問代理,負責解析請求路由並將key的路由資訊路由到對應的後端group上面。此外,codis-proxy還有一個很重要的功能,即在通過codis-fe進行叢集的擴縮容時,codis-proxy會根據group對應的slot的遷移狀態觸發key遷移的流程,能夠實現在不中斷業務服務的情況下熱遷移資料,以確保業務的可用性。

Pika接入Codis的挑戰

我們引入Pika主要是用來替換codis-server。作為360開源的類Redis儲存系統,Pika底層選用RocksDB,它完全相容Redis協議,並且主流版本提供Codis的接入能力。但在引入Pika以及將資料遷移到Codis的過程中,我們發現Pika和Codis的結合並非想象中完美。

問題一:語法不統一​

在接入之前,我們深入查閱並對比了Pika和Codis原始碼,發現Pika實現的命令相對較少,將Pika接入到Codis之後有些功能還能否正常使用有待觀察。

位於pika_command.h標頭檔案中的Pika (3.4.0版本) 原始碼:

//Codis Slots
const std::string kCmdNameSlotsInfo = "slotsinfo";
const std::string kCmdNameSlotsHashKey = "slotshashkey";
const std::string kCmdNameSlotsMgrtTagSlotAsync = "slotsmgrttagslot-async";
const std::string kCmdNameSlotsMgrtSlotAsync = "slotsmgrtslot-async";
const std::string kCmdNameSlotsDel = "slotsdel";
const std::string kCmdNameSlotsScan = "slotsscan";
const std::string kCmdNameSlotsMgrtExecWrapper = "slotsmgrt-exec-wrapper";
const std::string kCmdNameSlotsMgrtAsyncStatus = "slotsmgrt-async-status";
const std::string kCmdNameSlotsMgrtAsyncCancel = "slotsmgrt-async-cancel";
const std::string kCmdNameSlotsMgrtSlot = "slotsmgrtslot";
const std::string kCmdNameSlotsMgrtTagSlot = "slotsmgrttagslot";
const std::string kCmdNameSlotsMgrtOne = "slotsmgrtone";
const std::string kCmdNameSlotsMgrtTagOne = "slotsmgrttagone";

​codis-server支援的命令如下:

 {"slotsinfo",slotsinfoCommand,-1,"rF",0,NULL,0,0,0,0,0},
    {"slotsscan",slotsscanCommand,-3,"rR",0,NULL,0,0,0,0,0},
    {"slotsdel",slotsdelCommand,-2,"w",0,NULL,1,-1,1,0,0},
    {"slotsmgrtslot",slotsmgrtslotCommand,5,"w",0,NULL,0,0,0,0,0},
    {"slotsmgrttagslot",slotsmgrttagslotCommand,5,"w",0,NULL,0,0,0,0,0},
    {"slotsmgrtone",slotsmgrtoneCommand,5,"w",0,NULL,0,0,0,0,0},
    {"slotsmgrttagone",slotsmgrttagoneCommand,5,"w",0,NULL,0,0,0,0,0},
    {"slotshashkey",slotshashkeyCommand,-1,"rF",0,NULL,0,0,0,0,0},
    {"slotscheck",slotscheckCommand,0,"r",0,NULL,0,0,0,0,0},
    {"slotsrestore",slotsrestoreCommand,-4,"wm",0,NULL,0,0,0,0,0},
    {"slotsmgrtslot-async",slotsmgrtSlotAsyncCommand,8,"ws",0,NULL,0,0,0,0,0},
    {"slotsmgrttagslot-async",slotsmgrtTagSlotAsyncCommand,8,"ws",0,NULL,0,0,0,0,0},
    {"slotsmgrtone-async",slotsmgrtOneAsyncCommand,-7,"ws",0,NULL,0,0,0,0,0},
    {"slotsmgrttagone-async",slotsmgrtTagOneAsyncCommand,-7,"ws",0,NULL,0,0,0,0,0},
    {"slotsmgrtone-async-dump",slotsmgrtOneAsyncDumpCommand,-4,"rm",0,NULL,0,0,0,0,0},
    {"slotsmgrttagone-async-dump",slotsmgrtTagOneAsyncDumpCommand,-4,"rm",0,NULL,0,0,0,0,0},
    {"slotsmgrt-async-fence",slotsmgrtAsyncFenceCommand,0,"rs",0,NULL,0,0,0,0,0},
    {"slotsmgrt-async-cancel",slotsmgrtAsyncCancelCommand,0,"F",0,NULL,0,0,0,0,0},
    {"slotsmgrt-async-status",slotsmgrtAsyncStatusCommand,0,"F",0,NULL,0,0,0,0,0},
    {"slotsmgrt-exec-wrapper",slotsmgrtExecWrapperCommand,-3,"wm",0,NULL,0,0,0,0,0},
    {"slotsrestore-async",slotsrestoreAsyncCommand,-2,"wm",0,NULL,0,0,0,0,0},
    {"slotsrestore-async-auth",slotsrestoreAsyncAuthCommand,2,"sltF",0,NULL,0,0,0,0,0},
    {"slotsrestore-async-select",slotsrestoreAsyncSelectCommand,2,"lF",0,NULL,0,0,0,0,0},
    {"slotsrestore-async-ack",slotsrestoreAsyncAckCommand,3,"w",0,NULL,0,0,0,0,0},

此外,codis-server和Pika支援的語法也有所不同。例如,如果要檢視某一節點上slot 1的詳細資訊,Codis與Pika執行的命令分別如下: 也就是說,我們必須在codis-fe層命令排程與管理功能方面加上對Pika語法格式的支援。

針對此問題,我們在codis-dashboard層中,通過修改部分原始碼邏輯,實現了對Pika主從同步、主從提升等相關命令的支援,從而完成了在codis-fe層面的操作。

問題二:未成功完成資料遷移

完成了以上操作之後,我們便開始將kv資料遷移到Pika。然後,問題來了,我們發現雖然codis-fe介面上顯示資料均已遷移完成,但實際上要遷移的資料並未被遷移到對應的叢集。在codis-fe介面上,我們也未檢視到明顯的報錯資訊。

到底為何出現此問題呢?

我們繼續檢視了Pika有關slot的原始碼:

void SlotsMgrtSlotAsyncCmd::Do(std::shared_ptr<Partition> partition) {
  int64_t moved = 0;
  int64_t remained = 0;
  res_.AppendArrayLen(2);
  res_.AppendInteger(moved);
  res_.AppendInteger(remained);
}

我們發現,在日常的執行情況下,通過codis-dashboard傳送給Pika的指令就是成功返回,這樣codis-dashboard在遷移時立馬就收到了成功的訊號,然後就直接將遷移狀態修改為成功,而其實此時資料遷移並沒有被真的執行。

針對這種情況,我們查閱了有關Pika的官方文件 Pika配合Codis擴容案例。

從官方的文件來看,這種遷移方案是一種可能會丟資料的有損方案,我們需要根據自身情況來重新設計和調整遷移方案。

1.設計開發Pika遷移工具

首先,根據Codis的資料擴縮容原理,我們參考codis-proxy的架構設計,使用Go語言自行設計並開發了一套Pika資料遷移工具,目的是實現以下功能需求:

  • 將Pika遷移工具偽裝成一個Pika例項接入Codis並提供服務。
  • 把Pika遷移工具作為一個流量轉發工具,類似於codis-proxy,能夠將對應slot的請求轉發到指定的Pika例項上面,從而保證遷移過程中的業務可用性。
  • 使Pika遷移工具能夠感知到遷移過程中的主從同步情況,在主從完成的情況下可自動從節點斷開,並將新增資料寫入新叢集,從而在流量分發過程中全力保證資料一致性。

2.使用Pika遷移工具進行資料的熱遷移

根據如上需求完成Pika遷移工具的設計開發後,我們就可以使用該工具對資料進行熱遷移。

遷移過程如下:

Step1: 叢集原始狀態

通過下圖,可以看到,我們需要將801-1023中901-1023區間的slot資訊遷移到新元件即Group4上,作為新例項提供服務。

Step2: 將Pika遷移工具接入Codis提供服務

在Pika遷移工具接入Codis之前,我們需將Group3中待遷移的901-1023作為Group4的主節點,並進行主從資料同步。此時Group3的901-1023作為主,Group4的901-1023作為從。在完成該步驟之後就可將Pika遷移工具接入Codis。 首先將801-1023的slot資訊遷移到Pika遷移工具。 此時Pika遷移工具將801-900的讀寫資訊寫入Group3。 在Pika遷移工具中,將901-1023的讀寫資訊同時指向Group4和Group3。然後進入下一步。

Step3: 主從同步資料並動態切換主從

此時Pika遷移工具已經完成接入,它將轉發801-1023的slot請求到後端。 這裡需要注意,Pika遷移工具在處理寫流量時,會檢查主從同步是否完成。 如果主從同步完成,Pika遷移工具會直接將Group4中Pika例項的從斷掉,並將新資料寫入到Group4中,否則就繼續將寫入的資料路由到Group3。 如果是讀流量,Pika遷移工具會先嚐試獲取Group4的資料,如果獲取到則返回,否則就去Group3獲取資料。 如果901-1023的slot中沒有寫流量,則無法判斷該slot主從同步是否完成以及是否要斷開主從,那麼我們可以向Pika遷移工具傳送針對該slot的命令來執行該操作。 直到Group4中所有slot的主從同步完成且主從斷開,方進行下一步。

下圖比較形象地展示了Pika遷移工具的作業邏輯:

Step4: 將待遷移的slot遷入新的Group

在完成步驟3之後,再將Pika遷移工具的slot資訊,即801-900,遷移回Group3,將901-1023遷移到Group4。 將901-1023完全遷移到Group4之後,就可將原來Group3中冗餘的舊資料刪除。

至此,我們通過Pika遷移工具完成了對kv叢集的擴容。

這裡需要說明的是,Pika遷移工具的大部分功能和codis-proxy相似,只不過需要將對應的路由規則進行轉換,並新增上支援Pika的語法指令。之所以能夠如此設計實現,是因為在codis-proxy的遷移過程中產生的都是原子性命令的操作,從而能夠在Pika遷移工具這一層攔截目標端的資料,並動態地將資料寫入到對應的叢集中。

方案效果實測

經過以上一系列的操作之後,我們成功使用Pika替換了原有的codis-server。那麼我們預先的兼顧成本與效能的目標是否有達成呢?

首先,在效能方面,根據線上業務方的使用反饋,當前總體的業務服務p99值為250毫秒(包括對Codis和Pika的多次操作),能夠滿足當前現網對效能的需求。

再看成本方面,由於儲存的key的資料結構類似,佔用的實際物理空間基本相同。通過將Pika的資料轉換成codis-server的儲存量,記憶體使用大概為24/482 = 480G的記憶體空間。 根據當前的運維經驗,如果實際儲存480G的資料,按照每個節點儲存10G資料,單節點最大15G,需要48個節點,即需要256G6臺機器(3主3從)提供服務。

這樣我們就可以得出結論:儲存同等容量的資料,使用Pika的花費成本僅為Codis的5~10%!

真誠的選型建議

我們還對Pika的單例項與Redis的單例項進行了效能壓測對比。

壓測命令為redis-benchmark -r 1000000000 -n 1000 -c 50時,效能表現如下:

壓測命令為redis-benchmark -r 1000000000 -n 1000 -c 100時,效能表現如下:

從測試環境的壓測結果來看,相對而言,單例項壓測情況下,Redis表現佔優;使用Pika的場景建議為kv型別效能較好,在五種資料結構裡面推薦使用String型別。

綜合壓測資料和現網情況,我們對Codis + codis-server和Codis + Pika兩種技術棧的優缺點進行了總結:

針對如上對比,我們的選型建議如下: ​

總結

以上是個推使用Pika替換codis-server,以低成本實現海量kv資料儲存與讀寫的實戰過程。 個推《大資料降本提效》專欄還將持續關注效能與成本的平衡之道,希望我們的實戰經驗能幫助大資料從業者們更快地找到大資料降本提效的最優解。

相關文章