业务场景
- 我们使用Spring Cloud微服务架构,使用Spring Boot 构建项目
- 现在需要将项目与另一个业务系统集成,使用同一个认证中心和用户系统
- 平台服务接入认证中心时,约定在网关服务里进行用户认证,所有服务接口调用都经过网关服务
- 网关服务接口调用时,判断是否已获取token,是否在有效期,如果没有则重新校验获取token
- 统一认证服务,提供token申请接口、校验接口和刷新接口,网关服务可以调用这些接口进行token的获取与校验
- 如果每个接口都调用统一认证服务的token校验接口,会对统一认证服务造成比较大的压力,需要做一定的缓存
缓存策略
redis缓存
- 第一步是考虑使用redis缓存,统一认证服务将以通过校验的token缓存到redis,有效期到了后再从redis里删除
- 当调用刷新token接口时,通过校验后,延续token的有效期,同时更新redis里的token的失效时间
- 网关服务连接统一认证服务使用的这个redis服务,通过判断token是否在redis里,来判断token是否通过校验
- 相关代码如下:
public Boolean checkToken(ServerHttpRequest request) {
boolean pass = false;
String token = HttpClientUtil.getTokenFromRequest(request);
String[] arr = token.split(" ");
if (arr.length == 1 || "null".equals(arr[arr.length - 1]) || "undefined".equals(arr[arr.length - 1])) {
return pass;
}
if (Boolean.TRUE.equals(redisTemplate.hasKey(REDIS_TOKEN_KEY + arr[arr.length - 1]))) {
pass = true;
} else {
logger.info("token从redis里未获取到,从api接口检查:{}", REDIS_TOKEN_KEY + arr[arr.length - 1]);
pass = checkTokenFromApi(request);
}
if (pass && RandomUtils.nextInt(1, 100) > 90) {
refreshToken(request);
}
return pass;
}
- 统一认证接入后,进行一些功能测试和性能测试,最简单的测试方式是,大量持续发出接口请求到网关服务,网关服务再获取和校验token
- 在频繁调用redis校验token时,发生了一个错误,
redisTemplate.hasKey
请求校验已有的token
时偶发返回false
(实际在redis里存在,在有效期内) - 从接口获取,和从redis获取,加了log日志打印,发现同时发起40个请求时,中间有2次
redisTemplate.hasKey
返回false
- 排查了下代码,发现是代码了做了token刷新,调用统一认证中心接口
refreshToken
请求刷新token - 为了防止频繁调用刷新接口给统一认证服务造成压力,这里简单使用
RandomUtils
做了个随机刷新,在调用此接口刷新时,统一认证服务会重新设置token有效期,可能是此时redis里短暂没有token了(由于看不到统一认证服务的代码,不知道token刷新时redis的具体处理步骤,此处为猜测)
内存Map缓存
- 为了减少进一步对统一认证服务的接口请求频率,并减少redis请求压力,决定在redis之上,再在内存里加一层缓存
- 最简单的实现方式,就是使用Map缓存,代码如下:
public Boolean checkToken(ServerHttpRequest request) {
boolean pass = false;
String token = HttpClientUtil.getTokenFromRequest(request);
String[] arr = token.split(" ");
if (arr.length == 1 || "null".equals(arr[arr.length - 1]) || "undefined".equals(arr[arr.length - 1])) {
return pass;
}
String tokenKey = REDIS_TOKEN_KEY + arr[arr.length - 1];
if (!TokenCache.isExpired(tokenKey)) {
pass = true;
} else if (Boolean.TRUE.equals(redisTemplate.hasKey(tokenKey))) {
pass = true;
TokenCache.put(tokenKey, 5 * 1000L);
} else {
logger.info("token从api接口检查:{}", tokenKey);
pass = checkTokenFromApi(request);
if (pass) {
TokenCache.put(tokenKey, 60 * 1000L);
}
}
if (pass && RandomUtils.nextInt(1, 100) > 90) {
refreshToken(request);
}
return pass;
}
- 使用内存缓存后,大大减少请求redis频率和请求接口频率,增加了系统的并发量
缓存策略介绍
- 以要存储的
token
为key
,以有效期时间戳为 value
- 每次查询时,判断是否存在此
key
或者是否已过期(与当前时间戳对比) - 具体代码如下:
package com.newatc.com.authorization;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TokenCache {
private static Map<String, Long> cacheExpireTimeMap = new ConcurrentHashMap<>();
public static void put(String key, Long expireTime) {
cacheExpireTimeMap.put(key, System.currentTimeMillis() + expireTime);
}
public static Object get(String key) {
if (isExpired(key)) {
remove(key);
return null;
}
return cacheExpireTimeMap.get(key);
}
public static void remove(String key) {
cacheExpireTimeMap.remove(key);
}
public static boolean isExpired(String key) {
if (!cacheExpireTimeMap.containsKey(key)) {
return true;
}
Long expireTime = cacheExpireTimeMap.get(key);
if (expireTime == null || System.currentTimeMillis() > expireTime) {
remove(key);
return true;
}
return false;
}
}
- 这个只是使用
Map
做内存缓存的最简单的一种方式,如果同时要存一些数据,也可以使用2个Map
去实现
Map<String, Object> cacheValueMap = new ConcurrentHashMap<>();
Map<String, Long> cacheExpireTimeMap = new ConcurrentHashMap<>();
- 除了使用
ConcurrentHashMap
作为内存缓存,也可以使用其他技术框架,如Caffeine
(Caffeine 是一个基于 Java 8 ConcurrentHashMap 和 Google Guava 的高性能内存缓存库。它提供了非阻塞的缓存数据结构,利用了 Java 8 的一些新特性,性能非常高) ConcurrentHashMap
实现方式,只能在单体应用里生效,如果需要分布式缓存,就需要其他技术方案了,如Ehcache
、Redisson
、Hazelcast
等