京东热点检测 HotKey 学习笔记

代码指路:hotkey: 京东App后台中间件
项目官网介绍:
对任意突发性的无法预先感知的热点数据,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如恶意爬虫刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。然后对这些热数据、热用户等,推送到所有服务端JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由使用者决定如何分配、使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。这些热数据在整个服务端集群内保持一致性,并且业务隔离,worker端性能强悍。
京东APP后台热数据探测框架,历经多次高压压测和2020年京东618、双11大促考验。在上线运行的这段时间内,每天探测的key数量数十亿计,精准捕获了大量爬虫、刷子用户,另准确探测大量热门商品并毫秒级推送到各个服务端内存,大幅降低了热数据对数据层的查询压力,提升了应用性能。在大促期间,hotkey的worker集群秒级吞吐量达到1500万级别,由hotkey探测出的热key进而产生的本地缓存占应用总访问量的50%以上,使得大部分请求进行的是本地查询,减轻了redis层一半以上负担。

一、总体设计

丑陋的代码千奇百怪,优秀的设计趋于统一,HotKey 遵循的架构设计同样如此,我们可以简单将 HotKey 整体架构分为三个模块:

  1. ETCD 集群,负责整体的节点存储与信息协调
  2. Client 实例群,由 MAVEN JAR 包的形式导入项目中,调用其核心方法实现业务侵入性较小的热点检测
  3. Worker 集群,单独部署的服务集群,负责接受到 client 实例提交的数据进行热点检测并推送

从学习的角度来看,我们首先关注的应当是 Client 以及 Worker 的实现思想以及具体的代码逻辑,接下来我们就从源码的角度开始深入学习 HotKey,下面简单贴一下项目的整体代码结构:

接下来我将使用三个章节分别详细的描述一下 common、client 以及 worker 模块。

二、Common

废话不多说,先把这部分的代码结构贴出来

1. Coder

coder 部分其实也就是定义了 Netty 通信消息的序列化方法,也就是利用 ProtoBuf 进行消息的序列化并且在消息尾部添加 Netty 分隔符 DELIMITER = “ ( ∗ ∗ ) (* *) ()

public class MsgEncoder extends MessageToByteEncoder {

    @Override
    public void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) {
        if (in instanceof HotKeyMsg) {
            byte[] bytes = ProtostuffUtils.serialize(in);
            byte[] delimiter = Constant.DELIMITER.getBytes();

            byte[] total = new byte[bytes.length + delimiter.length];
            System.arraycopy(bytes, 0, total, 0, bytes.length);
            System.arraycopy(delimiter, 0, total, bytes.length, delimiter.length);

            out.writeBytes(total);
        }
    }
}

2. Configcenter

(1)ConfigConstant

负责保存一些常量,下面贴几个比较典型的常量

String appsPath = "/jd/apps/"; // 所有的app名字,存这里
String workersPath = "/jd/workers/"; // 所有的workers,存这里
String rulePath = "/jd/rules/"; // 所有的客户端规则(譬如哪个app的哪些前缀的才参与计算)
String hotKeyPath = "/jd/hotkeys/"; // 每个app的热key放这里。格式如:jd/hotkeys/app1/userA
String keyHitCountPath = "/jd/keyHitCount/"; // 存放客户端hotKey访问次数和总访问次数的path

(2)JdEtcdClient

ETCD 客户端,此处我们的大量操作主要集中在存储在 ETCD 中的 KV 对的操作上,因此,我们目前仅需要关注 kvClient 中的相关方法即可,下面贴出基本的 get 和 put 方法

@Override
public String get(String key) {
    // 此处均为 ETCD 提供的方法,我们仅需要简单了解即可,如需使用直接复制粘贴即可
    RangeResponse rangeResponse = kvClient.get(ByteString.copyFromUtf8(key)).sync();
    List<KeyValue> keyValues = rangeResponse.getKvsList();
    if (CollectionUtil.isEmpty(keyValues)) {
        return null;
    }
    return keyValues.get(0).getValue().toStringUtf8();
}

@Override
public void put(String key, String value, long leaseId) {
    kvClient.put(ByteString.copyFromUtf8(key), ByteString.copyFromUtf8(value), leaseId).sync();
}

此处插个眼,后续我们可能会用到 leaseClient 做一些过期处理,我们后续对这部分内容进行补充优化

3. Convert

此处只是为了解决 LongAddr 的序列化的相关问题,非核心,可以跳过学习

4. Model

(1)tyepenum

在此处枚举类中主要是定义了热点类型,主要包括 RedisKey、RequestPath、BlackList 以及其他,同时规定了消息类型,核心包括 RequestNewKey(上传 Key)、ResponseNewKey(接收热 Key)等

(2)Model

public class BaseModel {
    private String id = IdGenerater.generateId();
    private long createTime; // 创建的时间
    private String key; // key的名字
    @JSONField(serializeUsing = LongAdderSerializer.class)
    private LongAdder count; // 该 key 出现的数量,使用 LongAdder 解决多线程计数
}
public class HotKeyModel extends BaseModel {
    private String appName; // 来自于哪个应用
    private KeyType keyType; // key的类型(譬如是接口、热用户、redis的key等)
    private boolean remove; // 是否是删除事件
}
public class KeyCountModel {
    private String ruleKey; // 对应的规则名,如 pin_2020-08-09 11:32:43
    private int totalHitCount; // 总访问次数
    private int hotHitCount; // 热后访问次数
    private long createTime; // 发送时的时间
}

此处可以看出,HotKeyModel 其实不仅仅用于增加新的热点 Key,也负责删除部分非热点 Key

(3)HotKeyMsg

public class HotKeyMsg {
    private int magicNumber; // 魔法数字,后续其实我们Key的默认value都是这玩意
    private String appName; // 应用名
    private MessageType messageType; // 消息类型
    private String body; // 具体内容
    // 从 List 列表可以看出我们后续每次上传或者删除可能都是一组 Key
    // 通过定时任务进行批量处理提高性能
    private List<HotKeyModel> hotKeyModels; // key本身相关
    private List<KeyCountModel> keyCountModels; // 计数相关
}

5. Rule

热点规则也是整个热点检测组件中最核心的部分,其基本属性如下所示:

public class KeyRule {
    private String key; // key的前缀,也可以完全和key相同。为"*"时代表通配符
    private boolean prefix; // 是否是前缀,true是前缀
    private int interval; // 间隔时间(秒)
    private int threshold; // 累计数量
    private int duration; // 变热key后,本地、etcd缓存它多久。单位(秒),默认60
    private String desc; // 描述
}

在实际使用时,是否是热点取决于两个核心参数:interval 以及 threshold。通过计算间隔时间内的累计数量来决定是热点数据。

6. 小结

在本节介绍中我们主要按照顺序讲述了公共模块的相关组件以及一些核心实体类代表的含义,在后续的内容中我们将详细的介绍这些实体的具体用途以及使用场景。

三、Client

相较于 common 模块的介绍方法,在介绍 client 模块的时候我们可以按照惯用思维来进行详细的介绍。面对当前的热点检测组件来看,我们需要对客户端的功能实现提出以下几个关键问题:

(1)何种情况下推送 Key 至 worker 进行判定呢?

(2)以什么样的形式推送 Key 信息至 worker 呢?

(3)如何监听获取到 worker 发布的 Hot Key 呢?

(4)获取到 Hot Key 后会对其进行什么形式的处理呢?

针对上述问题,我们将从以下几个方面来进行阐述和解释,我们在这里还是贴一下整体的代码结构方便后续定位。

1. Netty 通信

代码对应于 netty 部分

在进行后续的学习之前我们需要明确一点,client 与 worker 之间的通信方式代码都是基于 netty 长连接进行实现的。有关 netty 通信部分的所有代码均集中于 NettyClient 即 Netty 连接器中,后续所有的通信方式均基于该单例进行处理

public class NettyClient {
    private static final NettyClient nettyClient = new NettyClient();
    // 后续均通过getInstance方法获取到对应的单例
    public static NettyClient getInstance() {
        return nettyClient;
    }
}

(1)初始化 Bootstrap

明确初始化 Bootstrap 的作用,和我一样不太理解 Netty 工作原理的朋友们可以简单理解为 Bootstrap 就是 Netty 提供给我们的一个便利的工厂类,能够快速的创建 Netty 客户端或者服务端的实例。

private Bootstrap initBootstrap() {
    // Netty中的线程池实现,专用于处理I/O事件,该线程组会使用两个线程管理I/O事件循环
    EventLoopGroup group = new NioEventLoopGroup(2);
    Bootstrap bootstrap = new Bootstrap();
    // 自定义处理器
    NettyClientHandler nettyClientHandler = new NettyClientHandler();
    bootstrap.group(group).channel(NioSocketChannel.class)
            // 启动TCP的SO_KEEPALIVE,目的是在连接处于空闲状态时如果超过两个小时
            // 本地的TCP实现会发送一个数据包给远程socket用于检测连接是否有效
            .option(ChannelOption.SO_KEEPALIVE, true)
            // 禁用Nagle算法,确保消息能够及时发送,不会因为数据包过小被延迟
            .option(ChannelOption.TCP_NODELAY, true)
            // 初始化新建的socketChannel时添加多个channelHandler处理消息
            .handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) {
                    // 这里就是获取到对应的分隔符喽!!前面的瓜在这结了果
                    ByteBuf delimiter = Unpooled.copiedBuffer(Constant.DELIMITER.getBytes());
                    ch.pipeline()
                            // 在这里我们就使用了自定义的分隔符防止粘包/半包问题
                            .addLast(new DelimiterBasedFrameDecoder(Constant.MAX_LENGTH, delimiter))
                            // 这里是自定义的消息的编解码器,用于序列化和反序列化,之前common模块有提到
                            .addLast(new MsgDecoder())
                            .addLast(new MsgEncoder())
                            //10秒没消息时,就发心跳包过去用于维持长连接
                            .addLast(new IdleStateHandler(0, 0, 30))
                            // 自定义的业务逻辑处理器,随后详细介绍
                            .addLast(nettyClientHandler);
                }
            });
    return bootstrap;
}

到这里我们已经成功初始化了一个 Bootstrap 实例,那后续我们只需要通过如下方法就可以建立连接啦

String[] ss = address.split(":");
try {
    ChannelFuture channelFuture = bootstrap.connect(ss[0], Integer.parseInt(ss[1])).sync();
    // 略
} catch (Exception e) {
    JdLogger.error(getClass(), "----该worker连不上----" + address);
    // 略
}

(2)自定义处理器

虽然我们这里仅仅是一个 Netty 通信的介绍,但是还是不免会有一些业务逻辑乱入,大家如果不太理解什么意思的话可以不用着急,继续向后看总能明白!

// 这个注解也简单介绍一下,其也就是声明这个handler时一个线程安全的处理器,可复用
@ChannelHandler.Sharable
public class NettyClientHandler extends SimpleChannelInboundHandler<HotKeyMsg> {

