超额计费
场景
账单系统上线后,运行正常。
但运营团队发现了一些问题:
运
用户反馈:
- 为什么我的账单有超额费用?
- 超额费用是怎么计算的?
- 我什么时候超的限?
技术问题:
- 统计数据延迟(月度统计不是实时的)
- 无法精确计算超额时间点
- 用户无法实时查看已用量
问题分析
我查了一下当前的计费逻辑:
现在的链路是「调用记录 → 月度统计表 → 账单」三段式,套餐内的免费额度靠事后聚合的结果反推。这条链路能算出月底的总数,却答不出用户最关心的三个问题:超额是从哪一次调用开始的、此刻到底用了多少、超额费用究竟怎么算出来的。要改就得先说清楚这条链路究竟在哪几个环节上不够用。
设计流程问题分析
- 步骤 1:梳理「调用记录 → 月度统计表 → 账单」的现有计费链路
- 步骤 2:核对统计表的聚合周期,确认数据比真实调用落后多少
- 步骤 3:逐条比对调用记录与账单明细,定位超额起点无法追溯的原因
- 步骤 4:归纳三个待改进点:统计延迟、超额时点查不到、用户看不到实时用量
关注点:月度统计的滞后、超额起点不可追溯、实时用量缺失。
问题:
- 使用月度统计表,数据有延迟
- 无法精确计算超额时间点
- 用户无法实时监控自己的用量
改进方案
实时用量追踪
不再依赖月度统计表,而是实时追踪:
第一刀切在数据来源上:把月底的聚合换成调用时的实时累加,让「已用量」成为一份随时可查的状态。累加口径必须和套餐额度完全对齐——按什么维度计数、要不要区分 API、跨月怎么归集,都要在这一步定死,否则后面算超额和出账单会各说各话。
设计流程实时用量追踪
- 步骤 1:确定用量累加维度:按 API Key、用户和计费周期分别计数
- 步骤 2:在请求链路上实时累加调用次数,并同步更新当前周期已用量
- 步骤 3:抽样比对实时计数与原始调用记录,确认无重复、无漏计
- 步骤 4:让实时用量成为唯一权威口径,月度统计表降级为对账用途
关注点:累加口径、套餐额度一致性、计数归属与可回溯性。
实时查询用量
实时追踪解决了数据准不准,但用户还需要一个入口看得到自己的用量。查询接口要直接给出当前周期已用次数、套餐上限、剩余额度和超额费用预估,并且能下钻到按 API、按天的分布——只有这样用户才能自己判断该不该继续调用,而不是等账单出来才发现超了。
设计流程实时查询用量
- 步骤 1:确定查询接口返回字段:已用量、套餐上限、剩余额度与超额预估
- 步骤 2:按用户和计费周期读取实时用量,并补充按 API、按天的分布
- 步骤 3:校验返回值与限流计数一致,剩余额度不得出现负数或漂移
- 步骤 4:让用户在超额发生前就能自行查看并调整调用节奏
关注点:查询口径、剩余额度准确性、用户可自助下钻。
超额预警
用量达到 80% 时预警
实时用量最有价值的用法是提前预警,而不是事后追责。设一条 80% 的阈值线,用量跨过它就通知用户:剩余额度还能支撑多久、按当前速度预计何时用完、要不要升级套餐。预警只触发一次,并且必须带上触发那一刻的用量快照,否则用户点进来看到的是另一个数字,信任就没了。
设计流程用量达到 80% 时预警
- 步骤 1:为每个计费周期配置 80% 预警阈值,并登记触发时的用量快照
- 步骤 2:用量累加后判断是否跨越阈值,只在首次跨越时发出通知
- 步骤 3:在预警内容中附上已用量、剩余额度与预计耗尽时间
- 步骤 4:保证同一周期不重复提醒,并记录通知状态供后续追溯
超额限制
硬限额 vs 软限额
超额之后是继续服务还是直接拒绝,是产品必须明确的取舍。软限额只提醒不拦截,靠预警把成本压力转给用户;硬限额直接拒绝超额请求,能把成本和外部依赖牢牢锁住,但要先确认用户已经收到过预警、并且有升级或提额的入口。两种策略可以并存:默认软限额,由用户自己选择是否开启硬限额。
设计流程硬限额 vs 软限额
- 步骤 1:为每个套餐标明采用软限额还是硬限额,并写明超额后的服务行为
- 步骤 2:软限额只记录超额用量并持续预警,不中断用户请求
- 步骤 3:硬限额在用量越界后拒绝新增请求,并返回可升级的错误信息
- 步骤 4:为用户提供切换限额方式和临时提额的入口,避免被硬拦截卡死
关注点:拦截与提醒的边界、用户体验与成本控制的平衡。
精确账单生成
相关数据模型
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | PRIMARY KEY, AUTO_INCREMENT |
endpoint | VARCHAR(255) | NOT NULL |
params | TEXT | 请求参数 |
response_status | INT | HTTP 状态码 |
response_time | INT | 响应时间(ms) |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| id | endpoint | params | response_status | response_time | created_at |
|---|
1 | /api/weather | city=北京 | 200 | 118 | 2026-03-31 14:00:00 |
2 | /api/weather | city=上海 | 200 | 132 | 2026-03-31 14:01:00 |
3 | /api/weather | city=广州 | 500 | 240 | 2026-03-31 14:02:00 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
email | VARCHAR(255) | UNIQUE, NOT NULL |
password_hash | VARCHAR(255) | NOT NULL |
api_key | VARCHAR(64) | UNIQUE, NOT NULL |
plan | VARCHAR(50) | DEFAULT 'free' |
daily_limit | INT | DEFAULT 1000 |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| id | email | plan | daily_limit | created_at |
|---|
1 | alice@demo.com | free | 1000 | 2026-04-08 10:32:11 |
2 | bob@demo.com | pro | 100000 | 2026-04-11 09:15:02 |
3 | ops@demo.com | basic | 10000 | 2026-04-13 21:44:36 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | NOT NULL |
endpoint | VARCHAR(255) | NOT NULL |
hour | TIMESTAMP | 精确到小时 |
total_calls | INT | DEFAULT 0 |
success_calls | INT | DEFAULT 0 |
error_calls | INT | DEFAULT 0 |
avg_response_time | FLOAT | 平均响应时间(ms) |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| user_id | endpoint | hour | total_calls | success_calls | error_calls |
|---|
1001 | /api/weather | 2026-04-20 10:00 | 132 | 128 | 4 |
1002 | /api/forecast | 2026-04-20 10:00 | 91 | 90 | 1 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | NOT NULL |
endpoint | VARCHAR(255) | NOT NULL |
date | DATE | 统计日期 |
total_calls | INT | DEFAULT 0 |
success_calls | INT | DEFAULT 0 |
error_calls | INT | DEFAULT 0 |
avg_response_time | FLOAT | 平均响应时间(ms) |
p95_response_time | FLOAT | 95分位响应时间(ms) |
p99_response_time | FLOAT | 99分位响应时间(ms) |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| user_id | endpoint | date | total_calls | success_calls | error_calls |
|---|
1001 | /api/weather | 2026-04-20 | 2410 | 2368 | 42 |
1002 | /api/forecast | 2026-04-20 | 1832 | 1810 | 22 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | NOT NULL |
month | DATE | 每月1号 |
total_calls | INT | DEFAULT 0 |
total_success | INT | DEFAULT 0 |
total_error | INT | DEFAULT 0 |
avg_daily_calls | FLOAT | 日均调用次数 |
peak_hour_calls | INT | 峰值小时调用量 |
most_used_endpoint | VARCHAR(255) | 最常调用接口 |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| user_id | month | total_calls | total_success | total_error | avg_daily_calls |
|---|
1001 | 2026-04 | 58420 | 57110 | 1310 | 1947 |
1002 | 2026-04 | 40680 | 39920 | 760 | 1356 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
name | VARCHAR(100) | UNIQUE, NOT NULL |
display_name | VARCHAR(100) | NOT NULL |
endpoint | VARCHAR(255) | NOT NULL |
method | VARCHAR(10) | DEFAULT 'GET' |
category | VARCHAR(50) | weather/news/finance/tools |
external_api_url | VARCHAR(255) | 上游服务地址 |
requires_auth | BOOLEAN | DEFAULT TRUE |
cache_ttl | INT | 缓存时间(秒) |
rate_limit_factor | INT | DEFAULT 1 |
status | VARCHAR(20) | active/deprecated/maintenance |
is_public | BOOLEAN | DEFAULT TRUE |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
updated_at | TIMESTAMP | ON UPDATE CURRENT_TIMESTAMP |
| id | name | display_name | category | method | cache_ttl | status |
|---|
1 | weather | 天气查询 | weather | GET | 3600 | active |
2 | news | 新闻快讯 | news | GET | 300 | active |
3 | stock | 股票行情 | finance | GET | 60 | maintenance |
user_api_permissionsFields| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | NOT NULL |
api_id | INT | NOT NULL, FK -> api_definitions.id |
granted_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
expires_at | TIMESTAMP | NULL 表示长期有效 |
status | VARCHAR(20) | active/revoked/expired |
| id | user_id | api_id | granted_at | expires_at | status |
|---|
8001 | 101 | 1 | 2026-04-10 10:00:00 | NULL | active |
8002 | 101 | 2 | 2026-04-10 10:00:00 | 2026-05-10 10:00:00 | active |
8003 | 204 | 3 | 2026-04-01 09:15:00 | 2026-04-30 23:59:59 | expired |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | NOT NULL, AUTO_INCREMENT |
user_id | INT | NOT NULL |
endpoint | VARCHAR(255) | NOT NULL |
params | TEXT | 请求参数 |
response_time | INT | 毫秒 |
status | VARCHAR(50) | 调用状态 |
created_at | TIMESTAMP | 分区键 |
pk | PRIMARY KEY(id, created_at) | 支持范围分区 |
| partition | range | records | storage | hotness |
|---|
p202401 | < 2024-02-01 | 58,200,000 | 370 GB | cold |
p202402 | < 2024-03-01 | 61,900,000 | 395 GB | warm |
pmax | MAXVALUE | ongoing | rolling | hot |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | BIGINT | NOT NULL |
user_id | INT | NOT NULL |
endpoint | VARCHAR(255) | NOT NULL |
response_time | INT | 毫秒 |
status | VARCHAR(50) | 调用状态 |
created_at | TIMESTAMP | 分层依据 |
storage_tier | VARCHAR(20) | hot/warm/cold |
archive_uri | VARCHAR(255) | 冷数据对象存储地址 |
| tier | time_range | query_freq | storage | cost_level |
|---|
hot | 0-30 days | high | MySQL 主库 | high |
warm | 30-180 days | medium | MySQL 从库 | medium |
cold | >180 days | low | 对象存储 | low |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
name | VARCHAR(50) | UNIQUE, free/basic/pro/enterprise |
display_name | VARCHAR(100) | 套餐展示名 |
price_monthly | DECIMAL(10,2) | 月费 |
daily_limit | INT | 每日配额 |
rate_limit_per_second | INT | 每秒限流 |
burst_capacity | INT | 突发容量 |
max_apis | INT | 可用 API 数量 |
data_retention_days | INT | 数据保留天数 |
support_type | VARCHAR(50) | community/email/priority/dedicated |
custom_domain | BOOLEAN | 是否支持自定义域名 |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| id | name | price_monthly | daily_limit | rate_limit_per_second | support_type |
|---|
1 | free | 0.00 | 1000 | 1 | community |
2 | basic | 99.00 | 10000 | 10 | email |
3 | pro | 499.00 | 100000 | 100 | priority |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | UNIQUE, FK -> users.id |
plan_id | INT | FK -> pricing_plans.id |
billing_cycle | VARCHAR(20) | monthly/yearly |
status | VARCHAR(20) | active/cancelled/expired/past_due |
current_period_start | TIMESTAMP | 当前周期开始 |
current_period_end | TIMESTAMP | 当前周期结束 |
cancel_at_period_end | BOOLEAN | 期末取消标记 |
payment_method_id | VARCHAR(255) | 支付渠道 ID |
updated_at | TIMESTAMP | ON UPDATE CURRENT_TIMESTAMP |
| id | user_id | plan_id | billing_cycle | status | current_period_end |
|---|
501 | 101 | 2 | monthly | active | 2026-05-31 23:59:59 |
502 | 204 | 3 | yearly | active | 2027-04-30 23:59:59 |
503 | 305 | 1 | monthly | past_due | 2026-04-30 23:59:59 |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
user_id | INT | FK -> users.id |
subscription_id | INT | FK -> user_subscriptions.id |
billing_period_start | DATE | 账期开始 |
billing_period_end | DATE | 账期结束 |
subtotal | DECIMAL(10,2) | 小计 |
tax | DECIMAL(10,2) | 税费 |
total | DECIMAL(10,2) | 总计 |
total_calls | INT | 周期调用总量 |
plan_limit | INT | 套餐周期限额 |
overage_calls | INT | 超额次数 |
status | VARCHAR(20) | draft/sent/paid/overdue/cancelled |
sent_at | TIMESTAMP | 发送时间 |
paid_at | TIMESTAMP | 支付时间 |
| id | user_id | billing_period_start | billing_period_end | total | status |
|---|
10001 | 101 | 2026-04-01 | 2026-04-30 | 132.40 | paid |
10002 | 204 | 2026-04-01 | 2026-04-30 | 529.00 | sent |
10003 | 305 | 2026-04-01 | 2026-04-30 | 99.00 | overdue |
| 字段名 | 类型 | 约束 / 说明 |
|---|
id | INT | PRIMARY KEY, AUTO_INCREMENT |
invoice_id | INT | FK -> invoices.id |
description | VARCHAR(255) | 费用项描述 |
quantity | INT | 数量 |
unit_price | DECIMAL(10,2) | 单价 |
amount | DECIMAL(10,2) | 金额 |
item_type | VARCHAR(50) | base_fee/overage/discount/tax |
metadata | JSON | 扩展信息 |
| id | invoice_id | description | quantity | unit_price | amount |
|---|
20001 | 10001 | 基础套餐费(Basic) | 1 | 99.00 | 99.00 |
20002 | 10001 | 超额调用费 | 28400 | 0.001 | 28.40 |
20003 | 10001 | 税费 | 1 | 5.00 | 5.00 |
使用实时数据生成账单
计费的终点是账单,而账单必须能被逐笔核对。改用实时用量生成账单后,每一笔超额费用都要能回溯到具体的调用记录和发生时点,基础费用、超额费用、已付状态分开列出。金额算错不可怕,可怕的是错得查不出来,所以账单和明细都要留下可对账的依据。
设计流程使用实时数据生成账单
- 步骤 1:按用户和计费周期取实时用量快照,固定本次出账的数据基准
- 步骤 2:分别计算套餐月费与超额部分费用,写入账单主记录
- 步骤 3:为每笔超额费用生成明细,关联到具体调用记录和发生时点
- 步骤 4:校验账单金额等于明细汇总,并标记出账状态供后续发送
关注点:账单与明细的一致性、金额可逐笔核对、状态可追溯。
用量仪表盘
已用 7,500 次限额 10,000 次
75.0%
当前套餐基础版
日限额333 次/天
剩余可用2,500 次
超额调用0 次
预估超额费用
¥0.00
计费周期2024-03
上线前多想一步
- 会不会重复扣费? 同一次调用、同一张账单,重试多次也只能算一次。
- 账单算错怎么办? 要能追到每一笔超额费用来自哪次调用,而不是只给一个总数。
- 用户申诉怎么查? 客服至少能看到用量时间线、套餐变化和扣费明细。
当前架构
GET https://dashboard.kuaiyizhi.cn/api/v3/weather?city=北京