Redis的緩存使用技巧分享(商戶查詢緩存)
1. 什么是緩存
緩存(Cache) 就是數(shù)據(jù)交換的緩沖區(qū),是存貯數(shù)據(jù)的臨時(shí)地方,一般讀寫性能較高。
緩存的作用:
- 降低后端負(fù)載
- 提高讀寫效率,降低響應(yīng)時(shí)間
緩存的成本:
- 數(shù)據(jù)一致性成本
- 代碼維護(hù)成本
- 運(yùn)維成本
2. 添加 Redis 緩存
2.1 緩存工作模型

2.2 代碼實(shí)現(xiàn)
前端請(qǐng)求說明:
| 說明 | |
|---|---|
| 請(qǐng)求方式 | POST |
| 請(qǐng)求路徑 | /shop/id |
| 請(qǐng)求參數(shù) | id |
| 返回值 |
后端接口實(shí)現(xiàn):
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
String key = "cache:shop:" + id;
// 1. 從 redis 查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if(!StrUtil.isBlank(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根據(jù) id 查詢數(shù)據(jù)庫(kù)
Shop shop = getById(id);
// 5. 不存在,返回錯(cuò)誤
if(shop == null){
return Result.fail("店鋪不存在!");
}
// 6. 存在,寫入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7. 返回
return Result.ok(shop);
}
}
3. 緩存更新策略
3.1 緩存更新策略類型
| 緩存更新策略 | 內(nèi)存淘汰 | 超時(shí)剔除 | 主動(dòng)更新 |
|---|---|---|---|
| 說明 | 不用自己維護(hù),利用 Redis 的內(nèi)存淘汰機(jī)制,當(dāng)內(nèi)存不足時(shí)自動(dòng)淘汰部分?jǐn)?shù)據(jù),下次查詢時(shí)更新緩存。 | 給緩存數(shù)據(jù)添加 TTL 時(shí)間,到期后自動(dòng)刪除緩存。下次查詢時(shí)更新緩存。 | 編寫業(yè)務(wù)邏輯,在修改數(shù)據(jù)庫(kù)的同時(shí),更新緩存。 |
| 一致性 | 差 | 一般 | 好 |
| 維護(hù)成本 | 無 | 低 | 高 |
業(yè)務(wù)場(chǎng)景:
- 低一致性:使用內(nèi)存淘汰機(jī)制。
- 高一致性:主動(dòng)更新,并以超時(shí)剔除作為兜底方案。
3.2 主動(dòng)更新策略
| 方式 | 描述 |
|---|---|
| Cache Aside Pattern | 由緩存的調(diào)用者,在更新數(shù)據(jù)庫(kù)的同時(shí)更新緩存。 |
| Read/Write Through Pattern | 緩存與數(shù)據(jù)庫(kù)整合為一個(gè)服務(wù),由服務(wù)來維護(hù)一致性。調(diào)用者調(diào)用該服務(wù),無需關(guān)心緩存一致性問題。 |
| Write Behind Caching Pattern | 調(diào)用者只操作緩存,由其它線程異步的將緩存持久化到數(shù)據(jù)庫(kù),保證最終一致性。 |
這里推薦使用 Cache Aside Pattern,但操作緩存和數(shù)據(jù)庫(kù)時(shí)有三個(gè)問題需要考慮:
刪除緩存還是更新緩存?
- 更新緩存:每次更新數(shù)據(jù)庫(kù)都更新緩存,無效寫操作較多。
- 刪除緩存(推薦):更新數(shù)據(jù)庫(kù)時(shí)讓緩存失效,查詢時(shí)再更新緩存。
如何保證緩存與數(shù)據(jù)庫(kù)的操作的同時(shí)成功或失敗?
- 單體系統(tǒng):將緩存與數(shù)據(jù)庫(kù)操作放在一個(gè)事務(wù)。
- 分布式系統(tǒng):利用 TTC 等分布式事務(wù)方案。
先操作緩存還是先操作數(shù)據(jù)庫(kù)?
- 先刪除緩存,再操作數(shù)據(jù)庫(kù)。(問題:當(dāng)一個(gè)線程進(jìn)行修改操作時(shí),先刪除了緩存,然后另一個(gè)線程讀取,讀取不到緩存便讀取數(shù)據(jù)庫(kù),然后更新緩存,更新的是舊的數(shù)據(jù)庫(kù)的值,最后第一個(gè)線程又更新數(shù)據(jù)庫(kù),導(dǎo)致數(shù)據(jù)庫(kù)和緩存不一致。這種問題出現(xiàn)的概率比較高。
- 先操作數(shù)據(jù)庫(kù),再刪除緩存。(推薦。問題:當(dāng)一個(gè)線程讀取時(shí)正好緩存過期,那么將讀取到數(shù)據(jù)庫(kù)的數(shù)據(jù),然后另一個(gè)線程進(jìn)入進(jìn)行修改操作,修改數(shù)據(jù)庫(kù)后,將緩存刪除。最后第一個(gè)線程將之前讀取的數(shù)據(jù)寫入緩存,就會(huì)造成數(shù)據(jù)庫(kù)和緩存不一致。但讀取緩存是微秒級(jí)的并又正好碰上緩存過期,因此該問題的概率很小。)
小結(jié):
- 讀操作:緩存命中直接返回;緩存未命中則查詢數(shù)據(jù)庫(kù),并寫入緩存,設(shè)定超時(shí)時(shí)間。
- 寫操作:先寫數(shù)據(jù)庫(kù),然后再刪緩存。要確保數(shù)據(jù)庫(kù)與緩存操作的原子性。
3.3 超時(shí)剔除和主動(dòng)更新緩存實(shí)現(xiàn)
后端接口實(shí)現(xiàn):
通過 id 查詢店鋪時(shí),如果緩存未命中,則查詢數(shù)據(jù)庫(kù),將數(shù)據(jù)庫(kù)寫入緩存,并設(shè)置超時(shí)時(shí)間。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
String key = "cache:shop:" + id;
// 1. 從 redis 查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if(!StrUtil.isBlank(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根據(jù) id 查詢數(shù)據(jù)庫(kù)
Shop shop = getById(id);
// 5. 不存在,返回錯(cuò)誤
if(shop == null){
return Result.fail("店鋪不存在!");
}
// 6. 存在,寫入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
// 7. 返回
return Result.ok(shop);
}
}
通過 id 修改店鋪時(shí),先修改數(shù)據(jù)庫(kù),再刪除緩存。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店鋪 id 不能為空!");
}
// 1. 更新數(shù)據(jù)庫(kù)
updateById(shop);
// 2. 刪除緩存
stringRedisTemplate.delete("cache:shop:" + id);
return Result.ok();
}
}
4. 緩存穿透
4.1 基本介紹
緩存穿透 是指客戶端請(qǐng)求的數(shù)據(jù)在緩存中和數(shù)據(jù)庫(kù)中都不存在,這樣緩存永遠(yuǎn)不生效,這些請(qǐng)求都會(huì)打到數(shù)據(jù)庫(kù)。(如果不斷發(fā)起這樣的請(qǐng)求,會(huì)給數(shù)據(jù)庫(kù)帶來巨大壓力)
常見解決方案:
| 方案 | 描述 | 優(yōu)點(diǎn) | 缺點(diǎn) |
|---|---|---|---|
| 緩存空對(duì)象 | 如果請(qǐng)求的數(shù)據(jù)緩存不存在,并且數(shù)據(jù)庫(kù)也不存在,數(shù)據(jù)庫(kù)將給緩存更新個(gè)空對(duì)象。 | 實(shí)現(xiàn)簡(jiǎn)單,維護(hù)方便。 | 額外的內(nèi)存消耗,可能造成短期的不一致。 |
| 布隆過濾器 | 內(nèi)存占用較少,沒有多余 key | 實(shí)現(xiàn)復(fù)雜,存在誤判可能 | |
| 增強(qiáng) id 的復(fù)雜度,避免被猜測(cè) id 規(guī)律 | |||
| 做好數(shù)據(jù)的基礎(chǔ)格式校驗(yàn) | |||
| 做好熱點(diǎn)參數(shù)的限流 |
4.2 通過緩存空對(duì)象解決緩存穿透問題

