?什么是緩存?
緩存就是數據交換的緩沖區,是存貯數據的臨時地方,一般讀寫性能較高。
怎么防止緩存穿透?
緩存穿透是指客戶端請求的數據在緩存中和數據庫中都不存在,這樣緩存永遠不會生效,這些請求都會打到數據庫,給數據庫帶來巨大壓力。
常見的解決方案有兩種:
緩存空對象
客戶端第一次請求是,發現數據庫中數據不存在,在緩存中設置該值為空串,后面的請求中若發現緩存中存儲的值為空串直接返回空串,不在查詢數據庫。(緩存需要設置一定時間內過期,防止數據庫中有數據后緩存仍然為空。或者在插入數據時,清空緩存)
- ????????優點:實現簡單,維護方便
- ????????缺點:額外的內存消耗,可能造成短期的不一致
/*** 設置空值解決緩存穿透*/public Shop queryWithPassThrough(Long id){//先從redis查詢商鋪緩存,若存在,從redis中返回,否則查詢數據庫,存在寫入redis,并返回String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);if(StringUtils.isNotBlank(shopJson)){Shop shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//判斷命中的是否為空,防止緩存穿透if(shopJson==null){ //若為null,說明redis中數據為空字符串,說明mysql數據庫也沒有數據return null;}//redis不存在,查詢數據庫Shop shop = getById(id);if(shop==null){//將空值寫入redisstringRedisTemplate.opsForValue().set("cache:shop:"+id,"",30, TimeUnit.MINUTES);return null;}stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(shop));return shop;}
布隆過濾
- ?????優點:內存占用較少,沒有多余key
- ?????缺點:實現復雜、存在誤判可能
其他方案:
?????????增強id的復雜度,避免被猜測id規律
?????????做好數據的基礎格式校驗
?????????加強用戶權限校驗
?????????做好熱點參數的限流
為什么會出現緩存雪崩?
緩存雪崩是指在同一時段大量的緩存key同時失效或者Redis服務宕機,導致大量請求到達數據庫,帶來巨大壓力。
解決方案:
- 給不同的Key的TTL添加隨機值
- 利用Redis集群提高服務的可用性
- 給緩存業務添加降級限流策略
- 給業務添加多級緩存
如何解決緩存擊穿?
緩存擊穿問題也叫熱點Key問題,就是一個被高并發訪問并且緩存重建業務較復雜的key突然失效了,無數的請求訪問會在瞬間給數據庫帶來巨大的沖擊。
常見的解決方案有兩種:
互斥鎖
?在高并發環境下,當有一個線程獲得鎖訪問數據庫時,其他線程等待。假如業務A需要獲取緩存A和緩存B,而業務B需要獲取緩存B和緩存A。此時業務A已經獲取了緩存A的鎖正在等待緩存B,而業務B獲取了緩存B的鎖等待緩存A,就出現了互相等待的情況,產生死鎖。
? ? ? ? 優點:沒有額外的內存消耗,保證一致性,實現簡單
? ? ? ? 缺點:線程需要等待,性能受影響,可能有死鎖風險
/*** 在設置空值,已經解決緩存穿透的基礎上,添加互斥鎖解決緩存擊穿*/public Shop queryWithMutex (Long id){//先從redis查詢商鋪緩存,若存在,從redis中返回,否則查詢數據庫,存在寫入redis,并返回String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);if(StringUtils.isNotBlank(shopJson)){Shop shop = JSONUtil.toBean(shopJson, Shop.class);return shop;}//判斷命中的是否為空,房子緩存穿透if(shopJson==null){ //部位null,說明redis中數據為空字符串,說明mysql數據庫也沒有數據return null;}//redis不存在,失效緩存重建//獲取互斥鎖,每個店鋪創建一個鎖String LockKey = "lock:shop:"+id;Shop shop = null;try {boolean isLock = tryLock(LockKey);//獲取鎖失敗,休眠重試if(!isLock){Thread.sleep(50);return queryWithMutex(id);}//獲取到鎖,查詢數據庫shop = getById(id);if(shop==null){//將空值寫入redisstringRedisTemplate.opsForValue().set("cache:shop:"+id,"",30, TimeUnit.MINUTES);return null;}stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(shop));}catch (InterruptedException e){throw new RuntimeException("系統異常");}finally {//釋放鎖unLock(LockKey);}return shop;}/***獲取鎖*/private boolean tryLock(String key){Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);}/*** 釋放鎖*/private void unLock(String key){stringRedisTemplate.delete(key);}
邏輯過期:
? ? ? ? 優點:線程無需等待,性能較好
? ? ? ? 缺點:不保證一致性,有額外內存消耗,實現復雜
//線程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);/*** 使用邏輯過期解決緩存擊穿(實際上數據不會過期,故不需要考慮緩存穿透問題),使用時需要先緩存熱點數據*/public Shop queryWithLogicalExpire (Long id){//先從redis查詢商鋪緩存,若存在,從redis中返回,否則查詢數據庫,存在寫入redis,并返回String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);//緩存中不存在,直接返回null。(熱點數據,通過一般需要自行初始化到redis緩存中,一般不會出現null的情況)if(StringUtils.isBlank(shopJson)){return null;}RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//獲取緩存數據LocalDateTime expireTime = redisData.getExpireTime();//獲取緩存過期時間//判斷是否過期if(expireTime.isAfter(LocalDateTime.now())){//未過期,直接返回return shop;}//過期,需要緩存重建//獲取互斥鎖String LockKey = "lock:shop:"+id;if(tryLock(LockKey)){//獲取鎖成功,開啟獨立線程,實現緩存重建CACHE_REBUILD_EXECUTOR.submit(() ->{//重建緩存try{this.saveShop2Redis(id,20L);}catch (Exception e){throw new RuntimeException(e);}finally {//釋放鎖unLock(LockKey);}});}return shop;}public void saveShop2Redis(Long id,Long expireSeconds){//查詢店鋪數據Shop shop = getById(id);//封裝邏輯過期時間RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusMinutes(expireSeconds));//寫入redisstringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(redisData));}/***獲取鎖*/private boolean tryLock(String key){Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);}/*** 釋放鎖*/private void unLock(String key){stringRedisTemplate.delete(key);}
全局ID生成器
如果使用數據庫自增ID就存在一些問題:
- id的規律性太明顯
- 受單表數據量的限制
全局ID生成器,是一種在分布式系統下用來生成全局唯一ID的工具,一般要滿足下列特性:
為了增加ID的安全性,我們可以不直接使用Redis自增的數值,而是拼接一些其它信息:
ID的組成部分:
- 符號位:1bit,永遠為0
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號:32bit,秒內的計數器,支持每秒產生2^32個不同ID
Redis自增ID策略:
- 每天一個key,方便統計訂單量
- ID構造是 時間戳 + 計數器
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;@Component
public class RedisIdWorker {private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}// 2022年開始時間戳private static final long BEGIN_TIMESTAMP = 1640995200L;// 序列號位數private static final int COUNT_BITS = 32;public long nextId(String KeyPrefix) {// 1.生成時間戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowSecond-BEGIN_TIMESTAMP;// 2.生成序列號String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));//自增長,返回自增序列號,key不存在會自動創建一個keylong count = stringRedisTemplate.opsForValue().increment("icr:" + KeyPrefix + ":" + date);// 3.拼接并返回// 時間戳左移32位,通過|運算,拼接序列號return timestamp << COUNT_BITS | count;}
}