前言 近幾個月一直在忙些瑣事,幾乎年後都沒怎麼閑過。忙忙碌碌中就進入了2018年的秋天了,不得不感嘆時間總是如白駒過隙,也不知道收穫了什麼和失去了什麼。最近稍微休息,買了兩本與技術無關的書,其一是Yann Martel 寫的《The High Mountains of Portugal》(葡萄牙的... ...
分散式系統之緩存的微觀應用經驗談(一) 【基礎細節篇】
前言
近幾個月一直在忙些瑣事,幾乎年後都沒怎麼閑過。忙忙碌碌中就進入了2018年的秋天了,不得不感嘆時間總是如白駒過隙,也不知道收穫了什麼和失去了什麼。最近稍微休息,買了兩本與技術無關的書,其一是Yann Martel 寫的《The High Mountains of Portugal》(葡萄牙的高山),發現閱讀此書是需要一些耐心的,對人生暗喻很深,也有足夠的留白,有興趣的朋友可以細品下。好了,下麵回歸正題,嘗試寫寫工作中緩存技術相關的一些實戰經驗和思考。
正文
在分散式Web程式設計中,解決高併發以及內部解耦的關鍵技術離不開緩存和隊列,而緩存角色類似電腦硬體中CPU的各級緩存。如今的業務規模稍大的互聯網項目,即使在最初beta版的開發上,都會進行預留設計。但是在諸多應用場景里,也帶來了某些高成本的技術問題,需要細緻權衡。本系列主要圍繞分散式系統中服務端緩存相關技術,也會結合朋友間的探討提及自己的思考細節。文中若有不妥之處,懇請指正。
第一篇這裡嘗試儘可能詳細的談談緩存自身的基礎設計應用,以及相關的操作細節等(具體應用主要以Redis 舉例)。
一、提下緩存的分類和基本特點(本文主要指服務端數據緩存)
1.1 一種區分
緩存基於不同的條件有很多種劃分方式,本地緩存(Local cache)和分散式緩存(Distributed cache)是一種常見分類,兩者自身又包含很多細類。
本地並不是指程式所在本地伺服器(從嚴格概念來說),而是更細粒度的指位於程式自身的內部存儲空間,而分散式更多強調的是存儲在進程之外的一個或者多個伺服器上,彼此交互通信,在具體軟體項目的設計和應用中,多數時候是混合一體。
(當然,個人認為對緩存本質的理解才是最重要的,至於概念上的分類只是一個不同理解下的劃分而已)
1.2 一些技術成本
在具體項目架構設計時,單純使用前者(本地緩存)的開發成本毋庸置疑是極低的,主要考慮的是本機的記憶體負載或者極少量的磁碟I/O影響。而後者的設計初心是為了利於分散式程式之間緩存數據的高效共用和管理,除了考慮緩存所在伺服器自身的記憶體負載,設計時更需要充分考慮網路I/O、CPU的負載,以及某些場景下的磁碟I/O的代價,同時還在具體設計時儘可能規避和權衡整體穩定性和效率,這些不僅僅只是作為緩存伺服器的硬體成本和技術維護。 需要謹慎考慮的底層問題包括緩存間通信、網路負載和延遲等各種需要權衡的細節。
其實如果理解了緩存本質就該知道,任何存儲介質在適當的場景下都可以充當一個高效的緩存角色併進行項目集成和緩存間集群。常見主流的Memcached和Redis等均是屬於後者範疇,甚至可以包括如基於NoSql設計的MongoDB這類文檔資料庫(但這是從角色角度講,而狹義劃分上這是基於磁碟的存儲庫,需要註意,各有專攻)。這些第三方緩存在進行項目集成和緩存間集群,也需要解決一些問題。甚至項目迭代到了後期階段,往往還需要具備較高專業知識的運維同時參與,並且在開發中的邏輯設計和代碼實現也會增加一定的工作量。所以有時候在具體項目的設計上,一方面要儘可能預留,一方面還得根據實際情況儘可能精簡。
額外說下其他體會:在個人有限的技術學習和實踐里,關於節點數據交互,尤其是服務間通信,是不存在完美的閉環的,理論上也都是在“當前階段”面向“高一致”的權衡罷了(大概跟生活是一樣的吧,呵,寫偏了)。
二、關於緩存資料庫結構的一些設計細節
(由於目前個人工作中大多數情況應用的是Redis 3.x,以下若有特性關聯,均是以此作為參照說明。)
2.1 實例(Instance)
根據業務場景,公共數據和業務耦合數據,一定分別使用不同的實例。如果是單實例,才可以考慮以DB劃分。當你使用的是Redis,那麼DB在Redis里是有數據隔離,但沒有嚴格許可權限制,所以劃庫只是一種選擇。在Cluster集群里則是保持預設單個庫,不過實際中我會嘗試根據項目大小來調整,至於在哪個開發階段則是作為預留設計。
額外需要註意的是,作為重度依賴伺服器記憶體的緩存產品,如果開啟了持久化(後面會提到),並且在為併發量極大的服務提供支持時,伺服器硬體資源會出現大量搶占,請結合持久策略配置,考慮實例是否進行分盤存儲。持久化本質是將記憶體數據同步寫入硬碟(刷盤),而磁碟I/O實在有限,被迫的寫入阻塞除了造成線程阻塞和服務超時,還會導致額外異常甚至波及其他底層依賴服務。當然,我的建議是,如果條件允許,最好是在項目初期設計時就進行規劃並確定。
2.2 緩存“表”(Table)
一般緩存中並沒有傳統RDBMS中直觀的表概念(往往以鍵值對“KV”形式存在),但從結構上來講,鍵值對本身就可以組裝為各種表結構。一般我會先生成資料庫表關係圖,然後分析什麼時候存儲字元串,什麼時候存儲對象,然後使用緩存鍵(KEY)進行表和欄位(列)分割。
假定需要存儲一個登錄伺服器表數據,包含欄位(列):name、sign、addr,那麼可以考慮將數據結構拆分為以下形式:
{ key : "server:name" , value : "xxxx" }
{ key : "server:sign" , value : "yyyy" }
{ key : "server:addr" , value : "zzzz" }
需要註意的是,往往在分散式緩存產品中,例如Redis,存在多種數據結構(如String、Hash等),還需要根據數據關聯性和列的數量,來選擇對應緩存的存儲數據結構,相關存儲空間和時間複雜度是完全不同的,而這個在初期階段是很難感受到的。
同時,就算緩存的記憶體設置的足夠大,剩餘也很多,也同樣需要考慮類似RDBMS中的單表容量問題,控制條目數量不能無限增長(比如預知到存儲條目可以輕鬆達到百萬級),“分庫分表”的設計思路都是相通的。
2.3 緩存鍵(Key)
上面提到了基於緩存鍵來設計表,這裡再單獨說明一下鍵相關的個人規範。在鍵長度足夠簡短的前提下,如果關聯相同業務模塊,則必須設計為以同一個標識(代號)開頭,目的是方便查找和統計管理。
如用戶登錄伺服器列表:
{ key : "ul:server:a" , value : "xxxx" }
{ key : "ul:server:b" , value : "yyyy" }
另外,每個獨立業務系統可考慮配置一個唯一的通用首碼標識,當然,這裡不是必需,若實際工作中,如果使用的是不同庫,則可以忽略。
2.4 緩存值(value)
緩存中的值(這裡指單一條目)的大小沒有平均標準,但Size自然是越小越好(若使用的是Redis,一次操作的value較大會直接影響整個Redis的響應時間,不僅僅是指網路I/O)。如果存儲占用空間直達10M+,建議考慮關聯的業務場景是否可以拆分為熱點和非熱點數據。
2.5 持久化(Permanence)
上面也簡單提了下,一般來說,持久和緩存本身是沒有直接關係的,可以粗略想象為一個面向硬碟一個面向記憶體。但如今的Web項目里,有些業務場景是高度依賴緩存的,持久化可以一方面幫助提高緩存服務重啟後的快速恢復,一方面提供特定場景下的存儲特性。當然,由於持久化必然需要犧牲一些性能,包括CPU的搶占和硬碟I/O影響。不過大多數時候是利大於弊,建議在應用緩存的時候,沒有特別情況的話,儘量搭配持久化,無論是使用自身機制還是第三方來實現。
如果是使用的Redis,其自身就具備相關持久策略,包含AOF和RDB,我在大多數情況下是兩者同時配置的(當然,最新官方版本本身也提供了混合模式)。如果在一些非高併發的場景下,或者說在一些中小項目的管理模塊里,僅僅只是作為優化手段,確定了不需持久,也可以直接設置關閉,節約性能開銷損耗,但建議在程式中將該實例做好標註,確保該實例的公共使用範圍。
2.6 淘汰(Eliminate)
緩存如果無限制的增長,即使設置了較短的過期(Expiration ),在一些時間點上,高併發的一批大數據會在較短時間內就達到了可使用記憶體的峰頂,此時程式中與緩存伺服器的交互會出現大量延遲和錯誤,甚至給伺服器自身都帶來了嚴重的不穩定性。所以在生產環境里儘量給緩存配置最大記憶體限制,以及適當的淘汰策略。
如果使用的是Redis,自身淘汰策略選擇比較靈活。個人的設計是,在數據呈現類似冪律分佈情況下,總有大量數據訪問較低,我會選擇配置allkeys-lru、volatile-lru,將最少訪問的數據進行淘汰。再比如緩存是作為日誌應用的,那麼我一般是項目前期是配置no-enviction,後期會配置為volatile-ttl。當然,我也見過一種特殊業務下的設計,緩存直接用來作為輕量的持久資料庫使用,而且是終端,開始覺得有些新奇,後來發現是非常符合業務設計的(比如幾乎沒有任何複雜邏輯和強事務)。所以合情合理,確實不應該禁錮在傳統設計里,畢竟架構總是基於業務去實時組合和改變的。
三、緩存的基礎CURD和其他相關(在這裡我主要討論一級緩存)
3.1 新增(Create)
如果沒有特殊業務需求(如上面提到的),插入必須設置過期時間。同時,儘量保證過期隨機性。如果是進行批量緩存,則個人的做法是保證設置的過期時間上至少是分散的,目的是為了降低緩存雪崩等風險和影響(關於這些我會在以後的擴展篇里嘗試闡述)。
如,批量緩存的對象是一個結果集,條目有10萬條,緩存時間基礎為 60*60*2(sec),現在需要同時進行緩存。我的做法是預設生成一個隨機數,如random(範圍 0 - 1000),過期時間則設置為( 60*60*2 + random ) 。
3.2 修改(Update)
更新一條緩存的數據,註意是否需要重新調整過期時間。同時在很多場合,如多個緩存間同步時,建議直接刪除該緩存,而不是更新緩存。修改操作很多時候是關聯到DB間的同步操作的,相對考究的多一些,需要權衡分散式事務上的問題,後續文章里會寫到。
3.3 讀取(Read)
查找緩存時,如果存在多條,並確定數據量不大,務必使用嚴格匹配key的模式,而儘量不要使用通配符方式。雖然發送指令的key數據變長了,但卻避免了不必要的緩存內的搜索性能損耗。
例如單純相信Redis里自身的存儲優化,無限制的使用 keys pattern而不考慮時間複雜度,同時造成大量線程阻塞(這裡與主從複製無關)。如果折中使用scan分頁替代,也並非一種“無憂”的實現,一是需要在程式代碼的封裝里設置較低的容量,二是請務必在程式邏輯里對數據幻讀等潛在問題做相關的管控處理。
另外可以額外類比一種場景,操作DB中的大表,命中的熱點數據分佈靠後。
3.4 刪除 / 清空(Delete / Clear)
刪除緩存,一般有直接移除和設置時間過期(並不是任何時候都是滑動增加過期)兩種方式,沒什麼細節上的說明。(倒是聽過一種特殊業務場合,批量請求同類數據,並且即時性沒有很高要求,設置過期時間並將時間稍作分散。)
清空緩存,我在項目里目前並未應用,甚至也不提倡直接使用。但是假如在應用時,需要慎重考慮兩個地方。一是清理時機,二是清理時效(若在Redis里,無論是flushdb或者flushall,都會形成一定阻塞)
3.5 鎖/信號(Locking)
本身無關緩存,屬於一些併發特性實現,有一定的適用場景。這在Redis中有一些基於原子的實現,但與本系列討論無關。本人去年寫過一篇與之相關的分享,詳見:商城系統下單庫存管控系列雜記(二)https://www.cnblogs.com/bsfz/p/7824428.html),但這裡不贅述。
3.6 發佈-訂閱(Publish-Subscribe)
為什麼提到這個跟生產消費(Produce-Consume)相關的動作呢?這個機制本身是不屬於緩存自身的範疇的,而是更相關於消息隊列(Message Queue)。之所以提到,是因為如今主流的緩存產品都自帶這一特性,很多場景使用起來較方便,配置也簡單,效率也夠快。只是,往往會造成濫用。最關鍵是不必要的強耦合也降低了整體靈活性和性能,擴展性也實在有限。當然,這是我目前的看法。
我的建議是:如果沒有特殊的場景應用,儘量不使用。至少本人是不會優先推薦使用緩存自身的發佈訂閱的,甚至在緩存集群系統中,需要考究的細節更多。而推薦的方式是,使用其他專業中間件解決,如基於MQ的產品替代方案。具體的候選有優秀的開源作品如RabbitMQ、Kafka等,包括有朋友提到的近兩年國內阿裡研發的RocketMQ等等,但是個人目前使用較多的依然是RabbitMQ。當然,這裡不去過多贅述了,根據場景選擇,合適的場景選用最合適的技術方案即可吧。
結語
本篇先寫到這裡,下一篇會圍繞相關主題嘗試擴展闡述。
PS:由於個人能力和經驗均有限,自己也在持續學習和實踐,文中若有不妥之處,懇請指正。
【預留占位:分散式系統之緩存的微觀應用經驗談(二)【交互場景篇】https://www.cnblogs.com/bsfz/p/9568951.html】
End.