本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
重放与 seek
本指南从头或从某个时刻重新读取主题,并在处理器内部移动正在运行的订阅。
消费者读完日志之后,Kafka 仍然保留它。 服务可以再读一遍:重建读模型,为新的消费者回填数据,或者跳过它无法使用的记录。 订阅在挂载点上取得起始位置,处理器则通过投递的上下文移动自己的订阅。
读模型
服务在内存中维护两个读模型:每个账户的余额和每张卡的消费额。 每条记账都带有账簿的修订号。 对过去记账的更正作为新的修订发布,读模型遇到新修订时,会从日志开头重建自己。
- 宏
- 手写
/// One posting of the ledger. `revision` is the ledger's revision: a correction of past
/// postings is published as a new revision, and a read model meeting one rebuilds itself.
#[derive(Debug, PartialEq, Deserialize)]
struct Entry {
id: u64,
account: String,
amount_cents: i64,
revision: u32,
}
/// A read model kept in memory: the balance of every account.
#[derive(Debug, Default)]
struct Projection {
revision: u32,
balances: HashMap<String, i64>,
}
impl Projection {
/// Applies `entry`, or clears the model and answers `false` when the entry belongs to a newer
/// revision, which the model has to be rebuilt for from the start of the log.
fn apply(&mut self, entry: &Entry) -> bool {
if entry.revision > self.revision {
self.revision = entry.revision;
self.balances.clear();
return false;
}
*self.balances.entry(entry.account.clone()).or_default() += entry.amount_cents;
true
}
}
/// The service's state: one read model per subscription that builds one.
#[derive(Clone, Default)]
struct Projections {
balances: Arc<Mutex<Projection>>,
spend: Arc<Mutex<Projection>>,
}
/// One posting of the ledger. `revision` is the ledger's revision: a correction of past
/// postings is published as a new revision, and a read model meeting one rebuilds itself.
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Entry {
id: u64,
account: String,
amount_cents: i64,
revision: u32,
}
/// A read model kept in memory: the balance of every account.
#[derive(Debug, Default)]
struct Projection {
revision: u32,
balances: HashMap<String, i64>,
}
impl Projection {
/// Applies `entry`, or clears the model and answers `false` when the entry belongs to a newer
/// revision, which the model has to be rebuilt for from the start of the log.
fn apply(&mut self, entry: &Entry) -> bool {
if entry.revision > self.revision {
self.revision = entry.revision;
self.balances.clear();
return false;
}
*self.balances.entry(entry.account.clone()).or_default() += entry.amount_cents;
true
}
}
/// The service's state: one read model per subscription that builds one.
#[derive(Clone, Default)]
struct Projections {
balances: Arc<Mutex<Projection>>,
spend: Arc<Mutex<Projection>>,
}
从头开始
余额保存在内存中,所以重启之后要从整个日志重新构建。
- 宏
- 手写
#[subscriber(
KafkaTopic::new("ledger").group("balances").commit(Commit::Tracked),
start_at(KafkaPosition::earliest())
)]
async fn project_balance(
entry: &Entry,
ctx: &mut Context<'_, KafkaContext, Projections>,
Ctx(seeker): Ctx<SeekHandle>,
) -> HandlerOutcome {
let applied = ctx
.state()
.balances
.lock()
.unwrap_or_else(PoisonError::into_inner)
.apply(entry);
if applied {
tracing::info!(entry.id, "balance updated");
return HandlerOutcome::ack();
}
tracing::info!(
entry.id,
entry.revision,
"new revision, rebuilding the balances"
);
if seeker.seek(KafkaPosition::earliest()).await.is_err() {
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// Declaring `KafkaContext` is what puts the seek handle in the context.
struct ProjectBalance;
impl Handle<Entry, (), (), KafkaContext, Projections> for ProjectBalance {
async fn handle(
&self,
entry: &Entry,
_outs: &(),
ctx: &mut Context<'_, KafkaContext, Projections>,
) -> Result<(), HandlerOutcome> {
let applied = ctx
.state()
.balances
.lock()
.unwrap_or_else(PoisonError::into_inner)
.apply(entry);
if applied {
tracing::info!(entry.id, "balance updated");
return Ok(());
}
tracing::info!(
entry.id,
entry.revision,
"new revision, rebuilding the balances"
);
if ctx
.context(SeekHandle)
.seek(KafkaPosition::earliest())
.await
.is_err()
{
return Err(HandlerOutcome::retry());
}
Ok(())
}
}
start_at(KafkaPosition::earliest()) 在每次启动时都从日志开头打开订阅,不管组 balances 之前提交过什么。
描述符上的 StartOffset 与此不同:它只在组没有已提交的偏移量时生效。
这个位置落在组分配给该消费者的分区上,位于它们的第一条记录之前。
KafkaPosition 有五种位置:
| 位置 | 订阅从哪里继续 |
|---|---|
earliest() | Kafka 保留的最旧记录 |
latest() | 日志末尾,因此只读取新记录 |
offset(partition, n) | 某个分区的偏移量 n |
topic_offset(topic, partition, n) | 某个主题某个分区的偏移量 n,用于读取多个主题的订阅 |
timestamp(ms) | 每个分区中时间戳不早于 ms 的第一条记录 |
起始位置如果没有指定订阅的任何一个分区,启动就会失败。
在 Assignment::CooperativeSticky 下,第一次分配可能只是组份额的一部分,之后增量到达的分区不会被移动。
在处理器中 seek
同一个处理器在收到更新修订的记账时重建余额。
Ctx<SeekHandle> 把处理器所在订阅的 KafkaSeeker 交给它。
seek 把订阅移到日志开头,处理器再读一遍每条记账。
触发重建的那条记账也会回来,这一次投影会应用它。
seek 可以使用上面任何一种位置。
Ctx<Position> 保存投递自身的位置:seek 到那里,会重新读取这条记录及其后的一切。
Ctx<Partition> 和 Ctx<Offset> 为 offset(..) 位置提供坐标。
在批处理器中 seek
卡的记账数量很大,所以消费额以最多 500 条的批构建。
- 宏
- 手写
#[subscriber(KafkaTopic::new("card-entries")
.group("card-spend")
.commit(Commit::Tracked))]
async fn project_spend(
entries: &[Entry],
ctx: &mut Context<'_, KafkaBatchContext, Projections>,
) -> HandlerOutcome {
let applied = {
let mut spend = ctx
.state()
.spend
.lock()
.unwrap_or_else(PoisonError::into_inner);
entries.iter().all(|entry| spend.apply(entry))
};
if applied {
tracing::info!(entries = entries.len(), "card spend updated");
return HandlerOutcome::ack();
}
tracing::info!("new revision, rebuilding the card spend");
if ctx
.context(SeekHandle)
.seek(KafkaPosition::earliest())
.await
.is_err()
{
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// A batch body declares `KafkaBatchContext`, which carries the same seek handle.
struct ProjectSpend;
impl Handle<[Entry], (), (), KafkaBatchContext, Projections> for ProjectSpend {
async fn handle(
&self,
entries: &[Entry],
_outs: &(),
ctx: &mut Context<'_, KafkaBatchContext, Projections>,
) -> Result<(), Vec<HandlerOutcome>> {
let applied = {
let mut spend = ctx
.state()
.spend
.lock()
.unwrap_or_else(PoisonError::into_inner);
entries.iter().all(|entry| spend.apply(entry))
};
if applied {
tracing::info!(entries = entries.len(), "card spend updated");
return Ok(());
}
tracing::info!("new revision, rebuilding the card spend");
if ctx
.context(SeekHandle)
.seek(KafkaPosition::earliest())
.await
.is_err()
{
return Err(entries.iter().map(|_| HandlerOutcome::retry()).collect());
}
Ok(())
}
}
批处理器声明 KafkaBatchContext,而不是单次投递的上下文。
它带有同样的 SeekHandle 键,但没有位置,因为一批跨越许多记录。
整批结算之后,订阅才会移动。
从某个时刻开始
付款审查在每次启动时读取最近七天的付款。
- 宏
- 手写
/// A payout as the payments service publishes it.
#[derive(Debug, PartialEq, Deserialize)]
struct Payout {
id: u64,
amount_cents: i64,
}
#[subscriber(KafkaTopic::new("payouts")
.group("payout-review")
.commit(Commit::Tracked))]
async fn review_payout(payout: &Payout) -> HandlerOutcome {
tracing::info!(payout.id, payout.amount_cents, "payout reviewed");
HandlerOutcome::ack()
}
/// A payout as the payments service publishes it.
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Payout {
id: u64,
amount_cents: i64,
}
/// The body reads no context, but runs in an app whose state is `Projections`.
struct ReviewPayout;
impl Handle<Payout, (), (), (), Projections> for ReviewPayout {
fn handle(
&self,
payout: &Payout,
_outs: &(),
_ctx: &mut Context<'_, (), Projections>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(payout.id, payout.amount_cents, "payout reviewed");
ready(Ok(()))
}
}
这个时刻要到服务启动时才知道,所以由挂载点而不是属性指定位置。
- 宏
- 手写
/// Milliseconds since the epoch, the unit Kafka stamps records with.
fn epoch_millis(at: SystemTime) -> i64 {
at.duration_since(UNIX_EPOCH).map_or(0, |since| {
i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
})
}
fn service(broker: KafkaBroker, review_from: SystemTime) -> impl App<State = Projections> {
RustStream::new(AppInfo::new("ledger", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Projections::default()))
.with_broker(broker, |b| {
b.include(project_balance);
b.include(project_spend.batch(nonzero!(500usize)));
b.include(review_payout.start_at(KafkaPosition::timestamp(epoch_millis(review_from))));
})
}
/// The review reads the payouts of the last seven days on every start.
const REVIEW_WINDOW: Duration = Duration::from_hours(7 * 24);
#[ruststream::app]
fn app() -> impl App<State = Projections> {
let review_from = SystemTime::now() - REVIEW_WINDOW;
service(KafkaBroker::new(["localhost:9092"]), review_from)
}
/// Milliseconds since the epoch, the unit Kafka stamps records with.
fn epoch_millis(at: SystemTime) -> i64 {
at.duration_since(UNIX_EPOCH).map_or(0, |since| {
i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
})
}
fn service(broker: KafkaBroker, review_from: SystemTime) -> impl App<State = Projections> {
RustStream::new(AppInfo::new("ledger", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Projections::default()))
.with_broker(broker, |b| {
b.include(
subscriber("ledger", ProjectBalance)
.map_source(|_| {
KafkaTopic::new("ledger")
.group("balances")
.commit(Commit::Tracked)
})
.start_at(KafkaPosition::earliest())
.build(),
);
b.include(
subscriber("card-entries", ProjectSpend)
.map_source(|_| {
KafkaTopic::new("card-entries")
.group("card-spend")
.commit(Commit::Tracked)
})
.batch(nonzero!(500usize))
.build(),
);
b.include(
subscriber("payouts", ReviewPayout)
.map_source(|_| {
KafkaTopic::new("payouts")
.group("payout-review")
.commit(Commit::Tracked)
})
.start_at(KafkaPosition::timestamp(epoch_millis(review_from)))
.build(),
);
})
}
/// The review reads the payouts of the last seven days on every start.
const REVIEW_WINDOW: Duration = Duration::from_hours(7 * 24);
fn app() -> impl App<State = Projections> {
let review_from = SystemTime::now() - REVIEW_WINDOW;
service(KafkaBroker::new(["localhost:9092"]), review_from)
}
fn main() -> ExitCode {
run_main(app)
}
KafkaPosition::timestamp 接收自纪元起的毫秒数,这正是 Kafka 给记录打时间戳所用的单位。
Kafka 在每个分区中把这个时刻解析为时间戳不早于它的第一条记录。
没有这种记录的分区从末尾继续。
seek 对组的影响
seek 移动的是这个消费者,而不是整个组。
它移动该成员持有的分区,不替任何人提交任何东西。
组的其他成员继续在原处读取。
下一次再均衡会抛弃这次 seek:届时分到某个分区的成员,从组已提交的偏移量继续。
start_at 位置在每次启动时都会重新生效。
Commit::Tracked 下的 seek
三个订阅都以 Commit::Tracked 提交(提交与投递保证)。
seek 会清除它移动的每个分区的跟踪位置。
在 seek 之前拉取的投递,结算后不产生任何效果,所以触发重建的那条记账的 ack 不提交任何东西。
因此,之后的提交绝不会越过一条被 seek 重放、但还没有处理器处理过的记录。