基于使用量的计费(Stripe):实用数据模型
使用 Stripe 的按使用量计费需要清晰的事件存储与对账。了解一个简单的模式、Webhook 流程、回填策略以及防止重复计费的做法。

你真正要构建的是什么(以及为什么会出问题)
按使用量计费听上去很简单:衡量客户的使用量,乘以单价,然后在周期结束时收费。实际上,你是在构建一个小型的会计系统。它必须在数据迟到、重复到达或根本未到达时依然保持正确。
大多数故障并不发生在结账或仪表盘上,而是在计量数据模型中。如果你无法自信地回答“这个发票计入的是哪些使用事件,为什么?”,最终你会多收、少收,或失去客户信任。
按使用计费通常在几种可预测的情况下出问题:系统故障后事件丢失、重试产生重复、迟到的数据在总额计算后出现,或不同系统间数据不一致且无法对账。
Stripe 在定价、发票、税务和收款方面非常出色。但 Stripe 并不知道你的产品原始的使用数据,除非你把它发过去。这就需要决定事实来源:Stripe 是账本,还是你的数据库是账本而 Stripe 只是反映?
对大多数团队来说,最稳妥的划分是:
- 你的数据库是原始使用事件及其生命周期的事实来源。
- Stripe 是实际已开票与已支付内容的事实来源。
举例:你跟踪“API 调用”。每次调用都会产生一个具有稳定唯一键的使用事件。在开票时,你只统计尚未计费的合规事件总和,然后创建或更新 Stripe 的发票项。如果摄取重试或 webhook 重复到达,幂等性规则会使重复变得无害。
在设计表之前要做的决定
在创建表之前,先确定那些决定后来计费是否可解释的定义。大多数“神秘发票 bug”源于规则不明,而不是糟糕的 SQL。
从你收费的单位开始。选择一个容易衡量且难以争议的单位。“API 调用”在重试、批量请求和失败时会变复杂。“分钟”会在重叠时变复杂。“GB”需要明确基准(GB 还是 GiB)以及清晰的测量方法(平均值还是峰值)。
接着,定义边界。系统需要确切知道一个事件属于哪个窗口。按照小时计、按日计、按计费周期计还是按客户操作计?如果客户在月中升级,你是拆分窗口还是对整个当月使用一个价格?这些选择会决定你如何分组事件以及如何解释总额。
还要决定哪个系统负责哪些事实。与 Stripe 常见的做法是:你的应用负责原始事件和派生总量,而 Stripe 负责发票和支付状态。这个方法在你不悄然修改历史时最有效。把更正作为新条目记录,保留原始记录。
一组简短的不可妥协原则能让你的模式更可靠:
- 可追溯性:每个被计费的单位都能追溯到存储的事件。
- 可审计性:几个月后你仍能回答“为什么会收费?”。
- 可逆性:错误通过显式调整来修复。
- 幂等性:相同输入不会被重复计数。
- 明确所有权:每个事实只有一个系统负责(使用量、定价、发票)。
举例:如果你对“发送的消息”计费,要决定重试是否计数、失败的投递是否计数,以及优先使用哪个时间戳(客户端时间还是服务器时间)。把这些规则写下来,然后把它们编码到事件字段和校验中,而不是凭记忆。
一种简单的使用事件数据模型
当你把使用量当作会计记录处理时,按使用计费最容易实现:原始事实是追加不变的,汇总是派生的。这个单一选择能避免大多数争议,因为你总能解释数字的来源。
一个实用的起点包含五张核心表(名称可变):
- customer:内部客户 id、Stripe customer id、状态、基本元数据。
- subscription:内部订阅 id、Stripe subscription id、预期计划/价格、开始/结束时间戳。
- meter:你要度量的内容(API 调用、席位、存储 GB-小时)。包含稳定的 meter key、单位,以及如何聚合(求和、取最大、唯一计数)。
- usage_event:每个被测量动作一行。存储 customer_id、subscription_id(如果已知)、meter_id、quantity、occurred_at(发生时间)、received_at(摄取时间)、source(应用、批量导入、合作方),以及一个用于去重的稳定外部键。
- usage_aggregate:派生总量,通常按 customer + meter + 时间桶(日或小时)和计费周期存储。保留汇总数量以及用于重算的版本或 last_event_received_at。
保持 usage_event 不可变。如果后来发现错误,写一条补偿事件(例如对取消写 -3 席位),而不是编辑历史。
保存原始事件以便审计和争议。如果不能永久保存,至少保存到你的计费回溯窗口加上退款/争议窗口长度。
把派生总量分离保存。聚合能加速发票和仪表盘,但它们是一次性可重建的。你应该能随时从 usage_event 重新构建 usage_aggregate,包括在回填之后。
幂等性与事件生命周期状态
使用数据通常很嘈杂。客户端会重试请求、队列会投递重复消息,Stripe webhook 也可能乱序到达。如果你的数据库不能证明“此使用事件已被计入”,最终你会多收费。
为每个使用事件提供一个稳定、确定的 event_id 并在其上强制唯一性约束。不要仅依赖自增 id 作为唯一标识。一个好的 event_id 来源于业务动作,例如 customer_id + meter + source_record_id(或 customer_id + meter + timestamp_bucket + sequence)。如果相同行为再次发送,它会产生相同的 event_id,插入会成为安全的无操作。
幂等性必须覆盖每条摄取通路,而不仅仅是公共 API。SDK 调用、批量导入、工作进程和 webhook 处理器都会被重试。用一条规则:如果输入可能被重试,那它就需要一个存到数据库并在改变总量前检查的幂等键。
一个简单的生命周期状态模型可以让重试变得安全并简化支持。把它显式化,并在失败时存储原因:
received:已存储,尚未校验validated:通过了 schema、客户、meter 和时间窗口规则posted:已计入计费周期总量rejected:永久忽略(带原因码)
例子:你的工作进程在校验后但在发帖前崩溃。重试时,它找到相同的 event_id 且处于 validated,然后继续到 posted,而不会创建第二条事件。
针对 Stripe webhook,使用相同模式:存储 Stripe 的 event.id 并只处理一次,这样重复投递就无害。
逐步流程:端到端摄取计量事件
把每个计量事件当钱一样处理:先校验、存原始记录,然后从事实来源推导总量。这样在系统重试或迟到发送数据时,计费仍然可预测。
可靠的摄取流程
在修改任何总量之前校验每个入站事件。至少要求:稳定的客户标识、meter 名称、数值型数量、时间戳以及用于幂等的唯一事件键。
先写入原始事件,即便你打算之后聚合。原始记录是你会重处理、审计并用来修复错误的依据,无需猜测。
一个可靠的流程如下:
- 接受事件,校验必填字段,规范化单位(例如秒 vs 分钟)。
- 使用事件键作为唯一约束插入一行原始使用事件。
- 将其聚合到某个桶(按日或按计费周期),累加事件数量。
- 如果你要向 Stripe 上报使用量,记录你发送的内容(meter、数量、周期和 Stripe 的响应标识)。
- 记录异常(被拒绝事件、单位转换、迟到事件)以便审计。
保持聚合可重复。常见做法是:在一个事务中插入原始事件,然后入队一个作业去更新桶。如果作业运行两次,它应能检测该原始事件已被应用。
当客户问“我为什么被计费 12,430 次 API 调用?”时,你应该能展示该计费窗口内确切被包含的原始事件集合。
将 Stripe webhook 与你的数据库对账
Webhooks 是 Stripe 实际操作的收据。你的应用可能创建草稿并上报使用量,但只有当 Stripe 表示某事发生时,发票状态才是真实的。
大多数团队关注一小类会影响计费结果的 webhook 类型:
invoice.created、invoice.finalized、invoice.paid、invoice.payment_failedcustomer.subscription.created、customer.subscription.updated、customer.subscription.deletedcheckout.session.completed(如果你通过 Checkout 启动订阅)
存储你收到的每个 webhook。保留原始载荷以及你收到时观察到的信息:Stripe 的 event.id、event.created、你的签名校验结果和服务器接收时间戳。调试不匹配或回答“为什么被收费?”时这段历史很重要。
一个稳健且幂等的对账模式如下:
- 将 webhook 插入
stripe_webhook_events表,并在event_id上建立唯一约束。 - 如果插入失败,说明是重试。停止处理。
- 验证签名并记录通过/失败。
- 通过 Stripe 的 id(customer、subscription、invoice)查找你的内部记录并处理事件。
- 仅当状态前进时才应用变更。
乱序投递是常态。使用“最大状态获胜”规则加上时间戳:永远不要把记录向后移动。
例子:你收到 invoice.paid 针对 invoice in_123,但你的内部发票行尚不存在。创建一个标记为“从 Stripe 看到”的行,然后在稍后使用 Stripe customer id 把它关联到正确账户。这样可以在不重复处理的情况下保持账本一致。
从使用总量到发票行项
把原始使用量变成发票行项,主要涉及时间点与边界的处理。决定你是否需要实时总量(仪表盘、消费提醒)或仅在计费时计算(发票)。很多团队两者都做:持续写事件,在计划任务中计算可用于发票的总量。
让你的使用窗口与 Stripe 的计费周期对齐。不要猜测日历月份。使用订阅项当前的计费周期开始和结束,只汇总发生时间戳在该窗口内的事件。以 UTC 存储时间戳,并把计费窗口也设为 UTC。
保持历史不可变。如果后来发现错误,不要编辑旧事件或改写先前的总量。创建一个指向原窗口并增加或减少数量的调整记录。这样更容易审计,也更容易解释。
计划变更和折算往往是可追溯性丢失的地方。如果客户在周期中更换计划,按每个价格的生效时间范围拆分使用。你的发票可以包含两条使用行项(或一条行项加一条调整),每条都与特定价格和时间范围关联。
一个实用流程:
- 从 Stripe 拉取发票窗口的 period start 与 end。
- 将合规事件在该窗口及对应价格下聚合为使用总量。
- 根据使用总量和任何调整生成发票行项。
- 存储一次计算运行 id,以便日后复现这些数字。
回填与迟到数据而不破坏信任
迟到的使用数据很常见。设备离线、批处理作业延迟、合作方重发文件、日志在故障后重放都会导致回填。关键是把回填当作更正工作,而不是用来“让数字变得好看”的手段。
明确回填可能来自哪里(应用日志、仓库导出、合作方系统)。在每个事件上记录来源,这样你可以解释为什么它迟到。
回填时保留两类时间戳:发生时间(你想计费的时间)和摄取时间。将事件标记为回填,但不要覆盖历史。
优先从原始事件重建总量,而不是把增量应用到当前的聚合表。重放(replay)是你在不猜测的情况下从 bug 中恢复的方式。如果你的流水线是幂等的,你可以重跑一天、一周或整个计费周期并得到相同的总量。
一旦发票存在,更正应遵循明确策略:
- 如果发票尚未最终化,则在最终化前重新计算并更新总量。
- 如果已最终化且少收,则开具追加发票(或增加新的发票项)并写明说明。
- 如果已最终化且多收,则开具贷项并引用原发票。
- 不要为了避免更正而把使用移到不同的期间。
- 存储简短的更正原因(合作方重发、延迟日志、Bug 修复)。
例子:合作方在 2 月 3 日发送了 1 月 28-29 日缺失的事件。你以 occurred_at 在一月、ingested_at 在二月、回填来源为“partner”插入这些事件。由于一月的发票已付,你为缺失的单位生成一张小的追加发票,并在对账记录旁记录原因。
导致重复计数的常见错误
重复计数发生在系统把“消息到达”当作“动作发生”时。随着重试、延迟 webhook 和回填,你需要把客户动作与处理过程分离。
常见元凶:
- 把重试当作新使用。如果每个事件没有携带稳定的动作 id(request_id、message_id)且数据库不强制唯一性,你会被重复计数。
- 用摄取时间代替发生时间来报告。按摄取时间报告会让迟到事件落在错误的期间,然后在重放时再次被计数。
- 删除或覆盖原始事件。如果你只保留运行中的总量,你无法证明发生了什么,重处理会造成总量膨胀。
- 假设 webhook 有序。Webhooks 可能重复、乱序或表示部分状态。通过 Stripe 对象 id 对账并保持“已处理”保护。
- 未明确定义取消、退款和贷项。如果你只增加使用而从不记录负调整,你最终会通过导入来“修正”总量并再次计数。
例子:你记录了“10 次 API 调用”,之后由于故障给 2 次做了退款。如果你通过重新发送整天的使用并同时应用退款来回填,客户会看到 18 次(10 + 10 - 2)而不是正确的 8 次。
上线前的快速检查清单
在对真实客户开启按使用量计费之前,最后过一遍能防止昂贵计费错误的基础项。大多数故障不是“Stripe 的问题”,而是数据问题:重复、缺失的天、以及无声的重试。
保持清单简短且可执行:
- 在使用事件上强制唯一性(例如在
event_id上加唯一约束),并坚持一种 id 策略。 - 存储每个 webhook,校验签名,并幂等地处理它们。
- 把原始使用视为不可变。用调整(正向或负向)来修正,而不是编辑。
- 运行每日对账作业,比对内部总量(按客户、按 meter、按天)与 Stripe 的计费状态。
- 为缺失天、负值总量、突增或“已摄取事件”与“已计费事件”间的大差异添加告警。
一个简单测试:挑一个客户,重跑过去 7 天的摄取,并确认总量不变。如果改变了,你仍然有幂等性或回填问题。
示例场景:一个真实的月度使用与发票流程
一个小型支持团队在客户门户中按每次处理的对话收费 $0.10。他们用 Stripe 做按使用计费,但信任来自于在数据混乱时的处理方式。
3 月 1 日客户开始新的计费周期。每当客服完成一次对话,你的应用就发出一个使用事件:
event_id:来自你应用的稳定 UUIDcustomer_id和subscription_item_idquantity:1 次对话occurred_at:关闭时间ingested_at:你首次看到它的时间
3 月 3 日,一个后台工作进程在超时后重试并再次发送同一条对话。由于 event_id 是唯一的,第二次插入变成无操作,总量不变。
月中,Stripe 发来发票预览和随后最终化的 webhook。你的 webhook 处理器存储 stripe_event_id、type 和 received_at,并在数据库事务提交后才标记为已处理。如果 webhook 重复投递,第二次会因 stripe_event_id 已存在而被忽略。
3 月 18 日,你从离线的移动客户端导入了一批晚到的数据,包含 3 月 17 日的 35 条对话。这些事件的 occurred_at 都是更早的时间,但仍有效。系统插入它们,重算 3 月 17 日的日聚合,并且由于它们还在未关闭的计费周期内,这些额外使用会在下次开票时被计入。
3 月 22 日,你发现由于一个 bug 生成了两个不同的 event_id,导致一条对话被记录了两次。你没有删除历史,而是写了一条 quantity = -1 的调整事件,并记录原因为“检测到重复”。这样保留了审计轨迹并使发票变更可解释。
下一步:实现、监控并安全迭代
从小处开始:一个 meter、一个计划、一个你理解透的客户分段。目标是简单的一致性——你的数据和 Stripe 每月一致,且没有惊喜。
先小规模构建,然后强化
一个务实的首次上线步骤:
- 定义一种事件形态(计数什么、用什么单位、按哪个时间计)。
- 使用唯一幂等键和清晰状态存储每个事件。
- 聚合到日(或小时)总量,以便能解释发票。
- 定期(而不仅仅是实时)与 Stripe webhook 对账。
- 发票后把该周期视为已关闭,迟到事件走更正路径。
即便是无代码工具,只要你把无效状态变为不可能,例如为幂等键强制唯一约束、要求外键指向 customer 和 subscription,并避免更新已接受的原始事件,也能保持强数据完整性。
能救你一命的监控
及早添加简单的审计界面。第一次有人问“为什么这个月账单更高?”时,它们就能自我返本付出。实用视图包括:按客户和周期搜索事件、按天查看每周期总量、跟踪 webhook 处理状态,以及审查回填和调整的谁/何时/为什么。
如果你在 AppMaster (appmaster.io) 上实现,这个模型很自然:在 Data Designer 中定义原始事件、聚合和调整,然后用 Business Processes 实现幂等摄取、计划聚合和 webhook 对账。这样你仍然有真实的账本和审计轨迹,而无需手写所有底层代码。
当你的第一个 meter 稳定后,再添加下一个。保持相同的生命周期规则、相同的审计工具和相同的习惯:一次只改一件事,然后端到端验证。
常见问题
把它当作一个小型账本来处理。难点不在于向卡片收费,而在于即便事件迟到、重复到达或需要更正,也要保持准确且可解释的记录。
一个安全的默认做法是:你的数据库作为原始使用事件及其状态的事实来源,Stripe 则是发票和支付结果的事实来源。这样的划分能在让 Stripe 处理定价、税务和收款的同时,保持计费可追溯。
让它稳定且可确定,这样重试会产生相同的标识。通常由真实的业务动作导出,例如 customer id + meter key + source record id,这样重复发送会变成无害的空操作,而不是额外计费。
不要编辑或删除已接受的使用事件。记录一个补偿性的调整事件(必要时为负数量),并保留原始记录,这样以后可以解释整个历史而不用猜测发生了什么。
保持原始使用事件为追加不变的数据,同时把聚合结果单独存储为可重建的派生数据。聚合用于加速和报告;原始事件用于审计、争议解决以及在出现 bug 或回填时重建总量。
至少存两类时间戳:发生时间和摄取时间,并记录来源。如果发票尚未最终化,则在最终化前重算;如果已最终化,则把它作为明确的更正处理(追加收费或开具贷项),不要悄然把使用移到别的期间。
存储每一个接收到的 webhook 载荷,并使用 Stripe 的事件 id 作为唯一键来强制幂等处理。Webhooks 经常重复或乱序到达,所以处理器只应应用那些使记录向前推进的状态变更。
使用 Stripe 的计费周期开始和结束时间作为窗口,并在价格变化时对使用进行拆分。目标是让每一条发票项都能对应到一个具体的时间范围和价格,从而保持总额可解释。
存储足以证明哪些原始事件被包含的聚合逻辑,并保存一次计算运行 id 或等效元数据以便重现总量。如果对同一窗口重跑摄取逻辑会改变总量,那么很可能存在幂等性或生命周期状态的 bug。
在 Data Designer 中建模原始使用事件、聚合、调整和 webhook 收件箱表,然后在 Business Processes 中实现幂等的摄取和对账逻辑,同时为幂等性设置唯一约束。你可以在不手写所有底层代码的情况下构建可审计的账本和定期对账。


