管理 Samza 状态
Samza 的一个更有趣的特点是有状态的流处理。任务可以通过 Samza 提供的 API 来存储和查询数据。该数据存储在与流任务相同的机器上; 与通过网络连接到远程数据库相比,Samza 的本地状态允许您以更好的性能读写大量数据。Samza 将这种状态复制到多台机器上以实现容错(下面详细描述)。
一些流处理作业不需要状态:如果您只需要一次转换一个消息,或者根据某些条件过滤掉消息,则您的工作可能很简单。对任务进程方法的每次调用都会处理一个传入消息,每个消息都与所有其他消息无关。
然而,能够维护状态为复杂的流处理作业开辟了许多可能性:加入输入流,分组消息和聚合消息组。通过与 SQL 的类比,查询的 select 和 where 子句通常是无状态的,但是连接,分组和聚合函数(如 sum 和 count)需要状态。Samza 尚未提供更高级别的类似 SQL 的语言,但它提供了可用于实现流聚合和连接的较低级别的原语。
常用的状态处理用例
首先,我们来看一下可以在消费者网站后端看到的状态流处理的一些简单例子。在本页后面,我们将讨论如何使用 Samza 内置的键值存储功能实现这些应用程序。
窗口聚合
示例:计算每个用户每小时的页面浏览量
在这种情况下,您的状态通常由多个计数器组成,当处理消息时会增加计数器。聚合通常限于时间窗口(例如1分钟,1小时,1天),以便您可以随时间观察活动的变化。这种窗口处理对于排名和相关性是常见的,检测“趋势主题”,以及实时报告和监视。
最简单的实现将这种状态保持在内存中(例如任务实例中的哈希映射),并在每个时间窗口的末尾将其写入数据库或输出流。但是,您需要考虑当容器发生故障并且内存中的状态丢失时会发生什么。您可以通过再次处理当前窗口中的所有消息来还原它,但如果窗口长时间可能需要很长时间。Samza 可以通过使状态容错而不是试图重新计算来加速这种恢复。
桌子加入
示例:通过 user_id 将用户配置文件表加入到用户设置表中,并发出连接的流
您可能会想:在流处理系统中加入两个表格是否有意义?如果您的数据库可以提供数据库中的所有更改的日志。数据库和更改日志流之间存在对偶性:您可以将每个数据更改发布到流中,如果从头到尾消耗整个流,则可以重构数据库的全部内容。Samza 专为符合这一理念的数据处理工作而设计。
如果您有多个数据库表的更改日志流,您可以编写一个流处理作业,将每个表的最新状态保存在本地键值存储中,您可以比通过对原始数据库进行查询更快地访问它。现在,当一个表中的数据发生变化时,可以将其与另一个表中相同键的最新数据相加,并输出加入的结果。
数据规范化的几个现实生活的例子基本上是这样工作的:
- 像亚马逊和 EBay 这样的电子商务公司需要从商家进口商品,将产品规格化,并向所有相关商家和定价信息展示产品。
- Web 搜索需要构建一个抓取工具,该抓取工具基本上创建一个网页内容表格,并且连接所有关联属性,如点击率或 pagerank。
- 社交网络采用用户输入的文字,需要对公司,学校和技能等实体进行规范化。
这些用例中的每一个都是大量复杂的数据规范化问题,可以被认为是在许多输入表上构建物化视图。Samza 可以有力地实施这些数据处理流水线。
流表连接
示例:使用用户的邮政编码来增加一个页面视图流(可能允许在后期通过邮政编码进行聚合)
将边信息连接到实时 Feed 是流处理的经典用途。这在广告,相关性排名,欺诈检测等领域尤为常见。诸如页面浏览的活动事件通常仅包括少量属性,例如观看者的 ID 和观看的项目,但不包括观看者的详细属性和所查看的项目,例如用户的邮政编码。如果要通过查看器或查看项目的属性聚合流,则需要分别与 users 表或 items 表一起加入。
在数据仓库术语中,您可以将原始事件流视为中心事实表中的行,这些行需要与维度表相结合,以便您可以在分析中使用维度的属性。
流流连接
示例:将广告点击次数加入到广告展示流中(将广告展示时间的信息链接到点击点击的信息)
流连接对于“几乎对齐”的流很有用,您希望在多个输入流中接收相关事件,并且您希望将它们组合成一个输出事件。您不能同时依赖到达流处理器的事件,但您可以设置允许事件扩展的最长时间。
为了执行流之间的连接,您的作业需要缓存要加入的时间窗口的事件。对于短时间窗口,您可以在内存中进行此操作(如果机器发生故障,则可能会丢失事件)。您还可以使用 Samza 的状态存储来缓冲事件,这样可以缓冲更多的消息,而不是内存中的内容。
更多
连接和聚合有许多变化,但大多数是上述模式的变化和组合。
管理任务状态的方法
那么系统如何支持这种状态处理呢?我们将通过描述我们在其他流处理系统中看到的内容,然后描述 Samza 所做的工作。
带检查点的内存状态
在学术流处理系统中常见的一种简单方法是定期将任务的整体内存数据保存到持久存储中。如果内存中状态仅由几个值组成,则此方法效果很好。但是,您必须在每个检查点上存储完整的任务状态,这随着任务状态的增长而变得越来越昂贵。不幸的是,连接和聚合的许多非平凡用例有大量的状态 - 通常是很多千兆字节。这使得国家完全不切实际。
一些学术系统除了完整的检查点之外还会产生差异,如果只有一些状态自最后一个检查点以来发生变化,则它们会更小。Storm的Trident抽象类似地保持内存中的缓存状态,并定期向远程存储(如 Cassandra)写入任何更改。但是,如果大多数状态保持不变,则此优化将有所帮助。在一些使用情况下,例如流连接,在该状态下有很多的流失是正常的,所以这种技术本质上会降低为每个消息发出远程数据库请求(见下文)。
使用外部商店
用于状态处理的另一种常见模式是将状态存储在外部数据库或键值存储中。传统的数据库复制可用于使数据库容错。架构看起来像这样:
Samza 允许这种处理方式 - 没有任何东西阻止您查询作业中的远程数据库或服务。然而,远程数据库可能会对有状态流处理有问题:
- 性能:通过网络进行数据库查询速度较慢且昂贵。一个 Kafka 流可以将每个 CPU 内核的数十万甚至数百万条消息传递给流处理器,但如果您需要对每个处理的消息进行远程请求,那么您的吞吐量可能会下降2-3个大小。您可以通过仔细缓存读取和批量写入来稍微缓解这一点,但是您将回到以上讨论的检查点问题。
- 隔离:如果您的数据库或服务也向用户提供请求,则与流处理器使用相同的数据库可能是危险的。可扩展的流处理系统可以以非常高的吞吐量运行,并且容易产生大量的负载(例如,当赶上队列积压时)。如果您不是非常小心,可能会对您自己的数据库造成拒绝服务攻击,并对用户的交互式请求造成问题。
- 查询功能:许多可扩展数据库暴露了非常有限的查询界面(例如,仅支持简单的键值查找),因为相当于“全表扫描”或丰富的遍历将太贵了。流过程通常对延迟敏感度较低,因此更丰富的查询功能将更为可行。
- 正确性:当流处理器出现故障并需要重新启动时,数据库状态如何与处理任务保持一致?为此,一些框架(如Storm)将元数据附加到数据库条目,但需要仔细处理,否则流过程会产生不正确的输出。
- 重新处理:有时,在大量历史数据上重新运行流程可能会很有用,例如在更新处理任务的代码之后。但是,上述问题使得外部查询的作业变得不切实际。
萨姆萨当地
Samza 允许任务以与上述方法不同的方式维护状态:
- 状态存储在磁盘上,所以作业可以保持比适合内存更多的状态。
- 它存储在与处理任务相同的机器上,以避免通过网络进行数据库查询的性能问题。
- 每个作业都有自己的数据存储区,以避免共享数据库的隔离问题(如果使用昂贵的查询,它只影响当前的任务,没有其他的)。
- 可以插入不同的存储引擎,实现丰富的查询功能。
- 状态不断复制,实现容错,无需检查大量状态的问题。
想象一下,您需要一个远程数据库,对其进行分区以匹配流处理作业中的任务数量,并将每个分区与其任务共同定位。结果如下:
如果机器故障,则该机器上运行的所有任务及其数据库分区都将丢失。为了使它们高度可用,对数据库分区的所有写入都将复制到持久的更新日志(通常为 Kafka)。现在,当机器发生故障时,我们可以在另一台机器上重新启动任务,并使用此更改日志来恢复数据库分区的内容。
请注意,每个任务只能访问自己的数据库分区,而不是任何其他任务的分区。这很重要:当您通过提供更多的计算资源来扩展您的工作时,Samza 需要将任务从一台机器移到另一台机器。通过给每个任务自己的状态,任务可以重新定位而不影响作业的操作。如果需要,您可以重新分配流,以使特定数据库分区的所有消息都路由到同一个任务实例。
日志压缩在更改日志主题的后台运行,并确保更改日志不会无限期增长。如果您在存储中多次覆盖相同的值,则日志压缩仅保留最近的值,并抛出日志中的任何旧值。如果从商店中删除一个项目,则日志压缩也会将其从日志中删除。通过正确的调整,更改日志不会比数据库本身大得多。
通过这种体系结构,Samza 允许任务能够维持大量的容错状态,性能几乎与纯内存实现一样好。只有一些限制:
- 如果您希望在任务之间(跨分区边界)共享某些数据,则需要进行一些额外的努力来重新分配和分发数据。每个任务都需要自己的数据副本,所以这可能会使用更多的空间。
- 当容器重新启动时,可能需要一些时间来恢复其所有状态分区中的数据。时间取决于数据量,存储引擎,访问模式等因素。根据经验,50 MB /秒是合理的恢复时间。
没有什么可以阻止您使用外部数据库,但是对于许多用例,Samza 的本地状态是启用状态流处理的强大工具。
键值存储
任何存储引擎都可以插入 Samza,如下所述。开箱即用,Samza 搭载了一个使用 JNI API 构建在 RocksDB 上的键值存储实现。
RocksDB 有几个不错的属性。它的内存分配不在 Java 堆中,这使得它比基于 Java 的存储引擎更加内存高效,并且不太容易进行垃圾回收暂停。对于适合内存的小型数据集来说,速度非常快; 大于内存的数据集较慢但仍然可能。它是日志结构,允许非常快速的写入。它还包括对块压缩的支持,这有助于减少 I / O 和内存使用。
Samza 在 RocksDB 前面增加了一个内存缓存层,避免了经常访问的对象和批次写入的反序列化成本。如果快速连续更新多个相同的密钥,则批处理将这些更新合并为单个写入。当任务提交时,写入将刷新到更改日志。
要在作业中使用键值存储,请将以下内容添加到作业配置中:
# Use the key-value store implementation for a store called "my-store"
stores.my-store.factory=org.apache.samza.storage.kv.RocksDbKeyValueStorageEngineFactory
# Use the Kafka topic "my-store-changelog" as the changelog stream for this store.
# This enables automatic recovery of the store after a failure. If you don't
# configure this, no changelog stream will be generated.
stores.my-store.changelog=kafka.my-store-changelog
# Encode keys and values in the store as UTF-8 strings.
serializers.registry.string.class=org.apache.samza.serializers.StringSerdeFactory
stores.my-store.key.serde=string
stores.my-store.msg.serde=string
有关 serde 选项的更多信息,请参阅序列化部分。
这是一个简单的例子,将每个传入的消息写入商店:
public class MyStatefulTask implements StreamTask, InitableTask {
private KeyValueStore<String, String> store;
public void init(Config config, TaskContext context) {
this.store = (KeyValueStore<String, String>) context.getStore("my-store");
}
public void process(IncomingMessageEnvelope envelope,
MessageCollector collector,
TaskCoordinator coordinator) {
store.put((String) envelope.getKey(), (String) envelope.getMessage());
}
}
以下是完整的键值存储API:
public interface KeyValueStore<K, V> {
V get(K key);
void put(K key, V value);
void putAll(List<Entry<K,V>> entries);
void delete(K key);
KeyValueIterator<K,V> range(K from, K to);
KeyValueIterator<K,V> all();
}
配置参考中记录了键值存储的其他配置属性。
调试键值存储
从更改日志实现状态存储
目前,Samza 提供了一种状态存储工具,可以将状态存储从更改日志流恢复到用户指定的目录以进行重用和调试。
samza-example/target/bin/state-storage-tool.sh \
--config-path=file:///path/to/job/config.properties \
--path=directory/to/put/state/stores
读取正在运行的RocksDB的值
Samza 还提供了一个工具来读取正在运行的工作的 RocksDB 的价值。
samza-example/target/bin/read-rocksdb-tool.sh \
--config-path=file:///path/to/job/config.properties \
--db-path=/tmp/nm-local-dir/state/test-state/Partition_0 \
--db-name=test-state \
--string-key=a,b,c
- --config-path(必填):您的工作的配置文件
- --db-path(必填):您的RocksDB的位置。如果 RocksDB 与工具在同一台机器上,这是很方便的。例如,如果您在本地机器上运行 hello-samza,则该位置可能位于/ tmp / hadoop / nm-local-dir / usercache / username / appcache / applicationId / containerId / state / storeName / PartitionNumber
- --db-name(必需):如果您只有一个状态存储在配置文件中指定,您可以忽略这一个。否则,您需要在此处提供状态商店名称。
- --string-key:关键列表。这个只有你的键是字符串才有效。也有另一种两种选择:--integer-key,--long-key。它们分别用于整数键和长键。
限制:
- 这只适用于三种键:string,integer和long。这是因为我们只能从命令行接受这些键(从命令行接受字节,avro,json等)真的很棘手。但是,编程方式也很容易使用这个工具(这两个键值和值都被反序列化)。
RocksDbKeyValueReader kvReader = new RocksDbKeyValueReader(dbName, pathOfdb, config)
Object value = kvReader.get(key)
- 因为 Samza 作业有一些高速缓存和缓冲区,所以您可能无法看到预期的值(甚至无法看到任何值,如果所有数据都被缓存)。一些相关配置的是 stores.store-name.container.write.buffer.size.bytes,stores.store-name.write.batch.size,stores.store-name.object.cache.size。您可能希望将其设置为非常小的测试。
- 由于 RocksDB memtable 在每次写入时都不会立即刷新到磁盘,所以在写入磁盘上的SST文件之前,您可能无法看到预期的值。有关 RocksDb 的更多详细信息,可以在这里参考文档。
已知的问题
RocksDB 有几个粗糙的边缘。建议您阅读 RocksDB 调整指南。需要注意的其他一些注意事项是:
- RocksDB 经过高度优化,能够运行 SSD 硬盘。非 SSD 的性能显着下降。
- Samza 的 KeyValueStorageEngine.putAll()方法目前不使用 RocksDB 的批处理 API,因为它在Java中不起作用。
- 调用 iterator.seekToFirst()非常慢,如果存在很多删除。
使用键值存储实现常见用例
在本节前面,我们讨论了有状态流处理的一些示例用例。我们来看看如何使用诸如 Samza 的 RocksDB 商店等键值存储引擎实现这些功能。
窗口聚合
示例:计算每个用户每小时的页面浏览量
实施:您需要两个处理阶段。
- 第一个按用户 ID 重新划分输入数据,以便将特定用户的所有事件路由到相同的流任务。如果输入流已经被用户 ID 划分,您可以跳过这个。
- 第二阶段使用将用户 ID 映射到运行计数的键值存储进行计数。对于每个新事件,作业将从存储中读取适当用户的当前计数,将其递增,并将其写回。当窗口完成(例如,在一小时结束时),作业将遍历存储的内容,并将聚合发送到输出流。
请注意,此工作有效地停留在小时标记以输出其结果。这对 Samza 是完全正确的,因为扫描键值存储的内容是相当快的。当工作正在做这个小时工作时,输入流被缓冲。
桌子加入
示例:通过 user_id 将用户配置文件表加入到用户设置表中,并发出连接的流
实现:作业订阅用户配置文件数据库和用户设置数据库的更改流,都由 user_id 分区。该作业保留一个由 user_id 键入的键值存储区,其中包含最新的配置文件记录和每个 user_id 的最新设置记录。当一个新的事件从两个流进来时,作业将查找其存储中的当前值,更新相应的字段(取决于是否是配置文件更新或设置更新),并将新加入的记录写回商店。存储的更新日志加倍为任务的输出流。
表格流连接
示例:使用用户的邮政编码来增加一个页面视图流(可能允许在后期通过邮政编码进行聚合)
实施:作业订阅用户配置文件更新流和页面浏览事件流。两个流都必须用 user_id 进行分区。该作业维护一个键值存储区,其中 key 是 user_id,该值是用户的邮政编码。每当作业接收到配置文件更新时,它将从配置文件更新中提取用户的新邮政编码,并将其写入商店。每次收到页面浏览事件时,它会从商店中读取该用户的邮政编码,并使用添加的邮政编码字段发送页面查看事件。
如果下一阶段需要通过邮政编码进行汇总,则可以将邮政编码用作作业输出流的分区键。这确保了相同邮政编码的所有事件都被发送到同一流分区。
流流连接
示例:将广告点击次数加入到广告展示流中(将广告展示时间的信息链接到点击点击的信息)
在此示例中,我们假设广告的每次展示都有唯一的标识符,例如 UUID,并且相同的标识符包含在展示和点击事件中。该标识符用作连接密钥。
实施:按照展示 ID 或用户 ID 分配广告点击和广告展示流(假设具有相同展示 ID 的两个事件始终具有相同的用户 ID)。该任务保留两个商店,一个包含点击事件,一个包含展示事件,使用展示 ID 作为两个商店的关键。当作业收到点击事件时,它会在展示商店中查找相应的展示,反之亦然。如果找到匹配项,则发送连接对,并删除条目。如果没有找到匹配项,则将事件写入相应的商店。定期地,作业将扫描两个商店,并删除在连接的时间窗口内未匹配的任何旧事件。
其他存储引擎
Samza 的容错机制(将本地商店的写入发送到复制的更改日志)与存储引擎的数据结构和查询 API 完全分离。虽然键值存储引擎对于通用处理是有利的,但您可以通过实施StorageEngine接口轻松地为其他类型的查询添加自己的存储引擎。Samza 的模式特别适用于与流任务相同的过程中作为库运行的嵌入式存储引擎。
其他存储引擎的一些想法可能是有用的:持久堆(用于运行前N个查询),近似算法(如bloom过滤器和超文本记录)或全文索引(如Lucene)。(补丁欢迎!)
具有状态的容错语义
如关于检查点的部分所述,Samza 目前只支持在出现故障的情况下至少提供一次交付保证(有时称为“保证交货”)。这意味着如果任务失败,则不会丢失任何消息,但可能会重新传递某些消息。
对于上面讨论的许多有状态处理使用情况,这不是一个问题:如果消息对状态的影响是幂等的,则对同一消息进行多次处理是安全的。例如,如果商店包含每个用户的邮政编码,则两次处理相同的配置文件更新没有任何效果,因为重复的更新不会更改邮政编码。
但是,对于非幂等操作(如计数),至少一次交货保证可能会给出不正确的结果。如果Samza任务失败并重新启动,则可能会在发生故障之前不久处理的一些消息进行双重计数。我们计划在未来的Samza发行版中解决这个限制。
更多建议: