緩存一致性策略以及雪崩、穿透問題
一. 緩存原理
高并發情境下首先考慮到的第一層優化方案就是增加緩存,尤其是通過Redis將原本在數據庫中的數據復制一份放到內存中,可以減少對數據庫的讀操作,數據庫的壓力降低,同時也會加快系統的響應速度,但是同樣的也會帶來其他的問題,比如需要考慮數據的一致性、還需要預防可能的緩存擊穿、穿透和雪崩問題等等。
1. 實現步驟
先查詢緩存中有沒有要的數據,如果有,就直接返回緩存中的數據。如果緩存中沒有要的數據,才去查詢數據庫,將得到數據更新到緩存再返回,如果數據庫中也沒有就可以返回空。
考慮數據一致性,緩存處的代碼邏輯都較為標準化,首先取Redis,擊中則返回,未擊中則通過數據庫來進行查詢和同步。
- public Result query(String id) {
- Result result = null;
- //1.從Redis緩存中取數據
- result = (Result)redisTemplate.opsForValue().get(id);
- if (null != result){
- System.out.println("緩存中得到數據");
- return result;
- }
- //2.通過DB查詢,有則同步更新redis,否則返回空
- System.out.println("數據庫中得到數據");
- result = Dao.query(id);
- if (null != result){
- redisTemplate.opsForValue().set(id,result);
- redisTemplate.expire(id,20000, TimeUnit.MILLISECONDS);
- }
- return result;
- }
其他的新增、刪除和更新操作,可以直接采用先清空該Key下的緩存值再進行DB操作,這樣邏輯清晰簡單,維護的復雜度會降低,而付出代價就是多查詢一次。
- public void update(Entity entity) {
- redisTemplate.delete(entity.getId());
- Dao.update(entity);
- return entity;
- }
- public Entity add(Entity entity) {
- redisTemplate.delete(entity.getId());
- Dao.insert(entity);
- return entity;
- }
2. 緩存更新策略
適用于做緩存的場景一般都是:訪問頻繁、讀場景較多而寫場景少、對數據一致性要求不高。如果上面三個條件都不符合,那維護一套緩存數據的意義并不大了,實際應用中通常都需要針對業務場景來選擇合適的緩存方案,下面給出了四種緩存策略,由上到下就是按照一致性由強到弱的順序。
更新策略特點適用場景
實時更新同步更新保證強一致性,與業務強侵入強耦合金融轉賬業務等
弱實時異步更新(MQ/發布訂閱/觀察者模式),業務解耦,弱一致性存在延遲不適合寫頻繁場景
失效機制設置緩存失效,有一定延遲,可能存在雪崩適用讀多寫少,能接受一定的延時
任務調度通過定時任務進行全量更新統計類業務,訪問頻繁且定期更新
二. 緩存雪崩和擊穿
1. 緩存雪崩概念
緩存雪崩是指在我們設置緩存時采用了相同的過期時間,導致緩存在某一時刻同時失效,請求全部轉發到DB,DB瞬時壓力過重雪崩。和緩存擊穿不同的是,緩存擊穿指并發查同一條數據,緩存雪崩是不同數據都過期了,很多數據都查不到從而查數據庫。
解決方案
將緩存失效時間分散開,比如我們可以在原有的失效時間基礎上增加一個隨機值,比如1-5分鐘隨機,這樣每一個緩存的過期時間的重復率就會降低,就很難引發集體失效的事件。
用加鎖或者隊列的方式保證緩存的單線程(進程)寫,從而避免失效時大量的并發請求落到底層存儲系統上。
第一種方案比較容易實現,第二種的思路主要是從加阻塞式的排它鎖來實現,在緩存查詢不到的情況下,每此只允許一個線程去查詢DB,這樣可避免同一個ID的大量并發請求都落到數據庫中。
- public Result query(String id) {
- // 1.從緩存中取數據
- Result result = null;
- result = (Result)redisTemplate.opsForValue().get(id);
- if (result ! = null) {
- logger.info("緩存中得到數據");
- return result;
- }
- //2.加鎖排隊,阻塞式鎖
- doLock(id);//多少個id就可能有多少把鎖
- try{
- //一次只有一個線程
- //雙重校驗,第一次獲取到后面的都可以從緩存中直接擊中
- result = (Result)redisTemplate.opsForValue().get(id);
- if (result != null) {
- logger.info("緩存中得到數據");
- return result;//第二個線程,這里返回
- }
- result = dao.query(id);
- // 3.從數據庫查詢的結果不為空,則把數據放入緩存中,方便下次查詢
- if (null != result) {
- redisTemplate.opsForValue().set(id,result);
- redisTemplate.expire(id,20000, TimeUnit.MILLISECONDS);
- }
- return provinces;
- } catch(Exception e) {
- return null;
- } finally {
- //4.解鎖
- releaseLock(provinceid);
- }
- }
- private void releaseLock(String userCode) {
- ReentrantLock oldLock = (ReentrantLock) locks.get(userCode);
- if(oldLock !=null && oldLock.isHeldByCurrentThread()){
- oldLock.unlock();
- }
- }
- private void doLock(String lockcode) {
- //id有不同的值
- //id相同的,加一個鎖,不是同一個key,不能用同一個鎖
- ReentrantLock newLock = new ReentrantLock();//創建一個鎖
- //若已存在,則newLock直接丟棄
- Lock oldLock = locks.putIfAbsent(lockcode, newLock);
- if(oldLock == null){
- newLock.lock();
- }else{
- oldLock.lock();
- }
- }
- ??
注意:加鎖排隊的解決方式在處理分布式環境的并發問題,有可能還要解決分布式鎖的問題;線程還會被阻塞,用戶體驗很差!因此,在真正的高并發場景下很少使用!
2. 緩存擊穿概念
一個存在的key,在緩存過期的一刻,同時有大量的請求,這些請求都會擊穿到DB,造成瞬時DB請求量大、壓力驟增。
解決方案
在訪問key之前,采用SETNX(set if not exists)來設置另一個短期key來鎖住當前key的訪問,訪問結束再刪除該短期key。
三. 緩存穿透
1. 緩存穿透概念
緩存穿透是指緩存和數據庫中都沒有的數據,而用戶不斷發起請求,如發起為id為“-1”的數據或id為特別大不存在的數據。這時的用戶很可能遭遇安全威脅,導致數據庫壓力過大。
解決方案:布隆過濾器
布隆過濾器的使用方法,類似java的SET集合,用來判斷某個元素(key)是否在某個集合中。和一般的hash set不同的是,這個算法無需存儲key的值,對于每個key,只需要k個比特位,每個存儲一個標志,用來判斷key是否在集合中。
使用步驟:
將List數據裝載入布隆過濾器中
- private BloomFilter<String> bf =null;
- //PostConstruct注解對象創建后,自動調用本方法
- @PostConstruct
- public void init(){
- //在bean初始化完成后,實例化bloomFilter,并加載數據
- List<Entity> entities= initList();
- //初始化布隆過濾器
- bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), entities.size());
- for (Entity entity : entities) {
- bf.put(entity.getId());
- }
- }
- ??
訪問經過布隆過濾器,存在才可以往db中查詢
- public Provinces query(String id) {
- //先判斷布隆過濾器中是否存在該值,值存在才允許訪問緩存和數據庫
- if(!bf.mightContain(id)) {
- Log.info("非法訪問"+System.currentTimeMillis());
- return null;
- }
- Log.info("數據庫中得到數據"+System.currentTimeMillis());
- Entity entity= super.query(id);
- return entity;
- }
這樣當外界有惡意威脅時,不存在的數據請求就可以直接攔截在過濾器層,而不會影響到底層數據庫系統。