业务背景
笔者所负责的项目中存在多个相似模块,需要与对应的外部API进行交互,并且该交互具备如下特点:
- 高频交互(超过10个并发/负载*s)
- 需要定期更新token\session
- 单个token访问并发存在限制,需要同时使用多个token
在传统业务流程中,我们通常遵循如下流程对token进行检查
这样做的缺陷很明显,即每个请求都会在是否存在token和是否可用上面做判断,增加开销的同时降低了接口的效率。同时如果token过期,请求又会额外进行koken获取的工作。这个通常来说是阻塞操作,也进一步降低了效率,提高了接口相应的时间。而且在缓存过期的同时发起的请求,会进行并发获取token的操作,也引发了一些不必要的风险产生。
所以,当我们使用了Caffeine这样的本地缓存时,利用缓存过期的特性,可以简化上述步骤,同时避免出现并发获取token的场景发生
但是我们也只是解决了部分的问题。
实现目标
那有没有一种办法可以达到如下目的:
- 异步进行token检查,并完成token更新
- 多Token管理,并对Token进行请求分散化处理(Token级别的负载均衡)
- 多场景中可以进行代码复用
“只要思想不滑坡,办法总比困难多”。这样的需求对于各位久经沙场的读者而言方案自然手到擒来。比如说:进行token的hash分布管理达到负载均衡的目的;使用工厂类保证代码可以被复用;利用异步线程池,使用run()方法实现异步的token检查工作。等等~
得益于WebFlux所基于的Reactive Stream框架,上述需求得以从不同的思路中轻松实现。
运行环境
- Java11
- SpringBoot 2.3.4.RELEASE
- Spring WebFlux
注:虽然使用了Java11 但是其中的API在Java8中都是支持的,可以直接迁移使用。
基础知识说明
WebFlux&Reactor
进这篇文章的各位多少对WebFlux有一定的认识。如果小伙伴对相关知识点还有些模糊,建议先参考如下这篇文章《探究WebFlux之WebFlux 的秘密》
doOnNext
作为Reactor中的一个重要操作符,doOnNext可以非常便捷得帮助我们完成一些异步操作,从而达到我们期望达到的项目目标。
对于Reactor相关操作符还不是非常熟悉的童鞋,可以参看Reactor官方文档
CAS
CAS通过CPU原生的指令对内存数据进行原子操作,在JAVA中具体使用的是Atomic类来进行实现。在这个项目中,Atomic类的相关实现被用作对应Token更新的原子锁。
基础流程图
我们遵循如下流程图。核心是利用其高并发,及响应式无阻塞特性,将Token更新尽量在后台完成,将其对实时请求的影响降到最低
这个流程图最大的改变是如果有可用Token,哪怕过了“更新有效期”需要进行更新,也不会再主线程中处理,又doOnNext进行一个独立的“流”操作。我们再通过CAS机制在token更新时对token加锁,过滤同一时期共同请求更新Token的线程请求,这样保证获取token的Api不会被集中调用应发灾难后果。
代码
接口
接口定义多个方法,用于不同场景下的使用
package com.ly.train.sb.service.cache;
import reactor.core.publisher.Mono;
/**
* BackgroundRefreshCacheService
* 后台更新缓存
* <p>
* 在许多业务场景下(如获取xx的Token\Cookie等),缓存在业务
* 调用时检测有效性并进行更新是一件非常损耗性能的事儿:及对应请
* 求需要等待缓存更新完毕后才能使用,直接拉长了请求。而当前实现
* 为了解决这一问题。及在高并发的场景下,通过设定一个异步更新流
* 来处理缓存值的更新问题
* <p>
* 在这个缓存工具中,有如下几个特点:
* 1、内部都为了实现同一个数据而存在(如token等),所以不存在
* Key的概念,只有Value一个泛型
* 2、全部使用响应式接口,用于配合响应式编程使用
* 3、在具体的接口实现中,可以使用配置的方式配置多个缓存值,使
* 用负载均衡逻辑平均每个值的使用频次。
*
* @author John Chen
* @since 2021/2/4
*/
public interface BackgroundRefreshCacheService<V> {
/**
* 获取值
* 在这个方法下,后方逻辑在缓存存在且有效的情况下给出缓存值,
* 并给出一个异步流(doOnNext())用于检查缓存是否需要更新。
* 当缓存已经无效时,进行直接更新
*
* @return 返回对应值。如果无法获取值,则会抛出异常
*/
Mono<V> get();
/**
* 获取指定索引位置的值
* 同{@link #get()}的基础逻辑一样,不同的是,通过index参数,
* 后端会给出清单中指定位置的缓存值
* 注意:建议通过{@link #get()}方法获取缓存
*
* @param index 缓存序列索引
* @return 返回指定索引位置的值,如果无法获取值,会抛出异常
*/
Mono<V> get(Mono<Integer> index);
/**
* 手动刷新缓存
* 调用该方法会手动将所有进入了更新周期的缓存刷新一遍。可以
* 用于方法并未被频繁访问时,手动更新缓存
*/
void refresh();
/**
* 手动刷新缓存
* 需要注意的是,同{@link #refresh()}不同,指定位索引位置
* 会被强制刷新,并不会判断是否已经过期
*
* @param index 缓存的索引位置
*/
void refresh(Integer index);
/**
* 强制刷新所有缓存
* 非常消耗性能的方法,使用时要格外注意!
*/
void refreshAll();
}
工厂类
工厂类几乎实现了所有接口中的方法,并完成了整个需求目标的主逻辑。实际场景中只要继承当前工厂类,完成对应的token获取逻辑,即可完成接入
package com.ly.train.sb.service.cache;
import com.ly.train.sb.common.config.properties.BackGroundRefreshConfigProperties;
import com.ly.train.sb.common.utils.RandomUtils;
import com.google.gson.Gson;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* BaseBackgroundRefreshCacheServiceFactory
* 用于构建{@link BackgroundRefreshCacheService}实现类的工厂类
*
* @author John Chen
* @since 2021/2/5
*/
@Slf4j
public abstract class BaseBackgroundRefreshCacheServiceFactory<V> implements BackgroundRefreshCacheService<V> {
/**
* 用于标识对应索引位置的缓存正在执行更新的标志位
* 使用true代表正在更新,使用false代表无任务正在更新
* 在运行前,先通过CAS机制,将值变更为true
* 利用{@link AtomicInteger}的CAS机制,当更新对应标志位成功时,对应任务则获取到执行许可(相当于一个乐观锁)
* 而CAS更新失败时,则证明有其它任务(线程)正在更新。则放弃当前任务的更新操作。从而实现无阻塞的后台更新机制
*/
private final Map<Integer, AtomicBoolean> cacheChangingSingleMap = new ConcurrentHashMap<>();
/**
* 用于存储真正缓存值的位置
*/
private final Map<Integer, CacheEntity> cacheMap = new ConcurrentHashMap<>();
/**
* 用于获得新的缓存值的函数
* 由于获取缓存新值可能是一个阻塞过程。所以使用Mono进行输出
*/
private final Supplier<Mono<V>> getCacheValue;
/**
* 当前类型的功能专用配置类
*/
private final Supplier<BackGroundRefreshConfigProperties.ConfigEntity> getConfigEntity;
/**
* 用于测试对应的缓存是否需要进行更新的函数
*/
private final Predicate<CacheEntity> cacheNeedRefresh;
private final Predicate<CacheEntity> cacheCanBeUsed;
private final Gson gson;
protected BaseBackgroundRefreshCacheServiceFactory(Supplier<Mono<V>> getCacheValue, Supplier<BackGroundRefreshConfigProperties.ConfigEntity> getConfigEntity, Gson gson, Predicate<CacheEntity> cacheNeedRefresh, Predicate<CacheEntity> cacheCanBeUsed) {
this.getCacheValue = getCacheValue;
this.getConfigEntity = getConfigEntity;
this.gson = gson;
this.cacheNeedRefresh = cacheNeedRefresh;
this.cacheCanBeUsed = cacheCanBeUsed;
}
/**
* 不指定判断缓存是否需要刷新的方法,使用默认方法
*
* @param getCacheValue 用于获得新的缓存值的函数
* @param getConfigEntity 当前类型的功能专用配置类
*/
protected BaseBackgroundRefreshCacheServiceFactory(Supplier<Mono<V>> getCacheValue, Supplier<BackGroundRefreshConfigProperties.ConfigEntity> getConfigEntity, Gson gson) {
this.getCacheValue = getCacheValue;
this.getConfigEntity = getConfigEntity;
this.gson = gson;
BackGroundRefreshConfigProperties.ConfigEntity configEntity = getConfigEntity.get();
this.cacheNeedRefresh = data -> defaultCacheNeedRefresh(data.getDataUpdateTime(), configEntity);
this.cacheCanBeUsed = data -> defaultCacheCanBeUsed(data.getDataUpdateTime(), configEntity);
}
/**
* 判断缓存是否需要刷新(不包含缓存是否可用判断)
* 在实际使用中,应当先判断缓存是否可用,再判断缓存是否需要刷新
*
* @param dataUpdateTime 数据生成时间
* @param configEntity 对应的配置实体
* @return 缓存是否需要刷新。true则需要刷新;false则无需刷新
*/
protected static boolean defaultCacheNeedRefresh(long dataUpdateTime, BackGroundRefreshConfigProperties.ConfigEntity configEntity) {
//已过去的秒数
long passTimeSec = (System.currentTimeMillis() - dataUpdateTime) / 1000;
return passTimeSec > configEntity.getNeedRefreshSec();
}
/**
* 默认的用于判断缓存是否可用的方法
*
* @param dataUpdateTime 数据生成时间
* @param configEntity 对应的配置实体
* @return 返回缓存是否可用。true缓存可用;false缓存不可用
*/
protected static boolean defaultCacheCanBeUsed(long dataUpdateTime, BackGroundRefreshConfigProperties.ConfigEntity configEntity) {
//已过去的秒数
long passTimeSec = (System.currentTimeMillis() - dataUpdateTime) / 1000;
return passTimeSec < configEntity.getMaxValidPeriod();
}
/**
* 初始化方法
* 用于启动时进行数据预热
* 会刷新全部缓存
*/
@PostConstruct
public void init() {
BackGroundRefreshConfigProperties.ConfigEntity configEntity = getConfigEntity.get();
log.info("init star.config:{}", configEntity);
Disposable disposable = Flux.range(0, configEntity.getCacheCount())
//元素延迟
.delayElements(configEntity.getBatchRefreshDelay())
.log("initting")
//从入参中逐个进行刷新
.subscribe(this::refresh);
while (true) {
if (disposable.isDisposed()) {
break;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
log.info("init finished");
}
/**
* 获取值
* 在这个方法下,后方逻辑在缓存存在且有效的情况下给出缓存值,
* 并给出一个异步流(doOnNext())用于检查缓存是否需要更新。
* 当缓存已经无效时,进行直接更新
*
* @return 返回对应值。如果无法获取值,则会抛出异常
*/
@Override
public Mono<V> get() {
return get(Mono.just(RandomUtils.randomIntFromMax(getConfigEntity.get().getCacheCount() - 1)));
}
/**
* 获取指定索引位置的值
* 同{@link #get()}的基础逻辑一样,不同的是,通过index参数,
* 后端会给出清单中指定位置的缓存值
* 注意:建议通过{@link #get()}方法获取缓存
*
* @param index 缓存序列索引
* @return 返回指定索引位置的值,如果无法获取值,会抛出异常
*/
@Override
public Mono<V> get(Mono<Integer> index) {
return index.flatMap(i -> {
CacheEntity cacheEntity = cacheMap.get(i);
//判断缓存是否存在
if (cacheEntity != null) {
//如果缓存存在且还处于可用状态,则返回缓存值,同时进行缓存刷新的判断和操作
return cacheCanBeUsed.test(cacheEntity) ?
//缓存处于可用状态,则首先返回值,并检查缓存是否需要更新
Mono.just(cacheEntity.getData())
//检查是否需要更新
.doOnNext(v -> {
if (cacheNeedRefresh.test(cacheEntity)) {
log.info("index:{} need refresh", index);
//需要被更新,则进行更新
this.refresh(i);
}
})
:
//缓存处于不可用状态,则进行强制刷新后返回数据
getNewValue(i);
} else {
//如果缓存不存在或不可用,则进行刷新处理
return getNewValue(i);
}
});
}
/**
* 手动刷新缓存
* 调用该方法会手动将所有进入了更新周期的缓存刷新一遍。可以
* 用于方法并未被频繁访问时,手动更新缓存
*/
@Override
public void refresh() {
BackGroundRefreshConfigProperties.ConfigEntity configEntity = getConfigEntity.get();
Integer cacheCount = configEntity.getCacheCount();
List<Integer> refreshIndexes = new ArrayList<>(cacheCount);
for (int i = 0; i < cacheCount; i++) {
CacheEntity entity = cacheMap.get(i);
/*
需要更新的3中情况:
1-entity不存在
2-cacheNeedRefresh=true
3-cacheCanBeUsed=false
*/
if (entity == null || cacheNeedRefresh.test(entity) || !cacheCanBeUsed.test(entity)) {
//如果需要更新,则上锁后放入更新队列
refreshIndexes.add(i);
}
}
//最后,进行刷新
refreshAll(Flux.fromIterable(refreshIndexes));
}
/**
* 手动刷新缓存
* 需要注意的是,同{@link #refresh()}不同,指定位索引位置
* 会被强制刷新,并不会判断是否已经过期
* 这里更新之前,建议都上一下锁
*
* @param index 缓存的索引位置
*/
@Override
public void refresh(Integer index) {
if (lock(index)) {
//上锁成功则进行更新
getNewValue(index).subscribe(v -> log.info("refresh index:{} completed.value:{}", index, gson.toJson(v)));
}
}
/**
* 强制刷新所有缓存
* 非常消耗性能的方法,使用时要格外注意!
*/
@Override
public void refreshAll() {
int cacheCount = getConfigEntity.get().getCacheCount();
refreshAll(Flux.range(0, cacheCount));
}
/**
* 根据索引进行刷新
* 调用刷新之前,记得都上一下锁
*
* @param refreshIndexes 需要被刷新的索引清单
*/
private void refreshAll(Flux<Integer> refreshIndexes) {
// refreshIndexes
refreshIndexes
//元素延迟
.delayElements(getConfigEntity.get().getBatchRefreshDelay())
//从入参中逐个进行刷新
.subscribe(this::refresh);
}
/**
* 获取一个新值
* 作为整个功能中,真正刷新缓存的并给出新值的地方
* 完成了数据获取及解锁的工作
* 相当于强制刷新缓存,同时返回流
* <p>
* 内部会再次进行上锁并执行更新缓存的操作,而忽略
* CAS的状态。至于是否要根据CAS的状态判断是否要更
* 新,应当由外部进行实现
*
* @param index 索引
* @return 返回一个新的值
*/
private Mono<V> getNewValue(Integer index) {
/*
这里的CAS锁使用机制说明:
由于在这个位置是进行强制刷新的,所以无论原来的状态是否被锁,这里都需要进行上锁
所以在此处直接使用了set方法,而无视原有状态
*/
lockEnforce(index);
return getCacheValue.get()
//获取到值后,将内容对缓存进行更新
.doOnNext(v -> {
//将值放入缓存中
cacheMap.put(index, new CacheEntity(System.currentTimeMillis(), v));
})
//通过Finally方式进行解锁
.doFinally(signalType ->
unlock(index)
);
}
/**
* 上锁操作
*
* @param index 对应的索引位置
* @return 返回是否上锁成功
*/
private boolean lock(int index) {
return cacheChangingSingleMap.computeIfAbsent(index, v -> new AtomicBoolean(false))
//获取到的锁通过CAS机制进行变更。如果变更失败,则说明已经有逻辑在进行更新了
.compareAndSet(false, true);
}
/**
* 强制上锁操作
*
* @param index 对应的索引位置
*/
private void lockEnforce(int index) {
cacheChangingSingleMap
//如果没有,则生成一个新的
//这里直接构建一个带有true值的变量
.computeIfAbsent(index, v -> new AtomicBoolean(true)).set(true);
}
/**
* 解锁操作
*
* @param index 对应的索引位置
*/
private void unlock(int index) {
cacheChangingSingleMap
//如果没有,则生成一个新的
//这里直接构建一个带有true值的变量
.computeIfAbsent(index, v -> new AtomicBoolean(false)).set(false);
}
/**
* 缓存实体,包含了一个时间标志位,用于记录缓存数据产生时间,判断有效性
*/
@AllArgsConstructor
@Getter
public class CacheEntity {
private final long dataUpdateTime;
private final V data;
}
}
配置类
配合工厂类中各个功能所需的配置,我们使用了一个配置类来完成配置的调控
package com.ly.train.sb.common.config.properties;
import com.ly.train.sb.common.constants.config.ConfigPropertiesPrefixConstants;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* BackGroundRefreshConfigProperties
* 服务于BaseBackgroundRefreshCacheServiceFactory的配置类
*
* @author John Chen
* @since 2021/2/5
*/
@Setter
@Slf4j
@ConfigurationProperties(ConfigPropertiesPrefixConstants.Application.BACKGROUND_REFRESH)
public class BackGroundRefreshConfigProperties {
/**
* 默认Config,用于配置异常,拿不到有效数据时
*/
private final static ConfigEntity DEFAULT_CONFIG_ENTITY = new ConfigEntity(60L, 6000L, 1, Duration.ofMillis(1));
/**
* C的Cookies缓存
*/
private ConfigEntity cCookies;
public ConfigEntity getCCookies() {
return czpcCookies == null ? DEFAULT_CONFIG_ENTITY : czpcCookies;
}
@AllArgsConstructor
@NoArgsConstructor
@Setter
@ToString
public static class ConfigEntity {
/**
* 缓存刷新周期(秒)
* 超过当前数值的缓存需要进行刷新
*/
private Long needRefreshSec;
/**
* 最长有效期(秒)
* 超过maxValidPeriod配置的缓存,无法使用,需要刷新后才能继续使用
* 通常来说,该数值需要配置>needRefreshSec值
*/
private Long maxValidPeriod;
/**
* 缓存值个数
*/
private Integer cacheCount;
/**
* 当需要批量刷新的时候,每个刷新任务的执行间隔
* 注意,实际使用时,参数通过毫秒数控制,则最小为1ms间隔。那么1s最多能够进行1000次刷新
* 一定要在实际使用中注意这一点!即批量刷新的性能上线必须满足实际刷新频次需求
*/
private Duration batchRefreshDelay;
/**
* 通过毫秒数配置batchRefreshDelay参数
*
* @param batchRefreshDelayMillis 批量刷新间隔秒数
*/
public void setBatchRefreshDelay(Long batchRefreshDelayMillis) {
this.batchRefreshDelay = Duration.ofMillis(batchRefreshDelayMillis);
}
public Long getNeedRefreshSec() {
if (needRefreshSec == null || needRefreshSec < 1) {
log.error("needRefreshSec is null or error,use default config.");
return DEFAULT_CONFIG_ENTITY.needRefreshSec;
} else {
return needRefreshSec;
}
}
public Long getMaxValidPeriod() {
if (maxValidPeriod == null || maxValidPeriod < 1) {
log.error("maxValidPeriod is null or error,use default config.");
return DEFAULT_CONFIG_ENTITY.maxValidPeriod;
} else {
return maxValidPeriod;
}
}
public Integer getCacheCount() {
if (cacheCount == null || cacheCount < 1) {
log.error("cacheCount is null or error,use default config.");
return DEFAULT_CONFIG_ENTITY.cacheCount;
} else {
return cacheCount;
}
}
public Duration getBatchRefreshDelay() {
if (batchRefreshDelay == null || batchRefreshDelay.toMillis() < 1) {
log.error("batchRefreshDelay is null or error,use default config.");
return DEFAULT_CONFIG_ENTITY.batchRefreshDelay;
} else {
return batchRefreshDelay;
}
}
}
}