利用Redis進(jìn)行數(shù)據(jù)緩存的項(xiàng)目實(shí)踐
1. 引言
緩存有啥用?
- 降低對(duì)數(shù)據(jù)庫(kù)的請(qǐng)求,減輕服務(wù)器壓力
- 提高了讀寫(xiě)效率
緩存有啥缺點(diǎn)?
- 如何保證數(shù)據(jù)庫(kù)與緩存的數(shù)據(jù)一致性問(wèn)題?
- 維護(hù)緩存代碼
- 搭建緩存一般是以集群的形式進(jìn)行搭建,需要運(yùn)維的成本
2. 將信息添加到緩存的業(yè)務(wù)流程

上圖可以清晰的了解Redis在項(xiàng)目中所處的位置,是數(shù)據(jù)庫(kù)與客戶(hù)端之間的一個(gè)中間件,也是數(shù)據(jù)庫(kù)的保護(hù)傘。有了Redis可以幫助數(shù)據(jù)庫(kù)進(jìn)行請(qǐng)求的阻擋,阻止請(qǐng)求直接打入數(shù)據(jù)庫(kù),提高響應(yīng)速率,極大的提升了系統(tǒng)的穩(wěn)定性。
3. 實(shí)現(xiàn)代碼

下面將根據(jù)查詢(xún)商鋪信息來(lái)作為背景進(jìn)行代碼書(shū)寫(xiě),具體的流程圖如上所示。
3.1 代碼實(shí)現(xiàn)(信息添加到緩存中)
public static final String SHOPCACHEPREFIX = "cache:shop:";
@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON工具
ObjectMapper objectMapper = new ObjectMapper();
@Override
public Result queryById(Long id) {
//從Redis查詢(xún)商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//判斷緩存中數(shù)據(jù)是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉(zhuǎn)換為對(duì)象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return Result.ok(shop);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
//緩存中不存在,則從數(shù)據(jù)庫(kù)里進(jìn)行數(shù)據(jù)查詢(xún)
Shop shop = getById(id);
//數(shù)據(jù)庫(kù)里不存在,返回404
if (null==shop){
return Result.fail("信息不存在");
}
//數(shù)據(jù)庫(kù)里存在,則將信息寫(xiě)入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 緩存更新策略
數(shù)據(jù)庫(kù)與緩存數(shù)據(jù)一致性問(wèn)題,當(dāng)數(shù)據(jù)庫(kù)信息修改后,緩存的信息應(yīng)該如何處理?
| 內(nèi)存淘汰 | 超時(shí)剔除 | 主動(dòng)更新 | |
|---|---|---|---|
| 說(shuō)明 | 不需要自己進(jìn)行維護(hù),利用Redis的淘汰機(jī)制進(jìn)行數(shù)據(jù)淘汰 | 給緩存數(shù)據(jù)添加TTL | 編寫(xiě)業(yè)務(wù)邏輯,在修改數(shù)據(jù)庫(kù)的同時(shí)更新緩存 |
| 一致性 | 差勁 | 一般 | 好 |
| 維護(hù)成本 | 無(wú) | 低 | 高 |
這里其實(shí)是需要根據(jù)業(yè)務(wù)場(chǎng)景來(lái)進(jìn)行選擇
- 高一致性:選主動(dòng)更新
- 低一致性:內(nèi)存淘汰和超時(shí)剔除
3.3 實(shí)現(xiàn)主動(dòng)更新
此時(shí)需要實(shí)現(xiàn)數(shù)據(jù)庫(kù)與緩存一致性問(wèn)題,在這個(gè)問(wèn)題之中還有多個(gè)問(wèn)題值得深思
刪除緩存還是更新緩存?
當(dāng)數(shù)據(jù)庫(kù)發(fā)生變化時(shí),我們?nèi)绾翁幚砭彺嬷袩o(wú)效的數(shù)據(jù),是刪除它還是更新它?
更新緩存:每次更新數(shù)據(jù)庫(kù)都更新緩存,無(wú)效寫(xiě)操作較多
刪除緩存:更新數(shù)據(jù)庫(kù)時(shí)刪除緩存,查詢(xún)時(shí)再添加緩存
由此可見(jiàn),選擇刪除緩存是高效的。
如何保證緩存與數(shù)據(jù)庫(kù)的操作的同時(shí)成功或失敗?
單體架構(gòu):?jiǎn)误w架構(gòu)中采用事務(wù)解決
分布式架構(gòu):利用分布式方案進(jìn)行解決
先刪除緩存還是先操作數(shù)據(jù)庫(kù)?

