一、任务增强
DynamicTP中,给提交的任务,Runnable,也做了一层封装(增强)。
其中有四种增强方式:MdcTaskWrapper、TtlTaskWrapper、SwTraceTaskWrapper、OpenTelemetryWrapper
这四种基本都是用于线程的参数传递的。
这里咱们主要聊一下MdcTaskWrapper的套路,以及他的实现原理。
1.1 MDC介绍
首先,咱们要清楚什么是MDC。本质MDC(Mapped Diagnostic Context)是日志框架提供的功能,作用其实就是在线程上下文进行数据的传递,本质就是ThreadLocal,是基于ThreadLocal实现的。
SpringBoot本质也引入了Slf4j以及LogBack的依赖,可以直接在项目的任何位置,直接基于MDC去操作
MDC.put(key,value);
// 其他位置
String value = MDC.get(key);
简单的看一下MDC是如何基于ThreadLocal去实现的。
1、首先查看MDC类,内部提供了一个 MDCAdapter 的属性,并且在静态代码块中,给这个属性赋值,并且构建的是 MDCAdapter 接口下的Logback的实现
2、查看 LogbackMDCAdapter 类,发现内部提供了一个
inal ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal<Map<String, String>>();
在基于MDC去做存取操作时,本质上其实就是对 这个 ThreadLocal 里的Map集合做读写操作。
2.2 MdcTaskWrapper应用
前面说完MDC之后,了解了,他是基于ThreadLocal在一个线程中传递数据。
但是现在是线程池的功能,需要当前线程的MDC存储的数据,可以在提交任务后,由执行的子线程也可以获取到提交任务线程的MDC数据。
先应用看效果:
java
/**
* 测试MDC任务增强
* @throws IOException
*/
@Test
public void taskWrapperTest() throws IOException {
// 这个是当前测试线程存储的数据
MDC.put("traceId","sakjdfhasjkfhsdjkfhsdf");
MDC.put("xxx","yyy");
// 提交任务到线程池
dtpExecutor.execute(new MdcTaskWrapper().wrap(() -> {
System.out.println("子线程执行任务");
System.out.println(Thread.currentThread().getName() + ":" + MDC.get("traceId"));
System.out.println(Thread.currentThread().getName() + ":" + MDC.get("xxx"));
}));
System.in.read();
}
发现可以在子线程中获取到父线程里提供的MDC中的数据。
上图中的写法有点麻烦,其实DynamicTP可以在配置指定好任务增强的方式后,提交普通任务即可,在任务基于execute方法投递到线程池时,他会自动将任务做增强
dynamictp:
enabled: true # 是否启用 dynamictp,默认true
线程池配置
executors: # 动态线程池配置,
- threadPoolName: dtpExecutor # 线程池名称,必填
taskWrapperNames: ["mdc"] # 任务包装器名称,继承TaskWrapper接口,可以追加ttl等
任务投递的方式,直接原生方式即可
java
/**
* 测试MDC任务增强
* @throws IOException
*/
@Test
public void taskWrapperTest2() throws IOException {
// 这个是当前测试线程存储的数据
MDC.put("traceId","sakjdfhasjkfhsdjkfhsdf");
MDC.put("xxx","yyy");
// 提交任务到线程池
dtpExecutor.execute(() -> {
System.out.println("子线程执行任务");
System.out.println(Thread.currentThread().getName() + ":" + MDC.get("traceId"));
System.out.println(Thread.currentThread().getName() + ":" + MDC.get("xxx"));
});
System.in.read();
}
1.3 分析MdcWapper的增强以及实现原理
1、查看任务增强过程
在dtpExecutor提交任务时,第一步就在执行
command = getEnhancedTask(command);
获取增强后的任务。
在内部会根据咱们的配置方式,获取到对应的TaskWrapper,比如咱们现在指定了mdc,就可以获取到MdcTaskWrapper对象。
最后直接基于 MdcTaskWrapper对象.wrap(任务) 做增强返回。
2、查看任务内部增强后的实现
本质其实就是将普通的Runnable,封装成了一个MdcRunnable的类型。
第一步就是执行了他提供的 有参构造 ,将Runnable进行封装。
将任务进行保存。
将父线程中的MDC里的ThreadLocal中的Map集合进行保存。(就是父线程的MDC数据)
将父线程进行保存。
第二步就是执行MdcRunnable中的run方法
做了一个健壮性校验,如果不是基于线程池的子线程执行任务,不走MDC传递。
将之前保存的父线程的MDC数据,写入到了子线程的MDC中。在后面执行任务后,就可以基于子线程的MDC拿到对应的父线程中的数据了。
在任务执行完毕之后,会将子线程MDC中的数据清除掉,除了traceId。
3、traceId为何保留以及何时清除
DynamicTP中提供了任务超时的一些报警机制,同时还要记录这种错误信息。
在记录信息或者报警的时候,可能需要用到traceId。
如果在任务执行完毕后,直接将traceId清楚,后续的日志记录无法找到traceId。
DynamicTP重写了线程池的 afterExecute 方法,在这个方法里需要记录一些日志,采集一些数据,这些过程涉及到了traceId,同时在 afterExecute 方法的最后,也会清除掉MDC里的traceId
Ps:ThreadPoolExecutor 线程池在执行任务时,提供了任务前后的扩展方法
Ps:类似TTL等增强,先不聊。比如提供的TtlTaskWrapper,用的阿里提供的TransmittableThreadLocal,后面单独的分析一下TransmittableThreadLocal也就知道怎么回事了。

