业务表主键:从纠结到想清楚
问题的由来
设计后台服务绕不开建表,表和表之间又互相关联:user、order,order 要引用 user。于是一个不起眼但天天要面对的问题冒出来:主键怎么命名?
我倾向过 user_id 这种带表名的命名——user 表的主键就叫 user_id,order 表引用它的列也叫 user_id。理由很朴素:每个字段一眼可辨,order.user_id 指向谁不用翻表结构。
但”一眼可辨”这四个字经得起推敲吗?我把这个问题拆开来想了一遍。先把结论交底:表名冻结、术语稳定的系统——金融核心、内部管理系统这类——我会选带表名的主键;业务词汇三年一换的互联网产品,我会选 id。为什么,一个维度一个维度说。
先把两种做法摆清楚
两种做法分别长这样:
- id 命名:
user表主键叫id,order表引用列叫user_id——两边不对称。Rails、Django、MyBatis-Plus 的约定生态都是这么默认的。 - 表名命名:
user表主键就叫user_id,order表引用列也叫user_id——同名对称。金融、电信这类传统企业系统里很常见。
顺带消个歧:这是主键列的命名问题,不是”代理键 vs 自然键”(用自增 id 还是用身份证号当主键)之争——那是另一个话题。它比看上去微妙的地方在于,user_id 这个词在两种做法里含义不同:一边是 user 表的主键,一边是引用列的通用名字。很多人在这个话题里吵的其实是两件不同的事。
同一张表引用两次的时候
想清楚的第一步,是撞上一个现实场景:同一张表引用同一张表两次。订单有买家和卖家,审批流有创建人和审批人,转账有付款方和收款方——全都指向 user 表。
这时候表名命名撞墙了:一张表里两列没法都叫 user_id,只能加角色前缀:
1 | -- 表名命名,多角色场景:同名规则破例,被迫加角色前缀 |
两条 JOIN 已经是同一种形态:引用列带角色名,和主键列名不再对称。Java 侧同理,getBuyerUserId() 谁来写都是这一个名字。
所以”永远不会有歧义”其实是一句说过头的话,准确的说法是”一条规则 + 一个例外条款”:一表一引用时用同名规则,一表多引用时改角色规则。而 id 命名从第一天起就只有一条规则:主键永远叫 id,外键叫 [角色_]目标_id,规则的简洁性上反而是后者占优。值得注意的是,这里丢的还不是自明性——buyer_user_id 照样一眼看出指向 user 表——丢的是规则本身的统一性。
读别人的 SQL 的时候
第二个维度是 JOIN 的可读性。读别人的长 SQL,JOIN 条件躺在第 30 行:
1 | -- id 命名 |
u.id 要求你滚回顶部确认 u 是谁;u.user_id 自己就把话说完了,别名乱成 t3 也无妨。这在维护期最明显:写 SQL 的人不总是读 SQL 的人——半年后的 code review、线上排障、接手离职同事的报表,读者都要重建上下文,自包含的列名替他省一步。
但我后来意识到别高估这条:id 命名把语义信息存在”别名 → 表”那一层,而读写 SQL 本来就得背着这层映射——SELECT 列、WHERE 条件全靠它限定。表名命名做的事,本质是把这层语义冗余编码进每一个列名:FROM user u ... u.user_id,”user”在一条 SQL 里出现两遍。这条我承认表名命名更舒服,但舒服得有限。
假如表要改名
第三个维度让我纠结最久:业务语言演化,user 表要改名 customer 怎么办?表名命名立刻陷入两难:
- 改列名:
user_id→customer_id,所有引用表的列、所有 JOIN、所有 Java 实体、所有日志埋点全要联动——一次大手术; - 不改:从此名实分离:
1 | -- 十年后:业务早就叫客户,表还叫 user |
十年后入职的人对着这段 schema,第一件事是问老人:为什么客户存在 user 表里?
暴露面也不一样:表名命名把表名编进了列名,改名时受牵连的是主键加全部外键;id 命名的主键不承诺任何表名,受牵连的只有外键。多表合并同理:user 和 member 并成一张表,表名命名的主键留谁的名字都是错,id 一个就收编两侧。
但我自己的经验里,表改名这种事从来没真正发生过——多数长命系统选择”沿用以前的命名”,表名是冻结的。这条顾虑在冻结的系统里不会兑现,在词汇漂移的系统里则是真实的债。
当值离开表以后
第四个维度,是我想明白自己为什么那么在意”自明”的关键:值离开 schema 之后。导出的 CSV、同步进数仓的宽表、DBA 拿裸 SQL 排障、数据订正脚本——这些地方没有实体类帮你补语义,列名是唯一的语义来源:
1 | # 排障时看到的数据(id 命名) |
半夜排障时,前者要先弄清 id=123 是哪张表的 id,后者一眼可辨。数仓场景尤其典型:宽表汇聚几十张表的列,全库主键同名 id,同名列冲突逼着数仓同学逐个起别名,而全局唯一列名在数仓里本来就是通行做法——带表名的命名天然合规。
有一个常见的反驳要处理掉:日志里打 id=123,其实在 log 语句层顺手写 userId= 就能补救,schema 不必出手。这话对——但 log 层救不了 CSV、数仓、DBA 的裸 SQL。这是表名命名真正占便宜的一个维度,也是它最该占便宜的。
写通用代码的时候
最后一个维度在 Java 这边。两种做法都有”一致性”,但不是同一种:表名命名是规则一致——主键 = 表名_id,规则统一但名字各异;id 命名是名字一致——所有主键都叫 id,不光规则统一,名字本身也统一。
名字一致是更强的一致性,它兑换成”面向主键编程免元数据”:
1 | // id 命名:名字一致,一个字段通吃所有实体 |
差距要到自研基建才真正显出来:缓存组件按主键拼 key,数据同步按主键取值,通用订正工具按主键定位行——id 命名一句 entity.getId() 通吃所有实体;表名命名的每张表都要过一遍注解元数据。
但 MyBatis-Plus 的 IService.getById() 之所以能对所有实体工作,内部就是靠 @TableId(value = "user_id") 这类注解把差异抹平的——框架替你把摩擦吃掉了大半,每个实体多一行注解而已。JPA、代码生成器同理。有差距,但被框架付掉了大半。
我的权衡汇总
五个维度想完,汇总成一张表:
| 维度 | 我的判断 |
|---|---|
| 同表引用两次 | 打平——表名命名的同名规则在这里天然破例 |
| JOIN 可读性 | 表名命名略占优——列名自包含,不依赖别名 |
| 假如表改名 | 看系统——表名冻结的库里这条不兑现 |
| 值离开表以后 | 表名命名胜——数仓、导出、裸 SQL 的唯一语义来源 |
| 写通用代码 | 接近——摩擦可被框架吸收 |
表名冻结、术语稳定的系统里,带表名的主键自洽且略占优——这大概也是金融、电信核心系统常见 表名_id、互联网常见 id 的原因:不是谁更先进,是各自的域把账算对了。
什么会让我改变主意
结论不是终点,有三个信号会让我重新掂量:
- 业务词汇开始漂移:产品三年换血、领域术语跟着业务跑,表名命名的”名字承诺”开始欠债;
- 重度自研泛型基建:公司框架、缓存组件、数据同步工具全要面向主键编程,表名命名的摩擦从”每实体一行注解”放大到每个基建组件;
- 多角色引用成为高频形态:此时两种做法趋同,选谁都一样,不必再为对称性付成本。
新项目起手,我会先问自己三个问题再落键盘:表改名、领域术语换血发生过吗?数据会流到库外面去吗——数仓、报表、导出、别的团队直接查?同一张表引用同一目标两次常见吗?答案决定了主键叫什么——别让别人的结论替你的域做决定,包括我的。