引言
在我們平時的開發工作中,對於一些複雜的業務接口可以通過優化業務邏輯來增加接口的吞吐量,但在更多時候,簡單的優化業務邏輯,調節系統參數仍然無法滿足性能提升的要求,爲了系統性能的提升,我們選擇將部分數據放入緩存當中,數據庫則主要承擔數據落盤的工作,合理,正確的使用緩存可以極大的提升系統的性能,大幅提升接口的響應速度!下面就讓我們一起深入瞭解性能提升大殺器—緩存。
緩存的使用
緩存固然香,但絕不是萬金油,盲目爲了提升性能而濫用緩存往往會弄巧成拙,需要我們根據業務去具體分析。
哪些數據適合放入緩存
- 即時性、數據一致性要求不高的數據,例如商品分類
- 訪問量大且不經常更新的數據,例如物流信息
讀模式緩存的大體使用流程如下:
特別注意:在開發中,凡是放入緩存中的數據我們都應該設置過期時間,這樣就算沒有主動更新緩存的機制也可以觸發數據加載進入緩存的流程,避免因業務崩潰導致的數據永久不一致問題。
springboot整合redis
我們最常用的緩存中間件非redis莫屬,redis是一款非常優秀的鍵值對存儲數據庫,springboot集成redis的方式十分簡單:
首先,引入maven依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>1.5.8.RELEASE</version>
</dependency>
<!--序列化使用-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.68</version>
</dependency>
配置application.yml:
spring:
redis:
# ip地址
host: localhost
port: 6379
password:
timeout: 3000
lettuce.pool.max-active: 10
lettuce.pool.max-wait: 5
lettuce.pool.max-idle: 8
lettuce.pool.min-idle: 0
配置redisConfig(指定序列化方式):
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
/**
* fastJson舊版本有漏洞,容易遭黑客攻擊,新版本需要添加類型轉換白名單
* @see https://github.com/alibaba/fastjson/wiki/enable_autotype
*/
ParserConfig.getGlobalInstance().addAccept("需要使用序列化的包名");
// key採用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也採用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式採用fastjson
template.setValueSerializer(fastJsonRedisSerializer);
// hash的value序列化方式採用fastjson
template.setHashValueSerializer(fastJsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
使用redisTemplate操作redis:
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Test
public void test(){
String key = "key";
String value= "value";
long time = 60L;
//設置或更新 key value
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
//key不存在時存入
boolean isSave = redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);
//獲取key的value
Object o = redisTemplate.opsForValue().get(key);
//刪除key
redisTemplate.delete(key);
//設置key的過期時間
redisTemplate.expire(key, time, TimeUnit.SECONDS);
//獲取過期時間
long expireTime = redisTemplate.getExpire(key, TimeUnit.SECONDS);
//判斷key是否存在
boolean isExist = redisTemplate.hasKey(key);
}
緩存雪崩、緩存穿透、緩存擊穿問題
在大併發讀的情景下有很大概率遇到緩存失效問題,緩存失效問題主要有以下三種:緩存雪崩,緩存擊穿和緩存穿透。
緩存雪崩
緩存雪崩是指我們設置緩存的時候採用了相同的過期時間,導致緩存在某一時刻同時失效,恰巧那一時刻併發量很大,導致所有請求轉發到數據庫,數據庫瞬時壓力過重崩潰,從而導致系統崩潰,如大批鬼子進村。
解決:通常我們會給緩存在原來失效時間的基礎上增加一個隨機值,例如1-5分鐘隨機,這樣會大大降低緩存過期時間的重複概率,從而避免緩存集體失效。
緩存擊穿
某些被設置了過期時間的 key 可能被同一時間被超高併發訪問,比如雙十一定點搶購某一商品的信息,這種數據被稱爲熱點數據。如果這個 key 在大量請求同時進來的時候正好過期失效,那麼對於這個 key 的相關查詢全部落到數據庫,導致數據庫崩潰,這種現象稱之爲緩存擊穿,如八路軍炸鬼子碉堡。
解決:使用分佈式鎖,在緩存同時失效的情況下,保證只放行一個請求讀取數據庫並更新緩存。
緩存穿透
緩存穿透是指查詢一個一定不存在的數據,由於緩存不命中,程序會去查詢數據庫,但是數據庫也無此記錄,這將導致這個不存在的數據每次請求都要到數據庫去查詢,緩存形同虛設,當流量大時,數據庫可能因此掛掉。如特務進城。
解決:緩存空結果、設置短的過期時間(簡單粗暴);布隆過濾器。
緩存數據一致性問題
爲了解決緩存和數據庫的數據一致性問題,主要有以下幾種方法:
雙寫模式
顧名思義,雙寫模式是指寫數據庫的同時更新緩存,但這種模式只能保證數據的最終一致性,如圖:
用戶1 和 用戶2 先後 執行了數據庫的寫操作,然後更新緩存,理論上緩存的最新數據應該是 用戶2 寫入的數據,但由於 用戶1 在寫數據庫的時候產生了卡頓,導致 用戶2 先寫完數據並更新緩存,最後緩存更新爲了 用戶1 寫入的數據,導致數據庫中的數據是 用戶2 寫入的數據,但緩存中的數據卻是 用戶1 寫入的數據,產生了數據不一致問題。
但隨着後續的數據更新,或者緩存過期後,可以保證數據的最終一致性。
失效模式
失效模式是指寫數據的時候刪除緩存,從而下次查詢觸發緩存更新。如圖:
但是很遺憾,這種模式也只能保證數據的最終一致性。
例如:用戶1 和 用戶2 先後 執行了數據庫的寫操作,並刪除緩存,正常情況下緩存最終更新的數據應該是 用戶2 寫入的數據,但由於 用戶2 的寫操作卡頓,數據庫沒有寫入完畢,這個時候 用戶3 讀取數據的時候讀取到的則是 用戶1 寫入的數據,隨之更新的緩存也是 用戶1 寫入的數據,從而導致數據不一致的問題產生。
分佈式讀寫鎖
使用分佈式讀寫鎖可以完美解決緩存數據不一致的問題,想要讀數據必須等待寫數據整個操作完成,這個之後的小節裏會詳細介紹。
使用阿里中間件canal
canal是阿里推出的一個數據同步中間件,可以用於實時監聽數據庫的數據變動操作,原理是僞造成數據庫的從節點,訂閱binlog日誌來實現實時監聽。
這樣當數據變動的時候,我們可以根據監聽的具體操作去更新緩存,達到緩存數據一致性,但可能存在一定延遲。
canal在大數據系統中可以用來解決數據異構問題,可以根據訂閱表的相關操作進行數據整合和分析,生成想要的結構數據,比如監聽每個用戶的瀏覽訪問記錄,進行整合分析,生成用戶推薦表,這樣用戶瀏覽的時候便可以看到自己喜歡的內容。
分佈式鎖
利用分佈式鎖我們可以很好的解決緩存擊穿和數據一致性問題。
通過加鎖操作我們可以讓寫數據變爲原子性操作,不被其他線程干擾。
在單體應用環境下如果我們想要操作具備原子性可以使用 synchronized 關鍵字或者ReentrantLock解決,但在分佈式環境下顯然已不再適用,因爲不同的應用沒有共享JVM,本地鎖也就失去了意義。
自己手寫一個分佈式鎖
在分佈式環境下,我們可以使用 redis 來實現分佈式鎖:
- 定義一個 key 的值。
- 當請求進來,判斷 redis 中是否存在此 key,若不存在,新增 key,生成一個隨機值作爲value,並設置過期時間,業務執行之後,獲取 key 對應的 value 與之前生成的 value 比較,若相等,刪除 key;若 key 存在,等待一段時間,再次嘗試獲取,直到獲取到爲止。
redis 的每個命令都是原子性的,所以我們不用考慮在命令執行的過程中被其他進程干擾的問題。同時加鎖操作和解鎖操作必須保證其原子性,否則在大併發情況下就會出現問題。
加鎖 = 判斷 key + 新增 key + 設置過期時間
解鎖 = 判斷 key 的 value 是否一致 + 刪除 key
加鎖操作 redis 有原生的命令可以支持,解鎖操作則需要使用 lua 腳本解決,否則無法保證其原子性,設計如下:
大致代碼如下:
public void testLock() {
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock",uuid,300,TimeUnit.SECONDS);
if (lock) {
System.out.println("獲取分佈式鎖成功...");
try {
//執行業務代碼。。。
} finally {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//刪除鎖
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class) , Arrays.asList("lock"), uuid);
}
} else {
System.out.println("獲取分佈式鎖失敗...等待重試");
try {
Thread.sleep(200);
} catch (Exception ignored){}
//自旋獲取鎖
testLock();
}
}
使用 redisson 實現分佈式鎖
前面我們自己利用 redis 實現了分佈式鎖的基本功能,雖然比較簡陋,但有助於我們理解其中的原理,在實際的開發中,我們可以直接利用 Redisson 來實現相關功能!
Redisson 是架設在 Redis 基礎上的一個 Java 駐內存數據網格(In-Memory Data Grid)。充分利用了 Redis 鍵值數據庫提供的一系列優勢,基於Java 實用工具包中常用接口,爲使用者提供了一系列具有分佈式特性的常用工具類。使得原本作爲協調單機多線程併發程序的工 具包獲得了協調分佈式多機多線程併發系統的能力,大大降低了設計和研發大規模分佈式系統的難度。同時結合各富特色的分佈式服務,進一步簡化了分佈式環境中程序相互之間 的協作。
springboot 集成 Redisson
引入 maven 依賴:
<dependency>
<groupId>org.redission</groupId>
<artifactId>redission</artifactId>
<version>3.12.0</version>
</dependency>
使用方法如下:
配置 MyRedissonConfig:
@Configuration
public class MyRedissonConfig {
/**
* 所有對Redisson的使用都是通過RedissonClient
* @return
* @throws IOException
*/
@Bean
public RedissonClient redisson() throws IOException {
//1、創建配置
Config config = new Config();
//集羣模式
/* config.useClusterServers()
.addNodeAddress("127.0.0.1:6379","127.0.0.1:6380")*/
//單點模式
//Redis url should start with redis:// or rediss://(啓用SSL)
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
//2、根據Config創建出RedissonClient實例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
引入 MyRedissonConfig 使用:
@Autowired
private RedissonClient redissonClient;
@Test
private void testLock() {
//獲取一把鎖,只要鎖名字相同,就是一把鎖
RLock lock = redisson.getLock("my-lock");
//加鎖
lock.lock();
// 嘗試加鎖,最多等待 100 秒,上鎖以後 10 秒自動解鎖
//boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
// 加鎖以後 10 秒鐘自動解鎖
//lock.lock(10, TimeUnit.SECONDS);
try {
//執行業務代碼
} finally {
//解鎖
lock.unlock()
}
}
也許大家會有疑問,在使用 lock 方法沒有指定過期時間的情況下,如果負責儲存分佈式鎖的 redisson 節點宕機後,這個鎖正好處於鎖定狀態,會出現鎖死的情況,爲了避免這種情況的發生,redission 對鎖設置了默認的過期時間爲30s,可以通過修改Config.lockWatchdogTimeout指定,所以就算宕機,鎖也會在指定時間內過期,也就不會出現死鎖的情況。
此外,redisson 內部提供了一個監控鎖的看門狗,它的作用是在 redisson 實例被關閉前,不斷對鎖進行續期,如果你的業務執行時間較長,它可以爲鎖自動續期,保證業務執行完畢後再釋放鎖,不被其他進程干擾,但一般情況下,我們會事先評估業務完成所需要的時間,設置鎖的過期時間>=業務完成的時間,因爲自動續期會消耗一定的性能,不過最終採用何種方式加鎖具體還要根據項目需求而定。
redisson 還支持讀寫鎖 ReadWriteLock ,閉鎖 countDownLatch,信號量 Semaphore 等,用法跟 java.util.concurrent 包下對應類的用法基本是一樣的,這裏就不再一一列舉啦,同學們可以自行了解。
小結
看到這裏,相信同學們應該對緩存有了更深的理解,我們在考慮使用緩存的時候應該儘量遵循以下原則:
- 放入緩存的數據不應該是實時性,一致性要求超高的。
- 不應該過度設計,增加系統整體的複雜性。
- 遇到實時性,一致性要求高的數據,即使慢一些,也推薦直接查數據庫。
互聯網開發真的沒有銀彈,具體業務需要我們具體分析,深思熟慮,才能找到適合自己系統業務的解決方案,千萬不要爲了用技術而用技術,當你能站在系統層面整體考慮,割捨掉一些技術情懷的時候,就真的成長了。
最後,送大家一份最新整理的Java面試題目(帶答案),有準備跳槽的小夥伴可以收藏下,涵蓋的知識點還是很全面的,對於面試絕對是有幫助的,面試官基本也就問這些問題,部分題目如下:
喜歡的朋友關注公衆號
螺旋編程極客
發送 雙十一
免費獲取哦,我們下次更新再見啦!