如何在 database 中正确维护 SSOT?
结论
如果 UNPAID、PAID、CANCELLED 都能根据支付数据准确推导,那么单独保存当前 order_status 的确会造成数据冗余。这种设计并非一定错误,但会增加数据不一致的风险。
通常可以这样处理:
- 把支付记录作为事实数据和支付状态的权威来源。
- 用普通视图集中封装状态计算。
- 只有需要记录状态变更历史、支持人工干预或处理独立业务流程时,才保留
order_status。 - 查询量非常大时,再考虑物化视图、缓存或冗余列,并通过事务保证数据同步。
设计表结构前,还要先明确一个业务问题:订单取消和支付撤销是不是同一件事?在很多系统里,未付款的订单也可以取消,支付撤销则可能只是退款,并不表示订单已经取消。因此,CANCELLED 通常不能只根据 payment_cancellation 推导。
先确定什么是事实,什么是派生状态
SSOT 并不要求所有信息都放在一张表里。它要求同一个业务事实有且只有一个明确的权威来源。
例如:
payment表记录发生过的支付。payment_cancellation表记录某笔支付被撤销。- 订单当前是否已支付,可以根据前两项计算。
- 用户主动取消订单是另一个独立事实,通常由订单表或订单事件表记录。
所以,首先要为每个状态写出准确的业务定义。原问题中的判断规则至少依赖以下前提:
payment表只保存成功入账的支付,不保存PENDING、FAILED等支付尝试。- “最后一笔支付”有稳定且唯一的排序规则。
- 最后一笔支付被撤销,就表示整个订单已经取消。
- 即使一个订单存在多笔支付,也不需要计算累计实付金额。
payment_cancellation表示完整撤销,而非部分退款。
只要有一个前提不成立,单凭“是否存在 payment”就无法判断订单状态。
实际系统通常还需要在 payment 中保存支付状态和金额,例如:
CREATE TYPE payment_state AS ENUM (
'PENDING',
'SUCCEEDED',
'FAILED',
'CANCELLED',
'REFUNDED'
);
CREATE TABLE payment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES customer_order(id),
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
state payment_state NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
有了这些字段,系统才能区分“创建过支付请求”和“订单已经实际付款”。
另外,user 和 order 容易与 SQL 关键字或语法冲突。PostgreSQL 虽然可以用双引号处理,但从长期维护来看,使用 app_user、customer_order 等名称更合适。
order_status 是否冗余
如果表中只保存一条可以实时计算出来的当前状态,例如:
order_id | status
---------+--------
1001 | PAID
那么这条状态就是派生数据。每次写入、撤销或删除支付记录时,都必须同步修改它,否则可能出现以下情况:
- 已有成功支付,状态仍是
UNPAID。 - 支付已经撤销,状态仍是
PAID。 - 状态是
CANCELLED,却找不到对应的业务事实。 - 并发请求分别更新支付和状态,最后只有一项更新成功。
冗余字段不一定会破坏 SSOT,但系统必须明确两者的关系:支付记录是权威数据,order_status 只是同步维护的投影。应用程序不能把它们当作两个独立且同等可信的数据来源。
如果 order_status 与订单是 1-n 关系,它也可以用来保存状态历史:
order_id | status | changed_at
---------+-----------+-----------------------
1001 | UNPAID | 2026-08-01 10:00:00
1001 | PAID | 2026-08-01 10:05:00
1001 | CANCELLED | 2026-08-02 09:00:00
这时,表中记录的不只是当前状态,还包括状态发生变化的时间。如果系统需要审计、客服查询、统计状态持续时间或事件回放,这张表就有独立价值。
不过,order_status_history 或 order_event 会是更准确的名称,也能避免开发者误以为它只保存订单当前状态。
使用视图集中计算状态
如果状态能够根据现有关系可靠推导,可以先使用普通视图。视图不会额外保存数据,因此没有同步问题,也能避免在各处业务代码中重复编写复杂的 JOIN。
下面的示例严格按照“最后一笔支付是否被撤销”来计算状态:
CREATE VIEW order_with_status AS
SELECT
o.*,
CASE
WHEN last_payment.id IS NULL THEN 'UNPAID'
WHEN last_payment.is_cancelled THEN 'CANCELLED'
ELSE 'PAID'
END AS status
FROM customer_order AS o
LEFT JOIN LATERAL (
SELECT
p.id,
EXISTS (
SELECT 1
FROM payment_cancellation AS pc
WHERE pc.payment_id = p.id
) AS is_cancelled
FROM payment AS p
WHERE p.order_id = o.id
ORDER BY p.created_at DESC, p.id DESC
LIMIT 1
) AS last_payment ON true;
查询时只需要:
SELECT id, user_id, status
FROM order_with_status
WHERE id = $1;
这里同时按 created_at 和 id 排序,避免两笔支付时间相同时无法稳定判断哪一笔是“最后一笔”。如果业务中已有支付序列号,应优先使用该字段。
如果 payment_cancellation 与 payment 确实是 1-1 关系,应在数据库中添加约束,而不能只把规则写在设计文档里:
CREATE TABLE payment_cancellation (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payment_id bigint NOT NULL UNIQUE REFERENCES payment(id),
cancelled_at timestamptz NOT NULL DEFAULT now()
);
UNIQUE (payment_id) 可以保证一笔支付最多对应一条撤销记录。
查询性能不等于 JOIN 数量
JOIN 多并不代表查询一定很慢。只要查询方式合理,并为外键列建立适当的索引,PostgreSQL 通常可以高效执行这类查询。真正影响性能的是数据量、过滤条件、返回的订单数量和执行计划,而不是连接了几张表。
针对上面的查询,可以建立以下索引:
CREATE INDEX payment_order_latest_idx
ON payment (order_id, created_at DESC, id DESC);
CREATE UNIQUE INDEX payment_cancellation_payment_idx
ON payment_cancellation (payment_id);
如果第二个索引已经由 UNIQUE 约束创建,就不必重复建立。
如果只需要判断记录是否存在,优先使用 EXISTS,不必连接并返回所有支付行:
SELECT
o.id,
CASE
WHEN EXISTS (
SELECT 1
FROM payment AS p
WHERE p.order_id = o.id
AND p.state = 'SUCCEEDED'
)
THEN 'PAID'
ELSE 'UNPAID'
END AS status
FROM customer_order AS o
WHERE o.id = $1;
是否需要优化,应根据实际数据和 EXPLAIN (ANALYZE, BUFFERS) 的结果判断,不能只看表的数量。
什么时候可以保存冗余状态
如果订单列表规模很大,状态计算复杂,而且读取频率远高于写入频率,可以保存当前状态,但要把它当作受控冗余处理。例如,在 customer_order 中增加 payment_status:
ALTER TABLE customer_order
ADD COLUMN payment_status text NOT NULL DEFAULT 'UNPAID';
支付记录和订单状态必须在同一个数据库事务中更新:
BEGIN;
INSERT INTO payment (order_id, amount, state)
VALUES ($1, $2, 'SUCCEEDED');
UPDATE customer_order
SET payment_status = 'PAID'
WHERE id = $1;
COMMIT;
这样可以避免出现支付已经写入、订单状态却没有更新的半完成结果。不过,其他代码仍有可能绕过这条写入路径。对一致性要求较高时,可以使用数据库触发器、限制写入权限或统一存储过程,但维护成本也会随之增加。
读取密集型场景还可以使用物化视图:
CREATE MATERIALIZED VIEW order_status_snapshot AS
SELECT ...
物化视图不是实时数据,必须执行 REFRESH MATERIALIZED VIEW 才能更新。如果页面需要立即反映支付变化,它通常不适合作为唯一的数据来源。
更稳妥的建模建议
订单生命周期和支付生命周期最好分开建模:
- 订单状态:
DRAFT、CONFIRMED、FULFILLED、CANCELLED。 - 支付状态:
UNPAID、PARTIALLY_PAID、PAID、REFUNDED。 - 支付尝试状态:
PENDING、SUCCEEDED、FAILED、CANCELLED。
订单取消不一定伴随支付撤销,支付退款也不等于订单取消。如果把这些概念压缩成一个三值 status,初期虽然简单,但以后加入部分付款、支付重试、退款、拒付或多币种支付时,很难继续扩展。
初始阶段可以保留规范化的事实表,通过视图计算支付状态,并建立必要的索引。等实际执行计划证明计算成本无法接受后,再引入冗余状态、物化视图或缓存。这样既能明确权威数据来源,也能避免过早承担数据同步和一致性维护的成本。
备注:内容仅供参考。
