你知道緩存的這個(gè)問題到底把多少程序員坑慘了嗎?
引言
你是不是也經(jīng)歷過這種情況?某個(gè)陽光明媚的上午,你正享受著枸杞泡水和敲代碼的美妙時(shí)刻,突然接到客服部門的電話。
電話那頭的聲音急切又焦慮:“客戶抱怨說,怎么點(diǎn)擊購買總是失?。俊?/p>
作為技術(shù)人員,你瞬間腦袋里浮現(xiàn)出無數(shù)可能的原因,其中最讓人頭疼的,莫過于緩存與數(shù)據(jù)庫的不一致問題。
這個(gè)問題不僅影響用戶體驗(yàn),還可能導(dǎo)致業(yè)務(wù)數(shù)據(jù)的混亂。
今兒就來聊聊緩存與數(shù)據(jù)庫不一致的問題,幫大家理清楚問題所在,并給出推薦方案。
問題剖析
在現(xiàn)代系統(tǒng)中,緩存可以極大地提升性能,減少數(shù)據(jù)庫的壓力。
然而,一旦緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致,就會(huì)引發(fā)各種詭異的問題。
系統(tǒng)維護(hù)的重要性在此凸顯,極端手段比如清空緩存,可能會(huì)在短時(shí)間內(nèi)解決問題,但從長(zhǎng)遠(yuǎn)來看,這些操作往往帶來更大的隱患。
解決方案概覽
我們來看看幾種常見的解決緩存與數(shù)據(jù)庫不一致的方案,每種方案都有各自的優(yōu)缺點(diǎn):
- 先更新緩存,再更新數(shù)據(jù)庫
- 先更新數(shù)據(jù)庫,再更新緩存
- 先刪除緩存,后更新數(shù)據(jù)庫
- 先更新數(shù)據(jù)庫,后刪除緩存
深入探討
先更新緩存,再更新數(shù)據(jù)庫
這種方案看似簡(jiǎn)單,實(shí)際上很少被推薦。
原因在于如果在更新數(shù)據(jù)庫之前發(fā)生了錯(cuò)誤,緩存中的數(shù)據(jù)將和數(shù)據(jù)庫中的數(shù)據(jù)不一致,最終導(dǎo)致更大的問題。
public void updateCacheThenDatabase(String key, String value) {
cache.put(key, value);
try {
database.update(key, value);
} catch (Exception e) {
// 數(shù)據(jù)庫更新失敗,緩存數(shù)據(jù)可能錯(cuò)誤
System.err.println("數(shù)據(jù)庫更新失敗: " + e.getMessage());
}
}
先更新數(shù)據(jù)庫,再更新緩存
這種方法解決了更新緩存失敗的問題,但可能引發(fā)另外一個(gè)問題:
在高并發(fā)場(chǎng)景下,數(shù)據(jù)庫已經(jīng)更新,但緩存還沒有更新時(shí),其他請(qǐng)求可能會(huì)讀到舊的緩存數(shù)據(jù)。
public void updateDatabaseThenCache(String key, String value) {
database.update(key, value);
cache.put(key, value);
}
先刪除緩存,后更新數(shù)據(jù)庫
這種方案在高并發(fā)下容易產(chǎn)生問題:
在緩存刪除和數(shù)據(jù)庫更新之間的時(shí)間窗口內(nèi),其他請(qǐng)求可能會(huì)讀取到舊的數(shù)據(jù),導(dǎo)致短時(shí)間內(nèi)的數(shù)據(jù)不一致。
public void deleteCacheThenUpdateDatabase(String key, String value) {
cache.remove(key);
try {
database.update(key, value);
} catch (Exception e) {
// 數(shù)據(jù)庫更新失敗,需要重新設(shè)置緩存
cache.put(key, getOldValueFromDatabase(key));
System.err.println("數(shù)據(jù)庫更新失敗: " + e.getMessage());
}
}
先更新數(shù)據(jù)庫,后刪除緩存
這是較為推薦的一種方法,但在高并發(fā)場(chǎng)景下也有一定的局限性:
如果數(shù)據(jù)庫更新成功但緩存刪除失敗,可能導(dǎo)致短時(shí)間內(nèi)的數(shù)據(jù)不一致。
public void updateDatabaseThenDeleteCache(String key, String value) {
database.update(key, value);
cache.remove(key);
}
強(qiáng)一致性與最終一致性
在討論一致性的時(shí)候,我們常常會(huì)提到強(qiáng)一致性和最終一致性。
強(qiáng)一致性保證每次讀取的數(shù)據(jù)都是最新的,但在分布式系統(tǒng)中實(shí)現(xiàn)成本較高。
最終一致性則允許數(shù)據(jù)在一定時(shí)間內(nèi)不一致,適用于大多數(shù)實(shí)際業(yè)務(wù)場(chǎng)景。
根據(jù)業(yè)務(wù)需求權(quán)衡這兩者,是緩存策略設(shè)計(jì)中的重要一步。
后面我會(huì)給出一個(gè)弱一致性的推薦方案,供大家參考。
SpringCache
SpringCache是一個(gè)非常實(shí)用的緩存管理框架,能幫助我們簡(jiǎn)化緩存操作。
以下是一個(gè)簡(jiǎn)單的SpringCache配置示例:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("entities");
}
}
SpringCache 的優(yōu)點(diǎn):
- 簡(jiǎn)化緩存管理:SpringCache 提供了簡(jiǎn)潔的 API,能夠輕松集成到現(xiàn)有項(xiàng)目中。
- 注解驅(qū)動(dòng):使用注解如 @Cacheable、@CachePut 和 @CacheEvict,使得緩存操作更直觀。
- 靈活配置:支持多種緩存實(shí)現(xiàn),能夠根據(jù)需求選擇合適的緩存方案。
SpringCache 的缺點(diǎn):
- 限制性:默認(rèn)的緩存管理器在一些復(fù)雜場(chǎng)景下可能不夠靈活。
- 性能瓶頸:在高并發(fā)場(chǎng)景下,內(nèi)存緩存可能會(huì)成為性能瓶頸,需要合理設(shè)計(jì)和優(yōu)化。
常用的注解包括@Cacheable、@CachePut和@CacheEvict:
@Cacheable("entities")
public Entity findById(Long id) {
// 如果緩存中存在,則直接返回緩存中的數(shù)據(jù)
// 否則調(diào)用方法查詢數(shù)據(jù)庫,并將結(jié)果存入緩存
return repository.findById(id).orElse(null);
}
@CachePut(value = "entities", key = "#entity.id")
public Entity updateEntity(Entity entity) {
// 更新數(shù)據(jù)庫,同時(shí)更新緩存中的數(shù)據(jù)
return repository.save(entity);
}
@CacheEvict(value = "entities", allEntries = true)
public void clearCache() {
// 清空緩存
}
緩存預(yù)熱策略
緩存預(yù)熱的重要性不言而喻,上線后瞬時(shí)大流量可能導(dǎo)致緩存擊穿。
以下是幾種常見的緩存預(yù)熱方案:
- 啟動(dòng)時(shí)加載:系統(tǒng)啟動(dòng)時(shí)加載常用數(shù)據(jù)到緩存。
@Component
public class CachePreloader {
@Autowired
private CacheManager cacheManager;
@PostConstruct
public void preload() {
Cache cache = cacheManager.getCache("entities");
if (cache != null) {
// 假設(shè)我們有一個(gè)服務(wù)可以獲取需要預(yù)熱的數(shù)據(jù)
List<Entity> entities = fetchEntitiesForPreloading();
for (Entity entity : entities) {
cache.put(entity.getId(), entity);
}
}
}
private List<Entity> fetchEntitiesForPreloading() {
// 獲取需要預(yù)熱的數(shù)據(jù)
return repository.findAll();
}
}
- 定時(shí)加載:定時(shí)任務(wù)定期加載數(shù)據(jù)。
@Component
public class ScheduledCachePreloader {
@Autowired
private CacheManager cacheManager;
@Scheduled(fixedRate = 60000) // 每分鐘執(zhí)行一次
public void preload() {
Cache cache = cacheManager.getCache("entities");
if (cache != null) {
// 獲取需要預(yù)熱的數(shù)據(jù)并加載到緩存
List<Entity> entities = fetchEntitiesForPreloading();
for (Entity entity : entities) {
cache.put(entity.getId(), entity);
}
}
}
private List<Entity> fetchEntitiesForPreloading() {
// 獲取需要預(yù)熱的數(shù)據(jù)
return repository.findAll();
}
}
- 手動(dòng)加載:手動(dòng)執(zhí)行預(yù)熱腳本。
@Component
public class ManualCachePreloader {
@Autowired
private CacheManager cacheManager;
public void preload() {
Cache cache = cacheManager.getCache("entities");
if (cache != null) {
// 獲取需要預(yù)熱的數(shù)據(jù)并加載到緩存
List<Entity> entities = fetchEntitiesForPreloading();
for (Entity entity : entities) {
cache.put(entity.getId(), entity);
}
}
}
private List<Entity> fetchEntitiesForPreloading() {
// 獲取需要預(yù)熱的數(shù)據(jù)
return repository.findAll();
}
}
推薦方案
綜合考慮各種方案的優(yōu)缺點(diǎn),我給大家一種工作中真正常用的方案,也是我待過的互聯(lián)網(wǎng)公司中實(shí)踐過的方案。
基本策略:刪除Redis中緩存 -> 更新數(shù)據(jù)庫 -> 最新數(shù)據(jù)set到Redis
延遲雙刪:刪除Redis中緩存 -> 更新數(shù)據(jù)庫 -> 休眠500ms -> 再次刪除Redis中緩存 -> 最新數(shù)據(jù)set到Redis
延遲三刪:刪除Redis中緩存 -> 更新數(shù)據(jù)庫 -> 休眠500ms -> 再次刪除Redis中緩存 -> 休眠500ms -> 再次刪除Redis中緩存 -> 最新數(shù)據(jù)set到Redis
這是一種非常簡(jiǎn)單且成本很低的操作,但能解決絕大多數(shù)的緩存與數(shù)據(jù)庫不一致問題。
原理很好理解,就是更新數(shù)據(jù)庫之后設(shè)置合理的休眠時(shí)間,然后再次刪除掉其他線程請(qǐng)求進(jìn)來導(dǎo)致的舊緩存,最終達(dá)到緩存和數(shù)據(jù)庫都是最新數(shù)據(jù)的目的。
其中休眠時(shí)間要根據(jù)自身業(yè)務(wù)的平均耗時(shí)來決定,而延遲雙刪其實(shí)就夠了,延遲三刪只是為了開闊大家的思路,因?yàn)檎嬗行┕緞h除三次來保證一些極端情況的不一致,但我覺得沒必要,太極端就不是弱一致性了。
如果是比較復(fù)雜的項(xiàng)目,甚至能再進(jìn)一步的優(yōu)化,也就是借用定時(shí)任務(wù)和MQ來替代休眠線程,實(shí)現(xiàn)異步刪除緩存,達(dá)到弱一致性的結(jié)果。
總結(jié)與思考
緩存與數(shù)據(jù)庫一致性是一個(gè)復(fù)雜的問題,需要根據(jù)具體業(yè)務(wù)場(chǎng)景選擇合適的策略。
大家需要記住一點(diǎn),高可用性是系統(tǒng)的最優(yōu)先選擇,所以弱一致性就必然成為數(shù)據(jù)不一致性的最優(yōu)解,這是一種良性閉環(huán)。
因?yàn)橄到y(tǒng)不能用,那是直接虧損,但數(shù)據(jù)出現(xiàn)低量的不一致,完全可以接受。