代碼實(shí)現(xiàn):
public Shop queryWithPassThrough(Long id) {
String key = "cache:shop:" + id;
// 1. 從 redis 查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if (!StrUtil.isBlank(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判斷命中的是否為空值
if (shopJson != null) {
return null;
}
// 4. 不存在,根據(jù) id 查詢數(shù)據(jù)庫(kù)
Shop shop = getById(id);
// 5. 不存在,返回錯(cuò)誤
if (shop == null) {
// 將空值寫入 Redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回錯(cuò)誤信息
return null;
}
// 6. 存在,寫入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
// 7. 返回
return shop;
}
5. 緩存雪崩
緩存雪崩 是指在同一時(shí)段大量的緩存 key 同時(shí)失效或者 Redis 服務(wù)宕機(jī),導(dǎo)致大量請(qǐng)求到達(dá)數(shù)據(jù)庫(kù),帶來巨大壓力。
解決方案:
- 給不同的 key 的 TTL 添加隨機(jī)值
- 利用 Redis 集群提高服務(wù)的可用性
- 給緩存業(yè)務(wù)添加降級(jí)限流策略
- 給業(yè)務(wù)添加多級(jí)緩存
6. 緩存擊穿
6.1 基本介紹
緩存擊穿 問題也叫熱點(diǎn) key 問題,就是一個(gè)被高并發(fā)訪問并且緩存重建業(yè)務(wù)較復(fù)雜的 key 突然失效了,無數(shù)的請(qǐng)求訪問會(huì)在瞬間給數(shù)據(jù)庫(kù)帶來巨大的沖擊。
常見解決方案:
| 解決方案 | 優(yōu)點(diǎn) | 缺點(diǎn) |
|---|---|---|
| 互斥鎖 | 沒有額外的內(nèi)存消耗;保證一致性;實(shí)現(xiàn)簡(jiǎn)單 | 線程需要等待,性能受影響;可能有死鎖風(fēng)險(xiǎn) |
| 邏輯過期 | 線程無需等待,性能較好 | 不保證一致性;有額外內(nèi)存消耗;實(shí)現(xiàn)復(fù)雜 |

