pugWoo's Life   All-Posts  About

关于数据库主键、外键的选择

在设计表时,最好为表加上一个主键id(尽量使用数字,主键索引数据最小),一种常见的做法是所有的表都有个自增主键id,其它表对该表有关联时,都关联上主键id。在实际开发过程中,这种方式是主要大多数的情况,但在一些场景下,有其麻烦的地方,需要更深入思考其本质。

先假设一个场景,有一张学生表,学生有一个唯一的学号,还有姓名等其他信息。我们有两种方式来设计表:

方式一,表字段:

字段 说明
id 自增id
student_no 学生学号,也是一个数字或字符串,也是唯一的,有业务含义的编号
name 学生姓名

方式二,表字段:

字段 说明
id 学生学号,主键,一个数字或字符串,也是唯一的,有业务含义的编号
name 学生姓名

方式一:自增id的情况

我先直观说下自增id在哪些场景下会有麻烦的事情要处理:

  1. 自增id在开发、测试、线上每个环境是不同的,如果代码中需要写死引用哪个id的话,由于每个环境id不同,就需要为此配置不同的id值,增加了配置项和维护工作。当然,此时配置的情况下,就应该配置为学号student_no,然后代码中再翻查出学生id。

  2. 主键id对分表不友好,像上面的学生表,如果分表的话肯定是按学生学号来分表的,而数据库外表引用又是用主键id来引用。当分完好多表了,这些表自增id都是从1开始的,所以这些分表的id就大量相同,外表引用学生表主键id就很不好处理。

  3. 自增id一般是系统内部的id,对外部用户是不可见的也没有业务含义。如果关联了另外一个表的id,那么展示给前端学生编号时,就需要每次靠这个内部自增id去反查学号student_no。自增id并无显著编码规则,如果关联错了字段也不易看出来,容易引用错导致数据污染。

  4. 自增id相当于是由数据库控制的主键,应用无法自行指定其id。当发生删除数据,再添加数据时,所有原来的表外键引用都会失效,重新关联也很麻烦。例如学生都有个学校id字段,如果学校id引用的是t_school表的自增id,当该学校记录被删除,那么应用将没法(没那么容易)重新插入数据获得原来的学校id。而如果是使用编号作为主键,那么表之间的关联稳定性就会强多了。当然,这个缺点也有优点的时候,自增的id可以轻易保证不会被其它表引用,而人工指定的id则不排除该id已经被其它表引用了,导致可能的脏关联。

方式二:业务id的情况

业务ID一般来自于现实世界的某个不变且唯一能标识一个对象的值,例如标识中国公民的身份证。在整个软件内,都要求这个值是唯一且不变的。这个往往难达到,即便是身份证,也有可能因为要修改出生年月而修改。而一旦业务ID变了,那么对于系统来说会非常麻烦,所有相关的业务ID都需要修改,这个工作量不可估量,出错的概率也非常大。

但我发现,现实生活中,很多业务ID即便变了也很少提供透明的迁移服务。即便有迁移服务,也是部分的,显式调用的。例如手机号,当你更换手机号时,你的通话记录短信记录之类的都无法迁移过去,甚至连话费余额都没有迁移。例如QQ号,当换QQ号时,腾讯也只提供了好友克隆这样的功能,以前的QQ空间、QQ音乐、QQ宠物各种数据都不会帮你克隆。所以,即便业务ID变了又有多少实际业务帮忙全量迁移数据呢?更别说透明换ID了。

反过来,业务ID的优势非常明显,首先这个业务ID有一定的规则和含义,一目了然。最重要的,当你的系统业务线很长时,一个稳定的业务ID,可以在各系统、线下、数据仓库都可以以该有含义的业务ID为准。例如淘宝下单产生了一个订单号,这个订单号是数据库的主键,也是买家卖家看到的订单号,如果这个单下游做支付、做促销、参与其它抽奖活动,这个主键可以带到下游的各个系统,甚至下游的下游,都可以以这个订单ID为主直接获取各个系统关于该订单的信息。整个数据层面扁平化了、ER图是星型的。例如QQ号,它唯一标识了用户,用户也清楚自己的QQ号,在腾讯内部所有的业务系统中,如果是一对一的业务,那么QQ号会作为主键;如果是一对多的业务,QQ号也会作为组合主键。

