1.背景
公司的主要开发语言是Java,算法部门主要使用的语言为Python。算法应用经常需要订阅业务系统产生的各种消息,但是业务部门使用的消息队列却为开源的Qmq。Qmq原生并没有提供对Python的支持,因此需要编写一个Python Qmq Client。本文虽然是以Qmq为原型,但是其他大多数的MQ Client基本上是相同的套路,还是具有普适意义的。
2.需求分析
从功能角度看,Client可以分为Consumer以及Producer两个模块。由于算法需求主要为订阅消息,所以选择优先完成Consumer模块,再完成Producer模块。
从职责角度分析,Client可以分为以下四个部分:
- 网络
- 序列化和反序列化
- 线程控制
- 交互逻辑
本文从上面几个部分分别入手,逐个讲解分析实现网络、序列化反序列化、线程控制、交互逻辑四大部分需要了解的原理性知识和可能遇到的问题以及实现过程中如何解决这些问题,从而帮助大家更好的实现自己的Client。
3.网络
此部分的讲解网络处理,主要涉及数据包的处理,response响应回调,网络连接管理和框架选择。
3.1 数据包处理
在网络编程的数据包处理中,我们主要面临两个核心问题:
确定request所对应的response
这个问题的意思是,客户端发送一个请求给服务端,服务端会回一个响应。但实际中客户端会发送很多请求给服务端,服务端也会回复很多响应给客户端。但是我们要将客户端回复的响应与客户端发送的请求要对应上。我们有两种途径解决这个问题:
第一种为串行化发送request,串行化接收response, request和response的对应是依赖顺序的。典型的例如redis的所采用的方式,比如向redis server发送get key1, get key2, get key3,那么redis server回复的响应是 value of key1, value of key2, value of key3。
第二种为通过request id映射。此种需要在报文中定义一个字段用来存放此request请求的id,服务端响应此request的response报文中也有一个id字段,并且此字段和request中的id字段相等。这样我们就能够去确定request-response 之间的对应关系了:发送request的时候生成一个id,并有一个map维护id <-> request之间的关系,当收到response的时候从response里取出id去map里获取request。 这里Qmq使用的是第二种方式。两种方式并无绝对的优劣,只是选择不同而已。
粘包/半包/拆包
粘包和半包的说法并不准确,因为TCP是面向流的,何来粘和半一说呢。面向流是什么意思呢?我们可以类比河流,河里的水是源源不断地,它没有明确的界限。但是在上层的应用中我们发送一个数据,往往是有界限的。比如我们这里的MQ,那么一般我们把一条消息作为一个单元,发送端发送了两条消息,我们要从TCP这条河流里读取出两条消息
处理此问题一般有下列几种方法:
- 消息定长,报文大小固定,不够的空格补全。
- 使用特殊分隔符。例如FTP协议、redis报文协议等则采用回车换行符来作为报文的符。
- 在消息头中包含表示信息总长度(或者消息体总长度)的字段,通过解析消息头中消息体的长度配合缓冲区来解决粘包/拆包问题。
了解了一般性的解决方案,我们来看看Qmq他是怎么解决这两个问题的呢?qmq使用自定义格式的报文协议来进行网络传输。自定义报文分为消息头header和消息体body,对于不同目的的请求request, body的编码解码格式不同。qmq的报文格式如下:
qmq报文格式
首先的四个字节为报文的总长度,而后的两个字节为报文头长度(其实我们可以发现Qmq的报文头部大小是固定的,并不需要一个额外的header size,这个可能是协议设计之初考虑有变长的情况遗留下来的吧)。而后的四个字节为特定的魔数,紫色的Opaque则为报文的id标识(即前文的request id),Qmq就是使用这个字段确定 response和request的对应关系,使用Map保存请求上下文:在发送request前,使用request的报文标识id为key,报文标识id用一个原子自增整型生成就行了,使用可以异步触发的对象为value。例如在java中为Future对象,在python中也可以为Future或者Deffered等对象。在response到来时解析response的id,查询map,如果此id的key不存在,则抛出异常,IO错误。否则触发此key对应的value对象。
同样,从报文协议我们就可以看出,对于粘包/拆包,Qmq通过使用totalsize字段来分割报文。在接收到tcp报文时,首先判定buffer中加上当前报文的总长度是否大于等于四个字节,因为我们报文中的totalsize就占用了四个字节,如果大于四个字节,我们读出totalsize中的值,去判断当前长度是否大于totalsize,如果小于,说明当前收到的内容还不够一个数据包(即所谓的半包),这时将当前所有字节内容放入一个buffer中,等待下一次处理。否则切割出此长度的字节片段传递给上层(或者在这里将网络包封装为Datagram对象)。
3.2 连接管理
连接管理主要目的为复用tcp长连接,避免每次发送报文时都需要再次重新建立连接。实现上我们可以考虑将tcp连接保存到map中,tcp连接的目标ip和端口的字符串为key,每次发送时判断是否有此连接如果有,则使用保存的连接,否则新建立连接。由于这一个map会在多个线程中被访问,所以需要考虑线程安全问题。另外需要注意的,我们将连接缓存在一个map里,需要使用的时候从map里取出使用,但是我们必须使用有效的链接。何为有效链接呢?其实在应用层我们是没有直接的办法能判断一个缓存的链接是否有效的,唯一的办法是发送一个包看看是否能收到响应,如果能收到响应则为有效,所以一般使用长连接的应用中,常常会在链接中发送心跳包来探测链接的有效性。
简单的实现连接管理实现类如下:
class ClientManager(object):
def __init__(self):
self.channels = {}
self.opaque = AtomicCounter()
self.channelsLock = RLock()
def getOrCreateChannel(self, remoteAddr):
tmpChannel = self.channels.get(remoteAddr)
if tmpChannel == None:
with self.channelsLock:
if self.channels.get(remoteAddr) == None:
serverClient = _ServerClient(reactor, HostnameEndpoint, remoteAddr, ClientIdProvider.get(),
_DEFAULT_RETRY_POLICY)
self.channels[remoteAddr] = serverClient
serverClient.clientManager = self
return serverClient
else:
return self.channels.get(remoteAddr)
else:
return tmpChannel
clientManager = ClientManager()
3.3 IO框架选择
磨刀不误砍柴工,选取一个正确的IO框架可以帮助我们简单快速高效的搭建网络层。因此选择一个符合需求下最优的网络层框架成为了我们当前目标。 python中常用的支持自定义网络协议框架有tornado,twisted , asyncio等,在选取时主要考量了下列三个指标:
- 能够完成需求(面向TCP的流处理)
- 文档齐全,社区完善,使用人数较多,出现问题可以很容易获得帮助
- 性能较高的同时占用资源较低
由于我们最开始选取的python版本为2.7,而asyncio为3.4时出现的支持(如果为python 3.7,建议使用此模块),所以我们的目光放在了tornado和twsited之间。它们两个都能够完成我们的需求,并且文档和社区都相差不大。主要的考量点为性能上,经过简单的收发测试,tornado处理同样数量的包耗时相对较短(平均短6%),但是cpu的占用相比twisted增加了40%(本测试是简单而粗糙的,不一定准确)。考虑我们不需要为了极高的性能而占用系统较多的资源,所以我们选取了twisted作为我们的网络层框架。 具体实现上,我们首先需要实现抽象的Protocol类,此类主要管理连接、收发报文等,而后我们需要实现ClientFactory类,通过此类来管理创建自定义Protocol对象的创建以及生命周期。
4. 序列化和反序列化
序列化和反序列化主要工作为将二进制的网络报文反序列化为程序内部对象(或者将程序内部 Request对象序列化为二进制网络报文)。对于有着固定格式的报文头,我们可以利用python的struct模块解析。例如:
struct.pack(('>hihhiih'),headersize,-557774114,code,version,opaque,flag,requestcode)
对于报文头的序列化和反序列化,我们只需要注意二进制的方向问题(大端/小端)即可。
对于body,常见的基本类型的序列化和反序列化我们也只需要简单的利用struct的api即可。需要注意的是String类型(python 中str类型)的序列化和反序列化。字符串序列化时需要特别注意编码格式。 示例如下:
def write_short_text(s)
'''
写由short类型标识字符串长度的文本字符串
'''
if s is None:
return _NULL_SHORT_STRING
if not isinstance(s, str):
raise TypeError('{!r} is not text'.format(s))
return write_short_bytes(s.encode('utf-8'))
def write_short_bytes(b):
if b is None:
return _NULL_SHORT_STRING
if not isinstance(b, bytes):
raise TypeError('{!r} is not bytes'.format(b))
elif len(b) > 32767:
raise struct.error(len(b))
else:
return struct.pack('>h', len(b)) + b
5.线程控制
此部分主要讲述qmq client内部线程如何设计。qmq client内部线程大体上可以分为IO线程和业务线程。 首先我们讲述如何将IO线程的工作范围和业务线程分开。首先明确一个原则,不确定执行时长的或者执行时间较长的任务绝对不能放入IO线程!在twisted中reactor线程为reactor.run()所在的线程,所以为了让 IO线程独立出来而不等同于用户线程,我们需要单独创建一个线程执行reactor.run(),这里有一个小细节就是注意声明installSignalHanlders=0,具体代码如下:
def run():
"""
由于此线程不是用户线程,默认不能接收信号
"""
reactor.run(installSignalHandlers=0)
_started.inc(1)
_iothread = threading.Thread(target=run, name="IOThread")
_iothread.start()
其次,我们就会面临到IO线程和其他线程的执行接力问题,如何将业务线程需要发送的请求交付给IO线程?这也就是一个线程间通信问题,方法很多。在twisted中我们可以采用reactor.callFromThread()方法来完成其他线程与IO线程的交互。再者,如何防止用户的不恰当使用导致系统资源紧张?或者说如何优雅的控制客户端流量?最开始设计pull方法并不是阻塞的,而是立即返回一个Deferred对象(有点类似Future对象),但是这样会导致一个问题,如果用户多线程循环调用此方法,则会导致客户端流量不正常,占用资源不断升高。后续经过 TL指点,将此接口设计为阻塞的,此时即可自适应地控制流量(业务代码处理慢就会拉取得慢,业务代码处理的快就会拉取的快),增强程序的健壮性。
最后,如何在多线程下减少线程安全类的bug呢?线程安全相关的问题处理起来难度较高,容易出错,排查也较困难。所以我们应该考虑在满足需求的情况下,尽量减少线程安全方面的考虑。由于用户线程不可避免的可以多线程调用,而我们可以考虑利用一个队列,内部维护一个线程监听此队列,而每次用户调用都会转化为此队列中的一个元素,这样我们则可以做到将用户多线程转化为内部的单线程执行。由于内部为单线程执行的,所以不需要考虑线程安全问题了。
6.交互逻辑
讲完了上述复杂的不知所云的问题后,我们现在将不同的部分组装在一起,最后完成Qmq的Consumer Client。
本部分主要会讲述以下几个部分:
-
交互逻辑分析
-
Consumer交互逻辑
- Pull 拉取逻辑
- Listener拉取逻辑
- Ack逻辑
6.1 交互逻辑分析
在Qmq消费的交互逻辑中涉及这几个角色:metaserver, server和consumer。Metaserver是整个交互的控制中枢,server是实际存储消息和转发消息的。Consumer在消费消息时有会两次请求:
- 1.向metaserver发起请求获取server的地址集合,metaserver记录了哪些topic的消息落在哪些server中
- 2.向第1步获取到的server地址集合发起拉取消息请求
6.2 Consumer交互逻辑
Consumer消费消息,从使用方的API角度一般分为拉模式(pull)和推模式(push)。
拉模式
拉模式指的是使用方主动调用Consumer的pull API拉取消息,使用方会开启一个循环,在循环里拉取消息->处理消息->拉取下一批消息…循环往复。
Pull模式下的逻辑如下图:
- a.getOrCreatePullConsumer()获取一个pullConsumer实例,并且在这里将PullConsumer中的 run方法提交到线程池中运行。
- b.pull()方法会创建一个future对象放入pullConsumer.requestQueue队列中,并且将future.get() 作为结果返回
- c.PullConsumer的run方法为一个while循环,将不断的尝试从阻塞队列requestQueue中获取元素,然后发起请求
推模式
推模式指的是使用方调用Consumer API添加监听器方式的消费消息,这种方式主动权在Consumer,Consumer拉取到消息后主动触发监听器,监听器里运行的是业务系统代码,一般在独立线程池里执行。 虽然在使用层面分为拉模式和推模式,但是底层的实现是一致的,都是Consumer向Server端发起拉消息请求。
Listener顶层关系设计如下图:
每个监听器由PullRegister负责注册,pullRegister对象是client的全局唯一对象,其拥有一个pullEntryMap的属性,根据监听器的监听的subject(topic)不同,创建不同的ParallelPullEntry 对象,而每一个此对象拥有多个DefaultPullEntry,每一个DefaultPullEntry对象都负责某一个subject在某台server上的拉取。因为一个subject可能对应多台server,这些server分布在不同的机器,而每一个DefaultPullEntry对象就是负责某一台机器上关于此subject的拉取。
Ack逻辑
当消息处理成功后Consumer需要ACK消息,用来表示该消息消费成功了,不用再消费了。为了提高效率,Qmq并不记录每一条消息的消费状态,而是用一个offset来记录。比如Consumer拉取了1-100条消息,现在前50条都消费成功了,则只需要记录一个数字50就可以表示前50条都消费成功了。但是消息的消费并不是顺序的,比如前50条消息,第30条消息一直没消费成功,而31-50都消费成功了,则我们也只能ACK 1- 29。所以ACK机制就类似一种滑动窗口的方式,当使用方调用ack方法标记一条消息消费成功,我们就尝试向右移动窗口,如果消费成功的消息不连续则窗口无法向右移动。
7.总结
至此,我们从背景需求,到网络、序列化反序列化、线程控制、交互逻辑等不同部分讲述了实现一个MQ Client的基本要素,描述了实现一个客户端应该考虑的问题以及简单的知识拓展。
作者
便利蜂基础架构组的大三实习生,用较短的时间,独立从需求分析,Qmq Java Client的代码熟悉,到很好的完成Python的方案设计和实现,最后总结出网络编程中一些基本的注意事项。
如果你对相关的技术感兴趣,致力于研发效率提升,欢迎加入我们。
- 邮箱地址:tech-hiring@bianlifeng.com
- 邮件标题:产研平台基础组件部
招聘官网
bianlifeng.gllue.me/portal/home…
点击“阅读原文”进入,了解更多职位详情