Skip to content

这个金额从哪来:快照、价格版本与凭证链 ​

客户投诉:上个月的订单详情页显示的金额,和当时付款的金额不一样。排查发现订单行只存了商品 id 和数量,详情页按商品的当前价格现算。商品调过一次价,所有历史订单的金额都跟着变了。

一个金额出现在订单、发票、报表上,背后总有一条来历:它由哪个价格算出来,那个价格在什么时候有效,谁在什么时候确认了它。只存结果,来历就丢了;只存引用,结果就会随着被引用的东西变化。业务数据里有大量这样的「派生值」,建模时要决定的,是每个值应该引用、复制还是引用一个不会变的版本。

本文用订单、价格、报价和发票四个对象,在 PostgreSQL 18 上比较几种存法各自能回答哪些问题。实验见文末。

10 月 1 日下单 A×2、B×1;10 月 20 日 A 从 100 调到 120引用当前价格历史订单变成 270「现在的」才适用快照单价仍是 230说不出来源快照 + 价格版本230,来自版本 #3版本删不掉报价有效期内按 200 成交过期需重新报价价格版本(有效期不重叠,排他约束)LIST 100 [01-01, 10-20)LIST 120 [10-20, ∞)PROMO 90[10-01, 10-08):o-3 在 10 月 5 日引用它凭证链价格版本 #3不可变订单 o-4版本 1 → 被改成 2发票 inv-1记录订单版本 1查出 1 张不一致触发器冻结后修改被拒判断顺序:这个值是「现在的」还是「当时的」?当时的,要不要追溯来源?被下游用过之后还能不能改?
图 1 · 实测(PostgreSQL 18.6):订单行只引用商品时,调价让一张 10 月 1 日的历史订单从 230 变成 270。快照保住了金额,却说不出单价来自哪个价格。订单行同时保存单价、引用不可变的价格版本,金额和来历都在,被引用的版本删不掉。发票记录订单的版本号,订单被改后能查出不一致;已开票的订单由模型和触发器冻结。

一、引用当前值:历史跟着现在变 ​

text
10 月 1 日下单 o-1(A×2、B×1),当时合计 230.00;10 月 20 日 A 调价到 120 后再查这张历史订单:270.00

订单行只引用商品,金额按当前价格计算。这在「订单还在购物车里」时是正确的:购物车本来就应该反映当前价格。下单之后就不对了:下单是一个已经发生的事实,它成交的价格不应该再变。

判断的标准是这个值的语义是「现在的」还是「当时的」。购物车里的价格、商品详情页的价格是「现在的」,引用就对;订单的成交价、发票的金额、工资单的税率是「当时的」,引用当前值就是错的。同一个字段在一个对象的不同阶段可能语义不同:购物车转成订单的那一刻,价格的语义就从「现在的」变成了「当时的」,这正是需要复制或固定版本的时机。

二、快照:值保住了,来历没有 ​

text
下单时把单价抄进订单行,同样调价后 o-2 的合计:230.00;但快照只有一个数字,问「这个 100 是标价、会员价还是促销价」答不上来

快照是最常用的修法:下单时把成交单价复制到订单行。之后商品怎么调价,订单都不受影响。

快照的局限是它只保存了结果。客服要回答「为什么这一单比别人便宜」,财务要核对「促销活动一共让利多少」,都需要知道这个单价是怎样来的:标价、会员价、哪一个促销、有效期是什么。只有一个数字,这些都答不上来。可以在快照里多存几个字段(价格类型、促销编号),但这些字段本身又是另一份快照,和真正的价格规则之间没有约束。

三、价格版本:引用一个不会变的版本 ​

sql
CREATE TABLE price_version (
    id SERIAL PRIMARY KEY, product_id TEXT REFERENCES product, price NUMERIC(10,2), kind TEXT, valid DATERANGE,
    EXCLUDE USING gist (product_id WITH =, kind WITH =, valid WITH &&));
CREATE TABLE order_line (order_id TEXT, product_id TEXT, qty INT,
    unit_price NUMERIC(10,2) NOT NULL, price_version_id INT REFERENCES price_version);
text
给 A 再加一个与现有标价重叠的版本:被拒绝(conflicting key value violates exclusion constraint …)
10 月 5 日下单 o-3,单价与来源:90.00 来自 PROMO 版本 #3 [2026-10-01,2026-10-08)
按日期回答「A 当天的标价」:10 月 5 日 100.00,10 月 25 日 120.00
删除已被订单引用的促销价版本:被拒绝(… violates foreign key constraint …)