    // 用户自定义的空闲状态事件触发
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            // 心跳检测事件哦
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            if (idleStateEvent.state() == IdleState.ALL_IDLE) {
                //向服务端发送消息
                ctx.writeAndFlush(new HotKeyMsg(MessageType.PING, Context.APP_NAME));
            }
        }
        super.userEventTriggered(ctx, evt);
    }

    // channel激活事件时触发,核心目标是告诉服务器客户端的应用名称
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        JdLogger.info(getClass(), "channelActive:" + ctx.name());
        ctx.writeAndFlush(new HotKeyMsg(MessageType.APP_NAME, Context.APP_NAME));
    }

    // channel断开事件触发,核心目标是处理相关的掉线逻辑
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        super.channelInactive(ctx);
        //断线了,可能只是client和server断了,但都和etcd没断。也可能是client自己断网了,也可能是server断了
        //发布断线事件。后续10秒后进行重连,根据etcd里的worker信息来决定是否重连
        // 如果etcd里没了,就不重连。如果etcd里有,就重连
        notifyWorkerChange(ctx.channel());
    }

    // 这里其实就是利用总线事件去通知,后续介绍
    private void notifyWorkerChange(Channel channel) {
        EventBusCenter.getInstance().post(new ChannelInactiveEvent(channel));
    }

    // 当客户端从服务器接收到一条消息时,channelRead0方法会被触发
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, HotKeyMsg msg) {
        // 如果为心跳事件就直接打印日志即可
        if (MessageType.PONG == msg.getMessageType()) {
            JdLogger.info(getClass(), "heart beat");
            return;
        }
        // 如果为检测出的hotkey响应,则又需要发送ReceiveNewKeyEvent消息啦,后续进行介绍
        if (MessageType.RESPONSE_NEW_KEY == msg.getMessageType()) {
            JdLogger.info(getClass(), "receive new key : " + msg);
            if (CollectionUtil.isEmpty(msg.getHotKeyModels())) {
                return;
            }
            // 这里也能看出来,我们就是一组Key哦
            for (HotKeyModel model : msg.getHotKeyModels()) {
                EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
            }
        }
    }
}

2. 异步解耦

代码对应于 core.EventBus 部分

对于常规的业务操作,如果现在有一个异步操作的需求,大家第一时间想到的一定是消息队列,当然,我们一个节点内部的异步操作也是可以通过 SpringEvent 进行实现,当然 SpringEvent 本身比较重,异步处理也需要额外的配置,在我们本次的场景中,我们无须进行高级的事务支持以及上下文的传递,因此,我们可以采用更加轻量以及更少配置的 EventBus 来达到同样的目的。

public class EventBusCenter {
    private static final EventBus eventBus = new EventBus();
    private EventBusCenter() {
    }
    public static EventBus getInstance() {
        return eventBus;
    }
    public static void register(Object obj) {
        eventBus.register(obj);
    }
    public static void unregister(Object obj) {
        eventBus.unregister(obj);
    }
    public static void post(Object obj) {
        eventBus.post(obj);
    }
}

当然我们也需要注册相关的监听事件,以及使用@Subscribe搭配食用,这里我们可以在 ClientStarter 中看到相关的注册代码

private void registEventBus() {
    //netty连接器会关注WorkerInfoChangeEvent事件
    EventBusCenter.register(new WorkerChangeSubscriber());
    //热key探测回调关注热key事件
    EventBusCenter.register(new ReceiveNewKeySubscribe());
    //Rule的变化的事件
    EventBusCenter.register(new KeyRuleHolder());
}

3. 本地缓存

代码对应于 cache 部分

这里我们首先介绍一下 client 中如何组织包装本地缓存的吧!针对热Key缓存或者规则缓存的基本结构一定是一个 key-value 结构,项目中通过实现自定义的 LocalCache 接口来实现对于 Caffieine 的封装,这里只给出几个典型的方法!

public class CaffeineCache implements LocalCache {

    // 缓存存储的具体位置,Cache是由Caffieine提供的
    private Cache<String, Object> cache;

    public CaffeineCache(int duration) {
        this.cache = CaffeineBuilder.cache(duration);
    }

    @Override
    public Object get(String key, Object defaultValue) {
        Object o = cache.getIfPresent(key);
        if (o == null) {
            return defaultValue;
        }
        return o;
    }
    
    @Override
    public void set(String key, Object value) {
        cache.put(key, value);
    }
}

public class CaffeineBuilder {

    public static Cache<String, Object> cache(int duration) {
        return cache(128, Context.CAFFEINE_SIZE, duration);
    }
    // ...
    public static Cache<String, Object> cache(int minSize, int maxSize, int expireSeconds) {
        return Caffeine.newBuilder()
                .initialCapacity(minSize)//初始大小
                .maximumSize(maxSize)//最大数量
                .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)//过期时间
                .build();
    }
}

// 定义一个工厂类用于获取本地缓存
public class CacheFactory {
    private static final LocalCache DEFAULT_CACHE = new DefaultCaffeineCache();

    public static LocalCache build(int duration) {
        return new CaffeineCache(duration);
    }

    public static LocalCache getNonNullCache(String key) {
        LocalCache localCache = getCache(key);
        if (localCache == null) {
            return DEFAULT_CACHE;
        }
        return localCache;
    }

    public static LocalCache getCache(String key) {
        return KeyRuleHolder.findByKey(key);
    }
}

4. ETCD 连接管理

在 common 模块我们简单介绍了 JdEtcdClient 客户端,用于获取对应的 KV 键值对。现在我们对于 ETCD 本身的任务需要明确:

  1. 我们需要通过 ETCD 来获取到对应的 worker 信息并将后续的消息负载均衡到每一个 worker 上,其实也就是一个注册中心的作用。
  2. 我们需要在集群之间同步所有的热 Key 规则,即需要利用 ETCD 来做一个规则同步
  3. 我们需要能够手工自定义一些热 Key(来自手工添加的目录)

ETCD 本身不能做什么事情呢?

  • 不能直接作为热 Key 同步的中央节点,没有任何一个单点能够支撑如此高的并发量,最终的策略一定是依赖于 worker 直接向对应的 client 发送热 Key 信息

明确好了上述需求之后,我们可以着手学习 ETCD 连接管理的相关内容。

4.1. 客户端启动

首先,大家可以参考 sample 中的 Starter 可以看出,HotKey 客户端需要进行初始化启动,代码如下:

@PostConstruct
public void init() {
    ClientStarter.Builder builder = new ClientStarter.Builder();
    ClientStarter starter = builder.setAppName(appName).setEtcdServer(etcd).build();
    starter.startPipeline();
}

可以看到,最核心的一句代码为

starter.startPipeline();

这个函数位于 client 包下面的 ClientStarter 中,ClientStart 定义了整个客户端所有的初始化事件,我们在这里仅简单介绍一下于 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();

    // 与etcd相关的监听都开启
    EtcdStarter starter = new EtcdStarter();
    starter.start();
}

这里能够看出我们在 startPipeline 中设置了 ETCD 的地址并进行了相关事件的监听,接下来进行具体的分析

4.2. ETCD 启动/监听事件

代码对应于 EtcdStarter 部分

public void start() {
    fetchWorkerInfo(); // 每隔30秒拉取worker信息
    fetchRule(); //  拉取规则信息
    startWatchRule(); // 监听规则信息变化
    startWatchHotKey(); //监听热key事件,只监听手工添加、删除的key
}
4.2.1. 定时拉取 worker 信息

这里我们可以强调一下,所有的 worker 信息的都是通过 core.worker 中的相关类进行维护的,不管是信息存储或者是订阅关系我们都可以首先去寻找这个模块中的代码逻辑。

/**
 * 每隔30秒拉取worker信息
 */
private void fetchWorkerInfo() {
    // 直接开启一个单线程线程池去执行定时任务
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    //开启拉取etcd的worker信息,如果拉取失败,则定时继续拉取
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        JdLogger.info(getClass(), "trying to connect to etcd and fetch worker info");
        fetch();

    }, 0, 30, TimeUnit.SECONDS);
}

private void fetch() {
    // 这里获取到的就是JdEtcdClient,即随后的configCenter = JdEtcdClient
    IConfigCenter configCenter = EtcdConfigFactory.configCenter();
    try {
        //获取当前AppName对应的所有worker的ip
        List<KeyValue> keyValues = configCenter.getPrefix(ConfigConstant.workersPath + Context.APP_NAME);
        //worker为空,可能该APP没有自己的worker集群,就去连默认的,如果默认的也没有,就不管了,等着心跳
        if (CollectionUtil.isEmpty(keyValues)) {
            keyValues = configCenter.getPrefix(ConfigConstant.workersPath + "default");
        }
        //全是空,给个警告
        if (CollectionUtil.isEmpty(keyValues)) {
            JdLogger.warn(getClass(), "very important warn !!! workers ip info is null!!!");
        }

        List<String> addresses = new ArrayList<>();
        if (keyValues != null) {
            for (KeyValue keyValue : keyValues) {
                //value里放的是ip地址
                String ipPort = keyValue.getValue().toStringUtf8();
                addresses.add(ipPort);
            }
        }

        JdLogger.info(getClass(), "worker info list is : " + addresses + ", now addresses is "
                + WorkerInfoHolder.getWorkers());
        //发布workerinfo变更信息
        notifyWorkerChange(addresses);
    } catch (StatusRuntimeException ex) {
        //etcd连不上
        JdLogger.error(getClass(), "etcd connected fail. Check the etcd address!!!");
    }

}

可以看到,我们在 42 行发布 workerInfo 变更信息,我们可以查看订阅了该消息的处理逻辑,

代码对应于 core.worker.WorkerChangeSubscriber 部分

public class WorkerChangeSubscriber {
    /**
     * 监听worker信息变动
     */
    @Subscribe
    public void connectAll(WorkerInfoChangeEvent event) {
        // 获取到刚才从ETCD拿到的当前appName对应的所有worker
        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);
    }
}

在这两段代码中,其实我们可以看出所有的 worker 信息以及对应的连接都是由 WorkerInfoHolder 进行维护的,那让我们再进去看看究竟发生了什么,这里我们只看连接建立部分了,断开连接部分大家可以自行阅读学习

代码对应于 core.worker.WorkerInfoHolder部分

public class WorkerInfoHolder {
    /**
     * 保存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<>();

    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;
    }

    /**
     * 监听到worker信息变化后
     * 将新的worker信息和当前的进行合并,并且连接新的address
     * address例子:10.12.139.152:11111
     */
    public static void mergeAndConnectNew(List<String> allAddresses) {
        // 移除那些在最新的worker地址集里没有的那些
        removeNoneUsed(allAddresses);
        //去连接那些在etcd里有,但是list里没有的
        List<String> needConnectWorkers = newWorkers(allAddresses);
        if (needConnectWorkers.size() == 0) {
            return;
        }
        JdLogger.info(WorkerInfoHolder.class, "new workers : " + needConnectWorkers);
        // 这里是核心逻辑,我们会调用之前定义的connect方法,通过netty建立长连接
        // 随后我们可以获取到长连接对应的channel
        // 再调用 WorkerInfoHolder中的put方法将对应的server的channel设置为获取到的channel
        NettyClient.getInstance().connect(needConnectWorkers);
        Collections.sort(WORKER_HOLDER);
    }
}

private static class Server implements Comparable<Server> {
    private String address;
    private Channel channel;
}

可以得出,在执行拉取任务后,我们能够实时更新每个 AppName 对应的所有的 worker 的 channel 从而在后续任务中能够快速推送待检测的 Key

4.2.2. 拉取规则信息

(1)从 ETCD 拉取规则信息

代码对应于 EtcdStarter.fetchRule 部分

private void fetchRule() {
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    //开启拉取etcd的worker信息,如果拉取失败,则定时继续拉取
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        JdLogger.info(getClass(), "trying to connect to etcd and fetch rule info");
        boolean success = fetchRuleFromEtcd();
        if (success) {
            //拉取已存在的热key
            fetchExistHotKey();
            scheduledExecutorService.shutdown();
        }
    }, 0, 5, TimeUnit.SECONDS);
}

这里可以关注一下第六行代码,fetchRuleFromEtcd 其实就是去获取对应的规则信息,这里会返回一个布尔类型的结果,如果成功则会直接拉取在配置文件中配置的热 key,并且停止当前线程池的执行,否则会继续定时执行改任务知道成功为止

代码对应于 EtcdStarter.fetchRuleFromEtcd 部分

private boolean fetchRuleFromEtcd() {
    // 这个就是之前我们提到的 JdEtcdClient,里面提供了ETCD的相关方法
    IConfigCenter configCenter = EtcdConfigFactory.configCenter();
    try {
        List<KeyRule> ruleList = new ArrayList<>();
        //从etcd获取自己的rule,这里其实也就是利用kvClient去获取对应的KV键值对
        String rules = configCenter.get(ConfigConstant.rulePath + Context.APP_NAME);
        if (StringUtil.isNullOrEmpty(rules)) {
            JdLogger.warn(getClass(), "rule is empty");
            // 如果说ETCD里面都没有对应的规则了,那就直接清空本地缓存队列
            // 这里也是利用通知的方式,我们以后看到notify都可以理解为是利用BusEvent去做异步通知
            notifyRuleChange(ruleList);
            return true;
        }
        // 反序列化
        ruleList = FastJsonUtils.toList(rules, KeyRule.class);
        // 通知规则变化,也是异步完成的
        notifyRuleChange(ruleList);
        return true;
    } catch (StatusRuntimeException ex) {
        //etcd连不上
        JdLogger.error(getClass(), "etcd connected fail. Check the etcd address!!!");
        return false;
    } catch (Exception e) {
        JdLogger.error(getClass(), "fetch rule failure, please check the rule info in etcd");
        return true;
    }

}

这里我们又可以强调一下,所有的 rule 信息的都是通过 core.rule 中的相关类进行维护的,不管是信息存储或者是订阅关系我们都可以首先去寻找这个模块中的代码逻辑。
在代码中出现了两个 notifyRuleChange 通知,分别为通知清空本地规则缓存以及通知规则出现变化

private void notifyRuleChange(List<KeyRule> rules) {
    EventBusCenter.getInstance().post(new KeyRuleInfoChangeEvent(rules));
}
@Subscribe
public void ruleChange(KeyRuleInfoChangeEvent event) {
    JdLogger.info(getClass(), "new rules info is :" + event.getKeyRules());
    List<KeyRule> ruleList = event.getKeyRules();
    if (ruleList == null) {
        return;
    }
    putRules(ruleList);
}
// 保存超时时间和caffeine的映射,key是超时时间,value是caffeine
// 这里我们存个疑问,为什么使用这样的duration作为key呢?
private static final ConcurrentHashMap<Integer, LocalCache> RULE_CACHE_MAP = new ConcurrentHashMap<>();
private static final List<KeyRule> KEY_RULES = new ArrayList<>();

public static void putRules(List<KeyRule> keyRules) {
    // 这里加了一个锁,我目前没有太明白具体含义
    // 我的猜测是在后续的任务我们利用BusEvent同时监听到了两次变化
    // 都会来进行rules的更新所以加个锁防止出现并发问题
    synchronized (KEY_RULES) {
        // 如果规则为空,清空规则表,直接对应如果传进来一个空的规则列表即清空缓存
        if (CollectionUtil.isEmpty(keyRules)) {
            KEY_RULES.clear();
            RULE_CACHE_MAP.clear();
            return;
        }
        KEY_RULES.clear();
        KEY_RULES.addAll(keyRules);
        Set<Integer> durationSet = keyRules.stream().map(KeyRule::getDuration).collect(Collectors.toSet());
        for (Integer duration : RULE_CACHE_MAP.keySet()) {
            // 先清除掉那些在RULE_CACHE_MAP里存的,但是rule里已没有的
            if (!durationSet.contains(duration)) {
                RULE_CACHE_MAP.remove(duration);
            }
        }
        // 遍历所有的规则
        // 这里其实我们也可以看出来仅仅只是初始化了一个Caffeine,里面没有任何的实际value
        // 在后续的学习中我们再来回头看看这里到底是干嘛的!
        for (KeyRule keyRule : keyRules) {
            int duration = keyRule.getDuration();
            if (RULE_CACHE_MAP.get(duration) == null) {
                LocalCache cache = CacheFactory.build(duration);
                RULE_CACHE_MAP.put(duration, cache);
            }
        }
    }
}

(2)拉取已经存在的热 Key

代码对应于 EtcdStarter.fetchExistHotKey

这里其实也就是去拉取已经在手工目录添加的热 Key,检测出来的 Key一定是 worker 直接推送给 client 的,这个需要明确一下

private void fetchExistHotKey() {
    JdLogger.info(getClass(), "--- begin fetch exist hotKey from etcd ----");
    // 熟悉的JdEtcdClient又来了
    IConfigCenter configCenter = EtcdConfigFactory.configCenter();
    try {
        //获取所有热key
        List<KeyValue> handKeyValues = configCenter.getPrefix(ConfigConstant.hotKeyPath + Context.APP_NAME);
        for (KeyValue keyValue : handKeyValues) {
            String key = keyValue.getKey().toStringUtf8().replace(ConfigConstant.hotKeyPath + Context.APP_NAME + "/", "");
            HotKeyModel model = new HotKeyModel();
            model.setRemove(false);
            model.setKey(key);
            EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
        }
    } catch (StatusRuntimeException ex) {
        //etcd连不上
        JdLogger.error(getClass(), "etcd connected fail. Check the etcd address!!!");
    }

}

这里其实可以看到我们也发送了一个 ReceiveNewKeyEvent 通知,但是这部分逻辑呢其实也属于对于热点 Key 的处理逻辑,我们这里卖个关子,后续再去详细介绍

4.2.3. 异步监听规则变化

代码对应于 EtcdStarter.startWatchRule

private void startWatchRule() {
    // 这里可以看到我们这里日常来了一个单线程线程池来执行监听任务
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    executorService.submit(() -> {
        JdLogger.info(getClass(), "--- begin watch rule change ----");
        try {
            // JdEtcdClient,之后就不再提了
            IConfigCenter configCenter = EtcdConfigFactory.configCenter();
            // 这里就是etcd提供的监听器,以迭代器的方式通过next获取到监听结果
            KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.rulePath + Context.APP_NAME);
            // 如果有新事件,即rule的变更,就重新拉取所有的信息
            while (watchIterator.hasNext()) {
                // 这句必须写,next会让他卡住,除非真的有新rule变更
                WatchUpdate watchUpdate = watchIterator.next();
                List<Event> eventList = watchUpdate.getEvents();
                JdLogger.info(getClass(), "rules info changed. begin to fetch new infos. rule change is " + eventList);
                // 管你修改了啥,我反手全量拉取rule信息
                fetchRuleFromEtcd();
            }
        } catch (Exception e) {
            // 使用next获取失败后会直接抛出异常
            JdLogger.error(getClass(), "watch err");
        }
    });
}
4.2.4. 异步监听热点变化

代码对应于 EtcdStarter.startWatchHotKey

在之前我们有提到在进行 ETCD 相关的初始化事件时会将手工定义的热 Key 加载到本地缓存中,这里也会出现一种情况就是我们对手工定义的热 Key 进行了修改,那此时也是需要被感知到该操作的,所以同样需要进行监听。这里其实我们就能够发现,我们 client 需要额外的除了相关静态定义的规则或者热点数据。

private void startWatchHotKey() {
    // 日常上来一个单线程线程池,如果需要实现定时任务的相关功能的话则可以使用Schedule
    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();
                // 这里其实也就能看出来虽然上面是一个List,但是其实发生的每次变化都会被记录
                // 也就是说每次的变化内容都只有一个结果
                KeyValue keyValue = eventList.get(0).getKv();
                Event.EventType eventType = eventList.get(0).getType();
                try {
                    // 把一些前缀进行去除,只保留Key的名称
                    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");
        }
    });
}

这里我们可以看到里面有大量的添加 Key 或者删除 Key 的逻辑,我们可以先跳过,只需要知道对于手工定义的热Key我们同样需要对其进行处理即可!

到此为止我们的 ETCD 相关的启动/监听事件已经大致介绍完毕了,那其实从项目代码来观察的话,其实我们已经介绍完毕了etcd、core.eventbus、core.rule 以及 core.worker 的相关内容,接下来让我们继续研究如何推送 Key 吧!

5. 热点推送

走到这了,可能会有兄弟提出疑问,你搞了这么半天,所以我们在项目中怎么使用呢,我如何判定某些 key 是不是热点呢?这个就涉及到我们在项目中使用到的核心函数以及具体的推送策略!接下来我们将对整个 client 侧的核心函数进行详细解释,整体的介绍步骤按照 JdHotKeyStore 中提供的函数顺序进行。

5.1. 整体介绍

代码对应于 callback.JdHotKeyStore 部分

public class JdHotKeyStore {
    // 是否临近过期
    private static boolean isNearExpire(ValueModel valueModel)
    // 判断是否是热key
    public static boolean isHotKey(String key)
    // 从本地caffeine取值
    public static Object get(String key)
    // 判断是否是热key,如果是热key,则给value赋值
    public static void smartSet(String key, Object value)
    // 强制给value赋值
    public static void forceSet(String key, Object value)
    // 获取value,如果value不存在则发往netty
    public static Object getValue(String key, KeyType keyType)
    public static Object getValue(String key)
    // 仅获取value,如果不存在也不上报热key
    static ValueModel getValueSimple(String key)
    // 纯粹的本地缓存,无需该key是热key
    static void setValueDirectly(String key, Object value)
    // 删除某key,会通知整个集群删除
    public static void remove(String key)
    // 判断是否是热key。适用于只需要判断key,而不需要value的场景
    static boolean isHot(String key)
    // 获取本地缓存
    private static LocalCache getCache(String key)
    // 判断这个key是否在被探测的规则范围内
    private static boolean inRule(String key)
}

整体的提供的方法如图所示,我们仅选择其中的最核心的逻辑进行解析,其他的各位朋友自行学习吧!

5.2. 核心逻辑

代码对应于 callback.JdHotKeyStore.isHotKey 部分

public static boolean isHotKey(String key) {
    try {
        // 1.规则判定
        if (!inRule(key)) {
            return false;
        }
        // 2.热点判定
        boolean isHot = isHot(key);
        if (!isHot) {
            // 3.推送检测
            HotKeyPusher.push(key, null);
        } else {
            ValueModel valueModel = getValueSimple(key);
            //判断是否过期时间小于1秒,小于1秒的话也发送
            if (isNearExpire(valueModel)) {
                HotKeyPusher.push(key, null);
            }
        }
        // 4.统计计数
        KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, isHot));
        return isHot;
    } catch (Exception e) {
        return false;
    }
}
5.2.1. 规则判定
private static boolean inRule(String key) {
    // 之前我们仅仅是在【3.本地缓存中】简单列举了该方法
    return CacheFactory.getCache(key) != null;
    /**
    * public static LocalCache getCache(String key) {
    *     return KeyRuleHolder.findByKey(key);
    * }
    */
}

代码对应于 core.rule.KeyRuleHolder 部分

public static LocalCache findByKey(String key) {
    if (StrUtil.isEmpty(key)) {
        return null;
    }
    // 通过Key查询到对应的规则
    KeyRule keyRule = findRule(key);
    if (keyRule == null) {
        return null;
    }
    // 这里返回的是一整个本地缓存
    // 通过对应的key可以访问到具体的value值,但是目前这里应该是空的?
    // 因为目前我们没有看到真正的添加key-value逻辑
    return RULE_CACHE_MAP.get(keyRule.getDuration());
}
// 作者备注强调:
// 遍历该app的所有rule,找到与key匹配的rule。优先全匹配->prefix匹配-> * 通配
// 这一段虽然看起来比较奇怪,但是没毛病,不要乱改
private static KeyRule findRule(String key) {
    KeyRule prefix = null;
    KeyRule common = null;
    // 遍历的是KEY_RULES,该列表中存储了所有的规则
    for (KeyRule keyRule : KEY_RULES) {
        // 如果完全符合直接返回规则即可
        if (key.equals(keyRule.getKey())) {
            return keyRule;
        }
        // 如果前缀符合也可以直接返回前缀即可
        if ((keyRule.isPrefix() && key.startsWith(keyRule.getKey()))) {
            prefix = keyRule;
        }
        // 如果规则中有*的代表的其实也就是通用规则,最后进行匹配
        if ("*".equals(keyRule.getKey())) {
            common = keyRule;
        }
    }
    if (prefix != null) {
        return prefix;
    }
    return common;
}
5.2.2. 热点判定