二、报警通知
功能的目的就是在线程池出现了
参数调整
活跃线程数阈值
队列任务阈值
任务超时
排队超时
拒绝策略
上述6种情况时,都可以给咱们做一个通知。这里咱们直接基于DingDing的方式实现一下通知效果。
很多配置即便没写,他也有默认值。
2.1 设置平台报警
平台报警的配置模板,官方有提供,直接照葫芦画瓢~~~
只需要额外的在DingDing或者其他的通知平台上获取到对应的 access_token 即可。
dynamictp:
告警渠道
platforms: # 通知报警平台配置
https://oapi.dingtalk.com/robot/send?access_token=6d2a71da0266a8e4aac6e26c87fbec4604a49f2f768455c19e18afa2061315a9
- platform: ding
platformId: 1 # 平台id,自定义,随便写数值
urlKey: 6d2a71da0266a8e4aac6e26c87fbec4604a49f2f768455c19e18afa2061315a9 # webhook 中的 access_token
secret: # 安全设置在验签模式下才的秘钥,非验签模式没有此值
receivers: 18888888888 # 钉钉账号手机号,这里给通知后,会@手机号的用户,主负责人。
配置完毕后,算是有DingDing报警机器人了。
2.2 指定报警触发方式
基于官方提供的配置方式,保留两种报警触发的方式
线程池参数改变
线程池队列任务达到阈值
dynamictp:
全局配置
globalExecutorProps:
notifyItems:
- type: change # 线程池核心参数变更通知
silencePeriod: 1 # 通知静默时间(单位:s),默认值1,0表示不静默
- type: capacity # 队列容量使用率,报警项类型,查看源码 NotifyTypeEnum枚举类
threshold: 80 # 报警阈值,意思是队列使用率达到70%告警;默认值=70
count: 2 # 在一个统计周期内,如果触发阈值的数量达到 count,则触发报警;默认值=1
period: 30 # 报警统计周期(单位:s),默认值=120
silencePeriod: 0 # 报警静默时间(单位:s),0表示不静默,默认值=120
上面是全局的配置,如果需要单独的针对某一个线程池指定也是可以的。
dynamictp:
线程池配置
executors: # 动态线程池配置,都有默认值,采用默认值的可以不配置该项,减少配置量
- threadPoolName: dtpExecutor # 线程池名称,必填
单独针对当前线程池指定报警触发方式
notifyItems:
- type: change # 线程池核心参数变更通知
silencePeriod: 1 # 通知静默时间(单位:s),默认值1,0表示不静默
- type: capacity # 队列容量使用率,报警项类型,查看源码 NotifyTypeEnum枚举类
threshold: 80 # 报警阈值,意思是队列使用率达到70%告警;默认值=70
count: 2 # 在一个统计周期内,如果触发阈值的数量达到 count,则触发报警;默认值=1
period: 30 # 报警统计周期(单位:s),默认值=120
silencePeriod: 0 # 报警静默时间(单位:s),0表示不静默,默认值=120
2.3 将报警方式设置到具体的线程池
其实就是将配置好的DingDing里指定的标识 ,1 ,设置到具体的线程池,或者指定到全局配置即可
dynamictp:
线程池配置
executors: # 动态线程池配置,都有默认值,采用默认值的可以不配置该项,减少配置量
- threadPoolName: dtpExecutor # 线程池名称,必填
platformIds: [1] # 这个 1 ,就是前面配置的DingDing
上述是指针某个线程池的配置,也可以设置全局配置。
问题:单个服务的通知次数,咱们可以通过配置文件中提供的静默时间去限制,但是如果后期服务是集群的方式部署,并且节点还不少,通知次数就无法通过单个服务的配置项来解决频繁通知的问题了。
2.4 频繁报警通知问题
前面说道了,单个服务基于静默时间配置即可,但是集群,无法处理。
So,在1.0.8 版本开始支持集群限流,基于 Redis 实现的滑动窗口限流,会限制实际进行推送的节点个数,使用引入以下依赖。
<dependency>
<groupId>org.dromara.dynamictp</groupId>
<artifactId>dynamic-tp-spring-boot-starter-extension-limiter-redis</artifactId>
<version>1.2.2</version>
</dependency>
只需要再配置文件中追加cluster-limit配置即可。(局部也可以设置)
dynamictp:
全局配置
globalExecutorProps:
notifyItems:
- type: change # 线程池核心参数变更通知
silencePeriod: 120 # 通知静默时间(单位:s),默认值1,0表示不静默
clusterLimit: 1 # 集群限流
- type: capacity # 队列容量使用率,报警项类型,查看源码 NotifyTypeEnum枚举类
threshold: 80 # 报警阈值,意思是队列使用率达到80%告警;默认值=80
count: 2 # 在一个统计周期内,如果触发阈值的数量达到 count,则触发报警;默认值=1
period: 30 # 报警统计周期(单位:s),默认值=120
silencePeriod: 120 # 报警静默时间(单位:s),0表示不静默,默认值=120
clusterLimit: 1 # 集群限流
记得启动Redis服务,查看Redis的数据的变化。

