之前的内容我们学习测试了nsq各个组件的功能以及用户身份认证机制,这篇文章我们根据官方文档来继续研究学习nsq客户端的工作内容及原理,有助于我们深入理解nsqd的工作机制,并进行性能优化和问题排查。
nsq设计者将很多的功能放到客户端实现,这样保障了服务端的健壮和高效。同时发送消息到服务端是很容易和简单的,这里主要探讨消费者的功能实现。
必要的配置
一个消费者通过一个到snqd的tcp链接来订阅一个主题的某个通道。一个topic被多个消费者消费的情况要考虑。
- 客户端要通过配置支持直接链接nsqd服务,和链接nsqlookupd服务 从中读取nsqd服务信息进行链接。当链接nsqlookupd时,它需要定期从nsqlookupd中拉取配置信息,定期拉取的周期需要可配置。
- 由于nsq部署环境一般都是多生产者多消费者的分布式环境,因此客户端应该尽量均匀分布,避免惊群效应。
- 对于客户端来说,可以批量接收多少条消息(不需要给服务器响应),是一个重要的能调节指标。nsq中这个参数就是max_in_flight。这个参数影响rdy 这个状态的管理。rdy后续会介绍
- 客户端要实现消息处理失败的重试机制,并且重试次数可配置。对于发送失败的消息要自动进行重排序,nsq支持通过req命令一起发送延迟时间,客户要能确定第一次失败延迟多久,下一次/每增加一次 失败 延迟多久。
- 客户端处理snqd推送的消息需要提供一个回调方法,这个方法只有一个参数就是message对象。
服务发现
nsqlookupd服务是一个重要服务,可以让客户端在运行时定位topic所在的nsqd节点。这大大减少了维护和扩展nsqd节点的配置工作量。
客户端要定期轮训从所有的nsqlookupd实例中获取配置信息进行汇总,并管理好到各个nsqd实例的链接信息。(因为多个nsqlookupd之间不共享数据,因此客户端要遍历所有已经配置的nsqlookupd服务请求数据然后将数据汇总);调用一个nsqlookupd 服务的一个http接口(/lookup?topic=clicks),查询某个topic的位置;根据查询到的nsqd信息的broadcast_address 和tcp_port 组合, 建立到nsqd实例的链接
官方给出的 topic 查询返回结果示例
{
"channels": ["archive", "science", "metrics"],
"producers": [
{
"broadcast_address": "clicksapi01.routable.domain.net",
"hostname": "clicksapi01.domain.net",
"remote_address": "172.31.27.114:51996",
"tcp_port": 4150,
"http_port": 4151,
"version": "1.0.0-compat"
},
{
"broadcast_address": "clicksapi02.routable.domain.net",
"hostname": "clicksapi02.domain.net",
"remote_address": "172.31.34.29:14340",
"tcp_port": 4150,
"http_port": 4151,
"version": "1.0.0-compat"
}
]
}
连接
客户端必须为每个想订阅的topic建立一个单独的连接,初始建连接需要发送如下内容
- 唯一标识
- 命令标识符 和相关参数 和读取/校验响应
- 子命令(指定目标topic) 和读取/校验响应
- 一个初始化的 rdy 值 1
如果和nsqd链接中断,客户端要实现自动重新连接功能,
- 当客户端直接链接nsqd服务时,重连要使用指数回退的时延进行尝试(第一次 4s 后尝试 第二次 8s 后 第三次 16s后…只到超时)
- 如果客户端时通过nsqlookupd发现nsqd时,要自动根据特定的间隔进行链接尝试,一般是每次从nsqlookupd拉取完进行链接尝试。
参数/特性协商
建立tcp连接后,客户端通过发给服务端的命令标识符可以更改服务端的配置信息,比如:告知服务端客户端的心跳间隔、是否使用压缩算法、使用TLS 等 ,详情见spec,
服务端会通过包含服务端配置信息的json格式数据响应,如此完成双方通信参数的协商。
客户端发送信息示例
{
"client_id": "metrics_increment", --发给服务端来区分不同的客户端
"hostname": "app01.bitly.net", --发给服务端来区分不同的客户端
"heartbeat_interval": 30000, -- 告知服务端客户端的心跳间隔
"feature_negotiation": true
}
如果服务端不支持特性协商,那么直接返回ok ,如果支持那么返回如下结构体:
{
"max_rdy_count": 2500,
"version": "0.2.20-alpha"
}
心跳和数据流
客户端要通过异步IO/线程,来异步接收服务发来的消息数据。而且要定时(默认30s)响应服务端发来的心跳,客户端可以使用任何内容回复服务端的心跳请求,但是一般只回复一个NOP.
当发生协议传输错误时(比如:客户端发送了一个服务端不认识的命令)大部分情况下都认为是致命错误,服务端关闭连接来保护自己。
以下几种错误不是致命的,这几个错误一般是时间问题导致,通常发生在消息发送超时,服务端重排序并发送给其他消费者时。
E_FIN_FAILED - a FIN command for an invalid message ID
E_REQ_FAILED - a REQ command for an invalid message ID
E_TOUCH_FAILED - a TOUCH command for an invalid message ID
消息处理
当nsqd io循环收到消息,就会将消息传递给配置好的消费者/处理器。nsqd 希望希望能够在超时时间内(默认60s)收到答复。有几种可能的场景:
- 消息处理成功
- 消息处理未成功
- 接收端处理超时
- 超过nsqd的发送超时时间
前三种情况 客户端应该发送适当的命令(FIN REQ TOUCH)
FIN:表示处理成功或者无需处理,可以丢弃消息了
REQ:表示处理失败,服务端应该重新发送该消息,同时可能会携带延迟时间参数。如果消费者没有穿这个参数,客户端程序需要根据尝试次数自己计算这个延迟时间。如果重发超过一定次数 客户端要丢弃这条消息,同时调用回调方法记录通知并进行特殊处理(写入日志或磁盘)。
TOUCH:该命令可以重置nsqd端的等待时间(比如 一条消息的处理时间可能要超过nsqd的发送超时时间,但是又不希望超时重发,这个时候消费者就可以通过touch重置nsqd的等待时间),这个命令可以重复发送直到 客户端处理成功或失败。或者直到nsqd端max_msg_timeout参数 配置的时间。客户端库不应该代表消费者自动调用touch.
如果nsqd 没有收到任何命令那么消息超时自动重新排队,重新发送给可用的消费者。
RDY 状态
消息从nsqd 发送到客户端,通过RDY状态来控制数据流。
客户端会配置一个max_in_flight 参数,这是一个并发和性能控制器。比如 如果下游消费者 消费很快的 这个值会自动变大。反之 变小。
当一个客户端链接到一个nsqd实例时,会传递一个初始的RDY值 0. 表示nsqd不会推送任何消息过来。
客户端必须做好以下处理:
- 启动后将配置的max_in_flight 值平均分配给所有的连接
- 不允许所有连接的max_in_flight 总和超过配置的max_in_flight
- 每个连接的max_in_flight不要超过nsqd配置的max_rdy_count。
- 提供一个api接口来显示信息流不足
启动和分配
启动并为连接分配max_in_flight 时需要考虑如下因素:
- 连接数是冬天的,并不总能提前直到
- max_in_flight 值可能小于连接数
考虑上上述两个因素,一般启动时最初只为每个连接分配rdy的值为1
之后每次消息处理完成,客户端库都要评估是否需要更新rdy的值,如果nsqd端当前的rdy为0 或者为低于上次设定值得25%,则重新设定。
为了公平分配一般客户端为每个连接 分配 max_in_flight /num_conns 值作为rdy的值。但是当 max_in_flight <num_conns 时 ,客户端要通过上次收到消息到现在的时间间隔,对nsqd的活跃度进行动态评估。保证将max_in_flight 额度尽量分配给活跃的nsqd连接。
管理max_in_flight
客户端要管理所有连接对应的rdy值不超过配置的max_in_flight 参数。如下为python客户端中的控制逻辑
nsqd 的max_rdy_count参数
每个nsqd实例都会配置一个max_rdy_count参数,握手过程中会将该值传至客户端。如果设置nsqd端rdy的值时超过了这个限制那么连接将会被关闭。如果nsqd不支持协商那么就坚定这个值时2500
Message Flow Starvation
客户端应该提供一个api接口来显示信息流不足,
在消息处理过程中,消费者仅仅比较正在传输的消息数量和max_in_flight的大小是不够的的。以下两种情况下这样会有问题
- 当消费者配置的max_in_flight>1 ,max_in_flight 可能无法被 num_conns 除尽,这个时候必须向下取整,最终导致 rdy值总和小于max_in_flight
- 嘉定只有一部分nsqd实例在工作,但是由于平均分配的原因,哪些活跃的nsqd服务只分得了一部分max_in_flight 的值。
这两种情况下客户端无法收到max_in_flight 数量的并发消息。所以应该提供一个is_starved 方法 用来评估是否有连接是饥饿的。消费者/消息处理者调用这个方法来进行评估。
示例: 说实在的不是十分理解,慢慢理解吧!
def is_starved(conns):
for c in conns:
# the constant 0.85 is designed to *anticipate* starvation rather than wait for it
if c.in_flight > 0 and c.in_flight >= (c.last_ready * 0.85):
return True
return False
Backoff/补偿
消息处理失败的情况不太好处理。如上所述可以要求服务器延时发送,其实同时还可以要求降低流量,这种方式可以通过发送RDY 0 给nsqd 来实现。
加密和压缩
nsq支持压缩和加密,客户端可以通过 IDENTIFY 命令来和服务端协商。TLS用来实现加密, Snappy 和 DEFLATE用来实现压缩。
当请求了 携带了tls_v1 标识,那么会得到如下响应:
{
"deflate": false,
"deflate_level": 0,
"max_deflate_level": 6,
"max_msg_timeout": 900000,
"max_rdy_count": 2500,
"msg_timeout": 60000,
"sample_rate": 0,
"snappy": true,
"tls_v1": true, 表示 nsqd支持TLS,客户端可以发起TLS握手。
"version": "0.2.28"
}
“tls_v1”: true, 表示 nsqd支持TLS,发送/接收数据前客户端可以发起TLS握手。握手完成后必须先接收一个加密的ok报文。
压缩也类似,snappy / deflate =true 表示支持压缩,
总结
分布式系统很有意思,各个组件分工协作提供一个健壮的消息服务。实现客户端的时候可以参考pynsq 和go-nsq 。大概分为三个核心部分
Message: 一个高级消息对象,包含了所有对nsqd的请求和响应消息(FIN REQ TOUCH)和元数据信息。
connection: 一个高级的tcp连接包装类,包含了协商信息、rdy状态等
Consumer:用户交互的前端API,包括处理创建连接、管理rdy状态、解析数据、创建消息并分发给处理方法 等功能
Producer:处理消息发布的前端API,供用户调用。