这不是一个讲概念的专栏,而且我也不擅长讲概念,每一篇文章都是一个故事,我希望你可以通过这些故事了解我当时在实际工作中遇到问题和背后的思考,架构设计是种经验,我有幸参与到多个亿级系统的架构设计中,有所收获的同时也希望把这些收获分享与大家。
2015年,我在实现了 APP 服务端的平台化转型之后,进一步开始了对服务端架构的升级改造,故事由此继续。
承接上篇,API 网关经过两年的发展,逐渐演进拆分成了面向 ISV 的 API 开放网关和面向客户端的 API 服务网关,其中面向 ISV 的 API 开放网关延续了前两篇已经介绍过的技术栈,后期则主要在高并发、高可用、高性能上进行技术攻坚;而面向客户端的 API 服务网关则迎来了新一轮的蜕变。此时的 API 网关主要还是基于 HTTP 实现的,由于 HTTP 是无状态的,这使得服务端对客户端的中心管控问题就显得愈加凸显,尤为严重的是在上文提到的一些事故。由此构建有状态的 TCP 长连接 API 网关就成为解决该问题的一颗银弹,可喜的是,TCP 网关的双向通信不仅可以保持客户端与服务端的会话状态,实现有效管控,更在未来为重构消息 PUSH 系统提供了技术底座。
在2016年整体上线了基于 Netty4.x + Protobuf3.x 的 TCP 长连接网关,实现了支持 APP 上下行通信的高可用、高性能、高稳定的 TCP 网关,而其性能也较 HTTP 网关提升10倍以上,稳定性也远远高于 HTTP 网关。
我们先来谈谈 Netty,谈起 Netty 现在大家都很熟悉了,它在很多中间件和平台架构里都有扮演很关键的角色,我最早了解到 Netty 是在阅读 dubbo 源码时。
Netty 是一个可用于快速开发可维护的高性能协议服务器和客户端的异步的事件驱动网络应用框架(引自 netty.io),就我个人理解,它在实现基于 TCP NIO 长链接的通信领域可以提供强大的框架支持。所以,了解 Netty 是非常大有裨益的,推荐书籍《Netty in Action》(Norman Maurer)。本文不会对 Netty 技术做深入的阐述,有兴趣的同学也可以订阅我的专栏《Netty 核心源码解读》。
言归正传,构建基于 Netty 实现 TCP 网关的第一步,就是 Netty 版本的选型问题,当时调研了 3.x 的 jboss 和 4.x 的改进版本,包括 Mina 的技术,最终综合考虑选择了 Netty 4.x 的主流版本。其次在架构结构的设计上,由于 Netty 本身就是一个容器服务,这就与 HTTP 网关需要 Nginx + Tomcat 的部署架构有所不同,APP 客户端可以通过域名和端口直接访问到 Netty 服务,也基于此,通过不同域名对应各地域的 VIP,VIP 发布在 LVS,再由 LVS 将请求转发给后端的 HAProxy,最后由 HAProxy 把请求转发给后端的 TCP Netty 网关上,部署结构如下图:
期间,遇到诸多技术上的小问题,尤为烦扰的就是长连接的保持问题,因为网络问题导致 TCP 长连接很容易闪断,这里首先跟网络部协同优化了很多细节,包括对 keepalive 参数设置和 gzip 的数据压缩的调优等,其次是在 TCP 网关的 Session 设计和弱网闪断重连等多个技术细节点上做了很多的创新,最终实现了百万级 TCP 长连接的稳定服务。
当然,提供一个稳定的 TCP 长连接服务更离不开对通信协议的设计考量,之前 HTTP 网关是基于 JSON 进行数据传输的,JSON 是 key-value 的键值对通信协议,生成的报文会很大,所以传输性能会有所影响。考虑到报文的传输性能,构建 TCP 网关的通信协议选型最后采用了 Protocol Buffer,一是 Protobuf 协议天然支持 Java、Objective-C 和 C++ 等语言,做到了语言无关、平台无关;二是 Protobuf 协议数据压缩比很高,通常一个整型要占8比特位,通过 Protobuf 可以压缩到2比特位进行通信传输,提升数据传输效率。
因为目前 API 网关已经支持了泛化调用,泛化可以理解为通过配置和协议转化直接调用后端服务接口,所以,此时也就不需要每次有新需求,都要在网关增加 Protocol Buffer 对象定义新接口。数据传输的本质都是字节流的序列化和反序列化,所以 APP 的数据流可以以二进制流的方式在 TCP 网关直接反序列化为后端服务的接口对象,完成整条通信链路 API 服务的请求调度。
接下来,我们来谈谈业务线程池,一个疑问:为什么要有业务线程池?其实,我在初期构建 TCP 网关也是没有业务线程池的,直到一次事件后才加了单独的业务线程池。其实,逻辑很简单,我们知道通过 Netty 的 ChannlRead 方法就能方便的获取到通信的入站(Inbound)和出站(Outbound)数据,如果在 ChannelRead 方法里直接调用后端服务请求,就有可能由于后端服务响应 RT 高而阻塞住 Netty 的 IO 线程池组。为了说清楚这其中的原由,我先简单的介绍下 Netty 的线程池模型。
Netty 是 Reactor 模式的一个实现,Reactor 是一种经典的线程模型,Reactor 模型分为单线程模型、多线程模型和主从多线程模型,三种常用模式。
Reactor 单线程模型
所谓 Reactor 单线程模型,指的是客户端的所有操作都是在一个线程上完成的,包括请求的连接和读写操作。
在 Reactor 单线程模型中,由 Acceptor 负责监听客户端 accept 事件,当有客户端连接后,服务端创建对应的 Channel,并注册到 Reactor 上,进行读写事件的监听,当有事件触发后,事件会触发 Reactor 进行相应的读写处理,Reactor 会创建独立的 Handler 对请求数据进行处理,其中 Handler 是具体处理事件的处理器。
Reactor 多线程模型
在 Reactor 单线程模型中,虽然 Reactor 可以支持多个客户端的同时请求,但如果 Handler 出现阻塞,就会造成客户端请求被积压,严重的会导致整个服务不能接收客户端的新请求。所以,在这种线程模型下,将接收连接和处理请求分成两个部分,通过引入线程池的方式,将建立连接的请求先放到线程池中,一个线程负责一个或多个 Channel 的请求处理,这样客户端的请求就不会出现阻塞。
所谓 Reactor 多线程模型,指的是由一个专门的线程负责 Acceptor 处理连接请求,由一个线程池负责多线程处理请求的读写操作。
Reactor 主从多线程模型
在 Reactor 多线程模型中,由于 Reactor 处于一个承上启下的位置,需要处理 Acceptor 请求,并分发给 Handler 进行处理,所以,当客户端的请求进一步增加的时候,Reactor 就会出现瓶颈。为了解决 Acceptor 一个线程可能存在的性能问题,通过将 Reactor 分为两部分:Main Reactor 和 Sub Reactor。这种拆分之后,当 Acceptor 接收到客户端的请求之后,会先创建 Channel 并注册到 Main Reactor 的线程池上,由 Main Reactor 负责处理连接请求,当连接正式建立后,Main Reactor 会将 Channel 移除并重新注册到 Sub Reactor 的线程池上,由 Sub Reactor 负责处理请求的读写操作。
所谓 Reactor 主从多线程模型,指的是由一个独立的线程池负责 Acceptor 处理连接请求,由另一个独立的线程池负责处理请求的读写操作。
最后,我说一下我自己对 Reactor 的整体理解,我认为 Reactor 是一种设计模式,因主要应用于服务端的网络框架的线程池模型的实现,所以,很多时候又称为了 Reactor 线程模型。我理解,多线程 Reactor 模型通过使用 Acceptor 处理就绪的 OP_ACCEPT 事件,为请求连接创建 SocketChannel,并将该 Channel 注册到事件监听器,监听 OP_READ / OP_WRITE 等事件,Acceptor 是 Reactor 非常重要的模块之一;当事件监听器获取到就绪的读写事件,就会进行事件的分发,由 Reactor 创建 Handler 进行多线程的并发处理 IO 读写。
Netty 线程模型
在了解了线程模型及 Reactor 线程模型之后,那么 Netty 是哪种模型呢?Netty 的线程模型很像是 Reactor 主从多线程模型,但有所不同的是,Netty 没有使用线程池并行的处理请求,而是由多个 Reactor 组成一个 Reactor Group,请求在每个 Reactor 中串行的被处理执行。
在 Netty 中主要有 Boss Reactor 和 Worker Reactor,Boss Reactor 负责连接请求,Worker Reactor 负责处理请求的读写。在实现上,Reactor 线程模型对应的实现类是 EventLoop,常用的是 NioEventLoop,一个 NioEventLoop 聚合了一个多路复用器 Selector。
一个 EventLoop 可以处理一个或多个连接请求,连接被封装成 Channel,一个 EventLoop 始终由一个线程驱动,所以一个 Channel 内所有的请求和事件都是由 EventLoop 的这个线程来处理。一个或多个 EventLoop 组成一个 EventLoopGroup,一个 EventLoopGroup 相当于 Reactor 的线程池。从线程模型的角度理解,一个 EventLoopGroup 可以类似于一个 ExecutorService,一个 EventLoop 可以理解成一个线程。
TCP 网关线程模型
在实现上,TCP 网关共使用了三组线程池,分别是的 BossGroup、WokerGroup 和 ExecutorGroup,三组线程池分别实现了 TCP 长连接的请求接入、IO 读写和业务操作。因为 TCP 网关采用 Netty 进行构建,所以,BossGroup 和 WorkerGrlup 采用的是 Netty 的线程池 NioEventLoopGroup。由 BossGroup 负责处理客户端的 TCP 连接请求,WorkerGroup 负责处理 I/O 读写操作、执行系统 Task 和定时任务。
ExecutorGroup 是 TCP 网关实现的线程池,负责处理 TCP 网关业务及将请求转发给后端服务等业务操作。
我附上一张 TCP 网关线程模型的整体架构图,一起来看下。
在实现上,设置 bossGroup 线程数为1,因为 TCP 网关对外监听的是一个端口,所以使用一个线程处理。设置 workerGroup 线程数为 CPU 核数乘以2,比如8核 CPU 会创建16个线程,但注意如果容器是 Docker,这个值使用下面的方法就不准了。原则是每个 CPU 绑定的 worker 线程数不用设置过多,避免不必要的 CPU 线程切换,默认值是 CPU 核数乘以2。
int GROUP_SIZE = 1;
int THREAD_SIZE = Runtime.getRuntime().availableProcessors() * 2;
EventLoopGroup bossGroup = new NioEventLoopGroup(GROUP_SIZE);
EventLoopGroup workerGroup = new NioEventLoopGroup(THREAD_SIZE);
public void init() throws Exception {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ServerChannelInitializer());
bootstrap.bind(port).sync();
}
Netty 不是已经提供了线程池的实现,为什么还需要实现使用 ExecutorGroup?这是因为,Netty 的两个线程池组 BossGroup 和 WorkerGroup 是实现了线程隔离的,但没有对 IO 线程和业务线线程进行隔离。在这种模式下,每个 EventLoop 负责处理的 IO 操作与业务操作是在同一个线程里执行,如果业务操作出现了阻塞,就会影响 EvenLoop 下所分配的所有 Channel,因为 EventLoop 是通过多路复用器 Selector 获取就绪事件,并串行的执行处理请求读写事件。
下面,我就说下上文提到的事件,在使用 Netty 构建 TCP 网关时,每个客户端会与服务端建立一个 TCP 长连接,一个长连接对应一个 Channel,而这个 Channel 是由一个 EventLoop 进行事件监听和处理的,客户端当时连续向 TCP 网关请求多次调用,正巧第一个请求事件在处理时阻塞了,这就导致了 EventLoop 中的就绪事件出现了积压,从而造成了客户端请求无响应的现象,或是等了半天又一下子返回所有请求结果。
所以,我通过引入线程池的方式,对业务处理从 WorkerGroup 进行了剥离。我们知道, Netty 通过 ChannelPipeline 管道技术处理 Handler,在处理我自己的 TcpHandler 的 channelRead 方法里,将请求放入一个线程池里进行异步的处理,这样就不会出现 EventLoop 的事件阻塞。在实践中,引入线程池之后,客户端请求无响应的问题就基本得到解决。
.Sharable
public class TcpServerHandler extends ChannelInboundHandlerAdapter {
private ExecutorService threadPool
= new ThreadPoolExecutor(minPoolSize,
maxPoolSize,
aliveTime,
TimeUnit.MILLISECONDS,
blockQueue,
threadFactory,
rejectedHandler);
public void channelRead(ChannelHandlerContext ctx, Object o)
throws Exception {
/* */
Task task = new Task(this, ctx, o);
threadPool.execute(task);
/* */
}
}
其实,Netty 里也提供了对业务线程池的支持,我们看下 Netty 提供的方法:
ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler);
Netty 使用 add 方法向 ChannelPipeline 中添加 ChannelHandler,ChannelPipeline 会从 EventExecutorGroup 中选择一个 EventExecutor 分配给这个 ChannelHandler。上述方法中,传入 group 参数,则 ChannelPipeline 会从 group 中选择一个 EventExecutor 分配给这个 ChannelHandler,否则,ChannelPipeline 会从 WorkerGroup 中的 EventExecutorGroup 进行选择,这也就会出现业务操作与 WorkerGroup 的 IO 操作共享线程池的情况。
但是,Netty 提供的这种方式和我自己实现线程池的方式,哪种方式好呢?通过分析,Netty 提供的方法与使用自定义线程池有所不同,ThreadPool 采用的是多线程并行执行任务,而 Netty 传入线程池的方式,是将一个 EventExecutor 线程绑定到该 Channel 对应的 ChannelHandler上。所以,也就是说一个客户端长连接的 Channel 中的事件,还是会在一个 EventExecutor 出现阻塞。
不过,我认为这种方式还是解决了一个问题的,那就是一个 EventLoop 是绑定了多个 Channel 的,并由 EventLoop 中的 Selector 进行统一的事件分发,通过传入 group 的方式,多个 Channel 则可能会被重新绑定到不同的 EventExecutor 中,这就可以解决一个 Channel 由于 IO 阻塞了,其他的 Channel 也会出现阻塞的情况。但是,不能解决一个 Channel 中某一个事件阻塞了,造成该 Channel 就会阻塞的问题。
如此看来,就不难理解,使用 Netty 时为什么要设置单独的业务线程池了。
言而总之,本篇文章重点讲述了 TCP 网关的 Netty 框架、Protobuf 格式、业务线程池。下篇文章,我将继续介绍心跳、Session 管理、断线重连。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
故事1:从零构建亿级流量API网关
01 | API网关:统一接入、分层架构、高可用架构
02 | 流量调度:配置中心、泛化调用
故事2:架构演进构建TCP长连接网关
03 | TCP网关:Netty框架、Protobuf格式、业务线程池
04 | TCP长连接:心跳、Session管理、断线重连
故事3:架构演进重构消息PUSH系统
05 | 消息PUSH:消息推送、消息送达率、APNs
故事4:从焦油坑爬出来的交易系统
06 | 交易平台:订单管道、订单状态机、服务编排、任务引擎
07 | 微服务化:服务治理、领域设计
故事5:烦人的焦油开始到处都是
08 | 新老系统:业务整合、数据融合、系统迁移
09 | 高可用架构:隔离部署、系统监控与日志、可灰度、可降级
故事6:稳定性架构与大促保障
10 | 大道至简:系统复杂度、三明治架构
11 | 大促保障:自动化测试、故障演练、性能压测