京东hotKey-服务端源码讲解汇总

916 阅读7分钟

服务端-源码 基础结构

image.png

服务端概览

涉及到的启动类

com.jd.platform.hotkey.worker.starters.NodesServerStarter
com.jd.platform.hotkey.worker.netty.server.NodesServer

借助

@Component 
@PostConstruct
AsyncPool.asyncDo

启动neety服务, 服务端和客户端使用的neety链接,neety网络传输好处 自不必多说,自行搜索

image.png image.png

NodesServerStarter逻辑
  • 对client 进行维护

  • 对消息设置过滤器器 也算是一直责任链模式,通过order 控制顺序, 相对代码比较简洁优雅

public interface INettyMsgFilter {
    boolean chain(HotKeyMsg message, ChannelHandlerContext ctx);
}
@Resource
private IClientChangeListener iClientChangeListener;

//如果注入list集合,则元素是该list泛型的所有实现类
// (这里的list元素顺序,我们可以在springbean上加上@Order控制顺序)
@Resource
private List<INettyMsgFilter> messageFilters;
NodesServer逻辑
//boss单线程
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(CpuNum.workerCount());
try {
    ServerBootstrap bootstrap = new ServerBootstrap();
    bootstrap.group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .handler(new LoggingHandler(LogLevel.INFO))
            .option(ChannelOption.SO_BACKLOG, 1024)
            //保持长连接
            .childOption(ChannelOption.SO_KEEPALIVE, true)
            //出来网络io事件,如记录日志、对消息编解码等
            .childHandler(new ChildChannelHandler());
    //绑定端口,同步等待成功
    ChannelFuture future = bootstrap.bind(port).sync();
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        bossGroup.shutdownGracefully (1000, 3000, TimeUnit.MILLISECONDS);
        workerGroup.shutdownGracefully (1000, 3000, TimeUnit.MILLISECONDS);
    }));
    //等待服务器监听端口关闭
    future.channel().closeFuture().sync();
} catch (Exception e) {
    e.printStackTrace();
    //do nothing
    System.out.println("netty stop");
} finally {
    //优雅退出,释放线程池资源
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
}

上述代码 通过 一个bootstrap 绑定 group(线程模型),channel(io事件的发现方式),option(网络长连接) 以及 childHandler

childHandler 逻辑

childHandler 一般来说 里面会设置:空闲连接处理,编解码,半包处理 ,看多了其他中间件以后你会发现 都是差不多的。

image.png

重点 我们需要关注

  • 编解码如何实现
  • 自定义的 handler 逻辑

值得说的 是 里面的编解码工具类 使用的 是 ProtostuffUtils 序列化极快 可以直接复制到其他项目中使用

ProtostuffUtils
package com.jd.platform.hotkey.common.tool;

import com.jd.platform.hotkey.common.convert.LongAdderDelegate;
import io.protostuff.*;
import io.protostuff.runtime.DefaultIdStrategy;
import io.protostuff.runtime.RuntimeEnv;
import io.protostuff.runtime.RuntimeSchema;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 *
 * @author wuweifeng10
 * @date 2020/7/18
 **/
public class ProtostuffUtils {

    private final static DefaultIdStrategy idStrategy = ((DefaultIdStrategy) RuntimeEnv.ID_STRATEGY);

    /**
     * 避免每次序列化都重新申请Buffer空间
     * 这句话在实际生产上没有意义,耗时减少的极小,但高并发下,如果还用这个buffer,会报异常说buffer还没清空,就又被使用了
     */
//    private static LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
    /**
     * 缓存Schema
     */
    private static Map<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap<>();

    static {
        idStrategy.registerDelegate(new LongAdderDelegate());
    }

    /**
     * 序列化方法,把指定对象序列化成字节数组
     *
     * @param obj
     * @param <T>
     * @return
     */
    @SuppressWarnings("unchecked")
    public static <T> byte[] serialize(T obj) {
        Class<T> clazz = (Class<T>) obj.getClass();
        Schema<T> schema = getSchema(clazz);
        LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
        byte[] data;
        try {
            data = ProtobufIOUtil.toByteArray(obj, schema, buffer);
//            data = ProtostuffIOUtil.toByteArray(obj, schema, buffer);
        } finally {
            buffer.clear();
        }

        return data;
    }

