權限系統(tǒng)是管理類系統(tǒng)中必不可少的一個模塊照雁,一個好的緩存設計更是權限系統(tǒng)的重中之重,今天來聊下如何更好設計權限系統(tǒng)的緩存担锤。
單節(jié)點緩存
權限校驗屬于使用頻率超高的操作被丧,如果每次都去請求db的話,不僅會給db帶來壓力板祝,也會導致用戶響應過慢宫静,造成很不好的用戶體驗,因此把權限相關數(shù)據(jù)放到緩存中是很有必要的,偽代碼如下:
private static final FUNCTION_CACHE_KEY = "function_cache_key";
public List<Function> loadFunctions() {
// 優(yōu)先從緩存中取
List<Function> functions = cacheService.get(FUNCTION_CACHE_KEY);
if(functions != null){
return functions;
}
// 緩存中沒有孤里,從數(shù)據(jù)庫中取伏伯,并放入緩存
functions = functionDao.loadFunctions();
cacheService.put(FUNCTION_CACHE_KEY, functions);
return functions;
}
推薦使用ehcache作為緩存組件,ehcache是一個純Java的進程內(nèi)緩存框架,支持數(shù)據(jù)持久化到磁盤捌袜,并且支持多種緩存策略说搅,對于權限數(shù)據(jù)這種大數(shù)據(jù)量的緩存可以說是非常合適。
集群緩存
ehcache屬于進程級緩存虏等,對集群支持不是很友好弄唧,雖然可以通過一些方案實現(xiàn)分布式緩存,但總感覺沒有直接用memcached或redis來的痛快霍衫,但直接用memcached或redis的話候引,會經(jīng)過一次網(wǎng)絡調(diào)用,而且對于權限緩存這樣內(nèi)存比較大的數(shù)據(jù)慕淡,性能沒有ehcache這種進程級緩存好背伴。那有沒有一直方案可以兼顧ehcache的性能優(yōu)勢和redis的分布式優(yōu)勢呢?
可以通過ehcache和redis共用的方式來解決這個問題峰髓,大致思路是用ehcache做主緩存傻寂,緩存更新通過MQ在集群間進行通信嫡纠,而redis做為二級緩存使用烁兰。
具體方案如下:
更新數(shù)據(jù)
把數(shù)據(jù)同時放入ehcache和redis中,同時通過MQ通知其它節(jié)點更新自身的緩存听皿,更新的數(shù)據(jù)從redis里面拉取
刪除數(shù)據(jù)
刪除ehcache和redis中數(shù)據(jù)徐紧,同時通過MQ通知其它節(jié)點刪除自身的數(shù)據(jù)
其實對于權限緩存静檬,一般情況下更新操作并不頻繁,通過MQ做變更通知并级,redis做二級緩存拂檩,這樣就可以在集群環(huán)境下仍舊使用ehcache的高效存儲了
用時間戳保證級聯(lián)緩存的一致性
在設計緩存的時候,并不是所有的緩存都是從數(shù)據(jù)庫取的嘲碧,有的緩存是從其它緩存從取的稻励,這樣可以減少使用時的計算時間
數(shù)據(jù)庫 --> 緩存a --> 緩存b
有上面的依賴關系可以看出,緩存a發(fā)生變更時愈涩,緩存b如果不重新從緩存a中重新加載望抽,就會造成緩存臟數(shù)據(jù)。
最直觀的方案是刷新a緩存時履婉,同步刷新b緩存煤篙,但從上述依賴關系可以看到,b依賴a毁腿,a并不依賴b辑奈,b緩存對于a應該是不可見的苛茂,所以從邏輯上來說不符合依賴的規(guī)則。
而且上面只是二級關聯(lián)身害,如果是四級草戈,五級的話塌鸯,上層緩存的變更帶動了太多下級緩存的變更,需要耗費很多時間唐片,因此如果能用延遲刷新或許是更好的方案丙猬。
用時間戳或許是個不錯的辦法,上述例子中费韭,可以給緩存a增加一個時間戳茧球,每次a緩存變更,同步更新時間戳星持。獲取b的時候只需要校驗下a的時間戳是否變更抢埋,變更了就重新加載b緩存,否則直接返回b督暂。
偽代碼如下:
// 權限信息緩存key
private static final FUNCTION_CACHE_KEY = "function_cache_key";
// 權限信息緩存時間戳
private static final FUNCTION_TIME_STAMP = "function_time_stamp";
// 權限信息緩存舊的時間戳
private static final FUNCTION_OLD_TIME_STAMP = "function_old_time_stamp";
// 用戶權限信息緩存key
private static final USER_FUNCTION_CACHE_KEY = "uer_function_cache_key";
// 加載所有的權限信息
public List<Function> loadFunctions() {
// 優(yōu)先從緩存中取
List<Function> functions = cacheService.get(FUNCTION_CACHE_KEY);
if(functions != null){
return functions;
}
// 緩存中沒有揪垄,從數(shù)據(jù)庫中取,并放入緩存
functions = functionDao.loadFunctions();
cacheService.put(FUNCTION_CACHE_KEY, functions);
// 同步更新時間戳
String timeStamp = String.valueOf(System.currentTimeMillis());
cacheService.put(FUNCTION_TIME_STAMP, timeStamp);
return functions;
}
// 根據(jù)用戶id加載用戶的權限信息
public List<Function> loadUserFunctions(Long userId) {
List<Function> functions = loadFunctions();
// 加載緩存中用戶權限信息
List<Function> userFunctions = cacheService.get(USER_FUNCTION_CACHE_KEY + userId);
String newTimeStamp= cacheService.get(FUNCTION_TIME_STAMP);
String oldTimeStamp= cacheService.get(FUNCTION_OLD_TIME_STAMP);
// 如果緩存中沒有用戶權限信息逻翁,或者時間戳不相等饥努,重新從權限信息里面加載用戶權限信息
if(userFunctions == null || newTimeStamp != oldTimeStamp){
userFunctions = getUserFunctions(functions, userId);
// 把用戶權限信息放入緩存
cacheService.put(USER_FUNCTION_CACHE_KEY + userId, functions);
// 把當前時間戳放入緩存
cacheService.put(FUNCTION_OLD_TIME_STAMP, newTimeStamp);
return userFunctions;
}
return userFunctions;
}
需要說明的是八回,上述代碼只是作為示例酷愧,真正開發(fā)時用戶的權限信息一般有更好的處理方式,并不一定是上面示例中每個用戶都單獨放一份緩存缠诅。
因為上面緩存只是二級級聯(lián)溶浴,如果級數(shù)更多,同樣可以用時間戳來進行延遲加載
數(shù)據(jù)庫 --> 緩存a --> 緩存b --> 緩存c --> 緩存d
獲取緩存d時管引,可以校驗 緩存a時間戳 + 緩存b時間戳 + 緩存c時間戳士败,abc任何一個時間戳發(fā)生變化,緩存d都需要重新加載汉匙,思路和上面的差不多拱烁,這里就不多贅述了。
guava 的妙用
對于權限校驗中使用頻率高噩翠,但校驗邏輯又不常變化的地方可以再加一層緩存戏自。
例如一般都權限系統(tǒng)都有對外的接口,可以直接匿名訪問伤锚,校驗代碼如下
// ant風格 url 匹配器
private AntPathMatcher matcher = new AntPathMatcher();
// 可以訪問的匿名url集合擅笔,通常采用ant風格,例如 /open/api/**
// 匿名url通常寫在配置文件中,并且在bean初始化時加載到該集合中
private Set<String> anonymousUrlPatterns = new HashSet<String>();
// 判斷url是否能匿名訪問
public boolean couldAnonymous(String url) {
for (String patternUrl : anonymousUrlPatterns) {
if (matcher.match(patternUrl, url)) {
isMatch = true;
break;
}
}
return isMatch;
}
可以看到,每一次url訪問都會校驗猛们,可以通過加一層緩存來優(yōu)化性能
用分布式緩存感覺有點大材小用念脯,ehcache又有點太重量級,ConcurrentHashMap又不支持緩存策略弯淘,思來想去guava貌似是最好的選擇绿店,改造完后的代碼如下:
// ant風格 url 匹配器
private AntPathMatcher matcher = new AntPathMatcher();
// 可以訪問的匿名url集合,通常采用ant風格,例如 /open/api/**
// 匿名url通常寫在配置文件中庐橙,并且在bean初始化時加載到該集合中
private Set<String> anonymousUrlPatterns = new HashSet<String>();
// 匿名url訪問權限緩存
private static Cache<String, Boolean> anonymousUrlCache = CacheBuilder.newBuilder()
.maximumSize(5000)
.initialCapacity(1000)
.expireAfterAccess(1, TimeUnit.DAYS) // 設置cache中的的對象多久沒有被訪問后過期
.build();
// 判斷url 是否能匿名訪問
public boolean couldAnonymous(String url) {
// 先從緩存中取假勿,有的話直接返回
Boolean couldAnonymousAccess = anonymousUrlCache.getIfPresent(url);
if (couldAnonymousAccess != null) {
return couldAnonymousAccess;
}
boolean isMatch = false;
for (String patternUrl : anonymousUrlPatterns) {
if (matcher.match(patternUrl, url)) {
isMatch = true;
break;
}
}
// 匹配結果放入緩存
anonymousUrlCache.put(url, isMatch);
return isMatch;
}
localStorage 緩存
localStorage 是 HTML5支持的新特性,可以把一些數(shù)據(jù)緩存放在客戶端态鳖,減輕服務器的壓力转培,例如可以把菜單數(shù)據(jù)放到客戶端,菜單數(shù)據(jù)是否過期通過時間戳來判斷浆竭,偽代碼如下:
var timestamp = localStorage.getItem("timestamp" + userId);
// 請求后臺獲取菜單接口浸须,帶上時間戳參數(shù) timestamp
// 后臺校驗時間戳是否變更,如果變更邦泄,返回新的菜單數(shù)據(jù)和新的時間戳删窒,否則不需要返回菜單數(shù)據(jù),仍舊返回舊的時間戳即可
// 后臺接口返回數(shù)據(jù)格式 result = {menus:{},timestamp:""}
var newTimestamp = result.timestamp;
// 時間戳變更虎韵,把新的菜單數(shù)據(jù)和新的時間戳 放入 localStorage
if (newTimestamp != timestamp) {
localStorage.setItem("menus" + userId, JSON.stringify(result.menus));
localStorage.setItem("timestamp" + userId, newTimestamp);
}
有人擔心把緩存放在localStorage中如果被修改會造成安全問題易稠,其實這個擔心是沒必要的,因為權限校驗是在服務器端做的包蓝,localStorage中的緩存只做展示使用驶社,因此修改localStorage時沒有任何意義的。
總結
在不同的情況下测萎,上述場景分別用了ehcache,redis,guava,localStorage做緩存亡电,更加說明了沒有最好的技術,只有最適合的技術硅瞧。通過引入時間戳這種版本號的機制份乒,解決了緩存更新問題。最終的目的只有一個腕唧,保證緩存數(shù)據(jù)一致性的同時或辖,把性能做的極致,用戶體驗做到最好枣接。