同时处理单次与 recurring charges 的数据库设计
结论
建议保留两类数据,但不要放在同一张 Charges 表中:
Charges:记录已经发生、可以进入账单的费用。Subscriptions:记录订阅规则,包括金额、周期和下一次扣费时间。Invoices与InvoiceItems:保存已经出具的账单及明细。
单次购买直接生成一条 Charges 记录。定时任务扫描到期的 Subscriptions,再为每个计费周期生成对应的 Charges 记录。
这与现有方案的基本思路接近,但表的职责更明确。订阅规定未来如何产生费用,charge 记录已经发生的费用。两者的生命周期不同,不适合只用一张表管理。
为什么不建议只使用一张 Charges 表
单次费用通常只需要记录:
- 向哪个客户收费;
- 金额和币种;
- 费用发生时间;
- 是否已经开票。
订阅则还要记录:
- 计费周期;
- 下一次计费时间;
- 生效和终止时间;
- 当前状态;
- 套餐数量、价格和续费规则。
如果把这些信息全部放进 Charges,单次费用会带有大量无用的周期字段,订阅记录也要同时承担规则和交易明细两种职责。以后处理暂停、取消、改价、按比例计费、重试和历史审计时,数据含义很容易变得含混。
更合适的关系是:
Customers
├── Subscriptions ──< Charges
├── Charges
└── Invoices ──< InvoiceItems >── Charges
Charges.subscription_id 可以为空:
- 为空时表示单次费用;
- 不为空时表示该费用由某个订阅生成。
这样既可以统一查询所有费用,也不会把订阅规则和实际费用混在一起。
推荐的表结构
下面是一个简化的 PostgreSQL 示例。其他 SQL 数据库也可以使用同样的数据模型,只需调整字段类型和语法。
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(200) NOT NULL
);
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status VARCHAR(20) NOT NULL
CHECK (status IN ('active', 'paused', 'cancelled')),
amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
currency CHAR(3) NOT NULL,
interval_unit VARCHAR(10) NOT NULL
CHECK (interval_unit IN ('day', 'week', 'month', 'year')),
interval_count INTEGER NOT NULL DEFAULT 1
CHECK (interval_count > 0),
next_billing_at TIMESTAMPTZ,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (ends_at IS NULL OR ends_at > starts_at)
);
CREATE TABLE charges (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
subscription_id BIGINT REFERENCES subscriptions(id),
charge_type VARCHAR(20) NOT NULL
CHECK (charge_type IN ('one_time', 'recurring')),
description VARCHAR(500) NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
service_period_from TIMESTAMPTZ,
service_period_to TIMESTAMPTZ,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
invoice_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'invoiced', 'void')),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (
(charge_type = 'one_time' AND subscription_id IS NULL)
OR
(charge_type = 'recurring' AND subscription_id IS NOT NULL)
)
);
CREATE INDEX idx_subscriptions_due
ON subscriptions (next_billing_at)
WHERE status = 'active';
CREATE INDEX idx_charges_uninvoiced
ON charges (customer_id, occurred_at)
WHERE status = 'pending';
金额最好用最小货币单位的整数保存,例如人民币分或美元美分。amount_minor = 1099 表示 10.99,这样可以避免浮点数运算带来的舍入误差。如果业务需要比最小货币单位更高的精度,可以改用固定精度的 DECIMAL,但不要用 FLOAT 或 DOUBLE 保存金额。
即使可以从订阅表查到客户、金额、币种和描述,charges 仍应保存这些字段,不能只依赖实时关联。订阅以后可能改价或更换套餐,历史费用则必须保留生成时的信息。
防止重复生成 recurring charge
定时任务可能因超时、重启或多实例并发而重复执行。只做”先查询、再插入”并不可靠,应为每个订阅的计费周期设置数据库级唯一约束。
ALTER TABLE charges
ADD COLUMN billing_cycle_at TIMESTAMPTZ;
CREATE UNIQUE INDEX uq_recurring_charge_cycle
ON charges (subscription_id, billing_cycle_at)
WHERE subscription_id IS NOT NULL;
生成周期性费用时:
- 找出
next_billing_at <= current_timestamp的有效订阅。 - 锁定订阅记录。
- 按当前
next_billing_at创建费用。 - 将
next_billing_at推进到下一个周期。 - 在同一个事务中提交这两项修改。
示意代码如下:
BEGIN;
SELECT *
FROM subscriptions
WHERE id = :subscription_id
AND status = 'active'
AND next_billing_at <= CURRENT_TIMESTAMP
FOR UPDATE;
INSERT INTO charges (
customer_id,
subscription_id,
charge_type,
description,
amount_minor,
currency,
service_period_from,
service_period_to,
billing_cycle_at,
occurred_at
)
VALUES (
:customer_id,
:subscription_id,
'recurring',
:description,
:amount_minor,
:currency,
:period_start,
:period_end,
:billing_cycle_at,
CURRENT_TIMESTAMP
)
ON CONFLICT DO NOTHING;
UPDATE subscriptions
SET next_billing_at = :next_billing_at,
updated_at = CURRENT_TIMESTAMP
WHERE id = :subscription_id;
COMMIT;
这里的 :billing_cycle_at 是本次原定的计费时间,不是任务实际运行的时间。即使任务晚执行了几个小时,计费周期的标识也不会改变。
ON CONFLICT DO NOTHING 是 PostgreSQL 语法。在 MySQL、SQL Server 或 Oracle 中,需要换成相应的幂等写入方式。
不要简单地把日期加一个月
按月订阅需要处理月底问题。例如,直接给 1 月 31 日加一个月,不同数据库和日期库可能会得到不同结果。
业务应先明确以下规则:
- 每月固定在哪一天计费;
- 如果当月没有该日期,是否改为当月最后一天;
- 后续月份是否仍按原始账单日计算。
因此,可以在订阅表中额外保存原始账单日和时区:
ALTER TABLE subscriptions
ADD COLUMN billing_day SMALLINT,
ADD COLUMN billing_timezone VARCHAR(64);
不要以上一次调整后的日期为基础继续递增。否则,1 月 31 日调整为 2 月 28 日后,后续账单日可能会一直变成每月 28 日。更可靠的方式是根据订阅起始日期、原始账单日和周期序号计算下一次时间。
如果任务停运一段时间后重新启动,还要决定是否补齐遗漏的周期。通常应循环生成所有已经到期的周期,而不是只生成一笔费用后直接把日期跳到未来:
while next_billing_at <= now:
generate_charge(next_billing_at)
next_billing_at = calculate_next_cycle(next_billing_at)
唯一约束能保证补偿过程即使重复执行,也不会为同一周期生成两笔费用。
Invoice 不应该只是实时汇总结果
如果每次查看历史账单时都重新执行 SUM(charges.amount_minor),后续的改价、作废或数据修正可能会改变已经发送给客户的账单。
invoice 应作为不能随意变化的业务记录保存:
CREATE TABLE invoices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
invoice_number VARCHAR(50) NOT NULL UNIQUE,
currency CHAR(3) NOT NULL,
period_from TIMESTAMPTZ NOT NULL,
period_to TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL
CHECK (status IN ('draft', 'issued', 'paid', 'void')),
total_minor BIGINT NOT NULL DEFAULT 0,
issued_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE invoice_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
charge_id BIGINT NOT NULL REFERENCES charges(id),
description VARCHAR(500) NOT NULL,
amount_minor BIGINT NOT NULL,
UNIQUE (charge_id)
);
ALTER TABLE charges
ADD CONSTRAINT fk_charges_invoice
FOREIGN KEY (invoice_id) REFERENCES invoices(id);
创建账单时,应在一个事务中完成以下操作:
- 创建
invoices记录。 - 锁定该客户所有符合账期且尚未开票的
charges。 - 将费用复制到
invoice_items。 - 计算并保存
total_minor。 - 将相关费用标记为
invoiced。 - 提交事务。
账期范围建议使用半开区间:
occurred_at >= :period_from
AND occurred_at < :period_to
例如,8 月账期可以写成 [2026-08-01 00:00:00, 2026-09-01 00:00:00)。这种写法不依赖”当月最后一秒”的精度,也可以避免边界记录被重复计入账单。
取消、退款和历史修正
已经开票的费用通常不应直接修改或删除,更合适的做法是追加记录:
- 取消尚未开票的费用时,将状态改为
void。 - 冲销已经开票的费用时,创建金额为负数的 adjustment 或 credit charge。
- 退款单独记录为 refund,并关联原始支付或费用。
- 订阅改价只修改未来的计费规则,不回写历史 charge。
如果系统还要处理税费、折扣、退款和支付,可以继续拆分:
Subscriptions -> 产生费用规则
Charges -> 应收费用
Invoices -> 对客户出具的账单
Payments -> 实际收款
Refunds -> 实际退款
Credits -> 贷项或余额调整
这些概念不能只靠一个 status 字段混在一起。“费用已开票”不等于”账单已支付”,“退款成功”也不表示原始费用从未发生。
对现有设计的迁移方式
不必一次重写整个 billing system,可以分阶段迁移:
- 新建统一的
charges表,把StandardCharges数据迁入,并标记为one_time。 - 把
RecurringCharges迁入subscriptions。 - 为订阅生成的费用填写
subscription_id、billing_cycle_at,并将类型设为recurring。 - 增加周期唯一索引和事务锁,保证定时任务可以幂等执行。
- 增加
invoices和invoice_items,不再依赖历史数据的动态汇总。 - 让新旧流程并行运行,并核对一个或多个完整账期,确认无误后再停用旧表。
最终设计仍然会统一存储所有已经发生的 charges,但 Charges 不再负责订阅调度。表的数量不是重点。未来的计费规则、已经发生的费用、已经出具的账单和实际收款应分别建模,才能保证并发安全和历史可追溯,也便于以后处理取消订阅、改价、补扣与退款。
备注:内容仅供参考。
