1. 分佈式系統中的領域信息模型與緩存機制
在領域驅動設計的方法中,確定領域的信息模型(知識模型)是系統設計的重要工作。在我們識別了領域模型中的實體,值對象和聚合對象之後,需要在面向對象的系統中將其實例化。
在分佈式系統中,不同的服務(子系統)可能關注於不同的實體對象,但也有一些公共的實體對象可能會被多個服務所關注。在分佈式系統中,一些實體對象會被某些服務頻繁使用,因而緩存機制在系統實現中必不可少。
在實際應用中,我們通常使用三級緩存機制,即內存(一級)、redis(二級)、數據庫(三級)三級數據緩存機制。在讀取數據時依次從內存、redis、數據庫中讀取數據。在寫入數據時依次從數據庫、redis、內存中寫入。
根據spring的設計慣例,我們常常將一個實體對象的訪問類用一個DAO對象來表示,在DAO對象中實現分佈式的三級緩存機制。則使用DAO對象對應用系統屏蔽了緩存的實現細節。應用系統則只關注如何使用DAO與領域實體進行交互。
分佈式系統中常常使用一個共享的緩存中間件實現各服務(子系統)之間的緩存的交互,這個緩存中間件我們常常使用redis集羣。對於分佈式系統而言,在三級緩存體系中,各服務(子系統)擁有自己的內存緩存(一級)和獨立的數據庫(三級),共享一個二級緩存redis集羣。
在這樣一個分佈式的緩存體系中,緩存數據的同步是必須要考慮的。考慮這樣一個場景:A服務修改了某個實體對象,同時更新到二級緩存,B服務再次使用該實體對象時,應該使用更新後的實體對象。如果使用redis集羣來實現二級緩存,應該使用訂閱發佈機制來實現各服務(子系統)之間的緩存同步。
同時,在一級緩存中,緩存的過期策略通常也需要設置。設置的目的也是爲了確保數據不同步引發的錯誤是可自愈的,參數常用的是寫過期時間和讀過期時間兩類。
整個分佈式應用系統中的三級緩存架構如圖1所示。
2 spring提供的緩存機制
spring框架中提供對緩存的支持,支持如圖2所示的緩存類型。
spring框架中對緩存的支持主要有兩個接口的實現來支持,一個爲CacheManager接口(org.springframework.cache.CacheManager),一個爲Cache接口(org.springframework.cache.Cache)。
spring框架中對緩存的應用主要使用:@Cacheable @CachePut @CacheEvict @Caching等註解實現。
相關的資料可從網上獲取,在此不再贅訴。
3 分佈式緩存實現實例
在我們的實際應用項目中,我們設計了一種分佈式的三級緩存機制,這裏使用了redis作爲二級緩存,caffeine作爲一級緩存,數據庫作爲三級存儲介質。
3.1 實現類的層次
在實際應用中,針對redis的各數據類型設計了對不同數據類型的抽象操作子類,對於領域內各實體對象的操作DAO類則繼承自這些redis的抽象操作子類。其類層次結構圖如圖3所示:
3.2 一級緩存的實現與策略配置
在實現中,我們使用caffeine作爲一級緩存,實現了自定義的CacheManager,可實現動態與指定名稱cache的生成,並可針對指定名字的cache配置不同的緩存策略。其部分實現代碼如下:
/**
* 自定義cacheManager,實現動態生成的cache使用缺省的配置
*
* @author zhang.kai
*
*/
public class VlineCacheManager extends SimpleCacheManager {
@Override
protected Cache getMissingCache(String cacheName) {
return createCaffeineCache(cacheName, cacheProperties.getDefaultspec());
}
}
/**
* CacheManager對象注入
*
* @return
*/
@Bean
public CacheManager getCacheManager() {
if ((null == cacheProperties) || (null == cacheProperties.getDefaultspec())
|| (null == cacheProperties.getCachespecs())) {
log.error("cacheProperties is invalid:{}", cacheProperties);
return null;
}
VlineCacheManager cm = new VlineCacheManager();
List<Cache> caches = new ArrayList<>();
cacheProperties.getCachespecs().keySet().forEach(cacheName -> {
String cacheSpec = cacheProperties.getCachespecs().get(cacheName);
if (StringUtils.isEmpty(cacheName)) {
log.error("XXX no cacheSpec for cacheName{}", cacheName);
return;
}
CaffeineCache cache = createCaffeineCache(cacheName, cacheSpec);
caches.add(cache);
});
if (caches.size() > 0) {
cm.setCaches(caches);
} else {
log.error("XXX no cache inited!");
return null;
}
// 設置redis的發佈ID
redisPublisherId = getRedisPublishId();
log.info("%%%%%get redisPublisherId:{}",redisPublisherId);
return cm;
}
對應的配置文件中,對cache的讀寫策略配置如下:
vline:
cache:
defaultspec: initialCapacity=50,maximumSize=500,expireAfterWrite=5s,expireAfterAccess=500s
cachespecs:
CONFIG_INFO: initialCapacity=50,maximumSize=500,expireAfterWrite=5s,expireAfterAccess=500s
USER_LOGIN_DEVICE_INFO: initialCapacity=60,maximumSize=500,expireAfterWrite=5s,expireAfterAccess=7s
3.3 緩存讀取方式
對於三級緩存依次從內存,redis,數據庫中獲取,其代碼片段如下所示:
/**
* 從內存,redis,DB中依次獲取實體
*
* @param key
* @return value
*/
public T get(String key) {
T value = null;
// 1.如果在內存緩存中,則獲取內存緩存
if (enableMemoryCached) {
value = getFromCacheManager(key);
if (null != value) {
log.debug("***get from memory***{}->{}", getRedisKey(key), value);
return value;
}
}
// 2.從redis中獲取,對於鍵不存在的hash,redis返回empty map
value = getFromRedis(key);
if (null != value) {
// 從redis中獲取並回寫到內存
log.debug("***get from redis***{}->{}", getRedisKey(key), value);
putToCacheManager(key, value);
} else {
if (enableDbLoad) {
// 3.從數據庫中獲取並回寫到redis和內存,可能存在競爭,重試一定次數
RedisLock redisLock = new RedisLock(srt, getRedisKey(key) + "_sync_lock");
int retryCount = 0;
do {
try {
// 如果獲取到分佈式鎖,則從數據庫中獲取,取完後返回
if (redisLock.lock()) {
value = loadValueFromDb(key);
log.debug("***get from db***{}->{}", getRedisKey(key), value);
if (null != value) {
set(key, value); // 回寫入redis與內存
}
threadAwait.signalAll(getRedisKey(key)); // 喚醒所有等待該key的線程
return value;
}
// 如果未獲取到分佈式鎖,說明別的進程正在進行查詢數據庫,等待一段時間後查詢redis
threadAwait.await(getRedisKey(key), CacheConsts.LOAD_FROM_DB_WAIT_TIME);
value = getFromRedis(key);
if (null != value) {
if (enableMemoryCached) {
putToCacheManager(key, value);
}
return value;
}
} catch (Exception e) {
log.error("get" + getRedisKey(key) + "fail!", e);
} finally {
redisLock.unlock();
}
retryCount++;
} while (retryCount < this.retryTimes);
}
}
return value;
}
這裏考慮當多個進程(線程)讀取同一key時,使用分佈式鎖來實現。使得只有第一個獲取鎖的進程(線程),會去三級存儲數據庫中獲取並存入redis中。其它讀取該key的進程(線程)將在redis中獲取。這裏代碼中使用的分佈式鎖和線程喚醒對象參考了來自https://github.com/wyh-spring-ecosystem-student/spring-boot-student處的代碼。
3.4 緩存的失效與同步
在一級緩存caffeine中,我們設置了緩存的失效策略。同時在分佈式系統中,不同的進程可能都會在一級緩存中緩存相同的key,因此在對應的redis key發生變化時(修改、刪除),應該通知倒各進程,這個通過redis的訂閱發佈機制實現。
一個進程可能是某個key發生改變的發起者,則它應該在訂閱到該key發生變化時不做任何處理,這裏我們通過在發佈信息中增加一個發佈者的唯一標識來實現,發佈者的唯一標識在public CacheManager getCacheManager()函數中實現。在redis中發佈的信息形式爲:{redisPublishId}${keyPrefix}:{key}。
每個進程的監聽處理函數是這樣的:
@Override
public void onMessage(Message message, byte[] pattern) {
if (null != message.getBody()) {
String key = new String(message.getBody(), Charset.forName("UTF8"));
log.debug("+++reveive msg:{} which topic is:{}", key,
new String(message.getChannel(), Charset.forName("UTF8")));
String publisherId = findRedisPublisherId(key);
// 如果是本進程發佈的,不處理
if (StringUtils.isEmpty(publisherId) || publisherId.equals(CacheConfiguration.redisPublisherId)) {
return;
}
// 不是本進程發佈的,在一級緩存中失效.發佈的key形式爲:{redisPublishId}${keyPrefix}:{key}
String redisKey = key.substring(key.indexOf(CacheConsts.REDIS_PUBLISHER_ID_SEPARATE) + 1);
String keyPrefix = findKeyprefixFromRedisKey(redisKey);
Cache cache = cm.getCache(keyPrefix);
if (null != cache) {
// 從一級緩存中刪除
log.debug("---evict key:{} from cache:{}", redisKey, cache.getName());
cache.evict(redisKey);
}
} else {
log.error("RedisMsgListener onMessage null!");
}
}
4 小結
本文所描述的示例代碼可從:https://github.com/solarkai/distributedcache
處獲取。
對於三級緩存方式,網上有很多不同的實現方式。在我們的實現中,針對redis的不同數據類型衍生出不同的針對領域實體的DAO對象,這些對象屏蔽了緩存使用的細節;同時,在我們的實現中,實現了動態生成與指定名稱的緩存生成方式,可針對不同的緩存配置不同的策略;針對一級緩存的同步,我們使用redis的訂閱發佈機制實現,並在發佈內容中增加發布者的標識,避免重複的同步操作。