Glow Cache 構架
作為一家大數據公司,Glow每天都會收到海量的數據。這些數據的快速存取,是必須面對的一個問題。Cache,則是眾多解決方案中,最實用的一個。筆者將給大家介紹一下Glow的Cache框架,希望能對廣大創業團隊有所幫助。
什么是Cache
Wiki上說:a cache is a component that stores data so future requests for that data can be served faster
在絕大多數服務器框架中,就是用內存代替數據庫,以達到提高速度的目的。
傳統的Cache有兩種結構, write-through 和 write-back 。
- write-through :一個寫操作(write)同步更新cache和backend storage
- write-back :一個寫操作(write)只更新cache,當再有變化發生時,寫回backend storage
顯然, write-back 更高效,但更復雜。作為創業公司,我們首選 write-through :
Cache hit rate
在cache使用中,cache hit rate(命中率)是一個衡量cache的重要指標。
你不可能無限制地把所有數據都寫入cache中,cache system總會把一些數據丟棄。當一個“讀”操作訪問了不在cache中的數據,會產生一次backend storage讀,以及一次cache“寫”(寫回cache),我們稱之為cache miss。顯然,一個cache miss會比直接訪問backend storage有更大的開銷。因此一個成熟的cache系統,應該極大地降低cache miss,保持cache hit在 90% ,甚至更高。
Glow Cache 概括
不管是 write-through 還是 write-back ,任何一次“寫”操作,都會更新cache。而cache的目的,是提高“讀”操作的速度。細心的讀者或許會發現,這其中有一些邏輯上的問題:如果“寫”完的數據,不再被“讀”,不是白寫了?
的確。如果“寫”操作和“讀”操作相對平衡,那這樣的設計將會有比較好的效果。因為cache system會定期把數據丟棄。如果“寫”進cache的數據,有 90% 在被cache丟棄前就被“讀”過,那么hit rate就有 90% 。但實際生產環境中,我們發現,“寫”操作和“讀”操作并不平衡:
- 在用戶記錄健康信息的時候,“讀”操作遠遠低于“寫”操作,而“寫”進cache的數據,一部分會被馬上“讀”到,另一部分不會被馬上“讀”到。
- 而在用戶瀏覽論壇的時候,“讀”操作則遠遠高于“寫”操作,并且“讀”操作請求的數據,大部分都不是剛被“寫”過的。
為了達到 90% cache hit rate,提高cache的容量,是一個簡單粗暴的方式。這樣,“寫”進cache的數據,會存留更多的時間,隨時準備被“讀”。但隨著數據量越來越大,經濟成本會越來越高,即便一個創業團隊能負擔的起,也非常不劃算——性價比太低。
作為一個服務器構架師,創業公司的程序猿,應該采用最合適的技術框架,即解決問題又節約成本。于是,Glow的cache,是一種結合了 write-through 和 write-on-read 的框架。
Glow Cache: 當“寫”操作發生時,使用cache的程序員,有能力決定寫回cache,或刪掉cache,甚至任何其他操作:
- 寫回cache,就是 write-through 。
- 刪掉cache,當下次“讀”操作來時,再從backend storage里取。即所謂的 write-on-read
- 其他任何操作
在實際生產環境中,在用戶記錄健康信息的時候,會被馬上“讀”到的數據,我們采用 write-through ,比如 DailyDataCache ,后面會有例子;另一部分不會被馬上“讀”到的數據,我們采用 write-on-read 。
Glow Cache 實現
Glow Cache 使用Redis作為內存存儲解決方案 。這是一個在python界比較大眾化的解決方案。
Glow有復數臺服務器處理產品的業務邏輯,每一臺服務器都有復數個service,有些配有cache。
讓我們通過一個具體的例子 UserCache ,來介紹Glow的Cache結構。
class UserCache(object):
@property
def handlers(self):
return {
UserCacheEvent.USER_REMOVED: self.evict,
UserCacheEvent.USER_UPDATED: self.evict,
}
def __init__(self, conn, dbpool, notif):
self.conn = conn
self.dbpool = dbpool
[notif.subscribe(event, self.handle_event) for event in self.handlers.keys()]
def handle_event(self, event_name, event_obj):
func = self.handlers.get(event_name)
func(event_name, event_obj)
def evict(self, event_name, event_obj):
self.conn.delete(event_obj.user_id)
class UserCacheEvent(object):
USER_REMOVED = 'USER_REMOVED'
USER_UPDATED = 'USER_UPDATED'
def __init__(self, user_id):
self.user_id = user_id
Cache instance
class UserCache(object):
def __init__(self, conn, dbpool, notif):
self.conn = conn
self.dbpool = dbpool
[notif.subscribe(event, self.handle_event) for event in self.handlers.keys()]
每一個cache的實例,都配備有:
- 有連到Redis服務器的鏈接: conn ,用于支持內存讀寫。任何“讀寫”cache的操作,都需要操作 conn 。e.g, self.conn.set(user_id, {...}) 。具體命令可以翻閱Redis的官方文檔。
- 連接到數據庫的dbpool,用于支持數據庫讀寫。當cache miss發生的時候,需要從dbpool里取一個db connection,進而從數據庫里取相應的數據。
- 一個Notification Center的實例: notif 。并且向 notif 注冊(subscribe)所有用到的event。
Cache的更新
當“寫”操作發生時,Cache需要做相應的更新。Glow的Cache structure在更新時,會做如下幾件事:
(1) 首先,Cache在被實例化的時候,都向 notif 注冊了一系列event。在 UserCache 在這個例子中,有兩個event: USER_CREATED 和 USER_UPDATED 。他們的響應方法都是 evict
(2) 當User被更新時,構建一個 UserCacheEvent 的實例,并向 notif publish這個event:
def update(self, dbc, user_id, values):
# 更新數據庫
UserDBLogic(dbc).update(user_id, values)
# 更新cache
notif.publish(UserCacheEvent.USER_UPDATED, UserCacheEvent(user_id))
(3) Notification Center( notif )會依次調用每一個響應函數。 UserCache.evict 被響應,其中event_obj就是 UserCacheEvent(user_id) 這個實例, event_obj.user_id 就能取到被更新的 user_id 。而做的事情,就是在Redis里刪除這個user。
def evict(self, event_name, event_obj):
self.conn.delete(event_obj.user_id)
或許有人會問,Notification Center是每個進程一個,還是全局只有一個?
Glow擁有多臺服務器,每臺服務器都有多個service進程,每個進程都有自己的cache,自己的Notification Center。當某個進程接收了更新User的請求,publish了 USER_UPDATED event,只有當前進程Notification Center會接收到這個event,并執行相應的操作。但Redis是全局唯一的。任何“讀寫”Redis的操作,都會影響同一份數據。所以,即便只有當前進程的Notification Center響應了event,因為最終會修改Redis里的數據,這樣進程的cache再去讀Redis的話,也會讀到修改后的數據。
Cache的讀
當“讀”操作發生時,Cache應該做哪些事情?基本上,一個Cache應該實現以下方法:
- set(): a function to set a key with val into cache
- remove(): a function to remove a key
- get(): the interface function, to get value in cache or backend
- get_from_cache(): called by get(), try to get value from cache
- get_from_backend(): called by get(), get value from backend
其中, get() 函數的實現如下:
def get(self, key):
try:
return self.get_from_cache(key)
except KeyMissingError:
v = self.get_from_backend(key)
self.set(v)
return v
邏輯本身并不復雜,相信懂code同學一眼就能看明白。但實際生產環境中,這樣做有一個問題。一個很嚴重的問題,相信絕大部分同學都會遇到——進程安全。
Cache的進程安全
互聯網大數據創業公司中,很少有一臺服務器就能處理所有業務請求的例子,Glow也不例外。在任何分布式系統中,“數據”需要有一個唯一的源頭,一般來說,數據庫系統扮演了這個角色。但因為Cache的引入,Redis首先扮演了這個角色。不同之處在于,各種數據庫(mysql, oracle等等)都發展了幾十年,在并發響應,行鎖,表鎖,安全級別等等方面,都有很成熟的表現。而Redis,作為一個內存存儲解決方案,“并發安全”并不是它的特長。
理論上來說,Cache的讀和寫,都應該上鎖。
相信有服務器編程經驗的同學,一定會對進程間 上鎖,取鎖,釋放鎖,防止鎖死(dead lock) 等等,深惡痛絕。Glow作為一家創業公司,不可能做到盡善盡美。做90分的系統,適應99%以上的case。相信這也是大部分創業公司的選擇。
具體到 Glow cache 的例子,我們有選擇性的,適度的加鎖。
在實際生產環境中,的確會遇到好幾個進程處理同一個cache中的同一條記錄。分析了很多情況之后,我們總結如下:
-
首先,要認識到,當多個進程讀寫cache時,Redis中存的值,并不等于各個進程cache的返回值。因為Redis是全局唯一的,而各個進程自己管理cache instance。
-
當數據庫中有值,而cache.get()返回None的時候,是比較大的問題。因為,當業務代碼發現一條記錄不存在的時候,最多的操作是兩類:insert,會拋 DuplicatedEntry ;或直接拋異常( UserNotFoundException )。我們之后會通過例子分析。
-
當數據庫中有值,而cache.get()返回一個舊值的時候,這并不是一個很嚴重的問題。因為,這種情況經常發生在進程A在“讀”cache,進程B在“寫”cache。無論進程A“讀”到新值還是舊值,都是可以接受的。
-
當Redis中存的值,和數據庫中的值不一致的時候,比較復雜。理論上,它很嚴重,要解決它必須上“讀寫”鎖。但實際上,可以通過優化業務代碼,盡量避免有多個進程更新同一條row,且更新的值不同。
-
復數個cache進程同時訪問數據庫,并更新Redis,不是一個嚴重的問題。無非浪費一些機器資源。
-
復數個cache進程同時訪問數據庫,但 get_from_backend 執行很慢的情況下,就很嚴重。例如,有一個cache專門存 論壇中的關注者 。當同時有5個用戶在論壇里關注了某個大V,那么同時有5個進程要更新這個cache。可大V有非常多的關注者, get_from_backend 要執行10秒。于是這5個進程都要等待10秒的執行結果——盡管結果是相同的。而其他一些用戶請求本來只要0.1秒,卻無法得到執行。表現出來的現象,就是Glow所有的用戶請求都執行了10秒以上。事實上,完全可以只有一個進程執行 get_from_backend ,讓其他進程進入等待并釋放CPU資源,使得那些0.1秒的請求得以執行;在得到 get_from_backend 結果之后,喚醒另外4個進程,從 get_from_cache 里取結果即可。(這里會涉及到python的進程切換,不作展開)
帶鎖的cache讀
綜上所述,結合Glow 業務邏輯的實際情況,我們只給cache加了一道鎖: get_from_backend_with_lock 。而且,每一個cache可以自己選擇是否打開這個鎖。
對于其他情況,我們可以通過調整業務代碼,以減少沖突,又或是忽略不計。
自帶鎖:Redis的原子操作
前文提到,當數據庫中有值,而cache.get()返回None的時候,由于Glow的業務邏輯會導致很多異常,我們要盡量避免。而且,當這種情況發生的時候,我們發現Redis的值,并沒有和數據庫不一致。那么,為什么正常的業務邏輯,會產生cache的讀取錯誤呢?
我們先來看一下cache中 get_from_cache 的實現:
def get_from_cache(self, key):
if not self.conn.exists(key):
raise KeyMissingError(key)
else:
return self.conn.get(key)
如果你不是很精通服務器編程,可能看不出任何問題。但對于擅長服務器編程的同學來說,可能馬上就注意到了: self.conn.exists(key) 和 self.conn.get(key) 之間并沒有上鎖。在高并發的環境下,完全有可能被其他進程改變結果。實際生產環境中,的確如此。
進程1 進程2 進程3
| | |
| get_user |
| (redis key is set) |
get_user | |
cache.conn.exists? (Yes) | |
| | update_user
| | (redis key is removed)
cache.conn.get -> None | |
| | |
v v v
解決方案也很簡單,直接使用 conn.get 的返回值判斷是否存在,把 get_from_cache 變成原子操作:
def get_from_cache(self, key):
val = self.conn.get(key)
if val is None:
raise KeyMissingError(key)
return val
Glow cache的write-through
之前,我們都在介紹 write-on-read 。下面我通過另一個例子來給大家介紹 Glow cache 的 write-through
class DailyDataCache(object):
@property
def handlers(self):
return {
DailyDataCacheEvent.DAILY_DATA_UPDATED: self.daily_data_updated,
DailyDataCacheEvent.DAILY_DATA_CLEARED: self.evict_sub
}
def daily_data_updated(self, event_name, event_obj):
if event_obj.daily_data:
self.conn.hset(event_obj.user_id, event_obj.date, event_obj.daily_data)
else:
self.delete_subkey(event_obj.user_id, event_obj.date)
def evict_sub(self, event_name, event_obj):
self.delete_subkey(event_obj.user_id, event_obj.date)
class DailyDataCacheEvent(object):
DAILY_DATA_UPDATED = 'DAILY_DATA_UPDATED'
DAILY_DATA_CLEARED = 'DAILY_DATA_CLEARED'
def __init__(self, user_id, date, daily_data=None):
self.user_id = user_id
self.date = date
self.daily_data = daily_data
def upsert_daily_data(self, dbc, user_id, date, update_values):
# check cache before update
try:
exist_v = self.daily_data_cache.hget_from_cache(user_id, date)
except KeyMissingError:
exist_v = None
with dbc_transaction_block(dbc):
result = UserDailyDataLogic(dbc).upsert(user_id, date, update_values)
if exist_v:
# cache exist, we should update cache
notif.publish(DailyDataCacheEvent.DAILY_DATA_UPDATED,
DailyDataCacheEvent(user_id, date, result))
return result
在 DailyDataCache 中,有兩個event: DAILY_DATA_UPDATED 和 DAILY_DATA_CLEARED 。和 UserCache 不同,我們并不單純地只采用 evict 響應(這樣的話,就是 write-on-read ),而是在update的情況下,直接修改cache。
實際生產環境中,每一次的DailyData update,都會緊接著幾次DailyData read。如果用evict處理,至少會多一次數據庫讀( get_from_backend ),而這次讀是沒有必要的,因為upsert函數已經提供了必須數據。
細心的同學可能會發現,這么做也有一個隱含的問題:
- 當進程A和進程B同時更新一條DailyData的時候,可能會發生:A的數據先寫進數據庫,B的數據后寫進數據庫;但B的數據先寫進cache,A的數據后寫進cache。這樣會發生數據庫和cache不一致的問題。
這個進程安全問題,之前我們也提到了。Glow的解決方法是,盡量避免有A和B兩個進程,寫同一天的DailyData,并且數據不同。事實上,也沒有這樣的需求。如果哪一天,我們的業務邏輯會從產生這樣的情況,那就需要對 DailyDataCache.daily_data_updated 加鎖。
更進一步,在 Glow cache 的結構下,event的響應可以是任意一個函數。一個Cache的程序員可以利用這個函數,做任何事情,比如合并兩個row,多次數據庫查詢,等等。這就給了程序員更大的自由度。
另外,如果cache中本身并沒有這條數據,根據 write-through 的規則,我們不應該更新cache。所以增加了如下判斷:
def upsert_daily_data(self, dbc, user_id, date, update_values):
# check cache before update
try:
exist_v = self.daily_data_cache.hget_from_cache(user_id, date)
except KeyMissingError:
exist_v = None
# update database
if exist_v:
# update cache
小結
今天,筆者給大家介紹了 Glow cache 的框架,這是一種結合了 write-through 和 write-on-read ,并且給予了程序員一定自由度的 cache 框架。
它并不完美,有一些并發安全的問題,甚至開放的自由度也會帶來一些cache數據一致性問題。但它能解決 Glow 99% 以上的需求。一個脫離實際業務邏輯,大而全cache框架,不符合創業公司的實際情況。希望 Glow 的 cache 框架能為創業伙伴們帶來一絲靈感,解決一些問題。也希望和廣大程序猿一起討論,進步。
來自: http://tech.glowing.com/cn/glow-cache-structure/