多层账户与用户模型设计:为什么一个用户只能属于一个账户

三层账户与用户模型:法律实体 → 账户 → 用户。为什么先否决这个设计,两个月后又上线,以及四个只有真实数据才抓得到的静默失败 bug。

Posted by Jessie Jia on 2026-08-11

绝大多数计费 bug 不是算错了钱,而是身份问题:这次请求到底该扣谁的余额。身份模型一旦设计歪,下游每张表都会长出第二个可空列和一个 if

这篇讲一套三层账户与用户模型的设计——法律实体 → 账户 → 用户——重点是那条听起来很武断、但看过它挡住的事故之后就很难反对的规则:每个用户必须且只属于一个账户,且这层归属永不可变。

有意思的是,这个设计我们在两个月前刚否决过。后来改变的不是 schema,而是一条产品规则。

结论先行

  • 三层:法律实体 → 账户 → 用户。钱包挂在账户层。
  • 独立个人拿到的是个人账户,而不是「没有账户」。”个人”的含义是没有公司,不是没有账户。
  • account_idaccount_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
2
CONSTRAINT wallets_exactly_one_owner_check
CHECK ((account_id IS NOT NULL)::int + (user_id IS NOT NULL)::int = 1)

有且仅有一个 owner——账户 XOR 用户。干净、对称,于是就上线了。

代价:所有东西都要挂两次

这个 XOR 在钱包表里很优雅,在其他所有地方都很贵。任何需要回答「这笔钱算谁的」的表,从此都要带两个可空列和一个分支。看我们的凭证表:

形态 行数
account_iduser_id 都有 240
只有 account_id 52
只有 user_id 10

302 行里的 10 行,撑起了整个双列结构。每一次 join、每一个查询、每一条解析付款方的代码路径,都要处理一个只覆盖 3% 数据的分支——而且很容易写错,因为「错的那条分支」通常返回空,而不是报错。

方案二:给每个人一个单人账户——被否决

显而易见的替代方案:给每个独立用户配一个只有他自己的账户,这样所有东西都统一挂在账户上。我们第一次评估时,把它失败的原因写了下来:

给每个独立用户一个单人账户看起来等价,但只要他接受了一次邀请就会崩:由于每个用户只有一行成员记录,加入另一个账户只能覆盖 account_id,于是单人账户变成零成员,却仍然持有装着他余额的钱包。billing.wallets.account_idON DELETE RESTRICT,这个账户连删都删不掉——钱被困住且不可达。

把它具体走一遍:

  1. Alice 有单人账户 A,钱包里有 200 美元,归属 A
  2. Alice 接受了公司账户 B 的邀请。
  3. 每个用户只有一行成员记录(PRIMARY KEY (user_id)),加入 B 只能覆盖 account_id
  4. 账户 A 现在零成员——没有人能够到它——但它仍然持有 Alice 那 200 美元。
  5. ON DELETE RESTRICT 意味着你连删掉 A 来收尾都做不到。

200 美元装在一个没有把手的桶里。这是实打实的缺陷,也是当时「空账户」方案胜出的原因。

真正改变的是一条产品规则

两个月后我们采纳了这个被否决的设计。schema 层面的论证没有任何进步——变的是前提

再看一遍那条失败路径。每一步都依赖第 2 步:Alice 接受邀请。整个场景成立的前提,是存在「个人 → 组织」的转换

于是我们把它删掉了。产品规则变成:

个人用户永远不能加入组织。组织用户只可能在创建时就是组织用户——通过自助注册,或由组织管理员创建。

没有转换,就没有改挂;没有改挂,就没有孤儿账户;没有孤儿账户,就没有被困住的钱。这条反对意见不是被缓解,而是被消除了——第 2 到 5 步根本无法表达。

这一点值得推广:一个 schema 层面的反对意见,强度只等于你允许的操作集合。我们花了不少时间去找更聪明的 schema,而答案是删掉一个我们其实从未真正上线过的能力。

代价是真实的,应当说清楚:一个人无法同时拥有个人空间和公司成员身份。由于一个邮箱对应一个用户,他需要第二个邮箱。对 B2B API 平台这是可接受的;对消费级产品就不是。

把规则写进数据库

只活在应用代码里的规则等于建议。这里绝大多数是声明式的:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 只有两种形态
account_type text NOT NULL CHECK (account_type IN ('personal','organizational'))

-- 成员行缓存所属账户的类型;复合外键阻止它漂移
UNIQUE (account_id, account_type) -- customer_accounts 上
FOREIGN KEY (account_id, account_type) -- account_users 上
REFERENCES customer_accounts (account_id, account_type)

-- 个人账户有且只有一个成员——任何人都无法被邀请进来
CREATE UNIQUE INDEX ON account_users (account_id) WHERE account_type = 'personal';

-- 这个成员永远是它的管理员
CHECK (account_type <> 'personal' OR role = 'admin')

上面第三条用的是 部分索引(partial index)——只对一部分行生效的唯一约束。要表达「这条规则只对个人账户成立」,这是最干净的写法。

有两个细节值得抄走:

复合外键。account_type 反范式到成员行上,才使得那个部分唯一索引成为可能——索引只能看见一张表。通常反范式意味着漂移,但这里复合外键让漂移无法被表达(account_id, account_type) 这一对必须存在于父表,所以成员行不可能声称一个它的账户并不具备的类型。

「每个用户至多一个个人账户」完全不需要约束。 它从 PRIMARY KEY (user_id) 里自然掉出来——每个用户一行成员记录,也就等于每个用户一个账户。最好的约束是你根本不用写的那条。

Postgres 唯一表达不了的东西

不可变性。没有 ALTER COLUMN … SET IMMUTABLE,而不可变性恰恰是这个设计的承重墙。所以用了两个 BEFORE UPDATE 触发器

1
2
3
CREATE TRIGGER account_users_membership_immutable
BEFORE UPDATE ON customer.account_users
FOR EACH ROW EXECUTE FUNCTION customer.reject_membership_reparent();

在生产数据副本上验证过:

1
2
UPDATE customer.account_users SET account_id = <org> WHERE account_type = 'personal';
-- ERROR: account_users.account_id is immutable (user 02aba239-…)

这条报错信息就是设计本身。当初被担心的失败路径,现在连在 SQL 提示符里敲出来都不行。

推导列属于数据库

account_users.account_type 是所属账户类型的缓存。第一版要求每个 INSERT 都自己传——结果立刻打断了九个测试 fixture,以及未来每一个插入点,每一个都是一次写错值的机会。

普通 DEFAULT 救不了:它得去读另一张表。所以让数据库来填:

1
2
3
4
5
6
7
8
9
CREATE OR REPLACE FUNCTION customer.fill_account_user_account_type() RETURNS trigger AS $$
BEGIN
IF NEW.account_type IS NULL THEN
SELECT a.account_type INTO NEW.account_type
FROM customer.customer_accounts a
WHERE a.account_id = NEW.account_id;
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;

调用方可以不传、并拿到正确的值;传了的值,复合外键照样拒绝。经验法则:如果一个列是推导出来的,就让数据库去推导它。 把这件事推给调用方,等于把一个不变量变成 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,而是那个问题:哪些操作你可以干脆不支持。其中最有价值的一行,是让某件事变得不可能的那一行。