在并發(fā)情況下,上述情況是極大可能會(huì)發(fā)生的,這樣子會(huì)導(dǎo)致緩存與數(shù)據(jù)庫(kù)數(shù)據(jù)庫(kù)不一致。

先操作數(shù)據(jù)庫(kù),在操作緩存這種情況,在緩存數(shù)據(jù)TTL剛好過(guò)期時(shí),出現(xiàn)一個(gè)A線程查詢(xún)緩存,由于緩存中沒(méi)有數(shù)據(jù),則向數(shù)據(jù)庫(kù)中查詢(xún),在這期間內(nèi)有另一個(gè)B線程進(jìn)行數(shù)據(jù)庫(kù)更新操作和刪除緩存操作,當(dāng)B的操作在A的兩個(gè)操作間完成時(shí),也會(huì)導(dǎo)致數(shù)據(jù)庫(kù)與緩存數(shù)據(jù)不一致問(wèn)題。
完蛋!?。煞N方案都會(huì)造成數(shù)據(jù)庫(kù)與緩存一致性問(wèn)題的發(fā)生,那么應(yīng)該如何來(lái)進(jìn)行選擇呢?
雖然兩者方案都會(huì)造成問(wèn)題的發(fā)生,但是概率上來(lái)說(shuō)還是先操作數(shù)據(jù)庫(kù),再刪除緩存發(fā)生問(wèn)題的概率低一些,所以可以選擇先操作數(shù)據(jù)庫(kù),再刪除緩存的方案。
個(gè)人見(jiàn)解:
如果說(shuō)我們?cè)谙炔僮鲾?shù)據(jù)庫(kù),再刪除緩存方案中線程B刪除緩存時(shí),我們利用java來(lái)刪除緩存會(huì)有Boolean返回值,如果是false,則說(shuō)明緩存已經(jīng)不存在了,緩存不存在了,則會(huì)出現(xiàn)上圖的情況,那么我們是否可以根據(jù)刪除緩存的Boolean值來(lái)進(jìn)行判斷是否需要線程B來(lái)進(jìn)行緩存的添加(因?yàn)橹笆切枰樵?xún)的線程來(lái)添加緩存,這里考慮線程B來(lái)添加緩存,線程B是操作數(shù)據(jù)庫(kù)的緩存),如果線程B的添加也在線程A的寫(xiě)入緩存之前完成也會(huì)造成數(shù)據(jù)庫(kù)與緩存的一致性問(wèn)題發(fā)生。那么是否可以延時(shí)一段時(shí)間(例如5s,10s)再進(jìn)行數(shù)據(jù)的添加,這樣子雖然最終會(huì)統(tǒng)一數(shù)據(jù)庫(kù)與緩存的一致性,但是若是在這5s,10s內(nèi)又有線程C,D等等來(lái)進(jìn)行緩存的訪問(wèn)呢?C,D線程的訪問(wèn)還是訪問(wèn)到了無(wú)效的緩存信息。
所以在數(shù)據(jù)庫(kù)與緩存的一致性問(wèn)題上,除非在寫(xiě)入正確緩存之前拒絕相關(guān)請(qǐng)求進(jìn)行服務(wù)器來(lái)進(jìn)行訪問(wèn)才能避免用戶(hù)訪問(wèn)到錯(cuò)誤信息,但是拒絕請(qǐng)求對(duì)用戶(hù)來(lái)說(shuō)是致命的,極大可能會(huì)導(dǎo)致用戶(hù)直接放棄使用應(yīng)用,所以我們只能盡可能的減少問(wèn)題可能性的發(fā)生。(個(gè)人理解,有問(wèn)題可以在評(píng)論區(qū)留言賜教)
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (null==id){
return Result.fail("店鋪id不能為空");
}
//更新數(shù)據(jù)庫(kù)
boolean b = updateById(shop);
//刪除緩存
stringRedisTemplate.delete(SHOPCACHEPREFIX+shop.getId());
return Result.ok();
}
4. 緩存穿透
緩存穿透是指客戶(hù)端請(qǐng)求的數(shù)據(jù)在緩存中和數(shù)據(jù)庫(kù)中都不存在,這樣緩存永遠(yuǎn)不會(huì)生效,這些請(qǐng)求都會(huì)打到數(shù)據(jù)庫(kù)。
解決方案:
緩存空對(duì)象

