C++如何实现带限流的API调用客户端?(令牌桶算法)

6次阅读

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

C++如何实现带限流的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_performboost::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_clocknow() 可能跳变,导致桶“倒流”补令牌 → 务必只用 std::chrono::steady_clock

真正难调的不是算法本身,而是时间语义、原子操作顺序、以及 burst 和 rate 在不同负载下的耦合表现。上线前至少用压测工具模拟 3 种节奏:匀速、脉冲、抖动,观察响应延迟和 429 比例是否符合预期。

text=ZqhQzanResources