1.初识JD-hotkey
在互联网应用中时常会出现一些爆款,比如微博平台的热点词条
又或者是京东商城的爆款单品
- 从用户视角来看,这个爆款能吸引大部分用户的注意力,来点击这个按键或者词条
- 从开发者角度来看,爆款意味的庞大的流量,也意味着对我们的服务器集群有着莫大的考验
如果有一个组件能实时计算这个词条,并且推送到内存中,大大缓解存储层的并发压力就好了!
因为这个共同的愿景,JD-hotkey诞生了,它是京东APP后台热数据探测框架,历经多次高压压测和2020年京东618、双11大促考验,无论是架构设计还是可用性都经过了大规模流量验证,这样的框架非常值得我们讨论学习
JD-hotkey框架诞生的目的: 对任意突发性的无法预先感知的热点数据,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如恶意爬虫刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。然后对这些热数据、热用户等,推送到所有服务端JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由使用者决定如何分配、使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。这些热数据在整个服务端集群内保持一致性,并且业务隔离,worker端性能强悍。
2.JD-hotkey架构和原理
2.1 底层架构
先来看一下官方架构图
这里就需要认识这个框架中几个重要组件
- 客户端(Client) :图片中没有呈现,一般部署在我们业务应用中,负责采集业务应用的 key 访问信息,并将其发送到收集器。
- 收集器(Collector) :图片中也没有呈现,这里是客户端到work的中间层,接收客户端上报的 key 访问信息,对数据进行聚合和处理,然后将结果发送到服务端。
- 服务端(Server) :对应着上图的docker集群接收收集器发送的数据,进行热点计算和判断,最终将热点 key 推送给客户端。
- 存储组件(如 Redis) :用于存储热点 key 信息,服务端会将热点 key 存储到 Redis 中,客户端也可以从 Redis 中获取热点 key。
2.2 核心原理
- 客户端集成:在业务代码中引入JD-hotkey客户端初始化流程,并且在访问key的时候引入key收集流程
@PostConstruct
public void initHotkey() {
ClientStarter.Builder builder = new ClientStarter.Builder();
ClientStarter starter = builder.setAppName("appName").setEtcdServer("http://1.8.8.4:2379,http://1.1.4.4:2379,http://1.1.1.1:2379").build();
starter.startPipeline();
}
- 服务端计算热点:
- 服务端使用 Netty 等网络框架监听收集器发送的数据。在
jd - hotkey - server模块中,会有一个Server类来启动服务并处理网络连接。 - 在
HotKeyCalculator类中实现滑动窗口算法来计算热点 key。
- 热点推送与存储:
- 在
HotKeyManager类中,将热点 key 存储到 Redis 中。 - 服务端使用 Netty 等网络框架将热点 key 推送给客户端。在
HotKeyPusher类中实现推送逻辑。
- 客户端响应热点:在
HotKeyCallbackManager类中,调用注册的回调函数处理热点 key。
3.核心源码解析
3.1 客户端核心源码
3.1.1 ClientStarter 的startPipeline 方法
/**
* 启动监听etcd
*/
public void startPipeline() {
JdLogger.info(getClass(), "etcdServer:" + etcdServer);
//设置caffeine的最大容量
Context.CAFFEINE_SIZE = caffeineSize;
//设置etcd地址
EtcdConfigFactory.buildConfigCenter(etcdServer);
//开始定时推送
PushSchedulerStarter.startPusher(pushPeriod);
PushSchedulerStarter.startCountPusher(10);
//开启worker重连器
WorkerRetryConnector.retryConnectWorkers();
registEventBus();
EtcdStarter starter = new EtcdStarter();
//与etcd相关的监听都开启
starter.start();
}
- 设置了caffeineSize 的容量,默认是5w
- 配置etcd相关参数,这是核心的配置中心等会会详细讲解
- 开启定时推送数据,也就是计算的热key数据
- 维护与work的关系并且订阅相关事件
- 开启etcd集群服务
3.1.2 registEventBus 方法
private void registEventBus() {
//netty连接器会关注WorkerInfoChangeEvent事件
EventBusCenter.register(new WorkerChangeSubscriber());
//热key探测回调关注热key事件
EventBusCenter.register(new ReceiveNewKeySubscribe());
//Rule的变化的事件
EventBusCenter.register(new KeyRuleHolder());
}
这里主要先看开启监听的 WorkerChangeSubscriber 订阅事件,可以看到一共订阅两个事件,一个是worker信息变动,一个是断连
/**
* 监听worker信息变动
*/
@Subscribe
public void connectAll(WorkerInfoChangeEvent event) {
List<String> addresses = event.getAddresses();
if (addresses == null) {
addresses = new ArrayList<>();
}
WorkerInfoHolder.mergeAndConnectNew(addresses);
}
/**
* 当client与worker的连接断开后,删除
*/
@Subscribe
public void channelInactive(ChannelInactiveEvent inactiveEvent) {
//获取断线的channel
Channel channel = inactiveEvent.getChannel();
InetSocketAddress socketAddress = (InetSocketAddress) channel.remoteAddress();
String address = socketAddress.getHostName() + ":" + socketAddress.getPort();
JdLogger.warn(getClass(), "this channel is inactive : " + socketAddress + " trying to remove this connection");
WorkerInfoHolder.dealChannelInactive(address);
}
这里我们详细探讨一下channelInactive方法,我们先看一下官方注解内容
/**java
* 保存worker的ip地址和Channel的映射关系,这是有序的。每次client发送消息时,都会根据该map的size进行hash
* 如key-1就发送到workerHolder的第1个Channel去,key-2就发到第2个Channel去
*/
private static final List<Server> WORKER_HOLDER = new CopyOnWriteArrayList<>();
WorkerInfoHolder类中用CopyOnWriteArrayList存储映射关系,并且映射关系是一对多,WORKER_HOLDER 的大小进行哈希运算。具体来说,当客户端需要发送一个key对应的消息时,会计算该键的哈希值,然后将哈希值对 WORKER_HOLDER 的大小取模,得到一个索引值。这个索引值就对应着 WORKER_HOLDER 列表中的一个 Server 对象,进而可以获取到对应的 Channel,将消息发送到该 Channel 对应的 worker 节点。在代码中,chooseChannel 方法实现了这个逻辑:
public static Channel chooseChannel(String key) {
int size = WORKER_HOLDER.size();
if (StrUtil.isEmpty(key) || size == 0) {
return null;
}
int index = Math.abs(key.hashCode() % size);
return WORKER_HOLDER.get(index).channel;
}
3.1.3 PushSchedulerStarter 的startPusher方法
/**
* 每0.5秒推送一次待测key
*/
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<HotKeyModel, HotKeyModel> collectHK = KeyHandlerFactory.getCollector();
List<HotKeyModel> hotKeyModels = collectHK.lockAndGetResult();
if(CollectionUtil.isNotEmpty(hotKeyModels)){
KeyHandlerFactory.getPusher().send(Context.APP_NAME, hotKeyModels);
collectHK.finishOnce();
}
},0, period, TimeUnit.MILLISECONDS);
}
这是核心的推送方法
- 异步线程池执行推送方法
- 对待测试key进行聚合
- 选择适合的work进行key消息推送
我们核心来看看send方法
@Override
public void send(String appName, List<HotKeyModel> list) {
//积攒了半秒的key集合,按照hash分发到不同的worker
long now = System.currentTimeMillis();
Map<Channel, List<HotKeyModel>> map = new HashMap<>();
for(HotKeyModel model : list) {
model.setCreateTime(now);
Channel channel = WorkerInfoHolder.chooseChannel(model.getKey());
if (channel == null) {
continue;
}
List<HotKeyModel> newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
newList.add(model);
}
for (Channel channel : map.keySet()) {
try {
List<HotKeyModel> 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");
}
}
}
}
这里就用到了上述的chooseChannel来匹配对应的work推送key,底层利用的netty框架进行的网络通信,可以留意一下异常处理机制,利用的是JdLogger的日志处理框架,也是JD项目组开源的
3.1.4 EtcdStarter 的start方法
public void start() {
fetchWorkerInfo();
fetchRule();
// startWatchWorker();
startWatchRule();
//监听热key事件,只监听手工添加、删除的key
startWatchHotKey();
}
- 拉取work相关信息
- 拉取流量计算规则
- 异步监听rule规则变化
- 监听热key事件,只监听手工添加、删除的key
核心来看一下最后一个方法startWatchHotKey
/**
* 异步开始监听热key变化信息,该目录里只有手工添加的key信息
*/
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<Event> 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");
}
});
}
异步监听对应APP_NAME下的key变化信息,采用的异步+迭代器自旋,配合EventBus框架做新增热key的任务分发
EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
3.2 Work计算节点核心方法
3.2.1 NodesServerStarter 的 start方法
@PostConstruct
public void start() {
AsyncPool.asyncDo(() -> {
logger.info("netty server is starting");
NodesServer nodesServer = new NodesServer();
nodesServer.setClientChangeListener(iClientChangeListener);
nodesServer.setMessageFilters(messageFilters);
try {
nodesServer.startNettyServer(port);
} catch (Exception e) {
e.printStackTrace();
}
});
}
底层是netty监听的,我们直接来看netty启动时候的监听事件
public void startNettyServer(int port) throws Exception {
//boss单线程
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(CpuNum.workerCount());
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.option(ChannelOption.SO_BACKLOG, 1024)
//保持长连接
.childOption(ChannelOption.SO_KEEPALIVE, true)
//出来网络io事件,如记录日志、对消息编解码等
.childHandler(new ChildChannelHandler());
//绑定端口,同步等待成功
ChannelFuture future = bootstrap.bind(port).sync();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
bossGroup.shutdownGracefully (1000, 3000, TimeUnit.MILLISECONDS);
workerGroup.shutdownGracefully (1000, 3000, TimeUnit.MILLISECONDS);
}));
//等待服务器监听端口关闭
future.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
//do nothing
System.out.println("netty stop");
} finally {
//优雅退出,释放线程池资源
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
监听事件
@Override
protected void initChannel(Channel ch) {
NodesServerHandler serverHandler = new NodesServerHandler();
serverHandler.setClientEventListener(clientChangeListener);
serverHandler.addMessageFilters(messageFilters);
ByteBuf delimiter = Unpooled.copiedBuffer(Constant.DELIMITER.getBytes());
ch.pipeline()
.addLast(new DelimiterBasedFrameDecoder(Constant.MAX_LENGTH, delimiter))
.addLast(new MsgDecoder())
.addLast(new MsgEncoder())
.addLast(serverHandler);
}
}
流程可以索引到最后的过滤器,可以看到实现有多个,分别是appName相关,心跳包,热key,和 key数据统计的,这里我们只关注热key相关的HotKeyFilter
3.2.2 HotKeyFilter 源码解析
@Component
@Order(3)
public class HotKeyFilter implements INettyMsgFilter {
@Resource
private KeyProducer keyProducer;
public static AtomicLong totalReceiveKeyCount = new AtomicLong();
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public boolean chain(HotKeyMsg message, ChannelHandlerContext ctx) {
if (MessageType.REQUEST_NEW_KEY == message.getMessageType()) {
totalReceiveKeyCount.incrementAndGet();
publishMsg(message, ctx);
return false;
}
return true;
}
private void publishMsg(HotKeyMsg message, ChannelHandlerContext ctx) {
//老版的用的单个HotKeyModel,新版用的数组
List<HotKeyModel> models = message.getHotKeyModels();
long now = SystemClock.now();
if (CollectionUtil.isEmpty(models)) {
return;
}
for (HotKeyModel model : models) {
//白名单key不处理
if (WhiteListHolder.contains(model.getKey())) {
continue;
}
long timeOut = now - model.getCreateTime();
if (timeOut > 1000) {
if (EtcdStarter.LOGGER_ON) {
logger.info("key timeout " + timeOut + ", from ip : " + NettyIpUtil.clientIp(ctx));
}
}
keyProducer.push(model, now);
}
}
}
- 统计接受的数量并且发送key消息
public void push(HotKeyModel model, long now) {
if (model == null || model.getKey() == null) {
return;
}
//5秒前的过时消息就不处理了
if (now - model.getCreateTime() > InitConstant.timeOut) {
expireTotalCount.increment();
return;
}
try {
QUEUE.put(model);
totalOfferCount.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
- 把消息放置在阻塞队列中,我们看一下这个默认队列
/**
* 队列
*/
public static BlockingQueue<HotKeyModel> QUEUE = new LinkedBlockingQueue<>(2000000);
3.2.3 Consumer的beginConsume
public class KeyConsumer {
private IKeyListener iKeyListener;
public void setKeyListener(IKeyListener iKeyListener) {
this.iKeyListener = iKeyListener;
}
public void beginConsume() {
while (true) {
try {
HotKeyModel model = QUEUE.take();
if (model.isRemove()) {
iKeyListener.removeKey(model, KeyEventOriginal.CLIENT);
} else {
iKeyListener.newKey(model, KeyEventOriginal.CLIENT);
}
//处理完毕,将数量加1
totalDealCount.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
上文是将消息放置于阻塞队列中,这里就是对阻塞队列中的消息进行消费,这里详细看看处理流程
@Override
public void newKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
//cache里的key
String key = buildKey(hotKeyModel);
//判断是不是刚热不久
Object o = hotCache.getIfPresent(key);
if (o != null) {
return;
}
//********** watch here ************//
//该方法会被InitConstant.threadCount个线程同时调用,存在多线程问题
//下面的那句addCount是加了锁的,代表给Key累加数量时是原子性的,不会发生多加、少加的情况,到了设定的阈值一定会hot
//譬如阈值是2,如果多个线程累加,在没hot前,hot的状态肯定是对的,譬如thread1 加1,thread2加1,那么thread2会hot返回true,开启推送
//但是极端情况下,譬如阈值是10,当前是9,thread1走到这里时,加1,返回true,thread2也走到这里,加1,此时是11,返回true,问题来了
//该key会走下面的else两次,也就是2次推送。
//所以出现问题的原因是hotCache.getIfPresent(key)这一句在并发情况下,没return掉,放了两个key+1到addCount这一步时,会有问题
//测试代码在TestBlockQueue类,直接运行可以看到会同时hot
//那么该问题用解决吗,NO,不需要解决,1 首先要发生的条件极其苛刻,很难触发,以京东这样高的并发量,线上我也没见过触发连续2次推送同一个key的
//2 即便触发了,后果也是可以接受的,2次推送而已,毫无影响,客户端无感知。但是如果非要解决,就要对slidingWindow实例加锁了,必然有一些开销
//所以只要保证key数量不多计算就可以,少计算了没事。因为热key必然频率高,漏计几次没事。但非热key,多计算了,被干成了热key就不对了
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);
}
}
}
- 上面注释很清晰,我们来看一下
滑动窗口的算法
private SlidingWindow checkWindow(HotKeyModel hotKeyModel, String key) {
//取该key的滑窗
return (SlidingWindow) CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).get(key, (Function<String, SlidingWindow>) s -> {
//是个新key,获取它的规则
KeyRule keyRule = KeyRuleHolder.getRuleByAppAndKey(hotKeyModel);
return new SlidingWindow(keyRule.getInterval(), keyRule.getThreshold());
});
}
本质是从配置中心取出这个滑窗的配置然后在这里调用,滑动窗口有自己的main函数,实际是作者写好的非常优质的工具类,我这里贴出来学习一下
package com.jd.platform.hotkey.worker.tool;
import cn.hutool.core.date.SystemClock;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicLong;
/**
* 滑动窗口。该窗口同样的key都是单线程计算。
*
* @author wuweifeng wrote on 2019-12-04.
*/
public class SlidingWindow {
/**
* 循环队列,就是装多个窗口用,该数量是windowSize的2倍
*/
private AtomicLong[] timeSlices;
/**
* 队列的总长度
*/
private int timeSliceSize;
/**
* 每个时间片的时长,以毫秒为单位
*/
private int timeMillisPerSlice;
/**
* 共有多少个时间片(即窗口长度)
*/
private int windowSize;
/**
* 在一个完整窗口期内允许通过的最大阈值
*/
private int threshold;
/**
* 该滑窗的起始创建时间,也就是第一个数据
*/
private long beginTimestamp;
/**
* 最后一个数据的时间戳
*/
private long lastAddTimestamp;
public static void main(String[] args) {
//1秒一个时间片,窗口共5个
SlidingWindow window = new SlidingWindow(2, 20);
CyclicBarrier cyclicBarrier = new CyclicBarrier(10);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
boolean hot = window.addCount(2);
System.out.println(hot);
}
}).start();
}
// for (int i = 0; i < 100; i++) {
// System.out.println(window.addCount(2));
//
// window.print();
// System.out.println("--------------------------");
// try {
// Thread.sleep(102);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
// }
}
public SlidingWindow(int duration, int threshold) {
//超过10分钟的按10分钟
if (duration > 600) {
duration = 600;
}
//要求5秒内探测出来的,
if (duration <= 5) {
this.windowSize = 5;
this.timeMillisPerSlice = duration * 200;
} else {
this.windowSize = 10;
this.timeMillisPerSlice = duration * 100;
}
this.threshold = threshold;
// 保证存储在至少两个window
this.timeSliceSize = windowSize * 2;
reset();
}
public SlidingWindow(int timeMillisPerSlice, int windowSize, int threshold) {
this.timeMillisPerSlice = timeMillisPerSlice;
this.windowSize = windowSize;
this.threshold = threshold;
// 保证存储在至少两个window
this.timeSliceSize = windowSize * 2;
reset();
}
/**
* 初始化
*/
private void reset() {
beginTimestamp = SystemClock.now();
//窗口个数
AtomicLong[] localTimeSlices = new AtomicLong[timeSliceSize];
for (int i = 0; i < timeSliceSize; i++) {
localTimeSlices[i] = new AtomicLong(0);
}
timeSlices = localTimeSlices;
}
/**
* 计算当前所在的时间片的位置
*/
private int locationIndex() {
long now = SystemClock.now();
//如果当前的key已经超出一整个时间片了,那么就直接初始化就行了,不用去计算了
if (now - lastAddTimestamp > timeMillisPerSlice * windowSize) {
reset();
}
int index = (int) (((now - beginTimestamp) / timeMillisPerSlice) % timeSliceSize);
if (index < 0) {
return 0;
}
return index;
}
/**
* 增加count个数量
*/
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;
}
private void clearFromIndex(int index) {
for (int i = 1; i <= windowSize; i++) {
int j = index + i;
if (j >= windowSize * 2) {
j -= windowSize * 2;
}
timeSlices[j].set(0);
}
}
}
3.2.4 AppServerPusher 的batchPushToClient方法
/**
* 和dashboard那边的推送主要区别在于,给app推送每10ms一次,dashboard那边1s一次
*/
@PostConstruct
public void batchPushToClient() {
AsyncPool.asyncDo(() -> {
while (true) {
try {
List<HotKeyModel> tempModels = new ArrayList<>();
//每10ms推送一次
Queues.drain(hotKeyStoreQueue, tempModels, 10, 10, TimeUnit.MILLISECONDS);
if (CollectionUtil.isEmpty(tempModels)) {
continue;
}
Map<String, List<HotKeyModel>> allAppHotKeyModels = new HashMap<>();
//拆分出每个app的热key集合,按app分堆
for (HotKeyModel hotKeyModel : tempModels) {
List<HotKeyModel> oneAppModels = allAppHotKeyModels.computeIfAbsent(hotKeyModel.getAppName(), (key) -> new ArrayList<>());
oneAppModels.add(hotKeyModel);
}
//遍历所有app,进行推送
for (AppInfo appInfo : ClientInfoHolder.apps) {
List<HotKeyModel> 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();
}
}
});
}
此方法是被@PostConstruct注释修饰过的说明在实例化后会自动调用这个方法,该方法的主要功能是每 10 毫秒从 hotKeyStoreQueue 中取出热键模型,按应用名称分组,然后将分组后的热键信息推送给对应的客户端应用。
3.3.5 线程数量(非常值得学习)
@Bean
public Consumer consumer() {
int nowCount = CpuNum.workerCount();
//将实际值赋给static变量
if (threadCount != 0) {
nowCount = threadCount;
} else {
if (nowCount >= 8) {
nowCount = nowCount / 2;
}
}
List<KeyConsumer> consumerList = new ArrayList<>();
for (int i = 0; i < nowCount; i++) {
KeyConsumer keyConsumer = new KeyConsumer();
keyConsumer.setKeyListener(iKeyListener);
consumerList.add(keyConsumer);
threadPoolExecutor.submit(keyConsumer::beginConsume);
}
return new Consumer(consumerList);
}
- 这里是消费热点消息的方法,在选取任务提交次数的时候作者根据CPU核心数量进行了边界优化
/**
* netty worker线程数量. cpu密集型
*/
public static int workerCount() {
//取cpu核数,新版jdk在docker里取的就是真实分配的,老版jdk取的是宿主机的,可能特别大,如32核
int count = Runtime.getRuntime().availableProcessors();
if (isNewerVersion()) {
return count;
} else {
count = count / 2;
if (count == 0) {
count = 1;
}
}
return count;
甚至详细到对版本号进行了拼接,从注解中不难发现这是作者在线上故障血淋淋的教训,Docker内或缺的CPU数量会根据版本号变化而变化,非常值得学习和探究!!!
private static boolean isNewerVersion() {
try {
//如1.8.0_20, 1.8.0_181,1.8.0_191-b12
String javaVersion = System.getProperty("java.version");
//1.8.0_191之前的java版本,在docker内获取availableProcessors的数量都不对,会取到宿主机的cpu数量,譬如宿主机32核,
//该docker只分配了4核,那么老版会取到32,新版会取到4。
//线上事故警告!!!!!!老版jdk取到数值过大,线程数太多,导致cpu瞬间100%,大量的线程切换等待
//先取前三位进行比较
String topThree = javaVersion.substring(0, 5);
if (topThree.compareTo("1.8.0") > 0) {
return true;
} else if (topThree.compareTo("1.8.0") < 0) {
return false;
} else {
//前三位相等,比小版本. 下面代码可能得到20,131,181,191-b12这种
String smallVersion = javaVersion.replace("1.8.0_", "");
//继续截取,找"-"这个字符串,把后面的全截掉
if (smallVersion.contains("-")) {
smallVersion = smallVersion.substring(0, smallVersion.indexOf("-"));
}
return Integer.valueOf(smallVersion) >= 191;
}
} catch (Exception e) {
return false;
}
}