最近在自己的工作中,把其中一個PHP項目的緩存從以前的APC緩存逐漸切換到Redis中,並且根據Redis所支持的數據結構做了庫存維護功能。緩存是在業務層做的,准確講應該是在MVC模型中Model的ORM里面。主要邏輯就是先查緩存,查不到的話再查數據庫。不過這些不是本文的主要內容,下面我把庫存管理功能的緩存設計思路分享一下,希望能帶給大家一些收獲,有不足之處或者有更好方案的,也希望各位多多指教。
一、業務背景
為了略去我們公司項目背景,我決定把這次的問題類比成一個考卷上的問題。至於業務細節,大家也無需關注~看題目就可以了:
假設你是某國最牛的收藏家,手里有各種價值連成的寶物。知道有一天,你覺得做收藏太沒意思了,打算把這些寶物賣掉換點現金。
不過把這些值錢的寶貝放在菜市場上賣實在太low了。在“互聯網+”時代,我們當然要玩一些不一樣的賣法:在你名下有一棟300個房間的大樓(編號為001至300),每個房間放着一個密碼鎖保險箱,在下個月(12月1日至12月31日)的每一天,你都會挑選300件最好的“極品寶物”(也稱作A類寶物),分別放入這300個房間的保險箱里,每天每個房間放什么寶物已經定好了,所有想買寶物的人必須至少提前一天在網上預定,到時候憑借預定碼自己打開保險箱取貨。沒有被預定的寶物將會被你收回,不再售賣。
要做這樣一個網絡預定系統,它的前端界面大概是這樣的:
上圖中三個要填的控件,單擊后可以出現選擇框。現在的問題是,一個房間只有一個寶物,不能被重復預定。所以當買家選擇了寶物類型和房間號之后,在選擇預定日期時,要在日期選擇框給用戶一個提示。比如12月3日051號房間已被預定,現在又有另一位用戶選擇了051號房間,那么在彈出日期選擇框時,12月3日要置為不可選。如下圖(12月3日顯示為“缺”):
那么,這樣一個簡單的庫存系統,如何在redis中存儲呢?
二、庫存管理方案(Redis)
最粗暴的想法是,我們的庫存其實就是一個很大的三維數組,第一維寶物類型,第二維房間號,第三維即預定日期。Redis支持5種存儲類型:String,Hash,List,Set,Sorted Set。目前的場景中Hash和Set類型都可以滿足要求,在此我們選擇使用Hash類型做存儲。
Redis的key設置為 寶物類型+房間號(例如 A:205,A代表極品寶物,205為房間號),Redis的value為hash類型,hash key為日期(例如 2016-12-05),hash value為true或false,表示已經被預定或沒有被預定。用圖表示為:
如果A類寶物158房間在12月8日已經被預定,則存儲為
Redis Key —— A:158 Redis Value —— hash table ['2016-12-08' => 1]
三、進階場景&庫存管理方案
你所推出的A類極品寶物很受歡迎,剛推出去不久即被預定出去很多。然而,動輒數十萬元的價格也讓很多有收藏興趣、卻沒那么富裕的中產階級望而卻步。於是,你又從自己的收藏中挑選出了比A類寶物稍次一些的B類寶物(也稱作“優質寶物”),價格更加親民。
由於B類寶物比A類寶物多一些,你打算換一種玩法,在這300個房間中,每個房間又放入了一個保險箱,這次,你每隔一個小時都會向300個房間的箱中各放入一件B類寶物,沒有被預定的寶物在這一個小時過后會被收回,換成下一個小時的寶物。買家預訂后,按照所預定的小時來取走寶物。對於B類寶物,你的預定系統會多了一個選項,即取貨時間。如下圖:
現在由於多了一個預定條件(取貨時間),那在做庫存存儲的時候,粗暴的方式想一下,庫存其實就是一個大的四維數組。第一維寶物類型,第二維房間號,第三維預定日期,第四維取貨時間。在Redis中怎樣存儲這類寶物呢?
其實仔細想一下,在存儲A類極品寶物的時候,我們在Redis中的存儲是有浪費維度的情況的,
當時hashValue只存了一個true表示有預定,這個維度其實是被浪費掉了。考慮到取貨時間全是整點,一整天也就是0至1點,1至2點,……,23至24點共計24種情況,所以我們完全可以使用二進制整數表示被預定的時間。例如1表示0至1點,2表示1至2點,4表示2至3點,……,
8388608 (= 2^23)表示23至24點。多個時間段被預定,只需要將數值取邏輯或操作即可。
這樣,我們的Redis結構變成了這樣子:
例如,B類寶物103房間,12月5日和6日的上午8點至12點被預定,在redis中存儲為
Redis Key —— B:103 Redis Value —— hash table ['2016-12-05' => 3840, '2016-12-06' => 3840]
對於B類寶物,在做新增預定時,需要注意先將原有的hash value取出,和新的預定取貨時間做邏輯或操作,然后再把結果寫回Redis中,而不能像A類寶物一樣直接調用hSet去設置hash value;取消預定時,要注意先將原有的hash value取出,把要取消的時間段從hash value中扣除掉(異或+邏輯與操作),然后重新將剩余的已預訂取貨時間寫回Redis中,而不能直接調用hDel去刪除。
四、再次進階&庫存管理方案
自從推出了B類寶物之后,你的生意又比以往火爆了許多。於是新的需求又來了,現在有大量的游客、學生黨等沒什么豐厚積蓄的人表示對你的寶物非常感興趣,來這個城市旅游的人都希望帶一些紀念品回去。然而,B類寶物的價格雖然比A類便宜一些,對於這些人來講還是有點貴。於是,你決定把自己余量最多的實惠寶物(C類寶物)拿出來售賣。
這部分寶物數量是最多的,於是你在這300個房間中,每個房間新增了100個寶箱,專門用於存放C類寶物。這100個寶箱分別被編號為1號,2號,……,100號。同樣的,每天的每個小時,你都會向這300個房間中,每個房間的100個寶箱中分別放入一件C類寶物(也就意味着,整個大樓每小時C類寶物會更新30000件)。如果沒有人預定,則下一個小時寶物更換。終於,這下可以滿足所有人的需求了。
對於C類寶物,你的預定界面成了下面的樣子:
我們又多了一個預定條件。此時,又面臨着庫存存儲的問題。照例,這個庫存其實就是一個大的五維數組,寶物類型、房間號、預定日期、取貨時間、寶箱編號各自占有一個維度。不過前面我們的Redis各個維度基本上已經占滿了,這次應該怎么存儲呢?
這次的Redis庫存存儲必須要結合業務特點來了。首先,寶箱編號和取貨時間這兩個維度,能取的值范圍並不太多,寶箱編號只有100個,只要把hash value變成一個長度為100的數組,數組的每個位置都存有INT類型表示的取貨時間即可。然而hash value只能是string……於是乎,只好做一個數組的序列化操作,讀取的時候再反序列化回來即可。好在長度只有100,序列化效率並不會成為系統的瓶頸。
例如,C類寶物,12月23日、24日,258房間,97和99號寶箱在11點至13點被預定,則存儲為:
Redis Key —— C:258 Redis Value —— hash table ['2016-12-23' => '[97 => 6144, 99 => 6144]', '2016-12-24' => '[97 => 6144, 99 => 6144]' ]
其中6144用二進制表示為‘110000000000’,hash value為數組序列化以后的字符串,實際項目中可以使用json格式。好了,現在Redis對於三種寶物的存儲都有了。
對於C類寶物,在用戶取消預定、新增預定時,同樣不能簡單地調用hSet和hDel進行覆蓋設置和刪除,要取出已經預定的情況,與已經預定的取貨時間做位運算。
五、存儲優化
庫存理論上就是一個多維數組,我們所做的主要工作就是怎樣把各個維度合理的存儲起來,並能夠方便地進行增加、刪除、查詢操作。從節約使用內存的角度講,在最開始還沒有任何人預定的時候,Redis整個可以是空的,對於A類寶物來說,hash value等於false和根本不存在對應的redis key或hash key是等效的。
另外,寶物類型和房間號合起來做redis key,會導致我們在redis中和寶物庫存相關的key的數量比較多,為了方便統一管理這些key,可以再增加一條redis緩存,專門用來存儲和寶物庫存相關的所有redis key值,如下圖所示。需要注意的是,這次我們並不需要hash數據類型了,set類型就已經足夠,增刪改查復雜度都是O(1)。里面存儲了所有redis中已經存在的庫存key值。
這么做的一個好處是,萬一哪天碰到一些特殊情況,需要把所有庫存相關緩存全部清空的話,我們可以很容易地取出所有的庫存key並做刪除操作。另外一個好處是,給我們提供了繼續擴展的思路……設想一下,現在最復雜的情況是C類寶物,一共5個維度。假設未來,你不再使用一幢樓的300個房間去售賣寶物,而是多幢樓,那么用戶在下訂單的時候又要多出一個維度——樓棟編號。碰到這種情況,我們完全可以將這個多出來的庫存Key集合退化為樓棟編號來使用,保證了可能出現的更復雜情況下的擴展性。
在做了這次擴展之后,每次新增預定記錄時,需要注意檢測庫存key集合中是否已經存在對應的redis key值,如果不存在需要將redis key值加入庫存key集合中。刪除操作也類似。
六、總結
上面使用了循序漸進的方法講述了一下問題,不過現實的場景中,這三種寶物類型在我們的業務中是同時存在的。上面的設計保持了三種寶物類型存儲上的統一性。如果只考慮A類寶物的話,庫存只有三個維度,其實完全不必使用hash數據類型來存儲,set類型就足夠了。
我們存儲這些預定情況的主要目的,就是為了方便快速地查到庫存沖突情況。比如有人已經定了12月3日,59號房間的A類寶物,那又有另外一個人想預定一樣的日期、房間的A類寶物時,通過內存中的庫存查詢,我們可以很方便地告訴客戶,該庫存已經被其他人搶先預定了。
以上就是我在業務中碰到的一個緩存設計的小問題,不吝賜教!