如果你使用了像Kafka这样的流式处理平台,就要搞清楚一件事情:你需要用到哪些主题?特别是如果你要将一堆不同的事件作为消息发布到Kafka,是将它们放在同一个主题中,还是将它们拆分到不同的主题中?
\\Kafka主题最重要的一个功能是可以让消费者指定它们想要消费的消息子集。在极端情况下,将所有数据放在同一个主题中可能不是一个好主意,因为这样消费者就无法选择它们感兴趣的事件——它们需要消费所有的消息。另一种极端情况,拥有数百万个不同的主题也不是一个好主意,因为Kafka的每个主题都是有成本的,拥有大量主题会损害性能。
\\实际上,从性能的角度来看,分区数量才是关键因素。在Kafka中,每个主题至少对应一个分区,如果你有n个主题,至少会有n个分区。不久之前,Jun Rao写了一篇博文,解释了拥有多个分区的成本(端到端延迟、文件描述符、内存开销、发生故障后的恢复时间)。根据经验,如果你关心延迟,那么每个节点分配几百个分区就可以了。如果每个节点的分区数量超过成千上万个,就会造成较大的延迟。
\\关于性能的讨论为设计主题结构提供了一些指导:如果你发现自己有数千个主题,那么将一些细粒度、低吞吐量的主题合并到粗粒度主题中可能是个明智之举,这样可以避免分区数量蔓延。
\\然而,性能并不是我们唯一关心的问题。在我看来,更重要的是主题结构的数据完整性和数据模型。我们将在本文的其余部分讨论这些内容。
\\主题等于相同类型事件的集合?
\\人们普遍认为应该将相同类型的事件放在同一主题中,不同的事件类型应该使用不同的主题。这种思路让我们联想到关系型数据库,其中表是相同类型记录的集合,于是我们就有了数据库表和Kafka主题之间的类比。
\\Confluent Avro Schema Registry进一步强化了这种概念,因为它鼓励你对主题的所有消息使用相同的Avro模式(schema)。模式可以在保持兼容性的同时进行演化(例如通过添加可选字段),但所有消息都必须符合某种记录类型。稍后我会再回过头来讨论这个问题。
\\对于某些类型的流式数据,例如活动事件,要求同一主题中所有消息都符合相同的模式,这是合理的。但是,有些人把Kafka当成了数据库来用,例如事件溯源,或者在微服务之间交换数据。对于这种情况,我认为是否将主题定义为具有相同模式的消息集合就不那么重要了。这个时候,更重要的是主题分区中的消息必须是有序的。
\\想象一下这样的场景:你有一个实体(比如客户),这个实体可能会发生许多不同的事情,比如创建客户、客户更改地址、客户向帐户中添加新的信用卡、客户发起客服请求,客户支付账单、客户关闭帐户。
\\这些事件之间的顺序很重要。例如,我们希望其他事件必须在创建客户之后才能发生,并且在客户关闭帐户之后不能再发生其他事件。在使用Kafka时,你可以将它们全部放在同一个主题分区中来保持它们的顺序。在这个示例中,你可以使用客户ID作为分区的键,然后将所有事件放在同一个主题中。它们必须位于同一主题中,因为不同的主题对应不同的分区,而Kafka是不保证分区之间的顺序的。
\\顺序问题
\\如果你为customerCreated、customerAddressChanged和customerInvoicePaid事件使用了不同的主题,那么这些主题的消费者可能就看不到这些事件之间的顺序。例如,消费者可能会看到一个不存在的客户做出的地址变更(这个客户尚未创建,因为相应的customerCreated事件可能发生了延迟)。
\\如果消费者暂停一段时间(比如进行维护或部署新版本),那么事件出现乱序的可能性就更高了。在消费者停止期间,事件继续发布,并且这些事件被存储在特定定的主题分区中。当消费者再次启动时,它会消费所有积压在分区中的事件。如果消费者只消费一个分区,那就没问题:积压的事件会按照它们存储的顺序依次被处理。但是,如果消费者同时消费几个主题,就会按任意顺序读取主题中数据。它可以先读取积压在一个主题上的所有数据,然后再读取另一个主题上积压的数据,或者交错地读取多个主题的数据。
\\因此,如果你将customerCreated、customerAddressChanged和customerInvoicePaid事件放在三个单独的主题中,那么消费者可能会在看到customerCreated事件之前先看到customerAddressChanged事件。因此,消费者很可能会看到一个客户的customerAddressChanged事件,但这个客户却未被创建。
\\你可能会想到为每条消息附加时间戳,并用它来对事件进行排序。如果你将事件导入数据仓库,再对事件进行排序,或许是没有问题的。但在流数据中只使用时间戳是不够的:在你收到一个具有特定时间戳的事件时,你不知道是否需要等待具有较早时间戳的事件,或者所有之前的事件是否已经在当前事情之前到达。依靠时钟进行同步通常会导致噩梦,有关时钟问题的更多详细信息,请参阅“Designing Data-Intensive Applications”的第8章。
\\何时拆分主题,何时合并主题?
\\基于这个背景,我将给出一些经验之谈,帮你确定哪些数据应该放在同一主题中,以及哪些数据应该放在不同的主题中。
\\- 首先,需要保持固定顺序的事件必须放在同一主题中(并且需要使用相同的分区键)。如果事件属于同一实体,那么事件的顺序就很重要。因此,我们可以说,同一实体的所有事件都应该保存在同一主题中。\\t
如果你使用事件溯源进行数据建模,事件的排序尤为重要。聚合对象的状态是通过以特定的顺序重放事件日志而得出的。因此,即使可能存在不同的事件类型,聚合所需要的所有事件也必须在同一主题中。
\\t\\t - 对于不同实体的事件,它们应该保存在相同的主题中还是不同的主题中?我想说,如果一个实体依赖于另一个实体(例如一个地址属于一个客户),或者经常需要同时用到它们,那么它们也应该保存在同一主题中。另一方面,如果它们不相关,并且属于不同的团队,那么最好将它们放在不同的主题中。\\t
另外,这也取决于事件的吞吐量:如果一个实体类型的事件吞吐量比其他实体要高很多,那么最好将它分成几个主题,以免让只想消费低吞吐量实体的消费者不堪重负(参见第4点)。不过,可以将多个具有低吞吐量的实体合并起来。
\\t\\t - 如果一个事件涉及多个实体该怎么办?例如,订单涉及到产品和客户,转账至少涉及到两个账户。\\t
我建议在一开始将这些事件记录为单个原子消息,而不是将其分成几个属于不同主题的消息。在记录事件时,最好可以保持原封不动,即尽可能保持数据的原始形式。你可以随后使用流式处理器来拆分复合事件,但如果过早进行拆分,想要重建原始事件会难得多。如果能够为初始事件分配一个唯一ID(例如UUID)就更好了,之后如果你要拆分原始事件,可以带上这个ID,从而可以追溯到每个事件的起源。
\\t\\t - 看看消费者需要订阅的主题数量。如果几个消费者都订阅了一组特定的主题,这表明可能需要将这些主题合并在一起。\\t
如果将细粒度的主题合并成粗粒度的主题,一些消费者可能会收到他们不需要的事件,需要将其忽略。这不是什么大问题:消费消息的成本非常低,即使最终忽略了一大半的事件,总的成本可能也不会很大。只有当消费者需要忽略绝大多数消息(例如99.9%是不需要的)时,我才建议将大容量事件流拆分成小容量事件流。
\\t\\t - 用作Kafka Streams状态存储(KTable)的变更日志主题应该与其他主题分开。在这种情况下,这些主题由Kafka Streams流程来管理,所以不应该包含其他类型的事件。\\t
最后,如果基于上述的规则依然无法做出正确的判断,该怎么办?那么就按照类型对事件进行分组,把相同类型的事件放在同一个主题中。不过,我认为这条规则是最不重要的。
\\t\
模式管理
\\如果你的数据是普通文本(如JSON),而且没有使用静态的模式,那么就可以轻松地将不同类型的事件放在同一个主题中。但是,如果你使用了模式编码(如Avro),那么在单个主题中保存多种类型的事件则需要考虑更多的事情。
\\如上所述,基于Avro的Kafka Confluent Schema Registry假设了一个前提,即每个主题都有一个模式(更确切地说,一个模式用于消息的键,一个模式用于消息的值)。你可以注册新版本的模式,注册表会检查模式是否向前和向后兼容。这样设计的一个好处是,你可以让不同的生产者和消费者同时使用不同版本的模式,并且仍然保持彼此的兼容性。
\\Confluent的Avro序列化器通过subject名称在注册表中注册模式。默认情况下,消息键的subject为\u0026lt;topic\u0026gt;-key,消息值的subject为\u0026lt;topic\u0026gt;-value。模式注册表会检查在特定subject下注册的所有模式的相互兼容性。
\\最近,我为Avro序列化器提供了一个补丁(https://github.com/confluentinc/schema-registry/pull/680),让兼容性检查变得更加灵活。这个补丁添加了两个新的配置选项:key.subject.name.strategy(用于定义如何构造消息键的subject名称)和value.subject.name.strategy(用于定义如何构造消息值的subject名称)。它们的值可以是如下几个:
\\- io.confluent.kafka.serializers.subject.TopicNameStrategy(默认):消息键的subject名称为\u0026lt;topic\u0026gt;-key,消息值为\u0026lt;topic\u0026gt;-value。这意味着主题中所有消息的模式必须相互兼容。\\t
- io.confluent.kafka.serializers.subject.RecordNameStrategy:subject名称是Avro记录类型的完全限定名。因此,模式注册表会检查特定记录类型的兼容性,而不管是哪个主题。这个设置允许同一主题包含不同类型的事件。\\t
- io.confluent.kafka.serializers.subject.TopicRecordNameStrategy:subject名称是\u0026lt;topic\u0026gt;-\u0026lt;type\u0026gt;,其中\u0026lt;topic\u0026gt;是Kafka主题名,\u0026lt;type\u0026gt;是Avro记录类型的完全限定名。这个设置允许同一主题包含不同类型的事件,并进一步对当前主题进行兼容性检查。\
有了这个新特性,你就可以轻松地将属于特定实体的所有不同类型的事件放在同一个主题中。现在,你可以自由选择主题的粒度,而不仅限于一个类型对应一个主题。
\\英文原文:http://martin.kleppmann.com/2018/01/18/event-types-in-kafka-topic.html
\\感谢蔡芳芳对本文的审校。