利用Redis進行數(shù)據(jù)緩存的項目實踐
1. 引言
緩存有啥用?
- 降低對數(shù)據(jù)庫的請求,減輕服務器壓力
- 提高了讀寫效率
緩存有啥缺點?
- 如何保證數(shù)據(jù)庫與緩存的數(shù)據(jù)一致性問題?
- 維護緩存代碼
- 搭建緩存一般是以集群的形式進行搭建,需要運維的成本
2. 將信息添加到緩存的業(yè)務流程
上圖可以清晰的了解Redis在項目中所處的位置,是數(shù)據(jù)庫與客戶端之間的一個中間件,也是數(shù)據(jù)庫的保護傘。有了Redis可以幫助數(shù)據(jù)庫進行請求的阻擋,阻止請求直接打入數(shù)據(jù)庫,提高響應速率,極大的提升了系統(tǒng)的穩(wěn)定性。
3. 實現(xiàn)代碼
下面將根據(jù)查詢商鋪信息來作為背景進行代碼書寫,具體的流程圖如上所示。
3.1 代碼實現(xiàn)(信息添加到緩存中)
public static final String SHOPCACHEPREFIX = "cache:shop:"; @Autowired private StringRedisTemplate stringRedisTemplate; // JSON工具 ObjectMapper objectMapper = new ObjectMapper(); @Override public Result queryById(Long id) { //從Redis查詢商鋪緩存 String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id); //判斷緩存中數(shù)據(jù)是否存在 if (!StringUtil.isNullOrEmpty(cacheShop)) { //緩存中存在則直接返回 try { // 將子字符串轉換為對象 Shop shop = objectMapper.readValue(cacheShop, Shop.class); return Result.ok(shop); } catch (JsonProcessingException e) { e.printStackTrace(); } } //緩存中不存在,則從數(shù)據(jù)庫里進行數(shù)據(jù)查詢 Shop shop = getById(id); //數(shù)據(jù)庫里不存在,返回404 if (null==shop){ return Result.fail("信息不存在"); } //數(shù)據(jù)庫里存在,則將信息寫入Redis try { String shopJSon = objectMapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,shopJSon,30,TimeUnit.MINUTES); } catch (JsonProcessingException e) { e.printStackTrace(); } //返回 return Result.ok(shop); }
3.2 緩存更新策略
數(shù)據(jù)庫與緩存數(shù)據(jù)一致性問題,當數(shù)據(jù)庫信息修改后,緩存的信息應該如何處理?
內(nèi)存淘汰 | 超時剔除 | 主動更新 | |
---|---|---|---|
說明 | 不需要自己進行維護,利用Redis的淘汰機制進行數(shù)據(jù)淘汰 | 給緩存數(shù)據(jù)添加TTL | 編寫業(yè)務邏輯,在修改數(shù)據(jù)庫的同時更新緩存 |
一致性 | 差勁 | 一般 | 好 |
維護成本 | 無 | 低 | 高 |
這里其實是需要根據(jù)業(yè)務場景來進行選擇
- 高一致性:選主動更新
- 低一致性:內(nèi)存淘汰和超時剔除
3.3 實現(xiàn)主動更新
此時需要實現(xiàn)數(shù)據(jù)庫與緩存一致性問題,在這個問題之中還有多個問題值得深思
刪除緩存還是更新緩存?
當數(shù)據(jù)庫發(fā)生變化時,我們?nèi)绾翁幚砭彺嬷袩o效的數(shù)據(jù),是刪除它還是更新它?
更新緩存:每次更新數(shù)據(jù)庫都更新緩存,無效寫操作較多
刪除緩存:更新數(shù)據(jù)庫時刪除緩存,查詢時再添加緩存
由此可見,選擇刪除緩存是高效的。
如何保證緩存與數(shù)據(jù)庫的操作的同時成功或失敗?
單體架構:單體架構中采用事務解決
分布式架構:利用分布式方案進行解決
先刪除緩存還是先操作數(shù)據(jù)庫?
在并發(fā)情況下,上述情況是極大可能會發(fā)生的,這樣子會導致緩存與數(shù)據(jù)庫數(shù)據(jù)庫不一致。
先操作數(shù)據(jù)庫,在操作緩存這種情況,在緩存數(shù)據(jù)TTL剛好過期時,出現(xiàn)一個A線程查詢緩存,由于緩存中沒有數(shù)據(jù),則向數(shù)據(jù)庫中查詢,在這期間內(nèi)有另一個B線程進行數(shù)據(jù)庫更新操作和刪除緩存操作,當B的操作在A的兩個操作間完成時,也會導致數(shù)據(jù)庫與緩存數(shù)據(jù)不一致問題。
完蛋?。?!兩種方案都會造成數(shù)據(jù)庫與緩存一致性問題的發(fā)生,那么應該如何來進行選擇呢?
雖然兩者方案都會造成問題的發(fā)生,但是概率上來說還是先操作數(shù)據(jù)庫,再刪除緩存發(fā)生問題的概率低一些,所以可以選擇先操作數(shù)據(jù)庫,再刪除緩存的方案。
個人見解:
如果說我們在先操作數(shù)據(jù)庫,再刪除緩存方案中線程B刪除緩存時,我們利用java來刪除緩存會有Boolean返回值,如果是false,則說明緩存已經(jīng)不存在了,緩存不存在了,則會出現(xiàn)上圖的情況,那么我們是否可以根據(jù)刪除緩存的Boolean值來進行判斷是否需要線程B來進行緩存的添加(因為之前是需要查詢的線程來添加緩存,這里考慮線程B來添加緩存,線程B是操作數(shù)據(jù)庫的緩存),如果線程B的添加也在線程A的寫入緩存之前完成也會造成數(shù)據(jù)庫與緩存的一致性問題發(fā)生。那么是否可以延時一段時間(例如5s,10s)再進行數(shù)據(jù)的添加,這樣子雖然最終會統(tǒng)一數(shù)據(jù)庫與緩存的一致性,但是若是在這5s,10s內(nèi)又有線程C,D等等來進行緩存的訪問呢?C,D線程的訪問還是訪問到了無效的緩存信息。
所以在數(shù)據(jù)庫與緩存的一致性問題上,除非在寫入正確緩存之前拒絕相關請求進行服務器來進行訪問才能避免用戶訪問到錯誤信息,但是拒絕請求對用戶來說是致命的,極大可能會導致用戶直接放棄使用應用,所以我們只能盡可能的減少問題可能性的發(fā)生。(個人理解,有問題可以在評論區(qū)留言賜教)
@Override @Transactional public Result updateShop(Shop shop) { Long id = shop.getId(); if (null==id){ return Result.fail("店鋪id不能為空"); } //更新數(shù)據(jù)庫 boolean b = updateById(shop); //刪除緩存 stringRedisTemplate.delete(SHOPCACHEPREFIX+shop.getId()); return Result.ok(); }
4. 緩存穿透
緩存穿透是指客戶端請求的數(shù)據(jù)在緩存中和數(shù)據(jù)庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到數(shù)據(jù)庫。
解決方案:
緩存空對象
缺點:
- 空間浪費
- 如果緩存了空對象,在空對象的有效期內(nèi),我們后臺在數(shù)據(jù)庫新增了和空對象相同id的數(shù)據(jù),這樣子就會造成數(shù)據(jù)庫與緩存一致性問題
布隆過濾器
優(yōu)點:
內(nèi)存占用少
缺點:
- 實現(xiàn)復雜
- 存在誤判的可能(存在的數(shù)據(jù)一定會判斷成功,但是不存在的數(shù)據(jù)也有可能會放行進來,有幾率造成緩存穿透)
4.1 解決緩存穿透(使用空對象進行解決)
public static final String SHOPCACHEPREFIX = "cache:shop:"; @Autowired private StringRedisTemplate stringRedisTemplate; // JSON工具 ObjectMapper objectMapper = new ObjectMapper(); @Override public Result queryById(Long id) { //從Redis查詢商鋪緩存 String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id); //判斷緩存中數(shù)據(jù)是否存在 if (!StringUtil.isNullOrEmpty(cacheShop)) { //緩存中存在則直接返回 try { // 將子字符串轉換為對象 Shop shop = objectMapper.readValue(cacheShop, Shop.class); return Result.ok(shop); } catch (JsonProcessingException e) { e.printStackTrace(); } } // 因為上面判斷了cacheShop是否為空,如果進到這個方法里面則一定是空,直接過濾,不打到數(shù)據(jù)庫 if (null != cacheShop){ return Result.fail("信息不存在"); } //緩存中不存在,則從數(shù)據(jù)庫里進行數(shù)據(jù)查詢 Shop shop = getById(id); //數(shù)據(jù)庫里不存在,返回404 if (null==shop){ // 緩存空對象 stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,"",2,TimeUnit.MINUTES); return Result.fail("信息不存在"); } //數(shù)據(jù)庫里存在,則將信息寫入Redis try { String shopJSon = objectMapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,shopJSon,30,TimeUnit.MINUTES); } catch (JsonProcessingException e) { e.printStackTrace(); } //返回 return Result.ok(shop); }
上述方案終究是被動方案,我們可以采取一些主動方案,例如
- 給id加復雜度
- 權限
- 熱點參數(shù)的限流
5. 緩存雪崩
緩存雪崩是指在同一時段大量的緩存key同時失效或者Redis服務宕機,導致大量請求到達數(shù)據(jù)庫,帶來巨大壓力。
解決方案:
- 給不同的Key的TTL添加隨機值
大量的Key同時失效,極大可能是TTL相同,我們可以隨機給TTL - 利用Redis集群提高服務的可用性
- 給緩存業(yè)務添加降級限流策略
- 給業(yè)務添加多級緩存
6. 緩存擊穿
緩存擊穿問題也叫熱點Key問題,就是一個被高并發(fā)訪問并且緩存重建業(yè)務較復雜的key突然失效了,無數(shù)的請求訪問會在瞬間給數(shù)據(jù)庫帶來巨大的沖擊。
常見的解決方案:
- 互斥鎖
- 邏輯過期
互斥鎖:
即采用鎖的方式來保證只有一個線程去重建緩存數(shù)據(jù),其余拿不到鎖的線程休眠一段時間再重新重頭去執(zhí)行查詢緩存的步驟
優(yōu)點:
- 沒有額外的內(nèi)存消耗(針對下面的邏輯過期方案)
- 保證了一致性
缺點:
- 線程需要等待,性能受到了影響
- 可能會產(chǎn)生死鎖
邏輯過期:
邏輯過期是在緩存數(shù)據(jù)中額外添加一個屬性,這個屬性就是邏輯過期的屬性,為什么要使用這個來判斷是否過期而不使用TTL呢?因為使用TTL的話,一旦過期,就獲取不到緩存中的數(shù)據(jù)了,沒有拿到鎖的線程就沒有舊的數(shù)據(jù)可以返回。
它與互斥鎖最大的區(qū)別就是沒有線程的等待了,誰先獲取到鎖就去重建緩存,其余線程沒有獲取到鎖就返回舊數(shù)據(jù),不去做休眠,輪詢?nèi)カ@取鎖。
重建緩存會新開一個線程去執(zhí)行重建緩存,目的是減少搶到鎖的線程的響應時間。
優(yōu)點:
線程無需等待,性能好
缺點:
- 不能保證一致性
- 緩存中有額外的內(nèi)存消耗
- 實現(xiàn)復雜
兩個方案各有優(yōu)缺點:一個保證了一致性,一個保證了可用性,選擇與否主要看業(yè)務的需求是什么,側重于可用性還是一致性。
6.1 互斥鎖代碼
互斥鎖的鎖用什么?
使用Redis命令的setnx命令。
首先實現(xiàn)獲取鎖和釋放鎖的代碼
/** * 嘗試獲取鎖 * * @param key * @return */ private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } /** * 刪除鎖 * * @param key */ private void unLock(String key) { stringRedisTemplate.delete(key); }
代碼實現(xiàn)
public Shop queryWithMutex(Long id) throws InterruptedException { //從Redis查詢商鋪緩存 String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id); //判斷緩存中數(shù)據(jù)是否存在 if (!StringUtil.isNullOrEmpty(cacheShop)) { //緩存中存在則直接返回 try { // 將子字符串轉換為對象 Shop shop = objectMapper.readValue(cacheShop, Shop.class); return shop; } catch (JsonProcessingException e) { e.printStackTrace(); } } // 因為上面判斷了cacheShop是否為空,如果進到這個方法里面則一定是空,直接過濾,不打到數(shù)據(jù)庫 if (null != cacheShop) { return null; } Shop shop = new Shop(); // 緩存擊穿,獲取鎖 String lockKey = "lock:shop:" + id; try{ boolean b = tryLock(lockKey); if (!b) { // 獲取鎖失敗了 Thread.sleep(50); return queryWithMutex(id); } //緩存中不存在,則從數(shù)據(jù)庫里進行數(shù)據(jù)查詢 shop = getById(id); //數(shù)據(jù)庫里不存在,返回404 if (null == shop) { // 緩存空對象 stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, "", 2, TimeUnit.MINUTES); return null; } //數(shù)據(jù)庫里存在,則將信息寫入Redis try { String shopJSon = objectMapper.writeValueAsString(shop); stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, shopJSon, 30, TimeUnit.MINUTES); } catch (JsonProcessingException e) { e.printStackTrace(); } }catch (Exception e){ }finally { // 釋放互斥鎖 unLock(lockKey); } //返回 return shop; }
6.2 邏輯過期實現(xiàn)
邏輯過期不設置TTL
代碼實現(xiàn)
@Data public class RedisData { private LocalDateTime expireTime; private Object data; }
由于是熱點key,所以key基本都是手動導入到緩存,代碼如下
/** * 邏輯過期時間對象寫入緩存 * @param id * @param expireSeconds */ public void saveShopToRedis(Long id,Long expireSeconds){ // 查詢店鋪數(shù)據(jù) Shop shop = getById(id); // 封裝為邏輯過期 RedisData redisData = new RedisData(); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); // 寫入Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(redisData)); }
邏輯過期代碼實現(xiàn)
/** * 緩存擊穿:邏輯過期解決 * @param id * @return * @throws InterruptedException */ public Shop queryWithPassLogicalExpire(Long id) throws InterruptedException { //1. 從Redis查詢商鋪緩存 String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id); //2. 判斷緩存中數(shù)據(jù)是否存在 if (StringUtil.isNullOrEmpty(cacheShop)) { // 3. 不存在 return null; } // 4. 存在,判斷是否過期 RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class); JSONObject jsonObject = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(jsonObject, Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); // 5. 判斷是否過期 if (expireTime.isAfter(LocalDateTime.now())){ // 5.1 未過期 return shop; } // 5.2 已過期 String lockKey = "lock:shop:"+id; boolean flag = tryLock(lockKey); if (flag){ // TODO 獲取鎖成功,開啟獨立線程,實現(xiàn)緩存重建,建議使用線程池去做 CACHE_REBUILD_EXECUTOR.submit(()->{ try { // 重建緩存 this.saveShopToRedis(id,1800L); }catch (Exception e){ }finally { // 釋放鎖 unLock(lockKey); } }); } // 獲取鎖失敗,返回過期的信息 return shop; } /** * 線程池 */ private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
到此這篇關于利用Redis進行數(shù)據(jù)緩存的項目實踐的文章就介紹到這了,更多相關Redis 數(shù)據(jù)緩存內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
解決Redis報錯MISCONF?Redis?is?configured?to?save?RDB?snap
這篇文章主要給大家介紹了關于如何解決Redis報錯MISCONF?Redis?is?configured?to?save?RDB?snapshots的相關資料,文中通過代碼介紹的非常詳細,需要的朋友可以參考下2023-11-11Redis集群指定主從關系及動態(tài)增刪節(jié)點方式
這篇文章主要介紹了Redis集群指定主從關系及動態(tài)增刪節(jié)點方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-01-01Redis Sentinel實現(xiàn)哨兵模式搭建小結
這篇文章主要介紹了Redis Sentinel實現(xiàn)哨兵模式搭建小結,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-12-12