代码对应于 callback.JdHotKeyStore.isHot

热点判定的核心逻辑就在于我们当前待检测的key是否已经在本地缓存中了,如果在则说明已经是热点key了,如果不在那可能需要推送到 worker 端进行检测

static boolean isHot(String key) {
    return getValueSimple(key) != null;
}
// 仅获取value,如果不存在也不上报热key
static ValueModel getValueSimple(String key) {
    // 这里我们可以看到可以通过getCache获取到对应的本地缓存
    // 再从本地缓存中取出对应的key
    Object object = getCache(key).get(key);
    // 没有取到就直接返回null即可,无须额外操作
    if (object == null) {
        return null;
    }
    return (ValueModel) object;
}
private static LocalCache getCache(String key) {
    return CacheFactory.getNonNullCache(key);
}

public static LocalCache getNonNullCache(String key) {
    LocalCache localCache = getCache(key);
    // 仅仅只是加了一个默认的本地缓存而已
    if (localCache == null) {
        return DEFAULT_CACHE;
    }
    return localCache;
}
5.2.3. 热点推送

如果说在上一个步骤中检测到的结果为false,则说明当前的key并没有经过热点检测,则需要推送到 worker 进行进一步的检测,如果说当前 key 在本地缓存中,也就是说这个key是之前检测到的热点,但是如果这个热点key快要过期的话我们同样需要发送到worker进行重新检测,防止一个过热的key由于没有续约在一瞬间大量的请求未命中本地缓存从而对redis造成压力。

整个热点推送逻辑分为两个部分:

(1)热点收集计数

(2)热点定时推送

5.2.3.1. 热点收集计数

这里我们就正式进入了core.key 中的相关代码的学习阶段,首当其冲的就是 HotKeyPusher!

代码对应于 core.key.HotKeyPusher 部分

public static void push(String key, KeyType keyType) {
    // 这里的false说明我们需要的操作是推送一个新key而不是需要删除key
    // 侧面也说明了我们是有能力去进行key的删除的,后续再进行详细介绍
    push(key, keyType, 1, false);
}
public static void push(String key, KeyType keyType, int count, boolean remove) {
    // 单次push其实也就是key的访问量的+1,所以我们可以直接将count设置为1
    if (count <= 0) {
        count = 1;
    }
    // 默认都是一个redisKey,我这边针对热点检测的学习也更多地考虑的是redisKey的场景
    if (keyType == null) {
        keyType = KeyType.REDIS_KEY;
    }
    // key为空那还搞个毛,直接return即可
    if (key == null) {
        return;
    }
    // 这里的计数器呢其实我们使用的就是一个LongAdder
    // 相较于AtomicLong,LongAdder的并发性能更强,这个就是涉及到两个并发安全的计数器的底层设计,有兴趣可以去学习一下
    // 简单来说就是通过使用cells数组将一个value拆分到这个数组中从而在高并发场景下将CAS操作分散到不同的索引从而减少竞争次数
    LongAdder adderCnt = new LongAdder();
    // 这里就是一个明确的1,这里的adderCnt是方法体内部定义的,不存在并发问题
    adderCnt.add(count);
    HotKeyModel hotKeyModel = new HotKeyModel();
    hotKeyModel.setAppName(Context.APP_NAME); // appName
    hotKeyModel.setKeyType(keyType); // redisKey
    hotKeyModel.setCount(adderCnt); // 1
    hotKeyModel.setRemove(remove); // false
    hotKeyModel.setKey(key); // keyName

    if (remove) {
        // 如果是删除手工定义目录的key,就直接发到 etcd 去
        // 但是这个删除不能删除 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);
        }
    }
}

这里打断一下,为了防止一直贴代码+注释这种问题,我这里再对代码结构进行一个简单解释,我们可以看到在前一段代码的45行我们拿到了一个KeyHanderFactory工厂类去获取对应的收集器,那其实整个client部分只有两个收集器,分别为 keyCollector 和 KeyCounter,其对应的功能我们后续都会介绍到,这个工厂其实也就是保存了一个 DefaultKeyHandler 的静态实例:

public class DefaultKeyHandler {
    private IKeyPusher iKeyPusher = new NettyKeyPusher(); // netty推送器
    private IKeyCollector<HotKeyModel, HotKeyModel> iKeyCollector = new TurnKeyCollector();
    private IKeyCollector<KeyHotModel, KeyCountModel> iKeyCounter = new TurnCountCollector();
}

能够通过方法获取到对应的收集器,具体的执行细节都在收集器内部进行处理,我们接着学习

代码对应于 core.key.TurnKeyCollector

public class TurnKeyCollector implements IKeyCollector<HotKeyModel, HotKeyModel> {
    // 这里简单介绍一下我们为什么要使用两个ConcurrentHashMap
    // 因为我们是一个ConcurrentHashMap负责将热点key进行计数保存
    // 另外一个ConcurrentHashMap负责将保存好的热点key进行一个定时推送上传
    // 因此我们可以通过一个额外的 AtomicLong 进行+1操作搭配取余最终实现在推送期间将写入操作移动到另外一个ConcurrentHashMap
    private ConcurrentHashMap<String, HotKeyModel> map0 = new ConcurrentHashMap<>();
    private ConcurrentHashMap<String, HotKeyModel> map1 = new ConcurrentHashMap<>();
    private AtomicLong atomicLong = new AtomicLong(0);

    // 这部分逻辑我们稍后再进行介绍,我们目前只关注收集计数部分!
    @Override
    public List<HotKeyModel> lockAndGetResult() {
        //自增后,对应的map就会停止被写入,等待被读取
        atomicLong.addAndGet(1);

        List<HotKeyModel> list;
        if (atomicLong.get() % 2 == 0) {
            list = get(map1);
            map1.clear();
        } else {
            list = get(map0);
            map0.clear();
        }
        return list;
    }

    private List<HotKeyModel> get(ConcurrentHashMap<String, HotKeyModel> map) {
        return CollectionUtil.list(false, map.values());
    }

    // 我们刚才需要调用的就是该收集计数方法
    @Override
    public void collect(HotKeyModel hotKeyModel) {
        // 获取到对应key的名称
        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) {
                // 这里就是计数的核心逻辑,当该key之前已经存在则将之前的count进行+1
                model.add(hotKeyModel.getCount());
            }
        } else {
            // 同理
            HotKeyModel model = map1.putIfAbsent(key, hotKeyModel);
            if (model != null) {
                model.add(hotKeyModel.getCount());
            }
        }
    }

    @Override
    public void finishOnce() {
    }
}
5.2.3.2. 热点定时推送

当我们的待检测的key完成计数后我们就应该将其推送到 worker 进行检测工作,那这个时候就会有一个回旋镖打过来,我们是在什么时候开启了这个定时任务呢?回顾【4.1客户端启动中的startPipeline】我们可以发现其中有两行关键的代码:

//开始定时推送
PushSchedulerStarter.startPusher(pushPeriod); // 开启热点推送
PushSchedulerStarter.startCountPusher(10); // 开启计数推送

我们这里仅需要关注热点推送即startPusher即可,startCountPusher会在后续工作中为大家介绍

代码对应于 core.key.PushSchedulerStarter.startPusher

// 每0.5秒推送一次待测key
public static void startPusher(Long period) {
    if (period == null || period <= 0) {
        period = 500L;
    }
    @SuppressWarnings("PMD.ThreadPoolCreationRule")
    // 我们的老熟人了,newSingleThreadScheduledExecutor实现简易的本地定时任务
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-pusher-service-executor", true));
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        // 之前介绍过的通过工厂获取到对应的keyCollector
        IKeyCollector<HotKeyModel, HotKeyModel> collectHK = KeyHandlerFactory.getCollector();
        // 这里其实就是推送的核心代码,之前未进行介绍的一部分
        List<HotKeyModel> hotKeyModels = collectHK.lockAndGetResult();
        // 如果hotKey不为空则执行具体的推送逻辑,其实也就是真正地利用netty进行key的发送
        if(CollectionUtil.isNotEmpty(hotKeyModels)){
            // 执行具体的发送逻辑
            KeyHandlerFactory.getPusher().send(Context.APP_NAME, hotKeyModels);
            // 这个目前没有明确的逻辑,只是方便扩展后续的功能
            collectHK.finishOnce();
        }

    },0, period, TimeUnit.MILLISECONDS);
}

代码位于 core.key.TurnKeyCollector 部分

public class TurnKeyCollector implements IKeyCollector<HotKeyModel, HotKeyModel> {
    private ConcurrentHashMap<String, HotKeyModel> map0 = new ConcurrentHashMap<>();
    private ConcurrentHashMap<String, HotKeyModel> map1 = new ConcurrentHashMap<>();
    private AtomicLong atomicLong = new AtomicLong(0);

    @Override
    public List<HotKeyModel> lockAndGetResult() {
        // 自增后,对应的map就会停止被写入,等待被读取
        // 每次执行推送的时候都会进行这样的ConcurrentHashMap切换
        atomicLong.addAndGet(1);
        List<HotKeyModel> list;
        if (atomicLong.get() % 2 == 0) {
            list = get(map1);
            map1.clear();
        } else {
            list = get(map0);
            map0.clear();
        }
        return list;
    }
    
    // 将对应的map转化为list,也就是提取所有的value即可
    private List<HotKeyModel> get(ConcurrentHashMap<String, HotKeyModel> map) {
        return CollectionUtil.list(false, map.values());
    }
}

代码位于 core.key.NettyKeyPusher.send 部分

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);
        // 这里就是简单的哈希取余进行worker的选择
        Channel channel = WorkerInfoHolder.chooseChannel(model.getKey());
        if (channel == null) {
            continue;
        }
        // 将channel和key进行关联,每个channel即worker对应一组key
        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);
            // 在common模块中我们提到这里其实就是一个List用于保存批量的key
            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");
            }
        }
    }
}
5.2.4. 统计计数

这里可能有兄弟会问,之前不都已经进行热点计数了么,怎么又来计数,到底有完没完?这里我们需要明确我们 client 端会上传两种 model,一种是 HotKeyModel,一种是 KeyCountModel,后者是在common模块中进行定义的,它最主要的目的就是去收集每种规则在热前以及热后的访问次数并存储至中央的 ETCD 中以供后续的可视化分析使用。

我们可以在 isHotKey 的逻辑的最后部分观察到这样一行代码:

KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, isHot));

这行代码的核心逻辑和之前的热点推送的逻辑类似,就是进行一个访问次数收集

代码对应于 core.key.TurnCountCollector

public class TurnCountCollector implements IKeyCollector<KeyHotModel, KeyCountModel> {
    /**
     * 存储格式为:appName_时间戳 -> 次数
     * pin_20200624091024 -> 10
     * sku_20200624091025 -> 142
     */
    // 这里同样的目的需要设置两个ConcurrentHashMap
    private ConcurrentHashMap<String, HitCount> HIT_MAP_0 = new ConcurrentHashMap<>(512);
    private ConcurrentHashMap<String, HitCount> HIT_MAP_1 = new ConcurrentHashMap<>(512);
    private static final String FORMAT = "yyyy-MM-dd HH:mm:ss";
    // 用于分片操作
    private AtomicLong atomicLong = new AtomicLong(0);
    // 这个是一个数据转换阈值,当数据量大于5000时我们可以考虑并行流处理增加转换速度
    private static final int DATA_CONVERT_SWITCH_THRESHOLD = 5000;

