为 Cloudflare Workers 构建用量熔断器

问题:Serverless 不等于无限

我运营着 3mins.news,一个完全构建在 Cloudflare Workers 上的 AI 新闻聚合器。后端有 10+ 个 Cron 触发器每隔几分钟运行一次——抓取 RSS、聚类文章、调用 LLM、发送邮件。一切运行良好,直到你意识到 Cloudflare Workers Paid Plan 有硬性的月度限额:

资源 月度限额 超额单价
Workers 请求 10M $0.30/M
Workers CPU 时间 30M ms $0.02/M ms
KV 读取 10M $0.50/M
KV 写入 1M $5.00/M
KV 删除 1M $5.00/M
KV 列举 1M $5.00/M
Queue 操作 1M $0.40/M
Observability 事件 20M $0.60/M

如果某个 bug 导致重试循环,或者意外流量高峰耗尽 KV 写入额度,你要么承受超额账单,要么应用不可预测地降级。Cloudflare 不会在你触达限额时自动暂停 Worker——它只是开始计费超额部分。

AWS 有 Budget Alerts,但那是被动通知。等你看到邮件并做出反应时,费用已经累积了。我想要的是主动的、应用层面的自我保护:系统应该在触顶前自动缩减非关键工作。

洞察:熔断器,但面向自身

熔断器模式广为人知——Netflix 的 Hystrix 让它成为保护下游级联故障的标准方案。核心思想:如果下游服务反复失败,暂时停止调用,快速失败。

但这里的威胁不在下游。威胁是自身的资源消耗对抗硬性预算上限。熔断器需要面向内部。

心智模型:

传统模式:  我的应用  ──→  [熔断器]  ──→  下游服务
                          "如果它失败就停止调用"

用量感知:  [熔断器]  ──→  我的应用的定时任务
            "如果我们在燃烧预算就停止运行"

架构

熔断器作为 Cron 调度器的一部分运行。每 5 分钟,它查询 Cloudflare 的 GraphQL API 和 Observability Telemetry API,获取当月所有 8 个资源维度的用量。两次检查之间,它从 KV 读取缓存状态。

┌──────────────────────────────────────────────────┐
│                  Cron 调度器                       │
│                                                   │
│  ┌──────────────────┐                             │
│  │    熔断检查器      │                            │
│  │                   │                            │
│  │  每 5 分钟:       │     ┌──────────────────┐   │
│  │  查询 CF API ─────┼────→│ CF GraphQL API   │   │
│  │  缓存到 KV         │     │ CF Telemetry API │   │
│  │                   │     └──────────────────┘   │
│  │  检查间隔期:      │                            │
│  │  读取 KV 缓存      │                            │
│  └────────┬─────────┘                             │
│           │                                       │
│     已熔断?── 是 ──→ 跳过所有任务                   │
│           │                                       │
│          否                                        │
│           │                                       │
│     正常运行定时任务                                  │
└──────────────────────────────────────────────────┘

按资源独立阈值

并非所有资源同样危险。Workers 请求 $0.30/M 很便宜——超额 10% 只需 $0.30。KV 写入 $5.00/M 很贵——超额 10% 需要 $5.00。而且有些资源(如 CPU 时间)在不停止全部任务的情况下根本无法"减少"。

因此我设计了按资源的独立阈值配置:

const RESOURCE_THRESHOLDS: Record<string, ResourceThresholds> = {
  // 仅预警:超额便宜或无法选择性减少
  'Workers 请求': { warn: 80, trip: null, recover: null },
  'Workers CPU':  { warn: 80, trip: null, recover: null },
  // 可触发熔断:超额昂贵
  'KV 读取':      { warn: 80, trip: 90, recover: 85 },
  'KV 写入':      { warn: 80, trip: 90, recover: 85 },
  'KV 删除':      { warn: 80, trip: 90, recover: 85 },
  'KV 列举':      { warn: 80, trip: 90, recover: 85 },
  'Queues 操作':  { warn: 80, trip: 90, recover: 85 },
  'Observability': { warn: 80, trip: 90, recover: 85 },
}

trip: null 配置是关键。它意味着"在 80% 时警告我,但永远不触发全面熔断"。Workers 请求和 CPU 属于这一类——它们要么超额便宜,要么无法选择性减少。熔断器只在超额昂贵系统可以通过暂停定时任务有效减少消耗的资源上触发。

三态 + 滞回机制

熔断器有三个状态:

                 任一可熔断资源 >= 90%(trip)
正常 ──────────────────────────────────────→ 已熔断
  ↑                                              │
  │         所有可熔断资源 < 85%(recover)         │
  └──────────────────────────────────────────────┘

trip(90%)和 recover(85%)之间 5% 的间隔防止了振荡。没有这个间隔,系统会在 90% 熔断、停止任务、降到 89% 恢复、恢复任务、马上回到 90%、再次熔断——经典的振荡问题。

