快取與分散式鎖的應用
快取與分散式鎖的應用
目錄1、快取應用
快取使用場景:
為了提升系統性能,我們一般會將部分資料放入快取中,加速訪問,而資料庫只承擔資料的落盤工作
那麼哪些資料適合放入快取呢?
- 即時性,資料一致性要求不高的
- 訪問量大且更新效率不高的
舉例:電商類應用、商品分類、商品列表等適合放入快取並加一個更新時間(由資料更新頻率來定),後臺釋出一個商品,買家需要5分鐘後才能看到新的商品一般是可以接受的
1、本地應用
/** * 自定義快取 */ private Map<String, Object> cache = new HashMap<>(); /** * 獲取真實資料 * * @return */ public NeedLockDataVO getDataVO() { NeedLockDataVO cacheDataVO = (NeedLockDataVO) cache.get("cacheDataVO"); // 如果有,就返回 if (cacheDataVO != null) { return cacheDataVO; } // 如果沒有,獲取到db中資料 NeedLockDataVO needLockDataVO = doGetNeedLockDataVO(); // put進快取 return (NeedLockDataVO) cache.put("cacheDataVO", needLockDataVO); } private NeedLockDataVO doGetNeedLockDataVO() { // TODO 繁瑣業務邏輯程式碼 try { Thread.sleep(3000); } catch (InterruptedException e) { } return new NeedLockDataVO(); }
我們這裡的快取元件利用了原生的Map。
在同一個專案,同一個JVM中,即本地儲存一個副本的,可以稱為本地快取。
在單個服務應用中,我們使用本地快取模式,快取元件和應用如果永遠只用同一個機器上部署,不會出現任何問題,並且效率很高。
但是我們思考下面一個場景:
如果在一個分散式的系統下,我們一個服務專案往往會部署十幾臺伺服器上,每一個伺服器都自帶一個自己的一個本地快取,這樣會出現什麼問題呢?
分散式場景下,單體應用自帶的快取僅對與自己所在伺服器上生效。假設我們有一個商品服務,同時部署在多個伺服器上,客戶端發起了查詢一個商品列表的請求,我們通過負載均衡找到第一臺伺服器,發現一號伺服器的本地快取中沒有,就從資料庫中查詢出來,放到了一號伺服器的本地快取中。第二個客戶端請求同樣是查詢這個商品列表,通過負載均衡,找到了第二臺伺服器,那麼此時第二臺伺服器的本地快取是不會有第一臺的快取資訊的。如此會引來一下問題:
問題:
- 伺服器各顧各的,每一個請求進來,都得查一遍放入自己的快取中
- 如果對資料進行修改,一般還要修改快取中的資料,假設我們通過負載均衡,只修改了一號伺服器快取資料,那麼以後負載均衡到其他伺服器上的請求所得到的資料,就會和一號伺服器的資料有所不同,就會出現一個嚴重的問題:資料一致性問題
那麼,我們分散式系統下,該如何使用快取,解決資料一致性的問題呢?
2、分散式場景中應用
我們引入一箇中間件redis,將快取控制交給第三方來處理,所有讀取|寫入快取的資料都交由redis,本地不再做快取
/**
* StringRedisTemplate
*/
@Resource
StringRedisTemplate cache;
/**
* 獲取真實資料
*
* @return
*/
public NeedLockDataVO getDataVO() {
// 從redis中取出
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// 如果沒有,獲取到db中資料
NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
// 存入redis
cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
return needLockDataVO;
}
private NeedLockDataVO doGetNeedLockDataVO() {
// TODO 繁瑣業務邏輯程式碼
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
接下來,我們利用jmeter壓測一下會發現,redis後期會頻繁報錯:OutOfDirectMemoryError
產生的原因:
Redis自動配置
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
}
SpringBoot2.0以後預設使用Lettuce作為操作redis的客戶端,它使用netty進行網路通訊lettuce的bug導致堆外記憶體溢位,netty如果沒有指定堆外記憶體,就會預設使用虛擬機器的-Xms的值,可以使用-Dio.netty.maxDirectMemory進行設定,時間久了堆外記憶體溢位問題肯定還會出現,治標不治本。
那我們該如何解決這個棘手的問題呢?
Jedis 替換SpringBoot預設使用的Lettuce
首先排除Lettuce包:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
堆外記憶體溢位OutOfDirectMemoryError,完美解決!
我們通過加第三方快取解決了快取一致性的問題,可是我們設想一個場景,如果有兩臺伺服器,A先請求更新db,B在之後更新db,但是A請求的伺服器,網路出現問題導致延時,B請求的伺服器在A請求的伺服器之前首先操作快取,那麼我們按照常理來講,應該是從快取中查詢到最後一次更新的資料,這就引來了另一個問題:快取的最終一致性問題
那麼,我們對這個"快取最終一致性"問題又該如何解決呢?我們先從快取存在的安全問題引出來解決方案!
3、快取相關問題
3.1、快取穿透
查詢了一定不存在的資料-快取穿透
快取穿透:是指快取和資料庫中都不存在的資料,而使用者不斷髮起請求,如發起查詢id=“-1”的資料或id為特別大不存在的資料,這時候使用者很可能是攻擊者,攻擊會導致資料庫壓力過大
解決方案:
- 介面層增加校驗(使用者鑑權、id作為基礎校驗,過濾id為-1的請求)
- 快取中去不到的資料,也寫入快取中,以key-null形式儲存(快取時間設定短一些,設定太長會導致正常情況也無法使用),這樣可以防止用同一個id暴力攻擊
- **布隆過濾器:具體看大佬的文章
3.2、快取雪崩
快取中不同資料大批量過期-快取穿透
快取雪崩:是指快取中資料大批量過期而且查詢量巨大,引起資料庫壓力巨大,甚至宕機,與快取擊穿不同,雪崩是查詢不同的資料都過期了
解決方案:
- 隨機時間:在原有的過期時間的基礎上,加上一個隨機的時間,防止同一時間資料大批量過期
- 永不過期快取:在情況允許的情況下,設定快取資料永不過期
- redis高可用:預防redis宕機導致雪崩問題
- 限流降級:通過加鎖和佇列的方式進行對資料庫的讀取和寫快取,例如:對某個key只允許一個執行緒查詢資料庫,其他執行緒等待
- 資料預熱:在正式部署之前,先把資料訪問一遍加入快取,設定不同的過期時間,讓快取失效的時間點儘量均勻
3.3、快取擊穿
熱點key剛好失效-快取擊穿
快取擊穿:一個熱點key在某個時間點失效的情況下,有大批量執行緒去查詢該key,導致大批量執行緒去查詢資料庫,引起資料庫壓力巨大,甚至宕機
解決方案:
- 互斥鎖:在併發的多個請求中,只有第一個請求執行緒能拿到鎖並執行資料庫查詢操作,等到第一個執行緒將資料寫入快取後,其他執行緒直接走快取
- 分散式鎖:在分散式場景下,本地互斥鎖不能保證只有一個執行緒去查詢資料庫,也可以使用分散式鎖去避免擊穿問題
- 熱點資料不過期:直接將快取設定為不過期,然後由定時任務去非同步載入資料,更新快取
關於快取擊穿,我們如何選定方案呢?
本質上我們是在併發場景很高的情況下,通過降低訪問資料庫的執行緒併發量,來達到避免快取擊穿的問題出現。
互斥鎖VS分散式鎖:
我們很多時候是通過叢集部署多個相同的服務,本地互斥鎖雖然不能嚴謹控制單個執行緒查詢資料庫,但是我們的目的是降低併發量,只要保證走到資料庫的請求能大大降低即可,所以還有另一個思路是 JVM 鎖,當然如果要保證快取最終一致性的場景,我們還是需要用到分散式鎖作為最終解決方案的!
JVM 鎖保證了在單臺伺服器上只有一個請求走到資料庫,通常來說已經足夠保證資料庫的壓力大大降低,同時在效能上比分散式鎖更好。
值得注意的是:無論是使用“分散式鎖”,還是“JVM 鎖”,加鎖時要按 key 維度去加鎖。
使用固定的key值加鎖,這樣會導致不同的 key 之間也會互相阻塞,造成效能嚴重損耗。
2、擊穿解決方案-鎖的應用
綜合上面的結果,我們的redis快取雖然提升了效能,但是在一些特殊場景下,仍會存在一些問題(快取擊穿與資料最終一致性)。
我們瞭解到分散式鎖是可以通過單個執行緒訪問資料庫資源,解決上面兩個問題的,那麼我們接下來討論一下“鎖”相關的應用。
1、本地鎖(包括JUC包下)
在我們引入解決方案之前,我們先看一個例子:
/**
* 獲取真實資料
*
* @return
*/
public NeedLockDataVO getDataVO() {
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// 如果沒有,獲取到db中資料
NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
// 存入redis
cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
return needLockDataVO;
}
private NeedLockDataVO doGetNeedLockDataVO() {
// 資料本地加鎖
synchronized (this){
// TODO 繁瑣業務邏輯程式碼
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
}
假設我們例項,交由Spring來管理,this獲取的是同一個
不知道我們有沒有發現以下問題:
- 快取的讀取與存入均不在鎖內,即便是單體伺服器,併發情況下都會出現宕機風險問題。
- 加鎖是在本地,多個伺服器下,仍然會有多個執行緒去訪問資料庫,快取資料一致性仍然得不到解決
我們對“1”的問題,只需要在進入鎖之後查一遍快取即可。
程式碼片段更改如下:
private NeedLockDataVO doGetNeedLockDataVO() {
synchronized (this){
// 再次查詢快取,預防宕機風險
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference<NeedLockDataVO>(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// TODO 繁瑣業務邏輯程式碼
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
}
我們對“2”的問題,如何做一個分散式的鎖來解決當前的隱患問題呢?
2、分散式鎖
什麼是?
當多個程序不在同一個系統中,用分散式鎖控制多個程序對資源的訪問。
分散式解決方案
針對分散式鎖的實現,目前比較常用的有以下幾種方案:
- 基於資料庫實現分散式鎖
- 基於快取(redis,memcached,tair)實現分散式鎖
- 基於Zookeeper實現分散式鎖
我們著重討論一下基於快取的分散式鎖演進實現:
階段一
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//加鎖成功。。。執行業務
Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//刪除鎖
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
問題:
- 如果我們現在在獲取到鎖以後,執行業務出現了異常,會導致鎖沒有釋放,造成死鎖
原因:加鎖和解鎖過程互不影響,不會整體回滾,沒有對出現異常後鎖做處理
解決方案:
- 為鎖指定過期時間,到期自動解鎖
階段二
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//加鎖成功。。。執行業務
//2:設定過期時間
stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//刪除鎖
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
問題:
- 同樣是如果因為異常原因,導致過期時間沒有設定執行,造成死鎖
原因:加鎖和設定過期時間側操作不是原子性
解決方案:
我們可以使用SET key value [EX seconds],保證加鎖和過期時間設定的原子性
階段三
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111",30, TimeUnit.SECONDS);
if (lock){
//加鎖成功。。。執行業務
//2:設定過期時間
// stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//刪除鎖
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
問題:
- 業務超時發現鎖已經到期自動刪除了,沒鎖可以刪除了,怎麼辦?
- 業務用時很長,鎖自動過期後,我們把別人的鎖刪除了,怎麼辦?其他的執行緒又進來怎麼辦?
原因:基於效能和網路的綜合原因,我們不能保證超時時間永遠小於過期時間,業務超時時間過長,會導鎖混亂,甚至達不到加鎖的目的。
解決方案:
我們要保證刪除鎖的時候,我們不可以刪除別人的鎖
階段四
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加鎖成功。。。執行業務
//2:設定過期時間
// stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
//獲取值對比和對比成功刪除一定要是一個原子操作
String lockValue = stringRedisTemplate.opsForValue().get("lock");
if (uuid.equals(lockValue)){
stringRedisTemplate.delete("lock");//刪除鎖
}
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
問題:
- 我們在做
uuid.equals(lockValue)
之後,由於網路原因導致超時,還沒有刪除鎖之前,其他執行緒更改了鎖,導致我們雖然覺得是自己的值,刪除的還是別人的鎖,又會有很多執行緒進來搶佔鎖。 - 業務用時很長,鎖自動過期後,我們把別人的鎖刪除了,怎麼辦?其他的執行緒又進來怎麼辦?
原因:刪除鎖沒能保證原子性
解決方案:
保證刪除鎖的時候的原子性
階段五
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加鎖成功。。。執行業務
Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
//原子刪除鎖
Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
問題:
- 仍然沒有解決鎖過期了的問題
原因:業務超時,還沒有刪除鎖,鎖就過期了,咋辦?
解決方案:
加長鎖時間
階段6
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1:佔分布式鎖。去redis佔坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加鎖成功。。。執行業務
Map<String, List<Catelog2Vo>> dataFromDB;
try {
dataFromDB = getDataFromDB();
}finally {
String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
//原子刪除鎖
Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
}
return dataFromDB;
}else {
//加鎖失敗。。。。重試
//休眠一百毫秒重試
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
分散式鎖總結:
- 設定足夠長的過期時間
- 加鎖和過期時間必須是原子性操作
- 刪除鎖也必須是原子性操作
3、Redisson——分散式中的JUC
在我們瞭解了分散式鎖的演進過程後,能解決一般的場景問題,但是遇到一些複雜的場景,我們需要更高階的分散式鎖,怎麼辦呢?Redis為我們提供了一站式解決方案——Redisson(Distributed locks with Redis)
Rediosson是什麼:Redisson是一個在Redis的基礎上實現的Java駐記憶體資料網格
所有用法,我們均可翻閱Redisson文件
4、Redisson 開始
配置-參考中文文件
/**
* @author lishanbiao
* @Date 2021/11/22
*/
@Configuration
public class MyRedissonConfig {
@Bean(destroyMethod="shutdown")
RedissonClient redisson() throws IOException {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
測試
@Autowired
private Redisson redisson;
/**
* hello world
*/
@RequestMapping("/delete")
// @RequiresPermissions("coupon:coupon:delete")
public String helloWorld(@RequestBody Long[] ids) {
// 獲取鎖
RLock lock = redisson.getLock("my-lock");
// 加鎖 阻塞式等待……
lock.lock();
try {
System.out.println("加鎖成功,執行業務……" + Thread.currentThread().getId());
Thread.sleep(3000);
} catch (Exception e) {
} finally {
// 解鎖
System.out.println("釋放鎖……" + Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
思考:程式刪除之前終端,會不會有死鎖問題呢?
測試會發現,並不會(自己動手實踐)。
原因:
翻看文件會發現,業務超長執行期間,Redisson內部提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,不斷的延長鎖的有效期。預設情況下,看門狗的檢查鎖的超時時間是30秒鐘,也可以通過修改Config.lockWatchdogTimeout來另行指定。
值得注意的是:如果為鎖指定了時間,則會關閉看門狗功能,業務超長後,刪除鎖的程式就會報錯
讀寫鎖
這些我只寫一些特性(具體請翻閱Redisson文件):
只要有寫鎖的存在都必須等待
- 讀 + 讀:相當於無鎖
- 讀 + 寫:寫等待讀鎖,讀完後執行
- 寫 + 讀:讀等待
- 寫 + 寫:寫等待
雙寫模式——寫資料庫的同時,去更新快取
失效模式——寫資料庫的同時去刪除快取,等待下一次讀取
我們根據上面兩張圖可以看出:
無論我們哪種模式,都會存在資料不一致的問題,但是我們可以怎麼辦?
- 如果使用者緯度資料(使用者不可能一會兒加單,一會兒刪單),併發機率非常小,不用考慮這個問題,加上過期時間,只需要隔一段時間主動查詢資料庫即可
- 如果是目錄,商品介紹等基礎資料,對業務產生不了大影響,允許快取的不一致,若想要考慮可以使用:cananl訂閱的方式
- 快取資料+過期時間:可以保證大部分的需求
- 通過加鎖併發讀寫,適合於寫少讀多的特點
總結:
- 我們放入快取的資料不應該是實時性、一致性要求超高的資料
- 不應該過度設計,增加系統的複雜性
- 遇到實時性要求高的資料,我們應該查資料庫,即使慢一些
本人只做彙總,以上所有來自各個大佬們