2.5 滑动时间创建时间原理
利用是的ZSet的结构,ZSet可以让score存储时间戳,并且ZSet还提供了zrangebyscore的查询方式。利用ZSet实现滑动时间创建的方式特别多。
直接通过源码的方式,查看到一个Lua脚本一步一步分析。
1、查看他执行脚本前需要赋值的5个属性

2、判断key是否存在,获取key对应的元素个数
key的个数,声明为0
local accepted = 0
查看key是否存在
local exists_key = redis.call('exists', key)
如果key存在
if (exists_key == 1) then
或者这个zset结构中member的个数
accepted = redis.call('zcard', key)
end
3、判断窗口内元素个数是否超过阈值
利用前面获取到的元素个数,比较limit限制的元素个数
if (accepted < limit) then
没有达到窗口的阈值,直接zadd,写入当前元素
redis.call('zadd', key, timestamp, member)
end
最后查看一下执行Lua脚本的代码
java
public List<Object> isAllowed(String key, long windowSize, int limit) {
// 获取Lua脚本文件
RedisScript<?> script = this.getScript();
// 生成ZSet的key,服务名:前缀:key key=具体线程池别名 + 报警触发方式
List<String> keys = this.getKeys(key);
// 获取另外四个参数。 时间窗口长度、限制个数、当前系统时间、写入的member
String[] values = this.getArgs(key, windowSize, limit);
// 执行Lua脚本
return Collections.unmodifiableList((List) Objects.requireNonNull(stringRedisTemplate.execute(script, keys,
values)));
}
执行之后,利用返回结果判断,是否能够通知
@Override
public boolean tryPass(String name, long interval, int limit) {
try {
// 调用前面的执行脚本的操作
val res = isAllowed(name, interval, limit);
// 判断结果是否为空,为空,那就发。
if (CollectionUtils.isEmpty(res)) {
return true;
}
// 基于返回结果的remain,如过remain为null或者小于等于0,不能发送通知
if (Objects.isNull(res.get(LUA_RES_REMAIN_INDEX)) || (long) res.get(LUA_RES_REMAIN_INDEX) <= 0) {
if (log.isDebugEnabled()) {
log.debug("DynamicTp notify, trigger redis rate limit, limitKey:{}, res:{}", name, res);
}
return false;
}
// remain有剩余发送次数,返回true
return true;
} catch (Exception e) {
log.error("DynamicTp notify, redis rate limit check failed, limitKey:{}", name, e);
return true;
}
}
这个实现的方式,可以套用到短信平台中,但是当前Lua脚本的实现,有一些缺点,时间窗口不严谨,存在明明可以发送,但是因为他的zremrangbyscore的太晚,导致无法发送,So,脚本可以优化。
But,当前只是通知报警,所以不严谨,对于当前的DynamicTP无伤大雅。。。。。。
套到短信平台的话,一定要优化一下,比如zremrangbyscore提前,获取元素个数滞后。