价格不再是商品上的一个可修改字段,而是一组按有效期排列的版本:调价是新增一个版本,而不是覆盖。订单行仍然保存成交单价(快照),同时引用产生它的那个版本(来历)。于是:

  • 历史订单的金额不变;
  • 能追溯到单价来自哪个价格、哪个有效期;
  • 被引用的版本删不掉,来历不会断;
  • 「某天的标价是多少」可以直接查询。

同类价格的有效期不能重叠,交给排他约束,原理见「同一时段不能重叠」守在哪里。既保存单价又引用版本看起来是冗余,但两者回答的是不同的问题:单价是「成交了多少」,版本是「为什么是这个数」。即使价格规则后来被证明算错了,成交了多少也不会因此改变,这正是要分开存的原因。

四、报价:一个带有效期的承诺 ​

text
q-1 报价 200、有效期至 10 月 10 日:10 月 9 日接受 → 按报价 200.00 成交;10 月 12 日接受 → 报价已过期,需重新报价

报价是另一种来历:它不是从价格表算出来的当前值,而是一个在有效期内有效的承诺。客户在有效期内接受,就按报价成交,哪怕价格表已经变了;过了有效期,报价就失效,需要重新报价。

建模时,报价是一个独立的概念,有自己的有效期和状态(有效、已接受、已过期),订单由「接受报价」这个动作创建,并引用报价。如果把报价当成「订单的草稿」、接受时再按当前价格重算,报价的承诺就失去了意义。时间语义的更多讨论见计划、发生与记录三个时间。

五、凭证链:下游知道自己来自上游的哪个版本 ​

text
开票后订单被改成 200(版本 2):发票仍是 230;按「发票记录的订单版本 ≠ 订单当前版本」能查出不一致的发票 1 张
加上「已开票的订单金额不可改」的触发器后再改:被拒绝(订单 o-4 已开票,不能修改金额,请开红字发票或更正单)

发票的金额来自订单,订单的金额来自价格。这是一条凭证链:每一环都应该能说出它来自上一环的哪个状态。只存 order_id,订单被改之后,发票与订单对不上,但说不清是哪一方变了、什么时候变的。发票记录订单的版本号,不一致就可以被查询出来。

更进一步,有些数据一旦被下游使用,就不应该再被修改。已开票的订单金额要改,正确的做法是开红字发票冲销、再开新的发票,或者生成一张更正单;直接修改原订单会让已经发出的凭证失去依据。这条规则首先应该在模型里:订单在「已开票」状态下没有修改金额的方法,只有「申请更正」。数据库触发器是兜底,防止有人绕过模型直接写表。

六、怎样选 ​

数据的语义存法例子
永远是「现在的」引用购物车里的价格、商品详情
「当时的」,不需要追溯来源快照收货地址、下单时的商品名称
「当时的」,需要追溯来源或核对规则快照 + 引用不可变的版本成交单价与价格版本、税率与税率版本
有期限的承诺独立概念,带有效期和状态报价、优惠券、预授权
下游凭证记录来源的 id 与版本;被使用后上游冻结或走更正流程发票、结算单、对账单

需要注意,版本化有成本:每个被版本化的概念都要回答「谁可以新增版本」「有效期能不能修改」「当前版本怎样查询」。只有确实需要追溯的数据才值得版本化,收货地址这类数据用快照就够了。

七、测试要证明什么 ​

  • 调价不影响历史:下单、调价、再读订单,金额不变。
  • 来源可追溯:从订单行能查到价格版本和有效期;被引用的版本不能删除。
  • 有效期边界:报价在有效期最后一天接受成功,第二天失败;价格版本在切换当天取到新版本。
  • 凭证冻结:已开票的订单不能修改金额,只能走更正流程。

八、检查清单 ​

  • 每个派生值先问:它的语义是「现在的」还是「当时的」。
  • 「当时的」值在状态转换(下单、确认、开票)的那一刻复制或固定版本。
  • 需要追溯来源时,引用不可变的版本,而不是可修改的当前值。
  • 版本的有效期不重叠,被引用的版本不能删除。
  • 报价、优惠这类承诺是带有效期和状态的独立概念。
  • 下游凭证记录上游的版本;上游被使用后冻结,修改走更正流程。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权