计量与速率限制
源码文件:13 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
katana/src/meter.rskatana/src/traffic.rskatana/src/connector.rskatana/src/outbound/proxy.rskatana/src/serve.rskatana/src/manager/node.rskatana/src/manager/mod.rskatana/src/api/mod.rskatana/tests/unit/meter.rskatana/tests/unit/traffic.rskatana/tests/unit/connector.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/wake.rs
katana 只在一个地方统计流量并控制速率:它交给内核 per-connection 运行时的出站流。src/meter.rs 用 Metered 包装这条流,把每个字节计入用户的 UserCounter,并在用户的 TokenBucket 处于欠额时暂停这条流。UDP fan-out 内部也放着同一个 Gate,所以数据报也由同一个令牌桶限速。
本页面向修改限速、字节计数器或出站路径的贡献者。内容包括涉及的三个类型、为什么在出站一侧计费、为什么长期速率与运行时如何切分读写无关,以及固定这些行为的测试。计数器如何上报给面板,见流量计费;从运维角度看用户的速率从何而来,见限速与连接限制。
| 组件 | 位置 | 负责 | 交给其他部分 |
|---|---|---|---|
TokenBucket |
src/traffic.rs |
为每个用户维护一个字节余额:按 rate 补充,上限为一秒的 rate,扣费大于余额时变为负数,并回答“欠额何时还清”。 |
等待。除测试外它从不 sleep。 |
UserCounter |
src/traffic.rs |
保存 up 和 down 两个字节总数以及用户的令牌桶。每个已注册用户一个,该用户的所有流通过 Arc 共享。 |
上报和核对总数(NodeTraffic)。 |
Gate |
src/meter.rs |
回答“这条流现在能否传输字节”:用户未退役,且令牌桶没有欠额。传输完成后再计费。 | 判定谁已退役(Admission)。 |
Metered<S> |
src/meter.rs |
包装一条出站字节流:写入算上行,读取算下行,每次都先过闸门、后计费。 | 实际搬运字节(内层流)和调度(运行时)。 |
KatanaConnector::connect |
src/connector.rs |
用已准入用户的计数器和租约为每条流构建一个 Gate,并用它包装流或 UDP fan-out。 |
路由和审计判定。 |
katana 没有自己的中继循环。Etemenanki 运行时在一个 task 中搬运一条连接的全部字节,katana 能控制的是运行时通过 connector 打开的每个出站。因此出站是 katana 唯一能看到字节经过的地方,计量也就放在这里。
TokenBucket
Section titled “TokenBucket”pub struct TokenBucket { rate: u64, state: Mutex<BucketState>,}
struct BucketState { tokens: f64, last: Instant,}
impl TokenBucket { pub fn new(rate: u64) -> Self; pub fn charge(&self, n: usize) -> Option<Instant>; pub fn ready_at(&self) -> Option<Instant>; #[cfg(test)] pub async fn consume(&self, n: usize); fn refill(&self, st: &mut BucketState) -> Instant; fn repaid_at(&self, st: &BucketState, now: Instant) -> Option<Instant>;}Mutex 是 parking_lot::Mutex,Instant 是 tokio::time::Instant,因此测试可以在暂停的时间上运行。
| 项 | 含义 |
|---|---|
rate |
每秒字节数。0 表示不限速:charge 和 ready_at 立即返回 None,不获取锁。构造时确定,之后不变。 |
tokens |
以字节计的余额,类型为 f64。令牌桶处于欠额时为负数。初始值为 rate,所以新建的令牌桶是满的。 |
last |
上次把余额更新到当前的时间。 |
refill |
按自 last 以来的 elapsed × rate 补充余额,并把余额截断到不超过 rate。这个上限就是突发量:无论令牌桶闲置多久,最多攒下一秒的速率。 |
charge(n) |
先补充,再完整扣除 n,然后返回 repaid_at。即使 n 超过余额也照样扣除。 |
ready_at() |
先补充,然后返回 repaid_at,不扣费。 |
repaid_at |
tokens >= 0 时为 None;否则为 now + (-tokens / rate) 秒,即补充使余额回到零的时刻。 |
consume(n) |
仅用于测试:先 charge(n),再 sleep_until 返回的时刻。 |
每个读取余额的方法都会先在同一把锁下调用 refill,所以比较或修改余额时,余额总是最新的。
UserCounter
Section titled “UserCounter”pub struct UserCounter { pub uid: i64, up: AtomicU64, down: AtomicU64, pub rate: u64, pub bucket: TokenBucket,}
impl UserCounter { pub(crate) fn new(uid: i64, rate: u64) -> Self; pub fn add_up(&self, n: u64); pub fn add_down(&self, n: u64); pub fn up(&self) -> u64; pub fn down(&self) -> u64; pub fn commit_reported(&self, up: u64, down: u64);}add_up 和 add_down 是使用 Ordering::Relaxed 的 fetch_add。计数器的 rate 是用户以每秒字节数表示的实际速率。build_user_entries(src/manager/mod.rs)为它注册的每个用户计算这个值(UUID 无法解析的用户会被跳过),使用的是
pub fn determine_rate(node_bps: u64, user_bps: u64) -> u64;它返回两个非零限制中较小的一个;两者都为 0 时返回 0(不限速)。
pub struct Gate { counter: Arc<UserCounter>, wait: Option<Pin<Box<Sleep>>>, retired: Pin<Box<WaitForCancellationFutureOwned>>,}
impl Gate { pub fn new(counter: Arc<UserCounter>, retired: CancellationToken) -> Self; pub fn poll_open(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>>; pub fn sent(&self, n: usize); pub fn received(&self, n: usize);}| 字段或方法 | 含义 |
|---|---|
counter |
用户的计数器,与该用户的其他所有流共享。 |
wait |
令牌桶处于欠额时,这个闸门等待的那一个 tokio::time::Sleep。流的两个方向共用它。 |
retired |
用户租约上的 CancellationToken::cancelled_owned(),在 new 中一次性装箱并 pin 住。 |
poll_open(cx) |
流可以传输字节时返回 Ready(Ok(()));用户退役后返回 Ready(Err(_));令牌桶处于欠额时返回 Pending,并把 cx 同时注册到退役和计时器上。无论返回什么,包括 Ready(Ok(())),cx 都会保持注册在退役上。 |
sent(n) |
先 counter.add_up(n),再 counter.bucket.charge(n)。 |
received(n) |
先 counter.add_down(n),再 counter.bucket.charge(n)。 |
由于 poll_open 即使放行传输也会 poll 退役 future,一条随后在内层流上等待的流(例如等待一个不发数据的目标的读取)在用户退役时仍会被唤醒。下一次 poll 返回 ConnectionAborted 错误。
sent 和 received 丢弃 charge 返回的时刻。一次传输留下的欠额,由接下来调用 poll_open 的一方偿还,可能是这条流,也可能是同一用户的其他任何流。
Gate 不实现 Clone,也不持有锁。它的两个被 pin 的 future 都装了箱,因此 Gate,以及对任意 S: Unpin 的 Metered<S>,都保持 Unpin;Metered 调用 self.get_mut() 时依赖这一点。
Metered<S>
Section titled “Metered<S>”pub struct Metered<S> { inner: S, gate: Gate,}
impl<S> Metered<S> { pub fn new(inner: S, gate: Gate) -> Self;}
impl<S: AsyncRead + Unpin> AsyncRead for Metered<S> { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll<io::Result<()>>;}
impl<S: AsyncWrite + Unpin> AsyncWrite for Metered<S> { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, data: &[u8], ) -> Poll<io::Result<usize>>; fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>; fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;}connector 把它实例化为 Metered<OutboundStream>,即 impl Connector<Flow<UserTag>> for KatanaConnector 的关联类型 Stream。OutboundStream(src/outbound/proxy.rs)可以是普通 TCP 流、装箱的代理客户端流,或 WireGuard 流。
| 方法 | 是否过闸门 | 计费 |
|---|---|---|
poll_read |
是,在内层读取之前 | 对内层读取加入 buf 的 n 字节调用 received(n),仅当 n > 0 |
poll_write |
是,在内层写入之前 | 对内层写入接受的 n 字节调用 sent(n),仅当 n > 0 |
poll_flush |
否 | 否 |
poll_shutdown |
否 | 否 |
Metered 只实现这四个方法。tokio 默认的 poll_write_vectored 会把第一个非空切片转给 poll_write,所以 vectored 写入和其他写入一样会过闸门并计费。
计量所处的位置
Section titled “计量所处的位置”运行时解码入站协议,然后把载荷转发给出站,再把出站返回的载荷转回客户端。Metered 包装的是出站,所以它看到的字节已经被入站协议去掉了分帧和加密,而出站协议还没有加上自己的那一层。
flowchart LR client["客户端"] --> transport["入站传输层:TCP、TLS、WS、gRPC"] transport --> core["协议核心:VMess、VLESS、Trojan、SS"] core --> runtime["ProxyServerRuntime"] runtime --> metered["Metered:Gate、UserCounter"] metered --> outbound["OutboundStream:TCP、代理客户端、WireGuard"] outbound --> target["目标"]
选择这个位置有三个原因:
- 这里的载荷是明文。 用户按自己要求传输的内容计费,而不是按 TLS 记录、WebSocket 帧、gRPC 分帧或 VMess chunk 头计费。流量相同的两个用户,无论通过哪种入站连接,费用都一样。
- 这是唯一的挂钩点。 入站一侧归运行时所有;katana 只提供 connector 及其返回值。
- 每条流都经过这里。 每个 TCP 请求、每个 mux 子流、每个 Hysteria 流和每个 UDP 关联都会到达
KatanaConnector::connect,闸门就在那里构建。
图中展示的是流式监听器(src/serve.rs 中的 serve_stream)。Hysteria 2 监听器走的是另一条入站路径:Etemenanki 的 Hysteria 入站为每个代理流运行一个运行时,为每个连接的 UDP 再运行一个,它们各自通过 run_hysteria 构建的 KatanaConnector 打开出站。出站一侧,以及计量,都是一样的。
Metered 对交给它的明文计费。如果出站本身是代理客户端(VMess、VLESS、Shadowsocks 等),客户端自己的分帧是在 inner 内部加上的,不计费。
闸门在 connector 中构建,这是每个入站的每条流都会经过的唯一位置:
impl Connector<Flow<UserTag>> for KatanaConnector { type Stream = Metered<OutboundStream>; type Datagram = FanOut; type Future = ConnectFuture; fn connect(&mut self, flow: Flow<UserTag>) -> ConnectFuture;}
impl Admission { pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)>;}KatanaConnector::connect 用 Admission::admit 准入流所属的用户,得到用户的 Arc<UserCounter> 和一个 CancellationToken 租约,然后为每条流构建一个闸门:
let gate = Gate::new(counter, lease);- UDP 流得到
FanOut::new(self.disp.clone(), gate, tag.uid, source)。 - TCP 流先经过路由和审计。被路由到
Outbound::Block或被审计规则禁止的流,在拨号之前就被拒绝,因此永远不会计费。其余情况下,拨出的流会被包装:Metered::new(dial.await?, gate)。
每条流有自己的 Gate 和自己的 wait 计时器。同一用户的所有闸门持有同一个 Arc<UserCounter>,因为 NodeTraffic::lookup 返回的是为该凭据和 uid 注册的唯一计数器。正是这一点让令牌桶按用户而不是按流划分。这个注册表属于单个节点(每个 NodeManager 拥有自己的 NodeTraffic),所以同一个 katana 进程中由两个节点服务的用户,在每个节点上各有一个令牌桶。
经过 Metered 的一次写入
Section titled “经过 Metered 的一次写入”运行时用 poll_write 把一段转发数据写入出站,写入接受了字节时,在同一步中调用 poll_flush。每个出站都用自己的 KeyWaker(concepts/src/wake.rs)来 poll,所以闸门计时器触发的唤醒只会重新调度对应的那个出站。
sequenceDiagram participant R as 运行时 task participant M as Metered participant G as Gate participant C as UserCounter participant B as TokenBucket participant S as 内层流 R->>M: poll_write(cx, data) M->>G: poll_open(cx) G->>G: poll retired(注册 cx) G->>B: ready_at() B-->>G: Some(at),令牌桶处于欠额 G->>G: wait = sleep_until(at),poll 它 G-->>M: Pending M-->>R: Pending,没有传输任何字节 Note over R,G: 计时器触发,唤醒该出站的 KeyWaker R->>M: poll_write(cx, data) M->>G: poll_open(cx) G->>G: 再次 poll retired G->>G: 计时器已触发,清空 wait G->>B: ready_at() B-->>G: None,已无欠额 G-->>M: Ready(Ok) M->>S: poll_write(cx, data) S-->>M: Ready(Ok(n)) M->>G: sent(n) G->>C: add_up(n) G->>B: charge(n),忽略返回值 M-->>R: Ready(Ok(n)) R->>M: poll_flush(cx),不过闸门
读取与此对称:先 poll_open,再内层 poll_read,然后对落入 ReadBuf 的字节调用 received(n)。返回流结束(n == 0)的读取不计费。
poll_open 每次调用都先检查退役,然后围绕令牌桶循环:
stateDiagram-v2 [*] --> CheckRetired CheckRetired --> Aborted: 租约已取消 CheckRetired --> AskBucket: 未取消 AskBucket --> Open: ready_at 为 None AskBucket --> Waiting: ready_at 为 Some(at) Waiting --> Parked: 计时器未到期 Waiting --> AskBucket: 计时器已触发,清空 wait Parked --> CheckRetired: 被计时器或取消唤醒 Open --> [*] Aborted --> [*]
- Aborted 返回
Err(io::Error::new(io::ErrorKind::ConnectionAborted, "the user was retired"))。 - Parked 即
Pending,cx同时注册在取消 future 和Sleep上,哪个先发生就由哪个唤醒这条流。 - 计时器触发后,闸门会再次询问令牌桶,而不是假定欠额已经还清。同一用户的另一条流可能在此期间又扣出了更多欠额;如果是这样,闸门会为新的时刻设置新的计时器。
FanOut(见 Connector 与 UDP fan-out)使用同一个 Gate,顺序也相同:先过闸门,再传输,最后计费。
| 步骤 | poll_send_to |
poll_recv_from |
|---|---|---|
| 过闸门之前 | 数据包先经过路由和审计。被拦截或被禁止的数据包返回 Ready(Ok(buf.len())),直接丢弃且不计费。 |
无。 |
| 过闸门 | self.gate.poll_open(cx) |
self.gate.poll_open(cx) |
| 传输 | 由所选出站对应的子链路发送数据包;子链路仍在打开时返回 Pending。如果打开子链路失败,数据包被丢弃、报告为已发送,且不计费。 |
从 next 开始轮询各个子链路。 |
| 计费 | 在 Ready(Ok(n)) 时调用 self.gate.sent(n) |
对落入缓冲区的载荷字节调用 self.gate.received(len) |
因此,一个用户的 TCP 流、mux 子流和 UDP 数据包都从同一个令牌桶取用,两个方向都是如此。
| 不变量 | 机制 | 由谁固定 |
|---|---|---|
| 每个用户一个令牌桶,由其所有流、两个方向、TCP 和 UDP 共享。 | 用户的每个 Gate 都持有来自 NodeTraffic::lookup 的 Arc<UserCounter>;sent 和 received 都调用 counter.bucket.charge;FanOut 和 Metered 都通过 Gate 计费。 |
the_limit_is_shared_by_both_directions(tests/unit/meter.rs)、debt_holds_back_the_next_charge_too(tests/unit/traffic.rs)、udp_is_billed_after_routing_and_blocked_packets_are_free(tests/unit/connector.rs) |
| 写入计上行,读取计下行,计入的恰好是实际传输的字节。 | poll_write 按内层写入返回的 n 计费;poll_read 按 buf.filled() 的增量计费。 |
each_direction_is_billed_to_the_user(tests/unit/meter.rs)、an_admitted_stream_is_billed_to_its_user(tests/unit/connector.rs) |
| 长期速率与 chunk 大小无关。 | charge 扣除整次传输,即使越过零;tokens < 0 时,poll_open 拒绝开始任何传输。 |
the_limit_holds_however_the_writes_are_sized(tests/unit/meter.rs)、a_chunk_larger_than_the_burst_is_still_limited(tests/unit/traffic.rs) |
| 闲置用户最多攒下一秒的速率。 | refill 把 tokens 截断到 rate。 |
an_idle_bucket_banks_one_second_and_no_more(tests/unit/traffic.rs) |
| 不限速的用户从不被延迟。 | rate == 0 让 charge 和 ready_at 直接返回 None。 |
token_bucket_unlimited_is_instant(tests/unit/traffic.rs) |
字节传输之后不会再返回 Pending。 |
闸门在内层调用之前检查;内层调用返回 Ready 之后,Metered 只计费并返回 Ready。读取不会丢失已放入 buf 的数据,写入也不会对内层流已接受的字节报告“什么都没写”。 |
由构造保证;each_direction_is_billed_to_the_user(tests/unit/meter.rs)和 an_admitted_stream_is_billed_to_its_user(tests/unit/connector.rs)检查字节完整到达且只计费一次。 |
闸门返回 Pending 意味着没有传输任何字节。 |
ready!(this.gate.poll_open(cx))? 在触及内层流之前就返回。 |
由构造保证。 |
| 即使流正在等待,退役也会结束它。 | poll_open 最先 poll retired,把 cx 注册到取消上;由于 owned 取消 future 会先检查 is_cancelled,之后每次 poll 都能观察到退役。 |
retiring_the_user_wakes_a_parked_read、a_retired_users_stream_refuses_to_move(tests/unit/meter.rs) |
| 被拦截和被禁止的流量不计费。 | TCP:connect 在构建 Metered 之前就拒绝。UDP:poll_send_to 在过闸门之前丢弃。 |
udp_is_billed_after_routing_and_blocked_packets_are_free、a_forbidden_udp_destination_is_dropped_and_recorded(tests/unit/connector.rs) |
| 已退役用户不会因无法打开的流而被计费。 | 对于已离开或被重新绑定的凭据,Admission::admit 返回 None,connect 在闸门存在之前就返回拒绝。 |
a_user_the_registry_does_not_know_is_refused、a_credential_rebound_to_another_uid_is_refused(tests/unit/connector.rs) |
为什么欠额让速率与 chunk 大小无关
Section titled “为什么欠额让速率与 chunk 大小无关”运行时不按固定大小读写。它从出站读入 scratch[..max],其中 max = (staging.room() - Core::STAGING_RESERVE).min(BUF_SIZE);写入的则是协议核心产生的任意一段转发数据。BUF_SIZE 因协议核心而异。如果限速器的行为取决于这些大小,用户在不同协议上就会得到不同的速度。
考虑另一种做法:“等令牌桶攒够 n 个令牌,再传输 n 字节”。由于突发上限是一秒,大于 rate 的传输永远攒不够令牌,因此这种限速器最多等一个补充周期就必须放行,结果按大 chunk 传输的流会跑得比 rate 快。
TokenBucket 通过允许余额变为负数来避免这个问题:
- 只要
tokens >= 0,传输就可以开始。 - 传输完成后,完整扣除其大小,即使越过零。
- 在补充以每秒
rate字节的速度把tokens拉回零之前,该用户的任何传输都不会再开始。
因此每个字节都按 rate 付费,无论它是一次传完还是分多次传完。唯一的余量是突发量(最多攒下 rate 字节),加上余额越过零时已经通过闸门的那些传输;它们的费用由之后的传输偿还。
以 the_limit_holds_however_the_writes_are_sized 为例,rate = 10_000,令牌桶初始为满:
| 步骤 | 之前余额 | 传输 | 之后余额 | 下一次传输可开始 |
|---|---|---|---|---|
| 写入 30,000 字节 | 10,000 | 立即传输 | −20,000 | 2 秒后 |
| 写入 1 字节 | 0(2 秒后) | 传输 | −1 | 0.0001 秒后 |
为什么两个方向共用一个计时器
Section titled “为什么两个方向共用一个计时器”Gate 只保留一个 wait,因为同一个 Metered 的两个方向由同一个运行时 task、用同一个 per-outbound waker(concepts/src/runtime.rs 中的 slot.waker)来 poll。一个 Sleep 只保存一个 waker;由于读和写注册的是同一个,任何一个方向都不会失去唤醒。计时器触发时,KeyWaker 把该出站加入队列并唤醒运行时 task。在这次 poll 中,运行时会重试仍排在其 effect 队列最前面的写入,并因为该出站的 key 已入队而读取它,所以两个方向看到的是同一个令牌桶状态。
失败路径与取消
Section titled “失败路径与取消”| 情形 | Metered 或 FanOut 返回什么 |
接下来会发生什么 |
|---|---|---|
用户的租约被取消(用户离开了用户列表、其凭据转到另一个 uid,或因监听器即将关闭而执行了 Admission::retire_all) |
之后的每次读、写、发送或接收都返回 Err,其 io::ErrorKind 为 ConnectionAborted,消息为 the user was retired |
运行时让该出站失败。在流式监听器上,连接本身也会通过租约槽结束;见准入与用户表。Hysteria 2 的 connector 没有租约槽,所以那里的流只能靠其出站拒绝传输而结束。 |
| 内层流出错 | 原样返回内层错误 | 这次调用不计费。 |
| 内层读取返回流结束 | Ready(Ok(())),没有新增数据 |
不计费。 |
| 令牌桶处于欠额 | Pending |
计时器触发或租约被取消时(以先发生者为准),流恢复。 |
处于欠额或已退役时调用 poll_flush 或 poll_shutdown |
转发给内层流 | 冲刷已接受的字节或关闭流,从不等待令牌桶,退役后也不会被拒绝。 |
丢弃 Metered 会丢弃它的 Gate,进而丢弃挂起的 Sleep 和取消 future。二者都不持有 task 或锁,所以取消无需清理。令牌桶的 Mutex 只在 charge 和 ready_at 内部的算术运算期间持有,从不跨越 .await 或 poll。
新速率通过一次面板轮询到达节点:可以是常规轮询,也可以是配置修改改动了 [node.api](例如 speed_limit 覆盖值)时,NodeManager::apply_static(src/manager/node.rs)立即发起的那次轮询。面板客户端自己保存了一份该覆盖值,因此这类修改会在轮询前先构建一个新的 PanelClient。新客户端不持有任何 ETag,所以面板对这次轮询返回完整的用户列表,而不是 304 Not Modified,每个变化了的速率都会出现在其中。
速率变化不等于退役。NodeTraffic::prepare 会给用户一个新的 UserCounter,带有一个初始为满的新令牌桶,因为令牌桶的 rate 在构造时就已确定。已有的闸门继续持有指向旧计数器的 Arc,因此:
- 变化之前打开的流继续计入旧计数器,并保持旧速率,直到结束;
- 变化之后打开的流使用新计数器和新速率;
- 有一段时间,用户同时从两个令牌桶取用。
旧计数器会逐渐排空,并且仍会上报;见流量计费。
| 名称 | 值 | 位置 | 含义 |
|---|---|---|---|
| 突发量 | rate 字节(一秒) |
TokenBucket::refill |
令牌桶最多能攒下的量。新令牌桶的初始值正好是这个。 |
| 不限速 | rate == 0 |
TokenBucket::charge、ready_at |
不限速;计数器照常计数。 |
MBPS_TO_BPS |
1_000_000.0 / 8.0 |
src/api/mod.rs |
面板以 Mbps 给出限制;mbps_to_bps 乘以这个值,所以 1 Mbps 对应的 rate 是每秒 125,000 字节。0 或更小的值变为 0,即不限速。 |
MAX_SUBS |
64 |
src/connector.rs |
一个 UDP 关联保留的子链路数;它们共用该关联唯一的 Gate。 |
令牌桶以每秒字节数为单位。面板客户端先用 mbps_to_bps 转换每个限制,再由 determine_rate 合并节点和用户的限制。哪个面板字段对应哪个限制,以及节点级覆盖,见限速与连接限制。
tests/unit/meter.rs,在 tokio::io::duplex 对上运行:
| 测试 | 固定的行为 |
|---|---|
each_direction_is_billed_to_the_user |
5 字节的写入计 up = 5,7 字节的读取计 down = 7。 |
the_limit_holds_however_the_writes_are_sized |
在暂停的时间上、速率为 10,000 B/s 时,一次 30,000 字节的写入后跟一次 1 字节的写入,至少耗时 2 秒:超大的写入留下了 20,000 字节的欠额。 |
the_limit_is_shared_by_both_directions |
速率为 10,000 B/s 时,20,000 字节的上传让接下来 5 字节的读取至少延迟 1 秒。 |
a_retired_users_stream_refuses_to_move |
租约取消后的写入以 ConnectionAborted 失败。 |
retiring_the_user_wakes_a_parked_read |
在不发数据的对端上等待的读取,在 1 秒内被取消唤醒,并以 ConnectionAborted 失败。 |
tests/unit/traffic.rs,只测试令牌桶本身:
| 测试 | 固定的行为 |
|---|---|
token_bucket_unlimited_is_instant |
rate = 0 从不等待,即使是 1,000,000 字节。 |
token_bucket_rate_limits |
突发量耗尽后,速率为 10,000 B/s 时,接下来的 10,000 字节等待约 1 秒。 |
a_chunk_larger_than_the_burst_is_still_limited |
在暂停的时间上、速率为 10,000 B/s 时,consume(30_000) 恰好耗时 2 秒。 |
debt_holds_back_the_next_charge_too |
charge(25_000) 返回 1.5 秒之后的时刻,接下来 1 字节的扣费要等到那时。 |
an_idle_bucket_banks_one_second_and_no_more |
闲置 60 秒后,charge(20_000) 仍会留下 1 秒的欠额。 |
determine_rate_min_nonzero |
实际速率取非零限制中较小的一个。 |
tests/unit/connector.rs,通过 KatanaConnector::connect 对本地 echo 服务器测试:
| 测试 | 固定的行为 |
|---|---|
an_admitted_stream_is_billed_to_its_user |
一次 5 字节的 TCP 往返向已注册的计数器计入 (5, 5)。 |
udp_is_billed_after_routing_and_blocked_packets_are_free |
一次 UDP echo 计入 (4, 4);发往被拦截地址的数据包被接受但不计费。 |
a_forbidden_udp_destination_is_dropped_and_recorded |
被审计规则禁止的数据包不计费,并记录为一次命中。 |
测量限速的测试运行在 #[tokio::test(start_paused = true)] 上,所以它们断言的时长是精确的,不受机器负载影响。tests/unit/traffic.rs 中有两个测试改用真实时间:token_bucket_rate_limits 断言下限 0.8 秒,token_bucket_unlimited_is_instant 断言上限 50 毫秒。retiring_the_user_wakes_a_parked_read 也运行在真实时间上,因为它只断言唤醒在 1 秒超时之内到达。为令牌桶或闸门新增计时测试时,请使用暂停的时间。