    /**
     * 反序列化方法,将字节数组反序列化成指定Class类型
     */
    public static <T> T deserialize(byte[] data, Class<T> clazz) {
        Schema<T> schema = getSchema(clazz);
        T obj = schema.newMessage();
        ProtobufIOUtil.mergeFrom(data, obj, schema);
//        ProtostuffIOUtil.mergeFrom(data, obj, schema);
        return obj;
    }

    @SuppressWarnings("unchecked")
    private static <T> Schema<T> getSchema(Class<T> clazz) {
        Schema<T> schema = (Schema<T>) schemaCache.get(clazz);
        if (Objects.isNull(schema)) {
            //这个schema通过RuntimeSchema进行懒创建并缓存
            //所以可以一直调用RuntimeSchema.getSchema(),这个方法是线程安全的
            schema = RuntimeSchema.getSchema(clazz, idStrategy);
            if (Objects.nonNull(schema)) {
                schemaCache.put(clazz, schema);
            }
        }

        return schema;
    }
}
<protostuff.version>1.7.4</protostuff.version>

<dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-core</artifactId>
    <version>${protostuff.version}</version>
</dependency>

<dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-runtime</artifactId>
    <version>${protostuff.version}</version>
</dependency>

服务端-核心处理类


@Resource
private IClientChangeListener iClientChangeListener;

//如果注入list集合,则元素是该list泛型的所有实现类
// (这里的list元素顺序,我们可以在springbean上加上@Order控制顺序)
@Resource
private List<INettyMsgFilter> messageFilters;

IClientChangeListener

/**
 * 发现新连接
 */
void newClient(String appName, String channelId, ChannelHandlerContext ctx);

/**
 * 客户端掉线
 */
void  loseClient(ChannelHandlerContext ctx);

image.png

说明 :维护一个 客户端 集合

ClientInfoHolder
public static List<AppInfo> apps = new ArrayList<>();

INettyMsgFilter

AppNameFilter:客户端上报自己的appName

image.png

HeartBeatFilter:心跳包处理

image.png

public enum MessageType {
   APP_NAME((byte) 1),
   REQUEST_NEW_KEY((byte) 2),
   RESPONSE_NEW_KEY((byte) 3),
   REQUEST_HIT_COUNT((byte) 7), //命中率
   REQUEST_HOT_KEY((byte) 8), //热key,worker->dashboard
   PING((byte) 4), PONG((byte) 5),
   EMPTY((byte) 6);
HotKeyFilter:热key消息,包括从netty来的和mq来的。收到消息,都发到队列去

image.png

  • totalReceiveKeyCount全局计数

  • 放到200万长度的队列中

image.png

  • 消费逻辑

image.png

多线程的提交消费任务

image.png

image.png

最终使用 com.jd.platform.hotkey.worker.keylistener.KeyListener#newKey 进行key 热度计算,使用的滑动窗口算法

image.png

最终 滑动窗口 放在了 CaffeineCacheHolder中

image.png

规则放在 KeyRuleHolder中

image.png

hotkey 放在com.jd.platform.hotkey.worker.keylistener.KeyListener#hotCache中

image.png

newkey代码
//cache里的key
String key = buildKey(hotKeyModel);
//判断是不是刚热不久
Object o = hotCache.getIfPresent(key);
if (o != null) {
    return;
}

//********** watch here ************//
//该方法会被InitConstant.threadCount个线程同时调用,存在多线程问题
//下面的那句addCount是加了锁的,代表给Key累加数量时是原子性的,不会发生多加、少加的情况,到了设定的阈值一定会hot
//譬如阈值是2,如果多个线程累加,在没hot前,hot的状态肯定是对的,譬如thread1 加1,thread2加1,那么thread2会hot返回true,开启推送
//但是极端情况下,譬如阈值是10,当前是9,thread1走到这里时,加1,返回true,thread2也走到这里,加1,此时是11,返回true,问题来了
//该key会走下面的else两次,也就是2次推送。
//所以出现问题的原因是hotCache.getIfPresent(key)这一句在并发情况下,没return掉,放了两个key+1到addCount这一步时,会有问题
//测试代码在TestBlockQueue类,直接运行可以看到会同时hot

//那么该问题用解决吗,NO,不需要解决,1 首先要发生的条件极其苛刻,很难触发,以京东这样高的并发量,线上我也没见过触发连续2次推送同一个key的
//2 即便触发了,后果也是可以接受的,2次推送而已,毫无影响,客户端无感知。但是如果非要解决,就要对slidingWindow实例加锁了,必然有一些开销

//所以只要保证key数量不多计算就可以,少计算了没事。因为热key必然频率高,漏计几次没事。但非热key,多计算了,被干成了热key就不对了
SlidingWindow slidingWindow = checkWindow(hotKeyModel, key);
//看看hot没
boolean hot = slidingWindow.addCount(hotKeyModel.getCount());

if (!hot) {
    //如果没hot,重新put,cache会自动刷新过期时间
    CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).put(key, slidingWindow);
} else {
    hotCache.put(key, 1);

    //删掉该key
    CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);

    //开启推送
    hotKeyModel.setCreateTime(SystemClock.now());

    //当开关打开时,打印日志。大促时关闭日志,就不打印了
    if (EtcdStarter.LOGGER_ON) {
        logger.info(NEW_KEY_EVENT + hotKeyModel.getKey());
    }

    //分别推送到各client和  dashbored  etcd, 推送到mq的
    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<? super String, ?>) s -> {
        //是个新key,获取它的规则
        KeyRule keyRule = KeyRuleHolder.getRuleByAppAndKey(hotKeyModel);
        return new SlidingWindow(keyRule.getInterval(), keyRule.getThreshold());
    });
}
KeyCounterFilter:对热key访问次数和总访问次数进行累计

