服务端-源码 基础结构
服务端概览
涉及到的启动类
com.jd.platform.hotkey.worker.starters.NodesServerStarter
com.jd.platform.hotkey.worker.netty.server.NodesServer
借助
@Component
@PostConstruct
AsyncPool.asyncDo
启动neety服务, 服务端和客户端使用的neety链接,neety网络传输好处 自不必多说,自行搜索
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 一般来说 里面会设置:空闲连接处理,编解码,半包处理 ,看多了其他中间件以后你会发现 都是差不多的。
重点 我们需要关注
- 编解码如何实现
- 自定义的 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);
说明 :维护一个 客户端 集合
ClientInfoHolder
public static List<AppInfo> apps = new ArrayList<>();
INettyMsgFilter
AppNameFilter:客户端上报自己的appName
HeartBeatFilter:心跳包处理
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来的。收到消息,都发到队列去
-
totalReceiveKeyCount全局计数
-
放到200万长度的队列中
- 消费逻辑
多线程的提交消费任务
最终使用 com.jd.platform.hotkey.worker.keylistener.KeyListener#newKey 进行key 热度计算,使用的滑动窗口算法
最终 滑动窗口 放在了 CaffeineCacheHolder中
规则放在 KeyRuleHolder中
hotkey 放在com.jd.platform.hotkey.worker.keylistener.KeyListener#hotCache中
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访问次数和总访问次数进行累计
最终使用 com.jd.platform.hotkey.worker.counter.CounterConsumer#beginConsume 进行点击次数计算, 为了etcd展示
说明: 都是使用 单生产者 + 单/多消费者 实现异步 并发消费处理请求的模式,hotKey的计算使用的是 cpu核数的消费者并发消费,记得考虑 并发的问题
服务端-核心算法-滑动窗口
服务端-推送
涉及代码
//分别推送到各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 是怎么拿到的
- 通过一个定时任务去做
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);
}
- 重意不重形,当然也不是生搬硬套。