万字解读JD-hotkey源码

383 阅读15分钟

1.初识JD-hotkey

在互联网应用中时常会出现一些爆款,比如微博平台的热点词条

image.png

又或者是京东商城的爆款单品

image.png

  • 从用户视角来看,这个爆款能吸引大部分用户的注意力,来点击这个按键或者词条
  • 从开发者角度来看,爆款意味的庞大的流量,也意味着对我们的服务器集群有着莫大的考验

如果有一个组件能实时计算这个词条,并且推送到内存中,大大缓解存储层的并发压力就好了!

因为这个共同的愿景,JD-hotkey诞生了,它是京东APP后台热数据探测框架,历经多次高压压测和2020年京东618、双11大促考验,无论是架构设计还是可用性都经过了大规模流量验证,这样的框架非常值得我们讨论学习

JD-hotkey框架诞生的目的: 对任意突发性的无法预先感知的热点数据,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如恶意爬虫刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。然后对这些热数据、热用户等,推送到所有服务端JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由使用者决定如何分配、使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。这些热数据在整个服务端集群内保持一致性,并且业务隔离,worker端性能强悍。

JD-hotkey官方源码

2.JD-hotkey架构和原理

2.1 底层架构

先来看一下官方架构图

image.png

这里就需要认识这个框架中几个重要组件

  • 客户端(Client) :图片中没有呈现,一般部署在我们业务应用中,负责采集业务应用的 key 访问信息,并将其发送到收集器。
  • 收集器(Collector) :图片中也没有呈现,这里是客户端到work的中间层,接收客户端上报的 key 访问信息,对数据进行聚合和处理,然后将结果发送到服务端。
  • 服务端(Server) :对应着上图的docker集群接收收集器发送的数据,进行热点计算和判断,最终将热点 key 推送给客户端。
  • 存储组件(如 Redis) :用于存储热点 key 信息,服务端会将热点 key 存储到 Redis 中,客户端也可以从 Redis 中获取热点 key。

2.2 核心原理

  1. 客户端集成:在业务代码中引入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();
}
  1. 服务端计算热点
  • 服务端使用 Netty 等网络框架监听收集器发送的数据。在 jd - hotkey - server 模块中,会有一个 Server 类来启动服务并处理网络连接。
  • 在 HotKeyCalculator 类中实现滑动窗口算法来计算热点 key。
  1. 热点推送与存储
  • 在 HotKeyManager 类中,将热点 key 存储到 Redis 中。
  • 服务端使用 Netty 等网络框架将热点 key 推送给客户端。在 HotKeyPusher 类中实现推送逻辑。
  1. 客户端响应热点:在 HotKeyCallbackManager 类中,调用注册的回调函数处理热点 key。

3.核心源码解析

3.1 客户端核心源码

3.1.1 ClientStarterstartPipeline 方法

/**
 * 启动监听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 PushSchedulerStarterstartPusher方法

/**
 * 每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 EtcdStarterstart方法

    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 NodesServerStarterstart方法


@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

image.png

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 ConsumerbeginConsume

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 AppServerPusherbatchPushToClient方法

/**
 * 和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;
    }


}