权限系统是管理类系统中必不可少的一个模块, 一个好的缓存设计更是权限系统的重中之重, 今天来聊下如何更好设计权限系统的缓存.
单节点缓存
权限校验属于使用频率超高的操作, 如果每次都去请求 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 做缓存, 更加说明了没有最好的技术, 只有最适合的技术. 通过引入时间戳这种版本号的机制, 解决了缓存更新问题. 最终的目的只有一个, 保证缓存数据一致性的同时, 把性能做的极致, 用户体验做到最好.
来源: https://www.cnblogs.com/zhaoguhong/p/9614517.html