2. 用量事件建模

用量计量的第一步不是写聚合 SQL,也不是配置配额阈值,而是把业务世界里发生的“动作”翻译成一条条可被系统稳定处理的用量事件。用量事件是计量系统的原子事实:它描述“谁、在什么时间、对什么资源、做了多少次/多少量、属于哪个维度”。如果事件模型设计得含糊,后续的聚合、计费、配额扣减都会在边界情况上反复出错;如果设计得清晰,聚合逻辑往往只是简单的分组求和。

本节围绕六个关键决策展开:可计量事件的定义、事件属性、维度、粒度、幂等键与时间语义,最后给出事件契约与版本演进策略。

一、什么算“可计量事件”

可计量事件必须满足三个条件:可观测(系统能捕获到它发生)、可归属(能确定它属于哪个租户/账户/资源)、可量化(能给出一个数值与单位)。例如“用户点击按钮”通常不可计量,因为它没有稳定的归属和量化语义;而“API 网关完成一次请求”是可计量的,因为它有租户标识、时间戳、请求路径和响应状态。

常见的可计量事件包括:API 调用、消息投递、存储写入字节、计算任务执行秒数、导出文件生成次数、活跃用户心跳等。每类事件都应有一个稳定的 event_type,例如 api.request.completed、storage.bytes.written、compute.seconds.consumed。命名建议采用“资源.动作.状态”的三段式,避免使用模糊的 usage 或 activity。

二、事件属性:最小充分集

一条用量事件不需要携带全部业务字段,但必须包含计量与追溯所需的最小充分集。典型属性如下:

  • event_id:全局唯一标识,用于去重与追踪。
  • event_type:事件类型,决定后续聚合规则。
  • tenant_id / account_id:归属主体,计费与配额的核心维度。
  • resource_id:被消耗的资源实例,如 API 路由、存储桶、计算集群。
  • quantity 与 unit:数值与单位,如 1 次、2048 字节、3.5 秒。
  • occurred_at:事件在业务上发生的时间。
  • recorded_at:事件被计量系统接收的时间。
  • idempotency_key:幂等键,用于防止重复计量。
  • dimensions:附加维度,如 region、plan、api_version。

属性设计要避免两个极端:字段太少导致无法按客户、按区域出账;字段太多导致事件体积膨胀、隐私风险上升。一个实用的判断标准是:任何未来可能用于分组、过滤或对账的字段,都应在事件契约中显式声明,而不是塞进自由文本。

三、维度与粒度:决定聚合的灵活度

维度是聚合时的分组键,粒度是事件的时间精度与数量精度。维度设计直接决定“能不能按客户+区域+API 版本出账”。常见维度包括租户、账户、资源、区域、套餐、环境(prod/staging)、API 版本。粒度则包括:

  • 时间粒度:秒级、分钟级、小时级。计费通常按小时或天聚合,但事件本身建议保留秒级时间戳。
  • 数量粒度:每次调用记 1 次,还是按请求体大小记字节;是记原始值还是预聚合值。
  • 资源粒度:按单个实例记,还是按资源组记。

粒度越细,聚合越灵活,但存储与处理成本越高。工程上常见的折中是:事件层保留细粒度原始事实,聚合层按小时或天生成汇总表。这样既支持实时配额扣减,也支持离线对账。

四、幂等键与时间语义

分布式系统中,事件重复投递几乎不可避免。幂等键的作用是让同一条业务事实即使被多次写入,也只计量一次。幂等键的构造应满足:同一业务事实的多次重试得到相同键,不同事实得到不同键。例如:

idempotency_key = sha256(
  tenant_id + "|" +
  event_type + "|" +
  resource_id + "|" +
  business_trace_id
)

其中 business_trace_id 是业务侧生成的唯一请求标识。若业务侧无法提供,可退化为 tenant_id + event_type + resource_id + occurred_at 截断到秒,但要接受同一秒内同资源多次调用可能被误合并的风险。