    // 这个是一样的道理
    @Override
    public List<KeyCountModel> lockAndGetResult() {
        //自增后,对应的map就会停止被写入,等待被读取
        atomicLong.addAndGet(1);

        List<KeyCountModel> list;
        if (atomicLong.get() % 2 == 0) {
            list = get(HIT_MAP_1);
            // 同样需要清空数据
            HIT_MAP_1.clear();
        } else {
            list = get(HIT_MAP_0);
            HIT_MAP_0.clear();
        }
        return list;
    }

    /**
     * 每10秒上报一次最近10秒的数据
     */
    private List<KeyCountModel> get(ConcurrentHashMap<String, HitCount> map) {
        //根据待转换并上报的统计数据的数据量选择是否启用并行参数转换
        if (map.size()>DATA_CONVERT_SWITCH_THRESHOLD){
            return parallelConvert(map);
        }else {
            return syncConvert(map);
        }
    }

    // 在数据量足够大的情况下 并行转换可以拥有比串行for循环更好的性能
    private List<KeyCountModel> parallelConvert(ConcurrentHashMap<String, HitCount> map) {
        return map.entrySet().parallelStream().map(entry->{
            String key = entry.getKey();
            HitCount hitCount = entry.getValue();
            KeyCountModel keyCountModel = new KeyCountModel();
            keyCountModel.setTotalHitCount((int)hitCount.totalHitCount.sum());
            keyCountModel.setRuleKey(key);
            keyCountModel.setHotHitCount((int)hitCount.hotHitCount.sum());
            return keyCountModel;
        }).collect(Collectors.toList());
    }

    // 在数据量不大的情况下,使用同步for循环进行数据转换性能也不错
    private List<KeyCountModel> syncConvert(ConcurrentHashMap<String, HitCount> map) {
        List<KeyCountModel> list = new ArrayList<>(map.size());
        for (Map.Entry<String, HitCount> entry : map.entrySet()) {
            String key = entry.getKey();
            HitCount hitCount = entry.getValue();
            KeyCountModel keyCountModel = new KeyCountModel();
            keyCountModel.setTotalHitCount((int)hitCount.totalHitCount.sum());
            keyCountModel.setRuleKey(key);
            keyCountModel.setHotHitCount((int)hitCount.hotHitCount.sum());
            list.add(keyCountModel);
        }
        return list;
    }

    // 这里也是一样的逻辑
    @Override
    public void collect(KeyHotModel keyHotModel) {
        if (atomicLong.get() % 2 == 0) {
            put(keyHotModel.getKey(), keyHotModel.isHot(), HIT_MAP_0);
        } else {
            put(keyHotModel.getKey(), keyHotModel.isHot(), HIT_MAP_1);
        }
    }

    @Override
    public void finishOnce() {
    }

    public void put(String key, boolean isHot, ConcurrentHashMap<String, HitCount> map) {
        //获取到对应的规则名称,如key是pin_的前缀,则存储pin_
        String rule = KeyRuleHolder.rule(key);
        //不在规则内的不处理
        if (StrUtil.isEmpty(rule)) {
            return;
        }
        String nowTime = nowTime();
        //rule + 分隔符 + 2020-10-23 21:11:22
        String mapKey = rule + Constant.COUNT_DELIMITER + nowTime;
        HitCount hitCount = map.computeIfAbsent(mapKey, v -> new HitCount());
        // 热时使用专用的hotHitCount进行计数
        if (isHot) {
            hitCount.hotHitCount.increment();
        }
        // 计算总访问次数
        hitCount.totalHitCount.increment();
    }

    private String nowTime() {
        Date nowTime = new Date(System.currentTimeMillis());
        SimpleDateFormat sdFormatter = new SimpleDateFormat(FORMAT);
        return sdFormatter.format(nowTime);
    }

    // 同样使用两个LongAdder去进行计数功能
    private class HitCount {
        private LongAdder hotHitCount = new LongAdder();
        private LongAdder totalHitCount = new LongAdder();
    }
}

这行代码就是最开始开启定时任务的第二行代码,用于开启计数推送

PushSchedulerStarter.startCountPusher(10); // 开启计数推送

代码对应于 core.key.PushSchedulerStarter.startCountPusher

// 每10秒推送一次数量统计
public static void startCountPusher(Integer period) {
    if (period == null || period <= 0) {
        period = 10;
    }
    @SuppressWarnings("PMD.ThreadPoolCreationRule")
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-count-pusher-service-executor", true));
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        IKeyCollector<KeyHotModel, KeyCountModel> collectHK = KeyHandlerFactory.getCounter();
        // 锁定计数并获取到对应的结果
        List<KeyCountModel> keyCountModels = collectHK.lockAndGetResult();
        if(CollectionUtil.isNotEmpty(keyCountModels)){
            // 批量发送,这里就不再详细进行介绍了,之前的步骤是一样的
            KeyHandlerFactory.getPusher().sendCount(Context.APP_NAME, keyCountModels);
            collectHK.finishOnce();
        }
    },0, period, TimeUnit.SECONDS);
}

5.3. 常用方法

在上一节中我们针对 isHotKey 函数进行了详细的介绍,当然京东 hotkey 也提供了其他的一些方便的方法用于获取或者设置key对应的值,下面分别对其进行简单介绍。

代码对应于 callback.JdHotKeyStore

public class JdHotKeyStore {

    /**
     * 是否临近过期
     */
    private static boolean isNearExpire(ValueModel valueModel) {
        //判断是否过期时间小于1秒,小于1秒的话也发送
        if (valueModel == null) {
            return true;
        }
        // 创建时间 + 持续时间 - 当前时间 <= 2秒 就是临近过期
        return valueModel.getCreateTime() + valueModel.getDuration() - System.currentTimeMillis() <= 2000;
    }

    // 判断是否是热key,如果不是,则推送至worker进行检测
    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;
        }

    }

    // 从本地caffeine缓存中取值
    public static Object get(String key) {
        ValueModel value = getValueSimple(key);
        if (value == null) {
            return null;
        }
        Object object = value.getValue();
        //如果是默认值也返回null,在设置热key对应的value时默认都是用魔法数字
        if (object instanceof Integer && Constant.MAGIC_NUMBER == (int) object) {
            return null;
        }
        return object;
    }

    // 仅获取value,如果不存在也不上报热key
    static ValueModel getValueSimple(String key) {
        Object object = getCache(key).get(key);
        if (object == null) {
            return null;
        }
        return (ValueModel) object;
    }

    // 判断是否是热key,如果是热key,则给value赋值
    public static void smartSet(String key, Object value) {
        if (isHot(key)) {
            ValueModel valueModel = getValueSimple(key);
            if (valueModel == null) {
                return;
            }
            valueModel.setValue(value);
        }
    }

    // 强制给value赋值
    public static void forceSet(String key, Object value) {
        ValueModel valueModel = ValueModel.defaultValue(key);
        if (valueModel != null) {
            valueModel.setValue(value);
        }
        setValueDirectly(key, valueModel);
    }

    // 纯粹的本地缓存,无需该key是热key
    static void setValueDirectly(String key, Object value) {
        getCache(key).set(key, value);
    }

    // 获取value,如果value不存在则发往worker
    // 这个就是isHotKey的升级版,直接去获取,获取不到就走推送逻辑
    public static Object getValue(String key, KeyType keyType) {
        try {
            //如果没有为该key配置规则,就不用上报key
            if (!inRule(key)) {
                return null;
            }
            Object userValue = null;
            ValueModel value = getValueSimple(key);
            if (value == null) {
                HotKeyPusher.push(key, keyType);
            } else {
                //临近过期了,也发
                if (isNearExpire(value)) {
                    HotKeyPusher.push(key, keyType);
                }
                Object object = value.getValue();
                //如果是默认值,也返回null
                if (object instanceof Integer && Constant.MAGIC_NUMBER == (int) object) {
                    userValue = null;
                } else {
                    userValue = object;
                }
            }
            //统计计数
            KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, value != null));
            return userValue;
        } catch (Exception e) {
            return null;
        }

    }

    // 删除某key,会通知整个集群删除,这里逻辑就是新增一个再删除它
    public static void remove(String key) {
        getCache(key).delete(key);
        HotKeyPusher.remove(key);
    }

    // 判断是否是热key。适用于只需要判断key,而不需要value的场景
    static boolean isHot(String key) {
        return getValueSimple(key) != null;
    }

    private static LocalCache getCache(String key) {
        return CacheFactory.getNonNullCache(key);
    }

    // 判断这个key是否在被探测的规则范围内
    private static boolean inRule(String key) {
        return CacheFactory.getCache(key) != null;
    }
}

6. 回调处理

这里我们又开始一个新的问题,我们如何接收到 worker 端推送回来的热 Key 呢?这个其实也是也是一个回旋镖,我们可以回顾一下【1.Netty通信】部分提到的自定义处理器部分的逻辑

代码对应于 netty.NettyClientHandler.channelRead0 部分

// 当客户端从服务器接收到一条消息时,channelRead0方法会被触发
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, HotKeyMsg msg) {
    // 如果为心跳事件就直接打印日志即可
    if (MessageType.PONG == msg.getMessageType()) {
        JdLogger.info(getClass(), "heart beat");
        return;
    }
    // 看过来看过来!!!!!!!!!!!!!!!!!!!!!!!!!
    // 如果为检测出的hotkey响应,则又需要发送ReceiveNewKeyEvent消息啦,后续进行介绍
    if (MessageType.RESPONSE_NEW_KEY == msg.getMessageType()) {
        JdLogger.info(getClass(), "receive new key : " + msg);
        if (CollectionUtil.isEmpty(msg.getHotKeyModels())) {
            return;
        }
        // 这里也能看出来,我们就是一组Key哦
        for (HotKeyModel model : msg.getHotKeyModels()) {
            EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
        }
    }
}

我们是在启动时就已经注册了这样的处理逻辑,当接收到 RESPONSE_NEW_KEY 类型的消息时,我们同样通过 EventBus 进行异步处理,接下来我们看一下具体的逻辑

代码对应于 callback.ReceiveNewKeySubscribe 部分

public class ReceiveNewKeySubscribe {
    // 这里其实就是一个监听器实例,我们这里使用的就是一个默认的监听器
    private ReceiveNewKeyListener receiveNewKeyListener = new DefaultNewKeyListener();

    @Subscribe
    public void newKeyComing(ReceiveNewKeyEvent event) {
        HotKeyModel hotKeyModel = event.getModel();
        if (hotKeyModel == null) {
            return;
        }
        //收到新key推送
        if (receiveNewKeyListener != null) {
            receiveNewKeyListener.newKey(hotKeyModel);
        }
    }

}

代码对应于 callback.DefaultNewKeyListener 部分

@Override
public void newKey(HotKeyModel hotKeyModel) {
    long now = System.currentTimeMillis();
    // 如果key到达时已经过去1秒了,记录一下。手工删除key时,没有CreateTime
    // 因为我们要求的其实就是对于热key的快速检测和处理,所以1秒已经很慢了,我们需要更快
    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);
    }
    // 执行具体的添加热Key逻辑
    addKey(hotKeyModel.getKey());
}
private void addKey(String key) {
    ValueModel valueModel = ValueModel.defaultValue(key);
    if (valueModel == null) {
        //不符合任何规则
        deleteKey(key);
        return;
    }
    // 如果原来该key已经存在了,那么value就被重置,过期时间也会被重置。
    // 如果原来不存在,就新增的热key
    // 这里可以看到我们设置进去的value就是一个defaultValue,也就是一个魔法数字
    // 热点检测框架只需要发现key即可,value还是需要手动进行设置的,例如smartSet
    JdHotKeyStore.setValueDirectly(key, valueModel);
}

这里补充一点,我们再4.2.4中提到的热点变化那块其实也是利用了这样的逻辑将手动定义的目录中的热Key进行缓存的!

7. 小结

到此为止呢,我们的客户端部分就已经全部介绍完毕了,最核心的逻辑已经是热点推送和回调处理部分,可以多加关注这部分的设计思想,当然了,具体的代码还是需要大家自行去认真的研究,本文更多的还是辅助梳理框架而已!

