权限缓存设计

权限系统缓存设计知多少

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

单节点缓存

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

转载地址:权限系统缓存设计知多少 - 赵孤鸿 - 博客园

本项目详细介绍请看:http://www.sojson.com/shiro (强烈推荐) Demo已经部署到线上,地址是http://shiro.itboy.net, 管理员帐号:admin,密码:sojson.com 如果密码错误,请用sojson。 PS:你可以注册自己的帐号,然后用管理员赋权限给你自己的帐号,但是,每20分钟会把数据初始化一次。建议自己下载源码,让Demo跑起来,然后跑的更快,有问题加群解决。 声明: 本人提供这个Shiro + SpringMvc + Mybatis + Redis 的Demo 本着学习的态度,如果有欠缺和不足的地方,给予指正,并且多多包涵。 “去其糟粕取其精华”。如果觉得写的好的地方就给个赞,写的不好的地方,也请多多包涵。 使用过程: 1.创建数据库。 创建语句 :tables.sql 2.插入初始化数据 插入初始化数据:init.data.sql 3.运行。 管理员帐号:admin 密码:sojson ps:定时任务的sql会把密码改变为sojson.com 新版本说明:http://www.sojson.com/blog/164.html 和 http://www.sojson.com/blog/165.html 主要解决是之前说的问题:Shiro 教程,关于最近反应的相关异常问题,解决方法合集。 项目在本页面的附件中提取。 一、Cache配置修改。 配置文件(spring-cache.xml )中已经修改为如下配置: <!-- redis 配置,也可以把配置挪到properties配置文件中,再读取 --> <!-- 这种 arguments 构造的方式,之前配置有缺点。 这里之前的配置有问题,因为参数类型不一致,有时候jar和环境的问题,导致参数根据index对应,会处理问题, 理论上加另一个 name,就可以解决,现在把name 和type都加上,更保险。 --> 二、登录获取上一个URL地址报错。 当没有获取到退出前的request ,为null 的时候会报错。在(UserLoginController.java )135行处有所修改。 /** * shiro 获取登录之前的地址 * 之前0.1版本这个没判断空。 */ SavedRequest savedRequest = WebUtils.getSavedRequest(request); String url = null ; if(null != savedRequest){ url = savedRequest.getRequestUrl(); } /** * 我们平常用的获取上一个请求的方式,在Session不一致的情况下是获取不到的 * String url = (String) request.getAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE); */ 三、删除了配置文件中的cookie写入域的问题。 在配置文件里(spring-shiro.xml )中的配置有所修改。 <!-- 会话Cookie模板 --> <!--cookie的name,我故意取名叫xxxxbaidu --> <!--cookie的有效时间 --> <!-- 配置存储Session Cookie的domain为 一级域名 --> 上面配置是去掉了 Session 的存储Key 的作用域,之前设置的.itboy.net ,是写到当前域名的 一级域名 下,这样就可以做到N 个 二级域名 下,三级、四级....下 Session 都是共享的。 <!-- 用户信息记住我功能的相关配置 --> <!-- 配置存储rememberMe Cookie的domain为 一级域名 --> <!-- 30天时间,记住我30天 --> 记住我登录的信息配置。和上面配置是一样的道理,可以在相同 一级域名 下的所有域名都可以获取到登录的信息。 四、简单实现了单个帐号只能在一处登录。 我们在其他的系统中可以看到,单个帐号只允许一人使用,在A处登录了,B处再登录,那A处就被踢出了。如下图所示。 但是此功能不是很完美,当A处被踢出后,再重新登录,这时候B处反应有点慢,具体我还没看,因为是之前加的功能,现在凌晨了,下次我有空再瞧瞧,同学你也可以看看,解决了和我说一声,我把功能修复。 五、修复功能(BUG) 1.修复权限添加功能BUG。 之前功能有问题,每当添加一个权限的时候,默认都给角色为“管理员”的角色默认添加当前新添加的权限。这样达到管理员的权限永远是最大的。由于代码有BUG ,导致所有权限删除了。现已修复。 2.修复项目只能部署到Root目录下的问题。 问题描述:之前项目只能部署到Root 下才能正常运行,目前已经修复,可以带项目路径进行访问了,之前只能这样访问,http://localhost:8080 而不能http://localhost:8080/shiro.demo/ 访问,目前是可以了。 解决方案:在 FreeMarkerViewExtend.java 33行处 增加了BasePath ,通过BasePath 来控制请求目录,在 Freemarker 中可以自由使用,而 JSP 中是直接在 JSP 中获取BasePath 使用。 解决后遗症:因为我们的权限是通过URL 来控制的,那么增加了项目的目录,导致权限不能正确的判断,再加上我们的项目名称(目录)可以自定义,导致更不好判断。 后遗症解决方案:PermissionFilter.java 50行处 解决了这个问题,详情请看代码和注释,其实就是replace 了一下。 HttpServletRequest httpRequest = ((HttpServletRequest)request); /** * 此处是改版后,为了兼容项目不需要部署到root下,也可以正常运行,但是权限没设置目前必须到root 的URI, * 原因:如果你把这个项目叫 ShiroDemo,那么路径就是 /ShiroDemo/xxxx.shtml ,那另外一个人使用,又叫Shiro_Demo,那么就要这么控制/Shiro_Demo/xxxx.shtml * 理解了吗? * 所以这里替换了一下,使用根目录开始的URI */ String uri = httpRequest.getRequestURI();//获取URI String basePath = httpRequest.getContextPath();//获取basePath if(null != uri && uri.startsWith(basePath)){ uri = uri.replace(basePath, ""); } 3.项目启动的时候报错,关于JNDI的错误提示。 其实也不是错,但是看着不舒服,所以还得解决这个问题。解决这个问题需要在web.xml 中的开始部位加入以下代码。 spring.profiles.active dev spring.profiles.default dev spring.liveBeansView.mbeanDomain dev 4.项目Maven打包问题。 打包的时候,不同版本的 Eclipse 还有IDEA 会有打包打不进去Mapper.xml 文件,这个时候要加如下代码(群里同学提供的)。 src/main/java **/*.properties **/*.xml false 在 标签内加入即可,如果还是不能解决,那么请你加群(改名后)说明你的问题,有人会回答你。 5.Tomcat7以上在访问JSP页面的时候,提示JSTL错误。 这个错误是因为Tomcat7 中没有 JSTL 的jar包,现在已经在项目pom.xml 中增加了如下 jar 的引入管理。 javax.servlet jstl 1.2 javax.servlet jsp-api 2.0 provided 如果还是不能解决问题,请在官方群(群号:259217951)内搜索“jstl” 如图下载依赖包。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值