令牌桶比简单计时器更适合api限流,因其能平滑吸收突发流量并保证长期速率不超限:桶容量控制最大突发量,填充速率控制长期均值,且不依赖固定时间窗口切分。

为什么令牌桶比简单计时器更适合API限流
因为真实API调用有突发性,而单纯用 std::chrono::steady_clock 做固定窗口计数(比如“每秒最多10次”)会导致临界时刻被打穿——前一秒末尾发10次,后一秒开头又发10次,实际20次/秒。令牌桶能平滑吸收突发,同时保证长期速率不超限。
核心在于:它维护一个随时间匀速填充的“桶”,每次调用前尝试取走一个令牌;桶空则阻塞或拒绝,不依赖窗口切分。
- 桶容量 = 最大允许突发请求数(如
burst_size = 5) - 填充速率 = 单位时间补充令牌数(如
10.0 / std::chrono::seconds(1)) - 必须用
std::atomic或互斥锁保护桶状态,多线程下调用客户端时不然会丢令牌
如何用 std::atomic 实现无锁令牌桶(C++20起推荐)
C++20 的 std::atomic<float></float> 支持浮点原子操作,可直接存当前令牌数;但更稳妥的做法是用 std::atomic<int64_t></int64_t> 存纳秒级“下次可取令牌时间戳”,避免浮点精度漂移和 ABA 问题。
示例关键逻辑:
立即学习“C++免费学习笔记(深入)”;
class TokenBucket {
std::atomic<int64_t> next_refill_time_{0}; // 下次能取令牌的时间点(纳秒)
const double rate_per_sec_;
const int burst_size_;
const std::chrono::nanoseconds refill_interval_;
public:
TokenBucket(double rate_per_sec, int burst_size)
: rate_per_sec_(rate_per_sec), burst_size_(burst_size),
refill_interval_(std::chrono::nanoseconds(
static_cast<int64_t>(1e9 / rate_per_sec))) {}
bool try_acquire() {
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
auto expected = next_refill_time_.load();
while (true) {
if (now >= expected) {
// 可以取走一个,更新下次时间(+ refill_interval_)
auto desired = expected + refill_interval_.count();
if (next_refill_time_.compare_exchange_weak(expected, desired))
return true;
// CAS失败:说明其他线程已更新,重试
continue;
}
// 还没到时间,检查是否在burst范围内(即桶里还有“预存”额度)
// 这里简化处理:只允许一次立即获取,否则返回false
return false;
}
}
};
- 注意
refill_interval_是倒算出来的,不是每周期 sleep;靠时间戳比较驱动,无忙等 -
compare_exchange_weak必须循环使用,单次失败不等于不可用 - 该实现不自动填充“历史欠额”,适合严格速率控制;若需支持突发后缓慢恢复,得额外记录当前令牌数
std::atomic<int></int>
集成到HTTP客户端时怎么避免阻塞整个请求线程
直接在 curl_easy_perform 或 boost::beast::http::async_write 前调用 try_acquire() 并 sleep 等待,会卡死线程——尤其在异步客户端里完全不可接受。
- 正确做法:把限流判断做成前置钩子,失败时返回
std::nullopt或抛出std::system_error(带std::errc::resource_unavailable_try_again),由上层决定重试策略 - 不要在限流逻辑里调用
std::this_thread::sleep_for;等待应交给调度器(如asio::steady_timer)或业务层退避 - 如果用
libcurl多线程,确保每个线程独享一个TokenBucket实例,或加std::mutex——原子版虽快,但高竞争下 CAS 自旋开销也不小
测试时最容易漏掉的边界情况
单元测试常只验证“匀速调用不被限”,但线上出问题多在边界上:
-
rate_per_sec设为0.1(即每10秒1次)时,refill_interval_.count()可能截断为0,导致永远无法填充 → 必须对refill_interval_做最小值约束(如不低于1ms) - 程序启动瞬间大量请求涌入,
next_refill_time_初始为0,首次调用总成功,但可能瞬间耗尽burst_size→ 要么启动时预设一个合理初始时间,要么在try_acquire中加入“首次填充”逻辑 - 系统时间被NTP校准回拨,
steady_clock不受影响,但若误用system_clock,now()可能跳变,导致桶“倒流”补令牌 → 务必只用std::chrono::steady_clock
真正难调的不是算法本身,而是时间语义、原子操作顺序、以及 burst 和 rate 在不同负载下的耦合表现。上线前至少用压测工具模拟 3 种节奏:匀速、脉冲、抖动,观察响应延迟和 429 比例是否符合预期。