四、Worker

worker 部分也是整个热点检测中的最重要的一个模块,负责对高并发场景中各个客户端发来的Key进行热点检测并将热点Key推送到所有的客户端中,这部分的很多通信逻辑和客户端中是一致的,我们这里在某些通信方式上就减少细节介绍了!

目前是打算将整个 worker 部分分为基础模块、启动配置、热点检测以及推送回调四个部分进行介绍。

这里贴一下代码结构!

1. 基础模块

基础模块其实也就是简单介绍一下项目结构中的一些基础部分,这里我们就直接按照文件夹顺序进行简单地介绍。

(1)cache

代码对应于 cache 部分

public class CaffeineCacheHolder {
    // key是appName,value是caffeine
    private static final Map<String, Cache<String, Object>> CACHE_MAP = new ConcurrentHashMap<>();
    private static final String DEFAULT = "default";

    public static Cache<String, Object> getCache(String appName) {
        if (StrUtil.isEmpty(appName)) {
            if (CACHE_MAP.get(DEFAULT) == null) {
                // 构建一个本地缓存,这部分代码在CaffeineBuilder中,比较简单
                Cache<String, Object> cache = CaffeineBuilder.buildAllKeyCache();
                CACHE_MAP.put(DEFAULT, cache);
            }
            return CACHE_MAP.get(DEFAULT);
        }
        if(CACHE_MAP.get(appName) == null) {
            Cache<String, Object> cache = CaffeineBuilder.buildAllKeyCache();
            CACHE_MAP.put(appName, cache);
        }
        return CACHE_MAP.get(appName);
    }

    // 清空某个app的缓存key
    public static void clearCacheByAppName(String appName) {
        if(CACHE_MAP.get(appName) != null) {
            CACHE_MAP.get(appName).invalidateAll();
        }
    }
    ...
}

public class CaffeineBuilder {
    private static ExecutorService executorService = Executors.newFixedThreadPool(4);
    // 构建所有来的要缓存的key cache
    public static Cache<String, Object> buildAllKeyCache() {
        //老版本jdk1.8.0_20之前,caffeine默认的forkJoinPool在及其密集的淘汰过期时,会有forkJoinPool报错。建议用新版jdk
        return Caffeine.newBuilder()
                .initialCapacity(8192)//初始大小
                .maximumSize(5000000)//最大数量。这个数值我设置的很大,按30万每秒,每分钟是1800万,实际可以调小
                .expireAfterWrite(InitConstant.caffeineMaxMinutes, TimeUnit.MINUTES)//过期时间,默认1分钟
                .executor(executorService)
                .softValues()
                .build();
    }

    // 刚生成的热key,先放这里放几秒后,应该所有客户端都收到了热key并本地缓存了。这几秒内,不再处理同样的key了
    public static Cache<String, Object> buildRecentHotKeyCache() {
        return Caffeine.newBuilder()
                .initialCapacity(256)//初始大小
                .maximumSize(50000)//最大数量
                .expireAfterWrite(5, TimeUnit.SECONDS)//过期时间
                .executor(executorService)
                .softValues()
                .build();
    }

}

(2)config

代码对应于 config 部分

这部分其实就是一些配置类而已,无须特别关注,其中有些部分我们在后续会进行详细介绍

@Configuration
public class CaffeineConfig {
    // 刚刚在前面提到地热Key缓存,后续就不会重复处理这些热key了
    @Bean("hotKeyCache")
    public Cache<String, Object> hotKeyCache() {
        return CaffeineBuilder.buildRecentHotKeyCache();
    }
}

@Configuration
public class ClientConfig {
    // 这个在后续也会使用到,用于管理客户端的连接
    @Bean
    public IClientChangeListener clientChangeListener() {
        return new ClientChangeListener();
    }
}

@Configuration
public class EtcdConfig {
    @Value("${etcd.server}")
    private String etcdServer;
    private Logger logger = LoggerFactory.getLogger(getClass());

    // 这个名字太熟悉了,其实就是JdEtcdClient,后面就不多介绍了
    @Bean
    public IConfigCenter client() {
        logger.info("etcd address : " + etcdServer);
        //连接多个时,逗号分隔
        return JdEtcdBuilder.build(etcdServer);
    }
}

(3)model

代码对应于 model 部分

这次的基础模型就两个,一个是 AppInfo 用于管理客户端的连接,一个是 TotalCount 用于记录接收到的 key 的数量以及处理的 key 的数量

public class AppInfo {
    private String appName; // 应用名
    private ChannelGroup channelGroup; // 某app的全部channel

    public AppInfo(String appName) {
        this.appName = appName;
        channelGroup  = new DefaultChannelGroup(appName, GlobalEventExecutor.INSTANCE);
    }

    public void groupPush(Object object) {
        channelGroup.writeAndFlush(object);
    }

    public void add(ChannelHandlerContext ctx) {
        channelGroup.add(ctx.channel());
    }

    public void remove(ChannelHandlerContext ctx) {
        channelGroup.remove(ctx.channel());
    }
    ...
}

public class TotalCount {
    private long totalReceiveCount; // 总接收数量
    private long totalDealCount; // 总处理数量
    ...
}

(4)rule

这个我本来应该放在具体业务处理逻辑部分进行介绍,但是这部分在客户端确实已经详细介绍了,服务端和客户端这部分的逻辑不同仅仅在于一个服务端对应多个客户端,所以需要使用 appName 进行区分而已

代码对应于 KeyRuleHolder 部分

// 保存各个app的rule信息
public class KeyRuleHolder {
    // key就是appName,value是rule
    private static final Map<String, List<KeyRule>> RULE_MAP = new ConcurrentHashMap<>();

    // 获取key对应的rule规则
    public static KeyRule getRuleByAppAndKey(HotKeyModel hotKeyModel) {
        List<KeyRule> keyRules = RULE_MAP.get(hotKeyModel.getAppName());
        //没有该key相关信息时,返回默认
        if (CollectionUtils.isEmpty(keyRules)) {
            return new DefaultKeyRule().getKeyRule();
        }
        KeyRule prefix = null;
        KeyRule common = null;
        //遍历该app的所有rule,找到与key匹配的rule。优先全匹配->prefix匹配-> * 通配
        // 熟悉的代码,我的评价是看代码人的福音
        for (KeyRule keyRule : keyRules) {
            if (hotKeyModel.getKey().equals(keyRule.getKey())) {
                return keyRule;
            }
            if (keyRule.isPrefix() && hotKeyModel.getKey().startsWith(keyRule.getKey())) {
                prefix = keyRule;
            }
            if ("*".equals(keyRule.getKey())) {
                common = keyRule;
            }
        }
        if (prefix != null) {
            return prefix;
        }
        if (common != null) {
            return common;
        }
        return new DefaultKeyRule().getKeyRule();
    }

    // 判断新取的rules和已有的是否一样
    public static void put(String appName, List<KeyRule> keyRules) {
        if (RULE_MAP.get(appName) == null) {
            RULE_MAP.put(appName, keyRules);
            return;
        }
        if (keyRules.toString().equals(RULE_MAP.get(appName).toString())) {
            return;
        }
        //判断该APP的rule是否有变化,如果有变化了,则需要清空该app的caffeine缓存。
        RULE_MAP.put(appName, keyRules);
        CaffeineCacheHolder.clearCacheByAppName(appName);
    }

}

(5)tool

这部分主要是一些工具类类库,大家可以自行学习,可以暂时忽略 SlidingWindow,这个后续我们在介绍计算部分的时候会详细进行分析。这部分就先不贴代码了,大家可以简单了解即可,后续自己需要使用的时候直接过来偷代码也挺好的哈哈哈

2. 启动配置

大家在学习客户端部分其实也能够发现,很多监听类或者定时任务的启动都是在启动时进行配置的,这部分的逻辑对于整个服务的正常运行起了至关重要的作用,我们可以直接观察到项目中有一个 starters 文件夹,我们就从这里出发!

2.1. ETCD 启动配置

代码对应于 starters.EtcdStarter 部分

接下来这段代码可能会有点长,我们顺着代码逐步分析,很多可能都是咱们在客户端学习过的,当然一些不重要我也做了一些删减!

// worker端对etcd相关的处理
@Component
public class EtcdStarter {
    public static boolean LOGGER_ON = true; // 是否开启日志
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Resource
    private IConfigCenter configCenter; // JdEtcdClient
    @Value("${netty.port}")
    private int port;
    // 该worker放到etcd worker目录的哪个app下,
    // 譬如放/app1下,则该worker只能被app1使用,不会为其他client提供服务
    // 当然,我们一般可以放在默认目录,只要我们worker够强,就可以做到来者不拒
    @Value("${etcd.workerPath}")
    private String workerPath;
    @Value("${local.address}")
    private String localAddress; // 本机地址
    // 开启持续无key发送监控,如果持续1分钟没发来key
    // 就断开和etcd的连接,之后重建和客户端连接
    @Value("${open.monitor}")
    private boolean openMonitor;
    private static final String DEFAULT_PATH = "default";
    private static final String MAO = ":";
    private static final String ETCD_DOWN = "etcd is unConnected . please do something";
    // 用来存储临时收到的key总量,来判断是否很久都没收到key了
    private long tempTotalReceiveKeyCount;
    // 每次10秒没收到key发过来,就将这个加1,加到3时,就停止自己注册etcd 30秒
    private int mayBeErrorTimes = 0;
    // 是否可以继续上报自己的ip
    private volatile boolean canUpload = true;

