權限系統是管理類系統中必不可少的一個模塊,一個好的緩存設計更是權限系統的重中之重,今天來聊下如何更好設計權限系統的緩存。
單節點緩存
權限校驗屬於使用頻率超高的操作,如果每次都去請求db的話,不僅會給db帶來壓力,也會導致用戶響應過慢,造成很不好的用戶體驗,因此把權限相關數據放到緩存中是很有必要的,偽代碼如下:
private static final FUNCTION_CACHE_KEY = "function_cache_key";
public List<Function> loadFunctions() {
// 優先從緩存中取
List<Function> functions = cacheService.get(FUNCTION_CACHE_KEY);
if(functions != null){
return functions;
}
// 緩存中沒有,從數據庫中取,並放入緩存
functions = functionDao.loadFunctions();
cacheService.put(FUNCTION_CACHE_KEY, functions);
return functions;
}
推薦使用ehcache作為緩存組件,ehcache是一個純Java的進程內緩存框架,支持數據持久化到磁盤,並且支持多種緩存策略,對於權限數據這種大數據量的緩存可以說是非常合適。
集群緩存
ehcache屬於進程級緩存,對集群支持不是很友好,雖然可以通過一些方案實現分布式緩存,但總感覺沒有直接用memcached或redis來的痛快,但直接用memcached或redis的話,會經過一次網絡調用,而且對於權限緩存這樣內存比較大的數據,性能沒有ehcache這種進程級緩存好。那有沒有一直方案可以兼顧ehcache的性能優勢和redis的分布式優勢呢?
可以通過ehcache和redis共用的方式來解決這個問題,大致思路是用ehcache做主緩存,緩存更新通過MQ在集群間進行通信,而redis做為二級緩存使用。
具體方案如下:
更新數據
把數據同時放入ehcache和redis中,同時通過MQ通知其它節點更新自身的緩存,更新的數據從redis里面拉取
刪除數據
刪除ehcache和redis中數據,同時通過MQ通知其它節點刪除自身的數據
其實對於權限緩存,一般情況下更新操作並不頻繁,通過MQ做變更通知,redis做二級緩存,這樣就可以在集群環境下仍舊使用ehcache的高效存儲了
用時間戳保證級聯緩存的一致性
在設計緩存的時候,並不是所有的緩存都是從數據庫取的,有的緩存是從其它緩存從取的,這樣可以減少使用時的計算時間
數據庫 --> 緩存a --> 緩存b
有上面的依賴關系可以看出,緩存a發生變更時,緩存b如果不重新從緩存a中重新加載,就會造成緩存臟數據。
最直觀的方案是刷新a緩存時,同步刷新b緩存,但從上述依賴關系可以看到,b依賴a,a並不依賴b,b緩存對於a應該是不可見的,所以從邏輯上來說不符合依賴的規則。
而且上面只是二級關聯,如果是四級,五級的話,上層緩存的變更帶動了太多下級緩存的變更,需要耗費很多時間,因此如果能用延遲刷新或許是更好的方案。
用時間戳或許是個不錯的辦法,上述例子中,可以給緩存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() {
// 優先從緩存中取
List<Function> functions = cacheService.get(FUNCTION_CACHE_KEY);
if(functions != null){
return functions;
}
// 緩存中沒有,從數據庫中取,並放入緩存
functions = functionDao.loadFunctions();
cacheService.put(FUNCTION_CACHE_KEY, functions);
// 同步更新時間戳
String timeStamp = String.valueOf(System.currentTimeMillis());
cacheService.put(FUNCTION_TIME_STAMP, timeStamp);
return functions;
}
// 根據用戶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;
}
需要說明的是,上述代碼只是作為示例,真正開發時用戶的權限信息一般有更好的處理方式,並不一定是上面示例中每個用戶都單獨放一份緩存。
因為上面緩存只是二級級聯,如果級數更多,同樣可以用時間戳來進行延遲加載
數據庫 --> 緩存a --> 緩存b --> 緩存c --> 緩存d
獲取緩存d時,可以校驗 緩存a時間戳 + 緩存b時間戳 + 緩存c時間戳,abc任何一個時間戳發生變化,緩存d都需要重新加載,思路和上面的差不多,這里就不多贅述了。
guava 的妙用
對於權限校驗中使用頻率高,但校驗邏輯又不常變化的地方可以再加一層緩存。
例如一般都權限系統都有對外的接口,可以直接匿名訪問,校驗代碼如下
// 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訪問都會校驗,可以通過加一層緩存來優化性能
用分布式緩存感覺有點大材小用,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支持的新特性,可以把一些數據緩存放在客戶端,減輕服務器的壓力,例如可以把菜單數據放到客戶端,菜單數據是否過期通過時間戳來判斷,偽代碼如下:
var timestamp = localStorage.getItem("timestamp" + userId);
// 請求后台獲取菜單接口,帶上時間戳參數 timestamp
// 后台校驗時間戳是否變更,如果變更,返回新的菜單數據和新的時間戳,否則不需要返回菜單數據,仍舊返回舊的時間戳即可
// 后台接口返回數據格式 result = {menus:{},timestamp:""}
var newTimestamp = result.timestamp;
// 時間戳變更,把新的菜單數據和新的時間戳 放入 localStorage
if (newTimestamp != timestamp) {
localStorage.setItem("menus" + userId, JSON.stringify(result.menus));
localStorage.setItem("timestamp" + userId, newTimestamp);
}
有人擔心把緩存放在localStorage中如果被修改會造成安全問題,其實這個擔心是沒必要的,因為權限校驗是在服務器端做的,localStorage中的緩存只做展示使用,因此修改localStorage時沒有任何意義的。
總結
在不同的情況下,上述場景分別用了ehcache,redis,guava,localStorage做緩存,更加說明了沒有最好的技術,只有最適合的技術。通過引入時間戳這種版本號的機制,解決了緩存更新問題。最終的目的只有一個,保證緩存數據一致性的同時,把性能做的極致,用戶體驗做到最好。