本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
分片、分区键与顺序
本指南用分区键为每条记录选定分片,在处理器并行运行时保持每个键的顺序,并在流重新分片时仍保持它。
示例是一个订单服务。它把每个订单记入客户的账簿,一次处理四个订单,并为每条账簿条目发出一张收据作为确认。
键如何选定分片
每条 Kinesis 记录都有一个分区键。Kinesis 用 MD5 对键做哈希,哈希值落在哪个分片的哈希键范围内,记录就存放在哪个分片上。Kinesis 在一个分片内保持记录的顺序,分片之间没有任何顺序。因此同一个键的记录保持顺序,不同键的记录分散到各个分片上。
一个键只能得到一个分片的吞吐量:一个分片每秒最多接收 1 MB 或 1000 条记录。承载大部分流量的键,会让它所在的分片成为整个流的瓶颈。
每个客户内的顺序
- 宏
- 手写
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "customer-orders")]
struct Order {
id: u64,
customer: String,
}
/// One line of a customer's journal.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "customer-orders.journal")]
struct Entry {
order: u64,
customer: String,
}
#[derive(OutSlot)]
#[publishes(Entry)]
struct Journal;
/// Posts each order to the journal, under the customer as its partition key.
#[subscriber(
KinesisStream::new("customer-orders").create_if_missing(2),
workers(4, by_key)
)]
async fn post(
order: &Order,
Out(journal): Out<impl Publisher<Options = KinesisPublishOptions>, Journal>,
) -> HandlerOutcome {
let entry = Entry {
order: order.id,
customer: order.customer.clone(),
};
if journal
.message(&entry)
.partition_key(order.customer.as_str())
.publish()
.await
.is_err()
{
return HandlerOutcome::retry();
}
tracing::info!(order.id, order.customer, "order posted");
HandlerOutcome::ack()
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
struct Order {
id: u64,
customer: String,
}
impl OutgoingDestination for Order {
type Form = FixedName;
const DESTINATION: &'static str = "customer-orders";
}
impl MessageHeaders for Order {
type Contract = NoHeaders;
}
/// One line of a customer's journal.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
struct Entry {
order: u64,
customer: String,
}
impl OutgoingDestination for Entry {
type Form = FixedName;
const DESTINATION: &'static str = "customer-orders.journal";
}
impl MessageHeaders for Entry {
type Contract = NoHeaders;
}
impl MessageInfo for Entry {
const NAME: &'static str = "Entry";
}
struct Journal;
impl OutSlot for Journal {
const NAME: &'static str = "Journal";
type Destination = Reads;
}
impl PublishedThrough<Journal> for Entry {}
/// Posts each order to the journal, under the customer as its partition key.
struct Post;
impl<Lines> Handle<Order, (), Outs<(Lines,)>> for Post
where
Lines: OutEntry<Journal, Wire: Publisher<Options = KinesisPublishOptions>>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(Lines,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let entry = Entry {
order: order.id,
customer: order.customer.clone(),
};
if outs
.get(Journal)
.message(&entry)
.partition_key(order.customer.as_str())
.publish()
.await
.is_err()
{
return Err(HandlerOutcome::retry());
}
tracing::info!(order.id, order.customer, "order posted");
Ok(())
}
}
商店以客户作为分区键发布每个订单,所以一个客户的订单按下单顺序位于同一个分片上。customer-orders 流有两个分片,订阅同时读取这两个分片。
workers(4, by_key) 运行四个工作分区;键相同的投递在同一个工作分区上依次运行。投递的键就是它的分区键,由 partition-key 消息头(PARTITION_KEY_HEADER)携带。因此 post 同时处理四个订单,却绝不会同时处理同一个客户的两个订单。在手写形式中,同一设置是 workers_by_key。普通的 workers(4) 不考虑键,同时运行四次投递,同一个客户的两个订单就可能以任意顺序完成。
为记录指定键
post 用发布上的步骤 partition_key(..) 写入每条账簿条目。账簿条目落在其客户所在的分片上,所以账簿的读者也按顺序看到每个客户。这个步骤是 Kinesis 的设置,所以槽位要以 Publisher<Options = KinesisPublishOptions> 为约束才能提供它。
- 宏
- 手写
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "receipts")]
struct Receipt {
order: u64,
}
/// Confirms each journal entry with a receipt.
#[subscriber(KinesisStream::new("customer-orders.journal").create_if_missing(1), reply)]
async fn confirm(entry: &Entry) -> Receipt {
Receipt { order: entry.order }
}
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
struct Receipt {
order: u64,
}
impl OutgoingDestination for Receipt {
type Form = FixedName;
const DESTINATION: &'static str = "receipts";
}
impl MessageHeaders for Receipt {
type Contract = NoHeaders;
}
impl MessageInfo for Receipt {
const NAME: &'static str = "Receipt";
}
/// Confirms each journal entry with a receipt.
struct Confirm;
impl Handle<Entry, Receipt> for Confirm {
fn handle(
&self,
entry: &Entry,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<Receipt, HandlerOutcome>> {
ready(Ok(Receipt { order: entry.order }))
}
}
- 宏
- 手写
fn service(broker: KinesisBroker) -> RustStream {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
b.include(post).out(Journal, Publish::default()).build();
// One key for every receipt: one shard, so the receipts keep one order between them.
b.include(confirm)
.out_reply(Publish::default())
.partition_key("receipts");
})
}
#[ruststream::app]
fn app() -> impl App {
service(
KinesisBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
}
fn service(broker: KinesisBroker) -> RustStream {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
b.include(
subscriber(
KinesisStream::new("customer-orders").create_if_missing(2),
Post,
)
.workers_by_key(nonzero!(4usize))
.build(),
)
.out(Journal, Publish::default())
.build();
// One key for every receipt: one shard, so the receipts keep one order between them.
b.include(
subscriber(
KinesisStream::new("customer-orders.journal").create_if_missing(1),
Confirm,
)
.reply()
.build(),
)
.out_reply(Publish::default())
.partition_key("receipts");
})
}
fn main() -> ExitCode {
run_main(|| {
service(
KinesisBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
})
}
回复没有可以加步骤的调用,所以由挂载点在策略上指定它的键:.out_reply(Publish::default()).partition_key("receipts")。于是每张收据都落在同一个分片上,读者按同一个顺序看到收据,代价是只能使用这一个分片的吞吐量。
一次发布按以下顺序确定它的键:
- 这次发布的
partition_key(..)步骤。 - 这次发布带有的
partition-key消息头:手工写入,或者随来自 RustStream 另一个 Broker 的记录一起到达。 - 挂载点在策略上指定的键。
- 为每条记录生成的新键,它把记录分散到各个分片上。
最后一级不保持任何顺序:先后发布的两条记录可能落在不同的分片上。示例的测试用消息头设置键,这是 RustStream 每个 Broker 都能读取的可移植写法。
拆分与合并
拆分关闭一个分片,并打开两个分享其哈希键范围的子分片。合并关闭两个相邻的分片,并打开一个子分片。已关闭的父分片保留它们的记录,直到保留期结束。订阅每十秒重新列出一次分片。只有当子分片的每个父分片都已读到末尾,订阅才开始读取该子分片,所以一个键的记录在这一变化前后保持顺序。没有检查点的子分片从它最旧的记录打开,所以读取者启动之前写入其中的记录不会遗漏。分片与重新分片用图展示了父分片和子分片,本 crate 连接真实服务的测试在 tests/shards_kinesis.rs 中拆分并合并一个流。
延迟重试把记录的副本发布到流的最新处,所以被重试的记录会失去它在同一个键的记录中的位置。
测试
示例文件中的测试在进程内运行应用。进程内 Kinesis 给每个流一个分片,所以通过两个分片读取订单的测试要连接 LocalStack 运行:
cargo test
KINESIS_TEST_ENDPOINT=http://localhost:4566 cargo test -- --test-threads=1