缺點(diǎn):
- 空間浪費(fèi)
- 如果緩存了空對(duì)象,在空對(duì)象的有效期內(nèi),我們后臺(tái)在數(shù)據(jù)庫(kù)新增了和空對(duì)象相同id的數(shù)據(jù),這樣子就會(huì)造成數(shù)據(jù)庫(kù)與緩存一致性問(wèn)題
布隆過(guò)濾器

優(yōu)點(diǎn):
內(nèi)存占用少
缺點(diǎn):
- 實(shí)現(xiàn)復(fù)雜
- 存在誤判的可能(存在的數(shù)據(jù)一定會(huì)判斷成功,但是不存在的數(shù)據(jù)也有可能會(huì)放行進(jìn)來(lái),有幾率造成緩存穿透)
4.1 解決緩存穿透(使用空對(duì)象進(jìn)行解決)
public static final String SHOPCACHEPREFIX = "cache:shop:";
@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON工具
ObjectMapper objectMapper = new ObjectMapper();
@Override
public Result queryById(Long id) {
//從Redis查詢(xún)商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//判斷緩存中數(shù)據(jù)是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉(zhuǎn)換為對(duì)象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return Result.ok(shop);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
// 因?yàn)樯厦媾袛嗔薱acheShop是否為空,如果進(jìn)到這個(gè)方法里面則一定是空,直接過(guò)濾,不打到數(shù)據(jù)庫(kù)
if (null != cacheShop){
return Result.fail("信息不存在");
}
//緩存中不存在,則從數(shù)據(jù)庫(kù)里進(jìn)行數(shù)據(jù)查詢(xún)
Shop shop = getById(id);
//數(shù)據(jù)庫(kù)里不存在,返回404
if (null==shop){
// 緩存空對(duì)象
stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,"",2,TimeUnit.MINUTES);
return Result.fail("信息不存在");
}
//數(shù)據(jù)庫(kù)里存在,則將信息寫(xiě)入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);
}
上述方案終究是被動(dòng)方案,我們可以采取一些主動(dòng)方案,例如
- 給id加復(fù)雜度
- 權(quán)限
- 熱點(diǎn)參數(shù)的限流
5. 緩存雪崩
緩存雪崩是指在同一時(shí)段大量的緩存key同時(shí)失效或者Redis服務(wù)宕機(jī),導(dǎo)致大量請(qǐng)求到達(dá)數(shù)據(jù)庫(kù),帶來(lái)巨大壓力。
解決方案:
- 給不同的Key的TTL添加隨機(jī)值
大量的Key同時(shí)失效,極大可能是TTL相同,我們可以隨機(jī)給TTL - 利用Redis集群提高服務(wù)的可用性
- 給緩存業(yè)務(wù)添加降級(jí)限流策略
- 給業(yè)務(wù)添加多級(jí)緩存
6. 緩存擊穿
緩存擊穿問(wèn)題也叫熱點(diǎn)Key問(wèn)題,就是一個(gè)被高并發(fā)訪問(wèn)并且緩存重建業(yè)務(wù)較復(fù)雜的key突然失效了,無(wú)數(shù)的請(qǐng)求訪問(wèn)會(huì)在瞬間給數(shù)據(jù)庫(kù)帶來(lái)巨大的沖擊。
常見(jiàn)的解決方案:
- 互斥鎖
- 邏輯過(guò)期
互斥鎖:

