TigerBeetle 限流实践:用漏桶算法与两阶段转账实现请求速率、带宽与转账金额限流
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
TigerBeetle 的核心数据模型是账户(Account)、转账(Transfer)与账本(Ledger),但这套复式记账模型不仅能记录资金,也能用来"计量"任何可量化的资源。本文基于 限流配方文档,完整讲解如何用漏桶(leaky bucket)算法的思想,借助带超时(timeout)的挂起转账(pending transfer)与账户余额约束,在 TigerBeetle 上实现请求速率限流、带宽限流和转账金额限流,并深入到 src/state_machine.zig 的校验与过期清理源码,说明其背后的确定性与悲观校验原理。读完本文,你将掌握一套不需要外部限流中间件、天然具备审计与幂等保证的限流建模方案。
为什么可以用 TigerBeetle 做限流
限流的本质是"配额"的分配与回收:在每个时间窗口内,用户能消费多少资源,消费后配额减少,窗口结束后配额恢复。这恰好对应复式记账中的两个动作:
- 配额发放 → 贷记(credit)用户账户
- 配额消耗 → 借记(debit)用户账户
TigerBeetle 可以"为非金融资源记账"(见 recipes 目录),本配方即用这一思想实现基于用户请求速率、带宽和金额的限流。它的核心优势在于:
- 确定性:所有判定都由 TigerBeetle 集群在提交时完成,不存在分布式系统中常见的竞态窗口;
- 可审计:每次配额消耗都是一条不可变的转账记录,天然形成审计轨迹;
- 幂等:转账 ID 由客户端指定,配合幂等提交语义可以安全重试(见 可靠事务提交)。
核心机制:配额账户 + 自动过期的挂起转账
整个方案的机制非常简单,分为四步:
- 按资源建账本:每一种要限流的资源(请求速率、带宽、金额)各建一个专属账本(ledger)。账本用来隔离不同类型的资源,避免"请求次数"与"字节数"混淆(账本定义见 Account.ledger)。
- 建账户对:每个账本上有一个运营方(Operator)账户和每个用户一个账户。
- 加余额约束:给每个用户账户设置
debits_must_not_exceed_credits标志,使其借记总额不能超过贷记总额,即"已消费 + 已挂起 ≤ 已发放配额"。 - 发放配额 + 挂起消耗:先把资源限额一次性贷记给用户;随后每个请求创建一个指向运营方的 挂起转账(pending transfer) 并带上 超时(timeout)。永远不 post 也不 void 这些转账,让它们超时自动过期。
为什么选择"让挂起转账过期"
挂起转账会将其金额计入账户的debits_pending/credits_pending(预留金额),而不改动debits_posted/credits_posted(已入账金额),详见 两阶段转账。其效果是:
- 发起请求 → 创建挂起转账 → 用户的可用余额被"扣减"(计入 pending);
- 超时到期 → TigerBeetle 自动把挂起金额归还到原账户,配额恢复;
- 配额用尽 → 新的挂起转账因
debits_must_not_exceed_credits约束被拒绝,create_transfers返回exceeds_credits。
整个过程不需要应用侧做任何回收工作——回收由集群在提交时间戳推进到到期时刻时自动完成。
关键:挂起转账在校验时就是悲观的
与"先通过、post 时再失败"的乐观方案不同,TigerBeetle 对带余额约束账户的挂起转账做悲观校验:debits_must_not_exceed_credits的判定在挂起转账创建时就执行(见 Account 标志说明 中account.debits_pending + account.debits_posted + transfer.amount > account.credits_posted的判定式)。因此配额超限的请求在提交时立刻失败,绝不会出现"先扣了、到 post 时才发现超限"的情况。
请求速率限流:每用户每分钟 10 次
假设我们要把每个用户限制为每分钟 10 次请求。
账户设置:为 "Request Rate" 资源建立一个账本,其中运营方账户不设约束,用户账户设置debits_must_not_exceed_credits:
| Ledger | Account | Flags |
|---|---|---|
| Request Rate | Operator | 0 |
| Request Rate | User | debits_must_not_exceed_credits |
初始化:先从运营方向用户转账 10 个配额单位:
| Transfer | Ledger | Debit Account | Credit Account | Amount |
|---|---|---|---|---|
| 1 | Request Rate | Operator | User | 10 |
每个请求:创建一个金额为 1、超时为 60 秒的挂起转账,方向是用户 → 运营方:
| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags |
|---|---|---|---|---|---|---|
| 2...N | Request Rate | User | Operator | 1 | 60 | pending |
注意这里超时取 60(秒),正是因为我们想要的窗口是"每分钟"。超时是以秒为单位的相对间隔而非绝对时间戳,这一设计对集群与应用之间的时钟偏差更稳健(见 TigerBeetle 的时间模型 与 Transfer.timeout)。
就这么简单:每个挂起转账"预占"用户的一部分配额,超时后自动归还。用户在窗口内发起第 11 个请求时,因为 10 个配额单位全部被挂起,新转账会被拒绝;60 秒后挂起金额逐批归还,配额恢复,限流自动"漏"掉。
带宽限流:每用户每分钟 10 MB
基于带宽限流与基于请求速率限流的差别只在于金额的含义:不再用每次请求记 1 单位,而是用请求的实际大小作为转账金额。
假设要把每个用户限制为每分钟 10 MB(10,000,000 字节):
账户设置与请求速率场景一致,只是换一个账本:
| Ledger | Account | Flags |
|---|---|---|
| Bandwidth | Operator | 0 |
| Bandwidth | User | debits_must_not_exceed_credits |
初始化:从运营方向用户转账 10,000,000 个单位(这里单位就是字节):
| Transfer | Ledger | Debit Account | Credit Account | Amount |
|---|---|---|---|---|
| 1 | Bandwidth | Operator | User | 10000000 |
每个请求:创建挂起转账,金额等于本次请求的大小,超时仍为 60 秒:
| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags |
|---|---|---|---|---|---|---|
| 2...N | Bandwidth | User | Operator | Request Size | 60 | pending |
这里依然使用 60 秒超时,但你可以根据业务需要把它调整成任意时间窗口(例如按秒限流就调小,按小时限流就调大)。原理完全一致:一个 3 MB 的请求会挂起 3,000,000 个配额单位;当窗口内累计请求大小达到 10,000,000 字节时,后续请求会因为exceeds_credits被拒绝。
转账金额限流:每账户每天最多 1000 USD
前两种方案限的是"资源消耗",而有时我们需要限的是用户实际转账的金额——例如限制每个账户每天最多转出 1000 USD。这需要两个账本(限流账本 + 资金账本)和 链接事件(linked transfers) 配合,使"扣减配额"与"真实转账"要么同时成功、要么同时失败。
账户设置:
| Ledger | Account | Flags |
|---|---|---|
| Rate Limiting | Operator | 0 |
| Rate Limiting | User | debits_must_not_exceed_credits |
| USD | Operator | 0 |
| USD | User | debits_must_not_exceed_credits |
初始化:在 Rate Limiting 账本上从运营方向用户转账 1000:
| Transfer | Ledger | Debit Account | Credit Account | Amount |
|---|---|---|---|---|
| 1 | Rate Limiting | Operator | User | 1000 |
每次用户发起转账:同时创建 2 个转账,并通过linked标志链在一起(|表示同时设置多个标志):
| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags(\|表示同时设置多个标志) |
|---|---|---|---|---|---|---|
| 2N | Rate Limiting | User | Operator | Transfer Amount | 86400 | pending|linked |
| 2N + 1 | USD | User | Destination | Transfer Amount | 0 | 0 |
这里超时取 86400 秒,因为这是一天的秒数。
链路语义:根据 链接事件,flags.linked会把当前事件的结果与下一个事件绑定,整条链要么全部成功、要么全部失败,且链内事件按顺序执行、出错即回滚。因此:
- 第 2N 条挂起转账在 Rate Limiting 账本上"消耗"配额(限额判定的关键一步);
- 第 2N + 1 条是 USD 账本上的真实转账;
- 如果用户当天已经转出了太多金额,第 2N 条会因
exceeds_credits失败,链接的第 2N + 1 条真实转账也随之失败(返回linked_event_failed)。
需要注意:链中最后一个事件不能带linked标志,否则会返回linked_event_chain_open错误。另外,链中若存在多组链接,各组可以独立成败。
源码视角:限流判定与自动过期如何落地
校验发生在挂起转账创建时
账户余额约束的实际判定位于 src/state_machine.zig 的create_transfer执行路径:
if (dr_account.debits_exceed_credits(amount_actual)) return .exceeds_credits; if (cr_account.credits_exceed_debits(amount_actual)) return .exceeds_debits;debits_exceed_credits即debits_pending + debits_posted + amount > credits_posted的封装,其含义与 Account 标志文档 完全一致。紧接着(L3927-L3934),挂起转账会把金额计入账户的debits_pending/credits_pending,从而"冻结"这部分配额:
if (t.flags.pending) { dr_account_new.debits_pending += amount_actual; cr_account_new.credits_pending += amount_actual; ... }也就是说:限流拒绝在提交时刻即生效,且是集群内确定性串行化的判定,多个并发请求之间不存在"同时通过再超卖"的窗口。
过期金额由集群自动归还
挂起转账的过期不是应用侧轮询的结果,而是由状态机在提交时间戳推进到到期时刻时触发。在 src/state_machine.zig 的execute_expire_pending_transfers中,TigerBeetle 找出所有到期未决的挂起转账,并把金额从debits_pending/credits_pending中减去:
const expires_at = p.timestamp + p.timeout_ns(); assert(expires_at <= timestamp_event); ... dr_account_new.debits_pending -= p.amount; cr_account_new.credits_pending -= p.amount;关于过期语义,有几点需要结合 Transfer.timeout 的文档说明理解:
- 转账在
timestamp + timeout时刻精确到期,到期之前挂起余额绝不会被提前移除; - 已到期的挂起转账不能再被手动 post 或 void(会返回
pending_transfer_expired); - 集群对过期清理是best-effort:不保证余额在到期那一刻被立即移除,客户端请求在短时间内仍可能观察到已到期但尚未清理的挂起余额。因此应用侧不应依赖"过期后余额立刻可用"的强实时性。
这正是限流配方选择"只挂起、不结算"方案的底层依据:无需任何后台任务,过期即自动回补配额。
落地要点与注意事项
金额语义与资源单位
TigerBeetle 的金额是128 位无符号整数。限流资源(请求次数、字节数、货币金额)都必须映射为整数单位:带宽场景直接把字节数作为金额;货币场景参照 小数金额与资产缩放 的建议,把最小有用单位映射为 1(例如 1000 USD 记作1000_00分或按你的资产缩放系数换算),避免浮点精度问题。
转账 ID 与幂等重试
- 每个请求的挂起转账都要生成唯一的转账 ID(参考 数据建模中的 ID 建议,如使用时间基 ID)。同一请求重试时必须复用同一个 ID,
create_transfers会返回exists而不是重复扣减配额; - 如果一条转账因瞬时错误(如
exceeds_credits、debit_account_not_found)失败,用相同 ID 重试会得到相同的失败结果(id_already_failed机制,见 create_transfers 结果说明),这保证了"配额不足时反复重试不会绕过限流"。
配额发放与重置的建模
本文中的"初始转账"是给用户发放窗口配额。更复杂的场景(如定期重置配额、多档套餐、租户隔离)可以进一步参考 余额上下界配方、货币兑换配方 与 多借多贷转账配方 的组合用法;多个限流账本也可以放进同一次create_transfers批量请求中一次提交。
客户端接入
各语言客户端均提供了与账户/转账/链接事件对应的 API,可直接调用create_accounts与create_transfers实现上述流程,具体接口见:
- .NET 客户端
- Go 客户端
- Java 客户端
- Node.js 客户端
- Python 客户端
更多客户端与示例可浏览 Clients 目录 及各子目录下的 samples。
小结
本文给出的三种限流方案共用同一套机制:按资源建账本 → 给用户账户加debits_must_not_exceed_credits约束 → 发放配额 → 每次消耗创建带 timeout 的挂起转账并让其自动过期。请求速率限流与带宽限流只差金额的语义,转账金额限流则通过两个账本加链接事件实现"配额扣减与真实转账原子绑定"。由于所有判定都发生在集群提交路径上(src/state_machine.zig),且过期回补由状态机自动执行(src/state_machine.zig),这套方案无需额外的限流组件,即可获得确定性、可审计、天然幂等的限流能力。更完整的账本与数据建模思路可继续阅读 数据建模 与 两阶段转账。
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考