Lua 中 Cache 冷數據的落地
今天有同學跟我討論了一下最近發現的一個 bug ,我覺得挺有意思的。
需求是這樣的:
我們的系統中,有一些數據是從外存(數據庫)加載進來的,由于性能考慮,并不需要每次修改這些數據就寫回外存。希望在數據變冷后,定期落地即可。
典型的場景是一個 cache 模塊,cache 的是一些玩家的業務數據,可以通過 uuid 從數據庫索引到。一旦業務需要訪問玩家數據,cache 模塊會從數據庫加載對應數據,然后把數據表交出去。當業務再次需要這些數據的時候,cache 模塊一旦發現數據存在于 cache 中,就直接交給玩家。
cache 模塊還希望在數據很久沒有被業務訪問時,將這些數據寫回數據庫。
我們的系統是基于 lua 構建的,數據 cache 模塊和修改這些數據的邏輯在同一個 vm 里。難點在于,修改數據的業務邏輯是可以長期持有數據的,cache 模塊需要正確感知這點。
先來看看最樸素的實現方法:
cache 模塊其實就是一張 uuid : 數據 表。加載數據的時候,檢查 cache 中是否存在,如果沒有就把數據寫到 cache 表中。然后刷新一下數據的時間戳,表示最后訪問的時間。
數據落地流程是:從隊列中取出一個超時(最后一次訪問過于久遠的)待落地數據,擦除 cache 對應項,標記 uuid 為鎖定狀態(阻止加載流程在落地過程重新加載),落地,完成后解鎖(并喚醒潛在的加載需求)。
這個方法可以實現在使用數據的過程中,如果有新的訪問需求,是不需要從數據庫加載,并貢獻內存中的同一份數據對象的。
但是這個方法是有漏洞的,因為訪問時間久遠,并不意味著沒有人持有它。而落地前的鎖只能阻止加載的沖突,不能阻止持有數據的人在落地過程中改寫數據。
為了解決這個問題,我們之前采取了一個改進方案。
使用 lua 的弱表來管理 cache 。在沒有人引用數據后,弱表中對應項會消失,此時才是數據落地的最佳時機。因為不會有改寫者干擾這個流程,僅僅鎖住新的加載會引起的沖突即可。注:如果需要定期落地,只需要定期把數據復制出去落地即可。
直接給數據塊加上 __gc 方法,在 gc 流程中做數據落地是不可行的。因為不提倡在 __gc 方法中做過于復雜的工作。所以我們只是在 __gc 中把對象重新放回一張叫 save 的待處理表,即讓這個數據表“復活”了。所謂復活,指在之前的 gc mark 流程,它已不被 vm 里除 __gc 方法外的任何地方引用,但是在 sweep 階段,又被重新塞會 vm 中,并不真的被 sweep 掉。關于這個用法,lua 實現的很好。
之后,落地流程可以慢慢的逐個處理 save 表。這個過程中,如果有業務需要訪問數據,那么它可以同時檢查 cache 表和 save 表里是否有數據,如果存在于 save 表中,則移回 cache 表。
方案看起來不錯。
當數據正被引用時,它總是存在于 cache 表中,不同地方的多次訪問會取到同一個引用。
只有當數據沒有任何業務引用時,它才會從 cache 表中移走,有另一個落地流程會逐步處理這些不被人引用的數據。這可以防止在落地流程中,有業務對數據修改。
在待落地處理的數據尚未處理時,如果有新的訪問需求,那么會搶在落地前拿回來,整個系統中每份數據的引用還是一致的。
鎖只需要加在數據落地和數據加載上,防止數據落地的過程中,同時加載數據。
但今天有同學報告了這個方案的 bug 。
問題出在 gc 把未引用的數據從 cache 這張弱表里抹掉的操作,和被抹掉的數據的 __gc 方法將其加回 save 表這兩者并不在一個原子操作內。
也就是說系統會處于某種第三狀態。一個 uuid 對應的數據并不在 cache 表中,也不在 save 表中,從業務邏輯上看,這組數據從 vm 中消失了。
當一組數據處于第三態時,如果此刻發生了訪問請求,那么就會觸發加載流程,從數據庫加載一個老版本(新版本尚未落地)。
怎么解決呢?
直接的方法是,當一組數據加載時,我們把 uuid 記錄在一個獨立集合中;只有在它真正被落地/丟棄處理后,才從這個集合抹掉。
這樣數據無論處于三種狀態中的什么狀態,我們都可以阻止已經處于系統中的數據再次從外存加載一個舊版本。
但一旦處于第三態的數據被請求,我們似乎沒有什么好的方法把它從第三態拉回來。因為它的的確確從 vm 中(暫時)消失了。能做的只有等。說到等,這和等數據從數據庫加載似乎沒有本質區別。我們只能在有等待操作期間,不斷的調用 gc 的 step ,督促 gc 過程進行下去,直到(也肯定能等到)數據從第三態出來,進入 save 表。(lua 的 gc 默認行為是只有新的內存申請發生,才可能發生下一步的行動)
有沒有別的方案?
利用元表給數據訪問加一個間接層能從另一個角度解決這個問題。
如果我們給需要 cache 數據表加上一層代理,讓代理的 __index 和 __newindex 都指向真正的數據表。那么業務用起來就是完全一樣的。
cache 表里放的僅僅是 uuid : 代理對象。
另外,將真正的數據表全部放在一張額外的 all 表中,并不讓業務層直接接觸這張表。
業務層不再引用某個 uuid 對應的數據時,cache 中消失的其實是代理對象,而不是真正的數據表。真正的數據表依然存在于 all 表中。
落地流程要處理的其實是 all 和 cache 的差集。它可以定期把差集求出來,放入 save 表中去處理。注:這里依舊生成一張 save 表,而不是直接對差集處理,是因為求差集時刻在變化,而我們無法一次將所有的差集里的數據全部落地。
ps. 以上的方案都隱含著另一個問題沒有解決:如果業務私自保留了數據表中的部分子表的引用,cache 模塊是無法感知的。不過這點比較容易通過約束業務的使用方法來回避。
來自:http://blog.codingnow.com/2016/11/cache_data.html