用量 %
  95 ─ ─ ─ ─ ─ ─ ┐
  90 ─ ─ ─ ─ ─ ─ ┤ ← 熔断阈值
  85 ─ ─ ─ ─ ─ ─ ┤ ← 恢复阈值
                  │
  无滞回:              有滞回:
  ↗90 熔断              ↗90 熔断
  ↘89 恢复              ↘89 维持熔断
  ↗90 熔断              ↘87 维持熔断
  ↘89 恢复              ↘84 恢复
  (振荡!)            (稳定)

告警去重

没人想每天收到 288 封相同的告警邮件(24 小时 × 12 次/小时)。系统在两个层级做告警去重:

  1. 预警告警:按资源、按月去重。如果 KV 读取触达 80% 并发了告警,同一资源当月不再重复告警。
  2. 熔断告警:全局、按月去重。每月一次熔断通知足矣。

去重使用带 TTL 的 KV:

// 按资源去重 key:"circuit:alert:warn:KV 读取:2026-03"
const kvKey = `circuit:alert:warn:${resource.name}:${monthKey}`
const alreadySent = await env.RUNTIME_KV.get(kvKey)
if (alreadySent) continue // 跳过此资源

// 发送后标记已发送(TTL: 3 天兜底)
await env.RUNTIME_KV.put(kvKey, String(Date.now()), {
  expirationTtl: 259200
})

检查流程

完整的决策流程,每 5 分钟执行一次:

flowchart TD
    Start[Cron 触发] --> IsCheckTime{minute % 5 == 0?}
    IsCheckTime -->|否| ReadCache[读取 KV 缓存]
    ReadCache --> ReturnCached{缓存已熔断?}
    ReturnCached -->|是| SkipTasks[跳过所有任务]
    ReturnCached -->|否| RunTasks[正常运行任务]

    IsCheckTime -->|是| QueryAPI[查询 CF GraphQL + Telemetry API]
    QueryAPI --> CacheUsage[缓存用量数据到 KV]
    CacheUsage --> EvalTrip{任一可熔断资源 >= 90%?}

    EvalTrip -->|是| WriteTripped[写入熔断标志到 KV]
    WriteTripped --> SendTripAlert[发送熔断告警]
    SendTripAlert --> SkipTasks

    EvalTrip -->|否| CheckCurrentState{当前已熔断?}
    CheckCurrentState -->|是| EvalRecover{所有可熔断资源 < 85%?}
    EvalRecover -->|是| ClearTripped[删除熔断标志]
    ClearTripped --> RunTasks
    EvalRecover -->|否| SkipTasks

    CheckCurrentState -->|否| EvalWarn{任一资源 >= 80%?}
    EvalWarn -->|是| SendWarnAlert[发送预警]
    EvalWarn -->|否| RunTasks
    SendWarnAlert --> RunTasks

几个值得注意的设计细节:

API 故障容忍:如果 Cloudflare API 查询失败,熔断器维持当前状态。它从不在 API 故障时假设"一切正常"——而是读取 KV 中最后的已知状态。这防止了监控中断掩盖真实的用量尖峰。

最小开销:在 5 分钟检查窗口之间,熔断器仅一次 KV 读取——亚毫秒级,几乎零成本。完整 API 检查每小时只运行 12 次。

恢复只看可熔断资源:评估恢复时,只有 trip != null 的资源参与。如果 Workers CPU 在 95% 但所有 KV/Queue 指标低于 85%,熔断器会恢复。CPU 无论如何都不能通过暂停任务来降低。

用量查询:两个 API 并行

Cloudflare 没有一个"给我所有用量"的端点。我从两个 API 拼凑数据:

GraphQL API — 覆盖 Workers 请求、CPU 时间、KV 操作(按类型分组)和 Queue 操作:

query CfUsage($accountTag: String!, $since: Date!, $until: Date!) {
  viewer {
    accounts(filter: { accountTag: $accountTag }) {
      workersInvocationsAdaptive(
        filter: { date_geq: $since, date_leq: $until }
        limit: 1
      ) {
        sum { requests cpuTimeUs }
      }
      kvOperationsAdaptiveGroups(
        filter: { date_geq: $since, date_leq: $until }
        limit: 10
      ) {
        dimensions { actionType }
        sum { requests }
      }
      queueMessageOperationsAdaptiveGroups(
        filter: { date_geq: $since, date_leq: $until }
        limit: 1
      ) {
        sum { billableOperations }
      }
    }
  }
}

Observability Telemetry REST API — 覆盖 Observability 事件(日志/追踪),这些不在 GraphQL API 中暴露:

const telemetryResponse = await fetch(CF_TELEMETRY_ENDPOINT, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
  body: JSON.stringify({
    timeframe: { from: monthStartMs, to: nowMs },
    view: 'calculations',
    parameters: {
      calculations: [{ operator: 'COUNT' }],
      datasets: [],
      limit: 1,
    },
    ignoreSeries: true,
  }),
})

两个请求并行执行。如果 Telemetry API 失败,系统仍然工作——只是将 Observability 报告为 0,依赖其他指标。