时间语义需要区分三个时间:业务发生时间(occurred_at)、系统记录时间(recorded_at)、聚合归属时间(billing_period)。计费通常按 occurred_at 归属账期,但若事件延迟到达,可能需要按 recorded_at 做补录。建议在事件契约中同时保留两者,并在聚合规则中明确以哪个为准。

用量事件从产生到聚合的完整链路 事件源 API 网关 消息队列 存储服务 计算调度器 产生 用量事件契约 event_id / event_type tenant_id / resource_id quantity + unit occurred_at / recorded_at idempotency_key dimensions{region, plan, ...} 校验 幂等去重 idempotency_key 唯一索引 / 布隆过滤 重复事件丢弃 首次事件放行 写入 聚合与配额 按 tenant 分组 按 billing_period SUM(quantity) 配额扣减 阈值告警 账单生成 时间语义 occurred_at 业务发生 recorded_at 系统接收 billing_period 账期归属 传输延迟 聚合窗口 事件契约版本演进 v1 基础字段 tenant, quantity, time 新增 v2 增加维度 region, plan, api_version 兼容 v3 幂等键强化 business_trace_id 弃用 v1 下线 双写过渡期

五、事件契约与版本演进

事件契约是事件源与计量系统之间的接口约定。它应明确字段名、类型、是否必填、取值范围和示例。契约一旦发布,就不能随意改字段名或删字段,否则下游聚合会静默出错。版本演进建议遵循以下原则:

  • 只增不改:新增字段是兼容的,修改字段语义或类型是不兼容的。
  • 双写过渡:从 v1 到 v2 时,事件源同时发送两个版本,计量系统按版本路由,确认 v2 稳定后再下线 v1。
  • 版本字段显式化:在事件中携带 schema_version,聚合层根据版本选择解析规则。
  • 弃用有窗口:旧版本至少保留一个完整账期,避免跨月对账时数据缺失。
实践建议:事件契约最好用 JSON Schema 或 Protobuf 定义,并纳入 CI 校验。每次变更都跑一遍契约兼容性检查,防止“加了个字段但下游没升级”导致解析失败。

六、一个可参考的事件契约示例

下面给出一个 API 调用事件的契约片段,字段均为示例,实际项目可按需裁剪:

{
  "event_id": "evt_9f3a2c1b",
  "event_type": "api.request.completed",
  "schema_version": "2.1",
  "tenant_id": "t_10086",
  "account_id": "acc_5521",
  "resource_id": "route:/v1/orders",
  "quantity": 1,
  "unit": "request",
  "occurred_at": "2025-03-12T08:15:30Z",
  "recorded_at": "2025-03-12T08:15:31Z",
  "idempotency_key": "sha256:t_10086|api.request.completed|route:/v1/orders|trace_abc123",
  "dimensions": {
    "region": "cn-east-1",
    "plan": "pro",
    "api_version": "v1",
    "status_code": 200
  }
}

聚合时,按 tenant_id + billing_period + dimensions.region 分组,对 quantity 求和,即可得到“某租户某账期某区域的 API 调用次数”。若需要按状态码区分成功与失败,可在 dimensions 中保留 status_code,聚合时过滤 status_code < 400 只计成功调用。

七、常见建模陷阱

  • 把事件当日志:日志可以随意增删字段,事件契约不能。事件是计费依据,必须稳定。
  • 幂等键用随机 UUID:随机 UUID 无法识别重试,等于没有幂等。幂等键必须由业务事实决定。
  • 时间语义混用:用 recorded_at 归属账期,会导致延迟事件跨月错账。建议默认用 occurred_at,补录场景显式标记。
  • 维度爆炸:把用户 IP、请求 ID 等高基数字段放进 dimensions,会让聚合表膨胀。高基数信息应放在事件明细中,不参与分组。

用量事件建模的最终检验标准是:给定任意一条事件,你能回答“它属于谁、发生在何时、消耗了多少、按什么维度聚合、重复投递会不会多算”。如果这五个问题都有明确答案,事件模型就具备了支撑计量、配额与计费的基础能力。