PAYATHON 2026

如何设计用于跟踪用户每月付款的 database structure?

支付老李

结论

不需要预先创建一张包含所有月份的表,也不要为每个月新增字段或单独建表。

通常可以这样设计:

  • users 表保存用户信息。
  • payments 表保存每笔付款。
  • users 与 payments 之间是一对多关系。
  • 付款所属月份由日期字段确定,不需要预先维护月份列表。

如果业务还要记录某个用户在某个月是否应付款、是否欠费,再增加按月生成的账单表。

基础结构:一笔付款一条记录

常见的表结构如下:

CREATE TABLE users (
    user_id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE payments (
    payment_id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    paid_at DATE NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

payments.user_id 指向付款用户,paid_at 记录实际付款日期。用户在不同月份付款时,直接插入新的付款记录即可,无须修改表结构。

例如:

payment_id | user_id | paid_at   | amount | status
-----------+---------+-----------+--------+---------
1          | 101     | 2010-01-05| 100.00 | paid
2          | 101     | 2010-02-08| 100.00 | paid
3          | 102     | 2010-02-10| 150.00 | paid

同一个用户也可以在一个月内留下多条付款记录,因此这种设计能处理补款、分期付款和退款等情况。

如何表示付款所属月份

一般可以直接从 paid_at 中查询年份和月份,无须单独建立月份表。

如果每笔付款必须归入明确的账期,而且付款时间可能与账期不同,可以增加账期字段:

CREATE TABLE payments (
    payment_id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    billing_month DATE NOT NULL,
    paid_at TIMESTAMP,
    amount DECIMAL(12, 2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

billing_month 可以统一保存为当月第一天,例如:

2010-01-01
2010-02-01
2010-03-01

这个日期表示账期,并非实际付款日。实际付款时间仍保存在 paid_at 中。

如果每名用户每个月只能有一条付款记录,可以增加唯一约束:

UNIQUE (user_id, billing_month)

如果允许一个月内分多次付款,则不应添加这个约束。可以让多笔付款共同关联一张月度账单。

需要跟踪欠费时,使用账单表

只保存付款记录会产生一个问题:某个月没有记录,既可能表示用户欠费,也可能表示该用户当月不需要付款。

要区分这两种情况,可以将”应付款”与”实际付款”分开存储:

CREATE TABLE monthly_invoices (
    invoice_id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    billing_month DATE NOT NULL,
    amount_due DECIMAL(12, 2) NOT NULL,
    due_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    UNIQUE (user_id, billing_month)
);

CREATE TABLE payments (
    payment_id BIGINT PRIMARY KEY,
    invoice_id BIGINT NOT NULL,
    paid_at TIMESTAMP NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    FOREIGN KEY (invoice_id) REFERENCES monthly_invoices(invoice_id)
);

表之间的关系变为:

users 1 ─── N monthly_invoices 1 ─── N payments

通过这套结构,可以记录:

  • 用户某个月应付的金额;
  • 账单到期日;
  • 账单处于未付款、部分付款、已付款还是逾期状态;
  • 一张账单是否由多笔付款结清;
  • 实际付款时间是否与所属账期不同。

月度账单既可以由定时任务按月生成,也可以在需要时创建。增加新的月份只需新增数据,无须修改数据库结构。

是否需要月份表

多数系统不需要月份表,因为年份和月份可以从日期字段中计算。

只有当月份本身包含业务属性时,单独建立账期表才有意义,例如:

CREATE TABLE billing_periods (
    period_id BIGINT PRIMARY KEY,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    due_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL
);

这种表适合统一管理账期的开放、关闭、结算或特殊截止日期。如果只是为了列出从 2010 年至今的月份,通常没有必要建表。

查询与索引

查询往往会按用户、账期或付款时间筛选,可以考虑建立以下索引:

CREATE INDEX idx_payments_user_paid_at
    ON payments (user_id, paid_at);

CREATE INDEX idx_invoices_user_month
    ON monthly_invoices (user_id, billing_month);

最终应根据实际查询方式选择索引,而不是看到字段就全部建立索引。

注意事项

  • 金额应使用 DECIMAL 或数据库提供的其他精确金额类型,不要使用 FLOAT。
  • “实际付款时间”和”付款所属账期”应分别记录,因为两者未必相同。
  • 退款、失败付款和撤销记录通常不应直接删除。可以使用状态字段或独立的交易记录保留审计轨迹。
  • 如果系统支持多种货币,需要增加 currency 字段,不能只记录金额。
  • 不要设计 payment_2010_01、payment_2010_02 这样的列,也不要每个月新建一张表。
  • 如果历史数据量很大,可以在确认存在性能问题后考虑按日期分区。分区是物理存储层面的优化,不应改变”一笔业务记录一行”的逻辑模型。

如果系统只需要记录用户的实际付款,使用 users 和 payments 两张表即可。如果还要准确区分未付款、部分付款和逾期状态,则更适合采用 users、monthly_invoices、payments 三层结构。

备注:内容仅供参考。