image.png

最终使用 com.jd.platform.hotkey.worker.counter.CounterConsumer#beginConsume 进行点击次数计算, 为了etcd展示

image.png

说明: 都是使用 单生产者 + 单/多消费者 实现异步 并发消费处理请求的模式,hotKey的计算使用的是 cpu核数的消费者并发消费,记得考虑 并发的问题

服务端-核心算法-滑动窗口

参考 juejin.cn/spost/72477…

服务端-推送

涉及代码

//分别推送到各client和  dashbored  etcd, 推送到mq的
    for (IPusher pusher : iPushers) {
        pusher.push(hotKeyModel);
    }
package com.jd.platform.hotkey.worker.netty.pusher;

import cn.hutool.core.collection.CollectionUtil;
import com.google.common.collect.Queues;
import com.jd.platform.hotkey.common.model.HotKeyModel;
import com.jd.platform.hotkey.common.tool.FastJsonUtils;
import com.jd.platform.hotkey.worker.netty.dashboard.DashboardHolder;
import com.jd.platform.hotkey.worker.tool.AsyncPool;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

/**
 * 将热key推送到dashboard供入库
 * @author wuweifeng
 * @version 1.0
 * @date 2020-08-31
 */
@Component
public class DashboardPusher implements IPusher {
    /**
     * 热key集中营
     */
    private static LinkedBlockingQueue<HotKeyModel> hotKeyStoreQueue = new LinkedBlockingQueue<>();

    @Override
    public void push(HotKeyModel model) {
        hotKeyStoreQueue.offer(model);
    }

    @Override
    public void remove(HotKeyModel model) {

    }

    @PostConstruct
    public void uploadToDashboard() {
        AsyncPool.asyncDo(() -> {
            while (true) {
                try {
                    //要么key达到1千个,要么达到1秒,就汇总上报给etcd一次
                    List<HotKeyModel> tempModels = new ArrayList<>();
                    Queues.drain(hotKeyStoreQueue, tempModels, 1000, 1, TimeUnit.SECONDS);
                    if (CollectionUtil.isEmpty(tempModels)) {
                        continue;
                    }

                    //将热key推到dashboard
                    DashboardHolder.flushToDashboard(FastJsonUtils.convertObjectToJSON(tempModels));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }
}
package com.jd.platform.hotkey.worker.netty.pusher;

import cn.hutool.core.collection.CollectionUtil;
import com.google.common.collect.Queues;
import com.jd.platform.hotkey.common.model.HotKeyModel;
import com.jd.platform.hotkey.common.model.HotKeyMsg;
import com.jd.platform.hotkey.common.model.typeenum.MessageType;
import com.jd.platform.hotkey.worker.model.AppInfo;
import com.jd.platform.hotkey.worker.netty.holder.ClientInfoHolder;
import com.jd.platform.hotkey.worker.tool.AsyncPool;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

/**
 * 推送到各客户端服务器
 *
 * @author wuweifeng wrote on 2020-02-24
 * @version 1.0
 */
@Component
public class AppServerPusher implements IPusher {
    /**
     * 热key集中营
     */
    private static LinkedBlockingQueue<HotKeyModel> hotKeyStoreQueue = new LinkedBlockingQueue<>();

    /**
     * 给客户端推key信息
     */
    @Override
    public void push(HotKeyModel model) {
        hotKeyStoreQueue.offer(model);
    }

    @Override
    public void remove(HotKeyModel model) {
        push(model);
    }

    /**
     * 和dashboard那边的推送主要区别在于,给app推送每10ms一次,dashboard那边1s一次
     */
    @PostConstruct
    public void batchPushToClient() {
        AsyncPool.asyncDo(() -> {
            while (true) {
                try {
                    List<HotKeyModel> tempModels = new ArrayList<>();
                    //每10ms推送一次
                    Queues.drain(hotKeyStoreQueue, tempModels, 10, 10, TimeUnit.MILLISECONDS);
                    if (CollectionUtil.isEmpty(tempModels)) {
                        continue;
                    }

                    Map<String, List<HotKeyModel>> allAppHotKeyModels = new HashMap<>();

                    //拆分出每个app的热key集合,按app分堆
                    for (HotKeyModel hotKeyModel : tempModels) {
                        List<HotKeyModel> oneAppModels = allAppHotKeyModels.computeIfAbsent(hotKeyModel.getAppName(), (key) -> new ArrayList<>());
                        oneAppModels.add(hotKeyModel);
                    }

                    //遍历所有app,进行推送
                    for (AppInfo appInfo : ClientInfoHolder.apps) {
                        List<HotKeyModel> list = allAppHotKeyModels.get(appInfo.getAppName());
                        if (CollectionUtil.isEmpty(list)) {
                            continue;
                        }

                        HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.RESPONSE_NEW_KEY);
                        hotKeyMsg.setHotKeyModels(list);

                        //整个app全部发送
                        appInfo.groupPush(hotKeyMsg);
                    }

                    allAppHotKeyModels = null;

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

高并发处理模式

  • 队列缓冲+异步处理的模式
  • 整个hotkey 中处处可以看到 这种设计模式 ,也是我们处理高并发场景值得参考的设计模式之一,除此之外客户端 还有个一个读写队列的模式 也值得借鉴
  • Queues.drain api 也可以批量高效的进行处理队列

推送主要包括 app推送和 etcd 推送

app推送

按照组推送,重点是 ClientInfoHolder借助 appNameFilter进行构建 重点方法: com.jd.platform.hotkey.worker.netty.client.ClientChangeListener#newClient

@Override
public synchronized void newClient(String appName, String ip, ChannelHandlerContext ctx) {
    logger.info(NEW_CLIENT);

    boolean appExist = false;
    for (AppInfo appInfo : ClientInfoHolder.apps) {
        if (appName.equals(appInfo.getAppName())) {
            appExist = true;
            appInfo.add(ctx);
            break;
        }
    }
    if (!appExist) {
        AppInfo appInfo = new AppInfo(appName);
        ClientInfoHolder.apps.add(appInfo);
        //客户端通道,加入到同名的 channelgroup
        appInfo.add(ctx);
    }

    logger.info(NEW_CLIENT_JOIN);
}

etcd推送

  • 主要应该关注 etcd的channel 是怎么拿到的
  • 通过一个定时任务去做

image.png

com.jd.platform.hotkey.worker.starters.EtcdStarter#fetchDashboardIp

总结

  • 该说不说 hotkey 服务端 代码确实有很多封装好的工具类,设计模式可以拿来直接使用

  • 序列话工具类 ProtostuffUtils,MsgDecoder,MsgEncoder

  • 滑动窗口设计 SlidingWindow

  • 优雅的提交异步任务 AsyncPool.asyncDo

  • 责任链条设计 List messageFilters

  • 队列缓冲+异步处理的模式 进行 热度计算和count计算以及结果推送

  • neety组件的封装和交互 基本也是可以拿到用的

  • 咖啡因的封装 CaffeineCacheHolder

api使用上

  • Queues.drain
  • computeIfAbsent
//拆分出每个app的热key集合,按app分堆

Map<String, List<HotKeyModel>> allAppHotKeyModels = new HashMap<>();
for (HotKeyModel hotKeyModel : tempModels) {
     List<HotKeyModel> oneAppModels = allAppHotKeyModels.computeIfAbsent(hotKeyModel.getAppName(), (key) -> new ArrayList<>());
     oneAppModels.add(hotKeyModel);
}
                    
  • 重意不重形,当然也不是生搬硬套。