一、引言
作者最近做的一个功能是需要监控一个翻译转换,根据国家和语言进行分组,然后定时把监控情况放到ck里面。为什么是分组和定时监控呢?因为调用比较高的系统的qps在单机一万多,70台机器,可怕的高频调用注定他不能实时分析。
二、方案设计
1、需求
先明确一下功能点,翻译监控很多读者可能不太理解,大家可以类比redis缓存监控,只不过由于高频不能做实时监控,要根据国家、语言把redis缓存进行分组监控,方便监控一段时间内的国家语言维度对应key的缓存访问频率。
2、分析
首先要增加切点,因为这种监控肯定要方便发布、无需更新pom,所以要在字节码增强里面做
要实现这样的监控,首先要有一个存储,把国家+语言+key作为唯一键,存储他的频率,这种肯定是需要一个map了。
还需要定时将map存储的数据发送到ck,那就需要开启一个可配置频率的定时任务。定时任务的开启也是一个问题,因为在javaagent里面使用不了Spring的定时包,原因之前的文章说过。所以必须使用jdk原生的工具,jdk倒是使用延时队列和lock阻塞实现了一个ScheduledThreadPoolExecutor。
使用ScheduledThreadPoolExecutor要注意它的无界队列,但是他没有提供更改队列数量的方法,那我们有两个方案:一个是使用信号量进行阻塞,防止大量任务进入队列;另外一种是封闭这个线程池,只在开启一次,队列不进行二次任务进入
这里就带来了并发问题,map在被定时任务读取发送到ck的时候,还有高频的写入操作,加锁就太影响性能了。那么有什么巧妙的设计可以规避吗?可以设置两个map,一个用来读一个用来写,在定时任务读取的时候设置标志位,切换map的使用。
三、代码
分析完了还是要在代码中实践,会发现更多问题
1、map存储
使用了一个AtomicBoolean标识目前使用的map
public class MonitorMapHandleUtils {
private static final ConcurrentHashMap<String, Integer> firstMap = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Integer> secondMap = new ConcurrentHashMap<>();
private static AtomicBoolean useFirst = new AtomicBoolean(true);
private static final String SEMICOLON = ";";
/**
* country+ key + locale维度分组
*
* @param key
*/
public static void pushMap(Object key, Object locale, String country) {
String key = country + SEMICOLON + key + SEMICOLON + locale;
if (useFirst.get()) {
firstMap.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
return;
}
secondMap.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
}
public static void runSendMap() {
if (useFirst.get()) {
sendMap(firstMap);
return;
}
sendMap(secondMap);
}
/**
* 控制分钟级别,不会有useFirst连续设置的风险
*
* @param map
*/
protected static void sendMap(ConcurrentHashMap<String, Integer> map) {
useFirst.set(!useFirst.get());
map.forEach((k, v) -> MonitorMapHandleUtils.sendCk(k, v));
map.clear();
}
private static void sendCk(String key, Integer count) {
}
}
2、定时发送
这里其实是不断的在取延时多久执行,配置中心都是推送客户端缓存的,所以这里的检查不会有多少性能损耗
使用start开启定时任务,可以思考下为什么要这样做。比如有的同事说为什么不在static静态块里面处理?
首先即使在静态块处理,这个类也必须是主动使用的,否则不会被按需加载,类的主动使用包括以下几种情况:
创建类的实例。
访问类的静态方法。
访问类的静态字段,除了声明为final的字段,它们是编译时常量。
使用java.lang.reflect包的方法对类进行反射调用。
初始化一个类的子类(首先会初始化父类)。
加载的时候必须保证所需的包已经加载好了,这里有什么操作?配置中心的首次检查,更新延时时间,所以我们必须让这个定时类的方法被调用,而且是在配置中心包加载好之后才能调用。
这样的话就要把MonitorMapHandleUtils使用的变量或者方法放在MonitorDynamicScheduledTask,然后调用,这样才能保证配置中心一定是已经加载好的,这取决于调用的时机,只有被调用到的时候这些类才会按需加载,这样代码结构看起来就会比较诡异,互相依赖
定时类只放开了一个方法,并且调用一次之后就不再允许处理,避免多次调用,所以就不会有无界队列过多任务的风险
public class MonitorDynamicScheduledTask {
private static final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);
/**
* 初始延迟时间,单位为分钟
*/
private static volatile int currentDelay = 240;
private static AtomicBoolean startFlag = new AtomicBoolean(false);
/**
* 开启仅执行一次,并发执行多次也没关系,瞬时不影响
*/
public static void start() {
if (startFlag.get()) {
return;
}
MonitorDynamicScheduledTask.checkDelay();
scheduleTask(currentDelay);
startFlag.set(true);
}
/**
* 进入队列,不会有递归调用的方法栈问题
*
* @param delayInHours
*/
private static void scheduleTask(int delayInHours) {
scheduler.schedule(() -> {
if (Config.monitorRun()) {
MonitorMapHandleUtils.runSendMap();
MonitorDynamicScheduledTask.checkDelay();
// 重新调度下一次执行
MonitorDynamicScheduledTask.scheduleTask(currentDelay);
}
}, delayInHours, TimeUnit.MINUTES);
}
private static void checkDelay() {
// 检查配置中心是否有更新
int newDelay = Config.monitorInterval();
if (newDelay != currentDelay && newDelay > 0) {
currentDelay = newDelay;
}
}
}
四、总结
翻译监控不难,难的是在agent无侵入的情况下去监控,要考虑性能损耗、类加载时机、包加载时机,即使是简单的并发也要考虑不能用锁造成性能损耗