绝大多数计费 bug 不是算错了钱,而是身份问题:这次请求到底该扣谁的余额。身份模型一旦设计歪,下游每张表都会长出第二个可空列和一个 if。
这篇讲一套三层账户与用户模型的设计——法律实体 → 账户 → 用户——重点是那条听起来很武断、但看过它挡住的事故之后就很难反对的规则:每个用户必须且只属于一个账户,且这层归属永不可变。
有意思的是,这个设计我们在两个月前刚否决过。后来改变的不是 schema,而是一条产品规则。
结论先行
- 三层:法律实体 → 账户 → 用户。钱包只挂在账户层。
- 独立个人拿到的是个人账户,而不是「没有账户」。”个人”的含义是没有公司,不是没有账户。
account_id与account_type不可变。整个设计的安全性都建立在这一条上。- 几乎所有不变量都是声明式的——复合外键、部分唯一索引、CHECK。只有「不可变」需要触发器,因为 SQL 没有办法表达「这一列永不更新」。
- 由其他表推导出来的列,应该由数据库来填,而不是每一个插入点。
- 当迁移移动的是数据而不是 schema 时,expand/contract 保护不了你。
三层结构
| 层级 | 表 | 是什么 |
|---|---|---|
| 法律实体 | customer.legal_entities |
签约主体——发票抬头 |
| 账户 | customer.customer_accounts |
计费单元。持有钱包,持有模型权限 |
| 用户 | customer.account_users |
一个人。有且只有一个账户 |
挂在这根主干上的有两样东西:
billing.wallets—— 归属账户authentication.api_credentials—— 同时带account_id(谁付钱)和user_id(谁发起)
这个区分很关键,也最容易被糊在一起:归因是谁发起了请求,结算是扣谁的余额。一家 20 个工程师的公司有 20 个归因、1 个结算。把它们放在两个列上,才可能既做到按人统计用量、又共用一个余额池。
方案一:让账户可以为空
最初的版本是 account_users.account_id NOT NULL——所有人都在某个账户里。只要有一个人想在没有公司的情况下使用平台,这就崩了。
修法看起来很自然:把 account_id 改成可空。没有账户的用户就是「独立用户」,直接持有自己的钱包。钱包表本来就同时建模了两种归属:
1 | CONSTRAINT wallets_exactly_one_owner_check |
有且仅有一个 owner——账户 XOR 用户。干净、对称,于是就上线了。
代价:所有东西都要挂两次
这个 XOR 在钱包表里很优雅,在其他所有地方都很贵。任何需要回答「这笔钱算谁的」的表,从此都要带两个可空列和一个分支。看我们的凭证表:
| 形态 | 行数 |
|---|---|
account_id 和 user_id 都有 |
240 |
只有 account_id |
52 |
只有 user_id |
10 |
302 行里的 10 行,撑起了整个双列结构。每一次 join、每一个查询、每一条解析付款方的代码路径,都要处理一个只覆盖 3% 数据的分支——而且很容易写错,因为「错的那条分支」通常返回空,而不是报错。
方案二:给每个人一个单人账户——被否决
显而易见的替代方案:给每个独立用户配一个只有他自己的账户,这样所有东西都统一挂在账户上。我们第一次评估时,把它失败的原因写了下来:
给每个独立用户一个单人账户看起来等价,但只要他接受了一次邀请就会崩:由于每个用户只有一行成员记录,加入另一个账户只能覆盖
account_id,于是单人账户变成零成员,却仍然持有装着他余额的钱包。billing.wallets.account_id是ON DELETE RESTRICT,这个账户连删都删不掉——钱被困住且不可达。
把它具体走一遍:
- Alice 有单人账户
A,钱包里有 200 美元,归属A。 - Alice 接受了公司账户
B的邀请。 - 每个用户只有一行成员记录(
PRIMARY KEY (user_id)),加入B只能覆盖account_id。 - 账户
A现在零成员——没有人能够到它——但它仍然持有 Alice 那 200 美元。 ON DELETE RESTRICT意味着你连删掉A来收尾都做不到。
200 美元装在一个没有把手的桶里。这是实打实的缺陷,也是当时「空账户」方案胜出的原因。
真正改变的是一条产品规则
两个月后我们采纳了这个被否决的设计。schema 层面的论证没有任何进步——变的是前提。
再看一遍那条失败路径。每一步都依赖第 2 步:Alice 接受邀请。整个场景成立的前提,是存在「个人 → 组织」的转换。
于是我们把它删掉了。产品规则变成:
个人用户永远不能加入组织。组织用户只可能在创建时就是组织用户——通过自助注册,或由组织管理员创建。
没有转换,就没有改挂;没有改挂,就没有孤儿账户;没有孤儿账户,就没有被困住的钱。这条反对意见不是被缓解,而是被消除了——第 2 到 5 步根本无法表达。
这一点值得推广:一个 schema 层面的反对意见,强度只等于你允许的操作集合。我们花了不少时间去找更聪明的 schema,而答案是删掉一个我们其实从未真正上线过的能力。
代价是真实的,应当说清楚:一个人无法同时拥有个人空间和公司成员身份。由于一个邮箱对应一个用户,他需要第二个邮箱。对 B2B API 平台这是可接受的;对消费级产品就不是。
把规则写进数据库
只活在应用代码里的规则等于建议。这里绝大多数是声明式的:
1 | -- 只有两种形态 |
上面第三条用的是 部分索引(partial index)——只对一部分行生效的唯一约束。要表达「这条规则只对个人账户成立」,这是最干净的写法。
有两个细节值得抄走:
复合外键。 把 account_type 反范式到成员行上,才使得那个部分唯一索引成为可能——索引只能看见一张表。通常反范式意味着漂移,但这里复合外键让漂移无法被表达:(account_id, account_type) 这一对必须存在于父表,所以成员行不可能声称一个它的账户并不具备的类型。
「每个用户至多一个个人账户」完全不需要约束。 它从 PRIMARY KEY (user_id) 里自然掉出来——每个用户一行成员记录,也就等于每个用户一个账户。最好的约束是你根本不用写的那条。
Postgres 唯一表达不了的东西
不可变性。没有 ALTER COLUMN … SET IMMUTABLE,而不可变性恰恰是这个设计的承重墙。所以用了两个 BEFORE UPDATE 触发器:
1 | CREATE TRIGGER account_users_membership_immutable |
在生产数据副本上验证过:
1 | UPDATE customer.account_users SET account_id = <org> WHERE account_type = 'personal'; |
这条报错信息就是设计本身。当初被担心的失败路径,现在连在 SQL 提示符里敲出来都不行。
推导列属于数据库
account_users.account_type 是所属账户类型的缓存。第一版要求每个 INSERT 都自己传——结果立刻打断了九个测试 fixture,以及未来每一个插入点,每一个都是一次写错值的机会。
普通 DEFAULT 救不了:它得去读另一张表。所以让数据库来填:
1 | CREATE OR REPLACE FUNCTION customer.fill_account_user_account_type() RETURNS trigger AS $$ |
调用方可以不传、并拿到正确的值;传了错的值,复合外键照样拒绝。经验法则:如果一个列是推导出来的,就让数据库去推导它。 把这件事推给调用方,等于把一个不变量变成 N 次违反它的机会。
测试抓到了、评审没抓到的四个问题
这次迁移是仔细评审过的。然后我们把集成测试跑在一份生产数据的分支拷贝上,抓出四个读代码没看出来的问题。
1. expand/contract 保护不了数据迁移。 计划很教科书,就是标准的 parallel change:先加、后删,中间放代码。保留 wallets.user_id 让现有读取继续工作——但这次迁移移动了数据。列还在,每一行的值都变成了 NULL,于是所有 WHERE user_id = $1 都匹配不到任何东西。
而且它是静默失败的——返回「没有钱包」,而不是报错。expand/contract 保护的是 schema 领先于代码的情况;当数据从一个语法依然正确的查询底下被搬走时,它什么也做不了。
2. 两张表之外的一个唯一约束。 个人账户原本用人名命名,而它会被镜像进遗留表的 customers.name,那一列带 UNIQUE。于是第二个注册的「张伟」直接 409。现在改用邮箱命名,天然唯一。
3. SELECT 里少了一列。 新增的「这是不是个人用户」判断读了一个查询没有 select 的字段。它是 undefined,分支永远不触发,而 fallback 路径看起来还挺合理。类型检查抓不到:行类型上这个字段是存在的,而 SQL 字符串对编译器不透明。
4. lower(contact_email) 上的部分唯一索引。 把本人邮箱镜像进遗留客户表时,如果他已经是某家公司的账单联系人,就会撞车。
贯穿这四个问题的是:它们全都安静地失败,没有一个抛异常。同样的形态我在 Little’s Law 与 vLLM 自动扩缩容 里从另一个方向撞见过:那次的负载信号并不是算错了,而是安静地量错了东西——系统 100% 满载时它报告的负载反而更低,于是扩缩容老老实实地缩了容。一个语法正确但语义错误的查询会返回空集,而空集看起来和「本来就没数据」一模一样——这正是它们能活过评审、却在真实数据面前五分钟内暴露的原因。
几点收获
- 把归因和结算分开。 谁发起、扣谁的钱,是两个问题,给它们两个列。
- schema 层面的反对意见,强度只等于你允许的操作集合。 在为一个失败模式做工程设计之前,先看看能不能直接删掉引发它的那个能力。
- 优先选择从已有键里自然掉出来的约束。「每个用户至多一个个人账户」零成本,因为主键已经这么说了。
- 反范式要配复合外键。 它把漂移从「需要监控的东西」变成「无法表达的东西」。
- 推导列属于数据库。 N 个插入点就是 N 次写错的机会。
- 静默失败类 bug 需要真实数据。 这四个缺陷都是返回空结果而非报错,评审里一个都看不出来。
这次迁移创建了 10 个个人账户,把 10 个钱包改挂到账户名下,让 140 个用户各自恰好属于一个账户。如果你也在设计自己的账户与用户模型,值得抄走的不是这套 schema,而是那个问题:哪些操作你可以干脆不支持。其中最有价值的一行,是让某件事变得不可能的那一行。