6.2 基于互斥鎖方式解決緩存擊穿問題
這里通過 Redis 中的 SETNX 命令去自定義一個(gè)互斥鎖,通過 del 命令去刪除這個(gè) key 來解鎖。

自定義嘗試獲取鎖和釋放鎖實(shí)現(xiàn)
// 嘗試獲取鎖
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);
}
業(yè)務(wù)邏輯實(shí)現(xiàn):
@Override
public Result queryShopById(Long id) {
// 互斥鎖緩存擊穿
Shop shop = queryWithMutex(id);
if(shop == null){
Result.fail("店鋪不存在!");
}
// 7. 返回
return Result.ok(shop);
}
// 互斥鎖存擊穿
public Shop queryWithMutex(Long id){
String key = "cache:shop:" + id;
// 1. 從 redis 查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if(!StrUtil.isBlank(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判斷命中的是否為空值
if(shopJson != null ){
return null;
}
// 4. 實(shí)現(xiàn)緩存重建
// 4.1 獲取互斥鎖
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判斷是否獲取成功
if(!isLock) {
// 4.3 失敗,則休眠并重試
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4 成功,則根據(jù) id 查詢數(shù)據(jù)庫(kù)
shop = getById(id);
// 5. 不存在,返回錯(cuò)誤
if(shop == null){
// 將空值寫入 Redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回錯(cuò)誤信息
return null;
}
// 6. 存在,寫入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
// 7. 釋放互斥鎖
unlock(lockKey);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 8. 返回
return shop;
}
6.3 基于邏輯過期方式解決緩存擊穿問題

在不修改原有實(shí)體類的情況下,可以新定義一個(gè)類用來保存原有的數(shù)據(jù)并添加邏輯過期時(shí)間
@Data
public class RedisData {
// 邏輯過期時(shí)間
private LocalDateTime expireTime;
// 要存儲(chǔ)到 Redis 中的數(shù)據(jù)
private Object data;
}
將店鋪數(shù)據(jù)和邏輯過期時(shí)間封裝并保存到 Redis 中
public void saveShop2Redis(Long id, Long expireSeconds){
// 1. 查詢店鋪數(shù)據(jù)
Shop shop = getById(id);
// 2. 封裝邏輯過期時(shí)間
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 寫入 Redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}
業(yè)務(wù)實(shí)現(xiàn):
// 定義線程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 基于邏輯過期緩存穿透
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
// 1. 從 redis 查詢商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if (StrUtil.isBlank(shopJson)) {
// 3. 不存在,直接返回
return null;
}
// 4. 命中,需要吧 json 反序列化為對(duì)象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 因?yàn)?data 類型為 Object,并不知道為 Shop,這里會(huì)轉(zhuǎn)成 JSONObject
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
// 5. 判斷是否過期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 未過期,直接返回店鋪信息
return shop;
}
// 5.2 已過期,需要緩存重建
// 6. 緩存重建
// 6.1 獲取互斥鎖
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// 6.2 判斷是否獲取鎖成功
if (isLock) {
// 6.3 成功,開啟獨(dú)立線程,實(shí)現(xiàn)緩存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
// 重建緩存
this.saveShop2Redis(id, 1800L);
// 釋放鎖
unlock(lockKey);
});
}
// 6.4 返回過期的店鋪信息
return shop;
}
7. 緩存工具封裝
接下來將對(duì)以下四個(gè)方法進(jìn)行封裝:
- 將任意 Java 對(duì)象序列化為 JSON 并存儲(chǔ)在 String 類型的 key 中,并可以設(shè)置 TTL 過期時(shí)間
- 將任意 Java 對(duì)象序列化為 JSON 并存儲(chǔ)在 String 類型的 key 中,并可以設(shè)置邏輯過期時(shí)間,用于處理緩存擊穿問題
- 根據(jù)指定的 key 查詢緩存,并反序列化為指定類型,利用緩存空值的方式解決緩存穿透問題
- 根據(jù)指定的 key 查詢緩存,并反序列化為指定類型,利用邏輯過期解決緩存擊穿問題
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 將任意 Java 對(duì)象序列化為 JSON 并存儲(chǔ)在 String 類型的 key 中,并可以設(shè)置 TTL 過期時(shí)間
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
// 將任意 Java 對(duì)象序列化為 JSON 并存儲(chǔ)在 String 類型的 key 中,并可以設(shè)置邏輯過期時(shí)間,用于處理緩存擊穿問題
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 設(shè)置邏輯過期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 寫入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 根據(jù)指定的 key 查詢緩存,并反序列化為指定類型,利用緩存空值的方式解決緩存穿透問題
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 從 redis 查詢商鋪緩存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if (!StrUtil.isBlank(json)) {
// 3. 存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判斷命中的是否為空值
if (json != null) {
return null;
}
// 4. 不存在,根據(jù) id 查詢數(shù)據(jù)庫(kù)
R r = dbFallback.apply(id);
// 5. 不存在,返回錯(cuò)誤
if (r == null) {
// 將空值寫入 Redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回錯(cuò)誤信息
return null;
}
// 6. 存在,寫入 redis
this.set(key, r, time, unit);
// 7. 返回
return r;
}
// 根據(jù)指定的 key 查詢緩存,并反序列化為指定類型,利用邏輯過期解決緩存擊穿問題
// 定義線程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 嘗試獲取鎖
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);
}
// 基于邏輯過期緩存穿透
public <R, ID> R queryWithLogicalExpire(
String keyPrefix1, String keyPrefix2, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix1 + id;
// 1. 從 redis 查詢商鋪緩存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判斷是否存在
if (StrUtil.isBlank(json)) {
// 3. 不存在,直接返回
return null;
}
// 4. 命中,需要吧 json 反序列化為對(duì)象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 因?yàn)?data 類型為 Object,并不知道為 Shop,這里會(huì)轉(zhuǎn)成 JSONObject
JSONObject data = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(data, type);
// 5. 判斷是否過期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 未過期,直接返回店鋪信息
return r;
}
// 5.2 已過期,需要緩存重建
// 6. 緩存重建
// 6.1 獲取互斥鎖
String lockKey = keyPrefix2 + id;
boolean isLock = tryLock(lockKey);
// 6.2 判斷是否獲取鎖成功
if (isLock) {
// 6.3 成功,開啟獨(dú)立線程,實(shí)現(xiàn)緩存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建緩存
// 查詢數(shù)據(jù)庫(kù)
R r1 = dbFallback.apply(id);
// 寫入 Redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 釋放鎖
unlock(lockKey);
}
});
}
// 6.4 返回過期的店鋪信息
return r;
}
}
總結(jié)
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
Redis GEO實(shí)現(xiàn)搜索附近用戶的項(xiàng)目實(shí)踐
RedisGEO主要用于存儲(chǔ)地理位置信息,并對(duì)存儲(chǔ)的信息進(jìn)行操作,本文主要介紹了Redis GEO實(shí)現(xiàn)搜索附近用戶的項(xiàng)目實(shí)踐,具有一定的參考價(jià)值,感興趣的可以了解一下2024-05-05
如何使用?redis?消息隊(duì)列完成秒殺過期訂單處理操作(二)
這篇文章主要介紹了如何使用?redis?消息隊(duì)列完成秒殺過期訂單處理操作,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-07-07
詳解redis是如何實(shí)現(xiàn)隊(duì)列消息的ack
這篇文章主要介紹了關(guān)于redis是如何實(shí)現(xiàn)隊(duì)列消息的ack的相關(guān)資料,文中介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面來一起看看吧。2017-04-04
windows下使用redis requirepass認(rèn)證不起作用的解決方法
今天小編就為大家分享一篇windows下使用redis requirepass認(rèn)證不起作用的解決方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-05-05