原因探索

当然这两种方式为什么方式二在外部引用时看起来比第一种更加舒服呢?经过思考之后我得出,上面的几个字段的主要特点:

字段 唯一性 不可变性(指数据库数值因为业务需求而需要改变的情况)
自增id
学生学号 绝大多数情况下,是
学生姓名

实际世界中的数据,除了已经发生了属于历史数据外,当前活的数据几乎都是可以改变的。例如注册手机号,可以因为换号而改变。人的身份证号,也可能因为某些原因而改变,甚至中国老一代还有不少不同人拥有相同身份证号的情况。

所以,在计算机内部,为了适应最极端的业务变更情况,全部表都使用自增id或无业务含义的随机id,是可以理解的,自增主键id的唯一性和不可变性是最稳定的。同时因为使用了自增id,而带来的麻烦,也要妥当处理。

而如果使用业务编号作为主键,我们就需要考察业务编号的不可变性,如果业务编号在其它系统和线下被大量引用,同时是不可变的或者是绝绝绝大多数情况下是不可变的,或者就算变化了,也容易进行数据迁移的,那么我更倾向使用业务id代替自增id,理由:

  1. 业务id是有业务编号含义的,例如身份证号可以看出人的地区和年龄的基本信息。
  2. 外部引用也使用了业务id,可以直接展示给用户。
  3. 每个环境中这个业务id是稳定的,容易进行数据迁移,容易直观发现数据异常。不会有删除数据后重新关联的问题。
  4. 方便跨环境的数据导入导出。

那么有哪些数据是唯一且不可变性,其实有很多,比如订单号或支付流水号这些一旦生成就不能再改的单号。比如QQ号,腾讯不会支持我修改qq号后,原来的所有数据还一模一样是原来的关联。例如一些内部可控的数据,也是有能力保证其不变的,例如商品品类编号。

但要提一句,设计业务编号id需要一定的额外工作量,建议在重要的对象上设计编号。次要的编号则使用自增id。

关于冗余信息

在设计表时,有好些基本信息,我们会把它冗余下来,最直观的好处就是以后的显示可以不用连表再查询一次。当然,这个还要讨论:

  1. 例如下单数据,下单时刻购买的产品名称和价格,是一定要做冗余而不能只存产品id,以避免产品购买之后产品信息变化的情况。

  2. 例如聊天记录或评论,用户的昵称和头像,如果冗余它当时的头像和昵称,也是可以的,例如人人网,当你看几年前自己发的动态和朋友的评论时,都是他们当时的头像,多么有纪念意义。

  3. 例如产品的分类信息,产品肯定关联了分类id,但是分类名称,我们期望当编辑了分类名称后,产品信息显示时也看到最新的,此时就不应该用冗余数据。

所以,数据是否冗余是根据业务来决定,其次才是是否带来了技术少查一次的便利(nimble-orm可以很容易实现关联查询的问题,而且性能不错,所以技术上多一次或少一次查询并没有太大的区别)。

我试了下微信的手机客户端,当我修改了我的昵称时,数据的显示变化情况:

  1. 聊天记录内容中的@某人的昵称不会变化。
  2. 收藏列表的昵称会变化。
  3. 群聊的参与者的名称会变化。
  4. 联系人列表会变化。
  5. 发表朋友圈、点赞、评论显示的名称会变化。

基本上,当前还“活”的数据就不应该冗余。历史发生的,无法再改变的数据,应该冗余。再次强调并总结一次,数据是否冗余是根据业务来决定

其它说明

  1. 还有另外一种主键的方式:使用随机字符串,UUID的方式,可以解决被用户穷举拖库的风险。

  2. 关于int型主键和varchar主键的性能差异,实质上并不大。参考这里

  3. 自增id(Surrogate_key)和业务id(Natural_key) 的wiki定义。

  4. 无论是自增id还是业务id(code),都是被外部引用的,都是给其它系统或人引用的。自增id是技术范畴,技术上有能力保证其不变,对外界是不可见的。业务id(code)适合更普适的业务场景,其可读性、传播广度、数据稳定性更佳。