订阅制 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。删除用户时,更常见的处理方式是匿名化个人信息,同时保留交易记录。
设计时应让每张表只记录一种独立事实:订单记录“谁买了什么”,支付记录“钱怎么流转”,订阅记录“谁在什么时间拥有何种权益”。只要字段表示的是不同事实,即使它们现在经常具有相同的值,也不是无意义的冗余。
备注:内容仅供参考。
