Rails API Usage Tracking 与 Metrics 数据结构设计
明确结论
不要把 usage 数据存进 Flow 的多层 Hash,也不建议在 Flow 记录上持续更新计数。
更合适的设计是:
- 用独立的时间分桶表保存聚合计数,例如每个
Flow每小时一条记录。 - 通过数据库的原子操作递增计数,避免并发请求造成数据丢失。
- 如果计费需要审计、去重,或者以后需要分析 IP、地理位置等维度,再增加一张不可变的原始事件表。
- 流量较高时,可以用 Redis 暂存计数并批量落库,但数据库中的聚合结果或原始事件仍应是可恢复、可审计的数据来源。
“每小时创建一个 object instance 并递增 integer field”这个方向是合理的。它本质上是一张时间序列 rollup 表,比嵌套 Hash 更适合范围查询、求和、索引和数据保留管理。
为什么不应继续使用多层 Hash
把年份、月份、日期和小时嵌套在一个字段中,会产生不少问题:
- 数据库很难利用普通索引执行时间范围查询。
- 按用户、多个
Flow或全站聚合时,需要加载并解析大量 Hash。 - 每次更新都可能重写整个字段。
- 多个请求同时更新同一个
Flow时,容易发生 lost update。 - 很难增加
status_code、API endpoint、region 等分析维度。 - 不便删除过期的小时数据,也不便生成日级、月级汇总。
- 单条
Flow记录会持续膨胀,最终成为写入热点。
JSON/JSONB 适合存储结构不固定的附加属性,不适合承担核心计数和时间范围统计。
推荐的数据结构
可以建立一张 flow_usage_buckets 表,每个时间桶保存调用次数:
class CreateFlowUsageBuckets < ActiveRecord::Migration[7.0]
def change
create_table :flow_usage_buckets do |t|
t.references :flow, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.datetime :bucket_start, null: false
t.string :granularity, null: false, default: "hour"
t.bigint :calls_count, null: false, default: 0
t.timestamps
end
add_index :flow_usage_buckets,
[:flow_id, :granularity, :bucket_start],
unique: true,
name: "idx_flow_usage_unique_bucket"
add_index :flow_usage_buckets,
[:user_id, :bucket_start]
add_index :flow_usage_buckets, :bucket_start
end
end
对应模型:
class FlowUsageBucket < ApplicationRecord
belongs_to :flow
belongs_to :user
validates :bucket_start, presence: true
validates :granularity, inclusion: { in: %w[minute hour day month] }
validates :calls_count,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
如果目前只保留小时粒度,可以暂时省略 granularity,让唯一索引和查询更简单:
add_index :flow_usage_buckets,
[:flow_id, :bucket_start],
unique: true
从关系上看,user_id 可以通过 flow.user_id 获取,因此它是一个冗余字段。不过,把它放进 bucket 表能明显简化按用户和时间统计的查询,代价是必须保证它与 flow.user_id 一致。如果 Flow 可能转移给其他用户,还要提前确定历史 usage 应归原用户还是当前用户。
使用原子 UPSERT 递增
不要使用“先查询当前值,再加一并保存”的写法:
bucket = FlowUsageBucket.find_or_create_by!(...)
bucket.update!(calls_count: bucket.calls_count + 1)
两个并发请求可能读到相同的旧值,最后只记录一次。应该让数据库通过原子 UPSERT 完成递增。
以 PostgreSQL 为例,可以封装一个 recorder:
class ApiUsageRecorder
def self.record!(flow:, occurred_at: Time.current)
bucket_start = occurred_at.utc.beginning_of_hour
now = Time.current
sql = ApplicationRecord.send(
:sanitize_sql_array,
[
<<~SQL,
INSERT INTO flow_usage_buckets
(flow_id, user_id, bucket_start, granularity, calls_count, created_at, updated_at)
VALUES
(?, ?, ?, 'hour', 1, ?, ?)
ON CONFLICT (flow_id, granularity, bucket_start)
DO UPDATE SET
calls_count = flow_usage_buckets.calls_count + 1,
updated_at = EXCLUDED.updated_at
SQL
flow.id,
flow.user_id,
bucket_start,
now,
now
]
)
ApplicationRecord.connection.execute(sql)
end
end
调用方式:
ApiUsageRecorder.record!(flow: flow)
这段代码依赖 PostgreSQL 的 ON CONFLICT。如果使用 MySQL,应改用对应的 INSERT ... ON DUPLICATE KEY UPDATE,具体 SQL 需要根据实际数据库调整。
Rails 的 upsert_all 也可以创建或覆盖记录,但“发生冲突时,把数据库中的现有值原子加一”并不是所有 Rails 与数据库组合都能通过同一套高层 API 清楚表达。对于计数器,显式 SQL 通常更直接,也更容易确认并发语义。
三类查询的实现
查询某个 Flow 的小时 usage
from = 7.days.ago.beginning_of_hour
to = Time.current.end_of_hour
usage = FlowUsageBucket
.where(
flow_id: flow.id,
granularity: "hour",
bucket_start: from...to
)
.order(:bucket_start)
.pluck(:bucket_start, :calls_count)
数据库只会返回实际有请求的时间桶。绘制连续曲线时,可以在展示层补齐缺失的小时,并把计数设为 0。
如果使用 PostgreSQL,也可以通过 generate_series 在 SQL 中补齐时间点。是否值得这样处理,要看图表的数据量和查询复杂度。
查询某用户某月全部 Flows 的总调用量
month = Time.zone.parse("2026-08-01")
range = month.beginning_of_month...month.next_month.beginning_of_month
total = FlowUsageBucket
.where(
user_id: user.id,
granularity: "hour",
bucket_start: range
)
.sum(:calls_count)
如果 bucket 表不保存 user_id,可以通过 flows 关联查询:
total = FlowUsageBucket
.joins(:flow)
.where(
flows: { user_id: user.id },
granularity: "hour",
bucket_start: range
)
.sum(:calls_count)
前一种写法查询更简单,后一种写法避免了数据冗余。选择时需要考虑查询频率、数据量,以及 Flow 的归属是否可能变化。
查询给定时间段的全站 usage
total = FlowUsageBucket
.where(
granularity: "hour",
bucket_start: from...to
)
.sum(:calls_count)
按小时生成全站趋势:
series = FlowUsageBucket
.where(
granularity: "hour",
bucket_start: from...to
)
.group(:bucket_start)
.order(:bucket_start)
.sum(:calls_count)
计费数据不能只考虑“计数”
如果 usage 会直接决定账单,一个可变计数器通常不够。还需要明确以下问题:
- 客户端重试是否会重复计费。
- 请求执行到一半失败时是否计费。
- 统计写入失败后能否重放。
- 如何把某笔账单追溯到具体请求。
- 是否应排除内部重试、健康检查和测试流量。
- 迟到事件应计入请求发生月份还是处理月份。
- 删除
Flow后是否仍需保留账单记录。
更稳妥的做法是记录不可变的 usage event:
class CreateApiUsageEvents < ActiveRecord::Migration[7.0]
def change
create_table :api_usage_events do |t|
t.references :flow, null: true, foreign_key: { on_delete: :nullify }
t.references :user, null: false, foreign_key: true
t.string :request_id, null: false
t.datetime :occurred_at, null: false
t.string :endpoint
t.integer :status_code
t.inet :ip_address
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :api_usage_events, :request_id, unique: true
add_index :api_usage_events, [:flow_id, :occurred_at]
add_index :api_usage_events, [:user_id, :occurred_at]
add_index :api_usage_events, :occurred_at
end
end
其中:
request_id或业务定义的idempotency_key用于防止重复计数。occurred_at表示实际发生时间,created_at表示事件写入时间。- 常用过滤字段应使用独立列。
- 只有结构不固定、使用频率低的属性才放进
metadata。 - IP 地址可能涉及隐私和合规问题,应限制用途、访问权限和保留周期。
之后可以通过后台任务把事件聚合到小时 bucket。展示报表时查询 bucket,审计和重新计算时使用原始事件。
这种模式的数据量更大,但能提供计费系统所需的可追溯性。可以通过分区、压缩、归档和保留策略控制成本,不应为了减少行数而牺牲账单的正确性。
高流量下的写入架构
对于中等流量,直接向数据库执行原子 UPSERT 通常是最简单、可靠的起点。流量增长后,可以逐步演进为:
API request
↓
写入 usage event 或消息队列
↓
后台消费者聚合
↓
小时 bucket / 日 bucket
↓
图表、报表与账单
也可以在 Redis 中使用 INCRBY:
usage:flow:123:2026-08-29T14:00:00Z
然后定期批量写入数据库。不过,使用 Redis 前必须明确:
- 进程崩溃或 Redis 故障时,是否允许丢失计数。
- flush 成功后,如何避免重复落库。
- key 的过期时间。
- 后台任务重试是否具有幂等性。
- Redis 与数据库不一致时如何修复。
因此,Redis 更适合作为写入缓冲或实时计数层。如果没有持久化和恢复方案,不应把它作为计费的唯一依据。
分钟级或更细粒度如何选择
时间桶越细,记录越多,但图表也越灵活。可以按实际展示和计费需求选择:
- 只展示长期趋势时,小时 bucket 通常足够。
- 展示短时间实时流量时,可以使用分钟 bucket。
- 需要请求级审计时,保存 event,同时生成分钟或小时 rollup。
- 长期保存时,可以只保留最近一段时间的分钟数据,之后保留小时或日汇总。
以一条持续活跃的 Flow 为例:
- 小时粒度每年最多约
8,760条 bucket。 - 分钟粒度每年最多约
525,600条 bucket。 - 原始事件数量与实际请求数相同。
实际记录数还取决于是否为空桶创建记录。通常没有必要为没有请求的时间段预建记录。
日级和月级汇总
小时表增长到一定规模后,可以再建立日汇总表:
flow_usage_hourly
flow_usage_daily
最近七天的图表读取小时表,月度账单和年度报表读取日表。日表由可重试的后台任务根据小时表生成,并通过唯一索引保证幂等性。
不要把小时表和基于这些小时数据生成的日表放在一起求和,否则会重复统计。每个查询都必须明确使用哪一种粒度。
账单结算后,建议生成独立且不可变的账单明细或 usage statement,不要在每次打开历史账单时重新查询当前 rollup。迟到事件、数据修正和业务规则变化都可能使重新计算的结果发生变化。
时区与月份边界
存储和分桶建议统一使用 UTC:
bucket_start = occurred_at.utc.beginning_of_hour
不过,“用户的某个月”可能是按其账单时区定义的。查询时,应先根据账单时区计算月份边界,再转换成 UTC:
zone = ActiveSupport::TimeZone[user.billing_time_zone]
local_start = zone.local(2026, 8, 1)
local_end = local_start.next_month
range = local_start.utc...local_end.utc
不要假设每个月或每天都有固定的小时数。使用夏令时的地区,一天可能有 23 小时或 25 小时。
索引、分区与数据保留
索引应按照实际查询模式设计,不必为每一列都添加索引。这个场景通常需要以下核心索引:
UNIQUE (flow_id, granularity, bucket_start)
(user_id, bucket_start)
(bucket_start)
原始事件表达到较大规模后,可以按 occurred_at 做月度分区,以便进行范围扫描和删除过期分区。是否需要分区,应以实际数据量和查询计划为依据。过早引入分区会增加迁移和运维的复杂度。
还需要制定清晰的数据保留策略,例如:
- 原始请求事件保留若干月。
- 分钟数据只保留较短时间。
- 小时或日汇总长期保留。
- 已结算的账单明细按照财务和合规要求保留。
具体期限取决于业务、合同和适用法规,不能只由技术实现决定。
是否需要 gem
这类需求的重点不在于缺少某个 Rails gem,而在于数据模型、并发更新、幂等性和保留策略是否清楚。
图表聚合可以考虑使用 groupdate 等时间分组类 gem,但它只能简化 GROUP BY 查询,不能代替 usage 事件、原子计数或计费审计设计。数据库监控方案也不等同于面向客户的 billing ledger。
如果规模继续扩大,可以评估专门的时间序列或分析系统,例如 PostgreSQL 分区表、TimescaleDB、ClickHouse,或者托管的计量计费服务。不过,对大多数早期 Rails 应用来说,PostgreSQL 加独立 bucket 表已经足够,后续迁移路径也很清楚。
实施建议
可以按风险从低到高逐步实施:
- 停止写入
Flow#daily_calls,建立小时 bucket 表。 - 使用唯一索引和数据库 UPSERT,实现并发安全的计数。
- 将统计查询改为基于
bucket_start的范围查询。 - 明确 UTC、账单时区,以及失败请求和重试请求的计费规则。
- 如果数据用于真实计费,增加带有唯一
request_id的不可变 usage event。 - 通过后台任务生成小时、日级 rollup,并核对事件总数与汇总总数。
- 等数据增长后,再引入 Redis 缓冲、表分区或专门的分析存储。
如果 usage 仅用于图表展示,小时 bucket 足以解决问题。如果 usage 会产生账单,建议采用“不可变事件 + 可重建汇总 + 已结算账单快照”的结构。这样既便于查询,也能在出现重复请求、迟到数据或统计错误时核对和修复。
备注:内容仅供参考。
