1. 引言
緩存有啥用?
降低對資料庫的請求,減輕伺服器壓力
提高了讀寫效率
緩存有啥缺點?
如何保證資料庫與緩存的數據一致性問題?
維護緩存代碼
搭建緩存一般是以集群的形式進行搭建,需要運維的成本
2. 將信息添加到緩存的業務流程
上圖可以清晰的了解Redis在項目中所處的位置,是資料庫與客戶端之間的一個中間件,也是資料庫的保護傘。有了Redis可以幫助資料庫進行請求的阻擋,阻止請求直接打入資料庫,提高響應速率,極大的提升了系統的穩定性。
3. 實現代碼
下面將根據查詢商鋪信息來作為背景進行代碼書寫,具體的流程圖如上所示。
3.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);
//判斷緩存中數據是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉換為對象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return Result.ok(shop);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
//緩存中不存在,則從資料庫里進行數據查詢
Shop shop = getById(id);
//資料庫里不存在,返回404
if (null==shop){
return Result.fail("信息不存在");
}
//資料庫里存在,則將信息寫入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 緩存更新策略
資料庫與緩存數據一致性問題,當資料庫信息修改後,緩存的信息應該如何處理?
內存淘汰 超時剔除 主動更新
說明 不需要自己進行維護,利用Redis的淘汰機制進行數據淘汰 給緩存數據添加TTL 編寫業務邏輯,在修改資料庫的同時更新緩存
一致性 差勁 一般 好
維護成本 無 低 高
這裡其實是需要根據業務場景來進行選擇
高一致性:選主動更新
低一致性:內存淘汰和超時剔除
3.3 實現主動更新
此時需要實現資料庫與緩存一致性問題,在這個問題之中還有多個問題值得深思
刪除緩存還是更新緩存?
當資料庫發生變化時,我們如何處理緩存中無效的數據,是刪除它還是更新它?
更新緩存:每次更新資料庫都更新緩存,無效寫操作較多
刪除緩存:更新資料庫時刪除緩存,查詢時再添加緩存
由此可見,選擇刪除緩存是高效的。
如何保證緩存與資料庫的操作的同時成功或失敗?
單體架構:單體架構中採用事務解決
分布式架構:利用分布式方案進行解決
先刪除緩存還是先操作資料庫?
在並發情況下,上述情況是極大可能會發生的,這樣子會導致緩存與資料庫資料庫不一致。
先操作資料庫,在操作緩存這種情況,在緩存數據TTL剛好過期時,出現一個A線程查詢緩存,由於緩存中沒有數據,則向資料庫中查詢,在這期間內有另一個B線程進行資料庫更新操作和刪除緩存操作,當B的操作在A的兩個操作間完成時,也會導致資料庫與緩存數據不一致問題。
完蛋!!!兩種方案都會造成資料庫與緩存一致性問題的發生,那麼應該如何來進行選擇呢?
雖然兩者方案都會造成問題的發生,但是概率上來說還是先操作資料庫,再刪除緩存發生問題的概率低一些,所以可以選擇先操作資料庫,再刪除緩存的方案。
個人見解:
如果說我們在先操作資料庫,再刪除緩存方案中線程B刪除緩存時,我們利用java來刪除緩存會有Boolean返回值,如果是false,則說明緩存已經不存在了,緩存不存在了,則會出現上圖的情況,那麼我們是否可以根據刪除緩存的Boolean值來進行判斷是否需要線程B來進行緩存的添加(因為之前是需要查詢的線程來添加緩存,這裡考慮線程B來添加緩存,線程B是操作資料庫的緩存),如果線程B的添加也在線程A的寫入緩存之前完成也會造成資料庫與緩存的一致性問題發生。那麼是否可以延時一段時間(例如5s,10s)再進行數據的添加,這樣子雖然最終會統一資料庫與緩存的一致性,但是若是在這5s,10s內又有線程C,D等等來進行緩存的訪問呢?C,D線程的訪問還是訪問到了無效的緩存信息。
所以在資料庫與緩存的一致性問題上,除非在寫入正確緩存之前拒絕相關請求進行伺服器來進行訪問才能避免用戶訪問到錯誤信息,但是拒絕請求對用戶來說是致命的,極大可能會導致用戶直接放棄使用應用,所以我們只能儘可能的減少問題可能性的發生。(個人理解,有問題可以在評論區留言賜教)
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (null==id){
return Result.fail("店鋪id不能為空");
}
//更新資料庫
boolean b = updateById(shop);
//刪除緩存
stringRedisTemplate.delete(SHOPCACHEPREFIX+shop.getId());
return Result.ok();
}
4. 緩存穿透
緩存穿透是指客戶端請求的數據在緩存中和資料庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到資料庫。
解決方案:
緩存空對象
缺點:
空間浪費
如果緩存了空對象,在空對象的有效期內,我們後台在資料庫新增了和空對象相同id的數據,這樣子就會造成資料庫與緩存一致性問題
布隆過濾器
優點:
內存占用少
缺點:
實現複雜
存在誤判的可能(存在的數據一定會判斷成功,但是不存在的數據也有可能會放行進來,有機率造成緩存穿透)
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);
//判斷緩存中數據是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉換為對象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return Result.ok(shop);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
// 因為上面判斷了cacheShop是否為空,如果進到這個方法裡面則一定是空,直接過濾,不打到資料庫
if (null != cacheShop){
return Result.fail("信息不存在");
}
//緩存中不存在,則從資料庫里進行數據查詢
Shop shop = getById(id);
//資料庫里不存在,返回404
if (null==shop){
// 緩存空對象
StringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,"",2,TimeUnit.MINUTES);
return Result.fail("信息不存在");
}
//資料庫里存在,則將信息寫入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加複雜度
權限
熱點參數的限流
5. 緩存雪崩
緩存雪崩是指在同一時段大量的緩存key同時失效或者Redis服務宕機,導致大量請求到達資料庫,帶來巨大壓力。
解決方案:
給不同的Key的TTL添加隨機值
大量的Key同時失效,極大可能是TTL相同,我們可以隨機給TTL
利用Redis集群提高服務的可用性
給緩存業務添加降級限流策略
給業務添加多級緩存
6. 緩存擊穿
緩存擊穿問題也叫熱點Key問題,就是一個被高並發訪問並且緩存重建業務較複雜的key突然失效了,無數的請求訪問會在瞬間給資料庫帶來巨大的衝擊。
常見的解決方案:
互斥鎖
邏輯過期
互斥鎖:
即採用鎖的方式來保證只有一個線程去重建緩存數據,其餘拿不到鎖的線程休眠一段時間再重新重頭去執行查詢緩存的步驟
優點:
沒有額外的內存消耗(針對下面的邏輯過期方案)
保證了一致性
缺點:
線程需要等待,性能受到了影響
可能會產生死鎖
邏輯過期:
邏輯過期是在緩存數據中額外添加一個屬性,這個屬性就是邏輯過期的屬性,為什麼要使用這個來判斷是否過期而不使用TTL呢?因為使用TTL的話,一旦過期,就獲取不到緩存中的數據了,沒有拿到鎖的線程就沒有舊的數據可以返回。
它與互斥鎖最大的區別就是沒有線程的等待了,誰先獲取到鎖就去重建緩存,其餘線程沒有獲取到鎖就返回舊數據,不去做休眠,輪詢去獲取鎖。
重建緩存會新開一個線程去執行重建緩存,目的是減少搶到鎖的線程的響應時間。
優點:
線程無需等待,性能好
缺點:
不能保證一致性
緩存中有額外的內存消耗
實現複雜
兩個方案各有優缺點:一個保證了一致性,一個保證了可用性,選擇與否主要看業務的需求是什麼,側重於可用性還是一致性。
6.1 互斥鎖代碼
互斥鎖的鎖用什麼?
使用Redis命令的setnx命令。
首先實現獲取鎖和釋放鎖的代碼
/**
* 嘗試獲取鎖
*
* @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);
}
代碼實現
public Shop queryWithMutex(Long id) throws InterruptedException {
//從Redis查詢商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//判斷緩存中數據是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉換為對象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return shop;
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
// 因為上面判斷了cacheShop是否為空,如果進到這個方法裡面則一定是空,直接過濾,不打到資料庫
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);
}
//緩存中不存在,則從資料庫里進行數據查詢
shop = getById(id);
//資料庫里不存在,返回404
if (null == shop) {
// 緩存空對象
stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, "", 2, TimeUnit.MINUTES);
return null;
}
//資料庫里存在,則將信息寫入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 邏輯過期實現
邏輯過期不設置TTL
代碼實現
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
由於是熱點key,所以key基本都是手動導入到緩存,代碼如下
/**
* 邏輯過期時間對象寫入緩存
* @param id
* @param expireSeconds
*/
public void saveShopToRedis(Long id,Long expireSeconds){
// 查詢店鋪數據
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));
}
邏輯過期代碼實現
/**
* 緩存擊穿:邏輯過期解決
* @param id
* @return
* @throws InterruptedException
*/
public Shop queryWithPassLogicalExpire(Long id) throws InterruptedException {
//1. 從Redis查詢商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//2. 判斷緩存中數據是否存在
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 獲取鎖成功,開啟獨立線程,實現緩存重建,建議使用線程池去做
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);