Twitter Storm
Storm的主线主要包括4条:nimbus, supervisor, worker和task。
1. Nimbus
要了解nimbus的具体做的操作,可以从提交一个topology的流程开始。
1.1 Prepare
Nimbus启动时候,运行了一个ThriftServer。它会在topology提交之前做以下四个工作。
(1) 清理一些中断了的topology(nimbus目录下/storm.local.dir/stormdist下存在,zk中 storms/topologyid中不存在的topology):删除ZK上相关信息(清理tasks/topologyid; storms/topologyid; assignments/topologyid这个三个目录)。
(2) 将storms/下所有的topology设置为启动状态: 能转换成startup状态的两种状态分别是:killed和rebalancing。nimbus的状态转换是很有意思的事情,killed状态的topology在nimbus启动的时候会被干掉;rebalancing状态的topology在nimbus启动的时候会重新分发任务,状态会变成rebalancing的上一个状态。
举例: 当某个topology提交的时候,会被设置成active状态,假设storm集群增加机器了,你希望重新分发任务,可以将状态转换成rebalance状态,转换成这个状态要做这几件事:
首先,启动一个延迟TOPOLOGY-MESSAGE-TIMEOUT-SECS秒执行的事件,将当前状态转换成do-rebalance状态,在这之前会将当前topology的状态设置成rebalancing状态(注意设置和转换的区别,设置就是指将ZK上存储的topology的状态进行重新设置)。
然后,将rebalancing的状态转换成do-rebalance,也就是将任务重新分发。
最后将状态设置成rebalancing的上一个状态active。
(3) 每间隔NIMBUS-MONITOR-FREQ-SECS长时间将ZK上/storms 下所有的topology状态转换成monitor状态,并且将不活跃的storm清理掉。只有当状态为active和inactive的时候,才能转换成monitor状态,转换成该状态就是将任务重新分发,监控是否与上一次的分配情况不同,如果存在不同,则替换,这个过程ZK上存储的topology的状态是不会被设置的。
(4) 删除过期的jar包: 过期时间为NIMBUS-INBOX-JAR-EXPIRATION-SECS。每间隔NIMBUS-CLEANUP-INBOX-FREQ-SECS长时间进行一次清理。
1.2 Submit topology
1.2.1 Submit
以下是提交一个topology的执行流程概述
(1) 检查:检查所提交的topology是否已经是active状态,如果是,抛出异常。
(2) 计数器:计数器+1,用来计算共提交了多少topology。
(3) 为这个topology生一个id。
(4) 计算要启动的acker的相关信息,每个acker都是一个bolt。根据配置的信息获取并发数等。
(5) 获取topology的code和config。
(6) 为该topology建立心跳目录(ZK:storm-zk-root/taskbeats/topologyid)。
(7) 为该topology分配任务。
(8) 设置storm状态为active。
1.2.2 Task assigned
这个是nimbus的重点,分配任务的流程:
(1) 获取所有的node+port:分配任务的目的就是将tasks分配到具体的node+port。
(2) 获取该topology已经分配了的任务(如果是新提交的topology,则为空)。
(3) 为每个需要分配的task分配到具体node+port。合并(1)和(2)中所有的node和port。具体为什么要这样,还不是很清楚。因为通过(1)应该是可以获取所有的node+port。
(4) 将2)中需要重新分配的task的启动时间不变,其他新分配的任务的时间设置为当前时间。
(5) 将以上的信息Assignment写入到ZK中。
1.2.3 scratch?
分配任务主要分为两种情况,代码中用scratch?来标识两种不同的情况(正常的分配任务和rebalance状态转换)。1.2.2中所说的需要分配的任务暗指此处的两种任务分配情况。
(1) rebalance状态转换
这种原因导致的重新分发任务,与正常的分配任务的区别在于两点:
第一:所有的任务都认为是活跃任务,即认为任务心跳都未超时。
第二:所有的任务都需要重新分配,即认为所有端口都是空闲的端口,允许被分配任务。
举例说明:
假设:提交的topology确定有10个task, taskid分别为1,2,3,…10。有两个supervisor(n
ode1和node2),每个supervisor可用的端口号为{6001, 6002, 6003}。
分配任务的目标是尽可能负载均衡,即每个node均衡,每个端口均衡, rebalance情况
下分配的结果为:
tasked | node+port |
1、7 | node1:6001 |
3、9 | node1:6002 |
5 | node1:6003 |
2、8 | node2:6001 |
4、10 | node2:6002 |
6 | node2:6003 |
(2) 正常分配任务:
正常分配任务比rebalance多考虑两个问题,考虑task的心跳是否超时和哪些端口上分配的任务是符合负载均衡的,可以不需要重新分发。所以要重新分发的task是那些心跳超时和所在端口没有分配均衡的那些任务。
nimbus用了一个叫task-heartbeat-cache来存储task的心跳时间,而不是直接用写入到zk上的心跳时间,这么做的目的是防止各个node上面的时间不同步。
举例说明:
假设提交的topology,跟上个例子相同。确定有10个task, taskid分别为1,2,3,…10。有两个supervisor(node1和node2),每个supervisor可用的端口号为{6001, 6002, 6003}。
首先,重新分配任务时,会先判断心跳超时的task,假设task1和task6心跳超时,task1和task6需要重新分配。
然后,判断哪些端口的分配是符合负载均衡的要求。判断过程是这样的:共10个task,6个端口,按照负载均衡原则,应该是4个端口上分配两个task,2个端口上分配1个task。依次判断哪些端口不符合要求。如果按顺序来判断,node1:6001和node2:6002是不符合要求的,他们应该要被分别分配2个任务。所以两个端口的任务也需要重新分发。
最后,确定要重新分发的任务是1、4、7和10,可用的端口号是node1:6001和node2:6002。
按照每个node均衡,每个端口均衡的分配原则,分配结果如下:
tasked | node+port |
1、7 | node1:6001 |
4、10 | node2:6002 |
2. Supervisor
Supervisor主要做了以下的事情。
2.1 HeartBeats
心跳之前会将supervisorid写入到本地。
Supervisor将自己的心跳信息(SupervisorInfo[心跳时间, 主机名, 端口号和运行时间])以SUPERVISOR-HEARTBEAT-FREQUENCY-SECS的频率写入到zk中。Nimbus会读zk上的心跳信息,以确定集群的supervisor的状态(目前还没实现)。
Supervisor启动的时候会执行一次心跳写入操作,然后会启动一个线程每隔SUPERVISOR-HEARTBEAT-FREQUENCY-SECS秒就写入一次心跳信息。
2.2 Synchronizesupervisor
启动一个线程,每隔10s执行一次这个操作。参数不可通过配置文件配置。主要包括的操作如下:
(1) 下载需要下载却未下载的topology资源(代码, jar包和配置文件):某个topology的任务分配到了该supervisor,则下载topology的资源到指定的目录(先下载到临时目录,然后把jar包进行解压复制到最终目录,删除临时目录)。
(2) 删除无效的topology资源,这些资源是指supervisor本地的从nimbus下载下来的topology资源,且这些topology没有被nimbus分配任务。
(3) 将要分发到supervisor的任务存入到localstate(本地/storm-zk-root/supervisor/localstate)中(key为LS-LOCAL-ASSIGNMENTS)。
(4) sync-processes
2.3 Sync-processes
启动一个线程,每隔SUPERVISOR_MONITOR_FREQUENCY_SECS秒执行以下操作
(1) 关闭本地无效的worker:通过获取worker心跳(WorkerHeartbeat)获取worker的状态,对状态不为:valid的worker的进程kill掉。
(2) 根据本地/storm-zk-root/supervisor/localstate下的信息判断哪些新的worker是需要启动的,哪些启动了的worker是可以保留的。将这些需要启动的worker建立相关的本地目录,将所有启动了的和将要启动的worker的ID和port存入localstate中。并启动要启动的worker。
2.4 Shutdownable、 SupervisorDaemon and DaemonCommon
(1) 关闭该supervisor:关闭supervisor后,它的worker和task相关的进程和线程不会被关闭。
(2) 可以获取配置文件,获取supervisorid和关闭所有的worker
(3) 判断supervisor是否处于等待状态
3. Worker
Worker主要完成了三件事情:
3.1 HeartBeats
Worker的心跳(WorkerHeartbeat)是写在本地的,路径是/STORM-LOCAL-DIR/workers/workerid/。
public class WorkerHeartbeat implements Serializable{
private int timeSecs;
private StringstormId;
private Set<Integer>taskIds;
private Integer port;
}
timeSecs:写入心跳的时间
stormId: stormId就是topologyId,怎么叫都行。
taskIds:每个worker对应于一个node下的一个port,就是某台机子上的某个端口
port:就是该Worker(端口)下所有要启动的任务。
Worker启动的时候,会从ZK获得哪些task是自己需要启动的,然后写入一次心跳到/STORM-LOCAL-DIR/workers/workerid/中。Worker还会启动一个线程,这个线程每隔WORKER-HEARTBEAT-FREQUENCY-SECS时间往/STORM-LOCAL-DIR/workers/workerid/上写入一次心跳信息。
监控Worker的心跳是由supervisor来做的,具体参考supervisor。
3.2 Refresh
3.2.1 Refresh connection
这个跟nimbus有关系,比如说:某种原因导致任务要重新分配,重新分配后,Worker下的任务发生变化了,所以需要一个线程来监控这个事件。
Worker会找到他要启动的任务都会发送消息到其他哪些任务中,这些任务都由那些Worker启动,会利用zeromq逐一建立对每个worker建立socket连接。当nimbus重新分配任务了,Worker会去重新读分配给她的任务是什么,然后判断那些任务是新的,那些任务是不要的,根据此来断开不需要连接的socket,连接需要连接却未连接的socket。
启动Worker的时候会执行一次以上过程,然后会启动一个线程,每隔TASK-REFRESH-POLL-SECS时间往事件管理器中添加refresh connection事件,事件管理器会每隔TASK-REFRESH-POLL-SECS时间执行该事件。除了以上时刻会执行外,Worker把refresh connection操作作为从ZK上读取分配任务的时候的watcher事件。也就是说一旦重新分配了任务,就会执行该事件。个人认为这么做的目的是为了双保险。其实不这么做也是可以的。
3.2.2 Refresh storm active
一个worker首先只能属于一个stormid。Refresh storm active目的是为了监控Worker所属的storm(topology)的状态,都有哪些状态呢,详细参考nimbus主线。Worker要做的事情是判断该storm(topology)的状态是否是active。判断这个有啥用,给task用的,task怎么用,参考task。
启动Worker的时候会执行一次以上的过程,然后启动一个线程,每隔TASK-REFRESH-POLL-SECS时间往事件管理器中添加refresh storm active的操作,事件管理器会每隔TASK-REFRESH-POLL-SECS时间执行该事件。
除了以上时候会执行外,Worker把refreshstorm active的操作作为从ZK上读取/storms/topologyid上的topology信息(StormBase)时候的watcher事件。即topology的信息一旦发生变化,就会执行该操作。个人认为这么做的目的也是为了双保险。
3.3 Sendmessage and Transfer message
3.3.1 Difference
Storm的消息收发是通过zeroMQ来完成的。流程如下:
(1) task发送消息给所属的worker:是通过一个队列实现的,每个worker产生一个队列,task往队列里写消息,消息的内容包括要发送到的taskid和所要发送的消息。
(2) worker从队列里取出消息通过zeroMQ发送到目的worker。
(3) worker接收通过zeroMQ发送的消息后,根据消息的taskid转发到目标task。
整个消息通信的过程表明:task和task之间的消息通信是通过worker来完成的。Sendmessage就是指过程(3), transfer message是指(1)和(2)。
3.3.2 Implements
(1) 首先Worker启动一个线程,该线程循环的从Worker的队列中取出消息,将消息+目标taskid发送到目的worker。
(2) Worker再启动一个线程(msg-loader/launch-virtual-port!),循环将收到的通过zeroMQ得到的消息分解:(taskid+msg),建立与虚拟端口的连接(zeroMQ中同一进程内的多线程之间的通信"inproc://" + virtualport)。Virtualport此处等于taskid。然后将消息msg发送到指定的task。
测试worker的时候,发现连接虚拟端口的时候,这个虚拟端口需要先被绑定。必须用同一进程的zero context。
4. Task
task由worker启动,由nimbus分配。
4.1 HeartBeats
task的心跳信息(TaskHeartbeat)由nimbus来监测,心跳信息写入到zk(/storm-zk-root/taskbeats/topologyid/taskid )中。
Task会启动一个线程,每间隔TASK-HEARTBEAT-FREQUENCY-SECS秒写入一次心跳信息。
BaseStatsData包括以下信息:
Type表示该任务是属于bolt还是spout
TaskStats 的主要内容为:
emitted和transferred存储的内容为:<the time window, <streamid, count/average>
union TaskSpecificStats {
BoltStats bolt;
SpoutStats spout;
}
struct BoltStats {
map<string, map<GlobalStreamId,i64>> acked;
map<string, map<GlobalStreamId,i64>> failed;
map<string, map<GlobalStreamId,double>> process_ms_avg;
}
struct GlobalStreamId {
string componentId;
string streamId;
}
struct SpoutStats {
map<string, map<string, i64>>acked;
map<string, map<string, i64>>failed;
map<string, map<string,double>> complete_ms_avg;
}
4.2 Transfermessage
Task最主要的工作就是往所属worker的队列中发送消息,消息的内容是[taskid, tuple],taskid是消息要发送的目的task。要完成发送需要做到两点:第一,确定要发送的目标task;第二,保证消息的可靠性(acker)。
(1) 确定目标task
Task通过分组来确定目标task。分组的方式由以下几种:
a. 按字段分组:通过对字段(提交topology的时候设置)hash实现,具有相同字段的值会分发到相同的task中,但是不同字段的值不一定会分发到不同的task中。
b. 随机分组:这种随机分组其实不是完全随机的,从代码来看可以看作是随机均匀分组,它会尽可能的保证每个目标task上的负载均匀,也就说让目标bolt中的每个task处理tuple的数量尽可能的相同。
c. none分组:和随机分组的差别不大,但是这种是纯粹的随机分组。
d. 直接分组:这种分组就是指定taskid,发送消息的时候用的方法也必须使用emitDirect来发射。
e. All: 全局发送
f. 自定义分组:自己定义分组方式
(2) 消息的可靠性acker
Task是通过acker来保证消息的可靠发送的,流程如下:
1) 首先spout会发送一条消息[val, taskid], 一个新的tuple产生时val为tupleid^0;
2) 然后bolt接收消息转发后,进行ack,会将接收的tupleid和产生的所有tupleid都与val异或;
3) 最后,acker(一个acker就是一个bolt,可以设置acker的并发数)会一直检测val什么时候变成0。变成0表示这个tuple发送完毕。
举例说明:
假设topology的结构是:spout发送消息到bolt1,然后bolt1发送消息分别到bolt11和bolt12,然后bolt11和bolt12将消息做一些处理后写入到KV中。
1) spout中的一个task(假设taskid为1),发送了tuple(假设tupleid为1)到bolt1中的一个task(假设taskid为2)
此时:acker会收到一条消息[0^1,1], 也就是[val, taskid];
2) task2接收到tuple1后,生成两个tuple(假设id分别为11和12),分别发送给bolt11中的task(假设taskid为3)和bolt12中的task(假设taskid为4)。
此时:task2 ack的时候会将接收到的tuple和新产生的tuple进行异或,val这时候的值是0^1^1^11^12
3) task3接收到消息后写入到KV中
此时: task3 ack的时候,val的值为0^1^1^11^12^11
4) task4接收到消息后
此时: task4 ack的时候,val的值为0^1^1^11^12^11^12=0
4.3 Implements
task的整体执行流程
1) 启动一个线程定时写入task心跳。
2) 再启动一个线程,循环执行spout或者bolt, task会判断自己是bolt的task还是spout的task。
Spout
a. 定义一个TimeCacheMap,用来将消息超时(TOPOLOGY-MESSAGE-TIMEOUT-SECS)的tuple发送给acker一个消息,表示发送失败。前提是发送消息的时候要指定message-id并且ack的并发数大于0(TOPOLOGY-ACKERS>0),否则不进行acker操作。
b. 发送消息之前会确定要输出到哪个taskid,这个是由分组的方式决定的。
c. 循环的执行spout的nextTuple函数,这里要提到worker中的一个操作,refresh storm active,此处如果发现topology的状态不是active的时候, 会持续等待,不会执行nextTuple, 除非topology的状态变为active. nextTuple里一般调用ISpoutOutputCollector中的emit方法,该方法会将消息和要发送的目标taskid写入到所属worker的队列中。
d. 循环的将收到的消息发送给acker。Spout的task接收的消息是指acker发送的消息,acker会将tuple的acker结果发送给消息的发起者spout task,这个发起者会调用spout的ack方法或者fail方法。
Bolt
a. 发送消息之前会确认要发送到哪个taskid,同样由分组的方式决定的。
b. task会循环的将收到的tuple信息传给bolt的execute方法,循环执行execute方法,execute一般会调用IOutputCollector中的emit方法,该方法会将产生的新的tupleid异或的结果存起来,将tuple和要发送的目标taskid写入到所属worker的队列中。如果调用ack方法,则会将新的tupleid异或的结果和收到的tupleid进行异或,结果发送给acker。最后acker会将tuple的结果(发送成功/发送失败)以tuple的形式报告给spout。
备注:
The complete latency: is the timefrom the spout emitting a tuple to that tuple being acked on the spout. So it tracks the time for the whole tuple tree to be processed.
The process latency:It's thetime from a bolt receiving a tuple to the time the bolt acks that tuple.
每个处理的tuple,必须被ack或者fail。因为storm追踪每个tuple要占用内存。所以如果你不ack/fail每一个tuple,那么最终你会看到OutOfMemory错误。