欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

权限系统缓存设计知多少

程序员文章站 2022-04-06 10:56:53
权限系统是管理类系统中必不可少的一个模块,一个好的缓存设计更是权限系统的重中之重,今天来聊下如何更好设计权限系统的缓存。 单节点缓存 权限校验属于使用频率超高的操作,如果每次都去请求db的话,不仅会给db带来压力,也会导致用户响应过慢,造成很不好的用户体验,因此把权限相关数据放到缓存中是很有必要的, ......

权限系统是管理类系统中必不可少的一个模块,一个好的缓存设计更是权限系统的重中之重,今天来聊下如何更好设计权限系统的缓存。

单节点缓存

权限校验属于使用频率超高的操作,如果每次都去请求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做缓存,更加说明了没有最好的技术,只有最适合的技术。通过引入时间戳这种版本号的机制,解决了缓存更新问题。最终的目的只有一个,保证缓存数据一致性的同时,把性能做的极致,用户体验做到最好。