博主今天聊一聊物联网领域的热点探测,讲一讲架构和源码,依赖的中间件主要是京东武伟峰的HotKey(这里已经征得创作者同意)hotkey: 京东App后台中间件,毫秒级探测热点数据,毫秒级推送至服务器集群内存,大幅降低热key对数据层查询压力 核心功能:热数据探测并推送至集群各个服务器,适用于各种第三方存储的热点探测。
说起来有点显得官方,具体看一下这个中间件在物联网、资产领域可以使用到的场景,然后介绍一下它的实现原理,便于搭建和二次开发。
机器在一些特定的使用方式和组合情况下会导致跳变,概括的内容其实很多,主要是反复持续的发生一些动作,比如卡口一松一紧、行程开关反复打开等。
这些会导致预警、修理等业务场景的频繁无效处理,导致大量的物联网心跳被消费,浪费性能和资源。
这里其实就可以使用HotKey,比如设置规则两秒接收五次以上的跳变就标记为问题,停止无用的处理,并且识别出之后交给硬件进行识别检测。
有的同学可能会说,我自己在服务里面做不也行吗?
是的,但是这偏向于技术需求,导致的问题也不严重,花时间精力做一个有点定制化而不是通用的需求,这不太现实。但是使用框架之后,除了搭建和运维需要一些成本,其他处处通用,而且作为服务开发人员不关心这些运维的事情,用起来简单就可以了。
对于调用别人的服务还是自己的服务给别人调来说,性能都是很重要的事情,对于性能要求没那么高的系统例如门店列表、机器列表、相关组织区域等,秒级的查询肯定也是刚需,不然服务质量管理组就要来找你了,很大一部分查询无解是因为:
1、调用外部系统过多
2、调用外部系统或者提供外部查询的时候流量突然的高峰,然后不管是磁盘还是网络瞬间的io都飚上去了
这种情况也可以用hotkey,把热点数据临时缓存,直接从本地内存查询,然后sla就不会找麻烦了。
在开源社区有许多人在一开始不了解这个中间件的时候认为他的策略是基于拦截的,比如拦截了发送给redis的请求发送给服务端计算是否热key。
实际上并不是这样,而且他针对的是所有热key,也就是说他不区分第三方存储是什么,无论是es、db、redis等等第三方存储,他都可以计算热key。
那么他是怎么做的呢,实际上是基于业务思维去考虑的,他的创作者武伟峰也是业务开发,不是中间件团队。
那么业务思维是什么呢,如果这对一个key-value你需要对外暴露或者统计,那么一定会先被查一下是不是热key,是就从本地缓存拿数据,不是的话再从其他存储取,那么这个时候代表这个key被访问了一次,就可以开始计数了。
如果足够热点,isHotKey这个方法就会被访问很多次。
客户端主要是JdHotKeyStore这个类作为依赖包中的暴露点其中最主要的是isHotKey方法,先在本地计数存一下,不会立刻发送给服务端。
public static boolean isHotKey(String key) {
try {
if (!inRule(key)) {
return false;
}
boolean isHot = isHot(key);
if (!isHot) {
HotKeyPusher.push(key, null);
} else {
ValueModel valueModel = getValueSimple(key);
//判断是否过期时间小于1秒,小于1秒的话也发送
if (isNearExpire(valueModel)) {
HotKeyPusher.push(key, null);
}
}
//统计计数
KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, isHot));
return isHot;
} catch (Exception e) {
return false;
}
}
基本是攒0.5秒一起发送给服务端
public static void push(String key, KeyType keyType, int count, boolean remove) {
if (count <= 0) {
count = 1;
}
if (keyType == null) {
keyType = KeyType.REDIS_KEY;
}
if (key == null) {
return;
}
LongAdder adderCnt = new LongAdder();
adderCnt.add(count);
HotKeyModel hotKeyModel = new HotKeyModel();
hotKeyModel.setAppName(Context.APP_NAME);
hotKeyModel.setKeyType(keyType);
hotKeyModel.setCount(adderCnt);
hotKeyModel.setRemove(remove);
hotKeyModel.setKey(key);
if (remove) {
//如果是删除key,就直接发到etcd去,不用做聚合。但是有点问题现在,这个删除只能删手工添加的key,不能删worker探测出来的
//因为各个client都在监听手工添加的那个path,没监听自动探测的path。所以如果手工的那个path下,没有该key,那么是删除不了的。
//删不了,就达不到集群监听删除事件的效果,怎么办呢?可以通过新增的方式,新增一个热key,然后删除它
EtcdConfigFactory.configCenter().putAndGrant(HotKeyPathTool.keyPath(hotKeyModel), Constant.DEFAULT_DELETE_VALUE, 1);
EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyPath(hotKeyModel));
//也删worker探测的目录
EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyRecordPath(hotKeyModel));
} else {
//如果key是规则内的要被探测的key,就积累等待传送
if (KeyRuleHolder.isKeyInRule(key)) {
//积攒起来,等待每半秒发送一次
KeyHandlerFactory.getCollector().collect(hotKeyModel);
}
}
}
这里本地攒在ConcurrentHashMap里面,比较巧妙的思路是用了两个map防止在map取出数据的时候,还在往里面写数据,每次拿数据的时候就把原子类变一下
public void collect(HotKeyModel hotKeyModel) {
String key = hotKeyModel.getKey();
if (StrUtil.isEmpty(key)) {
return;
}
if (atomicLong.get() % 2 == 0) {
//不存在时返回null并将key-value放入,已有相同key时,返回该key对应的value,并且不覆盖
HotKeyModel model = map0.putIfAbsent(key, hotKeyModel);
if (model != null) {
model.add(hotKeyModel.getCount());
}
} else {
HotKeyModel model = map1.putIfAbsent(key, hotKeyModel);
if (model != null) {
model.add(hotKeyModel.getCount());
}
}
}
public List lockAndGetResult() {
//自增后,对应的map就会停止被写入,等待被读取
atomicLong.addAndGet(1);
List list;
if (atomicLong.get() % 2 == 0) {
list = get(map1);
map1.clear();
} else {
list = get(map0);
map0.clear();
}
return list;
}
在服务启动的时候他就开启了一个定时任务,从map取出数据之后就会通过netty发送出去
public static void startPusher(Long period) {
if (period == null || period <= 0) {
period = 500L;
}
@SuppressWarnings("PMD.ThreadPoolCreationRule")
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-pusher-service-executor", true));
scheduledExecutorService.scheduleAtFixedRate(() -> {
IKeyCollector collectHK = KeyHandlerFactory.getCollector();
List hotKeyModels = collectHK.lockAndGetResult();
if(CollectionUtil.isNotEmpty(hotKeyModels)){
KeyHandlerFactory.getPusher().send(Context.APP_NAME, hotKeyModels);
collectHK.finishOnce();
}
},0, period, TimeUnit.MILLISECONDS);
}
public void send(String appName, List list) {
//积攒了半秒的key集合,按照hash分发到不同的worker
long now = System.currentTimeMillis();
Map> map = new HashMap<>();
for(HotKeyModel model : list) {
model.setCreateTime(now);
Channel channel = WorkerInfoHolder.chooseChannel(model.getKey());
if (channel == null) {
continue;
}
List newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
newList.add(model);
}
for (Channel channel : map.keySet()) {
try {
List batch = map.get(channel);
HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.REQUEST_NEW_KEY, Context.APP_NAME);
hotKeyMsg.setHotKeyModels(batch);
channel.writeAndFlush(hotKeyMsg).sync();
} catch (Exception e) {
try {
InetSocketAddress insocket = (InetSocketAddress) channel.remoteAddress();
JdLogger.error(getClass(),"flush error " + insocket.getAddress().getHostAddress());
} catch (Exception ex) {
JdLogger.error(getClass(),"flush error");
}
}
}
}
服务端监听到netty事件,如果是新的key访问就进入KeyListener的newKey方法
public void newKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
//cache里的key
String key = buildKey(hotKeyModel);
//判断是不是刚热不久
Object o = hotCache.getIfPresent(key);
if (o != null) {
return;
}
SlidingWindow slidingWindow = checkWindow(hotKeyModel, key);
//看看hot没
boolean hot = slidingWindow.addCount(hotKeyModel.getCount());
if (!hot) {
//如果没hot,重新put,cache会自动刷新过期时间
CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).put(key, slidingWindow);
} else {
hotCache.put(key, 1);
//删掉该key
CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);
//开启推送
hotKeyModel.setCreateTime(SystemClock.now());
//当开关打开时,打印日志。大促时关闭日志,就不打印了
if (EtcdStarter.LOGGER_ON) {
logger.info(NEW_KEY_EVENT + hotKeyModel.getKey());
}
//分别推送到各client和etcd
for (IPusher pusher : iPushers) {
pusher.push(hotKeyModel);
}
}
}
主要是开启了一个滑动窗口SlidingWindow,关键是两个key前缀对应的设定规则,一个是间隔一个是数量,比如机器故障key,2s5个访问数量
然后SlidingWindow的addCount方法判断是否变成或者本来就是热key
public synchronized boolean addCount(long count) {
//当前自己所在的位置,是哪个小时间窗
int index = locationIndex();
// System.out.println("index:" + index);
//然后清空自己前面windowSize到2*windowSize之间的数据格的数据
//譬如1秒分4个窗口,那么数组共计8个窗口
//当前index为5时,就清空6、7、8、1。然后把2、3、4、5的加起来就是该窗口内的总和
clearFromIndex(index);
int sum = 0;
// 在当前时间片里继续+1
sum += timeSlices[index].addAndGet(count);
//加上前面几个时间片
for (int i = 1; i < windowSize; i++) {
sum += timeSlices[(index - i + timeSliceSize) % timeSliceSize].get();
}
lastAddTimestamp = SystemClock.now();
return sum >= threshold;
}
计算出热key之后也不是立刻发送客户端的,会先放到队列,然后每10ms推送一次,其实对于体量没那么大的公司,这个其实没必要
public void batchPushToClient() {
AsyncPool.asyncDo(() -> {
while (true) {
try {
List tempModels = new ArrayList<>();
//每10ms推送一次
Queues.drain(hotKeyStoreQueue, tempModels, 10, 10, TimeUnit.MILLISECONDS);
if (CollectionUtil.isEmpty(tempModels)) {
continue;
}
Map> allAppHotKeyModels = new HashMap<>();
//拆分出每个app的热key集合,按app分堆
for (HotKeyModel hotKeyModel : tempModels) {
List oneAppModels = allAppHotKeyModels.computeIfAbsent(hotKeyModel.getAppName(), (key) -> new ArrayList<>());
oneAppModels.add(hotKeyModel);
}
//遍历所有app,进行推送
for (AppInfo appInfo : ClientInfoHolder.apps) {
List list = allAppHotKeyModels.get(appInfo.getAppName());
if (CollectionUtil.isEmpty(list)) {
continue;
}
HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.RESPONSE_NEW_KEY);
hotKeyMsg.setHotKeyModels(list);
//整个app全部发送
appInfo.groupPush(hotKeyMsg);
}
allAppHotKeyModels = null;
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
然后就又回到了客户端,客户端收到热key事件写入本地缓存,这里使用的是缓存性能之王Caffeine,这里如果是固定场景可以改写下,在加入缓存的时候根据key把value查出来再设置,这样就不需要在客户端判断是热key并且无值的时候,把值set进去。
public void newKey(HotKeyModel hotKeyModel) {
long now = System.currentTimeMillis();
//如果key到达时已经过去1秒了,记录一下。手工删除key时,没有CreateTime
if (hotKeyModel.getCreateTime() != 0 && Math.abs(now - hotKeyModel.getCreateTime()) > 1000) {
JdLogger.warn(getClass(), "the key comes too late : " + hotKeyModel.getKey() + " now " +
+now + " keyCreateAt " + hotKeyModel.getCreateTime());
}
if (hotKeyModel.isRemove()) {
//如果是删除事件,就直接删除
deleteKey(hotKeyModel.getKey());
return;
}
//已经是热key了,又推过来同样的热key,做个日志记录,并刷新一下
if (JdHotKeyStore.isHot(hotKeyModel.getKey())) {
JdLogger.warn(getClass(), "receive repeat hot key :" + hotKeyModel.getKey() + " at " + now);
}
addKey(hotKeyModel.getKey());
}
private void addKey(String key) {
ValueModel valueModel = ValueModel.defaultValue(key);
if (valueModel == null) {
//不符合任何规则
deleteKey(key);
return;
}
//如果原来该key已经存在了,那么value就被重置,过期时间也会被重置。如果原来不存在,就新增的热key
JdHotKeyStore.setValueDirectly(key, valueModel);
}
Etcd作为一个持久化存储,主要是hotkey为了防止服务单点故障或者发布导致热key丢失,同时还需要对热key规则进行存储。服务端每个节点还会把自己的ip信息放到etcd,让客户端可以拿到服务端的信息进行netty推送
通过监听对应路径下etcd的数据变化,从而更新本地缓存
private void startWatchHotKey() {
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
JdLogger.info(getClass(), "--- begin watch hotKey change ----");
IConfigCenter configCenter = EtcdConfigFactory.configCenter();
try {
KvClient.WatchIterator watchIterator = configCenter.watchPrefix(ConfigConstant.hotKeyPath + Context.APP_NAME);
//如果有新事件,即新key产生或删除
while (watchIterator.hasNext()) {
WatchUpdate watchUpdate = watchIterator.next();
List eventList = watchUpdate.getEvents();
KeyValue keyValue = eventList.get(0).getKv();
Event.EventType eventType = eventList.get(0).getType();
try {
String key = keyValue.getKey().toStringUtf8().replace(ConfigConstant.hotKeyPath + Context.APP_NAME + "/", "");
//如果是删除key,就立刻删除
if (Event.EventType.DELETE == eventType) {
HotKeyModel model = new HotKeyModel();
model.setRemove(true);
model.setKey(key);
EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
} else {
HotKeyModel model = new HotKeyModel();
model.setRemove(false);
String value = keyValue.getValue().toStringUtf8();
//新增热key
JdLogger.info(getClass(), "etcd receive new key : " + key + " --value:" + value);
//如果这是一个删除指令,就什么也不干
if (Constant.DEFAULT_DELETE_VALUE.equals(value)) {
continue;
}
//手工创建的value是时间戳
model.setCreateTime(Long.valueOf(keyValue.getValue().toStringUtf8()));
model.setKey(key);
EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
}
} catch (Exception e) {
JdLogger.error(getClass(), "new key err :" + keyValue);
}
}
} catch (Exception e) {
JdLogger.error(getClass(), "watch err");
}
});
}
private void startWatchRule() {
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
JdLogger.info(getClass(), "--- begin watch rule change ----");
try {
IConfigCenter configCenter = EtcdConfigFactory.configCenter();
KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.rulePath + Context.APP_NAME);
//如果有新事件,即rule的变更,就重新拉取所有的信息
while (watchIterator.hasNext()) {
//这句必须写,next会让他卡住,除非真的有新rule变更
WatchUpdate watchUpdate = watchIterator.next();
List eventList = watchUpdate.getEvents();
JdLogger.info(getClass(), "rules info changed. begin to fetch new infos. rule change is " + eventList);
//全量拉取rule信息
fetchRuleFromEtcd();
}
} catch (Exception e) {
JdLogger.error(getClass(), "watch err");
}
});
}
public void makeSureSelfOn() {
//开启上传worker信息
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.scheduleAtFixedRate(() -> {
try {
if (canUpload) {
uploadSelfInfo();
}
} catch (Exception e) {
//do nothing
}
}, 0, 5, TimeUnit.SECONDS);
}
private void uploadSelfInfo() {
configCenter.putAndGrant(buildKey(), buildValue(), 8);
}
@Scheduled(fixedRate = 30000)
public void fetchDashboardIp() {
try {
//获取DashboardIp
List keyValues = configCenter.getPrefix(ConfigConstant.dashboardPath);
//是空,给个警告
if (CollectionUtil.isEmpty(keyValues)) {
logger.warn("very important warn !!! Dashboard ip is null!!!");
return;
}
String dashboardIp = keyValues.get(0).getValue().toStringUtf8();
NettyClient.getInstance().connect(dashboardIp);
} catch (Exception e) {
e.printStackTrace();
}
}
在热key推送的时候,除了推送给客户端,还会推送给dashboard
@PostConstruct
public void uploadToDashboard() {
AsyncPool.asyncDo(() -> {
while (true) {
try {
//要么key达到1千个,要么达到1秒,就汇总上报给etcd一次
List tempModels = new ArrayList<>();
Queues.drain(hotKeyStoreQueue, tempModels, 1000, 1, TimeUnit.SECONDS);
if (CollectionUtil.isEmpty(tempModels)) {
continue;
}
//将热key推到dashboard
DashboardHolder.flushToDashboard(FastJsonUtils.convertObjectToJSON(tempModels));
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
通过netty接收到读取事件之后,存储到队列中
protected void channelRead0(ChannelHandlerContext ctx, String message) {
if (StringUtils.isEmpty(message)) {
return;
}
try {
HotKeyMsg msg = FastJsonUtils.toBean(message, HotKeyMsg.class);
if (MessageType.PING == msg.getMessageType()) {
String hotMsg = FastJsonUtils.convertObjectToJSON(new HotKeyMsg(MessageType.PONG, PONG));
FlushUtil.flush(ctx, MsgBuilder.buildByteBuf(hotMsg));
} else if (MessageType.REQUEST_HOT_KEY == msg.getMessageType()) {
List list = FastJsonUtils.toList(msg.getBody(), HotKeyModel.class);
for (HotKeyModel hotKeyModel : list) {
HotKeyReceiver.push(hotKeyModel);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
然后会不断的消费队列中的数据,把数据分别在阻塞队列和本地缓存放一份
public void dealHotKey() {
while (true) {
try {
HotKeyModel model = HotKeyReceiver.take();
//将该key放入实时热key本地缓存中
if (model != null) {
//将key放到队列里,供入库时分批调用
putRecord(model.getAppName(), model.getKey(), model.getCreateTime());
//获取发来的这个热key,存入本地caffeine,设置过期时间
HotKeyReceiver.writeToLocalCaffeine(model);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
另外一个异步线程会不断把数据插到mysql
public void insertRecords() {
while (true) {
try {
List records = new ArrayList<>();
Queues.drain(queue, records, 1000, 1, TimeUnit.SECONDS);
if (CollectionUtil.isEmpty(records)) {
continue;
}
List keyRecordList = new ArrayList<>(records.size());
for (IRecord iRecord : records) {
KeyRecord keyRecord = handHotKey(iRecord);
if (keyRecord != null) {
keyRecordList.add(keyRecord);
}
}
if(CollectionUtil.isEmpty(keyRecordList)){
continue;
}
keyRecordMapper.batchInsert(keyRecordList);
} catch (Exception e) {
log.error("batch insert error:{}", e.getMessage(), e);
// e.printStackTrace();
}
}
}
入口在RuleController
@PostMapping("/save")
@ResponseBody
public Result save(Rules rules){
checkApp(rules.getApp());
checkRule(rules.getRules());
rules.setUpdateUser(userName());
int b = ruleService.save(rules);
return b == 0 ? Result.fail():Result.success();
}
保存规则的时候会先往etcd插一份,这样客户端和服务端都可以通过监听etcd获取到最新的规则
public int save(Rules rules) {
String app = rules.getApp();
KeyValue kv = configCenter.getKv(ConfigConstant.rulePath + app);
String from = null;
if (kv != null) {
from = kv.getValue().toStringUtf8();
}
String to = JSON.toJSONString(rules);
configCenter.put(ConfigConstant.rulePath + app, rules.getRules());
logMapper.insertSelective(new ChangeLog(app, 1, from, to, rules.getUpdateUser(), app, SystemClock.nowDate()));
return 1;
}
hotkey为热点探测做了许多细节开发,如果有业务场景需要改动可以做二次开发,博主可以帮忙讨论,这里再次感谢hotkey创作者京东武伟峰。