    // 看到PostConstruct咱们就知道这是一个启动事件,还叫看门狗,铁铁是一个监听模块
    @PostConstruct
    public void watchLog() {
        // AsyncPool.asyncDo 其实也就是newCachedThreadPool去异步执行而已
        AsyncPool.asyncDo(() -> {
            try {
                String loggerOn = configCenter.get(ConfigConstant.logToggle);
                LOGGER_ON = "true".equals(loggerOn) || "1".equals(loggerOn);
            } catch (StatusRuntimeException ex) {
                logger.error(ETCD_DOWN, ex);
            }
            // 这段代码应该已经很熟悉了,妥妥一个监听器,也就是监听是否开启日志这一个事件
            KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.logToggle);
            while (watchIterator.hasNext()) {
                WatchUpdate watchUpdate = watchIterator.next();
                List<Event> eventList = watchUpdate.getEvents();
                KeyValue keyValue = eventList.get(0).getKv();
                logger.info("log toggle changed : " + keyValue)
                String value = keyValue.getValue().toStringUtf8();
                LOGGER_ON = "true".equals(value) || "1".equals(value);
            }
        });

    }

    // 启动回调监听器,监听规则变化,这段咱们也很熟悉了
    @PostConstruct
    public void watch() {
        AsyncPool.asyncDo(() -> {
            KvClient.WatchIterator watchIterator;
            if (isForSingle()) {
                watchIterator = configCenter.watch(ConfigConstant.rulePath + workerPath);
            } else {
                watchIterator = configCenter.watchPrefix(ConfigConstant.rulePath);
            }
            while (watchIterator.hasNext()) {
                WatchUpdate watchUpdate = watchIterator.next();
                List<Event> eventList = watchUpdate.getEvents();
                KeyValue keyValue = eventList.get(0).getKv();
                logger.info("rule changed : " + keyValue);
                try {
                    ruleChange(keyValue);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }

    // 启动回调监听器,监听白名单变化,只监听自己所在的app,白名单key不参与热key计算,直接忽略
    // 这真的是白名单么,怎么感觉是黑名单哈哈哈
    @PostConstruct
    public void watchWhiteList() {
        AsyncPool.asyncDo(() -> {
            //获取所有白名单
            fetchWhite();
            KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.whiteListPath + workerPath);
            while (watchIterator.hasNext()) {
                WatchUpdate watchUpdate = watchIterator.next();
                logger.info("whiteList changed ");
                try {
                    fetchWhite();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        })
    }

    private void fetchWhite() {
        String value = configCenter.get(ConfigConstant.whiteListPath + workerPath);
        if (StrUtil.isNotEmpty(value)) {
            String[] list = value.split(",");
            for (String s : list) {
                // 可以看到其实白名单也是使用一个holder进行维护的
                // 这部分对应于netty.holder部分
                WhiteListHolder.add(s);
            }
        }
    }

    // 每隔1分钟拉取一次,所有的app的rule
    // 当然啦,如果我们是只服务一个app的worker只需要拉取单个app的规则即可
    @Scheduled(fixedRate = 60000)
    public void pullRules() {
        try {
            if (isForSingle()) {
                String value = configCenter.get(ConfigConstant.rulePath + workerPath);
                if (!StrUtil.isEmpty(value)) {
                    List<KeyRule> keyRules = FastJsonUtils.toList(value, KeyRule.class);
                    KeyRuleHolder.put(workerPath, keyRules);
                }
            } else {
                List<KeyValue> keyValues = configCenter.getPrefix(ConfigConstant.rulePath);
                for (KeyValue keyValue : keyValues) {
                    ruleChange(keyValue);
                }
            }
        } catch (StatusRuntimeException ex) {
            logger.error(ETCD_DOWN, ex);
        }
    }

    /**
     * 每隔10秒上传一下client的数量到etcd中
     */
    @Scheduled(fixedRate = 10000)
    public void uploadClientCount() {
        try {
            String ip = IpUtils.getIp();
            // 这里是第二个holder,对应于netty.holder.ClientInfoHolder部分
            for (AppInfo appInfo : ClientInfoHolder.apps) {
                String appName = appInfo.getAppName();
                int count = appInfo.size();
                //即便是full gc也不能超过3秒
                configCenter.putAndGrant(ConfigConstant.clientCountPath + appName + "/" + ip, count + "", 13);
            }
            configCenter.putAndGrant(ConfigConstant.caffeineSizePath + ip, FastJsonUtils.convertObjectToJSON(CaffeineCacheHolder.getSize()), 13);
            //上报每秒QPS(接收key数量、处理key数量)
            String totalCount = FastJsonUtils.convertObjectToJSON(new TotalCount(HotKeyFilter.totalReceiveKeyCount.get(), totalDealCount.longValue()));
            configCenter.putAndGrant(ConfigConstant.totalReceiveKeyCount + ip, totalCount, 13);
            logger.info(totalCount + " expireCount:" + expireTotalCount + " offerCount:" + totalOfferCount);
            //如果是稳定一直有key发送的应用,建议开启该监控,以避免可能发生的网络故障
            if (openMonitor) {
                // 这里的操作其实就是校验一下接收的key的数量
                // 如果说一段时间都没有变化,说明网络可能出现了问题
                // 这个时候我们就断掉注册到etcd的心跳,让各个客户端重新连接一下自己
                checkReceiveKeyCount();
            }
        } catch (Exception ex) {
            logger.error(ETCD_DOWN, ex);
        }
    }

    private void checkReceiveKeyCount() {
        //如果一样,说明10秒没收到新key了
        if (tempTotalReceiveKeyCount == HotKeyFilter.totalReceiveKeyCount.get()) {
            if (canUpload) {
                mayBeErrorTimes++;
            }
        } else {
            tempTotalReceiveKeyCount = HotKeyFilter.totalReceiveKeyCount.get();
        }
        if (mayBeErrorTimes >= 6) {
            logger.error("network maybe error …… i stop the heartbeat to etcd");
            canUpload = false;
            new Thread(() -> {
                try {
                    Thread.sleep(35000);
                    canUpload = true;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
            //需要把注册ip到etcd停一段时间,让各client和自己断连,重新连接
            mayBeErrorTimes = 0;
            //清零各个数据
            tempTotalReceiveKeyCount = 0;
            HotKeyFilter.totalReceiveKeyCount.set(0);
            InitConstant.totalDealCount.reset();
            InitConstant.totalOfferCount.reset();
            InitConstant.expireTotalCount.reset();
        }
    }

    // 每隔30秒去获取一下dashboard的地址,可视化用途哦
    @Scheduled(fixedRate = 30000)
    public void fetchDashboardIp() {
        try {
            //获取DashboardIp
            List<KeyValue> 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();
            // 这里其实也就是netty.dashboard部分的逻辑,不是特别重要,我们就不多详细介绍了
            NettyClient.getInstance().connect(dashboardIp);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 优雅关机
    @PreDestroy
    public void removeNodeInfo() {
        try {
            String hostName = IpUtils.getHostName();
            configCenter.delete(ConfigConstant.workersPath + workerPath + "/" + hostName);
            AsyncPool.shutDown();
        } catch (Exception e) {
            logger.error("worker connect to etcd failure");
        }
    }

    // 每隔一会去check一下,自己还在不在etcd里
    @PostConstruct
    public void makeSureSelfOn() {
        //开启上传worker信息
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            try {
                if (canUpload) {
                    uploadSelfInfo();
                }
            } catch (Exception e) {
                //do nothing
            }
        }, 0, 5, TimeUnit.SECONDS);
    }

    // 通过http请求手工上传信息到etcd,适用于正常使用过程中,etcd挂掉,导致worker租期到期被删除,无法自动注册
    private void uploadSelfInfo() {
        configCenter.putAndGrant(buildKey(), buildValue(), 8);
    }
}

2.2. NodeServer 启动配置

接下来过渡到一个很重要的启动配置,我们 worker 端最重要的事情是什么?那肯定是接受客户端的key然后进行检测并返回给客户端,那我们如何监听到客户端发来的待检测key呢?这部分其实也同样是在我们的启动配置中进行注册!

2.2.1. 基本配置

代码对应于 starters.NodesServerStarter 部分

大量的逻辑凝聚在咱们小小的一段代码中,这就是设计模式的魅力

@Component
public class NodesServerStarter {
    @Value("${netty.port}")
    private int port;
    private Logger logger = LoggerFactory.getLogger(getClass());

    // 之前提到的的客户端监听器,用于进行客户端连接管理
    @Resource
    private IClientChangeListener iClientChangeListener;
    // 小小责任链
    @Resource
    private List<INettyMsgFilter> messageFilters;

    @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.server 部分

// 该server用于给各个微服务实例连接用
public class NodesServer {
    // 客户端连接管理
    private IClientChangeListener clientChangeListener;
    // 消息过滤器,稍后详细介绍
    private List<INettyMsgFilter> messageFilters;

    public void startNettyServer(int port) throws Exception {
        // boss单线程,负责连接管理
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        // worker线程组,负责事件处理
        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();
        }
    }

    private class ChildChannelHandler extends ChannelInitializer<Channel> {

        @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);
        }
    }
}

// 这里处理所有netty事件
public class NodesServerHandler extends SimpleChannelInboundHandler<HotKeyMsg> {
    // 客户端状态监听器
    private IClientChangeListener clientEventListener;
    // 自行维护Filter的添加顺序
    private List<INettyMsgFilter> messageFilters = new ArrayList<>();
    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, HotKeyMsg msg) {
        if (msg == null) {
            return;
        }
        // 走过滤器
        for (INettyMsgFilter messageFilter : messageFilters) {
            boolean doNext = messageFilter.chain(msg, ctx);
            // 这里可以看到,如果doNext为false,则可以直接终止责任链了
            if (!doNext) {
                return;
            }
        }
    }

    // 客户端掉线时触发
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        if (clientEventListener != null) {
            clientEventListener.loseClient(ctx);
        }
        ctx.close();
        super.channelInactive(ctx);
    }
}


其实在上述步骤中我们貌似没有看到客户端连接的处理、接收 Key或者其他的一些操作?这个时候就轮到我们的过滤器部分进行处理了!这个部分其实就是一个责任链处理逻辑,我们接下来一一进行讲解,这里我们就通过@Order指定的顺序进行介绍!

2.2.2. 心跳包处理

代码对应于 netty.filter.HeartBeatFilter 部分

@Component
@Order(1)
public class  implements INettyMsgFilter {
    @Override
    public boolean chain(HotKeyMsg message, ChannelHandlerContext ctx) {
        if (MessageType.PING == message.getMessageType()) {
            ctx.writeAndFlush(new HotKeyMsg(MessageType.PONG));
            // 这里可以看出,当我们识别出来消息类型是当前类型后就不需要继续走责任链条了
            // 直接返回一个false即可停止后续责任链的执行
            return false;
        }
        return true;
    }
}
2.2.3. 客户端上传 appName

代码对应于 netty.filter.AppNameFilter 部分

@Component
@Order(2)
public class AppNameFilter implements INettyMsgFilter {
    // 我们把这玩意又导入进来了,说明了什么?
    // 我们是在这里处理新客户端的连接问题
    @Resource
    private IClientChangeListener clientEventListener;
    @Override
    public boolean chain(HotKeyMsg message, ChannelHandlerContext ctx) {
        if (MessageType.APP_NAME == message.getMessageType()) {
            String appName = message.getAppName();
            if (clientEventListener != null) {
                // 新建连接
                clientEventListener.newClient(appName, NettyIpUtil.clientIp(ctx), ctx);
            }
            return false;
        }
        return true;
    }
}

@Override
public synchronized void newClient(String appName, String ip, ChannelHandlerContext ctx) {
    logger.info(NEW_CLIENT);
    boolean appExist = false;
    // 其实也就是遍历 ClientInfoHolder中的apps去查找是否已经存在
    for (AppInfo appInfo : ClientInfoHolder.apps) {
        if (appName.equals(appInfo.getAppName())) {
            appExist = true;
            appInfo.add(ctx);
            break;
        }
    }
    // 如果不存在则为当前客户端新建一个APPInfo用于存储其基本信息并使用holder进行维护
    if (!appExist) {
        AppInfo appInfo = new AppInfo(appName);
        ClientInfoHolder.apps.add(appInfo);
        appInfo.add(ctx);
    }
    logger.info(NEW_CLIENT_JOIN);
}
2.2.4. 检测请求接收

代码对应于 netty.filter.HotKeyFilter 部分

// 热key消息,包括从netty来的和mq来的。收到消息,都发到队列去
@Component
@Order(3)
public class HotKeyFilter implements INettyMsgFilter {
    // 我们这里看到一个生产者,按理说生产者不应该是我们的客户端么?
    // 这里其实意思也就是我们将服务端对于key的处理其实也分为了两个部分
    // 第一个部分负责key的接收,第二个部分负责key的处理
    // 那我们这两个部分其实也就是一个生产者和消费者的关系
    // 那一个建议的生产者-消费者模型就成立了,中间利用一个阻塞队列就可以实现生产消费逻辑
    @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()) {
            // 记录接收key的总数,这个其实也就是暂存,用于判断是否网络出现问题
            totalReceiveKeyCount.incrementAndGet();
            // 发布消息,其实也就是接收到新key的消息
            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));
                }
            }
            // 利用生产者推送key,我盲猜这里是送到一个阻塞队列大伙信不信
            keyProducer.push(model, now);
        }
    }
}

代码对应于 keydispatcher.KeyProducer 部分

