这个金额从哪来:快照、价格版本与凭证链
客户投诉:上个月的订单详情页显示的金额,和当时付款的金额不一样。排查发现订单行只存了商品 id 和数量,详情页按商品的当前价格现算。商品调过一次价,所有历史订单的金额都跟着变了。
一个金额出现在订单、发票、报表上,背后总有一条来历:它由哪个价格算出来,那个价格在什么时候有效,谁在什么时候确认了它。只存结果,来历就丢了;只存引用,结果就会随着被引用的东西变化。业务数据里有大量这样的「派生值」,建模时要决定的,是每个值应该引用、复制还是引用一个不会变的版本。
本文用订单、价格、报价和发票四个对象,在 PostgreSQL 18 上比较几种存法各自能回答哪些问题。实验见文末。
一、引用当前值:历史跟着现在变
10 月 1 日下单 o-1(A×2、B×1),当时合计 230.00;10 月 20 日 A 调价到 120 后再查这张历史订单:270.00订单行只引用商品,金额按当前价格计算。这在「订单还在购物车里」时是正确的:购物车本来就应该反映当前价格。下单之后就不对了:下单是一个已经发生的事实,它成交的价格不应该再变。
判断的标准是这个值的语义是「现在的」还是「当时的」。购物车里的价格、商品详情页的价格是「现在的」,引用就对;订单的成交价、发票的金额、工资单的税率是「当时的」,引用当前值就是错的。同一个字段在一个对象的不同阶段可能语义不同:购物车转成订单的那一刻,价格的语义就从「现在的」变成了「当时的」,这正是需要复制或固定版本的时机。
二、快照:值保住了,来历没有
下单时把单价抄进订单行,同样调价后 o-2 的合计:230.00;但快照只有一个数字,问「这个 100 是标价、会员价还是促销价」答不上来快照是最常用的修法:下单时把成交单价复制到订单行。之后商品怎么调价,订单都不受影响。
快照的局限是它只保存了结果。客服要回答「为什么这一单比别人便宜」,财务要核对「促销活动一共让利多少」,都需要知道这个单价是怎样来的:标价、会员价、哪一个促销、有效期是什么。只有一个数字,这些都答不上来。可以在快照里多存几个字段(价格类型、促销编号),但这些字段本身又是另一份快照,和真正的价格规则之间没有约束。
三、价格版本:引用一个不会变的版本
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);给 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 …)价格不再是商品上的一个可修改字段,而是一组按有效期排列的版本:调价是新增一个版本,而不是覆盖。订单行仍然保存成交单价(快照),同时引用产生它的那个版本(来历)。于是:
- 历史订单的金额不变;
- 能追溯到单价来自哪个价格、哪个有效期;
- 被引用的版本删不掉,来历不会断;
- 「某天的标价是多少」可以直接查询。
同类价格的有效期不能重叠,交给排他约束,原理见「同一时段不能重叠」守在哪里。既保存单价又引用版本看起来是冗余,但两者回答的是不同的问题:单价是「成交了多少」,版本是「为什么是这个数」。即使价格规则后来被证明算错了,成交了多少也不会因此改变,这正是要分开存的原因。
四、报价:一个带有效期的承诺
q-1 报价 200、有效期至 10 月 10 日:10 月 9 日接受 → 按报价 200.00 成交;10 月 12 日接受 → 报价已过期,需重新报价报价是另一种来历:它不是从价格表算出来的当前值,而是一个在有效期内有效的承诺。客户在有效期内接受,就按报价成交,哪怕价格表已经变了;过了有效期,报价就失效,需要重新报价。
建模时,报价是一个独立的概念,有自己的有效期和状态(有效、已接受、已过期),订单由「接受报价」这个动作创建,并引用报价。如果把报价当成「订单的草稿」、接受时再按当前价格重算,报价的承诺就失去了意义。时间语义的更多讨论见计划、发生与记录三个时间。
五、凭证链:下游知道自己来自上游的哪个版本
开票后订单被改成 200(版本 2):发票仍是 230;按「发票记录的订单版本 ≠ 订单当前版本」能查出不一致的发票 1 张
加上「已开票的订单金额不可改」的触发器后再改:被拒绝(订单 o-4 已开票,不能修改金额,请开红字发票或更正单)发票的金额来自订单,订单的金额来自价格。这是一条凭证链:每一环都应该能说出它来自上一环的哪个状态。只存 order_id,订单被改之后,发票与订单对不上,但说不清是哪一方变了、什么时候变的。发票记录订单的版本号,不一致就可以被查询出来。
更进一步,有些数据一旦被下游使用,就不应该再被修改。已开票的订单金额要改,正确的做法是开红字发票冲销、再开新的发票,或者生成一张更正单;直接修改原订单会让已经发出的凭证失去依据。这条规则首先应该在模型里:订单在「已开票」状态下没有修改金额的方法,只有「申请更正」。数据库触发器是兜底,防止有人绕过模型直接写表。
六、怎样选
| 数据的语义 | 存法 | 例子 |
|---|---|---|
| 永远是「现在的」 | 引用 | 购物车里的价格、商品详情 |
| 「当时的」,不需要追溯来源 | 快照 | 收货地址、下单时的商品名称 |
| 「当时的」,需要追溯来源或核对规则 | 快照 + 引用不可变的版本 | 成交单价与价格版本、税率与税率版本 |
| 有期限的承诺 | 独立概念,带有效期和状态 | 报价、优惠券、预授权 |
| 下游凭证 | 记录来源的 id 与版本;被使用后上游冻结或走更正流程 | 发票、结算单、对账单 |
需要注意,版本化有成本:每个被版本化的概念都要回答「谁可以新增版本」「有效期能不能修改」「当前版本怎样查询」。只有确实需要追溯的数据才值得版本化,收货地址这类数据用快照就够了。
七、测试要证明什么
- 调价不影响历史:下单、调价、再读订单,金额不变。
- 来源可追溯:从订单行能查到价格版本和有效期;被引用的版本不能删除。
- 有效期边界:报价在有效期最后一天接受成功,第二天失败;价格版本在切换当天取到新版本。
- 凭证冻结:已开票的订单不能修改金额,只能走更正流程。
八、检查清单
- 每个派生值先问:它的语义是「现在的」还是「当时的」。
- 「当时的」值在状态转换(下单、确认、开票)的那一刻复制或固定版本。
- 需要追溯来源时,引用不可变的版本,而不是可修改的当前值。
- 版本的有效期不重叠,被引用的版本不能删除。
- 报价、优惠这类承诺是带有效期和状态的独立概念。
- 下游凭证记录上游的版本;上游被使用后冻结,修改走更正流程。
配套实验
- codesphere-labs/ddd/data-provenance-snapshots:PostgreSQL 18.6、JDK 25.0.4.1:引用当前价格、成交价快照、价格版本与排他约束、报价有效期、发票记录订单版本与冻结触发器(验证记录)
参考资料
- Martin Fowler,Temporal Patterns(Temporal Property、Effectivity、Snapshot)
- Martin Fowler,《Analysis Patterns: Reusable Object Models》,Accounting 与 Planning 相关章节
- PostgreSQL 18 文档:Range Types