即采用鎖的方式來(lái)保證只有一個(gè)線程去重建緩存數(shù)據(jù),其余拿不到鎖的線程休眠一段時(shí)間再重新重頭去執(zhí)行查詢(xún)緩存的步驟
優(yōu)點(diǎn):
- 沒(méi)有額外的內(nèi)存消耗(針對(duì)下面的邏輯過(guò)期方案)
- 保證了一致性
缺點(diǎn):
- 線程需要等待,性能受到了影響
- 可能會(huì)產(chǎn)生死鎖
邏輯過(guò)期:

邏輯過(guò)期是在緩存數(shù)據(jù)中額外添加一個(gè)屬性,這個(gè)屬性就是邏輯過(guò)期的屬性,為什么要使用這個(gè)來(lái)判斷是否過(guò)期而不使用TTL呢?因?yàn)槭褂肨TL的話,一旦過(guò)期,就獲取不到緩存中的數(shù)據(jù)了,沒(méi)有拿到鎖的線程就沒(méi)有舊的數(shù)據(jù)可以返回。
它與互斥鎖最大的區(qū)別就是沒(méi)有線程的等待了,誰(shuí)先獲取到鎖就去重建緩存,其余線程沒(méi)有獲取到鎖就返回舊數(shù)據(jù),不去做休眠,輪詢(xún)?nèi)カ@取鎖。
重建緩存會(huì)新開(kāi)一個(gè)線程去執(zhí)行重建緩存,目的是減少搶到鎖的線程的響應(yīng)時(shí)間。
優(yōu)點(diǎn):
線程無(wú)需等待,性能好
缺點(diǎn):
- 不能保證一致性
- 緩存中有額外的內(nèi)存消耗
- 實(shí)現(xiàn)復(fù)雜
兩個(gè)方案各有優(yōu)缺點(diǎn):一個(gè)保證了一致性,一個(gè)保證了可用性,選擇與否主要看業(yè)務(wù)的需求是什么,側(cè)重于可用性還是一致性。
6.1 互斥鎖代碼
互斥鎖的鎖用什么?
使用Redis命令的setnx命令。
首先實(shí)現(xiàn)獲取鎖和釋放鎖的代碼
/**
* 嘗試獲取鎖
*
* @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);
}

代碼實(shí)現(xiàn)
public Shop queryWithMutex(Long id) throws InterruptedException {
//從Redis查詢(xún)商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//判斷緩存中數(shù)據(jù)是否存在
if (!StringUtil.isNullOrEmpty(cacheShop)) {
//緩存中存在則直接返回
try {
// 將子字符串轉(zhuǎn)換為對(duì)象
Shop shop = objectMapper.readValue(cacheShop, Shop.class);
return shop;
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
// 因?yàn)樯厦媾袛嗔薱acheShop是否為空,如果進(jìn)到這個(gè)方法里面則一定是空,直接過(guò)濾,不打到數(shù)據(jù)庫(kù)
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);
}
//緩存中不存在,則從數(shù)據(jù)庫(kù)里進(jìn)行數(shù)據(jù)查詢(xún)
shop = getById(id);
//數(shù)據(jù)庫(kù)里不存在,返回404
if (null == shop) {
// 緩存空對(duì)象
stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, "", 2, TimeUnit.MINUTES);
return null;
}
//數(shù)據(jù)庫(kù)里存在,則將信息寫(xiě)入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 邏輯過(guò)期實(shí)現(xiàn)
邏輯過(guò)期不設(shè)置TTL

代碼實(shí)現(xiàn)
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
由于是熱點(diǎn)key,所以key基本都是手動(dòng)導(dǎo)入到緩存,代碼如下
/**
* 邏輯過(guò)期時(shí)間對(duì)象寫(xiě)入緩存
* @param id
* @param expireSeconds
*/
public void saveShopToRedis(Long id,Long expireSeconds){
// 查詢(xún)店鋪數(shù)據(jù)
Shop shop = getById(id);
// 封裝為邏輯過(guò)期
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 寫(xiě)入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(redisData));
}
邏輯過(guò)期代碼實(shí)現(xiàn)
/**
* 緩存擊穿:邏輯過(guò)期解決
* @param id
* @return
* @throws InterruptedException
*/
public Shop queryWithPassLogicalExpire(Long id) throws InterruptedException {
//1. 從Redis查詢(xún)商鋪緩存
String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);
//2. 判斷緩存中數(shù)據(jù)是否存在
if (StringUtil.isNullOrEmpty(cacheShop)) {
// 3. 不存在
return null;
}
// 4. 存在,判斷是否過(guò)期
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判斷是否過(guò)期
if (expireTime.isAfter(LocalDateTime.now())){
// 5.1 未過(guò)期
return shop;
}
// 5.2 已過(guò)期
String lockKey = "lock:shop:"+id;
boolean flag = tryLock(lockKey);
if (flag){
// TODO 獲取鎖成功,開(kāi)啟獨(dú)立線程,實(shí)現(xiàn)緩存重建,建議使用線程池去做
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建緩存
this.saveShopToRedis(id,1800L);
}catch (Exception e){
}finally {
// 釋放鎖
unLock(lockKey);
}
});
}
// 獲取鎖失敗,返回過(guò)期的信息
return shop;
}
/**
* 線程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
到此這篇關(guān)于利用Redis進(jìn)行數(shù)據(jù)緩存的項(xiàng)目實(shí)踐的文章就介紹到這了,更多相關(guān)Redis 數(shù)據(jù)緩存內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis server 主從復(fù)制配置實(shí)現(xiàn)
從復(fù)制是指將一個(gè)Redis服務(wù)器的數(shù)據(jù)復(fù)制到其他Redis服務(wù)器的過(guò)程,本文主要介紹了Redis server 主從復(fù)制配置實(shí)現(xiàn),具有一定的參考價(jià)值,感興趣的可以了解一下2024-02-02
Redis中5種數(shù)據(jù)結(jié)構(gòu)的使用場(chǎng)景介紹
這篇文章主要介紹了Redis中5種數(shù)據(jù)結(jié)構(gòu)的使用場(chǎng)景介紹,本文對(duì)Redis中的5種數(shù)據(jù)類(lèi)型String、Hash、List、Set、Sorted Set做了講解,需要的朋友可以參考下2014-09-09
Redis在windows環(huán)境下如何啟動(dòng)
這篇文章主要介紹了Redis在windows環(huán)境下如何啟動(dòng)的實(shí)現(xiàn)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2025-04-04
redis cluster集群模式下實(shí)現(xiàn)批量可重入鎖
本文主要介紹了使用redis cluster集群版所遇到的問(wèn)題解決方案及redis可重入鎖是否會(huì)有死鎖的問(wèn)題等,具有一定的參考價(jià)值,感興趣的可以了解一下2024-02-02
使用Redis命令操作數(shù)據(jù)庫(kù)的常見(jiàn)錯(cuò)誤及解決方法
由于Redis是內(nèi)存數(shù)據(jù)庫(kù),因此可能會(huì)存在一些安全問(wèn)題,下面這篇文章主要給大家介紹了關(guān)于使用Redis命令操作數(shù)據(jù)庫(kù)的常見(jiàn)錯(cuò)誤及解決方法,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2024-02-02
Redis之常用數(shù)據(jù)結(jié)構(gòu)哈希表
這篇文章主要介紹了Redis常用的數(shù)據(jù)結(jié)構(gòu)哈希表,哈希表是一種保存鍵值對(duì)的數(shù)據(jù)結(jié)構(gòu),具有一定的參考價(jià)值,需要的朋友可以參考閱讀2023-04-04