@Component
public class KeyProducer {
    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 {
            // 果然送到队列里去了,这个队列的位置在DispatcherConfig中,用于后续的消费逻辑
            // 我们可以暂时先忽略这部分
            QUEUE.put(model);
            totalOfferCount.increment();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
2.2.5. 计数请求接收

代码对应于 netty.filter.KeyCounterFilter 部分

// 对热key访问次数和总访问次数进行累计
@Component
@Order(4)
public class KeyCounterFilter implements INettyMsgFilter {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Value("${etcd.workerPath}")
    private String workerPath;

    @Override
    public boolean chain(HotKeyMsg message, ChannelHandlerContext ctx) {
        if (MessageType.REQUEST_HIT_COUNT == message.getMessageType()) {
            //设置appName
            if (StrUtil.isEmpty(message.getAppName())) {
                message.setAppName(workerPath);
            }
            // 和上面的逻辑比较相似,不过多介绍
            publishMsg(message.getAppName(), message, ctx);
            return false;
        }
        return true;
    }

    private void publishMsg(String appName, HotKeyMsg message, ChannelHandlerContext ctx) {
        List<KeyCountModel> models = message.getKeyCountModels();
        if (CollectionUtil.isEmpty(models)) {
            return;
        }
        long timeOut = SystemClock.now() - models.get(0).getCreateTime();
        // 超时5秒以上的就不处理了,因为client是每10秒发送一次
        // 所以最迟15秒以后的就不处理了
        if (timeOut > InitConstant.timeOut + 10000) {
            logger.warn("key count timeout " + timeOut + ", from ip : " + NettyIpUtil.clientIp(ctx));
            return;
        }
        // 将收到的key放入延时队列,15秒后进行累加并发送
        try {
            // 这部分逻辑在CounterConfig中进行介绍,我们也不过多关注了
            // 后续在消费逻辑中我们再仔细分析
            COUNTER_QUEUE.put(new KeyCountItem(appName, models.get(0).getCreateTime(), models));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

3. 热点检测

在这一部分我们主要来介绍服务端究竟是如何计算单个待检测Key是否过热以及对于之前提到的key的访问统计同样也在这边进行介绍(逻辑很相似,所以就偷懒顺带介绍了)。

3.1. 定时任务

不同于常规的配置中心,我们这里可以在 keydispatchConfig 和 CounterConfig 中看到相似的代码片段,我们后续均以 key 部分进行举例,最后会简要介绍一下 counter 部分。

@Configuration
public class DispatcherConfig {
    // key的核心处理逻辑,后续同样会详细介绍
    @Resource
    private IKeyListener iKeyListener;
    // 对于热key检测任务来说,我们需要利用单个worker处理多个客户端的热key检测逻辑
    // 客户端会每隔0.5秒上传一批待检测的key,worker应当以最快的速度将检测结果返回
    // 因此,当前的消费逻辑就是整个worker最重要的一坏,必须开一个线程池去并发处理
    private ExecutorService threadPoolExecutor = Executors.newCachedThreadPool();
    @Value("${thread.count}")
    private int threadCount;
    // 待消费队列
    public static BlockingQueue<HotKeyModel> QUEUE = new LinkedBlockingQueue<>(2000000);

    @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);
            // 利用线程池进行执行具体的beginConsume逻辑
            threadPoolExecutor.submit(keyConsumer::beginConsume);
        }
        return new Consumer(consumerList);
    }
}

对于 counter 部分来说,定时逻辑就比较简单了,因为不需要特别高的并发能力,只需要单线逐步处理即可,客户端也是每隔10秒才会上传一次,压力不大

@Configuration
public class CounterConfig {
    public static LinkedBlockingQueue<KeyCountItem> COUNTER_QUEUE = new LinkedBlockingQueue<>();

    @Resource
    private IConfigCenter configCenter;

    @Bean
    public CounterConsumer counterConsumer() {
        CounterConsumer counterConsumer = new CounterConsumer();
        counterConsumer.beginConsume(configCenter);
        return counterConsumer;
    }
}

3.2. 消费逻辑

代码对应于 keydispatcher.KeyConsumer 部分

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()) {
                    // 这个删除蛮鸡肋的感觉,客户端直接去访问etcd删除不也挺好?
                    iKeyListener.removeKey(model, KeyEventOriginal.CLIENT);
                } else {
                    // 我们主要关注检测key的逻辑即可,删除就不关注了
                    iKeyListener.newKey(model, KeyEventOriginal.CLIENT);
                }
                // 处理完毕,将处理数量加1
                totalDealCount.increment();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

代码对应于keylistener.KeyListener 部分

@Component
public class KeyListener implements IKeyListener {
    // 用来存储是否已经是热点数据了,就不用重复判定了
    @Resource(name = "hotKeyCache")
    private Cache<String, Object> hotCache;
    // 后续的回调处理阶段详细介绍的东西,用于将检测结果推送回客户端
    @Resource
    private List<IPusher> iPushers;

    private static final String SPLITER = "-";
    private Logger logger = LoggerFactory.getLogger(getClass());
    private static final String NEW_KEY_EVENT = "new key created event, key : ";
    private static final String DELETE_KEY_EVENT = "key delete event key : ";

    @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 ************ //
        // 该方法会被多个消费者同时调用去执行消费,存在多线程问题
        // 下面的那句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);
        // 最核心的一个步骤来了!我们会根据滑窗来判定当前key是否过热
        boolean hot = slidingWindow.addCount(hotKeyModel.getCount());

        if (!hot) {
            //如果没hot,重新把当前的窗口put回缓存,cache会自动刷新过期时间
            CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).put(key, slidingWindow);
        } else {
            // 这个时候我们断定这哥们已经是热key了,所以给它塞到热key缓存
            // 短时间内我们不会再计算当前key
            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);
            }
        }
    }

    // 生成或返回该key的滑窗
    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());
        });
    }
}

在上面的 44 行中我们可以看到通过一个滑窗就可以判定是否过热,这个是就是通过设计好的滑窗进行实现的,接下来让我详细介绍一下滑窗的整体逻辑

代码对应于 tool.SlidingWindow

// 滑动窗口,该窗口同样的key都是单线程计算,需要保证计算的准确性!
public class SlidingWindow {
    private AtomicLong[] timeSlices; // 循环队列,装多个窗口,该数量是windowSize的2倍
    private int timeSliceSize; // 队列的总长度
    private int timeMillisPerSlice; // 每个时间片的时长,以毫秒为单位
    private int windowSize; // 共有多少个时间片(即窗口长度)
    private int threshold; // 在一个完整窗口期内允许通过的最大阈值
    private long beginTimestamp; // 该滑窗的起始创建时间,也就是第一个数据
    private long lastAddTimestamp; // 最后一个数据的时间戳

    public SlidingWindow(int duration, int threshold) {
        // 超过10分钟的按10分钟,duration的单位是秒
        if (duration > 600) {
            duration = 600;
        }
        //要求5秒内探测出来的,
        if (duration <= 5) {
            // 实际场景中我们不可能容忍5秒以上,纯扯淡,所以走的逻辑一定是当前分支
            // 因此我们的窗口大小为5,
            this.windowSize = 5;
            // timeMillisPerSlice就等于duration * 1000 / windowSize = duration * 200
            this.timeMillisPerSlice = duration * 200;
        } else {
            this.windowSize = 10;
            this.timeMillisPerSlice = duration * 100;
        }
        this.threshold = threshold;
        // 保证存储在至少两个window
        this.timeSliceSize = windowSize * 2;
        reset();
    }

    // 初始化
    private void reset() {
        beginTimestamp = SystemClock.now();
        //窗口个数,这里大概率也是10
        AtomicLong[] localTimeSlices = new AtomicLong[timeSliceSize];
        // 每个窗口其实就是一个AtomicLong用于进行计数,但是这里需要使用这玩意么
        // 后续不是全部加锁了么,有点没太理解
        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();
        // 然后清空自己前面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);
        }
    }
}

以上就是热key检测的全部流程,其核心就在于维护一个滑动窗口区进行热key的并发量计算!我们这里再简单介绍一下 counter 部分的相应内容。

代码对应于 counter.CounterConsumer 部分

public class CounterConsumer {
    private Logger logger = LoggerFactory.getLogger(getClass());

    public void beginConsume(IConfigCenter configCenter) {
        AsyncPool.asyncDo(() -> {
            Map<String, String> map = new HashMap<>(500);
            while (true) {
                try {
                    // 从队列中取出数据
                    KeyCountItem item = COUNTER_QUEUE.take();
                    List<KeyCountModel> keyCountModels = item.getList();
                    String appName = item.getAppName();
                    for (KeyCountModel keyCountModel : keyCountModels) {
                        //如 rule + Constant.COUNT_DELIMITER + nowTime;
                        //rule + 分隔符 + 2020-10-23 21:11:22
                        //pin__#**#2020-10-23 21:11:22
                        String ruleKey = keyCountModel.getRuleKey();
                        int hotHitCount = keyCountModel.getHotHitCount();
                        int totalHitCount = keyCountModel.getTotalHitCount();
                        String mapKey = appName + Constant.COUNT_DELIMITER + ruleKey;
                        if (map.get(mapKey) == null) {
                            map.put(mapKey, hotHitCount + "-" + totalHitCount);
                        } else {
                            // 合并统计
                            String[] counts = map.get(mapKey).split("-");
                            int hotCount = Integer.valueOf(counts[0]) + hotHitCount;
                            int totalCount = Integer.valueOf(counts[1]) + totalHitCount;
                            map.put(mapKey, hotCount + "-" + totalCount);
                        }
                    }
                    //300是什么意思呢?300就代表了300秒的数据了,已经不少了
                    if (map.size() >= 300) {
                        // 扔到ETCD做统计去了
                        configCenter.putAndGrant(ConfigConstant.keyHitCountPath + appName + "/" + IpUtils.getIp()
                                + "-" + System.currentTimeMillis(),
                                FastJsonUtils.convertObjectToJSON(map), 30);
                        logger.info("key Hit count : " + map);
                        map.clear();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

4. 推送回调

刚才我们的计数统计是直接推送到 ETCD 中了,但是我们的热Key是直接推送到所有的客户端的,这个可能也会有同学提问为什么不把热key直接推送到 ETCD 然后各个客户端监听实现全局共享?这个答案其实官网中也提到了,单个ETCD 其实无法承载这么大的集中流量,而且通过推送至 ETCD + 监听的方式终究还是需要两个步骤,我们的系统还是希望能够以最高效的方式尽快将热key推送至各个客户端,因此我们还是直接利用 netty 进行双向通信实现热 key 的快速推送。

// 定义接口,实现类有 AppServerPusher 和 DashboardPusher 两个,我们只介绍前者了哈
public interface IPusher {
    void push(HotKeyModel model);

    // !!!原来官方也觉得没啥用哈哈哈哈啊哈
    /**
     * worker是监听不到删除事件了,client不往worker发删除事件了
     */
    @Deprecated
    void remove(HotKeyModel model);
}

代码对应于 netty.pusher.AppServerPusher 部分

// 推送到各客户端服务器
@Component
public class AppServerPusher implements IPusher {
    // 热key集中营可还行哈哈哈
    private static LinkedBlockingQueue<HotKeyModel> hotKeyStoreQueue = new LinkedBlockingQueue<>();
    // 给客户端推key信息
    @Override
    public void push(HotKeyModel model) {
        // 其实也就是把model添加到阻塞队列
        hotKeyStoreQueue.offer(model);
    }

    // 和dashboard那边的推送主要区别在于,给app推送每10ms一次,dashboard那边1s一次
    @PostConstruct
    public void batchPushToClient() {
        AsyncPool.asyncDo(() -> {
            while (true) {
                try {
                    List<HotKeyModel> tempModels = new ArrayList<>();
                    // 尝试在 10 毫秒的超时时间内,
                    // 从 hotKeyStoreQueue 中批量移除最多 10 个元素,
                    // 并将这些元素添加到 tempModels 集合中
                    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();
                }
            }
        });
    }
}

5. 小结

以上就是 worker 端的全部内容了,整个京东 hotkey 的内容也基本讲解完成了,一些细节还是需要朋友们自行去阅读代码理解了,很多地方其实我理解的也很有限,只不过可以帮助大家快速理清整个代码的结构以及相关的设计思路,中间的一些问题我也会慢慢的继续修正,也希望大家能够提出宝贵的意见,共勉!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值