告警:邮件 + 飞书

当阈值被突破时,告警通过两个渠道并行发出:

  1. 邮件(Resend API)— 可持久化、可检索的记录
  2. 飞书 Webhook — 即时手机通知

飞书卡片包含每个资源的可视化进度条:

[生产环境] 用量预警

KV 读取     ■■■■■■■■□□ 82.3% (8.2M/10M)
KV 写入     ■■■■■■■■■□ 87.1% (871K/1M)

Promise.allSettled 确保一个渠道失败不会阻塞另一个。去重标记仅在邮件发送成功后写入——如果邮件投递失败,下一次检查周期会重试。

测试

熔断器有完整的单元测试覆盖所有状态转换:

describe('checkCircuitBreaker', () => {
  // 非检查周期:读 KV 缓存
  it('无缓存时返回 false')
  it('有熔断缓存时返回 true')

  // 检查周期:完整评估
  it('用量 < 80%:不告警,返回 false')
  it('80-90%:发预警,返回 false')
  it('>= 90%:触发熔断,写 KV 标志,返回 true')
  it('已熔断 + 全部 < 85%:恢复,删除 KV 标志')
  it('已熔断 + 85-90%:维持熔断(滞回)')

  // 按资源独立阈值
  it('Workers CPU >= 90%:仅预警不熔断(trip=null)')
  it('Workers CPU 95% + KV 读取 70%:不熔断')
  it('Workers CPU 95% + KV 写入 93%:仅 KV 写入触发熔断')
  it('已熔断 + CPU 仍高 + KV 已恢复:解除熔断')

  // 告警去重
  it('同一资源同月第二次预警:不重复发送')
  it('资源 A 已预警 + 资源 B 新增:仅发资源 B')

  // 边界情况
  it('API 故障 + 未熔断:返回 false')
  it('API 故障 + 已熔断:返回 true(维持状态)')
  it('未配置 CF_AE_TOKEN:跳过检查')
  it('缓存用量数据到 KV 供报告读取')
})

滞回机制的关键测试:

it('已熔断 + 85-90% → 维持熔断(滞回)', async () => {
  // KV 读取 87% — 低于熔断阈值(90%) 但高于恢复阈值(85%)
  vi.mocked(queryCfUsage).mockResolvedValue(
    makeUsageData({ 'KV 读取': { percent: 87 } })
  )

  // 已处于熔断状态
  const mockKV = {
    get: vi.fn().mockResolvedValue(
      JSON.stringify({ tripped: true, resources: ['KV 读取'], trippedAt: 1000 })
    ),
    put: vi.fn(), delete: vi.fn(),
  }

  const result = await checkCircuitBreaker(makeEnv({ RUNTIME_KV: mockKV }), new Date())

  expect(result).toBe(true)          // 仍然熔断
  expect(mockKV.delete).not.toHaveBeenCalled()  // 标志未清除
})

经验总结

1. 按资源独立阈值是必要的。 单一全局阈值在资源超额成本差异巨大时行不通。请求 $0.30/M 对比 KV 写入 $5.00/M 是 16 倍的差距。

2. 滞回防止振荡。 trip 和 recover 阈值之间 5% 的间隔是在测试中观察到振荡后加入的。这是控制系统中的经典技术,但在软件中容易被忽略。

3. 监控故障时"保守处理"。 当用量 API 不可用时,维持最后已知状态(而非默认"一切正常")防止了最坏情况:监控中断掩盖用量尖峰。

4. KV 完美契合这个场景。 熔断器状态需要跨 Worker 调用持久化,但不需要强一致性。KV 的最终一致性完全够用——在 5 分钟检查周期内几秒的陈旧状态无关紧要。

5. 告警疲劳是真实的。 如果没有按资源的月度去重,KV 读取首次触达 80% 后,我会在当月剩余时间里收到 8,640 封告警邮件(12 次/小时 × 24 小时 × 30 天)。去重逻辑不是可选的。

适用范围

这个模式适用于任何有基于用量定价和硬性限额的 Serverless 平台:

  • AWS Lambda:月度计算时间、并发执行数
  • Vercel:Serverless 函数调用、带宽
  • Supabase:数据库连接、存储、带宽
  • 任何计量 API:OpenAI、Stripe、Twilio——任何有预算上限的地方

核心思想很简单:像对待下游服务的错误率一样对待自身资源预算。 当信号恶化时,触发熔断并优雅降级——在平台替你降级(或者你的钱包替你降级)之前。


熔断器已在生产环境运行两周。它在月初捕获了 KV 读取 82% 的尖峰——我收到一封预警邮件,排查、修复了根因,从未触达熔断阈值。这正是我想要的结果:早期发现,零停机。

如果你在任何 Serverless 平台上运行非平凡的工作负载,考虑构建类似的东西。实现并不复杂,而且它比平台的计费告警——那些在损失已经发生后才到达的告警——好得多。


本文采用 CC BY-NC-SA 4.0 许可