TigerBeetle 客户端会话(Client Sessions)全解析:单飞行请求模型、驱逐机制与一致性保证
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
客户端会话(Client Session)是 TigerBeetle 中客户端与集群之间进行请求-回复交互的基本单位,也是其强一致模型的核心载体。本文以 docs/reference/sessions.md 为骨架,深入讲解会话的生命周期、
clients_max驱逐机制、无超时重试策略以及会话层面的一致性保证,并结合 src/vsr/client_sessions.zig、src/vsr/client.zig、src/config.zig 等源码揭示其底层实现。读完本文,你将理解为什么 TigerBeetle 客户端不超时、不报网络错误,以及如何正确地管理客户端数量、在应用崩溃恢复时安全地重放事务。
什么是客户端会话
在 TigerBeetle 中,客户端会话(Client Session)是指一个客户端与一个集群之间发送的、一串有序的请求(Request)与回复(Reply)序列。会话是客户端与集群之间全部交互的逻辑容器,客户端的所有create_accounts、create_transfers、lookup_*、query_*等操作都发生在其会话的上下文之中。
会话模型最核心、也最独特的一条约束是:
一个客户端会话在同一时刻最多只有一个在途请求(in-flight request)——即网络上最多只有一个已发出、尚未收到回复的请求。
这条约束带来了两个关键收益:
- 简化一致性:由于同一会话内的请求被严格串行化,客户端无需处理乱序回复、重复语义或请求交织,会话内天然具备顺序一致性;
- 静态容量保证:集群可以为每个会话精确地预留入站消息队列容量(在 src/constants.zig 中,
client_replies_size = clients_max * message_size_max,即回复区大小由客户端数量上限与单消息大小直接推导),从而在编译期/部署期就确定资源边界,无需依赖运行时的动态内存分配。
当应用提交的请求超出了这一个在途请求的窗口时,额外的请求并不会被拒绝:它们会在客户端侧排队,直到其前一个请求收到回复,才会被从队列中取出并发出。
应用线程 ──► [客户端内部队列] ──► 发送 request ──► 集群 ▲ └── 收到 reply 后,出队下一个 request与批处理(Batching)的关系
单在途请求约束是 TigerBeetle 强烈建议**批处理(Batching)**的根本原因。由于客户端同一时刻只能发送一个请求,为了最大化吞吐,应该让每个请求携带尽可能多的事件。TigerBeetle 客户端会自动完成这件事:由于客户端在等待上一次请求回复期间会不断积累小批量事件,应用层只需共享同一个客户端实例(跨线程/协程),客户端就会自动将多个小批次合并进同一个请求发送出去。默认配置下,单个请求最多可携带 8189 个事件(如create_transfers、create_accounts、lookup_*)。相关细节见 docs/coding/requests.md#batching-events。
会话生命周期(Lifecycle)
会话的开始:注册(Register)
一个客户端会话始于客户端向集群注册自身:
- 每个会话拥有一个唯一的客户端 ID(client id)——一个临时生成的随机 128 位整数(对应集群侧以
u128作为哈希键的会话表,见 src/vsr/client_sessions.zig 中的entries_by_client: AutoHashMapUnmanaged(u128, usize)); - 客户端发送一条特殊的
register消息,该消息会被集群提交;一旦客户端收到对应回复,它就完成了"注册",可以开始发送普通请求; - 注册完全由客户端库自动完成——客户端在初始化时、发出第一个业务请求之前,会自行完成注册流程,应用开发者无需(也无法)手动干预。
从源码看,这一流程封装在 src/vsr/client.zig 中:客户端在初始化后会调用register()(src/vsr/client.zig#L273)发送operation = .register的请求;当收到注册回复时,客户端将回复头中的commit号作为自己的会话号:
self.session = reply.header.commit; // The commit number becomes the session number.即会话号(session number)取值为注册请求被提交的 commit 号(见src/vsr/client.zig#L623)。同时,注册回复中还携带batch_size_limit,告知客户端该集群允许的最大批量大小,客户端据此决定如何聚合事件。
值得一提的是,src/vsr/client_sessions.zig 头部注释揭示了这套"显式注册"设计的历史背景:VRR(Viewstamped Replication Revisited)论文中的客户端表存在两个缺陷——连续崩溃可能使不同请求载荷的请求号发生碰撞(正确性缺陷),以及在视图变更中请求重排可能把客户端锁死在集群之外(活性缺陷)。TigerBeetle 因此改用通过状态机显式注册会话、保证会话号单调递增,并严格区分"已提交"与"未提交"的请求号。
会话的结束
一个会话在以下两种情况中先发生的那一个结束:
- 会话被集群驱逐(evicted);或
- 客户端终止(terminated)。
需要注意的是:客户端重启不会恢复旧会话。例如承载 TigerBeetle 客户端的应用服务被重启后,旧会话即告终结,客户端会以一个新的(随机)客户端 ID 开启一个全新的会话。
驱逐机制(Eviction)
硬上限:clients_max
与其他数据库类似,TigerBeetle 对并发客户端会话数设有一个硬性上限config.clients_max,默认值为64。该配置属于集群级(Cluster)配置,定义于 src/config.zig:
clients_max: u32(src/config.zig#L154)——生产默认配置default_production中为64(src/config.zig#L228);- 最小值为
clients_max_min = 1(src/config.zig#L181); - 最小测试配置
test_min使用clients_max = 4 + 3(src/config.zig#L256),用于在测试/模拟环境中更频繁地触发驱逐路径; - 集群中的所有副本必须使用相同的
clients_max,且在集群生命周期内不得更改——因为它直接决定了回复区大小、入站队列容量等静态布局,不同配置生成的存储格式互不兼容。
在 src/constants.zig 中,pub const clients_max = config.cluster.clients_max;(src/constants.zig#L81)将其提升为全局常量,并被client_replies_size、pipeline_request_queue_max、connection_send_queue_max_replica等下游容量计算引用,体现了"无动态内存"的静态容量设计哲学。
何时驱逐、驱逐谁
当一个新会话正在注册,而集群中的活跃会话数已达到clients_max上限时,集群必须驱逐一个既有会话来为新会话腾出空间。规则如下:
- 被驱逐的会话是"提交过请求但距今最久远"的那个——即其最近一次提交请求的时间戳/commit 号最小;
- 会话被驱逐后,该会话未来的任何请求都永远不会被执行;
- 集群会发送一条消息(
eviction命令,见 src/vsr/client.zig 中的on_eviction,src/vsr/client.zig#L424)通知被驱逐的会话其已终结。被驱逐的客户端通常已不再活跃(早已终止);若它仍活跃,这条驱逐消息会令其自我终止,并向上冒泡为应用层的session evicted错误。
驱逐的选择在 src/vsr/client_sessions.zig 的evictee()函数(src/vsr/client_sessions.zig#L275)中实现。源码注释强调了其正确性关键:
"所有副本必须**确定性(deterministically)**地选择同一个驱逐对象:不能依赖
HashMap.capacity()(该值可能随 Zig 标准库版本变化),只能依赖constants.clients_max;也不依赖哈希表迭代顺序,但要求所有条目的 commit 号互不相同且全部被遍历,从而保证总是选中 commit 号最小的条目。"
实现通过遍历全部会话条目、逐一比较header.commit(要求entry.header.commit >= entry.session),最终返回 commit 最小的客户端 ID。这也解释了为什么会话号取注册时的 commit 号:commit 号同时充当了"最近活跃时间"的度量,使驱逐选择完全确定且无需额外的时钟信息。
遇到session evicted错误怎么办
如果活跃客户端持续以session evicted错误终止,最可能的原因就是应用运行的并发客户端过多,超过了clients_max(默认 64)。此时应从应用架构上做减法:
- 尽量减少并发客户端数量,让少量客户端承载全部流量;
- 在每个请求中尽可能多地批量打包事件(参见 docs/coding/requests.md#batching-events)。
高吞吐的关键不是"更多的客户端",而是"更饱满的请求"——因为会话本身受单在途请求约束,客户端数量超出 64 后只会带来反复的注册与驱逐开销,而不会提升吞吐。在 src/testing/cluster.zig 中也可以看到测试集群特意支持"客户端数超过clients_max"的配置,目的正是覆盖会话驱逐这一场景的验证。
重试机制(Retries)
TigerBeetle 客户端的重试策略与绝大多数数据库/RPC 客户端截然不同:
- 客户端永远不会超时(never time out);
- 客户端没有任何重试次数上限(no retry limits);
- 客户端不向应用暴露网络错误(does not surface network errors)。
客户端会自动重试一个请求,直到满足以下两个条件之一:
- 客户端收到了集群的对应回复;或
- 客户端被终止。
为什么不能暴露超时与网络错误
这套激进重试策略源自 TigerBeetle 的严格一致性模型。在客户端/应用层暴露"超时"或"网络错误"是误导性的:这类错误隐含了"请求未执行"的语义,但实际情况是未知的——
- 一个被网络延迟的请求,可能在超时之后仍然执行;
- 一个被网络延迟的回复,可能让客户端误判请求未执行,而实际上请求早已执行完毕。
在强一致模型下,向应用返回一个"失败"而实际可能已成功的信号,远比"继续重试直到确认"更危险。因此客户端选择无限重试,直到从集群拿到确定的答复。
从实现上看,src/vsr/client.zig 的on_request_timeout(src/vsr/client.zig#L657)处理请求超时重发:它会以指数退避(backoff)的方式降低重发频率以减轻集群负载,然后重新发送同一请求(send_request_with_hedging)。请求消息头携带的request号与checksum保证了重发的是完全相同的请求,从而配合事件级幂等性(id幂等键)实现"至多执行一次"的语义(参见 docs/coding/requests.md 中的 Guarantees:一个请求在集群内至多执行一次;由原会话重试的请求会收到完全相同的回复)。
请求丢失 ──► 指数退避 ──► 重发同一请求 ──► 收到回复(确定性结果) 回复丢失 ──► 指数退避 ──► 重发同一请求 ──► 幂等去重,返回相同回复会话一致性保证(Guarantees)
在会话层面,TigerBeetle 对单个客户端会话提供如下保证:
| 保证 | 说明 |
|---|---|
| 单在途请求 | 一个会话最多有一个未收到回复的在途请求 |
| 读己之写(read-your-writes) | 在某个写操作之后发起的读操作,一定能观察到该写操作的效果 |
| 观察顺序 | 会话观察到集群中写入的发生顺序(即 commit 顺序) |
| 余额单调 | 会话观察到的debits_posted与credits_posted单调递增,绝不会回退(见 docs/reference/account.md) |
| 无未提交可见 | 会话永远不会观察到未提交(uncommitted)的更新 |
| 无破坏的不变量 | 会话永远不会观察到被破坏的不变量,例如flags.credits_must_not_exceed_debits(docs/reference/account.md)或flags.linked(docs/reference/transfer.md) |
| 回复即已执行 | 只要会话收到某个请求的回复,就可以认定该请求已被执行 |
| 会话间乱序 | 多个会话之间的回复可能相对乱序——两个客户端同时提交请求时,请求先被提交的那个客户端,其回复可能后到 |
| 重启:有回复则可见 | 若会话在终止前已收到某更新的回复,重启后的新会话保证能观察到该更新的效果 |
| 重启:无回复则不确定 | 若在重启前未收到对应回复,则不保证能观察到该更新的效果——它可能在未来的任意时刻发生,也可能永不发生 |
其中"重启"相关的前两条保证直接决定了应用崩溃恢复的编码方式:
- 如果应用在收到回复后崩溃,恢复后(以新会话)观察到的状态一定包含这笔更新——可以安全地推进业务状态;
- 如果应用在发出请求但未收到回复时崩溃,这笔更新的最终状态是未知的(可能已提交、可能尚未提交、可能永不到来)。
因此,安全的崩溃恢复必须依赖事件id的幂等重试:应用(而非 API 服务层)在提交前生成id并持久化到本地存储,恢复后以相同的id重放事件;若事件此前已创建,TigerBeetle 会返回exists,从而保证"只记录一次"(详见 docs/coding/reliable-transaction-submission.md)。
实践要点总结
- 控制客户端数量:并发客户端数受
clients_max(默认 64,见 src/config.zig)硬限制。把客户端实例共享给应用的所有线程/协程,避免"每请求一个客户端"的反模式。 - 把批处理当第一公民:利用客户端自动批处理能力(最多 8189 事件/请求),用"少而满"的请求换取吞吐。会话的单在途约束是这一切的前提。
- 容忍无限重试:不要为客户端层配置超时或重试上限——TigerBeetle 客户端会指数退避地无限重试,直到拿到确定的回复;收到回复即代表请求已执行。
- 用
id幂等处理崩溃恢复:在客户端软件侧生成并持久化事件id,恢复后用相同id重放,利用exists返回值完成"至多一次"语义。切勿依赖"旧会话未收到回复即未执行"这种不确定假设。 - 正确解读
session evicted:活跃客户端若成批地以session evicted终止,说明并发客户端超限,应减少客户端数量而非增加。
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考