緩存
數據交換的緩沖區,俗稱的緩存是緩沖區內的數據,一般從數據庫中獲取,
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并發例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等緩存例3:Static final Map<K,V> map = new HashMap(); 本地緩存
-
ConcurrentHashMap
:
線程安全的哈希表,支持高并發讀寫。適用于本地內存緩存,無需序列化,直接操作 Java 對象。但無法持久化或分布式共享。 -
CacheBuilder
(Guava Cache):
功能更豐富的本地緩存,支持過期策略、最大容量、弱引用等。通常用于本地二級緩存,配合 Redis 等遠程緩存使用,減少遠程訪問壓力。 -
HashMap
:
非線程安全的哈希表,直接用于緩存會有并發問題(如數據不一致、死循環)。不推薦在高并發場景使用,除非通過外部同步(如Collections.synchronizedMap
)。
使用緩存的目的
速度快,提高讀寫效率,降低響應時間
緩存數據存儲在內存中,而內存讀寫性能遠高于磁盤,緩存可以大大降低用戶訪問并發量帶來的服務器讀寫壓力(降低后端負載)
- 數據一致性成本:
若后端數據更新(如商品價格修改),緩存未及時同步,會出現 “緩存與源數據不一致” 的問題。需設計 緩存失效策略(如超時、主動更新),但這會增加代碼復雜度和異常處理成本。 - 代碼維護成本:
引入緩存后,代碼需新增 “緩存讀寫、失效、回源(緩存未命中時查后端)” 等邏輯,還需處理緩存穿透、擊穿、雪崩等異常場景,導致代碼更復雜,維護難度提升。 - 運維成本:
緩存系統(如 Redis、Memcached)需獨立部署、監控(內存、命中率、連接數)、擴容(集群化)、故障恢復,增加運維人力和資源投入。
如何使用緩存:
構建多級緩存,例如本地緩存和redis緩存并發使用
瀏覽器緩存:保存在瀏覽器端的緩存
應用層緩存:分為tomcat本地緩存,如使用map或redis
數據庫緩存:數據庫中有一個緩存池,增改查數據都會先加載到mysql緩存中
CPU緩存:CPU的L1、L2、L3級緩存
添加商戶緩存
在查詢商戶信息時,先到緩存中查詢
這里添加redis緩存
查詢時先訪問Redis,若沒有命中再訪問數據庫,同時寫緩存到Redis
String key = "cache:shop:" + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop);
}
Shop shop = getById(id);
if (shop == null) { return Result.fail("店鋪不存在哦");
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
先從redis查詢店鋪數據
如果存在,將JSOn格式的字符串通過JSONUtil反序列化成Shop類對象的實例
如果不存在,去數據庫中查找,將返回值寫入redis,這里同樣將Shop類對象的實例轉化成String類型
緩存更新策略
若緩存中數據過多,redis會對部分數據進行更新或淘汰
內存淘汰
當內存達到設定的最大值時,自動淘汰一些不重要的數據
超時剔除
設置過期時間,redis會將超時的數據進行刪除
主動更新
手動調用方法刪除緩存,通常用于解決緩存和數據庫不一致的問題
數據庫緩存不一致解決方案
由于緩存數據來源于數據庫,而數據庫中的數據是會發生變化的,若數據庫數據發生變化,而緩存未同步,就會出現一致性問題
后果是用戶可能使用緩存中過時數據,從而產生類似多線程數據安全問題
有如下解決方案:
人工編碼:內存調用者在更新完數據庫后更新緩存
讀寫穿透模式:系統作為中間層,同時管理緩存與數據庫的讀寫操作
寫回緩存模式:應用層僅操作緩存,數據庫更新由異步線程批量處理,調用者寫入緩存后直接返回,由異步線程定期將緩存數據批量寫入數據庫
綜合推薦使用方案一,
在操作數據庫時,我們可以將緩存刪除,待查詢時再從緩存中加載數據
為保證數據庫操作同時成功或失敗:
采用單體系統的情況,則將緩存與數據庫放在一個事務中
采用分布式系統,則利用TCC等分布式事務方案
具體操作緩存和數據庫時,應該采用先操作數據庫,后刪除緩存的操作
若先刪除緩存,再操作數據庫:
當有兩個線程并發查詢的時候,假設線程1先查詢,刪除緩存后此時線程2發現沒有緩存數據,從數據庫中讀取舊數據寫入到緩存中,此時線程1再進行更新數據庫的操作,那么緩存就是舊數據
@Override
@Transactional
public Result update(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店鋪id不能為空"); } // 1.更新數據庫 updateById(shop); // 2.刪除緩存 stringRedisTemplate.delete(CACHE_SHOP_KEY + id); return Result.ok();
}
具體到代碼在執行更新操作是,先更新數據庫,再刪除緩存
緩存穿透問題的解決思路
緩存穿透:客戶端請求的數據在緩存中和數據庫總都不存在,都有緩存永遠不會生效,這些請求都會打到數據庫。
解決方案:
將空對象緩存:哪怕數據在數據庫中不存在也存入redis中,這樣就不會訪問數據庫
實現簡單,但會造成額外的內存消耗
布隆過濾:通過一個龐大的二進制數據,走哈希的思想判斷這個數據是否存在,若存在才會放行
內存占用少,但實現復雜并有誤判可能
緩存雪崩及解決思路
緩存雪崩是指同一時間大量緩存key同時失效導致Redis服務宕機,導致大量請求到達數據庫,從而造成巨大的壓力
解決方案:
給不同Key的TTL添加隨機值
使用Redis集群
給緩存業務進行降級限流
給業務添加多級緩存
緩存擊穿及解決思路
也叫熱點key,就是一個高并發且緩存重建業務比較復雜(重建時間長)的key突然失效,無數請求的訪問會瞬間給數據庫帶來巨大的沖擊
解決方案:
互斥鎖
將并行查詢改為串行,一次只能一個線程訪問數據庫,使用tryLock和double check解決問題
邏輯過期
不設置過期時間,將過期時間設置在redis的value中,當線程1查詢緩存時,發現數據已經過期了,他會開啟一個新的線程去進行重構數據的邏輯,而線程1直接返回過期數據,假設線程3過來訪問,由于線程2持有鎖,線程3無法獲得鎖,它也直接返回過期數據
特點是在完成緩存重建之前,所有線程返回的都是臟數據
對比:
互斥鎖:簡單,保證數據一致,可能存在死鎖風險且性能低
邏輯過期:讀取不需要等待,性能好,在重構之前都是臟數據,實現復雜
使用互斥鎖解決緩存擊穿問題
public Shop queryWithMutex(Long id) {String key = CACHE_SHOP_KEY + id;// 1、從redis中查詢商鋪緩存String shopJson = stringRedisTemplate.opsForValue().get("key");// 2、判斷是否存在if (StrUtil.isNotBlank(shopJson)) {// 存在,直接返回return JSONUtil.toBean(shopJson, Shop.class);}//判斷命中的值是否是空值if (shopJson != null) {//返回一個錯誤信息return null;}// 4.實現緩存重構//4.1 獲取互斥鎖String lockKey = "lock:shop:" + id;Shop shop = null;try {boolean isLock = tryLock(lockKey);// 4.2 判斷否獲取成功if(!isLock){//4.3 失敗,則休眠重試Thread.sleep(50);return queryWithMutex(id);}//4.4 成功,根據id查詢數據庫shop = getById(id);// 5.不存在,返回錯誤if(shop == null){//將空值寫入redisstringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);//返回錯誤信息return null;}//6.寫入redisstringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);}catch (Exception e){throw new RuntimeException(e);}finally {//7.釋放互斥鎖unlock(lockKey);}return shop;}
先進行獲取鎖,未獲取到迭代繼續獲取,知道拿到后查詢數據庫,如果數據庫沒有,將空對象寫入redis并返回null
如果有就寫入redis,然后釋放鎖,最后返回數據庫的結果
使用邏輯過期解決緩存擊穿問題
在查詢redis時,先判斷是否命中,如果沒有命中直接返回空數據,不查詢數據庫,一旦命中將value取出,判斷value的過期時間,如果沒過期直接返回數據,過期則開啟獨立線程后返回之前的數據,獨立線程單獨重構數據,重構完成后釋放互斥鎖
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {String key = CACHE_SHOP_KEY + id;// 1.從redis查詢商鋪緩存String json = stringRedisTemplate.opsForValue().get(key);// 2.判斷是否存在if (StrUtil.isBlank(json)) {// 3.存在,直接返回return null;}// 4.命中,需要先把json反序列化為對象RedisData redisData = JSONUtil.toBean(json, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 5.判斷是否過期if(expireTime.isAfter(LocalDateTime.now())) {// 5.1.未過期,直接返回店鋪信息return shop;}// 5.2.已過期,需要緩存重建// 6.緩存重建// 6.1.獲取互斥鎖String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 6.2.判斷是否獲取鎖成功if (isLock){CACHE_REBUILD_EXECUTOR.submit( ()->{try{//重建緩存this.saveShop2Redis(id,20L);}catch (Exception e){throw new RuntimeException(e);}finally {unlock(lockKey);}});}// 6.4.返回過期的商鋪信息return shop;
}
- 線程池的運用:
這里創建了一個固定大小為 10 的線程池,目的是管控緩存重建任務。借助線程池,可以避免因大量創建線程而導致系統資源被過度占用。private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
- 異步任務的提交:
當商鋪緩存過期后,會向線程池提交一個重建緩存的任務,這樣可以讓主線程繼續執行后續操作,不用等待緩存重建完成。CACHE_REBUILD_EXECUTOR.submit( ()->{// 任務內容 });
- 緩存重建的流程:
try{// 重建緩存this.saveShop2Redis(id, 20L); } catch (Exception e) {throw new RuntimeException(e); }
saveShop2Redis(id, 20L)
方法會從數據庫獲取最新的商鋪數據,然后把這些數據存入 Redis,同時設置 20 秒的邏輯過期時間。- 對可能出現的異常進行捕獲,將其封裝成運行時異常后重新拋出。
- 鎖的釋放操作:
不管緩存重建成功與否,最終都會執行finally {unlock(lockKey); }
unlock(lockKey)
方法來釋放鎖,防止出現死鎖的情況。
封裝redis工具類
基于StringRedisTemplate封裝一個緩存工具類
方法1:將任意Java對象序列化為Json并儲存在string類型的key中,可設置TTL過期時間
方法2:將任意Java對象序列化為Json并儲存在String類型的key中,可以設置邏輯過期時間,用于處理緩存擊穿問題
方法3:根據指定的key查詢緩存,并反序列化為指定類型,利用緩存空值的方式解決緩存穿透的問題
根據指定的key查詢緩存,并反序列化為指定類型,需要利用邏輯過期解決緩存擊穿問題
Shop shop = cacheClient.queryWithPassThrough( CACHE_SHOP_KEY, // 緩存鍵前綴
id, // 商鋪ID
Shop.class, // 返回類型
this::getById, // 數據庫查詢回調
CACHE_SHOP_TTL, // 緩存時間
TimeUnit.MINUTES // 時間單位 );
public <R, ID> R queryWithPassThrough( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { // ... R r = dbFallback.apply(id); // 調用傳入的函數 // ... }
這里的 Function<ID, R>
是一個函數式接口,表示接受一個 ID 類型的參數,返回一個 R 類型的結果。
等價于id->getById(id),表示傳入一個id參數,調用當前對象的getById方法處理它,并返回結果
總結
-
緩存的核心作用是什么?
緩存通過將高頻訪問數據存儲在內存中,顯著提升讀寫效率、降低響應時間,同時減少后端數據庫的訪問壓力,緩解高并發場景下的服務器負載。 -
常見的本地緩存實現有哪些?核心區別是什么?
常見實現包括ConcurrentHashMap
(線程安全,適用于高并發本地緩存,無持久化)、Guava Cache
(功能豐富,支持過期策略、容量控制等,適合本地二級緩存)、HashMap
(非線程安全,高并發下易出問題,不推薦直接使用)。核心區別在于線程安全性、功能豐富度及適用場景。 -
如何解決緩存與數據庫的數據一致性問題?
推薦 “先更新數據庫,后刪除緩存” 的策略:更新操作時,先保證數據庫數據正確,再刪除對應緩存,避免舊數據殘留。單體系統中可通過事務保證操作原子性,分布式系統需結合 TCC 等分布式事務方案。 -
什么是緩存穿透?如何解決?
緩存穿透指請求數據在緩存和數據庫中均不存在,導致請求直接穿透緩存沖擊數據庫。解決方式包括:①緩存空對象(將不存在的數據以空值存入緩存,避免重復穿透);②布隆過濾(通過哈希判斷數據是否存在,提前攔截無效請求)。 -
緩存擊穿的解決方式有哪些?各有什么特點?
緩存擊穿指高并發下熱點 Key 突然失效,大量請求瞬間沖擊數據庫。解決方式包括:①互斥鎖(串行化請求,保證緩存重建時僅一個線程訪問數據庫,數據一致但性能略低);②邏輯過期(不設置物理過期,通過 value 中的邏輯時間判斷,過期時異步重建緩存,性能高但可能返回臟數據)。 -
緩存雪崩的成因及預防措施是什么?
緩存雪崩指大量緩存 Key 同時失效,導致 Redis 壓力驟降、請求集中沖擊數據庫。預防措施包括:①給 Key 的 TTL 添加隨機值,避免集中過期;②使用 Redis 集群提高可用性;③對緩存業務降級限流;④引入多級緩存減少單一層級依賴。