PAYATHON 2026

如何在 database 中正确维护 SSOT?

支付阿杰

结论

如果 UNPAID、PAID、CANCELLED 都能根据支付数据准确推导,那么单独保存当前 order_status 的确会造成数据冗余。这种设计并非一定错误,但会增加数据不一致的风险。

通常可以这样处理:

  • 把支付记录作为事实数据和支付状态的权威来源。
  • 用普通视图集中封装状态计算。
  • 只有需要记录状态变更历史、支持人工干预或处理独立业务流程时,才保留 order_status。
  • 查询量非常大时,再考虑物化视图、缓存或冗余列,并通过事务保证数据同步。

设计表结构前,还要先明确一个业务问题:订单取消和支付撤销是不是同一件事?在很多系统里,未付款的订单也可以取消,支付撤销则可能只是退款,并不表示订单已经取消。因此,CANCELLED 通常不能只根据 payment_cancellation 推导。

先确定什么是事实,什么是派生状态

SSOT 并不要求所有信息都放在一张表里。它要求同一个业务事实有且只有一个明确的权威来源。

例如:

  • payment 表记录发生过的支付。
  • payment_cancellation 表记录某笔支付被撤销。
  • 订单当前是否已支付,可以根据前两项计算。
  • 用户主动取消订单是另一个独立事实,通常由订单表或订单事件表记录。

所以,首先要为每个状态写出准确的业务定义。原问题中的判断规则至少依赖以下前提:

  1. payment 表只保存成功入账的支付,不保存 PENDING、FAILED 等支付尝试。
  2. “最后一笔支付”有稳定且唯一的排序规则。
  3. 最后一笔支付被撤销,就表示整个订单已经取消。
  4. 即使一个订单存在多笔支付,也不需要计算累计实付金额。
  5. 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,初期虽然简单,但以后加入部分付款、支付重试、退款、拒付或多币种支付时,很难继续扩展。

初始阶段可以保留规范化的事实表,通过视图计算支付状态,并建立必要的索引。等实际执行计划证明计算成本无法接受后,再引入冗余状态、物化视图或缓存。这样既能明确权威数据来源,也能避免过早承担数据同步和一致性维护的成本。

备注:内容仅供参考。