用好緩存的十條軍規(guī)
前言
"蘇工!首頁崩了!"
幾年前的一天晚上,我接到電話時,我正夢見自己成了緩存之神。
打開監(jiān)控一看:
緩存命中率:0%
數(shù)據(jù)庫QPS:10萬+
線程阻塞數(shù):2000+根本原因竟是之前有同事寫的這段代碼:
public Product getProduct(Long id) {
return productDao.findById(id);
}直連數(shù)據(jù)庫,未加緩存。
這一刻我意識到:不會用緩存的程序員,就像不會剎車的賽車手。
今天這篇文章跟大家一起聊聊使用緩存的10條軍規(guī),希望對你會有所幫助。
圖片
軍規(guī)1: 避免大key
反例場景:
@Cacheable(value = "user", key = "#id")
public User getUser(Long id) {
return userDao.findWithAllRelations(id);
}這里一次查詢出了用戶及其所有關(guān)聯(lián)對象,然后添加到內(nèi)存緩存中。
如果通過id查詢用戶信息的請求量非常大,會導(dǎo)致頻繁的GC。
正確實踐:
@Cacheable(value = "user_base", key = "#id")
public UserBase getBaseInfo(Long id) { /*...*/ }
@Cacheable(value = "user_detail", key = "#id")
public UserDetail getDetailInfo(Long id) { /*...*/ }這種情況,需要拆分緩存對象,比如:將用戶基本信息和用戶詳細(xì)信息分開緩存。
緩存不是存儲數(shù)據(jù)的垃圾桶,需要根據(jù)數(shù)據(jù)訪問頻率、讀寫比例、數(shù)據(jù)一致性要求進(jìn)行分級管理。
大對象緩存會導(dǎo)致內(nèi)存碎片化,甚至觸發(fā)Full GC。
建議將基礎(chǔ)信息(如用戶ID、名稱)與擴展信息(如訂單記錄)分離存儲。
軍規(guī)2: 永遠(yuǎn)設(shè)置過期時間
血淚案例:某系統(tǒng)將配置信息緩存設(shè)置為永不過期,導(dǎo)致修改配置后三天才生效。
正確配置:
@Cacheable(value = "config", key = "#key",
unless = "#result == null",
cacheManager = "redisCacheManager")
public String getConfig(String key) {
return configDao.get(key);
}Redis配置如下:
spring.cache.redis.time-to-live=300000 // 5分鐘
spring.cache.redis.cache-null-values=false需要指定key的存活時間,比如:time-to-live設(shè)置成5分鐘。
TTL設(shè)置公式:
最優(yōu)TTL = 平均數(shù)據(jù)變更周期 × 0.3深層思考:過期時間過短會導(dǎo)致緩存穿透風(fēng)險,過長會導(dǎo)致數(shù)據(jù)不一致。
建議采用動態(tài)TTL策略。
例如電商商品詳情頁可設(shè)置30分鐘基礎(chǔ)TTL+隨機5分鐘抖動。
軍規(guī)3: 避免批量失效
典型事故:所有緩存設(shè)置相同TTL,導(dǎo)致每天凌晨集中失效,數(shù)據(jù)庫瞬時被打爆。
解決方案:
使用基礎(chǔ)TTL + 隨機抖動的方案:
public long randomTtl(long baseTtl) {
return baseTtl + new Random().nextInt(300);
}TTL增加0-5分鐘隨機值。
使用示例
redisTemplate.opsForValue().set(key, value, randomTtl(1800), TimeUnit.SECONDS);失效時間分布:
圖片
軍規(guī)4: 需要增加熔斷降級
我們在使用緩存的時候,需要增加熔斷降級策略,防止萬一緩存掛了,不能影響整個服務(wù)的可用性。
Hystrix實現(xiàn)示例:
@HystrixCommand(fallbackMethod = "getProductFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000")
})
public Product getProduct(Long id) {
return productDao.findById(id);
}
public Product getProductFallback(Long id) {
return new Product().setDefault(); // 返回兜底數(shù)據(jù)
}熔斷狀態(tài)機:
圖片
軍規(guī)5: 空值緩存
在用戶請求并發(fā)量大的業(yè)務(wù)場景種,我們需要把空值緩存起來。
防止大批量在系統(tǒng)中不存在的用戶id,沒有命中緩存,而直接查詢數(shù)據(jù)庫的情況。
典型代碼:
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = redis.get(key);
if (product != null) {
if (product.isEmpty()) { // 空對象標(biāo)識
returnnull;
}
return product;
}
product = productDao.findById(id);
if (product == null) {
redis.setex(key, 300, "empty"); // 緩存空值5分鐘
returnnull;
}
redis.setex(key, 3600, product);
return product;
}空值緩存原理:
圖片
需要將數(shù)據(jù)庫中返回的空值,緩存起來。
后面如果有相同的key查詢數(shù)據(jù),則直接從緩存中返回空值。
而無需再查詢一次數(shù)據(jù)庫。
軍規(guī)6: 分布式鎖用Redisson
用Redis做分布式鎖的時候,可能會遇到很多問題。
建議大家使用Redisson做分布式鎖。
Redisson分布式鎖實現(xiàn):
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = redis.get(key);
if (product == null) {
RLock lock = redisson.getLock("lock:" + key);
try {
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
product = productDao.findById(id);
redis.setex(key, 3600, product);
}
} finally {
lock.unlock();
}
}
return product;
}鎖競爭流程圖:
圖片
軍規(guī)7: 延遲雙刪策略
在保證數(shù)據(jù)庫和緩存雙寫數(shù)據(jù)一致性的業(yè)務(wù)場景種,可以使用延遲雙刪的策略。
例如:
@Transactional
public void updateProduct(Product product) {
// 1. 先刪緩存
redis.delete("product:" + product.getId());
// 2. 更新數(shù)據(jù)庫
productDao.update(product);
// 3. 延時再刪
executor.schedule(() -> {
redis.delete("product:" + product.getId());
}, 500, TimeUnit.MILLISECONDS);
}軍規(guī)8: 最終一致性方案
延遲雙刪可能還有其他的問題。
我們可以使用最終一致性方案。
基于Binlog的方案:
圖片
DB更新數(shù)據(jù)之后,Canal會自動監(jiān)聽數(shù)據(jù)的變化,它會解析數(shù)據(jù)事件,然后發(fā)送一條MQ消息。
在MQ消費者中,刪除緩存。
軍規(guī)9: 熱點數(shù)據(jù)預(yù)加載
對于一些經(jīng)常使用的熱點數(shù)據(jù),我們可以提前做數(shù)據(jù)的預(yù)加載。
實時監(jiān)控方案:
// 使用Redis HyperLogLog統(tǒng)計訪問頻率
public void recordAccess(Long productId) {
String key = "access:product:" + productId;
redis.pfadd(key, UUID.randomUUID().toString());
redis.expire(key, 60); // 統(tǒng)計最近60秒
}
// 定時任務(wù)檢測熱點
@Scheduled(fixedRate = 10000)
public void detectHotKeys() {
Set<String> keys = redis.keys("access:product:*");
keys.forEach(key -> {
long count = redis.pfcount(key);
if (count > 1000) { // 閾值
Long productId = extractId(key);
preloadProduct(productId);
}
});
}定時任務(wù)檢測熱點,并且更新到緩存中。
軍規(guī)10: 根據(jù)場景選擇數(shù)據(jù)結(jié)構(gòu)
血淚案例:某社交平臺使用String類型存儲用戶信息。
錯誤用String存儲對象:
redis.set("user:123", JSON.toJSONString(user));每次更新單個字段都需要反序列化整個對象。
導(dǎo)致問題:
- 序列化/反序列化開銷大
- 更新單個字段需讀寫整個對象
- 內(nèi)存占用高 正確實踐:
// 使用Hash存儲
redis.opsForHash().putAll("user:123", userToMap(user));
// 局部更新
redis.opsForHash().put("user:123", "age", "25");數(shù)據(jù)結(jié)構(gòu)選擇矩陣:
圖片
各數(shù)據(jù)結(jié)構(gòu)最佳實踐:
1.String
計數(shù)器
redis.opsForValue().increment("article:123:views");分布式鎖
redis.opsForValue().set("lock:order:456", "1", "NX", "EX", 30);2.Hash
存儲商品信息
Map<String, String> productMap = new HashMap<>();
productMap.put("name", "iPhone15");
productMap.put("price", "7999");
redis.opsForHash().putAll("product:789", productMap);部分更新
redis.opsForHash().put("product:789", "stock", "100");3.List
消息隊列
redis.opsForList().leftPush("queue:payment", orderJson);最新N條記錄
redis.opsForList().trim("user:123:logs", 0, 99);4.Set
標(biāo)簽系統(tǒng)
redis.opsForSet().add("article:123:tags", "科技", "數(shù)碼");共同好友
redis.opsForSet().intersect("user:123:friends", "user:456:friends");5.ZSet
排行榜
redis.opsForZSet().add("leaderboard", "player1", 2500);
redis.opsForZSet().reverseRange("leaderboard", 0, 9);延遲隊列
redis.opsForZSet().add("delay:queue", "task1", System.currentTimeMillis() + 5000);總結(jié)
緩存治理黃金法則
問題類型 | 推薦方案 | 工具推薦 |
緩存穿透 | 空值緩存+布隆過濾器 | Redisson BloomFilter |
緩存雪崩 | 隨機TTL+熔斷降級 | Hystrix/Sentinel |
緩存擊穿 | 互斥鎖+熱點預(yù)加載 | Redisson Lock |
數(shù)據(jù)一致性 | 延遲雙刪+最終一致性 | Canal+RocketMQ |
圖片
最后忠告:緩存是把雙刃劍,用得好是性能利器,用不好就是定時炸彈。
當(dāng)你準(zhǔn)備引入緩存時,先問自己三個問題:
- 真的需要緩存嗎?
- 緩存方案是否完整?
- 有沒有兜底措施?


























