消息中台融合了各种信息触达方式,包括短信、语音、邮件、微信、5G消息、视频消息、AIM消息、站内信、APP Push等,为整个集团及外部租户提供了统一的消息推送接入方式、统一的申请流程、统一的模板管理,并会自动计算出各业务方的费用分摊。在不同通信方式之间以及同一通信方式的不同渠道之间,进行智能路由,以降低费用并提高信息触达的成功率。
其中,短信作为一种比较传统的信息推送方式,既支持公司针对(目标)客户进行营销,提供验证码等服务,也能接收客户的信息回传,以形成一种双向的沟通手段。与微信、5G消息等富媒体通信方式相比,纯文本的短信对终端要求不高,价格低廉,无需用户主动关注亦可通信,优势突出,在消息中台的所有调用量中独占一半以上份额。
短信可以由三大运营商直接提供支持,同时也存在大量的第三方中间供应商。运营商以TCP而非HTTP协议方式对外提供短信服务,不同运营商有各自独特的、基于TCP协议的应用层通信协议。我们的消息中台在对外部租户提供服务时保持了和市场上很多主流供应商一样,以移动CMPP协议的方式与租户方进行通信。
由此可见,TCP通信和服务在消息中台里占有重要地位,需要在技术层面进行良好的设计,以与消息中台的其它通信方式和业务逻辑实现兼容。
本文的后续章节,将着重介绍短信场景中的TCP通信。
在通过运营商/供应商提供的TCP接口来发送短信时,通常会申请并分配若干账号,每个账号只支持固定数量的链接。
短信在发送时要选择通道,因此具有通道号,通道号统一为106号码,即短信在手机上展示时,发送来源号码是106打头的。
消息中台要求通信层使用尽可能少的资源,同时提供高性能、可靠的短信渠道服务,因此需要满足一系列的功能和技术要求:
在作为外部运营商和供应商的使用方时,消息中台会作为客户端;在作为向外部租户提供短信服务的提供方时,消息中台会作为服务端。这样就要求在消息中台里,既要实现TCP客户端,也要实现TCP服务端。
不同运营商和供应商提供了不同的短信协议,每种协议支持若干数量的业务指令,消息中台的短信通信模块能将上层业务的功能匹配为短信协议中的不同指令。
指令通常分为几种类型,连接、断开连接、链路检测、短信下行、短信上行、状态回执。
大多数指令是单向的,即从客户端到服务端,但也有少数指令是双向的,比如链路检测。还有些指令在A运营商处是从客户端到服务端的查询或请求行为,但在B运营商处就可能是服务端到客户端的推送行为。
某些运营商或供应商在通道号之外还支持扩展码。
长短信要拆分为若干条短的短信来发送,系统版本较新的手机在接收时会自动合并为1条短信。但对应的状态回执数量可能是多条,也可能是1条,这取决于运营商和供应商,且长短信和短短信的报文格式不同。
每个账号用于若干特殊场景,则需要从业务上实现隔离
比如,营销短信或者某些特殊用途的短信容易被封,连带着对应的账号也会被封,所以需要从业务上实现隔离。
每个账号支持的连接数量与消息中台的通信服务器数量不匹配
账号的连接数最大容量可能小于、等于或大于通信服务器的数量,且在大于的情形下,也不一定是整数倍的关系。
为了确保系统在大流量下也不至于崩溃,还要保障不会把短信运营商或供应商的服务压垮,短信服务的上游要实现限流的功能,短信服务层则需针对调用请求进行熔断,并且在调用下游时也要实现限流。
在外部运营商和供应商出现问题时,要规定的若干个渠道间进行自动切换,且一旦恢复正常,则要继续保持管理界面上配置的流量分配比例。
短信使用基于TCP协议的应用层协议进行通信。为了进行TCP协议通信,需要针对Socket编程。因为接近底层,对于大多数人来说,不但在技术上难以掌握,写出来的程序也很难维护,出现问题时进行排查一般也是困难重重。
针对Socket编程,Java语言支持3种编程模式:
BIO(同步阻塞式I/O):一个连接分配一个线程,对资源消耗极大,只适用于连接数少且固定的场合。
NIO(同步非阻塞式I/O):一个请求分配一个线程,适用于连接数目多且连接比较短的架构。
AIO(又称NIO.2,异步非阻塞式I/O):一个有效请求分配一个线程,适用于连接数目多且连接比较长的架构。
所谓有效请求,是指用户线程不但在数据准备阶段无需等待,在数据复制阶段也无需等待,这两个阶段的工作均由操作系统的内核线程代为完成,直至数据在用户缓冲区准备就绪后,用户线程才开始接管并处理数据。
Windows的IOCP是一种成熟的AIO实现方案,而Linux中至今没有统一的、成熟的AIO实现,而是要通过epoll机制来模拟,复杂且没有性能优势。
生产环境中通常使用Linux作为服务器,因此尽管AIO有诸多理论上的优点,但实际项目中使用并不是很多。
虽说对于短信来说,NIO和AIO都能满足需求,但基于上述考虑,主流选择仍然是使用NIO来实现。但直接使用Java提供的NIO API,不但复杂、麻烦、工作量大,对开发者的多线程编程技巧、网络编程的熟练程度都有相当高的要求,还需要针对性地使用零拷贝等技术来提高性能,稍不留意,写出来的代码耦合性就很高,难以维护和扩展。所以我们需要使用成熟的组件Netty,来实现短信服务的通信功能。
Netty基于Java NIO进行了封装,避免了上述局限性,也解决了JDK中NIO实现代码长期存在的Bug(例如,epoll Bug导致Selector空轮询、会极大的耗费CPU资源等),提高了网络I/O编程的开发效率和可靠性,同时Netty还具有以下优点:
设计优雅,提供阻塞和非阻塞的 Socket,提供灵活可拓展的事件模型,提供高度可定制的线程模型。
具备更高的性能和更大的吞吐量,使用零拷贝技术减少不必要的内存复制,减少资源的消耗。
提供安全传输的特性。
支持多种主流协议,并预置多种编解码功能,支持用户开发私有协议。
短信协议恰恰是TCP协议之上的自定义应用层协议,利用Netty的能力可大大加快这类私有协议的开发。
NioEventLoopGroup 相当于一个事件循环组,这个组中含有多个事件循环,每个事件循环就是一个NioEventLoop。
NioEventLoop表示一个不断循环的事件处理线程,每个NioEventLoop都包含一个 Selector用于监听注册在其上的 Socket 网络连接(Channel)。
在每个 Boss NioEventLoop 中循环执行以下三个步骤:
在每个 Worker NioEventLoop 中循环需执行以下三个步骤:
在以上两个processSelectedKeys步骤中会使用 Pipeline(管道),Pipeline 中引用了 Channel,即通过 Pipeline 可以获取到对应的 Channel,Pipeline 中维护了很多的处理器(拦截处理器、过滤处理器、自定义处理器等)。
Netty在设计上采用三层架构,即:
Reactor通信调度层
上节中的Netty线程模型图中主要表现的是事件驱动的通信调度,即所谓的Reactor模型。该层主要包含NioSocketChannel/NioServerSocketChannel,Eventloop,ByteBuffer和Task。其主要职责是监听网络的读写和连接操作,负责将网络层的数据读取到内存缓冲区中,然后触发各种网络事件(例如连接、读/写等事件),并将这些事件注入到Pipeline中,由Pipeline管理的职责链来进行后续处理。
责任链ChannelPipeline
上图描述的是业务逻辑单元块,即“业务逻辑编排层”是如何组织成责任链的,该层负责管理责任链和动态编排职责链。
通信调度层生成的事件会在职责链中有序传播,被链中对该事件感兴趣的“业务单元”依次处理。
每个Channel拥有自己独立的1条ChannelPipeline,这条ChannelPipeline中的所有ChannelHandlerContext组织成一条双向链表。每个ChannelHandlerContext内部包裹着1个ChannelHandler,同一条管道中的ChannelHandler可以是入站、出站、双向类型的。
同一个ChannelHandler可以属于不同的ChannelPipeline,即被不同的ChannelHandlerContext包含,但一个ChannelHandlerContext只能属于一个ChannelPipeline。
入站和出站信息都需要流经所属Channel的Pipeline进行处理。入站信息从队头往队尾方向遍历,并且只被入站和双向类型的ChannelHandler处理;入站信息从队尾往队头方向遍历,并且只被出站和双向类型的ChannelHandler处理。
业务逻辑编排层(Service ChannelHandler)
业务逻辑编排层通常有两类,一类是纯粹的业务逻辑编排,还有一类是其他的应用层协议插件,用于特定协议相关的会话和链路管理。在通常情况下,业务开发者只需要关心责任链的拦截和业务Handler的编排。
在上图所示的消息中台架构中,左上角和左下角红色椭圆标示的两个部分都需要使用Netty来进行通信功能的开发。
“短信商户平台”中的CMPP短信服务器部分
TCP协议的运营商和供应商通信客户端,CMPP、SMGP和SGIP分别是移动、电信和联通的短信协议,一些第三方供应商也采用这些协议来对外提供短信服务。
具体Netty代码如何编写,可从官方的User Guide和API Reference中得到最好、最详细的帮助,本文不涉及这些代码的细节,而主要介绍一些需要注意的技术关键点。想通了这些点,那么对应的代码就能顺畅的被编写出来。
线程数量的控制
在cmpp客户端实现时,考虑到需要对接p个渠道,每个渠道申请了k个账号用于不同目的,而每个账号最大允许n个连接,则极端情形下的并发连接数为 p*k*n
,这个值有可能非常大。
不管是为每个账号申请一个线程池,还是为每个渠道维护一个独立的线程池,都有可能导致系统中的线程数失控。
为了在满足需求的前提下对线程数量进行有效的控制,创建了2个线程池,分别用于work Event Loop和一般的业务逻辑处理,它们都在所有渠道的所有连接间共享,如上图所示。通过Apollo配置参数,就能很好地控制系统中的线程总数量。
通信调度逻辑与纯业务逻辑的分离
如上图所示,实现时要对通信相关逻辑和业务逻辑进行了分离。
1)通信调度、数据的编码/解码工作,集中在Pipeline中进行集中管理和执行。
2)带有业务特征的逻辑,全部通过Spring容器中的Bean来实现,这些Bean实现了相同的Interface,可以被抽象化处理。
3)在Pipeline中有一个专门的入站处理器CustomInboundHandler,它遍历容器中的所有业务Handler,筛选出与当前消息头中的命令码匹配的业务Handler后依次执行。
关于链路检测
此处讨论的是Cmpp应用协议层的链路检测,而非ISO七层协议栈中的物理链路层协议。
1)链路检测既可以由我方发起,也可以由运营商或供应商发起,如上图所示。
2)当某个通道在一段时间的空闲后,Netty核心将按照时间轮算法生成并向该通道发送Idle消息。该消息作为用户事件,将触发该通道的linkDetect()方法被调用,从而发起主动的链路检测,并等待三方的响应。
3)反过来,三方也可以向我方发起链路检测请求,我方收到后进行响应。
通过这种双向的链路检测请求/响应数据包,实现了Cmpp通信双方的心跳和健康检查。
关于Netty连接池
为了最大程度地实现连接复用,减少TCP连接建立的耗时,消息中台继承了AbstractChannelPoolMap类,通过它为不同的渠道管理和创建通道连接池。
属于同一个渠道的通道连接池中的所有通道连接(Channel)共享相同的配置,且这些配置会作为附件从Bootstrap到Channel一层层传递下来。每次消息下发时,最新的渠道配置会从DB、Apollo中查询出来,通过调用传递到SmsClientFactory中,从而实现对渠道配置的动态更新。
学习完Netty在生产场景中的运用后,你肯定会从以下两个角度来讨论问题。
如何提高和改进
如果消息中台的调用量有10倍、100倍的增长,是否能有效应对?需要从哪些角度来改进?
如何推广
如何将本文中讨论的技术推广并复用到C10K~C100K场景。比如,出行业务公司(如滴滴)需要跟每位司机在后台保持长连接,同时高频次上报GPS坐标,并高优先级推送订单信息,...?
下面主要从通信特别是Netty的角度来讨论一些未来可能需要解决的问题,同时也希望可以引发大家的思考。
单队列网卡 vs. 多队列网卡
极高的流量下,必然要求硬件和软件协同,才可能切实解决问题。
硬件方面,首先想到的就是,需要把单队列网卡换成多队列网卡,以充分利用CPU每个核的性能。消息中台是运行在容器里,那么在容器化场景下,就要配置并使用多队列网卡,要最大限度地压榨网卡和CPU的硬件性能,同时还要保持容器化应用的高扩展性。
内存使用控制粒度不够细、垃圾回收难以有效控制
例如,当使用无参数的短信模板时,对于所有接收者来说,要发送的消息内容完全相同。此时完全可以只使用一份ByteBuffer并复用到多个通道中,以减少内存拷贝,这样在提高性能的同时还减少了GC的次数和影响,又进一步改善了性能。
大量连接时避免偶发的CPU使用率飙高
支持按优先级发送数据
提供细粒度监控
不同供应商提供的账号数量不同,每个账号允许的最大连接数不同,要更有效地将账号、连接容量等匹配到网关节点上。
Netty是基于Java NIO实现的,没有用到Java AIO(NIO.2)。当Linux对AIO的支持成熟后,有必要从Netty切换到Java AIO来实现系统的通信层。
招聘信息
Java、大数据、前端、测试等各种技术岗位热招中,欢迎扫码了解~