權限系統緩存設計知多少


權限系統是管理類系統中必不可少的一個模塊,一個好的緩存設計更是權限系統的重中之重,今天來聊下如何更好設計權限系統的緩存。

單節點緩存

權限校驗屬於使用頻率超高的操作,如果每次都去請求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做緩存,更加說明了沒有最好的技術,只有最適合的技術。通過引入時間戳這種版本號的機制,解決了緩存更新問題。最終的目的只有一個,保證緩存數據一致性的同時,把性能做的極致,用戶體驗做到最好。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM