PAYATHON 2026

订阅制 e-commerce 应用的数据库设计与冗余问题

支付阿杰

结论

Subscriptions 不应把 user_id 和 payment_id 同时视为同一次购买的来源。

建议按以下方式划分职责:

  • Orders 记录谁发起了购买;
  • OrderItems 记录购买的是哪个订阅计划;
  • Payments 记录订单的支付尝试及结果;
  • Subscriptions 记录订阅的生命周期;
  • 通过 order_item_id 或单独的关联表,将订阅与产生它的交易关联起来。

通常不应在 Subscriptions 中直接保存 payment_id。一份订阅可能涉及首次付款、续费、补扣和退款等多笔支付,一笔订单也可能发生多次支付尝试。如果订阅表中只能保存一个 payment_id,原本的一对多关系就会被错误地压缩成一对一关系。

是否保留 Subscriptions.user_id,要看这个字段具体表示什么:

  • 如果表示“订阅权益属于谁”,应当保留,建议改名为 subscriber_id;
  • 如果只是重复表示“哪个用户创建了订单”,则可以删除,购买者可通过订单查询;
  • 如果购买者和权益使用者始终是同一个人,保留 subscriber_id 可以让模型更清楚,但需要通过约束或事务保证数据一致。

先区分几个不同概念

冗余往往不是因为 foreign key 太多,而是因为模型把不同的业务概念当成了同一种关系。

订单购买者

Orders.user_id 表示谁提交了订单并承担付款责任。更准确的名称可以是:

Orders.purchaser_id

订阅权益所有者

Subscriptions.subscriber_id 表示谁可以访问 premium content。

购买者不一定就是订阅者,例如:

  • 用户购买订阅后赠送给另一个用户;
  • 企业管理员为员工购买订阅;
  • 家庭套餐由一个人付款,多个人使用;
  • 客服补发订阅,且没有对应的用户订单。

所以,即使 Orders.purchaser_id 和 Subscriptions.subscriber_id 目前总是相同,它们在业务上仍可能表示两个不同的属性。

支付记录

Payments 表示资金交易或支付尝试,不适合作为订阅权益归属的依据,因为支付可能失败、重试、部分退款或被撤销。

订阅状态也不能由某一笔支付的状态直接决定。例如,某次续费失败后,订阅可能进入宽限期,而不是立即失效。

推荐的关系结构

可以采用下面这套基础模型:

Users
  ├──< Orders
  └──< Subscriptions

SubscriptionPlans
  ├──< OrderItems
  └──< Subscriptions

Orders
  ├──< OrderItems
  └──< Payments

OrderItems
  └──< Subscriptions

Subscriptions
  └──< SubscriptionCharges >── Payments

各表分别负责:

  • SubscriptionPlans:定义可销售的套餐;
  • Orders:保存订单级信息和购买者;
  • OrderItems:保存订单中购买的具体商品或套餐;
  • Payments:保存支付尝试和支付结果;
  • Subscriptions:保存权益归属、有效期及订阅状态;
  • SubscriptionCharges:在需要时关联订阅周期与支付记录。

如果一个订单项只会生成一份订阅,可以在 Subscriptions.order_item_id 上增加 UNIQUE 约束。如果购买数量可能大于 1,则不能增加这个约束。

DDL 示例

以下示例使用接近 PostgreSQL 的语法,字段类型和约束需要根据实际数据库调整。

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email VARCHAR(320) NOT NULL UNIQUE
);

CREATE TABLE subscription_plans (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    billing_interval VARCHAR(20) NOT NULL,
    price_amount NUMERIC(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    CHECK (price_amount >= 0)
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    purchaser_id BIGINT NOT NULL REFERENCES users(id),
    status VARCHAR(20) NOT NULL,
    currency CHAR(3) NOT NULL,
    total_amount NUMERIC(12, 2) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (total_amount >= 0)
);

CREATE TABLE order_items (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    subscription_plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
    quantity INTEGER NOT NULL DEFAULT 1,
    unit_price NUMERIC(12, 2) NOT NULL,
    CHECK (quantity > 0),
    CHECK (unit_price >= 0)
);

CREATE TABLE payments (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    status VARCHAR(20) NOT NULL,
    amount NUMERIC(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    provider VARCHAR(50),
    provider_transaction_id VARCHAR(255),
    paid_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (amount >= 0),
    UNIQUE (provider, provider_transaction_id)
);

CREATE TABLE subscriptions (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscriber_id BIGINT NOT NULL REFERENCES users(id),
    subscription_plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
    originating_order_item_id BIGINT REFERENCES order_items(id),
    status VARCHAR(20) NOT NULL,
    current_period_start TIMESTAMP WITH TIME ZONE NOT NULL,
    current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
    canceled_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (current_period_end > current_period_start)
);

originating_order_item_id 允许为空,因为试用、赠送、数据迁移或后台补发的订阅可能没有普通订单。如果业务规定所有订阅都必须经过购买,则可以将其设为 NOT NULL。

多次续费如何关联

如果只需要从支付记录追溯订单,使用 Payments.order_id 就足够了,无须再让订阅引用支付。

如果需要准确查询“哪笔支付覆盖了哪个订阅周期”,可以增加一张关联表:

CREATE TABLE subscription_charges (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscription_id BIGINT NOT NULL REFERENCES subscriptions(id),
    payment_id BIGINT NOT NULL REFERENCES payments(id),
    period_start TIMESTAMP WITH TIME ZONE NOT NULL,
    period_end TIMESTAMP WITH TIME ZONE NOT NULL,
    amount_applied NUMERIC(12, 2) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (period_end > period_start),
    CHECK (amount_applied >= 0),
    UNIQUE (subscription_id, payment_id, period_start)
);

这张表可以表示:

  • 一份订阅经历多次续费;
  • 一笔支付覆盖一份或多份订阅;
  • 一笔支付只承担订单金额的一部分;
  • 退款后仍保留原始交易关系。

如果系统可以保证一笔支付只对应一个订单,而且订单内的所有订阅都按整单支付,这张关联表可以暂时省略。

是否删除 Subscriptions.user_id

不能因为某个用户 ID 可以通过 join 查到,就直接认定该字段违反规范化原则。判断依据是两个字段是否表示同一事实。

如果订阅者必然等于订单购买者,可以采用完全规范化的设计:

SELECT o.purchaser_id
FROM subscriptions AS s
JOIN order_items AS oi
  ON oi.id = s.originating_order_item_id
JOIN orders AS o
  ON o.id = oi.order_id
WHERE s.id = :subscription_id;

在这种设计下,删除 Subscriptions.subscriber_id 可以防止订单用户被修改后产生数据不一致,但也会带来一些限制:

  • 无法自然支持赠送订阅;
  • 无法表示没有订单的试用或补发;
  • 每次鉴权都需要跨表查询;
  • 购买关系和权益归属会被强制绑定。

对于 premium content 应用,订阅本身是核心业务实体,因此直接保存 subscriber_id 通常更合适。这个字段表示权益归属,并不是对订单购买者的简单缓存。

查询用户当前有效的订阅也会更直接:

SELECT s.*
FROM subscriptions AS s
WHERE s.subscriber_id = :user_id
  AND s.status IN ('active', 'trialing')
  AND s.current_period_start <= CURRENT_TIMESTAMP
  AND s.current_period_end > CURRENT_TIMESTAMP;

避免数据不一致

如果当前业务要求购买者和订阅者必须相同,应在创建订单和订阅的同一个事务中完成校验,而不是依靠开发人员自行遵守这条规则:

BEGIN;

-- 确认订单已成功支付,并读取购买者和套餐信息。
SELECT o.purchaser_id, oi.subscription_plan_id
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE oi.id = :order_item_id
  AND o.status = 'paid'
FOR UPDATE;

-- 使用查询到的 purchaser_id 创建订阅。
INSERT INTO subscriptions (
    subscriber_id,
    subscription_plan_id,
    originating_order_item_id,
    status,
    current_period_start,
    current_period_end
)
VALUES (
    :purchaser_id,
    :plan_id,
    :order_item_id,
    'active',
    :period_start,
    :period_end
);

COMMIT;

普通 foreign key 只能保证被引用的记录存在,不能保证 subscriptions.subscriber_id 与关联订单的 purchaser_id 相同。如果必须在数据库层强制执行这条跨表规则,可以使用触发器。不过通常更适合通过清晰的服务层事务维护一致性,以免触发器隐藏业务逻辑。

还需要注意的事项

不要从当前套餐价格推导历史订单金额

套餐价格可能发生变化。OrderItems.unit_price 应保存下单时的价格快照,不能只保存 subscription_plan_id,再实时读取套餐的当前价格。

不要把支付成功等同于订阅有效

支付和订阅有各自的状态机:

Payment: pending -> succeeded -> refunded
Subscription: trialing -> active -> past_due -> canceled -> expired

二者相互关联,但并不是同一个状态。退款后是否立即终止权益,扣款失败后是否进入宽限期,都应由具体业务规则决定。

支付记录通常不能被覆盖

支付重试应创建新的 Payments 记录,而不是反复修改同一笔失败记录。只有保留每次尝试,才能形成完整的审计和对账信息。

外部支付标识应具有幂等约束

支付平台可能重复推送 webhook。可以为 provider 和 provider_transaction_id 建立唯一约束,防止同一支付结果被重复处理。

历史记录不宜级联删除

订单、支付和订阅通常需要保留,以供对账和审计使用,因此应谨慎对这些表使用 ON DELETE CASCADE。删除用户时,更常见的处理方式是匿名化个人信息,同时保留交易记录。

设计时应让每张表只记录一种独立事实:订单记录“谁买了什么”,支付记录“钱怎么流转”,订阅记录“谁在什么时间拥有何种权益”。只要字段表示的是不同事实,即使它们现在经常具有相同的值,也不是无意义的冗余。

备注:内容仅供参考。