职位描述:
蚂蚁金服中间件团队是服务于整个蚂蚁金服集团的核心技术团队,打造了世界领先的金融级分布式架构的基础中间件平台。欢迎加入我们,一起挑战核心技术,拓展商业边界!
消息系统广泛应用于蚂蚁金服的各项业务中,是支撑双十一、双十二以及新春红包等大型活动的重要系统,每天线上有万亿规模的消息流转,支持复杂的 LDC 单元化消息路由,并且需要具备高性能、高可用、高可靠和强一致等特性,具有非常高的技术挑战。随着云原生时代的到来,消息系统正在进行 Message Mesh 和 Serverless 架构的探索和升级。
另外,消息系统也在基于蚂蚁金融科技开放战略进行商业化输出,是金融分布式架构的重要技术组件。欢迎您一起来参与金融级分布式消息系统、云原生时代的下一代消息系统的架构设计和研发,以及消息系统商业化输出
职位要求:
重点来了:可以年前先面试,年后再入职。P6可以不需要消息的背景,有中间件的背景即可。
联系邮箱:
qihuang.zqh at antfin.com
为了减少客户端每次拉取都要拉取全部的分区,增加了增量拉取分区的概念。
拉取会话(Fetch Session),类似于web中的session是有状态的,客户端的fetch也可以认为是有状态的。
这里的状态指的是知道“要拉取哪些分区”,如果第一次拉取了分区1,如果后续分区1没有数据,就不需要拉取分区1了。
FetchSession的数据结构如下:
1 | case class FetchSession(val id: Int, // session编号是随机32位数字,防止未授权的客户端伪造数据 |
为了支持增量拉取,FetchSession需要维护每个分区的以下信息:
因为Follower或者Consumer发送拉取请求都是到Leader,所以FetchSession也是记录在Leader节点上的
FetchRequest Metadata(客户端的拉取请求元数据)
sessionId | epoch | 含义 |
---|---|---|
0 | -1 | 全量拉取(没有使用或者创建session时) |
0 | 0 | 全量拉取(如果是新的会话,epoch从1开始) |
$ID |
0 | 关闭标识为$ID 的增量拉取会话,并创建一个新的全量拉取 |
$ID |
$EPOCH |
创建增量拉取 |
对于客户端而言,什么时候一个分区会被包含到增量的拉取请求中:
Fetch Response Metadata(服务端返回给客户端的sessionId)
sessionId | 含义 |
---|---|
0 | 之前没有创建过拉取回话 |
$ID |
下一个请求会是增量的拉取请求,并且sessionId是$ID |
服务端增加分区包含到增量的拉取响应中:
Fetcher.java#sendFetches(): prepareFetchRequests创建FetchSessionHandler.FetchRequestData。
构建拉取请求通过FetchSessionHandler.Builder,builder.add(partition, PartitionData)会添加next:
即要拉取的分区。构建时调用Builder.build(),针对Full拉取:
1 | // FetchSessionHandler.Builder.build() |
收到响应结果后,通过sessionHandler,调用FetchSessionHandler.handleResponse()。
假设第一次是Full拉取,响应结果没有出错时,nextMetadata.isFull()仍然为true。
假设服务端创建了一个新的session(随机的唯一ID),客户端的Fetch SessionId会设置为服务端返回的sessionId,
并且epoch会增加1。这样下次客户端的拉取就不再是Full,而是Increment了(toSend, toForget分别表示要拉取的和不需要拉取的)。
同样假设服务端正常处理(这次不会生成新的session),客户端也正常处理响应,则sessionId不会增加,但是epoch会增加1
1 | public boolean handleResponse(FetchResponse<?> response) { |
服务端处理拉取请求时,会创建不同类型的FetchContext:
1 | // KafkaApis.handleFetchRequest |
服务端的FetchManager创建Context时,如果FetchMetadata.isFull,再判断epoch=-1时,类型为SessionlessFetchContext,
否则(epoch=0)时,类型为FullFetchContext。如果!isFull(),必须保证session.epoch = FetchMetadata.epoch,否则类型为SessionErrorContext。
当!isFull且epoch相等时,先增加session.epoch(服务端的epoch,即为客户端下次拉取的epoch),然后返回类型为IncrementalFetchContext。
FullFetchContext更新响应数据,对于全量拉取,一般是新会话,所以需要更新缓存
1 | override def updateAndGenerateResponseData(updates: FetchSession.RESP_MAP): FetchResponse[Records] = { |
总结下客户端和服务端的Full拉取过程:
1.客户端创建的拉取请求FetchMetadata.isFull(),初始时epoch=0
2.服务端创建的FetchContext类型为FullFetchContext
3.服务端创建新的Session(xxx),以及初始化epoch=1(0+1=1),并缓存
4.客户端收到服务端的FetchResponse,设置FetchMetadata.sessionId为response中的sessionId(xxx),并增加epoch=1(从步骤1的0+1=1)
5.客户端继续拉取,isFull=false,sessionId=xxx, epoch=1
6.服务端创建的FetchContext类型为IncrementalFetchContext(满足session.epoch=reqMetadata.epoch=1, isFull=false)
7.服务端增加epoch,设置session.epoch=2,为下次的拉取(对比epoch)做准备
8.对reqMetadata.epoch加1(=2)然后对比session.epoch(2),如果不等,返回错误码INVALID_FETCH_SESSION_EPOCH,相等返回NONE
9.客户端收到服务端的FetchResponse,设置epoch增加1(sessionId没有变化时,不需要更新sessionId,实际上设置的是nextMetadata对象)
Update:
2017-11-22: 公司内部做的一个分享:Kafka构建流式数据处理平台
本书主要以0.10版本的Kafka源码为基础,并通过图文详解的方式分析Kafka内部组件的实现细节,全书原创的图片有近400幅。对于Kafka流处理的一些新特性,也会分析0.11版本的相关源码。本书各个章节的主要内容如下。
Reactor
模式处理客户端的请求。KStream
与KTable
,它们都定义了一些常用的流处理算子操作,比如无状态的操作(过滤、映射等)、有状态的操作(连接、窗口等)。本书相关的示例代码在笔者的Github主页https://github.com/zqhxuyuan/kafka-book上,另外,限于篇幅,本书的附录部分会放在个人博客上。由于个人能力有限,文中的错误在所难免,读者在阅读本书的过程中,发现不妥之处,可以私信笔者的微博:http://weibo.com/xuyuantree,笔者会定期将勘误表更新到个人博客上。
《Apache Kafka Internal》
This book mostly based on Kafka-0.10, and some part of 0.11 for streaming. It has nearly 400 pictures to analysis Kafka internal implementation. The book written from client to coordinator, from storage to controller, and also including Kafka Connect and Kafka Streams. Here is content introduction of each chapter:
Chapter 1: Being a streaming platform, kafka composed of message system, storage and streaming processing. There are three model of Kafka basic concepts: Partition model, Consumer model and Distributed model. We also introduce some important design ideas of kafka, such as file system persistent, data transformation, producer and consumer, replication and HA.
Chapter 2: From a producer example into how client send message. The whole workflow include record accumulator, sender thread, grouping message, create request and at last send to different target broker. Then we introduce Kafka channel, selector and also how server use NIO reactor to handle client request.
Chapter 3: From a old high-level consumer example into zookeeper based api. The most important of high-level consuemr is consumer thread model. Then we introduce two approach to commit consumer offset which is zookeeper or internal topic. After that, we illustrate how to write low-level consumer to ensure processing messages stability and robust.
Chapter 4: New version consumer client use subscription state and polling fetch instead of fetcher thread. We also introduce how consumer use callback, handler, listener, adapter, chain to implement different asynchronous request mode. Last we introduce heartbeat, offset commit and three consumer processing semantic: at-most-once,at-least-once,exactly-once.
Chapter 5: New consumer communicate with server coordinator by ConsumerCoordinator, there’re mainly two request/response involved: Join-group and Sync-group. This process also called consumer group rebalance. We also discussed how server coordinator use state machine to ensure group state transformation, such as PreparingRebalance,AwaitingSync,Stable. This chapter also give some different scene to help reader understand how consumer group worked in production environment.
Chapter 6: Kafka’s storage layer process include log read/write, log manager, log compaction. In server side, ReplicationManager is responsible for client’s request. Then we introduce Replication mechanism concepts, such as Partition, Replication, HW, LEO. Last we introduce delayed operation and delayed purgatory. If server can’t response immediately to client, they have to cache request and send response to client some times later.
Chapter 7: Kafka Controller component is in charge of managing PartitionState, ReplicationState, and some listeners, such as broker up/down, topic deletion, partition reassign. The main duty of controller is selecting partition’s leader and sent LeaderAndIsr request down to brokers. Target brokers receiving request will decide to be partition leader or follower. Furthermore, we introduce the different between local replication and remote replication, also the function of metadata cache.
Chapter 8: First we introduce two kind of cluster synchronization: Kafka internal MirrorMaker and Uber open sourced uReplicator, we also show how apache helix build replicated uReplicator. Next we introduce new build-in kafka connect framework and how to develop a custom connector plugin. Then we deep into connector’s architecture, mainly concentrate on data model, connector model, worker model.
Chapter 9: Introduce Kafka Streams two api: low-level processor and high-level DSL. This chapter focus on streaming thread model, including stream instance, thread and task. We also introduce local state store used by standby task for recovery. After that, we introduce two abstract components in High-level DSL: KStream and KTable, they both based on low-level processor, support common operator and advance function, such as window, join and so on.
Chapter 10: Introduce some advanced features. such as client quota, new message format in 0.11 and also transaction support.
TODO
下面是以前写的一些博客,当然实际的书籍已经改动很大了,下面的一些博文仅供参考。
我们可以手动创建主题或者让Kafka自动创建主题,手动创建主题必须指定分区数和副本因子。如果服务端开启了自动创建主题,新数据写入一个不存在的主题,服务端会自动创建这个主题。自动模式下主题的配置信息在server.properties文件中,比如分区数默认只有一个。因为分区是Kafka的最小并行单位,所以我们一般会根据集群规模设置合理的分区数,来达到客户端和服务端的负载均衡。副本因子(replication-factor
)是分区的副本数量,每条消息会复制到多个节点上,一般设置为3个副本。假设副本数为N,则最多允许N - 1个节点宕机。 下面的实验在本机安装Kafka,假设ZK的端口为2181,Kafka的端口为9092。
1 | # 创建主题 |
在0.8.2版本之后,Kafka提供了删除主题的功能,但是默认并不会直接将Topic数据物理删除。如果要启用物理删除(即删除主题后,日志文件也会一同删除),需要在server.properties中设置delete.topic.enable=true
。
1 | $ bin/kafka-topics.sh --zookeeper localhost:2181 --delete --topic test |
管理员创建好主题后,主题会被生产者和消费者使用。注意下面的实验中,新版本的生产者和消费者都是使用Broker地址连接Kafka集群,旧版本的消费者则使用ZK地址连接Kafka集群。
在终端控制台模拟生产消息和消费消息,每个控制台的消费者都会被分配唯一的消费组:
1 | # 生产者 |
执行查看消费组列表的操作,可以列出当前活动的消费组,默认控制台的消费组是console-consumer
加上一个随机数。上面由于分别启动了两个版本的消费者,所以对应了两个消费组。当然,也可以在控制台通过其他参数来指定消费组。
1 | # 查看使用旧消费者的消费组列表 |
查看消费组对某个主题的消费状态,需要指定主题和消费组,这会打印出主题的所有分区、日志的大小、所属的消费者等。
采用新消费者方式的Owner
为none
:
1 | $ bin/kafka-consumer-offset-checker.sh --zookeeper localhost:2181 \ |
要向已有的Kafka集群添加新节点,我们只需要保证broker.id
编号是唯一的,即可启动Kafka服务。但是新节点不会自动地分配到分区,除非在新加节点之后,新创建了主题。因此,通常我们希望在新添加节点后,能够将旧节点上的分区迁移一部分到新节点上,从而达到负载均衡的目的。迁移分区,实际上是将新节点作为分区的备份副本,当新节点完全复制了一个分区的所有数据,并且加入分区的ISR集合后,旧节点已有的一个副本就会被删除。在整个迁移过程中,分区的副本数保持不变,只不过分区的所属节点从旧节点迁移到了新节点。Kafka提供了分区重新分配(partition reassignment tool
)的工具来在不同节点之间移动分区,但该工具并不会自动学习Kafka集群的数据分布来移动分区达到数据的均匀分布,管理员需要手动指定哪些主题或分区需要移动。使用该工具需要执行下面的3个步骤。
--generate
:给定主题和需要移动到的目标节点,生成候选的分区分配计划。--execute
:根据上一步的分区分配计划或者手动定义的计划执行数据迁移的任务。--verify
:验证上一步执行任务涉及的所有分区的分配状态是否已经完成。下面的示例会将foo1
和foo2
主题的所有分区全部移动到新的节点5、6上,最后这两个主题的所有分区都只在5、6节点上。第一步生成计划时,会列举出当前主题所有分区目前所在的节点,如果执行失败,管理员还可以进行回滚操作。
1 | # [1] 生成分区分配计划,指定需要移动的主题和需要移动到的目标节点 |
除了给定主题,由工具生成所有分区的执行计划,我们也可以直接指定主题需要迁移的分区(当然在execute
阶段,工具还是会列出指定主题分区当前所在的节点):
1 | $ cat custom-reassignment.json |
除此之外,迁移工具还适用于给分区增加副本数。增加副本数是复制(而不是移动)已有的分区到其他节点,不管使用手动还是自动生成的分配计划,都要包含分区之前所在的节点。下面的示例中,foo
主题的分区0只有一个副本是存在节点5上,增加到3个副本后,存在的节点有5、6、7这3个节点。
1 | $ cat increase-replication-factor.json |
注意:修改主题的分区数可以直接采用修改主题的方式,但是修改分区的副本数涉及数据的复制,需要用到上面的分区迁移工具。
Security
)Kafka的安全机制主要分为下面两个部分:
Authentication
):对客户端与服务器的连接进行身份认证。Kafka目前支持SSL
、SASL/Kerberos
、SASL/PLAIN
三种认证机制。Authorization
):对消息级别的访问控制列表(ACL)权限控制。下面以SASL/PLAIN
的身份认证为例,服务端需要先修改下面三个配置文件,然后启动服务端:
1 | $ vi config/server.properties |
客户端也需要添加两个配置项,下面以控制台的生产者和消费者为例,说明客户端的身份认证:
1 | $ vi config/producer.properties |
如果使用代码,还需要设置java.security.auth.login.config
为系统的环境变量配置。下面是生产者使用身份认证的示例:
1 | public class KafkaProducerDemo { |
上面我们只分析了SASL_PLAINTEXT
安全协议的例子,Kafka支持的其他安全协议以及权限认证可以参考官方的文档。另外,服务端与ZooKeeper以及服务端之间也都有安全机制和身份认证机制,这里就不再深入分析。
Kafka官方文档中针对服务端(代理节点)、主题、生产者、消费者都有完整的配置说明,下面列举了比较重要的一些配置项。
服务端的配置项参见表1。
表1 服务端配置信息
配置项 | 说明 |
---|---|
broker.id |
Kafka服务器的编号,同一个集群不同节点的编号应该唯一 |
zookeeper.connect |
连接ZooKeeper的地址,不同Kafka集群如果连接到同一个ZooKeeper,应该使用不同的chroot路径 |
auto.create.topics.enable |
自动创建主题,默认为true |
auto.leader.rebalance.enable |
开启主副本自动平衡,当节点宕机后,会影响这个节点上的主副本转移到其他节点,宕机的节点重启后只能作为备份副本,如果开启平衡,则会将主副本转移到原节点 |
delete.topic.enable |
自动删除主题,默认为false ,通过delete 命令删除主题,并不会物理删除,只有开启该选项才会真正删除主题的日志文件 |
log.dirs |
日志文件的目录,可以指定多个目录。默认是/tmp/kafka-logs |
log.flush.interval.messages |
在消息集刷写到磁盘之前需要收集的消息数量,默认值为Long.MAX |
log.flush.scheduler.interval.ms |
日志刷新线程过久,检查一次是否有日志文件需要刷写到磁盘,默认值为Long.MAX 。 |
log.retention.bytes |
日志文件超过最大大小时删除旧数据,默认值为-1 ,即永不会删除 |
log.retention.hours |
日志文件保留的时间,默认为168小时,即7天 |
log.segment.bytes |
单个日志文件片段的最大值,默认为1 GB,日志超过1 GB后会刷写到磁盘 |
message.max.bytes |
服务端接收的消息最大值,默认为1 MB,即一批消息最大不能超过1 MB |
min.insync.replicas |
当生产者的应答策略设置为all 时,写操作的数量必须满足该值才算成功。默认值为1 ,表示只要写到一个节点就算成功 |
offsets.commit.required.acks |
消费者提交偏移量和生产者写消息的行为类似,用应答来表示写操作是否成功,默认值为-1 |
offsets.commit.timeout.ms |
类似于生产者的请求超时时间,写请求会被延迟,默认5秒 |
offsets.topic.num.partitions |
消费者提交偏移量内部主题的分区数量,默认为50个 |
offsets.topic.replication.factor |
消费者提交偏移量内部主题的副本数量,默认为3个 |
replica.fetch.min.bytes |
每个拉取请求最少要拉取的字节数量,默认为1byte。 |
replica.fetch.wait.max.ms |
每个拉取请求的最大等待时间,默认为500毫秒 |
replica.lag.time.max.ms |
备份副本在指定时间内都没有发送拉取请求,或者在这个时间内仍然没有赶上主副本,它将会被从ISR中移除,默认10秒 |
request.timeout.ms |
客户端从发送请求到接收响应的超时时间,默认30秒 |
zookeeper.session.timeout.ms |
ZooKeeper会话的超时时间,默认6秒 |
default.replication.factor |
自动创建的主题的副本数,默认为1个 |
log.cleaner.delete.retention.ms |
被删除的记录保存的时间,默认为1天 |
log.cleaner.enable |
是否开启日志清理线程,当清理策略为compact 时,建议开启 |
log.index.interval.bytes |
添加1条索引到日志文件的间隔,默认为4096条 |
log.index.size.max.bytes |
索引文件的最大大小,默认为10 MB |
num.partitions |
每个主题的分区数量,默认为1个 |
replica.fetch.max.bytes |
拉取请求中每个分区的消息最大值,默认为1 MB |
replica.fetch.response.max.bytes |
整个拉取请求的消息最大值,默认为10 MB |
主题级别的一些配置和服务端级别的设置类似,比如flush.messages
类似log.flush.interval.messages
,表示刷写到磁盘的消息数量;flush.ms
类似log.flush.scheduler.interval.ms
,表示刷写到磁盘的时间间隔;max.message.bytes
类似message.max.bytes
,表示服务端接收的单条消息大小。
生产者配置信息参见表2。
表2 生产者配置信息
配置项 | 说明 |
---|---|
bootstrap.servers |
生产者客户端连接Kafka集群的地址和端口,多个节点用逗号分隔 |
acks |
生产者请求要求主副本收到的应答数量满足后,写请求才算成功。0 表示记录添加到网络缓冲区后就认为已经发送,生产者不会等待服务端的任何应答;1 表示主副本会将记录到本地日志文件,但不会等待任何备份副本的应答;-1 或all 表示主副本必须等待ISR中所有副本都返回应答给它 |
retries |
发送时出现短暂的错误或者收到错误码,客户端会重新发送记录。如果max.in.flight.requests.per.connection 没有设置为1 ,在异常重试时,服务端收到的记录可能是乱序的 |
buffer.memory |
生产者发送记录给服务端在客户端的缓冲区,默认为32 MB |
batch.size |
当多条记录发送到同一个分区,生产者会尝试将一批记录分成更少的请求,来提高客户端和服务端的性能,默认每一个Batch的大小为16 KB。如果一条记录就超过了16 KB,则这条记录不会和其他记录组成Batch。Batch太小会减小吞吐量,Batch太大会占用太多的内存 |
max.request.size |
一个请求的最大值,实际上也是记录的最大值。注意服务端关于记录的最大值(Broker的message.max.bytes ,或者Topic的max.message.bytes )可能和它不同(实际上默认值都是1 MB)。这个配置项会限制生产者一个请求中Batch的记录数,防止发送过大的请求 |
partitioner.class |
消息的分区语义,对消息进行路由到指定的分区,实现分区接口 |
request.timeout.ms |
客户端等待一个请求的响应的最长时间,超时后客户端会重新发送或失败 |
timeout.ms |
服务端等待备份的应答来达到生产者设置的ack 的最长时间,超时后不满足失败 |
新消费者的配置信息参见表3。
表3 新消费者的配置信息
配置项 | 说明 |
---|---|
fetch.min.bytes |
拉取请求要求服务端返回的数据最小值,如果服务端的数据量还不够,客户端的请求会一直等待,直到服务端收集到足够的数据才会返回响应给客户端。默认值为1个字节,表示服务端处理的拉取请求数据量只要达到1个字节就立即收到响应,或者因为在等待数据的到达一直没有满足最小值时而超时后,拉取请求也会结束。将该值设置大一点,可以牺牲一些延迟来获取服务端更高的吞吐量 |
fetch.max.bytes |
服务端对一个拉取请求返回数据的最大值,默认值为50 MB |
fetch.max.wait.ms |
在没有收集到满足fetch.min.bytes 大小的数据之前,服务端对拉取请求的响应会阻塞直到超时,默认500毫秒 |
group.id |
消费者所述的唯一消费组名称,在使用基于Kafka的偏移量管理策略,或者使用消费组管理协议的订阅方法时,必须指定消费组名称 |
heartbeat.interval.ms |
使用消费组管理协议时消费者和协调者的心跳间隔,心跳用来确保消费者的会话保持活动的状态,以及当有新消费者加入或消费者离开时可以更容易地进行平衡,该选项必须比session.timeout.ms 小,通常设置为不大于它的1/3。默认值为3秒,我们可以将心跳值设置得更低,来更好地控制平衡:需要平衡时,心跳间隔越短就能越快地感知到 |
max.partition.fetch.bytes |
服务端返回的数据中每个分区的最大值,默认值为1 MB |
session.timeout.ms |
使用消费组管理协议检测到消费者失败的最大时间,消费者定时地向Broker发送心跳表示处于存活状态。服务端的Broker会记录消费者的心跳时间,如果在指定的会话时间内都没有收到消费者的心跳,Broker会将其从消费组中移除并启动一次平衡 |
auto.offset.reset |
Kafka中没有分区的初始偏移量,消费者任何定位分区位置。earliest 表示重置到最旧的位置;latest 表示重置到最新的位置,默认值为latest |
enable.auto.commit |
消费者的偏移量是否会在后台定时地提交,默认值为true |
auto.commit.interval.ms |
消费者自动提交偏移量的时间间隔,默认值为5秒 |
max.poll.interval.ms |
使用消费组管理协议时,在调用poll() 之间的最大延迟,它设置了消费者在下一次拉取更多记录之前允许的最长停顿时间。如果超时后消费者仍然没有调用poll() ,那么消费者就会被认为失败了,就会启动消费组的平衡,默认值为5秒 |
max.poll.records |
在一次poll() 调用中允许返回的最大记录数,默认值为500条 |
partition.assignment.strategy |
使用消费者管理协议时,消费者实例之间用来进行分区分配的策略,默认值为RangeAssignor |
Kafka的ZooKeeper配置和命令行的ZooKeeper地址不一致导致连接不上ZooKeeper,下面是server.properties的ZooKeeper连接配置,指定了Kafka在ZooKeeper中的根节点是/kafka
:
1 | broker.id=0 |
如果命令行中连接的ZooKeeper地址没有加上/kafka
,创建主题会报错可用的节点为0,加上/kafka
后可以成功创建主题:
1 | $ bin/kafka-topics.sh --create --zookeeper localhost:2181 \ |
生产者连接的是Kafka代理节点的地址,和ZooKeeper没有关系。而旧消费者连接的是ZooKeeper,所以也要加上/kafka
才能读取到消息:
1 | $ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test |
上面的实验通过在Kafka服务端的配置文件中设置ZooKeeper根节点,可以在一个ZooKeeper中区分多个Kafka集群。下面的实验就利用了该功能。
MirrorMaker
演示消费者线程数量单机模拟多个Kafka集群,每个集群各自只有一台服务器。不同Kafka集群的zookeeper.connect
配置项分别是:localhost:2181/kafka
和localhost:2181/kafka_dc
(这两个集群叫作kafka集群、kafka_dc集群)。查看ZooKeeper的节点,因为是不同的Kafka集群,所以代理节点的编号可以一样(当然由于在本机模拟多个集群,端口号不能一样):
1 | [zk: localhost:2181(CONNECTED) 0] ls / |
在Kafka集群创建分区数只有一个的主题test
,然后启动MirrorMaker
,设置消费者线程数量为3:
1 | $ bin/kafka-mirror-maker.sh --num.streams 3 \ |
ZooKeeper中消费者的数量也有3个,但是因为分区只有一个,消费者Owner
也只有一个:
1 | [zk: localhost:2181] ls /kafka/consumers/mm/ids |
因为消费者数量比分区的数量要多,所以有些消费者会分配不到分区。在执行MirrorMaker
程序时,控制台会提示有两个消费者线程没有分配到分区。
1 | WARN No broker partitions consumed by consumer thread |
通过控制台的消费者检查Mirror
(kafka_dc)目标集群是否有数据写入,可以看到虽然我们没有在kafka_dc集群创建test
主题,但是通过镜像工具,源集群的数据会复制到目标集群。
1 | $ bin/kafka-topics.sh --list --zookeeper localhost:2181/kafka_dc |
检查消费组所有消费者的消费情况,也只有一个消费者:
1 | $ bin/kafka-run-class.sh kafka.tools.ConsumerOffsetChecker \ |
Kafka提供了一些工具类,包括生产者和消费者的性能测试,端到端的延迟。下面的实验是在一个小型的Kafka集群上,并且测试主题test-rep-3
有3个副本、6个分区:
1 | $ zookeeper=192.168.6.55:2181,192.168.6.56:2181,192.168.6.57:2181/kafka010 |
接着对生产者和消费者进行性能测试(笔者的测试环境还有其他服务,所以测试结果并不是很理想,如果要对Kafka进行压测,最好模拟线上的机器配置):
1 | #####生产者性能测试##### |
在生产者的测试过程中,有些分区由于网络或者其他原因会对ISR进行调整,日志如下:
1 | INFO Partition [test-rep-3,1] on broker 0: Shrinking ISR from 0,1,3 to 0,1 |
这时如果查看主题信息,会发现主题中每个分区的ISR和最开始创建的时候不同。不过等生产者测试运行完毕,再过一段时间,就会恢复到刚开始的ISR,这是因为默认开启了主副本自动迁移:
1 | $ bin/kafka-topics.sh --describe --zookeeper $zookeeper --topic test-rep-3 |
Confluent的各个组件和默认端口如下:
Component | Default Port |
---|---|
Zookeeper | 2181 |
Apache Kafka brokers (plain text) | 9092 |
Schema Registry REST API | 8081 |
REST Proxy | 8082 |
Kafka Connect REST API | 8083 |
Confluent Control Center | 9021 |
安装包主要有三个目录:
1 | confluent-3.3.0/bin/ # Driver scripts for starting/stopping services |
启动各个组件:
1 | ./bin/zookeeper-server-start ./etc/kafka/zookeeper.properties & |
Confluent商业产品的一个重要功能是控制中心(Controll Center)。在启动控制中心之前呢,需要修改下面三个文件的配置信息:
1 | sed 's/#metric.reporters=io.confluent.metrics.reporter.ConfluentMetricsReporter/metric.reporters=io.confluent.metrics.reporter.ConfluentMetricsReporter/g' && \ |
接着启动confluent-control-center和分布式的Kafka连接器集群:
1 | bin/control-center-start etc/confluent-control-center/control-center.properties & |
然后执行一些性能测试,比如执行生产者和消费者的性能测试脚本:
1 | bin/kafka-topics --zookeeper localhost:2181 --create \ |
打开浏览器:http://192.168.6.53:9021,观察到页面实时显示集群的相关度量曲线图:
自带的kafka-connect-elasticsearch插件的相关文件:
1 | [qihuang.zheng@dp0653 confluent-3.2.1]$ ll etc/kafka-connect-elasticsearch/ |
客户端的连接对象(NetworkClient
)在轮询时会判断是否需要更新元数据。客户端调用元数据更新器的maybeUpdate()
方法,并不一定每次都需要更新元数据。只有当元数据的超时时间(metadataTimeout
)等于0
时,客户端才会发送元数据请求。
客户端调用选择器的轮询方法,最长的阻塞时间会在“轮询时间(pollTimeout
)、元数据的更新时间(metadataTimeout
)、请求的超时时间(requestTimeoutMs
)”三者中选取最小值。如果元数据的更新时间等于0,表示客户端会立即发送元数据请求,不会阻塞。下面解释这几个时间变量的数据来源,以及它们在发送请求过程中所代表的含义。
requestTimeoutMs
变量,对应的配置项是request.timeout.ms
,默认值30
秒。该配置表示生产者等待收到响应结果的最长时间。如果生产者在这个时间超时后没有收到响应结果,就会认为生产请求失败,它可以重新发送生产请求。retryBackoffMs
变量,对应的配置项是retry.backoff.ms
,默认值100
毫秒。该配置表示客户端发送请求失败时,为了避免在短时间内客户端重复地发送请求导致重试次数用光,客户端必须要等待一小会儿才允许发送新的请求。这个配置项可用于元数据请求、生产请求和拉取请求,但只有在发送失败时才会用到。该配置会传给元数据对象(元数据请求)、记录收集器(生产请求)。lingerMs
变量,对应的配置项是linger.ms
,默认值为0
毫秒。该配置表示生产者在发送请求之前是否会延迟等待一段时间收集更多的消息。如果等于0,表示生产者会立即发送请求。1 | // 客户端的网络连接对象在每次轮询之前,都会判断是否需要更新元数据 |
客户端每次轮询收到元数据请求的响应结果后,会解析成Cluster
对象,然后更新元数据对象。
元数据对象有多个用于控制元数据更新策略的变量,相关的时间配置项主要有下面几个。
metadata.fetch.timeout.ms
(生产者的maxBlockTimeMs
变量,默认值为60
秒):生产者第一次发送消息,如果主题没有分区,它等待元数据更新的最长阻塞时间(第7.3.2节第三小节)。metadata.max.age.ms
(元数据的metadataExpireMs
变量,默认值为五分钟):即使不需要更新元数据,客户端也需要间隔一段时间更新一次元数据。retry.backoff.ms
(元数据的refreshBackoffMs
变量,默认值为100
毫秒):客户端多次发送元数据请求,需要等待一小段时间再发送元数据请求。元数据的更新时间主要与后两项配置有关。refreshBackoffMs
变量用来计算允许更新的时间(timeToAllowUpdate
),metadataExpireMs
变量用来计算失效的时间(timeToExpire
)。默认情况下,retry.backoff.ms
等于100
毫秒时,允许更新的时间一般小于0
。timeToNextUpdate()
方法主要取决于失效的时间,下面列举了几种不同的场景。
0
,表示需要立即更新元数据。注意:元数据对象的
metadataExpireMs
和refreshBackoffMs
都是固定的值,timeToNextUpdate()
方法依赖needUpdate
和上次的更新时间,来计算下次更新元数据的时间。当调用元数据对象的requestUpdate()
方法和update()
方法时,才会分别更新needUpdate
和上次的更新时间。
1 | public final class Metadata { |
元数据对象的每个方法都加上了synchronized
关键字,即使有多个客户端线程(用户线程)使用同一个生产者示例,并且访问相同的元数据对象,也是线程安全的。awaitUpdate()
方法只会被生产者在的waitOnMetadata()
方法调用。如果元数据的版本号(this.version
)小于上一次的版本号(lastVersion
),用户线程会通过wait()
进入阻塞状态。调用元数据对象的update()
方法,更新版本号,并通知用户线程退出awaitUpdate()
方法。
元数据对象除了会更新元数据内容,还有一个保存集群配置的Cluster
对象。Cluster
保存了分区信息相关的变量,分区信息包括分区的主副本、ISR
、AR
等内容。第二章生产者客户端发送消息时,利用“分区信息”为消息指定分区编号。本章从控制器、LeaderAndIsr
请求,最后到Metadata
请求,与第二章的“分区信息”互相呼应,算是画上了一个圆满的句号。
1 | public final class Cluster { // 集群配置 |
下面举例了生产者发送两条消息,为了模拟发送第一条消息时,生产者必须要等待元数据更新完成。下面的代码会在第一条消息发送完成后等待一秒钟才发送第二条消息。
1 | // 生产者发送消息的示例 |
为了更清晰地理解元数据、NetworkClient
一些变量的含义,在必要的地方加上了日志(比如needUpdate
、metadataTimeout
等)。将日志级别调成TRACE
后,更详细的日志如下。
1 | [18:00:04,596] TRACE Starting the Kafka producer |
如图1所示,将上面日志中一些重要的时间点与事件抽取出来,具体步骤如下。
图1 生产者发送消息与更新元数据的过程
Kafka作为一个流式数据平台,对开发者提供了三种客户端:生产者/消费者、连接器、流处理。本文着重分析这三种客户端的线程模型。
0.8版本以前的消费者客户端会创建一个基于ZK的消费者连接器,一个消费者客户端是一个Java进程,消费者可以订阅多个主题,每个主题也可以多个线程。为了让消息在多个节点被分布式地消费,提高消息处理的吞吐量,Kafka允许多个消费者订阅同一个主题,这些消费者需要满足“一个分区只能被一个消费者中的一个线程处理”的限制条件。通常,我们会将同一份相同业务处理逻辑的应用程序部署在不同机器上,并且指定一个消费组编号。当不同机器上的消费者进程启动后,所有这些消费者进程就组成了一个逻辑意义上的消费组。
消费组中的消费者数量是动态变化的,当有新消费者加入消费组,或者旧消费者离开消费组,都会触发基于ZK的消费组“再平衡”操作。当“再平衡”操作发生时,每个消费者都会在客户端执行分区分配算法,然后从全局的分配结果中获取属于自己的分区。它的缺点是消费者会和ZK产生频繁的交互,造成ZK集群的压力过大,并且容易产生羊群效应和脑裂等问题。
在0.8版本以后,Kafka重新设计了客户端,并且引入了“协调者”和“消费组管理协议”。新的消费者将“消费组管理协议”和“分区分配策略”进行了分离。协调者负责消费组的管理,而分区分配则会在消费组的一个主消费者中完成。采用这种方式,每个消费者都需要发送下面两种请求给协调者。
新版本的消费者客户端引入了一个客户端协调者的抽象类,它的实现除了消费者的协调者,还有一个连接器的实现。
Kafka连接器的出现标准化了Kafka与各种外部存储系统的数据同步。用户开发和使用连接器就变得非常简单,只需要在配置文件中定义连接器,就可以将外部系统的数据导入Kafka或将Kafka数据导出到外部系统。如图1所示,中间部分都是Kafka连接器的内部组件,包括源连接器(Source Connector)和目标连接器(Sink Connector)。
图1 Kafka连接器的源连接器与目标连接器
Kafka连接器的单机模式会在一个进程内启动一个Worker以及所有的连接器和任务。分布式模式的每个进程都有一个Worker,而连接器和任务则分别运行在各个节点上。图2列举了连接器和任务在不同Worker上的四种分布方式:
图2 分布式模式的Kafka连接器集群
分布式模式下,不同Worker进程之间的协调工作类似于消费者的协调。消费者通过协调者获取分配的分区,Worker也会通过协调者获取分配的连接器与任务。如图3所示,消费者客户端和Worker客户端为了加入到组管理中,分别通过客户端的协调者对象来和服务端的消费组协调者(GroupCoordinator)通信。
图3 消费者和Worker的工作都是通过协调者分配的
Kafka流处理的工作流程简单来看分成三个步骤:消费者读取输入分区的数据、流式地处理每条数据、生产者将处理结果写入输出分区,这里面步骤1也充分利用了“消费组管理协议”。Kafka流处理的输入数据源基于具有分布式分区模型的Kafka主题,它的线程模型主要由下面三个类组成:
如图4所示,输入主题有六个分区,Kafka流处理总共就会产生六个流任务。流实例可以动态扩展,流线程的个数也可以动态配置。图中一共有三个流线程,则每个流线程会有两个流任务,每个流任务都对应输入主题的一个分区。
图4 Kafka流处理的线程模型
Kafka的流处理框架使用并行的线程模型处理输入主题的数据集,这种设计思路和Kafka的消费者线程模型非常类似。消费者分配到订阅主题的不同分区,流处理框架的流任务也分配到输入主题的不同分区。如图5所示,输入主题1的分区P1和输入主题2的分区P1分配给流线程1的流任务,输入主题1的分区P2和输入主题2的分区P2分配给流线程2的流任务。流处理相比消费者,还会将拓扑的计算结果写到输出主题。
图5 消费者模型与流处理的线程模型
消费者和流处理的故障容错机制也是类似的。如图6所示,假设消费者2进程挂掉,它所持有的分区会被分配给同一个消费组中的消费者1,这样消费者1会分配到订阅主题的所有分区。对于流处理而言,如果流线程2挂掉了,流线程2中的流任务会分配给流线程1。即流线程1会运行两个流任务,每个流任务分配的分区仍然保持不变。
·
图6 消费者与流处理的故障容错机制
Kafka客户端抽象出来的的“组管理协议”充分运用在消费者、连接器、流处理三个使用场景中。客户端中的消费者、连接器中的工作者、流处理中的流进程都可以看做“组”的一个成员。当增加或减少组成员时,在这个协议的约束下,每个组成员都可以获取到最新的任务,从而做到无缝的任务迁移。一旦理解了“组管理协议”,对于理解Kafka的架构设计是很有帮助的。
参考文档
]]>Java内存模型(JMM)定义了:how and when different threads can see
values written to shared variables by other threads,
and how to synchronize access to shared variables when necessary.
Java堆和栈中的对象存储位置:
Java内存模型与硬件模型:
线程读取主内存的数据到CPU缓冲中,当数据放在不同位置时,会有两个问题:可见性与静态条件
A synchronized block in Java is synchronized on some object.
All synchronized blocks synchronized on the same object can only
have one thread executing inside them at the same time.
All other threads attempting to enter the synchronized block are blocked
until the thread inside the synchronized block exits the block.
The synchronized keyword can be used to mark four different types of blocks:
Synchronized Instance methods(实例方法的同步):
静态方法的同步:
代码块的同步:
用jstack查看,同一个监视器对象只允许有一个线程访问:
实例方法的同步加上代码块this的同步,仍然针对同一个实例对象:
自定义监视器对象:
同一个实例对象的加锁:
不同实例对象的加锁:
Volatile keyword guarantees visibility of changes to variables across threads.
every read of a volatile variable will be
read from the computer’s main memory,
and not from the CPU cache.
every write to a volatile variable will be
written to main memory,
and not just to the CPU cache.
If Thread A writes to a volatile variable and Thread B subsequently reads the same volatile variable, then all variables visible to Thread A before writing the volatile variable, will also be visible to Thread B after it has read the volatile variable.
The reading and writing instructions of volatile variables cannot be reordered by the JVM. Instructions before and after can be reordered, but the volatile read or write cannot be mixed with these instructions. Whatever instructions follow a read or write of a volatile variable are guaranteed to happen after the read or write.
volatile变量不保证事务:
volatile变量仍然会存在竞态条件:
volatile变量会禁止重排序:
如果变量在volatile变量更新之后,不保证写到主存:
为了保证可见性,不需要为每个变量都定义为volatile类型:
volatile变量是个内存屏障,在这之前和之后的指令可以重排序:
本地线程的示例:
下面的上图没有使用本地线程,下图使用了本地线程:
线程的信号量实现方式–busy waiting:
或者可以用volatile变量:
wait和notify的示例:
notify与notifyAll的示例:
等待线程有可能意外被唤醒,需要用while循环继续判断是否被唤醒线程notify:
一次唤醒所有线程,或者每次一个个地唤醒:
不同线程之间采用字符串作为监视器锁,会唤醒别的线程:
不同线程之间的信号没有共享,等待线程被唤醒后继续进入wait状态:
不同线程的等待与唤醒示例:
]]>Hash Index的目的是为数据库构建一份索引,方便根据key快速查询对应的value。
Compaction操作合并多个文件,相同key只会保存一份最新的value。
SSTables和LSM树:数据写到MemTable中是排序的,刷写到磁盘上也是有序的,最后通过定期的Compaction再合并数据。
由于每个SSTable的key都是唯一的,多个SSTable文件合并时,如果key重复,选取最新Segment的值,去掉旧Segment的所有值。
读取Segment不可避免地要扫描文件,所以可以对文件进行压缩,提高I/O带宽和传输速率。
不需要为所有SSTable的key建全量索引,只需要稀疏索引。由于key是有序的,可以通过二分查找快速定位key的位置。
稀疏索引不是必须的,不过通常需要稀疏索引。如果key和value的长度是固定的,就可以不需要稀疏索引,不同实际情况value一般是变长的。
LSM树的优化方法有:为文件添加BloomFilter、不同的合并策略。
传统数据库使用B树就地更新数据。B树一般将数据库分成固定的块或页,比如4K。这样读写操作每次也是一页。
这种方式和底层硬件对应起来,比如磁盘就是按照4K固定块组织的。
新增key到B树会调整树的结构,比如拆分出两个子Page,然后更新父Page。
B树的优化方法有:Copy-On-Write、不存储整个key,而是对key进行简写、范围查询时,子页之间会有指针。
为了容错,B树和LSM都有WAL预写日志,用于节点宕机后的数据恢复。
虽然LSM在后台执行增量的Compaction操作,但是磁盘资源有限,当执行一个昂贵的Compaction,
客户端请求可能需要等待Compaction完成,造成响应时间上升。
磁盘的写带宽会被三个操作共享:写WAL日志、MemTable刷写磁盘、Compaction。
数据库一旦变得越来越大,Compaction操作需要的带宽也会越来越多。
Compaction如果没有配置好,一旦写吞吐量很高,那么Compaction操作跟不上写请求。未合并的文件会越来越多,读请求也会越来越慢。
B树和LSM树的区别是:每个键在B树中只有一条记录,但在LSM中可能存在多条。这也是B树可以提供强一致性事务的保证(只对行进行加锁)。
]]>分别启动NameServer、Broker、生产者、消费者
1 | > nohup sh bin/mqnamesrv & |
RocketMQ的数据目录在store下
1 | [qihuang.zheng@dp0652 ~]$ tree store |
数据相关的文件夹有三个:
查看commitlog的内容
1 | [qihuang.zheng@dp0652 ~]$ strings store/commitlog/00000000000000000000 | head -30 |
消费者的相关配置:
consumerOffset.json
配置文件中subscriptionGroup.json
配置文件中topics.json
配置文件中Kafka中消费者订阅信息存储在ZooKeeper中
1 | [qihuang.zheng@dp0652 ~]$ cat store/config/consumerFilter.json |
在本机测试时,没有遇到问题。但是IDE连接远程机器时,报错连接不上,这是因为服务端装了docker导致IP有问题:
1 | org.apache.rocketmq.client.exception.MQClientException: Send [3] times, still failed, cost [6915]ms, Topic: TopicTestA, BrokersSent: [dp0652, dp0652, dp0652] |
172.17.42.1这个IP地址是docker的
1 | [qihuang.zheng@dp0652 rocketmq]$ ifconfig |
用模板生成,可以看到brokerIP1就是docker的IP:
1 | [qihuang.zheng@dp0652 rocketmq]$ sh bin/mqbroker -m > broker.p |
接下来重启broker:
1 | [qihuang.zheng@dp0652 rocketmq]$ sh bin/mqshutdown broker |
重启后发送消息正常,这里把Topic改成TopicTestA:
1 | SendResult [sendStatus=SEND_OK, msgId=0A39F12CF5A6355DA25460935C280000, offsetMsgId=C0A8063400002A9F000000000002BEB2, messageQueue=MessageQueue [topic=TopicTestA, brokerName=dp0652, queueId=0], queueOffset=0] |
查看store,可以看到commitlog没有新增文件夹,而consumequeue则新增了TopicTestA文件夹:
1 | ├── commitlog |
同步的生产者:http://rocketmq.apache.org/docs/simple-example/
1 | DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); |
异步的生产者:
1 | producer.send(msg, new SendCallback() { |
一次性的生产者,主要用于日志收集:
一个 RPC 调用,通常是这样一个过程:
所以一个 RPC 的耗时时间是上述三个步骤的总和,而某些场景要求耗时非常短,但是对可靠性要求并不高,
例如日志收集类应用,此类应用可以采用 oneway 形式调用,oneway 形式只发送请求不等待应答,
而发送请求在客户端实现层面仅仅是一个 os 系统调用的开销,即将数据写入客户端的 socket 缓冲区,此过程耗时通常在微秒级。
1 | producer.sendOneway(msg); |
有序的生产者:http://rocketmq.apache.org/docs/order-example/
1 | MQProducer producer = new DefaultMQProducer("example_group_name"); |
定时生产者:http://rocketmq.apache.org/docs/schedule-example/
定时消息是指消息发到 Broker 后,不能立刻被 Consumer 消费,要到特定的时间点或者等待特定的时间后才能被消费。
如果要支持任意的时间精度,在 Broker 局面,必须要做消息排序,如果再涉及到持久化,那么消息排序要不可避免的产生巨大性能开销。
RocketMQ 支持定时消息,但是不支持任意时间精度,仅支持特定的 level,例如定时 5s,10s,1m 等。
定时消息是在生产者端设置DelayTimeLevel,消费者端不做任何处理。
1 | public class ScheduledMessageProducer { |
批量消息:http://rocketmq.apache.org/docs/batch-example/
简单的批量消息只需要构造List
拉取消费者(PullConsumer):
1 | public class PullConsumer { |
推送消费者(PushConsumer):
*
,表示所有的Tag,不进行过滤1 | DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_JODIE_1"); |
广播模式的推送消费者,相比上一个示例增加了设置消息模型(setMessageModel
),其他没有变化。
1 | DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_1"); |
过滤器的消费者。过滤器采用Push方式时,过滤逻辑在Broker实现,Broker把过滤过的数据发送给消费者。
如果过滤器采用Pull模式,所有的数据都会传送到消费者,然后在消费者端执行过滤逻辑。
1 | DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ConsumerGroupNamecc4"); |
SQL消费者(生产者发送消息时通过putUserProperty可以指定自定义的属性,除了Tag外,自定义属性也可以被过滤):
1 | DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4"); |
有序的消费者:前面几种消费者注册的监听器是:MessageListenerConcurrently,这里是MessageListenerOrderly。
1 | DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("example_group_name"); |
消费者的监听器有两种形式:并发和有序。参考:http://rocketmq.apache.org/docs/best-practice-consumer/
监听器 | 上下文 | 返回状态 | 返回码 |
---|---|---|---|
MessageListenerConcurrently | ConsumeConcurrentlyContext | ConsumeConcurrentlyStatus | CONSUME_SUCCESS |
MessageListenerOrderly | ConsumeOrderlyContext | ConsumeOrderlyStatus | SUCCESS、ROLLBACK、COMMIT、SUSPEND_CURRENT_QUEUE_A_MOMENT |
消息消费的顺序问题:
rocketmq-remoting模块采用Netty封装了RPC的调用,包括客户端和服务端之间的交互。
不同分布式系统在通信上都会实现RPC模块,比如Kafka、Hadoop等都有各自的RPC实现。
先来查看测试用例RemotingServerTest的使用方法:
以异步调用为例,RemotingClient的invokeAsync()方法主要有三个参数:
RPC调用的具体步骤如下:
1 | public static RemotingServer createRemotingServer() throws InterruptedException { |
RemotingServer的registerProcessor()方法有三个参数:
客户端调用服务端有三种方式:同步(Sync)、异步(Async)、一次性(OneWay)。前两种有响应结果,最后一种不产生响应结果。
NettyRemotingServer
在启动时,会绑定NettyServerHandler。Netty RPC的特点如下:
下面举例客户端和服务端执行一次RPC调用链路的过程:
NettyRemotingAbstract用processorTable
变量记录了请求编码、处理器、线程池之间的关系。
不同的请求编码在不同的线程池中运行,以发送消息和消费消息为例:
请求编码(request code) | 处理器 | 线程池 |
---|---|---|
SEND_MESSAGE | SendMessageProcessor | ExecutorService#1 |
GET_MESSAGE | PullMessageProcessor | ExecutorService#2 |
以经典的RPC通信模型来看,客户端向服务端发起RPC调用请求。那么processorTable
主要针对服务端,responseTable
则主要针对客户端。
那么opaque是如何在请求和响应之间进行关联的呢?下面代码中的注释说明了opaque在请求和响应之间的设置和获取流程。
opaque表示:请求发起方在同一连接上不同的请求标识代码,多线程连接复用使用
1 | protected final HashMap<Integer/* request code */, Pair<NettyRequestProcessor, ExecutorService>> processorTable = |
以example/quickstart下的Producer发送消息为例,入口方法走到DefaultMQProducerImpl的sendDefaultImpl()方法。
发送消息过程涉及下面几个步骤:
接下来进入DefaultMQProducerImpl的内核发送方法,主要的参数有:Message、MessageQueue、TopicPublishInfo
接下来进入MQClientAPIImpl的sendMessage()方法
1 | private SendResult sendMessageSync( |
生产者通过MQClientAPIImpl发起RPC调用,request请求对象的编码是SEND_MESSAGE。这里的地址指的是Broker的地址,而不是NameServer。
虽然生产者连接的是NameServer,但这中间会有选择MessageQueue,再选择Broker的过程,由于这里先关注整体的流程,暂时不去分析具体的细节。
客户端通过RemotingClient
调用了服务端Broker,接下来看服务端BrokerController
的处理。
BrokerController启动时会为各种请求类型注册不同的请求处理器,比如SEND_MESSAGE注册了SendMessageProcessor处理器:
1 | public void registerProcessor() { |
SendMessageProcessor的processRequest()方法会处理生产者客户端发送的SEND_MESSAGE请求。
客户端在发送请求之前构建了SendMessageContext
和SendMessageRequestHeader
,这里对应的会首先从RemotingCommand反解析出着两个对象
1 | public RemotingCommand processRequest(ChannelHandlerContext ctx, |
接下来进入DefaultMessageStore的putMessage()方法,这个方法会调用CommitLog的putMessage()方法
CommitLog首先获取最近的MappedFile,然后追加消息到映射文件中。
1 | public PutMessageResult putMessage(final MessageExtBrokerInner msg) { |
同样,我们省略了具体写入到CommitLog中的细节,以及如何处理磁盘的刷写、HA等细枝末节。实际上,到这里为止,
生产者客户端发起RPC调用,到服务端处理请求,服务端返回响应,客户端接收响应结果,这个过程已经分析完毕了。
PULL_MESSAGE对应的处理器是PullMessageProcessor。与生产消息调用MessageStore的putMessage()类似,
消费消息调用MessageStore的getMessage()方法,并返回GetMessageResult。
请求编码 | 消息处理器 | 消息存储 | 结果 |
---|---|---|---|
SEND_MESSAGE | SendMessageProcessor | putMessage() | PutMessageResult |
PULL_MESSAGE | PullMessageProcessor | getMessage() | GetMessageResult |
消费者还需要提交偏移量,对应ConsumerOffsetManager的commitOffset()方法。
1 | private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend) { |
存储层设计到文件操作时,生产消息会写到CommitLog,消费消息则会调用getMessage方法,给定偏移量和大小。
NameServer is a fully functional server, which mainly includes two features:
Broker server is responsible for message store and delivery, message query, HA guarantee, and so on.
Name server follows the share-nothing design paradigm. Brokers send heartbeat data to all name servers.
Producers and consumers can query meta data from any of name servers available while sending / consuming messages.
Brokers can be divided into two categories according to their roles: master and slave.
Master brokers provide RW access while slave brokers only accept read access.
To deploy a high-availability RocketMQ cluster with no single point of failure, a series of broker sets should be deployed.
A broker set contains one master with brokerId set to 0 and several slaves with non-zero brokerIDs.
All of the brokers in one set have the same brokerName. In serious scenarios,
we should have at least two brokers in one broker set. Each topic resides in two or more brokers.
Broker is a major component of the RocketMQ system.
It receives messages sent from producers, store them and prepare to handle pull requests from consumers.
It also stores message related meta data, including consumer groups, consuming progress offsets and topic / queue info.
Name Server 是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。
Broker 部署相对复杂,Broker 分为 Master 与 Slave,
一个 Master 可以对应多个 Slave, 但是一个 Slave 只能对应一个 Master,
Master 与 Slave 的对应关系通过指定相同的 BrokerName,不同的 BrokerId 来定义,
BrokerId为 0 表示 Master,非 0 表示 Slave。Master 也可以部署多个。
每个 Broker 与 Name Server 集群中的所有节点建立长连接,定时注册 Topic 信息到所有 Name Server。
Producer 与 Name Server 集群中的其中一个节点(随机选择)建立长连接,
定期从 Name Server 取 Topic 路由信息,
并向提供 Topic 服务的 Master 建立长连接,
且定时向 Master 发送心跳。
Producer 完全无状态,可集群部署。
Consumer 与 Name Server 集群中的其中一个节点(随机选择)建立长连接,
定期从 Name Server 取 Topic 路由信息,
并向提供 Topic 服务的 Master、Slave 建立长连接,
且定时向 Master、Slave 发送心跳。
Consumer 既可以从 Master 订阅消息,也可以从 Slave 订阅消息,订阅规则由 Broker 配置决定。
Producer Group 用来表示一个发送消息应用,一个 Producer Group 下包含多个 Producer 实例,
可以是多台机器,也可以是一台机器的多个进程,或者一个进程的多个 Producer 对象。
一个 Producer Group 可以发送多个 Topic 消息,Producer Group 作用如下:
Consumer Group 用来表示一个消费消息应用,一个 Consumer Group 下包含多个 Consumer 实例,
可以是多台机器,也可以是多个进程,或者是一个进程的多个 Consumer 对象。
一个 Consumer Group 下的多个 Consumer 以均摊/集群(CLUSTER)方式消费消息,
如果设置为广播方式(BROADCAST),那么这个 Consumer Group 下的每个实例都消费全量数据。
总结一句话:生产消息时先写入PageCache,然后刷写到磁盘。
同步刷盘与异步刷盘的唯一区别是异步刷盘写完 PAGECACHE 直接返回,而同步刷盘需要等待刷盘完成才返回, 同步刷盘流程如下:
读取消息的ConsumeQueue文件也会加载到PageCache,读PageCache和内存速度差不多。
有两种类型的消息过滤:
RocketMQ 的 Consumer 都是从 Broker 拉消息来消费,但是为了能做到实时收消息,
RocketMQ 使用长轮询方式,可以保证消息实时性同 Push 方式一致。简单说就是长轮询Pull = Push。
消息有序指的是一类消息消费时,能按照发送的顺序来消费。
例如:一个订单产生了 3 条消息,分别是订单创建,订单付款,订单完成。
消费时,要按照这个顺序消费才能有意义。但是同时订单之间是可以并行消费的。
缺点:
单队列并行消费采用滑动窗口方式并行消费,如图所示,3~7的消息在一个滑动窗口区间,可以有多个线程并行消费,但是每次提交的 Offset 都是最小 Offset,例如 3。
修改消费并行度的两种方法:
批量方式消费:
某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吏量,例如订单扣款类应用,
一次处理一个订单耗时 1 秒钟,一次处理 10 个订单可能也只耗时 2 秒钟,这样即可大幅度提高消费的吞吏量。
通过设置 consumer 的 consumeMessageBatchMaxSize 返个参数,
默认是 1,即一次只消费一条消息,例如设置为 N,那么每次消费的 消息数小于等于 N。
分布式事务涉及到两阶段提交问题,在数据存储方面的方面必然需要 KV 存储的支持,
因为第二阶段的提交回滚需要修改消息状态,一定涉及到根据 Key 去查找 Message 的动作。
RocketMQ 在第二阶段绕过了根据 Key 去查找 Message 的问题,
采用第一阶段发送 Prepared 消息时,拿到了消息的 Offset,
第二阶段通过 Offset 去访问消息, 并修改状态,Offset 就是数据的地址。
RocketMQ 这种实现事务方式,没有通过 KV 存储做,而是通过 Offset 方式,
存在一个显著缺陷,即通过 Offset 更改数据,会令系统的脏页过多,需要特别关注。
Producers of the same role are grouped together.
A different producer instance of the same producer group
may be contacted by a broker to commit or roll back a transaction
in case the original producer crashed after the transaction.
Warning: Considering the provided producer is sufficiently powerful at sending messages,
only one instance is allowed per producer group to avoid unnecessary initialization of producer instances.
扩容是整个系统中的很重要的一个环节。在保证顺序的情况下进行扩容的难度会更大。
基本的策略是让向一个队列写入数据的消息发送者能够知道应该把消息写入迁移到新的队列中,
并且需要让消息的订阅者知道,当前的队列消费完数据后需要迁移到新队列去消费消息。关键点如下:
那么对于Metaq顺序消息,如何做到不停写扩容呢?我说说自己的看法:
在队列扩容的时候考虑到需要处理最新的消息服务,为了不丢失这部分消息,
可以采取让Producer暂存消息在本地磁盘设备中,
等扩容完成后再与Broker交互。这是我目前能想到的不停写扩容方式。
重拾了一把JavaWeb的部署流程,使用Jetty在Idea专业版上运行。步骤如下:
坑爹的是由于有三个工程,第二个Jetty工程即使在vm.options中添加-Djetty.http.port=8081,使用的还是8080端口
遂放弃,采用mvn jetty:run的方式
在根pom.xml中添加jetty-plugin的配置(注意:不需要在capital/order/red下添加):
1 | <plugin> |
然后进入到tcc-transaction的根目录(注意不要进入到实际的capital/order/red等目录)分别执行(开三个终端):
1 | ➜ tcc-transaction git:(master-1.2.x) ✗ mvn jetty:run -projects tcc-transaction-tutorial-sample/tcc-transaction-http-sample/tcc-transaction-http-capital -am |
解释下这里的参数含义:
比如capital的依赖:
1 | [INFO] tcc-transaction |
red的依赖:
1 | [INFO] tcc-transaction |
order的依赖:
1 | [INFO] tcc-transaction |
如果没有报错,会输出下面类似的启动成功日志(以order的8086端口为例):
1 | [INFO] Started ServerConnector@6bc6692e{HTTP/1.1,[http/1.1]}{0.0.0.0:8086} |
既然命令行方式启动,也可以通过Idea的maven插件代替执行:
但是要使用Debug时,还是会出现地址已经被使用的情况。可以通过在命令行启动mvnDebug:
1 | ➜ tcc-transaction git:(master-1.2.x) ✗ mvnDebug -Djetty.http.port=8086 jetty:run -projects tcc-transaction-tutorial-sample/tcc-transaction-http-sample/tcc-transaction-http-order -am |
然后在Idea中配置Remote,保存后,在右上角点击Debug(也只有Debug,无法选Run):
打开order的页面,这里是http://localhost:8086
购买一个IPhonx后,三个终端的日志如下:
1 | //资金终端 |
再买一个Mac,资金都不够了,最终订单失败:
1 | //资金终端 |
数据库信息:
TCC的事务调用流程设计本地事务和远程事务、根事务与分支事务,并且还有一个Proxy代理层。
本地事务、代理事务、远程事务都加上了@Conpensable注解,并且都定义了try/confirm/cancel方法。
为了弄清楚各种事务的调用链,在相关代码上加上日志(补偿事务、资源协调者、业务类):
sample-http-order(订单主事务):
1 | 1.根事务(订单)的两个拦截器 |
sample-http-capital(资金分支事务):
1 | 3.远程事务的try方法: |
sample-http-redpacket(红包分支事务):
1 | 5.远程事务的try方法: |
调用图如下:
]]>在IDEA中运行Dubbo的快速入门:
zookeeper://127.0.0.1:2181
Provider启动后会一直运行,日志如下:
1 | [20/10/17 09:29:07:007 CST] main INFO zookeeper.ZookeeperRegistry: [DUBBO] Register: dubbo://10.57.241.44:20880/com.alibaba.dubbo.demo.DemoService?anyhost=true&application=demo-provider&dubbo=2.5.6&generic=false&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=4308&side=provider×tamp=1508462946325, dubbo version: 2.5.6, current host: 127.0.0.1 |
Consumer启动后,运行完成,终端就关闭,表示一次RPC调用完成,日志如下:
1 | [20/10/17 09:30:45:045 CST] main INFO zookeeper.ZookeeperRegistry: [DUBBO] Register: consumer://10.57.241.44/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&category=consumers&check=false&dubbo=2.5.6&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=4324&side=consumer×tamp=1508463045694, dubbo version: 2.5.6, current host: 10.57.241.44 |
provider和consumer的注册中心配置都是ZooKeeper,查看ZooKeeper的节点信息。
可以看出DemoService的providers目前有dubbo://10.57.241.44:20880
。
1 | [zk: localhost:2181(CONNECTED) 12] ls /dubbo/com.alibaba.dubbo.demo.DemoService |
provider提供了服务:dubbo:service
,consumer引用服务:dubbo:reference
。
除此之外,provider在启动后,只要没有停止,就需要一直暴露dubbo协议:dubbo:protocol
。
provider.xml:
1 | <beans> |
consumer.xml:
1 | <beans> |
为了模拟provider的负载均衡,我们再启动一个provider,并且更改协议端口为20881。再次查看ZK:
1 | [zk: localhost:2181(CONNECTED) 20] ls /dubbo/com.alibaba.dubbo.demo.DemoService/providers |
新启动的Provider的日志:
1 | [20/10/17 09:47:21:021 CST] main INFO zookeeper.ZookeeperRegistry: [DUBBO] Register: dubbo://10.57.241.44:20881/com.alibaba.dubbo.demo.DemoService?anyhost=true&application=demo-provider&dubbo=2.5.6&generic=false&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=4427&side=provider×tamp=1508464040452, dubbo version: 2.5.6, current host: 127.0.0.1 |
启动Consumer,为了观察RPC调用期间,消费者的相关流程,我们在RPC调用完,sleep了1分钟
1 | [20/10/17 09:50:13:013 CST] main INFO zookeeper.ZookeeperRegistry: [DUBBO] Register: consumer://10.57.241.44/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&category=consumers&check=false&dubbo=2.5.6&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=4434&side=consumer×tamp=1508464212849, dubbo version: 2.5.6, current host: 10.57.241.44 |
在这一分钟内,查看ZK的consumers信息:
1 | [zk: localhost:2181(CONNECTED) 26] ls /dubbo/com.alibaba.dubbo.demo.DemoService/consumers |
再调用多次consumer,可以看到每次RPC调用会负载到不同的provider上:
关闭provider:
1 | [20/10/17 11:38:58:058 CST] DubboShutdownHook INFO config.AbstractConfig: [DUBBO] Run shutdown hook now., dubbo version: 2.5.6, current host: 127.0.0.1 |
mysql服务端修改配置并重启
1 | $ vi /etc/my.cnf |
问题:创建canal用户的目的是什么?直接使用现有的用户名可以吗,比如root。
答案:有些用户没有REPLICATION SLAVE, REPLICATION CLIENT的权限,用这些用户连接canal时,无法获取到binlog。
这里的canal用户授权了全部权限,所以客户端可以从canal中获取binlog。
明确两个概念:canal server连接mysql,客户端连接canal server。
本机连接服务端,验证binlog的格式是ROW
1 | $ mysql -h192.168.6.52 -ucanal -pcanal |
mysql主从复制的原理:
在启动canal之前,先来了解下什么是mysql的binlog:
1 | mysql> show binlog events; |
mysql数据文件下会生成mysql-bin.xxx的binlog文件,以及索引文件
1 | [qihuang.zheng@dp0652 canal]$ ll /var/lib/mysql/ |
针对mysql的操作都会有二进制的事件记录到binlog文件中。下面的一些操作包括创建用户,授权,创建数据库,创建表,插入一条记录。
1 | [qihuang.zheng@dp0652 canal]$ sudo strings /var/lib/mysql/mysql-bin.000004 |
部署canal server到6.52,并启动。查看canal的日志:
1 | [qihuang.zheng@dp0652 canal]$ cat logs/canal/canal.log |
查看instance的日志:
1 | [qihuang.zheng@dp0652 canal]$ cat logs/example/example.log |
canal server的conf下有几个配置文件
1 | ➜ canal.deployer-1.0.24 tree conf |
先来看canal.properties
的common属性前四个配置项:
1 | canal.id= 1 |
canal.id是canal的编号,在集群环境下,不同canal的id不同,注意它和mysql的server_id不同。
ip这里不指定,默认为本机,比如上面是192.168.6.52,端口号是11111。zk用于canal cluster。
再看下canal.properties
下destinations相关的配置:
1 | ################################################# |
这里的canal.destinations = example可以设置多个,比如example1,example2,
则需要创建对应的两个文件夹,并且每个文件夹下都有一个instance.properties文件。
全局的canal实例管理用spring,这里的file-instance.xml
最终会实例化所有的destinations instances:
1 | <bean class="com.alibaba.otter.canal.instance.spring.support.PropertyPlaceholderConfigurer" lazy-init="false"> |
比如canal.instance.destination
等于example,就会加载example/instance.properties
配置文件
example下instance.properties配置文件不需要修改。一个canal server可以运行多个canal instance。
1 | ################################################# |
在mysql上创建数据库,创建表,插入一条记录,再修改记录。
1 | create database canal_test; |
修改客户端测试例子的连接信息。其中example对应了canal实例的名称。
1 | public class SimpleCanalClientTest extends AbstractCanalClientTest { |
注意:如果连接有错误,客户端测试例子会立即结束,打印## stop the canal client。正常的话,终端不会退出,会一直运行。
SimpleCanalClientTest控制台的结果如下:
1 | **************************************************** |
插入一条记录:(其中uid和name的update都等于true)
1 | **************************************************** |
修改记录:(其中name的update等于true)
1 | **************************************************** |
canal安装包下的example instance下除了example.log外,还有一个meta.log
1 | [qihuang.zheng@dp0652 canal]$ cat logs/example/meta.log |
canal client与canal server之间是C/S模式的通信,客户端采用NIO,服务端采用Netty。
canal server启动后,如果没有canal client,那么canal server不会去mysql拉取binlog。
即Canal客户端主动发起拉取请求,服务端才会模拟一个MySQL Slave节点去主节点拉取binlog。
通常Canal客户端是一个死循环,这样客户端一直调用get方法,服务端也就会一直拉取binlog。
1 | public class AbstractCanalClientTest { |
canal client与canal server之间属于增量订阅/消费,流程图如下:(其中C端是canal client,S端是canal server)
canal client调用connect()
方法时,发送的数据包(PacketType)类型为:
canal client调用subscribe()
方法,类型为[SUBSCRIPTION]。
对应服务端采用netty处理RPC请求(CanalServerWithNetty
):
1 | public class CanalServerWithNetty extends AbstractCanalLifeCycle implements CanalServer { |
ClientAuthenticationHandler处理鉴权后,会移除HandshakeInitializationHandler和ClientAuthenticationHandler。
最重要的是会话处理器SessionHandler。
以client发送GET,server从mysql得到binlog后,返回MESSAGES给client为例,说明client和server的rpc交互过程:
SimpleCanalConnector发送GET请求,并读取响应结果的流程:
1 | public Message getWithoutAck(int batchSize, Long timeout, TimeUnit unit) throws CanalClientException { |
服务端SessionHandler处理客户端发送的GET请求流程:
1 | case GET: |
get/ack/rollback协议介绍:
Message getWithoutAck(int batchSize)
,允许指定batchSize,一次可以获取多条,每次返回的对象为Message,包含的内容为:void rollback(long batchId)
,回滚上次的get请求,重新获取数据。基于get获取的batchId进行提交,避免误操作void ack(long batchId)
,确认已经消费成功,通知server删除数据。基于get获取的batchId进行提交,避免误操作EntryProtocol.protod对应的canal消息结构如下:
1 | Entry |
SessionHandler中服务端处理客户端的其他类型请求,都会调用CanalServerWithEmbedded的相关方法:
1 | case SUBSCRIPTION: |
所以真正的处理逻辑在CanalServerWithEmbedded中,下面重点来了。。。
CanalServer包含多个Instance,它的成员变量canalInstances
记录了instance名称与实例的映射关系。
因为是一个Map,所以同一个Server不允许出现相同instance名称(本例中实例名称为example),
比如不能同时有两个example在一个server上。但是允许一个Server上有example1和example2。
注意:
CanalServer
中最重要的是CanalServerWithEmbedded
,而CanalServerWithEmbedded中最重要的是CanalInstance
。
1 | public class CanalServerWithEmbedded extends AbstractCanalLifeCycle implements CanalServer, CanalService { |
下图表示一个server配置了两个Canal实例(instance),每个Client连接一个Instance。
每个Canal实例模拟为一个MySQL的slave,所以每个Instance的slaveId必须不一样。
比如图中两个Instance的id分别是1234和1235,它们都会拉取MySQL主节点的binlog。
这里每个Canal Client都对应一个Instance,每个Client在启动时,
都会指定一个Destination,这个Destination就表示Instance的名称。
所以CanalServerWithEmbedded处理各种请求时的参数都有ClientIdentity,
从ClientIdentity中获取destination,就可以获取出对应的CanalInstance。
理解下各个组件的对应关系:
下面以CanalServerWithEmbedded的订阅方法为例:
注意:提供订阅方法的作用是:MySQL新增了一张表,客户端原先没有同步这张表,现在需要同步,所以需要重新订阅。
1 | public void subscribe(ClientIdentity clientIdentity) throws CanalServerException { |
每个CanalInstance中包括了四个组件:EventParser、EventSink、EventStore、MetaManager。
服务端主要的处理方法包括get/ack/rollback,这三个方法都会用到Instance上面的几个内部组件,主要还是EventStore和MetaManager:
在这之前,要先理解EventStore的含义,EventStore是一个RingBuffer,有三个指针:Put、Get、Ack。
这三个操作与Instance组件的关系如下:
客户端通过canal server获取mysql binlog有几种方式(get方法和getWithoutAck):
1 | private Events<Event> getEvents(CanalEventStore eventStore, Position start, int batchSize, Long timeout, |
注意:EventStore的实现采用了类似Disruptor的RingBuffer环形缓冲区。RingBuffer的实现类是MemoryEventStoreWithBuffer
get方法和getWithoutAck方法的区别是:
以10条数据为例,初始时current=-1,第一个元素起始next=0,end=9,循环[0,9]
所有元素。
List元素为(A,B,C,D,E,F,G,H,I,J)
next | entries[next] | next-current-1 | list element |
---|---|---|---|
0 | entries[0] | 0-(-1)-1=0 | A |
1 | entries[1] | 1-(-1)-1=1 | B |
2 | entries[2] | 2-(-1)-1=2 | C |
3 | entries[3] | 3-(-1)-1=3 | D |
. | ………. | ………. | . |
9 | entries[9] | 9-(-1)-1=9 | J |
第一批10个元素put完成后,putSequence设置为end=9。假设第二批又Put了5个元素:(K,L,M,N,O)
current=9,起始next=9+1=10,end=9+5=14,在Put完成后,putSequence设置为end=14。
next | entries[next] | next-current-1 | list element |
---|---|---|---|
10 | entries[10] | 10-(9)-1=0 | K |
11 | entries[11] | 11-(9)-1=1 | L |
12 | entries[12] | 12-(9)-1=2 | M |
13 | entries[13] | 13-(9)-1=3 | N |
14 | entries[14] | 14-(9)-1=3 | O |
这里假设环形缓冲区的最大大小为15个(源码中是16MB),那么上面两批一共产生了15个元素,刚好填满了环形缓冲区。
如果又有Put事件进来,由于环形缓冲区已经满了,没有可用的slot,则Put操作会被阻塞,直到被消费掉。
下面是Put填充环形缓冲区的代码,检查可用slot(checkFreeSlotAt方法)在几个put方法中。
1 | public class MemoryEventStoreWithBuffer extends AbstractCanalStoreScavenge implements CanalEventStore<Event>, CanalStoreScavenge { |
Put是生产数据,Get是消费数据,Get一定不会超过Put。比如Put了10条数据,Get最多只能获取到10条数据。但有时候为了保证Get处理的速度,Put和Get并不会相等。
可以把Put看做是生产者,Get看做是消费者。生产者速度可以很快,消费者则可以慢慢地消费。比如Put了1000条,而Get我们只需要每次处理10条数据。
仍然以前面的示例来说明Get的流程,初始时current=-1,假设Put了两批数据一共15条,maxAbleSequence=14,而Get的BatchSize假设为10。
初始时next=current=-1,end=-1。通过startPosition,会设置next=0。最后end又被赋值为9,即循环缓冲区[0,9]一共10个元素。
1 | private Events<Event> doGet(Position start, int batchSize) throws CanalStoreException { |
ack操作的上限是Get,假设Put了15条数据,Get了10条数据,最多也只能Ack10条数据。Ack的目的是清空缓冲区中已经被Get过的数据
1 | public void ack(Position position) throws CanalStoreException { |
rollback回滚方法的实现则比较简单,将getSequence回退到ack位置。
1 | public void rollback() throws CanalStoreException { |
下图展示了RingBuffer的几个操作示例:
EventStore负责存储解析后的Binlog事件,而解析动作负责拉取Binlog,它的流程比较复杂。需要和MetaManager进行交互。
比如要记录每次拉取的Position,这样下一次就可以从上一次的最后一个位置继续拉取。所以MetaManager应该是有状态的。
EventParser的流程如下:
上面提到的Connection指的是实现了ErosaConnection
接口的MysqlConnection
。EventParser
的实现类是实现了AbstractEventParser
的MysqlEventParser
。
EventParser
解析binlog后通过EventSink
写入到EventStore
,这条链路可以通过EventStore的put方法串联起来:
其实这里还有一个EventTransactionBuffer缓冲区,即Parser解析后先放到缓冲区中,
当事务发生时或者数据超过阈值,就会执行刷新操作:即消费缓冲区的数据,放到EventStore中。
这个缓冲区有两个偏移量指针:putSequence和flushSequence。
单机模拟两个Canal Server,将单机模式复制出两个文件夹,并修改相关配置
canal_m/conf/canal.properties
1 | canal.id= 2 |
canal_m/conf/example/instance.properties
1 | canal.instance.mysql.slaveId = 1235 |
canal_s
1 | canal.id= 3 |
canal_s/conf/example/instance.properties
1 | canal.instance.mysql.slaveId = 1236 |
启动canal_m
1 | 2017-10-12 14:51:45.202 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## start the canal server. |
启动canal_s
1 | 2017-10-12 14:52:18.999 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## start the canal server. |
master提供服务,canal_m/logs/example/example.log下有日志,而canal_s/logs没有example文件夹
1 | [qihuang.zheng@dp0652 ~]$ tail -f canal_m/logs/example/example.log |
查看Canal HA记录在ZK的信息
1 | [zk: 192.168.6.52:2181(CONNECTED) 7] ls /otter/canal/destinations/example/cluster |
启动example的ClusterCanalClientTest
1 | CanalConnector connector = CanalConnectors.newClusterConnector("192.168.6.52:2181", destination, "canal", "canal"); |
执行SQL:update test set name = 'zqh' where uid=1;
,控制台打印日志如下:
1 | **************************************************** |
再次查看ZK中记录的客户端信息:
1 | [zk: 192.168.6.52:2181(CONNECTED) 18] get /otter/canal/destinations/example/1001/running |
停止canal_m
1 | [qihuang.zheng@dp0652 canal_m]$ bin/stop.sh |
Instance会在slave节点即canal_s上启动
1 | [qihuang.zheng@dp0652 ~]$ tail -f canal_s/logs/example/example.log |
停止canal_m后,只剩下canal_s,所以Canal集群只有一个节点了:
1 | [zk: 192.168.6.52:2181(CONNECTED) 14] ls /otter/canal/cluster |
切换过程中,Client的日志
1 | 2017-10-12 15:17:22.524 [Thread-2] WARN c.alibaba.otter.canal.client.impl.ClusterCanalConnector - failed to connect to:/192.168.6.52:11113 after retry 0 times |
再次执行SQL语句
1 | **************************************************** |
停止客户端后,查询ZK中的客户端信息。注意,仍然有cursor信息,但是没有running,因为instance没有对应的client了。
1 | [zk: 192.168.6.52:2181(CONNECTED) 1] ls /otter/canal/destinations/example |
cursor信息是instance消费binlog的位置,即使客户端停掉了,也仍然保留在zk中。
注意:1001是ClientIdentity的固定编号,相关源码在SimpleCanalConnector的构造方法里。
下面总结下zk中的相关记录:
1 | /otter/canal/ |
注意这里有两个running节点,第一个是CanalServer,第二个是CanalClient。
/otter/canal/destinations/example1/running
: {“active”:true,”address”:”192.168.6.52:11112”,”cid”:2}/otter/canal/destinations/example1/1001/running
: {“active”:true,”address”:”10.57.241.44:53942”,”clientId”:1001}下图是Canal Server HA的流程图:
Canal Client的方式和canal server方式类似,也是利用zookeeper的抢占EPHEMERAL节点的方式进行控制。
HA的实现,客户端是ClientRunningMonitor,服务端是ServerRunningMonitor。
关于Canal Client HA的验证,可以参考:http://blog.csdn.net/xiaolinzi007/article/details/52933909
Client1的日志:
1 | **************************************************** |
停止Client1后,Client2的日志:
1 | **************************************************** |
观察ZK节点中instance对应的client节点,在Client切换时,会进行变更。
比如下面的客户端从56806端口切换到了56842端口。
把所有客户端都关闭后,1001下没有running。表示instance没有客户端消费binlog了。
1 | 启动两个客户端,第一个客户端(56806)正在运行 |
具体实现相关类有:ClientRunningMonitor/ClientRunningListener/ClientRunningData。
client running相关控制,主要为解决client自身的failover机制。
canal client允许同时启动多个canal client,
通过running机制,可保证只有一个client在工作,其他client做为冷备.
当运行中的client挂了,running会控制让冷备中的client转为工作模式,
这样就可以确保canal client也不会是单点. 保证整个系统的高可用性.
下图左边是客户端的HA实现,右边是服务端的HA实现
先理解下面的类图结构:
重新看下CanalServerWithEmbedded的订阅方法。我们知道客户端在连接服务端的某个destination之后,会紧接着调用subscribe()方法。
客户端连接服务端时,必须指定destination名称,因为一个服务端可能有多个destination。
比如服务端启动了两个Instance,它们的destination名称分别是example1和example2。
假设有两个客户端A和B,A连接example1,B连接example2(在代码中手动指定的,不是自动选择)。
服务端的canalInstances字典为:{example1=>Instance1,example2->Instance2}。
那么ClientA的destination等于example1,对应的服务端实例为Instance1。
ClientB的destination等于example2,对应的服务端实例为Instance3。
1 | /** |
这里面关于订阅方法有两个地方,CanalInstance本身调用了subscribeChange,它关联的MetaManager也调用了subscribe方法。
一个CanalServer可以有多个CanalInstance,每个Instance都会有一个MetaManager。
而一个Instance对应一个Client。那么,这么说来,一个MetaManager也就只会有一个Client了。
但是从下面的数据结构来看的话,一个MetaManager貌似可以有多个Destination。
1 | public class MemoryMetaManager extends AbstractCanalLifeCycle implements CanalMetaManager { |
猜测:多个Client可以连接到同一个Instance(虽然只会有一个Instance起作用),所以一个MetaManager可以管理多个Client。
NO!Client的HA与MetaManager记录的Client是不一样的。HA表示同一时间只有一个Client起作用,那么MetaManager不可能同时记录两个Client。
官方ClientAPI文档上:ClientIdentity是canal client和server交互之间的身份标识,目前clientId写死为1001.
目前canal server上的一个instance只能有一个client消费,
clientId的设计是为1个instance多client消费模式而预留的,暂时不需要理会。
也就是说:一个Instance还是有可能有多个Client连接上来的,只是目前只允许一个而已!!!
这里的数据结构为什么这么设计,还需要参考AbstractMetaManagerTest的doSubscribeTest方法来理解。
对于相同的destination,可以订阅不同的client。下面的示例分别订阅了[client1,client2]和[client1,client3]。
1 | public void doSubscribeTest(CanalMetaManager metaManager) { |
有不懂的地方,可以看看测试用例,验证自己的想法是否正确。
CanalServerWithEmbedded的订阅方法最后还会调用AbstractCanalInstance的subscribeChange
方法。
这里会设置表名的filter,以及黑名单。配置项在instance.properties中。
1 | # table regex |
filter表示客户端要通过Canal Server获取MySQL哪些表的binlog,上面配置项表示获取所有表。
1 | public class AbstractCanalInstance extends AbstractCanalLifeCycle implements CanalInstance { |
对应在EventParser中,存在两个Filter的引用。比如上面eventParser.setEventFilter()方法会设置AbstractEventParser的eventFilter。
1 | public abstract class AbstractEventParser<EVENT> extends AbstractCanalLifeCycle implements CanalEventParser<EVENT> { |
AbstractEventParser的start()方法是解析binlog的主要方法。
在启动transactionBuffer和BinLogParser后,
会启动一个后台的工作线程parseThread一直运行:
注意:下面的几个步骤是嵌套在一个while死循环里,最后会进行sleep。
1 | // 开始执行replication |
这里的erosaConnection指的是Canal Server到MySQL的连接。
而前面我们说的客户端(CanalClient)连接CanalConnector指的是CanalClient到CanalServer的连接。
CanalServer到MySQL的连接是要获取binlog的dump数据包。而CanalClient到CanalServer有多种请求(GET/ACK等)。
我们不会具体分析dump的流程,不过粗略看下erosaConnection的MySQL实现MysqlConnection是如何在获取到事件后调用回调函数。
1 | public void dump(String binlogfilename, Long binlogPosition, SinkFunction func) throws IOException { |
服务端有一个心跳线程,它的目的是消费transactionBuffer,并写入到EventSink中。
1 | protected boolean consumeTheEventAndProfilingIfNecessary(List<CanalEntry.Entry> entrys) { |
EventSink最终会将数据写入到EventStore中,即Put到RingBuffer中。回顾下这张图:
前面分析了这么多,一直没分析Canal服务是怎么起来的,其实很简单,
执行脚本startup.sh本质上通过CanalLauncher会启动CanalController。
1 | [zk: 192.168.6.55:2181(CONNECTED) 3] ls /otter/canal/destinations |
Spark的DataSource API可以方便地扩展。如果没有使用META-INFO这种ServiceLocator机制,则自定义的数据源名称必须是DefaultSource.
并且必须实现RelationProvider接口。
1 | class DefaultSource extends RelationProvider { |
通常自定义数据源都有不同的配置文件,所以我们也要实现自己的BaseRelation
1 | class DefaultSource extends RelationProvider{ |
主要的起始还是BaseRelation的实现类,但是这里怎么获取schema和SQLContext呢。由于DefaultSource的createRelation方法中已经有SQLContext。所以我们可以改成
1 | class DefaultSource extends RelationProvider{ |
那么Schema怎么确定呢?通常它需要从DefaultSource的createRelation方法的parameters确定。
所以通常我们会给自定义的BaseRelation加上一个参数:
1 | class DefaultSource extends RelationProvider{ |
这个schema的具体实现必须依赖于如何读取数据源。所以EmptyRelation还需要实现另外一个接口:TableScan
1 | case class EmptyRelation(parameters: Map[String, String]) |
现在有两个方法需要我们自己实现。buildScan表示如何读取数据源,并生成RDD[ROW]
。
下面以一个简单的示例入门:
1 | case class EmptyRelation(parameters: Map[String, String]) |
接下来就可以运行测试例子了:
1 | object TestExample { |
什么,只有40行代码,就实现了自定义的DataSource!!!
1 | root |
上面示例EmptyRelation中,schema方法和buildScan方法有如下特点:
总结下自定义数据源相关的类:
1 | RelationProvider BaseRelation TableScan |
开启mysql的查询日志,对应的日志文件是/usr/local/var/mysql/zqhmac.log
:
1 | mysql> set GLOBAL general_log = on; |
spark读取jdbc有多种方式:
1 | val url = "jdbc:mysql://localhost/test" |
后台日志:
1 | 2008 Query SELECT `id`,`name`,`total` FROM test |
Spark UI上可以看到只有一个Executor和一个Task:
如果数据量太大,就会报错OOM:
1 | val columnName = "id" |
指定上下界有个限制条件是分区字段必须是整数类型:
1 | def jdbc( |
spark的做法是根据上下界,分区个数,自动切分。这种场景主要针对数据库的主键是自增字段(当然是整数了)。
因为自增的数字分布很均匀,所以给定上下界和分区的数量,每个分区拉取的数据也是很均匀的。
后台日志:
1 | 2010 Query SELECT `id`,`name`,`total` FROM test WHERE id < 201 or id is null |
1 | val predicates = Array( |
后台日志:
1 | 2016 Query SELECT `id`,`name`,`total` FROM test WHERE id>=0 and id<10 |
如果数据分布不均匀,可以采用这种方式,而且这种方式不限于主键、整数类型,可以是任意类型,任意字段。
比如我们的测试mysql表数据如下:
1 | mysql> select * from test; |
现在要根据name列进行手动指定查询方式:
1 | val predicates = Array( |
后台日志:
1 | 2020 Query SELECT `id`,`name`,`total` FROM test WHERE name = 'A' |
由于是自定义查询条件,所以我们可以使用任何方式,比如limit方法:
1 | val predicates = Array( |
后台日志:
1 | 2025 Query SELECT 1 FROM test WHERE 1=1 order by name limit 3 offset 3 |
动态指定排序字段和个数:
1 | val orderByColumn = "name" |
后台日志:
1 | 2030 Query SELECT 1 FROM test WHERE 1=1 order by name limit 3 offset 3 |
当然上面的predicates还是不够智能,正确的做法是先查询总数,然后根据limitCount构造predicates数组。
1 | val orderByColumn = "name" |
后台日志:
1 | 2050 Query SELECT 1 FROM test WHERE 1=1 order by name limit 3 offset 0 |
spark.read.jdbc进入DataFrameReader,真正执行在load()方法中:
1 | def load(paths: String*): DataFrame = { |
JDBC格式对应的Provider就定义在DataSource中:
1 | object DataSource extends Logging { |
jdbc数据源的定义类是:JdbcRelationProvider
参考: http://blog.csdn.net/cjuexuan/article/details/52333970
category是唯一键,存在则更新num,不存在则插入category,num。
1 | INSERT INTO ip_category_count |
对应的Statemen写法, set时从1开始,get时从0开始:
1 | ps.setInt(1, row.getInt(0)) |
假设有下面的SQL:
1 | INSERT INTO test_1 (`id`,`year`,count`) VALUES (?,?,?) |
对应的写法:
1 | ps.setInt(1, row.getInt(0)) |
总结出来的规则:stmt.setInt(pos + 1, row.getInt(pos - offset))
1 | 1. i<midField, position=i, offset=0 => stmt.setInt(i + 1, row.getInt(i - 0)) |
以3个字段为例,当i<midField
时:
当i>=midField
时:
setter方法的第一个参数:index of setter,第二个参数:index of row。
比如对于i小于midField而言,get的位置等于索引减去0;i大于midField而言,get的位置等于索引减去3。
1 | row[1,2,3] |
代码:
1 | val length = rddSchema.fields.length |
总结下对应关系:
1 |
|
单个Job的配置示例:
1 | { |
多个Job的配置示例:
1 | { |
StreamingPro支持Spark、SparkStreaming、SparkStruncture、Flink。入口类都是统一的StreamingApp
。
1 | object StreamingApp { |
通过streaming.platform可以指定不同的运行平台。当然,不同的运行引擎的jar包也不同。
1 | SHome=/Users/allwefantasy/streamingpro |
jar包会被用来加载不同的Runtime。Runtime运行的映射关系定义在PlatformManager
的platformNameMapping
变量中。
Runtime是一个接口,最主要的是startRuntime方法和params方法。后面我们把Runtime叫做执行引擎。
1 | trait StreamingRuntime { |
StreamingPro本质上还是通过spark-submit运行。框架的整体运行流程在PlatformManager
的run
方法中。主要的步骤有:
关于Dispatcher、Strategy的概念,参考作者的ServiceframeworkDispatcher项目。
反射创建执行引擎,调用的是对应Object类的getOrCreate方法,并传入params参数,最后实例化为StreamingRuntime。
1 | def platformNameMapping = Map[String, String]( |
注意:StreamingPro的Runtime只是Spark作业的执行引擎,具体根据配置文件加载策略是ServiceframeworkDispatcher的工作。
假设我们定义了下面的一个配置文件,由于采用了shortName,需要定义一个ShortNameMapping
1 | { |
DefaultShortNameMapping的定义如下。这样配置文件中的spark就和ServiceframeworkDispatcher的加载过程对应起来了。
1 | class DefaultShortNameMapping extends ShortNameMapping { |
ServiceframeworkDispatcher的核心是StrategyDispatcher,这个类在创建的时候,会读取配置文件。
然后解析配置文件中的strategy、algorithm(processor)、ref、compositor、configParams等配置项,并构造对应的对象。
ServiceframeworkDispatcher是一个模块组合框架,它主要定义了Compositor、Processor、Strategy三个接口。
Strategy接口包含了processor、ref、compositor,以及初始化和result方法。
1 | trait Strategy[T] extends ServiceInj{ |
Strategy策略的初始化需要算法、引用、组合器,以及配置信息,对应的方法是StrategyDispatcher的createStrategy方法。
注意下面的initialize方法,createAlgorithms和createCompositors初始化时
会读取params配置,这是一个嵌套了Map的列表:JList[JMap[String, Any]]
。
1 | def createStrategy(name: String, desc: JMap[_, _]): Option[Strategy[T]] = { |
ServiceframeworkDispatcher的核心是StrategyDispatcher,而StrategyDispatcher的核心是其dispatch方法。
1 | def dispatch(params: JMap[Any, Any]): JList[T] = { |
不同执行引擎的启动方法实现不同:
1 | class SparkRuntime(_params: JMap[Any, Any]) extends StreamingRuntime with PlatformManagerListener { |
但真正执行StreamingPro主流程在streamingpro-commons下的SparkStreamingStrategy类。
注意:如果是spark-1.6,则streamingpro-spark下也有一个SparkStreamingStrategy类。
1 | class SparkStreamingStrategy[T] extends Strategy[T] with DebugTrait with JobStrategy { |
注意:配置文件中每个Job都有一个strategy
级别的configParams
,ref
也会使用这个全局的configParams
。
它是一个Map[String, Any]
的结构。每个Compositor和Processor内部也有一个params
配置,这是一个数组。
实际上,全局的
configParams
参数会被用在Strategy、Ref/Processor和Compositor的result()方法的最后一个参数。
1 | "compositor": [ |
接下来以读取多个数据源的Compositor实现类为例:
_configParams
是在创建Compositor时初始化调用的,这是一个List[Map[String, Any]]
的结构,对应了params
列表配置outputTable
1 | class MultiSQLSourceCompositor[T] extends Compositor[T] with CompositorHelper { |
为了支持配置的动态替换,_cfg
参数会做一些处理,比如上面的s"streaming.sql.source.${name}.${f._1}"
如果需要被替换,则会被替换为f._2
。
下表列举了StreamingPro支持的几种替换方式。
配置参数 | 配置示例 | 动态传参数 |
---|---|---|
streaming.sql.source.[name].[参数] |
“path”: “file:///tmp/sample_article.txt” | -streaming.sql.source.firstSource.path file:///tmp/wow.txt |
streaming.sql.out.[name].[参数] |
“path”: “file:///tmp/sample_article.txt” | -streaming.sql.source.firstSink.path file:///tmp/wow_20170101.txt |
streaming.sql.params.[param-name] |
“sql”: “select * from test where hp_time=:today” | -streaming.sql.params.today “20170101” |
假设有两个数据输入源和一个输出目标的配置如下:
1 | { |
Source的功能是:读取输入源形成DataFrame,然后创建临时表。其他组件比如SQL也是类似的。至此StreamingPro的大致流程就分析完了。
]]>版本:carbondata-1.1.0,spark-2.1.1,hadoop-2.6.0
1 | $ mvn -DskipTests -Pspark-2.1 -Dspark.version=2.1.1 -Dhadoop.version=2.6.0 clean package |
本地模式测试,创建CarbonSession的第一个参数为本地文件系统
1 | bin/spark-shell --jars ~/Github/carbondata-parent-1.1.0/assembly/target/scala-2.11/carbondata_2.11-1.1.1-shade-hadoop2.6.0.jar |
本地文件系统的文件夹包括Fact(表数据)、Metadata(表结构)
1 | ➜ carbondata-parent-1.1.0 tree /tmp/carbon |
yarn模式按照官网部署http://carbondata.apache.org/installation-guide.html
注意:使用yarn模式,不需要把carbondata通过scp分发到各个节点,只需要在Driver端有就可以。另外,当前版本不依赖kettle
1 | cd spark-2.1.1* |
启动spark-shell还需要加上--jars
。注意创建CarbonSession时第一个参数必须加上hdfs前缀,否则会报错找不到文件
1 | $ bin/spark-shell --jars /home/admin/carbondata_2.11-1.1.1-shade-hadoop2.6.0.jar |
carbondata运行在HDFS时,它的事实数据与元数据保存在HDFS上。
将hdfs表数据导入到carbondata建立的表后,执行一些查询语句,观察ui。
注意:导入数据时,carbondata分为两个步骤:全局字典(GlobalDictionary)和CarbonDataRDD。
其中全局字典会在Metadata下生产索引文件,CarbonDataRDD会在Fact下生成数据文件。
建立crosspartner carbondata表
1 | import org.apache.spark.sql.SparkSession |
再生成carbondata表:
1 | carbon.sql("insert into cross_partner_carbon select * from crosspartner") |
比较crosspartner_hdfs的过滤与carbondata的查询
1 | carbon.sql("select sequenceId from cross_partner_carbon where partnerCode='qufenqi' and eventType='Loan' and idNumber=''").show |
创建carbondata表时,如果默认所有字段都加上索引,导入数据时Executor会报错OOM。
如果去掉所有字段的索引,导入数据很快,但是查询速度就满了。
比较磁盘空间的大小,没有索引下,Parquet和Carbondata差不多
activity事件数据,只取借贷和放贷的数据,并保存成临时表crosspartner_hdfs
1 | spark.sql("""CREATE TABLE crosspartner_hdfs( |
上面如果建表时没有指定存储为parquet,最后是part-xxx。
而且即使指定了parquet,insert sql也不能指定分区数量。
下面改用parquet文件夹加上手动分区的形式:cross_partner_hdfs。
1 | import java.text.SimpleDateFormat |
查询parquet,建立临时表,使用SparkSQL查询
1 | val df=spark.read.parquet("/user/hive/warehouse/cross_partner_hdfs/*") |
使用临时表的数据插入到carbondata table
1 | val df=spark.read.parquet("/user/hive/warehouse/cross_partner_hdfs/*") |
carbondata不认识用df注册的临时表:
创建hive表时指定parquet格式,并从parquet文件夹的数据直接生成表
1 | spark.sql("""CREATE TABLE crosspartner( |
或者直接用parquet文件创建外部表:
1 | spark.sql(""" |
一次性将所有数据插入carbondata太慢了
1 | carbon.sql(s"insert into cross_partner_carbon select * from crosspartner where ds like '$ymd%'") |
改用按月/天插入carbondata表
1 | import java.text.SimpleDateFormat |
导入数据时还是会报错:
增加内存:
1 | bin/spark/shell \ |
1 | carbon.sql("""CREATE TABLE IF NOT EXISTS crosspartner1( |
1 |
|
carbondata-1.1.1目前不支持spark2.2。如果加上profile,更改spark版本为2.2.0,编译不通过
1 | $ mvn -DskipTests -Pspark-2.2 -Dspark.version=2.2.0 -Dhadoop.version=2.6.0 clean package |
如果使用spark2.1.1编译的二进制包,放到spark2.2.0下,也会报错:
spark-1.6.2
1 | case class InsertIntoTable( |
spark-2.2.0
1 | case class InsertIntoTable( |
更改为i.query后,重新编译:
1 | [INFO] Apache CarbonData :: Assembly ...................... FAILURE [ 2.180 s] |
默认1.6版本的assembly无法下载1.1.1的pom,将默认版本改为(添加)2.2.0
1 | <profile> |
由于下载的snappydata已经带了spark,所以不需要使用–packges
1 | $ cd snappydata-0.9-bin |
执行CRUD操作:
1 | val snappy = new org.apache.spark.sql.SnappySession(spark.sparkContext) |
打开http://192.168.6.52:4042/dashboard/,查看web-ui的dashboard页面
查看quickstartdir,索引采用GF(GemFire)
1 | $ tree quickstartdatadir/ |
简单的性能测试:
1 | def benchmark(name: String, times: Int = 10, warmups: Int = 6)(f: => Unit) { |
左图为本地模式,右图为伪分布式模式:分别启动locator(左下)、server(DataServer,右上)、
leader(左上),quickstartdir为右下(share-nothing store).
伪分布式模式的三个组件都在本机启动,使用不同的文件夹。
1 | $ cd snappydata-0.9-bin |
如果要修改地址,可以用xx=xx的方式,
比如(修改locator的地址)[https://snappydatainc.github.io/snappydata/reference/configuration_parameters/start-locator/]
1 | bin/snappy locator start -dir=node-a/locator1 -start-locator=192.168.6.52[1529] |
关闭各个组件:
1 | bin/snappy locator stop -dir=node-a/locator1 |
执行spark-shell,并指定snappydata的连接地址为localhost:1527
.
1 | bin/spark-shell --driver-memory=4g \ |
如果打开http://192.168.6.52:4042,有spark app的页面,但是没有dashboard的页面。
打开http://192.168.6.52:5050/dashboard/,可以查看snappydata的web ui。
5050类似于spark standalone的8082 web-ui,4040类似于spark app的ui。
上面三个启动脚本可以用一个脚本执行,这种情况默认的文件夹在work下。
1 | sbin/snappy-start-all.sh |
snappy-start-all.sh会在本地启动一个locator,一个server,一个leader.
1 | $ sbin/snappy-start-all.sh |
查看默认work下的目录
1 | $ tree work/ |
先停止snappydata,然后修改远程机器conf下的servers, locators, leads.
将localhost改为主机地址:192.168.6.52,再重启snappydata。
注意:默认启动时,使用的是localhost,work下的文件夹页是localhost开头。
1 | [qihuang.zheng@dp0652 snappydata-0.9-bin]$ sbin/snappy-start-all.sh |
查看进程
1 | 45860 io.snappydata.tools.ServerLauncher server -critical-heap-percentage=90 -eviction-heap-percentage=81 locators=192.168.6.52:10334 log-file=snappyserver.log -client-bind-address=192.168.6.52 |
本机下载snappydata的二进制包,并启动snappy脚本,通过thrift/jdbc连接远程的snappydata cluster
1 | ➜ snappydata-0.9-bin bin/snappy |
Prefix: I’ve heard Gearpump nearly one or two years ago, but never take a deep look inside. Until recently I’m almost done writing my chinese book about kafka internal implimentation, and decide to add some kafka relation opensouce system to my book’s appendix, such as spark streaming,storm,flink, and gearpump! So I finaly have a chance to deep into Gearpump.
According to offical documentation: “Gearpump is a 100% Akka based platform. We model big data streaming within the Akka actor hierarchy”. Below It’s Gearpump Actor Hierarchy architecture. PS: If you don’t know Actor right now, It’s fine, just think that’s another RPC layer or message transformer.
Everything in the diagram is an actor; they fall into two categories, Cluster Actors and Application Actors.
Cluster Actors
Worker: Maps to a physical worker machine. It is responsible for managing resources and report metrics on that machine.
Master: Heart of the cluster, which manages workers, resources, and applications. The main function is delegated to three child actors, App Manager, Worker Manager, and Resource Scheduler.
Application Actors
AppMaster: Responsible to schedule the tasks to workers and manage the state of the application. Different applications have different AppMaster instances and are isolated.
Executor: Child of AppMaster, represents a JVM process. Its job is to manage the life cycle of tasks and recover the tasks in case of failure.
Task: Child of Executor, does the real job. Every task actor has a global unique address. One task actor can send data to any other task actors. This gives us great flexibility of how the computation DAG is distributed.
All actors in the graph are weaved together with actor supervision, and actor watching and every error is handled properly via supervisors. In a master, a risky job is isolated and delegated to child actors, so it’s more robust. In the application, an extra intermediate layer “Executor” is created so that we can do fine-grained and fast recovery in case of task failure. A master watches the lifecycle of AppMaster and worker to handle the failures, but the life cycle of Worker and AppMaster are not bound to a Master Actor by supervision, so that Master node can fail independently. Several Master Actors form an Akka cluster, the Master state is exchanged using the Gossip protocol in a conflict-free consistent way so that there is no single point of failure. With this hierarchy design, we are able to achieve high availability.
Next It’s a good entrance to knowing some basic concepts. It’s very necessary, you should first take a detail/serious look at if you want to know how gearpump works.
Master & Worker
Gearpump follow master slave architecture. Every cluster contains one or more Master node, and several worker nodes. Worker node is responsible to manage local resources on single machine, and Master node is responsible to manage global resources of the whole cluster.
If you have already know hadoop/spark such bigdata system, you should familiar those terminology. Here is the first comparison about gearpump and other system.
bigdata system | Master | Slave |
---|---|---|
Hadoop HDFS | NameNode | DataNode |
Hadoop YARN | ReourceManager | NodeManager |
Spark | ClusterManagement | Worker |
Storm | Nimbus | Supervisor |
Gearpump | Master | Worker |
Application & AppMaster & Executor
Application is what we want to parallel and run on the cluster. There are different application types, for example MapReduce application and streaming application are different application types. Gearpump natively supports Streaming Application types, it also contains several templates to help user to create custom application types, like distributedShell.
In runtime, every application instance is represented by a single AppMaster and a list of Executors. AppMaster represents the command and controls center of the Application instance. It communicates with user, master, worker, and executor to get the job done. Each executor is a parallel unit for distributed application. Typically AppMaster and Executor will be started as JVM processes on worker nodes.
Now we have talking all important components in gearpump. Notice here we did’t mentioned Task as appeared in previous actor hierarchy. Also notice that Application is not an actor but an Java main class. Next take a look at Application Submission Flow in gearpump.
When user submits an application to Master, Master will first find an available worker to start the AppMaster. After AppMaster is started, AppMaster will request Master for more resources (worker) to start executors. The Executor now is only an empty container. After the executors are started, the AppMaster will then distribute real computation tasks to the executor and run them in parallel way.
To submit an application, a Gearpump client specifies a computation defined within a DAG and submits this to an active master. The SubmitApplication message is sent to the Master who then forwards this to an AppManager.
The AppManager locates an available worker and launches an AppMaster in a sub-process JVM of the worker. The AppMaster will then negotiate with the Master for Resource allocation in order to distribute the DAG as defined within the Application. The allocated workers will then launch Executors (new JVMs).
Here I summary basic steps of submit application. notice the step number below are’t corresponding to the official pictures above.
SubmitApplication
request to AppManager;SubmitApplicationResult
to client;RequestResource
to master, the purpose of this step is ask resources to run/launch Tasks which doing real job. After all, AppMaster is not responsible to running job, but instead let Tasks doing the job. Notice the lifecycle of both AppMaster and Tasks all resides in Executors. So If you want to start AppMaster or Task, you first must start Executor, then let Executor start AppMaster and Task;ResouceAllocated
response, it’ll send LaunchExecutor
to workers which Master pointing out where to go. For ex, the ResouceAllocated response says by Master to AppMaster: you can run executors on workers #1 and #2. Then AppMaster will send LaunchExecutor request to this two workers;RegisterExecutor
request. If someone regist to other-one, that means someone wants to be managed/controlled by other-one. for example, students regist to school, company regist to Mainland China, employee regist to company and so on;RegisterExecutor
request from Executor on Worker, it then ask Executor to start Task;The workflow above was extraordinary like yarn application below. I take the picture and description from this excellent hortonworks blog.
The picture above start two client application to yarn cluster, the ApplicationMaster reside on node2 of red one start three containers on node1 and node3, the ApplicationMaster reside on node1 of blue one only start one container.
In yarn, ResouceManager take responsible to launch ApplicationManager on one of container, and launching Tasks on containers is the responsibility of ApplicationManager. But as you know, the ApplicationManager did’t know cluster resources, so he ask ResouceManager to give him the information of where to start tasks. Now we summary some conclusions:
Step into gearpump, there are similiarity idea inspired from yarn. We could take yarn’s container as gearpump’s Executor, and yarn’s NodeManager as gearpump’s Worker. Because Containers reside in NodeManager at yarn world, and Executors reside in Worker at gearpump world.
We could also consider yarn’s ResouceManager as gearpump’s AppManager. Note that AppManager is different from AppMaster, which the former is at Master side, and the latter is at Worker side.
The Master in Gearpump have three main components: AppManager,Scheduler,Worker Manager. In reality, there are non WorkerManager class around gearpump source code,but Master indeed has a map which mapping Worker ActorRef to WorkerId.
After oveview gearpump architecture, Let’s begin explore gearpump inside now.
First given a WordCount example, We sumbit an StreamApplication through ClientContext. Inside the application() method, we create three Processor
and connect by ~
to construct a DAG graph.
1 | object WordCount extends AkkaApp { |
StreamApplication is one of gearpump supported application type, there’re other applications such as MapReduce could run in gearpump. Each Application type has special appMaster class, StreamApplication’s appMaster is AppMaster. There’re some other ApplicationMaster actor implementation embeded: DistShellAppMaster,DistServiceAppMaster,and AppMaster.
Note Application is a scala App, but ApplicationMaster is an Actor. So what’s different between an App and and Actor? Well, App normaly has a main method doing what you want, but actor doing much more complicate thing.
1 | trait Application { |
ClientContext is a user facing util to submit/manage an application. The AppDescription describe application metadata such as appMaster name(here is AppMaster).
In the Akka world, Actor is the king. Client send SubmitApplication request to Master Actor, and expect get SubmitApplicationResult response from Master. Messages are sent to an Actor through one of the following methods.
!
means “fire-and-forget”, e.g. send a message asynchronously and return immediately. Also known as tell.?
sends a message asynchronously and returns a Future representing a possible reply. Also known as ask. That’s the way client submit application doing here.1 | class ClientContext(config: Config, sys: ActorSystem, _master: ActorRef) { |
Now Let’s see how Master deal with SubmitApplication. Before this, you should know that client only submit application when Master has started. Also note that when start Master, we also start some Workers to form a gearpump cluster. Only then the cluster is stabled, client then can submit application. We can see that when startup Master, in preStart() method, Master created an AppManager and Scheduler by invoking context.actorOf(...)
. That means before client submit application, AppManager and Scheduler already exists in Master, and they both preparing to work.
We’re also seeing a receiveHandler()
method return Receive object, and was invoked by waitForNextWorkerId()
method. What context.become()
and orElse
meaning? well, normaly you define one receive method, but here you have seen there’re multi receive method, so become() method of ActorContext is used for switchover between different receive method.
1 | private[cluster] class Master extends Actor with Stash { |
Now you have overview the main function in Master, lets see how clientMsgHandler receive method response to client’s submit application request. I have omit other unimportance request only left submit and restart application. The Master delegate/forward reqeust to AppManager.
1 | def clientMsgHandler: Receive = { |
AppManager is dedicated child of Master to manager all applications. The AppManager behaviour similar as Master.
1 | private[cluster] class AppManager( |
Master create AppManager by invoke context.actorOf(Props(...))
, here AppManager create AppMasterLauncher Actor by context.actorOf(launcher.props(..))
. AppMasterLauncher
is a child Actor of AppManager
, it is responsible to launch the AppMaster
on the cluster.
When AppManager receive SubmitApplication from client, it create AppMasterLauncher, and send RequestResource to master then wait for ResourceAllocation.
When AppMasterLauncher receive ResourceAllocated response from master, it will Try to launch a executor for AppMaster on worker specified by ResourceAllocated response.
1 | class AppMasterLauncher(...,master: ActorRef, client: ActorRef) extends Actor { |
Let’s see how Worker deal with LaunchExecutor reqeust from AppMasterLauncher.
1 | private[cluster] class Worker(masterProxy: ActorRef) extends Actor{ |
The ExecutorWatcher create a java process and the main class ActorSystemBooter
is coming from ExecutorJVMConfig which defined in AppMasterLauncher.
1 | class ExecutorWatcher(launch: LaunchExecutor, |
ExecutorWatcher is an Actor, ActorSystemBooter is an pure scala app. But inside ActorSystemBooter’s main method, it create another actor: Daemon.
1 | class ActorSystemBooter(config: Config) { |
Those many Actor headache me, and the invoke chain nest and nest again. So I draw a picture to help me understand what happend all the way around. To make my picture looks vividly, I use gear to indicate an Actor, you can see except ActorSystemBooter, all others are Actor. The underline character means request. Let me outlines some import steps.
Now the AppMasterLauncher is going to deal with RegisterActorSystem request. If you backward to check AppMasterLauncher, you can find that: after AppMasterLauncher send LaunchExecutor, it is waiting for ActorSystem to start.
After Daemon actor in Worker send RegisterActorSystem
request to AppMasterLauncher
, the AppMasterLauncher finally have chance to receive RegisterActorSystem event, first it send ActorSystemRegistered
request to Daemon, and then send another request CreateActor
to Daemon again.
1 | class AppMasterLauncher(...,master: ActorRef, client: ActorRef) extends Actor { |
Seems AppMasterLauncher and Daemon are playing ping-pong, and they both back and forth many times. Finally after Daemon create another Actor which we’ll talk about later, it then send ActorCreated back to AppMasterLauncher.
1 | class Daemon(val name: String, reportBack: String) extends Actor { |
Daemon create an Actor which defined in RegisterActorSystem on AppMasterLauncher. This Actor is AppMasterRuntimeEnvironment
, it’ll create AppMaster.
We know that create Actor can use context.actorOf(props)
method, here the props is passed from AppMasterLauncher to Daemon, but not created on Daemon side. Why do we doing this way? Because only AppMasterLauncher know how to create an AppMaster. Passing the props inside CreateActor is just like passing other request. Now the mainpoint focus transfer to AppMasterRuntimeEnvironment.
1 | object AppMasterRuntimeEnvironment { |
AppMasterRuntimeEnvironment will create three Actor once it’s created. It serves as runtime environment for AppMaster. When starting an AppMaster, we need to setup the connection to master(an MasterProxy which substitute to Master), and prepare other environments.
The MasterProxy also extend the function of Master, by providing a scheduler service for Executor System. AppMaster can ask Master for executor system directly. details like requesting resource, contacting worker to start a process, and then starting an executor system is hidden from AppMaster.
1 | private[appmaster] class AppMasterRuntimeEnvironment( |
The workflow from creating AppMasterRuntimeEnvironment
to create AppMaster
is trigged through MasterConnectionKeeper
by sending RegisterAppMaster
request to AppMasterLauncher
. Finally when AppMasterRuntimeEnvironment
receive MasterConnected
from MasterConnectionKeeper
, it send StartAppMaster
to AppMaster
. happy now! Take long long way bring up to AppMaster.
Note AppMasterRuntimeEnvironment did not send StartAppMaster directory to AppMaster but to LazyStartAppMaster. and Every message send to LazyStartAppMaster will forward to AppMaster. Why do we need a Lazy AppMaster? If you take look at LazyStartAppMaster, you’ll notice that LazyStartAppMaster is not really an AppMaster but it’s responsible to create AppMaster only when it receive StartAppMaster request from AppMasterRuntimeEnvironment. So you wont’t find StartAppMaster on AppMaster.
1 | class LazyStartAppMaster(appId: Int, appMasterProps: Props) |
The AppMaster is the head of a streaming application. It contains:
1 | class AppMaster(appContext: AppMasterContext, app: AppDescription) |
At now I lost my line of argument, as there’re no request send trigger inside AppMaster, so what’s the entry of AppMaster?
Keep in mind, once create AppMaster, it will create ExecutorManager
and TaskManager
. Althrough we did’t see request send directory from AppMaster, we could find if there’re something inside ExecutorManager or TaskManager.
Suddenly comeup so many Managers make me unprepared. But unlike AppManager
reside in Master, ExecutorManager
and TaskManager
both reside in Worker!
1 | class Planner { |
1 | case class DataSourceOp( |
1 | class TaskWrapper( |
Utility that helps user to create a DAG starting with [[DataSourceTask]] user should pass in a [[DataSource]]
Here is an example to build a DAG that reads from Kafka source followed by word count
1 | val source = new KafkaSource() |
1 | object DataSourceProcessor { |
Default Task container for [[org.apache.gearpump.streaming.source.DataSource]] that reads from DataSource in batch
DataSourceTask calls:
DataSource.open()
in onStart
and pass in [[org.apache.gearpump.streaming.task.TaskContext]]and application start time
DataSource.read()
in each onNext
, which reads a batch of messagesDataSource.close()
in onStop
1 | class DataSourceTask[IN, OUT] private[source]( |
https://issues.apache.org/jira/browse/KAFKA
https://github.com/apache/kafka/pull/723
最后来分析KafkaBasedLog
的readToLogEnd()
方法如何读取到日志的最末尾,具体步骤如下。
seekToEnd()
只是声明了重置策略为LATEST
,并没有真正定位。客户端还需要调用消费者的轮询方法,才能保证发送拉取请求,并更新消费者的当前位置;endOffset
)与上一次还没定位到最末尾时的位置(startOffset
),如果前者大于后者,客户端需要调用seek()
方法定位到旧的位置(startOffset
);1 | public class KafkaBasedLog<K, V> { |
客户端调用readToLogEnd()
之前,如果还有新的消息没有消费,当调用readToLogEnd()
方法时,可以保证客户端会完全消费新写入的消息。如图8-31(左图)所示,偏移量从3
到6
是新写入的消息(比如一个连接器配置、两个任务配置、一个提交日志的配置,总共四条消息)。客户端为了读取到分区最近的位置,先定位到最近的位置(7
)。注意这时不能立即调用轮询方法,因为如果客户端在最近的位置,调用轮询不会有任何的新消息。客户端应该再定位到上次消费的位置(3
),然后才能调用轮询方法,直到消费者的当前位置大于等于最近位置时,就说明客户端读取到了日志的最末尾。右图中,假设客户端已经消费到了日志的最末尾,那么调用readToLogEnd()
方法会立即返回。
图8-31 读取到分区最末尾的位置
注意:上面的
readToLogEnd()
方法用到了Kafka新消费者的三个方法。(1):postion()
方法返回消费者当前的位置,即消费进度,这个值比客户端真正消费过的位置要大1
。比如客户端消费了两条消息,postion()
方法的返回值就等于3
。(2):seekToEnd(tp)
方法定位到日志的最末尾,同样,这个值也是实际的偏移量加上1
(即nextOffset
)。比如分区实际只有六条消息,最末尾的偏移量等于7
。(3):seekTo(tp,offset)
方法定位到日志的指定位置。客户端定位到指定位置后,下一步一般是要调用轮询方法,并从这个位置拉取消息。所以如果客户端已经消费了偏移量等于1
和2
的两条消息,定位的位置是3
,表示要拉取第三条的消息。不能定位到2
,那样的话,从位置2
开始拉取消息,就重复拉取了第二条消息。
直接添加到命令行后
1 | --files=/yourPath/metrics.properties --conf spark.metrics.conf=metrics.properties |
The –files flag will cause /path/to/metrics.properties to be sent to every executor,
and spark.metrics.conf=metrics.properties will tell all executors to load that file
when initializing their respective MetricsSystems.
或者用conf的形式
1 | --conf spark.metrics.conf.*.sink.graphite.class=org.apache.spark.metrics.sink.GraphiteSink \ |
1 | *.sink.console.class=org.apache.spark.metrics.sink.ConsoleSink |
1 | ➜ spark-2.0.1-bin-hadoop2.7 bin/spark-shell |
1 | ➜ ~ ll /tmp/ -rth |
1 | executor.source.cassandra-connector.class=org.apache.spark.metrics.CassandraConnectorSource |
https://github.com/palantir/spark-influx-sink
spark.driver.extraClassPath=spark-influx-sink.jar:metrics-influxdb.jar
spark.executor.extraClassPath=spark-influx-sink.jar:metrics-influxdb.jar
1 | *.sink.influx.class=org.apache.spark.metrics.sink.InfluxDbSink |
执行./datatorrent-rts-community-3.7.0.bin --help
打印帮助项
1 | [qihuang.zheng@dp0653 install]$ sudo -u admin ./datatorrent-rts-community-3.7.0.bin \ |
创建apex项目,并打包
1 | name=salesapp |
上传到datatorrent平台
maven-assembly-plugin
1 | <plugin> |
maven-shade-plugin
1 | <plugin> |
1 | [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ test --- |
maven-assembly-plugin生成test-1.0-SNAPSHOT-jar-with-dependencies.jar
maven-shade-plugin的shadedClassifierName为fat
,结果:test-1.0-SNAPSHOT-fat.jar
1 | ➜ test jar -tvf target/test-1.0-SNAPSHOT-jar-with-dependencies.jar|grep shaded |
1 | mvn install:install-file -Dfile=~/Downloads/ojdbc6-11.2.0.3.jar -DgroupId=com.oracle -DartifactId=ojdbc6 -Dversion=11.2.0 -Dpackaging=jar |
源码包上传
1 | mvn deploy |
本地包上传到nexus
1 | mvn deploy:deploy-file -DgroupId=<group-id> \ |
以-数字开头或者-V开头生成准备文件:
1 | val files = new java.io.File("/Users/zhengqh/Downloads/V100R002C60U20CP003/common/lib").listFiles.map(_.getName).filter(_.startsWith("h")).toList |
导入到maven仓库:
1 | cat genMaven.txt | while read line |
不更改groupId,从MANIFEST中获取groupId
1 | cat genMaven.txt | while read line |
找不到的jar包改版本后重新上传
1 | mvn deploy:deploy-file -Dfile=hadoop-yarn-server-tests-2.7.2.jar \ |