用std::chrono::system_clock实现的令牌桶在测试环境全绿,生产环境却因系统时钟突前后在第17秒冲出300请求、随后饥饿2秒;根因是时钟源不可信而非桶逻辑。
令牌桶很简单。底层的时钟不是。
我需要一个限速器,用于遥测网关——将事件批量转发给上游 API,上游限制 100 req/s。让我在一个免费模型端点上实现这个桶和一套单元测试。18 个测试全部通过。然后 60 秒的负载测试显示,第 17 秒处理了 300 个请求,随后两秒完全饿死。
根本原因不在桶逻辑,而在 std::chrono::system_clock。
网关批量发送事件,上游在超过 100 req/s 时返回 429。重试 429 会让问题更糟。限速器必须在本地实现、开销低廉、在负载下正确。
令牌桶是标准方案:令牌以固定速率补充,每个请求消耗一个令牌,桶有突发容量上限。实现大约二十行。失败模式藏在细节里。
实现一个 C++ 令牌桶:
约束条件:实现和测试将由免费模型端点生成,整个验证循环在免费服务器上运行。我不会手写第一版,只做审查。
我用 MonkeyCode 的免费模型访问来生成代码,用它的免费服务器选项来运行编译-测试-浸泡循环。MonkeyCode 是一个开源项目;它的免费层目前包含 1000 万 token 和一个免费服务器选项,适用于这类任务。README 记录了当前条款,这里的数字来自 2026-08-24 的一次运行,不是产品基准测试。
声明:本文是 MonkeyCode 产品推广的一部分。
门槛很简单:任何生成的代码在通过单元测试、负载测试和浸泡测试之前不得进入网关。这正是本案例研究的重点。
提示词只有一段:"用 C++17 实现一个令牌桶,包含 try_acquire()。rate 是每秒令牌数,capacity 是突发容量。使用头文件形式的类。包含单元测试。"
模型返回了这段代码:
// token_bucket.hpp — v1, generated
#include <chrono>
#include <algorithm>
class TokenBucket {
public:
TokenBucket(double rate_per_sec, double capacity)
: rate_(rate_per_sec), capacity_(capacity),
tokens_(capacity), last_(std::chrono::system_clock::now()) {}
bool try_acquire() {
auto now = std::chrono::system_clock::now();
double elapsed = std::chrono::duration<double>(now - last_).count();
tokens_ = std::min(capacity_, tokens_ + elapsed * rate_);
last_ = now;
if (tokens_ >= 1.0) {
tokens_ -= 1.0;
return true;
}
return false;
}
private:
double rate_;
double capacity_;
double tokens_;
std::chrono::system_clock::time_point last_;
};
看起来正确。这就是陷阱所在。
模型还生成了 18 个单元测试:突发消耗、沉睡后补充、令牌不为负、容量上限。我用 g++ -std=c++17 -Wall -Wextra 编译并运行。
[==========] 18 tests from 4 test suites ran.
[ PASSED ] 18 tests.
全绿。免费模型写出了一个看起来正确的限速器,测试也认可了。我仍然不信任它。基于时间的代码出问题在时间上,不在逻辑上。
我写了一个工具来测量实际发生的事情,精确到秒。它运行桶 60 秒,统计每秒请求数,并报告总数。工具使用 steady_clock 做测量,独立于桶内部使用的任何时钟。
// load_test.cpp
#include "token_bucket.hpp"
#include <chrono>
#include <cstdio>
#include <thread>
int main() {
TokenBucket bucket(100.0, 100.0);
const int kSeconds = 60;
int per_second[kSeconds] = {0};
auto start = std::chrono::steady_clock::now();
int total = 0;
while (true) {
auto now = std::chrono::steady_clock::now();
int sec = static_cast<int>(
std::chrono::duration_cast<std::chrono::seconds>(now - start).count());
if (sec >= kSeconds) break;
if (bucket.try_acquire()) {
per_second[sec]++;
total++;
} else {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
for (int i = 0; i < kSeconds; i++)
std::printf("second %2d: %4d\n", i, per_second[i]);
std::printf("total: %d (expected ~%d)\n", total, 100 * kSeconds);
}
在免费服务器上构建和运行:
g++ -std=c++17 -O2 load_test.cpp -o load_test
./load_test
输出不是平坦的每秒 100 个。
second 15: 101
second 16: 99
second 17: 300
second 18: 0
second 19: 0
second 20: 102
...
total: 5998 (expected ~6000)
第 17 秒处理了 300 个请求。随后桶饿死了两秒。总数接近预期,但形状错了:突发是配置上限的三倍,随后是静默。对于会在 100 req/s 时返回 429 的上游来说,这个突发就是失败。
逻辑没问题。时钟有问题。system_clock 是墙上时间。在共享服务器上,NTP 可以将时钟向前或向后跳动几秒。向前跳动会让 elapsed 变大,桶瞬间发放大量补充。向后跳动让 elapsed 变负,桶拒绝一切直到债务偿还。
修复方法是使用单调时钟和整数微令牌,这样分数速率不依赖浮点数比较:
// token_bucket.hpp — v2, after the audit
#include <chrono>
#include <cstdint>
#include <algorithm>
class TokenBucket {
public:
TokenBucket(double rate_per_sec, double capacity)
: rate_per_us_(rate_per_sec / 1'000'000.0),
capacity_us_(static_cast<int64_t>(capacity * 1'000'000.0)),
tokens_us_(capacity_us_),
last_(std::chrono::steady_clock::now()) {}
bool try_acquire() {
auto now = std::chrono::steady_clock::now();
auto elapsed_us = std::chrono::duration_cast<std::chrono::microseconds>(
now - last_).count();
last_ = now;
tokens_us_ = std::min(capacity_us_,
tokens_us_ + static_cast<int64_t>(elapsed_us * rate_per_us_));
if (tokens_us_ >= 1'000'000) {
tokens_us_ -= 1'000'000;
return true;
}
return false;
}
private:
double rate_per_us_;
int64_t capacity_us_;
int64_t tokens_us_;
std::chrono::steady_clock::time_point last_;
};
时钟选择是一种决策,不是默认。
修复后负载测试通过了:最高 104每秒,最低 96,总数 5,987。对于 100 req/s 的上限来说已经足够好。
然后我以 0.1 req/s 运行了 24 小时浸泡,模拟一个慢上游。v1 代码中 tokens_ >= 1.0 发放了 8,639 个令牌而不是 8,640 个。浮点数比较在边界处丢失了一个令牌。整数版本精确发放了 8,640 个。
单元测试从未捕获这两个 bug。第一个需要真实时钟和 NTP。第二个需要分数速率和 24 小时。
生成代码上绿色的单元测试只能证明逻辑,不能证明环境。基于时间的代码必须用单调测量时钟做负载测试。
system_clock 用于时间戳。steady_clock 用于计时。在限速器里混用它们会将 NTP 同步变成一次流量突发。
浮点令牌数隐藏边界错误。整数微令牌让数学运算精确。
免费基础设施改变了验证的经济学。模型调用、编译、60 秒负载测试和 24 小时浸泡全部花费 $0。花费注意力的是门槛。
这种方法不适合所有人。如果你的限速器保护资金流转、安全系统或任何 3 倍突发会造成灾难的东西,你需要形式化推理和生产规模负载测试,而不是在免费机器上做浸泡测试。免费服务器选项适用于 CI、测试工具和长时浸泡运行。它不是生产基础设施的替代品。
上面的工具是完整的。用它跑 v1,看第 17 秒,应用时钟修复,再跑一次,然后跑 24 小时分数速率浸泡。整个循环只需几条命令。MonkeyCode 开源项目的 README 有免费模型访问和免费服务器选项的当前条款。