谈谈实体的 ID 与“键”
“实体”几乎是每个开发人员耳熟能详的概念。特别是对于使用过 ORM 框架的开发人员,当你提到“实体”,他们可能马上就会想到“就是需要映射为数据表(Table)的那些对象嘛”。
实体也是领域驱动设计(DDD)的战术层面最重要的概念之一(个人以为,DDD 战术层面最重要的三个概念是聚合、实体以及值对象)。由于大家对“实体”如此熟悉,所以要理解下面的讨论其实并不需要了解 DDD。
即使是不了解 DDD 的开发人员都“公认”实体是指这一类对象:拥有标识符(简称 ID),不管对象的状态如何变化,它的 ID 总是不变的。我们可以把这个 ID 称为实体的领域 ID。
比如说,我们的银行账户(Account),总是有一个编号(账号)的。我们存钱、取钱,账户里面的钱会发生变化,但是账号不变。我们通过这个账号,能够查询到账户的余额。所以,我们可以把银行账户建模为一个实体,选择它的编号作为这个实体的 ID。
问题
我相信很多人都思考过类似的问题:
实体的领域 ID 是不是应该映射到关系模型(数据库)的自然键?如果一个实体的 ID 已经是“自然键”了,那么与之对应的关系数据库的表(Table) 中还有必要再引入这个 ID 之外的代理键吗?
还有,如果领域 ID 可以是代理键,那么它什么时候应该是代理键?
我们可能都见过这样的软件系统:开发人员会不分青红皂白地给每个表(实体)设计一个代理主键。那么,在代码中这样重度地使用代理键是合理的做法吗?
如果一个实体需要被其他实体引用的时候,其他实体是不是应该尽可能统一地通过持有它的领域 ID 的值来引用(指向)它?
自然键与代理键
在上面的问题中,出现了自然键和代理键的概念。考虑到代理键是和自然键是相对的概念,我们先搞清楚什么是自然键(Natural Key)就可以了。那么,什么是自然键?根据维基百科:
In relational model database design, a natural key is a key that is formed of attributes that already exist in the real world. For example, a USA citizen's social security number could be used as a natural key.
In other words, a natural key is a candidate key that has a logical relationship to the attributes within that row. A natural key is sometimes called domain key.
中文翻译过来就是:
在关系模型数据库设计中,自然键是指由现实世界中已经存在的属性所构成的“键”。举个例子,美国公民的社会保险号码可以用作自然键。换句话来说,自然键是和那一“行”中的属性存在逻辑关系的候选键。自然键有时候也被称为领域键。
根据这个定义,自然键有时候也被称为领域键——领域键和领域 ID,啊哈,称呼已经很接近了不是吗?
我们看到,这个定义还依赖于另外一个概念:现实世界(the real world)。那么,什么是现实世界?如果这个概念没有定义,那么自然键的概念还是“不清不楚”的。
我曾经在网上 Google 到一篇文章(Choosing a Primary Key: Natural or Surrogate? ),也许,我们可以通过文章中的这句话去理解什么是 The Real Word(中文翻译版本):
注:Agiledata.org. Choosing a Primary Key: Natural or Surrogate?
http://www.agiledata.org/essays/keys.html
不要自然化代理键。一旦你向最终用户(end users)显示了代理键的值,或者更坏的是允许他们使用该值(例如搜索该值),实际上你已经给它们赋予了业务含义。这实际上是自然化了代理键从而失去了代理键的优点。
好吧,根据这个说法,我们貌似可以认为,使用软件的最终用户所生活的世界就是现实世界,这个世界没有代理键的容身之地。这么说来,代理键只应该活在 Geeks(技术人员)的世界里就对了啰?
总之,能不能见“人”(这里的人指的是最终用户,也就是说,Geeks 不算人:)——这是我能找到的用于区别自然键和代理键的最不让人困惑的标准了。
实体的 ID 需要被最终用户看到
那么,实体的 ID 应不应该见“人”(最终用户)呢?
我认为实体 ID 显然 99.99% 的可能性是需要见“人”的。
特别是对于 DDD 定义的实体,这个好像已经不需要再展开讨论了吧?因为 DDD 主张领域模型应该是由技术人员与领域专家共同参与构建的,而领域专家很可能就是最终用户。实体与值对象最本质的区别就是实体存在 ID,这么至关重要的事情,可不适合对领域专家藏着掖着吧?也许在软件的内部实现中,技术人员确实会使用一些不会被“软件所服务的领域”的最终用户看到的实体,但这些实体是“非主流”,而且对于这些实体来说,“技术领域”就是它服务的领域,技术人员就是它的最终用户,所以它还是被最终用户看到了。
提示
根据 DDD 的基本概念,有没有 ID 是实体和值对象的本质区别。没有 ID,实体这个概念就不存在了。而没有实体,就无法进行 DDD 领域模型的战术层面的设计。至关重要的实体的 ID 要被最终用户看见,所以它在数据库层面对应的东西就是自然键。所以,一个实体只有代理键没有自然键是不对的,代理键不能替代自然键。
上面的论证是从 DDD 的基本概念出发的,那么,不用 DDD 能不能开发出一个系统来?当然也是可以的……
如果实体的 ID 需要被最终用户看到,那么,在将领域模型映射到关系模型的时候,实体的 ID 的对应物就必然是“自然键”。(虽然 《领域驱动设计》一书中对此貌似有不同的看法,但是我坚持我的观点。)
另外,我想要说的是:不应该把键的生成方式当成区分代理键和自然键的标准。
这样的情形并不罕见:我们部署一个分布式的 ID 生成服务,我们即可以用它来生成最终用户看不到的代理键,也可以用来生成最终用户可以看到的自然键。
在给领域建模的时候,我完全可以做出这样的表述:订单号是订单实体的 ID,这个 ID 是 Arbitrary 的(也就是说我不关心它的编码规则,只要它是唯一的就好)。既然实体的 ID 有时候就是可以那么“随性”,所以谁规定自然键(领域 ID)就不能使用 GUID、HiLo Table 等方式生成呢?还有,只要最终用户可以接受,领域 ID 当然可以是一个顺序增长的的整数。
所以,如果一个 ID 需要被最终用户看到,即使它使用了数据库的“自增长列”来生成,那么也应该被理解为自然键。
既然实体的领域 ID——也就是自然键总是存在的,那么,大多数时候我们直接使用它就可以了啊。如果一定要给实体/表额外加上一个不能见“人”的代理键——多个“键”必然会加重我们程序员的选择困难症的,所以最好不要——那我们希望这玩意最好“沦落”为偶一为之的优化的技巧(Trick),省得大家经常需要为了选择用哪个“键”而伤脑筋。
那么,问题就是:代理键这个 Trick 应该在什么时候使用?它有什么好处和代价?
什么时候使用代理键?
同样是上面文章(Choosing a Primary Key: Natural or Surrogate?),这样举例说明使用代理键的好处(中文翻译版本):
自然键的缺点是由于具有业务含义,它们与业务直接耦合:你可能在业务需求变更时重新指定键。例如,当你的用户决定将 CustomerNumber 列从数字型改为字母数字型,除了更新表 Customer 的模式(这个是不可避免的)外,你还需要修改每一个使用 CustomerNumber 作为外键的表。
这里我们几乎可以非常肯定,在这个例子中,即使在 Customer 表引入一个代理键(假设列名叫做 ID),CustomerNumber(客户编号)仍然是个被广泛使用的备用键(即大家都知道它具有唯一性约束,并且会利用这一点)。也就是说,在现实世界里,CustomerNumber 可能会被很多最终用户看见,公司内部的各个部门,比如客服、订单处理、技术等部门,都可能使用客户编号来查询客户的信息。如果这个客户编号没有唯一性,只会带来没有必要的麻烦。
假设 Customer 表使用了一个代理键,当你在数据库中将 CustomerNumber 列从数字型改为字母数字型的时候,可能仅仅是修改数据库模式的工作量会小一些的。但是,不管怎么样,你仍然需要仔细地检查代码,确认所有使用了“Customer Number”这个概念的地方,都正确地处理了“CustomerNumber类型的变更”,你可能还要知会通过 API 使用了 Customer Number 的其他应用的开发团队。
让实体总是使用代理主键?
有人主张“让实体总是使用代理主键”的原因之一是:假设某个实体/表一开始使用的是自然主键,一段时间之后,可能发现开始作为自然主键的那个属性/列在现实中并不具备唯一性,这时候修改数据库的模式(Schema)和程序代码会比较麻烦。如果一开始这个表就使用了代理主键,那么修改会更容易。
我想,问题是:
如果,一开始这个表我们既使用了代理主键又使用了自然键作为备用键,那么,难道我们只需要把备用键那一列/那几列的唯一约束去掉就可以了?
或者,一开始这个表只使用了代理主键,没有其他备用键,这个设计是不是就没有问题?
对于第一种情况,和上面修改 CustomerNumber 的类型的问题一样,这些让“修改数据库模式更容易”的做法可能会诱惑开发人员放弃对领域的更深层次的思考,草率地进入编码阶段。当我们需要移除一个备用自然键,也许我们应该重新思考一些问题。比如到底是什么原因导致了当初的认知偏差?难道我们不需要检查其他代码,也不需要“告知”其他与当前应用存在交互的软件的开发团队?并且,如果去掉了某一列/某几列的唯一约束之后,这个实体/表除了代理主键没有其他的备用键,那么应用的最终用户是不是只能使用代理主键去追踪实体的状态从而自然化了代理键(让代理键实际上变成了自然键)?
对于第二种情况,一个实体/表只有代理主键(没有备用键),大多数时候我都会反对这么做。
关注领域 ID,忘掉代理主键
我的建议是:在设计领域模型的时候,先关注领域 ID,忘掉代理主键。
在我的工作中看到过的一个真实的例子,是某个电商系统的“会员等级表”只有一个自增长列代理主键,这个代理键是最终用户看不到的。这个系统曾经出现这样一个 Bug:部分客户在前端 App 上明明看着自己是“二星会员”,在购买商品的时候却没有享受到二星会员应该享受的折扣价。出现这个问题的时候用户和运营人员完全摸不着头脑。其实引发问题的原因是:数据库中存在两个名为“二星会员”的会员等级记录,其中有一个被运营人员设置为“弃用”,但是在客户表中,仍然有客户记录通过外键指向它。
虽然对于这个例子而言,当时开发人员编写的业务逻辑层的代码确实存在问题,但是,如果当初会员等级表除了存在一个代理键之外,还存在备用键,比如将表示等级高低的“等级序号”或“等级名称”作为备用键,那么这个问题就可以避免。因为运营人员将无法往表中添加具有同样等级序号或等级名称的多条记录。
很多很多开发人员实现代理主键的方式都是使用数据库的“自增长列”,也就是说在将记录数据插入数据库之前,我们并不知道记录的 ID。相信我,大多数时候,这种代理主键都是没有存在的必要的。一个实体/表只使用自增长列代理主键(没有其他备用键)往往会给数据库初始化、单元测试编写、系统集成、Bug 重现、数据分区(Sharding)、灰度发布等等方面带来各种麻烦。
比如说,当表中只存在一个自增长列代理键的时候,会给实现保证“幂等”的接口带来额外的复杂性。使用不幂等的接口来“导入数据”导致表中出现大批量的重复的数据——这样的问题我在工作中看到过很多次,有时候处理这些重复数据的代价极大。
我们使用的关系型数据库擅长保存数据,但不擅长实现“业务逻辑”,业务逻辑一般存在于数据库之外的应用代码中(我可不想考虑存储过程之类的存在)。我们经常会使用在代码中定义的一些表示实体 ID 的“常量”,然后使用它们到数据库中查找某些特定的记录,使用这些记录数据来实现某些业务逻辑。如果数据表只存在一个自增长列代理主键,会给这类业务逻辑的实现和测试带来不必要的麻烦。
所以,我强烈建议:在将领域模型的实体映射到关系模型的数据表时,如果一定要使用代理键,那么在代理键之外,务必设计有备用的自然键,这个自然键对应着实体的领域 ID。也就是说,一个表就算使用了代理键,我们还需要告诉大家:存在其他具有业务含义的列或列的组合是“键”,你们放心地可以利用这些“键”的唯一性,我们不会轻易地“取消”这些自然键。
我发现大多数人抗拒使用自然主键的原因主要是基于性能方面的考量。比如,如果仅使用代理主键,在插入记录前不用生成主键的值;如果使用自然键,插入前可能需要先去调用一个 ID Generator Service(ID 生成服务)。又比如,使用顺序增长的代理主键可以更有效地利用存储空间;使用一个整数型的代理主键来查找数据库记录,比起使用(可能根据最终用户的要求必须是)字符型的自然主键,速度可能快上不少。但是,“对软件的过早优化是万恶之源”,我们建议不要在一开始就给每个实体/表添加一个代理键,可以在后期有绝对必要的时候再去考虑作为“技术”优化的手段引入代理键。
<EOF>
本文节选自《深入实践 DDD:以 DSL 驱动复杂软件开发》,此书由拥有二十年商业软件开发经验及十年技术管理经验的资深技术专家杨捷锋撰写,是目前市场上仅有的阐述如何通过使用领域专用语言(DSL)实现领域驱动设计(DDD)的图书,本书深度解读DDD思想,揭示使用 DSL实现DDD快速落地的方法与技巧。得到众多业内人士的强烈推荐。
c
送书福利:在本文留言点赞前8的,由机械工业出版社免费赠送本书一本。截止时间:本周五(7月2日)23点之前。
参考阅读
原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。