1. 主键与外键:面试送分题还是送命题?
在Java面试里,主键和外键的区别几乎是必问的基础题。我这些年面试过两百多个候选人,能真正把这道题答出深度的人不到三成。大部分人的回答是“主键是唯一标识一条记录的,外键是关联其他表的”,然后就卡住了。这句话没错,但远远不够,尤其是面对Java后端岗位,面试官接下来会抛出连环追问:主键加了索引之后底层到底长什么样?外键为什么不建议用?你项目里为什么没建外键?这一连串问题下来,才算真正考验基本功。
为什么Java面试这么偏爱问主键和外键?因为这道题覆盖面太广了。它天然横跨了数据库原理、索引结构、Java持久层框架设计、分布式系统架构,甚至还能扯到分库分表和数据处理工具的使用。对一个Java开发来说,主键和外键不只是建表语句里的两行代码,它们背后体现的是你对数据建模的理解、对存储引擎的认知、对业务一致性的态度。所以,与其说面试官在问“这两个词的定义”,不如说他在衡量你这个人在实际项目中到底是怎么思考数据的。
我在实际面试中听到过很多让人哭笑不得的回答,比如“主键就是一个字段,外键是另一个表的字段”,“外键就是多表查询的时候用的”,“Java里设置外键后,查询会变快”,“外键是MySQL特有的”。这些回答都有一个共同问题:把数据库约束和SQL查询语法混为一谈,完全没意识到主键和外键是两种不同的机制。外键确实和“多表”有关,但它和查询效率没有任何直接关系,反而很多时候会拖慢写入性能。这个问题在今天的工程实践里特别敏感——几乎所有互联网团队都会告诉你:物理外键,能不用就不用。
1.1 为什么Java面试反复拷问主键与外键
这道题出现在Java面试里,几乎成了行业默契。我分析过很多面试流程,发现它通常出现在第二个阶段:技术一面,框架题之后、算法题之前。位置很讲究,因为面试官需要用一道数据库基础题来快速判断候选人的计算机功底是否扎实。对于Java工程师来说,数据库操作是日常工作的核心部分,如果连主键和外键这种基础概念都讲不清,后面那些Spring事务、MyBatis原理、微服务数据一致性,基本没法聊。
从考察维度来看,主键和外键这道题可以拆成三个层次。第一层是定义层:能不能准确说清楚“主键是表的唯一性约束”“外键是表之间的引用约束”。第二层是机制层:主键在InnoDB里怎么存储?外键的CASCADE和RESTRICT分别什么效果?第三层是工程层:分布式场景下为什么不用外键?多租户系统里的userId怎么维护?大多数候选人能过第一层,第二层开始露怯,第三层基本沉默。
这里我要给所有准备面试的Java开发者一个建议:不要背八股文式的答案,而是从“为什么存在”的角度去理解。主键之所以存在,是因为数据库需要稳定地定位一条记录;外键之所以存在,是因为数据库需要保证两张表之间的引用关系不被破坏。理解了这两个“为什么”,你会发现哪怕面试官把问题绕到索引优化、分库分表、数据管理工具,你也能自然地延伸过去。
1.2 我更关心候选人怎么回答而不是回答了什么
我举一个真实案例。有一次我面试一个自称“熟练使用Java和MySQL”的候选人,我问他:“你的订单表里为什么没有设计外键?”他想了想说:“因为MyBatis-Plus不支持外键。”这个回答让我有点意外,因为MyBatis-Plus确实不会主动生成外键约束,但它并不禁止——SQL语句里照样可以加FOREIGN KEY。他的真正问题在于:把框架的能力边界当成了数据库设计的上限。
另一个常见的翻车现场是主键相关。我问候选人:“你的用户表主键用的是什么类型?”他说:“自增Long。”我又问:“为什么不用UUID?”他答:“UUID生成太慢。”这里就有个知识盲区了。UUID的生成并不慢,慢的是把它作为聚簇索引主键后引发的页分裂和碎片问题。自增主键和UUID的取舍,其实是B+树结构和写入顺序之间的博弈,和“生成速度”关系不大。
所以我现在面试的时候,听到一个回答,不会急着判断对错,而是继续往下问两个“为什么”。比如候选人说“外键影响性能”,我会问:“影响的是哪个环节的性能?插入还是查询?底层是什么原理?”如果他说“外键影响插入性能”,那说明他真的理解;如果只说“反正运维不让用”,那基本就是背过面试题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主键的本质:数据库里最底层的“身份证”
主键的定义很简单:用于唯一标识表中每一行数据的列或列组合。但这个定义背后藏着一整套数据库存储机制。在MySQL的InnoDB存储引擎里,主键不只是约束,它直接决定了整张表的数据物理组织形式。InnoDB是聚簇索引结构,表数据就是按主键顺序排列的叶子节点。你建了一个主键,实际上就是给这张表定义了一个物理排序的目录,所有数据行都按照这个目录存放。
2.1 主键在InnoDB中的底层存储机制
理解主键的底层机制,是区分“会用”和“懂原理”的分水岭。InnoDB的数据文件本身就是一棵B+树,树的主键就是聚簇索引。聚簇索引的叶子节点存的是完整的数据行,而非聚簇索引(二级索引)的叶子节点存的是主键值。打个比方:聚簇索引就是一本按页码排列的字典,你翻到哪个页码,对应内容就在那里;二级索引则是书末尾的关键词索引,查到一个词,它告诉你这个词出现在第几页,你再回正文去找。
这个机制带来一个重要结论:每一张InnoDB表都必须有一个主键。如果你没有显式定义主键,InnoDB会偷偷选一个没有NULL值的唯一索引作为主键;如果连唯一索引都没有,它就会生成一个隐藏的6字节列作为主键。很多开发者在建表时偷懒不写主键,觉得“反正能跑”。能用是能用,但性能和数据维护都会变得糟糕。没有主键意味着你无法高效地做行级定位,数据量一旦涨上来,更新和删除语句就可能变成全表扫描。
主键的另一个特性是非空唯一。这个约束不是SQL层面的“允许不允许”的问题,而是存储引擎层面的需求。如果某个业务字段不适合做主键,比如可空、可重复,那就一定要单独设计一个不依赖业务的替代键。在我的实际项目中,主键我几乎清一色选择BIGINT类型,由统一的ID生成器生成,坚决不用业务字段。原因很简单:业务字段早晚会变,一旦主键变了,所有依赖它的二级索引、缓存、日志都要跟着改,成本极高。
2.2 自增主键、UUID和雪花ID:三种策略的取舍
这里我给读者们整理一份我实际用过的对照表,方便参考:
| 主键策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| AUTO_INCREMENT自增 | 写入顺序性好,B+树页不分裂;索引占用空间小 | 分布式场景冲突;迁移合并表时容易撞;泄露业务量 | 单库单表、内部管理系统 |
| UUID字符串 | 全局唯一,生成无需协调;隐蔽性较好 | 无序字符串作为聚簇索引会剧烈页分裂,UUID太长占空间(36字符);查询性能下降明显 | 不建议做表主键,可做业务编号 |
| 雪花ID / 号段模式 | 全局唯一且趋势递增,兼顾性能和分布式 | 需要引入发号器或ID生成组件;时钟回拨需处理 | 互联网高并发场景,微服务架构下的主流选择 |
为什么UUID在InnoDB里作为主键不友好?因为它完全无序。你想一下,一棵B+树,数据一直都在按主键顺序排列,插入一个随机字符串时,它落在哪个位置都有可能,数据库需要不断分裂页面、移动数据来维护树结构。数据量大了之后,页分裂频繁,写放大严重,随机IO增加,性能呈指数级下降。而自增主键是顺序写入,新记录永远追加在B+树最右端,代价小得多。这也是为什么很多传统业务系统坚持用自增主键的原因。
但自增主键在Java互联网项目里也有明显短板。分库分表之后,单表自增序号会重复;数据从测试库合并到生产库,ID会冲突;业务方很可能通过ID差异倒推系统订单量。所以在真实的Java企业级项目里,我见过最主流的做法是“雪花ID + 统一发号器”。雪花ID是64位Long,时间戳加机器号加序号,既能全局唯一,又有一定递增性,兼顾了人类可读、索引性能和分布式扩展。如果你还在纠结主键选型,记住这个结论:单体项目用自增Long,分布式项目用雪花ID,UUID留作业务编号和幂等标识更加合适。
3. 外键的两面性:物理外键与逻辑外键的工程之争
如果说主键是数据库的基石,那外键就是数据库设计中最富有争议的话题之一。外键的定义也不复杂:它是一个或多个字段,引用另一张表的主键或唯一键,用来保证引用完整性。但这段看似平凡的定义,在Java后端社区里引发过无数场讨论。核心争点只有一个:到底该不该在数据库层面建立物理外键?
3.1 物理外键做了什么:约束机制与级联行为
先看物理外键的机制。在MySQL中,创建一个外键约束之后,数据库会自动完成几件事:第一,它会在外键列上自动创建一个索引,用来加速外键检查的查询,不信你可以在建完外键之后执行SHOW INDEX FROM 订单表,会看到外键字段确实多了一个索引。第二,当你往子表插入数据时,数据库会检查父表中是否存在对应主键,不存在就抛异常。第三,当你更新或删除父表记录时,外键的级联规则(ON DELETE CASCADE、ON UPDATE SET NULL等)会自动作用于子表。
举个例子,假设我们有用户表和订单表,订单表通过user_id外键引用用户表的id。执行DELETE FROM 用户 WHERE id = 1时,如果这条删除语句的外键规则是CASCADE,那么属于这个用户的所有订单也会被自动删除;如果是RESTRICT,数据库会报错,拒绝删除,直到你先手动清理完相关订单。这两种行为的本质都是保证“数据引用不被破坏”。
用现实场景来理解的话,可以把外键看作财务审批制度:你提交一笔报销单,系统必须验证这个工号真实存在;如果某个员工离职了,系统会先拦住你,不让你删除他的档案,除非先把关联报销单处理完。这种制度保证了数据不乱,但也意味着每次报销都要跑一趟审批流程,比较费时。物理外键也是这样,它用约束换来一致性,用开销换来安全。
3.2 互联网团队为什么普遍禁用物理外键
现在我要聊一个在Java面试里几乎必考的知识点:为什么很多互联网团队明确要求“禁用物理外键”?这个问题的答案,直接暴露候选人有没有真正做过高并发系统。
第一个原因是性能开销。每次插入和更新子表数据时,数据库都要额外检查父表;随着表数据量增大,这个检查代价会明显增长。我举个例子,一个日订单量十万级的系统,每秒可能产生几百笔订单,每笔订单都触发外键检查,这意味着数据库要频繁地在用户表上加一个行锁或者间隙锁来验证user_id存在。在高并发场景下,这种额外的锁竞争会拉低整体吞吐量。阿里JAVA开发手册里有一条强制性规约:不得使用外键与级联,一切外键概念必须在应用层解决。这条规约背后就是为了避免外键在高并发环境下带来的性能问题和死锁风险。
第二个原因是分布式和多租户场景下物理外键存活不了。今天的大型Java系统,几乎没有单库单体了。订单数据、用户数据、支付数据,可能分散在不同的库、不同的实例甚至不同的数据中心。物理外键只能在同一数据库实例内生效,一旦分库分表,外键约束就完全失去了作用。我之前维护过一个用户库和订单库分开的系统,当时数据库DBA直接规定:任何跨库关联都不能用外键,只能靠应用层维护关系。这个规定不是我同意的,而是物理外键根本没能力管跨库的事。
第三个原因是级联操作的“不可控”。CASCADE看起来很方便,但一旦业务规则复杂,自动删除会带来很多隐患。比如你删一个商品分类,级联删除了该分类下所有商品,可这些商品某个时刻正在被某个营销活动引用,这波操作直接导致活动数据悬挂。物理外键的级联行为是数据库层写死的,它不懂业务是否允许。而逻辑外键,也就是只在应用层维护的外键关系,则由程序员在Service层显式地判断和处理,灵活性高得多。
用什么替代物理外键?答案就是逻辑外键。你仍然在子表里设计一个user_id字段,但不在数据库层面声明FOREIGN KEY约束,而是在Java代码里通过事务、分布式事务或补偿机制来保证一致性。查询的时候照常使用JOIN,因为SQL语法中的JOIN并不依赖有没有物理外键约束。这样既保留了表间关系的语义表达能力,又规避了数据库层的性能瓶颈和级联风险。
4. Java工程的日常:从DBeaver导出数据异常说起
聊完了概念,我们把视角切换到实际开发的日常。Java工程师每天面对的不只是写代码,还有大量的数据处理工作。其中一个高频痛点,就是数据库管理工具导数据。最近热词里有一条叫“dbeaver导出数据没有主键id”,非常贴近真实场景。我来说说这个坑到底是怎么踩进去的,以及主键缺失会在Java工程里引发什么连锁反应。
4.1 DBeaver导出数据没带主键ID的根因排查
DBeaver是Java生态里非常流行的开源数据库管理工具,很多团队都在用。但我在实际使用中遇到过一个非常微妙的现象:用DBeaver把某张表导出为SQL文件,再导入到另一台环境的数据库时,发现所有INSERT语句里都没有主键ID列。这个问题的表现是:目标表有主键,但导入的数据里主键字段全是默认值,或者直接被填充了自增序号,导致源数据和目标数据对不上。
为什么会出现这种情况?排查下来主要有两个根因。第一个是DBeaver的导出选项设置问题。在导出对话框的“高级”设置里,有一个选项叫“跳过生成列”或者“插入时排除标识列”,如果勾选了它,DBeaver会自动识别目标表的主键列为自增列,然后在导出脚本里把它省略掉。这个选项在默认配置下有时候会自动开启,特别是当这张表的主键是AUTO_INCREMENT时。第二个原因是主键列在DBeaver的表结构元数据中被标记为“自动生成”,工具自作聪明地认为不需要把该字段的值写进文件。
这个问题对Java开发者来说后果很严重。假如你负责一个订单同步任务,用DBeaver把生产库的订单全量导到预发库,结果订单ID全都丢了,那所有跟订单关联的明细数据、支付回调、状态变更记录都会错乱。更麻烦的是,如果订单表还设了唯一索引,重复ID会直接导致导入失败。我的解决办法是:导出前,打开DBeaver的表结构视图,确认主键列的“Auto Incremented”属性;在导出向导中选择“包含生成值”或者显式把主键字段选进导出列,不要用默认全选。如果你用的是命令行dbeaver dump,就加上——no-data=false、——insert-columns=true这类参数,强制INSERT语句带上所有列。
4.2 无主键表给Java应用埋的那些雷
除了导出工具的坑,还有一种更危险的情况:源表压根没有主键。我刚工作那会儿遇到过一张日志表,建表语句很随意,没有主键,也没有任何唯一索引。当时开发同学都说“日志表嘛,就是插和查,没有主键无所谓”。后来要做数据清洗,需要删除重复记录,写SQL时才发现,没有主键定位,只能根据时间戳和日志内容联合匹配,效率低得可怕。更糟的是,这张表跟其他表做关联查询,因为缺少主键索引,JOIN性能一塌糊涂,一条简单的关联SQL能跑十几秒。
这里要敲黑板了:对于Java应用来说,没有主键的表就是一团乱麻。MyBatis-Plus里,实体类上如果没有@TableId注解,分页查询就识别不了逻辑删除,更新操作也会因为缺主键而报错或者全表更新。Spring Data JPA对主键的要求更严格,没有标识符字段的实体会直接抛异常,因为它需要通过主键来管理实体的生命周期。如果你的团队还在用一张无主键表做核心业务,我的建议是立刻创建一个自增ID列作为主键,或者用业务上真正唯一的字段补上一个唯一索引。哪怕不是主键,也要有唯一索引,否则后续的每一条数据修复都可能演变成事故。
5. 实战建模:用户、订单、订单明细的主外键设计
现在我们动手做一个具体的设计,把主键和外键的知识点串起来。以电商系统里最常见的“用户-订单-订单明细”为例,从数据库表设计到Java代码落地,走一遍完整流程。
5.1 从三范式推导出的核心表结构
设计这三张表时,我会严格去做范式推导。用户表是实体表,表里包括id、username、user_level、created_at等字段。订单表属于过程表,记录一次购买行为,包括id、user_id、order_no、order_status、total_amount、created_at等字段。订单明细表再往下,记录订单内的每个商品,包括id、order_id、product_id、product_name、product_price、quantity等字段。
为什么外键要放在多的一方?因为外键表达的是“多对一”关系。一个用户可以有多个订单,所以“用户-订单”是一对多,外键user_id必须放在订单表;一个订单有多个商品明细,所以“订单-订单明细”是一对多,外键order_id放在明细表。这个规则是关系数据库设计的基本共识。如果你在建表时发现外键不知道放哪边,先判断两个表的关系是几比几:一对多放多的一侧,多对多需要中间表,一对一放任一表但通常放在查询频率高的一侧。
下面是这三张表一种合规的DDL定义(我刻意选择了逻辑外键风格,即字段存在但不用FOREIGN KEY约束,这也是当前Java后端的主流选择):
sql复制CREATE TABLE `用户` (
`id` BIGINT NOT NULL COMMENT '用户ID,雪花ID生成',
`username` VARCHAR(64) NOT NULL,
`user_level` TINYINT DEFAULT 1,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `订单` (
`id` BIGINT NOT NULL COMMENT '订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID,逻辑外键',
`order_no` VARCHAR(32) NOT NULL,
`order_status` TINYINT NOT NULL DEFAULT 0,
`total_amount` DECIMAL(12,2) NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `订单明细` (
`id` BIGINT NOT NULL COMMENT '明细ID',
`order_id` BIGINT NOT NULL COMMENT '订单ID,逻辑外键',
`product_id` BIGINT NOT NULL,
`product_name` VARCHAR(128) NOT NULL,
`product_price` DECIMAL(12,2) NOT NULL,
`quantity` INT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意,我没有在明细表的order_id上创建唯一索引,因为一个订单包含多个明细,order_id必然重复,普通索引就够了。而订单表的order_no作为业务唯一键,我建了唯一索引,这样在Java代码里才能安全地基于order_no做幂等判断。
5.2 逻辑外键在Java代码层的一致性保障
物理外键没建,那Java应用层怎么保证“订单的user_id一定存在于用户表”?答案是靠Service层的事务规则。最简单的方式:创建订单时,先select用户表确认用户存在,再insert订单表,并把两步放在同一个事务里。这种方式的可靠性在绝大多数场景下足够。如果你的系统已经微服务化了,用户信息在用户服务里,订单服务里光靠本地事务是查不到用户表的,那就得在上游网关或者下单接口里通过用户鉴权来保证user_id的合法性。简单说,逻辑外键的一致性由业务规则和事务边界来保证,而不是数据库来保证。
接下来还要解决关联查询的问题。虽然DDL里没有外键约束,但JOIN查询依然可以进行。我用MyBatis-Plus写一个根据用户ID查订单详情的DTO查询:
java复制public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> {
@Override
public List<OrderDetailVO> listOrderDetailByUserId(Long userId) {
QueryWrapper<Order> orderQuery = new QueryWrapper<>();
orderQuery.eq("user_id", userId).orderByDesc("created_at");
List<Order> orders = this.list(orderQuery);
if (orders.isEmpty()) {
return Collections.emptyList();
}
List<Long> orderIds = orders.stream().map(Order::getId).collect(Collectors.toList());
// 查明细表
List<OrderItem> items = orderItemMapper.selectList(
new QueryWrapper<OrderItem>().in("order_id", orderIds)
);
Map<Long, List<OrderItem>> itemsGroupByOrder = items.stream()
.collect(Collectors.groupingBy(OrderItem::getOrderId));
return orders.stream().map(order -> {
OrderDetailVO vo = new OrderDetailVO();
BeanUtils.copyProperties(order, vo);
vo.setItems(itemsGroupByOrder.getOrDefault(order.getId(), Collections.emptyList()));
return vo;
}).collect(Collectors.toList());
}
}
这段代码展示了一个很常见的模式:先查订单,再按orderId集合批量查明细,最后在内存里组装。这种方式比循环里查明细高效得多,避免了经典的N+1查询。可以看到,逻辑外键完全不阻碍关联查询,区别只在于,少了数据库层的约束检查,需要保证代码逻辑的正确性。
这里我还想分享一个查重复外键值的SQL技巧,这也是我在清洗脏数据时用得非常多的操作。假如在旧系统里没做好逻辑外键维护,订单表的user_id存在大量孤儿数据,你可以用一条SQL把问题揪出来:
sql复制SELECT o.user_id, COUNT(*) AS cnt
FROM 订单 o
LEFT JOIN 用户 u ON o.user_id = u.id
WHERE u.id IS NULL
GROUP BY o.user_id
ORDER BY cnt DESC;
这条SQL的原理非常直观:用左连接找到在用户表中不存在的user_id,也就是孤儿记录。在修复数据之前,请一定先在代码里确认处理的基准版本,并且备份一份原始数据,毕竟没有物理外键的保护,任何一步手误都可能造成数据错乱。
6. 面试官心里的满分回答:考点拆解与作答思路
聊到这里,主键和外键的技术细节和工程实践都已经覆盖了。最后我想专门给准备面试的同学梳理一下:面试官问出这道题时,心里到底想听到什么?知道了考察点,你就能针对性架构自己的回答,而不是只背一句定义。
6.1 这道题背后的三层考点
第一层,数据库基础是否扎实。面试官需要确认你对关系模型的基本认知是准确的,比如主键的非空唯一特性,外键的引用完整性作用。这一层只要概念清晰即可,不需要长篇大论。
第二层,对存储引擎和索引机制的理解深度。主键在InnoDB下是聚簇索引,如果没有主键,InnoDB会生成隐藏主键;外键列会自动创建索引;UUID主键会造成页分裂;自增主键对写入更友好。这些东西书上都有,但很多人只是“见过”,面试官一追问细节就会露馅。如果你能主动提到“聚簇索引”和“二级索引”,面试官大概率会眼睛一亮。
第三层,工程实践是否真实。这是拉开差距的关键。你有没有在分布式系统里被外键坑过?你有没有处理过无主键表的脏数据?你有没有在微服务架构中通过代码保证引用完整性?这些都是无法靠背题糊弄过去的经历。能讲出具体的案例和踩坑过程,比任何漂亮的术语都更能说服面试官。
6.2 一套可复用的作答框架
我的建议是,面试时按照“定义-机制-工程决策”三段式回答。先说定义:主键是表中的唯一标识,外键是表之间引用关系的约束。然后说机制:主键在InnoDB中作为聚簇索引,直接决定数据存储结构;外键通过约束维护引用完整性,通常在OLTP单库阶段有用。最后落到工程决策:在Java互联网项目中,团队一般使用逻辑外键而非物理外键,原因是高并发写入场景下外键检查影响性能,分库分表后物理外键无法工作,所以会由应用层事务和数据一致性方案替代。
最后一段是展示经验的地方,不要只扔结论,要给出一个你真实做过的决策细节。比如你可以说:“我之前负责的订单服务,最开始建表时是带物理外键的,上线前压测发现插入QPS比预期低了百分之二十左右,后来发现引擎层老是外键校验,和DBA讨论后去掉物理外键,同时把用户校验下沉到下单接口的第一个步骤,即在事务外先做一次用户状态查询,整个队列就顺畅了。”这种回答,既讲了概念,又讲了数据,还展示了工程判断,面试官想不给高分都难。
我个人面试时的经验是,这道题能聊五分钟就算合格,能聊十五分钟并且保持逻辑清晰,基本就是这个候选人数据库功底的加分证明。
最后再分享一点手工经验
主键和外键的讨论,最后都会回归到一句话:主键是数据的根,外键是数据之间的红线。根不稳,数据的唯一性和索引性能都会出问题;红线拉不好,业务一致性迟早要还债。我做了这么多年Java开发,吃过最多亏的地方,不是表结构设计太复杂,反而是最基础的主键设计疏漏——要么忘记了自增回滚的坑,要么在数据迁移时丢了主键值。如果你正在做一个新系统的表设计,我强烈建议你拿出一张纸,先把每一张表的主键类型写清楚,再标明哪些列其实是逻辑外键,最后再考虑要不要物理外键。把这个动作养成习惯,很多数据层面的隐患根本不会发生。
