本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
主题、消费者组与顺序
本指南在消费者组中读取主题,用并行的工作分区保持每个键的顺序,并按键、按编号或轮流把记录放到分区上。
示例是一家银行的账簿服务。 它把转账记入账户账簿,筛查欺诈,为自己的记录维护索引,并为有争议的转账立案。
每个账户内的顺序
Kafka 在一个分区内保持记录的顺序。
键相同的记录落到同一个分区,所以每个键的顺序得以保持。
post 读取以账户为键的转账,最多同时处理八条,而且不打乱这个顺序。
- 宏
- 手写
/// The account a record belongs to, carried as the Kafka record key.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct AccountKey {
#[serde(rename = "kafka-partition-key")]
account: String,
}
#[derive(Debug, PartialEq, Deserialize)]
struct Transfer {
id: u64,
amount_cents: i64,
}
/// One line of an account's ledger, keyed by the account like the transfer it came from.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "bank.entries", headers = AccountKey)]
struct Entry {
transfer_id: u64,
amount_cents: i64,
}
#[derive(OutSlot)]
#[publishes(Entry)]
struct Entries;
#[subscriber(
KafkaTopic::new("transfers").lane_key(LaneKey::RecordKey),
workers(8, by_key)
)]
async fn post(
transfer: &Transfer,
Headers(key): Headers<AccountKey>,
Out(entries): Out<impl Publisher, Entries>,
) -> HandlerOutcome {
let entry = Entry {
transfer_id: transfer.id,
amount_cents: transfer.amount_cents,
};
if entries
.message(&entry)
.with_headers(&key)
.publish()
.await
.is_err()
{
return HandlerOutcome::retry();
}
tracing::info!(transfer.id, account = %key.account, "transfer posted");
HandlerOutcome::ack()
}
/// The account a record belongs to, carried as the Kafka record key.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct AccountKey {
#[serde(rename = "kafka-partition-key")]
account: String,
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Transfer {
id: u64,
amount_cents: i64,
}
/// One line of an account's ledger, keyed by the account like the transfer it came from.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Entry {
transfer_id: u64,
amount_cents: i64,
}
impl OutgoingDestination for Entry {
type Form = FixedName;
const DESTINATION: &'static str = "bank.entries";
}
impl MessageHeaders for Entry {
type Contract = WithHeaders<AccountKey>;
}
impl MessageInfo for Entry {
const NAME: &'static str = "Entry";
}
struct Entries;
impl OutSlot for Entries {
const NAME: &'static str = "Entries";
type Destination = Reads;
}
impl PublishedThrough<Entries> for Entry {}
struct Post;
impl<Ledger> Handle<Message<AccountKey, Transfer>, (), Outs<(Ledger,)>> for Post
where
Ledger: OutEntry<Entries, Wire: Publisher>,
{
async fn handle(
&self,
transfer: &Message<AccountKey, Transfer>,
outs: &Outs<(Ledger,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let key = &transfer.headers;
let entry = Entry {
transfer_id: transfer.body.id,
amount_cents: transfer.body.amount_cents,
};
if outs
.get(Entries)
.message(&entry)
.with_headers(key)
.publish()
.await
.is_err()
{
return Err(HandlerOutcome::retry());
}
tracing::info!(transfer.body.id, account = %key.account, "transfer posted");
Ok(())
}
}
workers(8, by_key) 运行八个工作分区;分区键相同的投递在同一个工作分区上依次运行。
LaneKey::RecordKey 以记录键作为分区键,所以同一账户的两笔转账绝不会同时运行,即使它们在同一个分区内。
默认的 LaneKey::Partition 以来源分区作为分区键,这也覆盖了没有键的记录。
在手写形式中,同一设置是 workers_by_key。
记录键以 kafka-partition-key 消息头(PARTITION_KEY_HEADER)到达处理器。
AccountKey 是一个消息头契约,只有一个以该名字命名的字段。
Headers<AccountKey> 读取投递的键,with_headers(&key) 把它写成账簿条目的键。
于是条目在 bank.entries 中也落到其账户的分区上,账簿的读者按顺序看到每个账户。
没有键的转账不满足消息头契约,由解码策略结算,默认会丢弃它。
消费者组
一个消费者组对每条记录只读取一次,不论该分区分配给了组里的哪个成员。
每个组都读取自己的一份。
screen 在自己的组中读取同样的转账,所以 post 看到的每笔转账它都能看到。
- 宏
- 手写
#[subscriber(
KafkaTopic::new("transfers")
.group("fraud-check")
.start(StartOffset::Earliest)
.assignment(Assignment::CooperativeSticky)
)]
async fn screen(transfer: &Transfer) -> HandlerOutcome {
if transfer.amount_cents > 1_000_000 {
tracing::warn!(transfer.id, "large transfer flagged");
}
HandlerOutcome::ack()
}
struct Screen;
impl Handle<Transfer> for Screen {
fn handle(
&self,
transfer: &Transfer,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if transfer.amount_cents > 1_000_000 {
tracing::warn!(transfer.id, "large transfer flagged");
}
ready(Ok(()))
}
}
KafkaTopic::group 指定组。
没有指定组的订阅加入 Broker 的 default_group;post 就是这样。
Kafka 只在组内读取主题,所以两者都没有的服务会在启动时失败。
StartOffset 决定组在某个分区上没有已提交的偏移量时从哪里开始。
组第一次运行时,以及保留策略删除了它的偏移量之后,都属于这种情况。
Earliest 从最旧的记录开始,Latest 从日志末尾开始,默认的 Committed 把选择交给 librdkafka 的 auto.offset.reset,其值为 latest。
Assignment 选择组在成员之间分配分区的方式。
有成员加入或离开时,CooperativeSticky 只移动换了主人的分区,其余分区继续运行。
启动会等到组把分区分配给这个成员,最多等待 assignment_timeout(默认 60 秒)。
此后,发布到该主题的每条记录都会到达订阅。
给服务的每个订阅一个独立的组。
同一个组里读取不同主题的两个订阅会互相触发再均衡:第二个加入时,第一个会短暂失去它的分区。
在组重新分配分区之前,第一个成员不读取任何记录,之后从停下的位置继续。
多个主题与模式
KafkaTopics 通过一个消费者和一个组读取多个主题。
KafkaTopics::pattern 读取名字匹配某个正则表达式的所有主题,该表达式必须以 ^ 开头。
archive 读取银行的所有主题,并记下每条记录所在的位置。
- 宏
- 手写
/// Where a record of one of the bank's topics sits.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "archive.index")]
struct Archived {
topic: String,
partition: i32,
offset: i64,
}
#[subscriber(KafkaTopics::pattern("^bank\\..*").group("archive"), reply)]
async fn archive(
_record: &serde_json::Value,
Ctx(topic): Ctx<Topic>,
Ctx(partition): Ctx<Partition>,
Ctx(offset): Ctx<Offset>,
) -> Archived {
Archived {
topic: topic.to_string(),
partition,
offset,
}
}
/// Where a record of one of the bank's topics sits.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Archived {
topic: String,
partition: i32,
offset: i64,
}
impl OutgoingDestination for Archived {
type Form = FixedName;
const DESTINATION: &'static str = "archive.index";
}
impl MessageHeaders for Archived {
type Contract = NoHeaders;
}
impl MessageInfo for Archived {
const NAME: &'static str = "Archived";
}
struct Archive;
impl Handle<serde_json::Value, Archived, (), KafkaContext> for Archive {
fn handle(
&self,
_record: &serde_json::Value,
_outs: &(),
ctx: &mut Context<'_, KafkaContext>,
) -> impl Future<Output = Result<Archived, HandlerOutcome>> {
ready(Ok(Archived {
topic: ctx.context(Topic).to_string(),
partition: ctx.context(Partition),
offset: ctx.context(Offset),
}))
}
}
处理器从上下文读取投递的坐标:Topic、Partition 和 Offset。
所有匹配的主题共用一个载荷类型;archive 解码任意 JSON。
启动后创建的主题,会在 librdkafka 下一次刷新元数据时加入模式。
KafkaPartitions 读取它指定的分区,不加入任何组。
它从不再均衡,适合每个分区一个进程的部署,或检查单个分区的工具。
把记录放到分区上
open_case 把一笔有争议的转账变成 bank.cases 上的一个案件。
该主题有三个分区:0 和 1 平分普通案件,分区 2 存放紧急案件。
- 宏
- 手写
/// The partition of `bank.cases` kept for urgent cases.
const URGENT: i32 = 2;
#[derive(Debug, PartialEq, Deserialize)]
struct Dispute {
transfer_id: u64,
urgent: bool,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "bank.cases")]
struct Case {
transfer_id: u64,
}
#[derive(OutSlot)]
#[publishes(Case)]
struct Cases;
#[subscriber(KafkaTopic::new("card-disputes").group("case-intake"))]
async fn open_case(
dispute: &Dispute,
Out(cases): Out<impl Publisher<Options = KafkaOptions>, Cases>,
) -> HandlerOutcome {
let case = Case {
transfer_id: dispute.transfer_id,
};
let opened = if dispute.urgent {
cases.message(&case).partition(URGENT).publish().await
} else {
cases.message(&case).publish().await
};
if opened.is_err() {
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// The partition of `bank.cases` kept for urgent cases.
const URGENT: i32 = 2;
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Dispute {
transfer_id: u64,
urgent: bool,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Case {
transfer_id: u64,
}
impl OutgoingDestination for Case {
type Form = FixedName;
const DESTINATION: &'static str = "bank.cases";
}
impl MessageHeaders for Case {
type Contract = NoHeaders;
}
impl MessageInfo for Case {
const NAME: &'static str = "Case";
}
struct Cases;
impl OutSlot for Cases {
const NAME: &'static str = "Cases";
type Destination = Reads;
}
impl PublishedThrough<Cases> for Case {}
struct OpenCase;
impl<Opened> Handle<Dispute, (), Outs<(Opened,)>> for OpenCase
where
Opened: OutEntry<Cases, Wire: Publisher<Options = KafkaOptions>>,
{
async fn handle(
&self,
dispute: &Dispute,
outs: &Outs<(Opened,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let cases = outs.get(Cases);
let case = Case {
transfer_id: dispute.transfer_id,
};
let opened = if dispute.urgent {
cases.message(&case).partition(URGENT).publish().await
} else {
cases.message(&case).publish().await
};
opened.map_err(|_| HandlerOutcome::retry())
}
}
partition(n) 把一条记录固定到某个分区。
它是 KafkaPublishSteps 的一个步骤,所以槽位要以 Publisher<Options = KafkaOptions> 为约束才能提供它。
固定的分区优先于记录键,记录键优先于 librdkafka 的分区器。
主题中不存在的分区会让发布失败。
应用
- 宏
- 手写
#[ruststream::app]
fn app() -> impl App<State = ()> {
service(KafkaBroker::new(["localhost:9092"]).default_group("ledger"))
}
fn service(broker: KafkaBroker) -> impl App<State = ()> {
RustStream::new(AppInfo::new("ledger", "0.1.0")).with_broker(broker, |b| {
b.include(post).out(Entries, Publish::default()).build();
b.include(screen);
// A set of topics has no single topic to send a retry copy to, so the mount site names it.
b.include(archive)
.out_retry(Publish::default())
.transform(ToSourceTopic);
// Partitions 0 and 1 share the regular cases evenly; partition 2 stays for urgent ones.
b.include(open_case)
.out(Cases, Publish::default())
.transform(RoundRobin::partitions(2))
.build();
})
}
fn app() -> impl App<State = ()> {
service(KafkaBroker::new(["localhost:9092"]).default_group("ledger"))
}
fn service(broker: KafkaBroker) -> impl App<State = ()> {
RustStream::new(AppInfo::new("ledger", "0.1.0")).with_broker(broker, |b| {
b.include(
subscriber("transfers", Post)
.workers_by_key(nonzero!(8usize))
.map_source(|_| KafkaTopic::new("transfers").lane_key(LaneKey::RecordKey))
.build(),
)
.out(Entries, Publish::default())
.build();
b.include(
subscriber("transfers", Screen)
.map_source(|_| {
KafkaTopic::new("transfers")
.group("fraud-check")
.start(StartOffset::Earliest)
.assignment(Assignment::CooperativeSticky)
})
.build(),
);
// A set of topics has no single topic to send a retry copy to, so the mount site names it.
b.include(
subscriber("^bank\\..*", Archive)
.map_source(|_| KafkaTopics::pattern("^bank\\..*").group("archive"))
.reply()
.build(),
)
.out_retry(Publish::default())
.transform(ToSourceTopic);
// Partitions 0 and 1 share the regular cases evenly; partition 2 stays for urgent ones.
b.include(
subscriber("card-disputes", OpenCase)
.map_source(|_| KafkaTopic::new("card-disputes").group("case-intake"))
.build(),
)
.out(Cases, Publish::default())
.transform(RoundRobin::partitions(2))
.build();
})
}
fn main() -> ExitCode {
run_main(app)
}
RoundRobin::partitions(2) 把每个没有指定分区的案件依次放到分区 0 和 1 中的下一个。
librdkafka 把没有键的记录放到随机分区上,这会让一些消费者比另一些更忙;轮转则把记录均匀地分开。
带键或固定了分区的记录保持原来的位置。
数量是固定的:它等于轮转应使用的分区数,绝不超过主题拥有的分区数。
读取一组主题的订阅没有唯一的主题可以接收重试副本,所以应用用 ToSourceTopic 指定一个;重试与死信主题讲解了这一点。
示例文件中的测试在进程内运行应用。 进程内 Kafka 给每个主题一个分区,所以检查多分区放置的测试要连接集群运行:
KAFKA_TEST_URL=localhost:9092 cargo test