我正在参与「启航计划」
前语
为了便于剖析和排查问题,我期望可以保存下每一条恳求日志。那已然每一条都要保存,把这个功用增加到网关服务中我觉得是比较适宜的了。
可是也正因为一切的恳求都要通过这个被保存的进程,所以我期望这个进程要尽可能的简短不费时刻,不要影响我的拜访速度。
正巧我学过一点 Redis 的知识,简略了解过Redis耐久化的思路,所以我参阅了它的思路,给自己的网关服务写了一个运用 缓存 + 异步 的存储策略,当然我这儿耐久化便是保存到MySQL了。
本文所介绍的内容均来自我的开源项目学校博客中,开源地址:stick-i/scblogs: 学校博客,根据微服务架构且前后端别离的博客社区体系。项目后端技术栈:SpringBoot + SpringCloud + Mybatis-Plus + Nacos + MySQL + Redis + MQ + ElasticSearch + Docker。前端主要是根据Vue2和ElementUI进行开发的。 (github.com)
基本思路
我的大致思路是这样的:
- 先把每一条恳求信息存到缓存中,可以直接存 List,或许运用 Redis 。
- 每隔一段时刻,创建一个子线程,去读取缓存中的数据,并将数据存入硬盘,这儿我存MySQL。
- 假如没有新的拜访记载,那就不用去守时履行了,所以最好可以主动调理,通过恳求触发子进程的保存。
- 程序正常退出的情况下,最好可以再主动保存一次缓存中的数据,这儿可以注册一个Hook来履行,防止守时使命没到履行时刻。
由于这个功用我现已在项目中完成好了,而且现已运用一段时刻了,所以下面我会直接就着现已写好的代码来跟咱们剖析解说。
代码完成
首要介绍一下基本情况:
- 项目的网关运用的是
Spring Cloud Gateway
,保存恳求记载的逻辑通过大局过滤器GlobalFilter
来调用,也便是用一个单独的过滤器去保存拜访记载。 - 用于存储到数据库的实体类为
VisitRecord
,内容包含ip地址、uri、恳求办法、恳求参数、状态码等信息。这些信息可以从ServerWebExchange
的目标中获取,解析之后放到VisitRecord
的目标里就行了。
有上面这些条件后,下面咱们就可以只关注 恳求日志 的完成了,这部分的完成我放在了VisitRecordService
类里边了,源码所在位置:scblogs/VisitRecordService.java at main stick-i/scblogs (github.com)。
在下面的解说中,我剔除了大部分事务相关的东西,可是我保留了一部分。
是故意的仍是不小心的?
进口
通过调用下面的办法,可以将通过网关的拜访记载进行储存。
/**
* 保存拜访记载
*
* @param exchange gateway拜访合同
*/
public void add(ServerWebExchange exchange) {
// 获取信息
ServerHttpResponse response = exchange.getResponse();
ServerHttpRequest request = exchange.getRequest();
// 构建VisitRecord
VisitRecord visitRecord = getOrBuild(exchange);
// 打印拜访情况
log.info(visitRecord.toString());
// 增加拜访记载
addRecord(visitRecord);
}
这段代码很简略,便是先拿到了要被存储的拜访记载信息,然后再去调用了另一个办法addRecord()
。
存入缓存
咱们接着上面的addRecord()
办法持续往下看:
private void addRecord(VisitRecord record) {
// 增加记载到缓存中
visitCache.add(record);
// 履行使命,保存数据
doTask();
}
这个办法也很简略,便是往缓存里增加了这条新的记载,然后调用了doTask()
办法去履行存储的使命。
先看看这个visitCache
是个什么东西?
/**
* 缓存,在刺进数据库前先存入此。
* 为防止数据被重复刺进,故运用Set,但不能确保100%不会被重复存储。
*/
private HashSet<VisitRecord> visitCache = new HashSet<>();
其实便是个HashSet
,不过我这儿用Set是有原因的:
在我的这个项目中有个Gateway专用的大局反常处理器GlobalExceptionHandler
,假如产生反常的话,会被这个处理器捕获,而且会打断过滤器的履行。根据这个逻辑,可能会呈现两种情况:
- 现已履行了保存的办法,在保存恳求记载过滤器的后边抛出了反常,这样的话不需求再从头保存日志了。
- 还没履行过保存的办法,也便是在它前面抛出了反常,这样的话肯定是需求从头保存日志的。
所以归纳这两种情况,我挑选了运用Set,而且在反常处理器里加入了保存拜访记载的逻辑(便是调用最上面那个进口办法),这样可以确保不会呈现漏掉拜访记载的情况,也可以尽量避免重复保存的情况,但不能完全确保不会被重复保存。
多讲了几局题外话,这个跟主题关系不大了,感兴趣的朋友可以去GitHub看我的项目源码持续了解:链接。
履行使命
数据现已存到缓存了,咱们接着上面的 doTask();
办法看:
private final ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNamePrefix("visit-record-").build();
private final ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 3, 15, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
/**
* 信号量,用于标记当前是否有使命正在履行,{@code true}表明当前无使命进行。
*/
private volatile boolean taskFinish = true;
/**
* 单次批量刺进的数据量
*/
private final int BATCH_SIZE = 500;
private void doTask() {
if (taskFinish) {
// 当前没有使命的情况下,加锁并履行使命
synchronized (this) {
if (taskFinish) {
taskFinish = false;
threadPool.execute(() -> {
try {
// 当数据量较小时,则等候一段时刻再刺进数据,然后做到将数据尽可能的批量刺进数据库
if (visitCache.size() <= BATCH_SIZE) {
Thread.sleep(500);
}
batchSave();
} catch (InterruptedException e) {
log.error("休眠时产生了反常: {}", e.getMessage());
} finally {
// 使命履行结束后修正标志位
taskFinish = true;
}
});
}
}
}
}
这部分就有点东西了:
-
首要是经典的双重查看锁定,运用
taskFinish
信号量,而且被volatile润饰,估计在面试资料的单例模式写法上见过吧。这样可以确保第二个if里边的东西只会被单独履行,而不会并发履行。 -
有一个被final润饰的线程池,而且用了线程工厂,这样在打印日志的时候可以看到哪些日志是由这部分代码打印的噢。线程池的中心线程数是 1 ,这跟下面的使命提交有关,每次最多只会存在一个使命。
-
在第二个if里边,是这个办法的中心逻辑。首要把信号量改为了false,表明当前现已有使命在履行了,然后向线程池提交了一个使命,在使命中通过 finally 确保使命履行结束后再康复信号量。
-
考虑到数据量比较小的时候,可能会不断的创建使命,数据保存完之后立刻又需求保存新的数据,而且可能每次都只保存了一条两条数据,这样有点违背了咱们批量刺进的初心,也糟蹋了功能。
所以我设定了一个常量
BATCH_SIZE = 500
,用来表明我期望单次批量刺进的数据量。假如当前缓存里的数据量小于该数据量,那么让线程在此等候那么一会,再去履行真正的跟数据库交互的操作batchSave()
。这儿需求考虑的是,假如我有另一个服务会去读取并展示这些恳求日志,那我肯定期望恳求日志是可以实时更新的,所以我挑选sleep 0.5秒,而不是等到数据量到达500才存入。
这样既减轻了体系负担,又可以尽量做到即时更新,可谓一箭双雕。
存入数据库
经历了这么几个步骤,终于要存数据库了,也便是上文使命中的最后一个办法batchSave();
,先来看看代码:
/**
* 单次批量刺进的数据量
*/
private final int BATCH_SIZE = 500;
/**
* 减缩因子,每次更新缓存Set时缩小的倍数,对应HashSet的扩容倍数
*/
private final float REDUCE_FACTOR = 0.5f;
private void batchSave() {
log.debug("拜访记载准备刺进数据库,当前数据量:{}", visitCache.size());
if (visitCache.size() == 0) {
return;
}
// 结构新目标来存储数据,旧目标保存到数据库后不再运用
HashSet<VisitRecord> oldCache = visitCache;
visitCache = new HashSet<>((int) (oldCache.size() * REDUCE_FACTOR));
boolean isSave = false;
try {
// 存入数据库
isSave = visitLogService.saveBatch(oldCache, BATCH_SIZE);
} finally {
if (!isSave) {
// 假如刺进失利,则从头增加一切数据
visitCache.addAll(oldCache);
}
}
}
这段代码也是有亮点的,咱们来剖析一下:
- 首要查看了一下缓存中的数据量,这没什么好说的。
- 然后将缓存目标
visitCache
运用了一个新的变量oldCache
来引用,然后new了一个新的HashSet
目标,而且让visitCache
去引用了这个新目标,再把oldCache
批量刺进数据库,这儿的saveBatch是用的Mybatis-Plus的办法,便是批量刺进到数据库里的。
这儿是有说法的:
-
为什么我不直接保存
visitCache
到数据库,还要多创建一个新缓存目标,再去保存旧目标?结合本文存入缓存的代码,我无法确保在把这些数据存入数据库的期间没有新的恳求被存入缓存,也便是
visitCache
目标。那在visitLogService.saveBatch();
履行结束后,我就无法确保此时的visitCache
悉数被存到数据库了,那我究竟还要不要调用visitCache.clear()
办法呢? -
创建新目标时我是这么写的
visitCache = new HashSet<>((int) (oldCache.size() * REDUCE_FACTOR));
,为什么我给HashSet的初始巨细要运用 旧缓存的巨细 * 0.5 呢?-
首要,我不期望visitCache去渐渐扩容到适宜的巨细,这样糟蹋功能。
-
其次,我期望它不要有过多的冗余容量,假如我的初始化巨细直接便是
oldCahce.size()
,那它的容量永远都不会降下来了。 -
至于为什么是0.5,因为HashSet的底层其实便是个HashMap,而HashMap每次扩容都是上一次容量巨细的两倍,HashMap初始化容量巨细的值,也必须是2的次方。假如不是2的次方,则会主动帮你调整为向上取的第一个2的次方的数,比如我给的参数是10,那它的初始容量便是16咯。
这儿我乘0.5,其实也不过便是给它降了一次扩容的空间罢了,听懂掌声。
其实这儿我也考虑过运用两个HashSet去做一个滚筒的规划,就跟JVM内存中的from区to区一样。可是我仍是期望它的容量是可以降下来的,也算是主动调理吧,所以采用了这种计划。
-
注册Hook
最后我期望程序在正常退出的情况下,可以立马履行一次保存数据的使命,所以我在结构函数这儿增加一个ShutdownHook,让它去履行存入数据库的操作,尽量确保数据不丢掉。
public VisitRecordService() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
this.batchSave();
threadPool.shutdown();
}));
}
跋文
本文向咱们分享了一种运用 缓存 + 异步 来存储拜访日志的方式,其实不止是拜访日志,有其他相似场景的当地,也可以运用这种计划,我个人觉得是十分棒的。
假如有什么定见或许主张,欢迎在谈论区留言告诉我,究竟我也是菜鸡,咱们彼此学习彼此进步嘛。
假如你觉得我的思路还不错的话,麻烦在谈论去告诉我一下,让我也高兴高兴。