續上篇《Web開發基本准則-55實錄-Web訪問安全》。
Web開發基本准則-55實錄-緩存策略
鄭昀 創建於2013年2月
鄭昀 最后更新於2013年10月26日
提綱:
關鍵詞:
會話串號,Cache-Control頭域,緩存穿透,緩存集體失效,緩存重建,緩存雪崩,緩存永不過期,緩存計數器,
二,緩存策略
這里的“緩存”概念不只限於服務器端的“緩存”。
2.1.防會話串號
如果你收到一個投訴,說訪問“我的個人中心”頁面時進入其他人的帳號,至少訂單列表上顯示的不是自己的。此時,技術支持人員可以提三個問題,第一,對頁面上顯示的信息是否有操作權限,如取消訂單;第二,瀏覽器地址欄上給URL增加訪問參數,如追加一個&111之類的字符串,看看頁面是否還是顯示別人的信息;第三,投訴者上網接入方式是什么,如鐵通光纖寬帶,如通過某款代理軟件上網。
如果既無操作權限,追加URL參數后又能看到自己的帳號信息或頁面提示處於未登錄狀態,那么說明是
URL已被各級 HTTP Proxies 緩存:
即
在服務器端收到 Request 之前,網絡鏈路上的某一級代理已返回緩存數據。
2.1.1.簡單辦法,如利用Expiration Model
第一種:如果頁面 Response 里設置了正確的 Last-modified 和 Expires 頭域,那么
基本過期模型 已經能正常運轉了,因此,頭域里的 Cache-Control:private 聲明就已經夠了,HTTP Caches 和 User Agent 都會根據這兩個字段檢查緩存網頁是否陳舊。
第二種:重要頁面的URL上加時間戳參數。
第三種:像淘寶博文[
注1]所描述的:“
cookie 里增加一個值,用來記錄通過關鍵 cookie 計算出來的簽名,這個簽名的算法非常簡單。
每次請求到服務端的時候 session 框架代碼里會對此簽名進行匹配,如果和服務端獲取的數據簽名出來的值是一致的,則認為合法,
否則清空 session 信息和 cookie 信息,讓用戶重新登錄。”
2.1.2.需要有更多背景知識的辦法:利用 Cache-Control 頭域控制
Web開發工程師都需要了解 Cache-control 頭域背后的 HTTP 1.1緩存控制機制和緩存重驗證機制。
先說處理辦法是:含個人敏感信息的網頁響應頭里,聲明
Cache-Control:must-revalidate,proxy-revalidate,no-store,private,no-cache 即可。
HTTP1.1協議定義,Response 是可以被各種 HTTP caches 緩存的。
除非有 Cache-Control 控制指令的特殊約定,否則從瀏覽器端到源服務器(origin server)端之間鏈路上所存在的各種 Caching System 都完全
有可能緩存一個成功的 response:
如果這個 cache entry 是 fresh 的,
可能不會(去源服務器端)校驗直接返回;也
可能會做一個校驗再返回。
一個狀態碼是200, 203, 206, 300, 301, 410的 response,可能會被緩存。
2.2.緩存穿透
- 目的
- 防止訪問(短期內)必然不存在的數據導致請求穿透緩存直接打到 DB。
- 原因
- 可能是數據真的不存在,但也可能是第三方惡意構造大量不存在的 id 來沖擊 DB。
- 多種手段結合
- 『存儲EMPTY』思路:存儲一個 EMPTY 對象到緩存對應鍵值,設置一個較短的過期時間。這樣在緩存失效后,還能繼續查詢數據是否存在。
- 必須認真對待(不同業務不同端口的)緩存命中率(get_hits/cmd_get * 100)定期監控的結果,認真審視那些命中率低的緩存端口,找到命中率低的原因,提出優化方案。
- 『先行校驗』思想:采用布隆過濾器算法,將所有可能存在的數據(如所有有效商品的id)哈希到一個足夠大的 bitmap 里,那么一個一定不存在的數據會被這個 bitmap 攔截掉,從而避免了對底層存儲系統的查詢壓力。(出處)
2.3.”半緩存“策略
緩存命中率低,其中一個原因是,
你緩存的數據被人訪問間隔長、幾率低,於是在下次訪問到來之前緩存早已失效。命中率低,為我們指出了優化方向。
如,用戶在查詢一個列表頁時,我們可以把前6頁的數據緩存起來,再往后的頁碼,訪問頻次很低,也許就不需要緩存了。(
出處)
2.4.緩存集體失效
以下原因都會導致緩存集體失效,從而引發系統”抖動“甚至”雪崩“:
- 系統預熱數據的緩存過期時間過於整齊划一;
- 緩存系統宕機或重啟;
- 訪問高峰期間種下了一大批緩存,過期時間非常接近。
處理手段:
- 緩存過期時間散列開:在過期時間基礎上增加一個隨機值,如1秒~120秒隨機,將大家的過期時間盡量打散。
- 防范緩存節點暫不可用的緩存雙寫策略:
- memcache雙寫:向 memcahce 的 Master Ring 和 Backup Ring 雙寫,如下圖1所示:
圖1 memcache 雙寫 原圖出自點評技術PPT
- Redis備份寫:向 memcache 寫入的同時,寫一份到備份緩存 Redis 里,鍵值的緩存過期時間非常大,如原鍵值在 memcache 過期時間5分鍾,在 Redis 里則8小時過期。當 memcache 集群節點暫不可用時,Web工程就切換讀取備用緩存 Redis。這種思路是保證基本可用性,所以必要時刻可以給用戶返回臟數據。
- 對於不同的業務場景,緩存的使用策略也不同:
- 當系統面臨緩存異常的危險時,有些系統可以采用備份方案繼續支撐服務。有些系統則會優雅降級,將某些依賴緩存的功能直接去除,保證主服務的正確性。所以這兩種策略的選擇需要根據實際的業務場景考慮並實施。(出處)
2.5.分級緩存
有些業務場景里,應該把 DB 當成僅是一個存儲而已,靠分級緩存策略來層層抵擋緩存失效,不讓請求打到 DB。
- 手段:
- 由遠及近分層建立緩存,越靠近前端,緩存片段越大(或存儲粒度越大)。
- 上一層的緩存失效,可以靠下一級的緩存迅速重建。
- 目的:
- 避免系統產生抖動。
- 減少緩存雪崩,防止 DB 連接數暴漲、響應變慢,連累前端應用連接數持續高漲、最后宕機。
2.6.緩存重建
既然有緩存過期,自然有緩存重建。
熱點數據的緩存重建,無論是本地緩存還是遠端緩存,都有必要加鎖來確保進程內同一時刻只有一個 Worker 負責重建,甚至利用
分布式鎖保證集群環境下只有一個重建者,避免緩存雪崩時的 Race Condition。TimYang 早在2010年在《
Memcache mutex設計模式》中描述過如下風險:”
在大並發的場合,當cache失效時,大量並發同時取不到cache,會同一瞬間去訪問db並回設cache,可能會給系統帶來潛在的超負荷風險。我們曾經在線上系統出現過類似故障。“孫立將這種場景稱為 cache key mutex 問題[
注7]。
圖3 cache key mutex 問題的解決(圖出自 http://www.cnblogs.com/sunli/archive/2010/07/27/cache_key_mutex.html)
簡而言之,緩存重建時,當多個 Client 對同一個緩存數據發起請求時,會在客戶端采用加鎖等待的方式,對同一個 CacheKey 的重建需要獲取到相應的排他鎖才行,只有拿到鎖的 Client 才能訪問數據庫重建緩存,其他的 Client 都需要等待這個拿到鎖的 Client 重建好緩存后直接讀緩存。這樣,對同一個緩存數據,只有一次數據庫重建訪問。但是如果訪問分散比較嚴重,還是會瞬間對數據庫造成非常大的壓力。
當然也可以不加(悲觀)鎖,那么多線程並發讀寫同一個 cache key 可能會帶來“ABA問題”。
解決方法很簡單:memcached 1.2.5以及更高版本提供了 gets 和 cas 命令。如果使用 gets 命令查詢某個鍵值,memcached 會返回該鍵值的唯一標識 casUnique。如果覆寫了這個鍵值並想把它寫回到 memcached 中,可以通過 cas 命令把那個 casUnique 一起發送給 memcached。如果該鍵值存放在 memcached 中的 casUnique 與提供的一致,寫操作將會成功。如果另一個進程在這期間也修改了這個鍵值,那么該鍵值存放在 memcached 中的 casUnique 將會改變,寫操作就會失敗。
2.7.緩存永不過期
因為擔心緩存失效帶來的系統抖動,所以有些業務場景會讓緩存永不過期,數據變化時,由后端負責維護緩存數據一致性。
2.8.電商場景里的緩存計數器:秒殺和超賣
我們在秒殺和防超賣場景里的實現邏輯類似於淘寶這篇博客[
注3]所提及的”分布式緩存計數器“,所以我就直接照搬過來了:
分布式緩存的另一個應用場景是緩存計數器。
對於多服務器的系統,分布式緩存提供了統一的存儲和原子操作,便於集群環境下的使用。庫存計數器是分布式緩存的一個典型應用場景, 對於集群中的每一台機器,庫存都應該是一個統一的值,因此使用本地緩存記錄庫存,數據肯定是不准確的(下面會陳述例外情況)。因此,統一的存儲空間是必要 的條件。
由於庫存數據被多台機器共享,因此,必須使用鎖機制控制多個請求的並行並發問題。基於這樣的機制就可以實行庫存技術器的作用,防止貨物超賣。最近的積分商城超值兌換就是使用的這種機制。
這種機制下,需要注意操作的邏輯順序,錯誤的順序會導致意想不到的結果。積分兌換的業務流程為,用戶看到要搶兌的商品,如果庫存大於0,則用戶可以點擊搶兌操作,這時用戶會獲得兌換該商品的權限,從而優惠購買,這時庫存商品應該減一。
如果完全按照這個業務流程,我們會完成下面這三步操作:
- 驗證庫存是否大於0;
- 給用戶打標,使其獲得優惠購買資格;
- 獲得資格后,原子減庫存,記錄用戶購買記錄。
乍一看這樣的邏輯是很正常的,但是考慮一下異常情況,就會發現它防不住超賣。如果庫存只有一件,那么多個用戶並發驗證庫存時,都大於0。這樣並發的多個用戶都會獲得優惠資格,產生了超賣。
正確的邏輯為:
- 驗證庫存是否大於0,小於0直接返回;
- 原子減庫存,返回的結果如果小於0說明已經沒有庫存,直接返回;
- 如果返回的當前庫存大於等於0,為用戶打標,如果打標成功,記錄用戶購買記錄;如果打標失敗,回補原子庫存。
這樣的方法,無法保證緩存中的值一定大於等於0,因為並發的發生會把緩存減為負數,但是,真正能夠優惠購買的用戶一定是小於等於庫存數的。因為,每次原子減操作后,只有返回的庫存值大於等於零的用戶才能夠獲得購買資格。無論並發量有多大,原子操作都會成功的防止超賣的發生。
|
對於上述的邏輯,可以應對絕大多數的情況。
但是隨着量的增加,這種方式也有風險。當用戶量極大、貨物的庫存極少時,就變成了秒殺。這個時候,大量的用戶涌入分布式緩存減庫存,對分布式緩存有極大沖擊,一旦分布式緩存掛掉,秒殺活動也就宣告失敗。使用分布式緩存,目的是為了讓用戶准確的看到剩余庫存數 目,秒殺活動非常快,用戶還沒有看清楚庫存,活動就結束了。其實用戶只關心有沒有秒到商品,並不關心庫存的剩余數量,因此,庫存減得准不准確並不是主要矛盾,這時就可以放棄分布式緩存的設計,轉而使用本地緩存存儲庫存數,這也就是本地緩存使用的第二個場景。
比如,一共有10件商品,2台機器,可以設置每台機器的本地內存中庫存等於10,那么對於外網的千萬個用戶,就可以有20個人搶到商品,剩下的人都 被擋在庫存之外。當這20個人搶到后,就可以實現另一個處理邏輯,從20個人中選出10個真正中標的人,獲得10個商品的購買權限。這個選擇的邏輯非常靈活,可隨意定制。但是從20選10的操作,無論如何也比從千千萬萬個人中選10要好的多,這樣可以確保秒殺的安全完成。
如果秒殺的人繼續增多,那么也可以通過客戶端(即javascript)設置格擋率的方法,使少量的用戶可以發出請求到服務器,絕大多數的用戶都被擋在瀏覽器上。(注:一些技術人士在2013年吐槽小米網站搶購小米手機時,瀏覽器模擬排隊等待其實沒有發出任何網絡請求,這就是客戶端格擋率生效的結果。)
|
-未完待續-
備注參考資源:
贈圖幾枚: