Netty负载均衡方案
面临的问题
- 如何解决车载终端接入增多问题,如何解决海量的设备接入与实时性的要求
- 后端服务如何高可用高性能
- 如何记录车载终端和后台服务器的会话信息
技术解决方案
- 车载终端会随着公交车的数量的增长而增长,尤其是公交车等公共交通工具,这个数量将是海量的,我们需要同时处理众多的车载终端的连接,然而每个机器的句柄(Linux环境下叫文件描述符(fd),是操作系统内核描述文件地址,数据区等一些属性的数字)是有上限的,为此需要增加服务器的数量,才能应对海量的接入,需要做一个集群方案。同时为了降低数据的时延,我们需要借用Kafka(其他消息中间件的选型我们这里不探讨,主要考虑到该中间件的选择需要更好的和大数据模块整合,同时也需要高吞吐,低延迟,社区活跃的中间件)做异步处理,并加入线程池等更多异步处理的手段,来提高系统处理数据包的效率。
- 后端服务需要负载均衡来降低服务器的压力,结合Netty自带的监听channel管道变化的方法channelInactive和channelActive,统计每台服务器的连接数并set入Redis中,每次登录成功后,只需要返回最小连接数的那台服务器ip和端口给车载终端连接,这样就可解决海量接入问题。
- 设计基本的登录手段,根据协议现有的平台登入、车辆登入、车辆登出过程,设计登录登出,参考Session的设计方案,需要加入sessionID来记录会话信息,准确应答。
车载终端登录
平台登入:首先系统初始化的时候,需要加载一些初始化信息,包含车载终端的登录账户、公交车的车体信息、车牌、vin码等信息。其次车载终端上电启动的时候,需要去admin管理后台获取配置,配置内容包含加密的账号密码,波特率,发送间隔等配置信息); 车载终端获取配置信息后,解密出平台登入账号密码,携带着账号密码发送报文,后端服务查询Redis,匹配成功(除了校验账号密码,还要校验是否有该台车的vin码,做唯一性校验)后,信任此设备,存入sessionId到Redis,并返回系统生成的sessionId给车载终端。
车辆登入:车载终端接收到该sessionId后,携带该sessionId做车辆登入,服务器需要把车载终端的sessionId与Redis的sessionId比对,校验成功后,还需要放入到本地缓存(本地化缓存使用谷歌guava组件里的cache)。
看到这里,读者可能有疑问,为什么需要平台登入与车辆登入两个过程,直接登入不行?其实,这也是受限GB32960协议,这是国标规定要有的过程,其次,为了减少网络原因导致车载终端需要重新登录,影响正常的数据上报,例如这样的场景:车载终端在正常上报数据的过程中,只需要校验本地缓存是否有该vin码和对应的sessionId,有则证明他已经经过了平台登入和车辆登入两个过程,而不需要从远程缓存中获取,提高数据上报的效率,同时也可以增加可靠性,在本地缓存没有找到sessionId的过程中,可以从远程缓存中获取。做两重保障。
负载均衡实现方案
从如下图的设计方案,我们可以看出,Redis类似一个注册中心,每次需要连接接入的时候,都会从redis拿到所有服务器当前连接数的大小,再降序排序,取最后一条服务器信息(连接数最少),再把服务器信息(ip,端口)组装成协议报文,返回给车载终端,车载终端获取到该报文,携带sessionID访问该服务器
值得一提的是,上图引入的SLB(负载均衡器),这是防止海量接入导致某台服务器崩溃的防范措施,试想一下,若第一台接入的服务器都挂了,谈何取“最小连接数”?SLB可以和具体的云服务商(电信云,阿里云,腾讯云等)购买,或者有具体的硬件设备F5(很贵),SLB需要知道后台接入服务器的socket信息,以便轮训转发请求。
具体代码:
我们从平台登入的Handler中以最小连接数方法为例作为讲解:
/**
* 获得连接数最小的服务器redis中的key值
*
* @return String
*/
private String getMinServer() {
//从redis中,获取所有集群服务器连接信息(ip和端口,连接数),存入的时候存入了平台登入时间
Map<String, ConnectInfo> map = serverStateCache.getAllConnectNum();
//所有服务器连接数的有序集合
List<ConnectInfo> connectInfoBeanList = new ArrayList<>();
for (ConnectInfo connectInfoBean : map.values()) {
//毫秒差,当前时间减去平台登入时间,得到最近一次更新时间
long timeDifference = System.currentTimeMillis() - connectInfoBean.getDate().getTime();
//最近一次更新连接数时间小于三秒,更新超时
if (timeDifference < MILLISECOND) {
connectInfoBeanList.add(connectInfoBean);
} else {
//更新超时,删除该服务器
serverStateCache.deleteServer(connectInfoBean.getAddress().replace(":", "-"));
}
}
//若没有一个服务器可用,返回当前服务器信息,确保可用
if (connectInfoBeanList.isEmpty()) {
return environment.getProperty("netty.ip") + ":" + nettyConfig.getServerPort();
}
//connectInfoBean实现了Comparable接口,以连接数判断两个对象的大小关系,默认从小到大排序
Collections.sort(connectInfoBeanList);
return connectInfoBeanList.get(0).getAddress();
}
对连接数+1和-1操作方法都是同步的,本项目从激活通道开始,通道数+1,后续无论是车辆登入还是数据上报,只要断开连接,都视为-1,本文专门设置了一个WatchVehicleStateHandler类来监听管道断开操作
@LogDebug
@Slf4j
@ChannelHandler.Sharable
@Component
public class WatchVehicleStateHandler extends SimpleChannelInboundHandler<Object> {
private final SessionCache sessionCache;
private final KafkaSender vehicleVinSender;
private final ConnectService connectService;
public WatchVehicleStateHandler(SessionCache sessionCache, KafkaSender vehicleVinSender, ConnectService connectService) {
this.sessionCache = sessionCache;
this.vehicleVinSender = vehicleVinSender;
this.connectService = connectService;
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, Object o) throws Exception {
}
/**
* 通道断开捕获
*
* @param ctx 通道上下文
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) {
//从通道中获取vin码,并往kafka下线车辆vin码
Attribute<String> vinAttr = ctx.channel().attr(AttributeKey.valueOf(VehicleOnLineEnum.VEHICLE_VIN.getStrValue()));
String vin = vinAttr.get();
//获取为null,不发送
if (vin!=null){
VehicleOnLine vehicleOnLine = new VehicleOnLine();
vehicleOnLine.setVin(vin)
.setConnectStatus(VehicleOnLineEnum.VEHICLE_OFF_LINE.getIntValue());
vehicleVinSender.sendVehicleOnLine(vehicleOnLine);
}
//关闭通道,连接数-1
connectService.subtractVehicleConnect();
log.debug("VehicleLoginHandler channelInactive ...观察者通道监听-通道断开:"+vin);
sessionCache.deleteVin(vin);
}
}
可以看出 ,该WatchVehicleStateHandler的泛型是Object,即在channelPipeline中,任何发包都要经过该handler,为了需要把当前服务器的连接数更新到redis,还需要定时任务job,我们设置的周期是每隔0.5s把当前服务器的连接数set入到redis中,此时读者可能有疑问,当集群的时候,每台服务器都有定时任务,又没有类似xxl-job分布式任务调度,怎么能不加分布式锁呢?不会有并发修改redis的情况嘛?其实这个我们也讨论过,确实在高并发环境下,同时启定时任务去做同一件事情,会有冲突,要么加入分布式调度,让他们错开时间执行,要么加入分布式锁,只允许持有分布式锁的服务器写入,但是我们仔细一看,我们存入redis的key是一个hash结构,一个redis的hash结构,分key和hashkey、value,虽然key一样,但hashkey是不一样的,也就是说我们取到的第一个大对象是一样的,但内部的不一样,那么大家都拿到这个大对象,但是各自写入的却不是同一个对象,就如下图的情况:
其实加入分布式锁也更加安全,但是上锁和没上锁的性能可差太多了,不然cas无锁的概念是怎么诞生的呢?
车载终端升级方案
对于车载终端设备我们不知道会在什么情况出现问题,因此需要对车载终端的嵌入式软件程序升级,但是基于业务的需要,我们可能有不同的车载终端硬件合作方,我们不能直接把车载终端的软件程序交由供应商升级,很危险,也不安全,一旦升级错误,后果不堪设想,为此我们借鉴灰度发布的思想,先局部测试升级效果,再推广所有设备,我们通过版本标识和vin码来精准控制每一台设备的升级,对于不同的合作方,我们后台需要有不同的权限。每次车载终端上电就先检查当前设备版本和服务器是否相同(平台登入之前),相同返回空的升级地址url,不相同的版本则触发一次升级,升级由合作方提前上传升级文件(bin文件),需要对该文件做唯一性校验(md5加密,文件校验过程不便探讨),以下是对该升级过程的一些简单说明:
总结
通过该项目,加深了对netty的了解,也实现了基于netty的负载均衡方案,也感谢项目中同事教学,目前该项目单台服务器(8核16G,电信云服务器)做了简单的压力测试,由于测试机器有限,只测试到了单台5w的QPS。
参考资料
- 《Netty权威指南》第二版 李林锋著
- Stevens W R, Fenner B, Rudoff A M. UNIX network programming[M]. Addison-Wesley Professional, 2004.
- http://ifeve.com//谈谈netty的线程模型
- 计算机网络, 谢希仁
- JamesF.Kurose, KeithW.Ross, 库罗斯, 等. 计算机网络: 自顶向下方法 [M]. 机械工业出版社, 2014.
- W.RichardStevens. TCP/IP 详解. 卷 1, 协议 [M]. 机械工业出版社, 2006.