我们设计关系数据库Schema的都有一套完整的方案,而NoSQL却没有这些。半年前笔者读了本《SQL反模式》的书,觉得非常好。就开始留意,对于NoSQL是否也有反模式?好的反模式可以在我们设计Schema告诉哪里是陷阱和悬崖。NoSQL宣传的时候往往宣称是SchemaLess的,这会让人误解其不需要设计Schema。但如果不意识到设计Schema的必要,陷阱就在一直在黑暗中等着我们。这篇文章就总结一些别人的,也有自己犯过的深痛的设计Schema错误。
NoSQL数据库最主流的有文档数据库,列存数据库,键值数据库。三者分别有代表作MongoDB,HBase和Redis。如果将NoSQL比作兵器的话,可以这样(MySQL是典型的关系型数据库,一样参与比较):
关系模型试图将数据库模型和数据库实现分开,让开发者可以脱离底层很好的操作数据。但笔者以为关系模型在一些应用场景下有弱点,现在已经不得不面对。
针对这两个梦魇。文档数据库如MongoDB的的主要目的是 提供更丰富的数据结构来抛弃Join来适应在线业务。当然也不是MongoDB完全不能用Join,不能拿来做数据分析,讨论这个只是见仁见智的问题。所以文档数据库并不比关系数据库强大,由于对Join的弱支持,功能会弱许多。设计关系模型的时候,通常只需要考虑好数据直接的关系,定义数据模型。而设计文档数据库模型的时候,还需要考虑应用如何使用。因此设计好一个的文档数据库Schema比设计关系模型更加的困难。除此之外,由于文档数据库事务的支持也是比较弱,一般NoSQL只会提供一个行锁。这也给设计Schema更加增加了难度。对于文档数据库的使用有很多需要注意的地方,本文只关注模型设计的部分。
关系模型是数据存储的经典模型,使用数据模型范式的好处非常的明显。但是由于文档数据库不支持Join(包括和外键息息相关的外键约束)等特性,习惯性的沿用关系模型有的时候会出现问题。需要利用起文档数据库提供的丰富的数据模型来应对。
值得一提的是文档数据库的设计和关系模型不同,是灵活多样的。对于同一个情形,可以设计出有多种能够工作的模型,没有绝对意义上最好的模型。
下图是关系模型和文档模型的对比。
这个一个博客的数据模型,有Blog,User等表。左侧是关系模型,右侧是文档模型。这个文档模型并不是完全合理,可以作为“正反两面教材”在下文不断阐述。
问题一:存在描述多对多的关系表
症状:文档数据库中存储在有纯粹的关系表,例如:
id | user_id | blog_id |
0 | 0 | 0 |
1 | 0 | 1 |
这样的表就算在关系模型中也是不妥的,因为这个ID非常的多余,可以用联合主键来解决。但是在文档数据库中,由于必须强制单主键,不得不采取这样的设计。
坏处:
解决方案:
使用文档数据库典型的处理多对多的办法。不是建立一张关系表,而是在其中一个文档(如User)中,加入一个List字段。
user_id | user_name | blog_id[] | …… |
0 | Jake | 0,1 | …… |
1 | Rose | 1,2 | …… |
问题二:没有区分"一对多关系"和“多对一关系”
症状:关系模型不区分“一对多”和“多对一”,对于文档数据库来讲,关系模型只有“多对一”。就像这张Comment表:
comment_id | user_id | content | …… |
0 | 0 | “NoSQL反模式是好文章” | …… |
1 | 0 | “是啊” | …… |
如果整个模型都是这样的“多对一”关系就需要反思了。
坏处:
解决方案:
问题的核心在于是已知user_id查询两张表,还是已知comment_id查询两张表。如果是已知comment_id这样的设计就是合理的,但是如果是已知user_id来查询,把关系放在user表里的设计更合理一些。
user_id | user_name | comment_id[] | …… |
0 | Jake | 0,1 | …… |
1 | Rose | 1,2 | …… |
这样的设计,就可以避免一个索引。同理,对于多对多也是一样的,通过合理的安排字段的位置可以避免索引。
正确使用的场合:
关系型模型是非常成功的数据模型,合理的沿用是非常好的。但是由于文档数据库的特点,需要适当的调整,这样得出的数据模型,尽管性能不是最优,但是有最好的灵活性。并且也有利于和关系数据库转换。
症状:数据库设计中充满了xx_id的字端,在查询的时候需要大量的手动Join操作。就涉及到了这个反模式。正如上面提到的博客的关系模型,如果已知blog_id查询comments,需要至少执行3次查询,并且手动Join。
坏处:
解决方案:
适当使用内联数据结构。由于文档数据库支持更复杂的数据结构可以将引用转换为内联的数据,而不用新建一张表。这样做可以解决上面的一些问题,是一个推荐的方案。就像上面博客的例子一样。将五张表简化成了两张表。那什么时候使用内联呢?一般认为
范式化的使用场景,文档数据库会被多个应用使用。由于数据库设计无法估计多个应用现在及将来的查询情况,需要极大的灵活性。在这个时候,使用引用比内联靠谱。
问题一 妨碍到查询的内联
症状:频繁查询一些内联字段,丢弃其他字段。
坏处:
解决方案:
如果出现以上的症结,就可以考虑使用引用代替内联了。内联特性主要的用途在于提高性能,如果出现性能不升反降,那就没有意义了。如果对性能有很强烈的要求,可以考虑使用重复数据,同样的数据即在内联字段中也在引用的表里面。这样可以结合内联和引用的性能优势。缺点是数据出现重复,维护会比较麻烦。
问题二 无限膨胀的内联
症状:List,Map类型的内联字段不断膨胀,而且没有限制。就像前面提到的Blog的内联字段Comment。如果对每一篇Blog的Comment数量没有限制的话,Comment会无限膨胀。轻则影响性能,重则插入失败。
Blog_id | content | Comment[] | …… |
0 | “…” | “NoSQL反模式是好文章”, “是啊”,”无限增长中”… | …… |
坏处:
解决方案:
设定最大数目或者使用引用。还是Blog和Comment的例子,可以将Comment从Blog中剥离出成一张表。如果考虑到性能,可以在Blog表中新建一个字段如最近的评论。这样既保证了性能,又能够预防膨胀。
Blog_id | content | last_five_comment[] | …… |
0 | “…” | “NoSQL反模式是好文章”, “是啊”,”最多5条”… | …… |
问题三 无法维护的内联
症状:DBA想单独维护内联字段,但无法做到。
坏处:
问题四 盯死应用的内联
症状:应用可以非常好的运行在数据库上。但是当新的应用接入的时候会很麻烦。因为设计数据模型的时候考虑到了查询。所以当有新应用,新查询接入的时候,就会难于使用原有的模型。
坏处:
解决方案:
使用范式设计数据库,即用引用代替内联。或者在使用内联的时候,给每个内联对象一个全局唯一的Key,保证其和关系模型直接可以存在映射关系,这样可以提高数据模型的灵活性。如Blog表:
Blog_id | content | Comment[] | …… |
0 | “…” | [{"id"=1,"content"=“NoSQL反模式是好文章”}, {"id"=2,"content"=“是啊”}…] | …… |
症状:有一些运行时间很长的Query,由于有聚合计算,索引也不能解决。随着数据量的增长,逐渐成为性能瓶颈。
坏处:
解决方案:
首先要权衡,这个聚合操作是不是必要的,必须实时完成。如果没有必要实时完成的话,可以采取离线操作的方案。在夜深人静的时候,跑一个长查询,将结果缓存起来,给第二天使用。如果必须实时完成,则可以新建一个字段,用“incr”这样的操作,在运行的时候,实时聚合结果。而不是查询的时候执行一次长查询。如果逻辑比较复杂,或者觉得大量“incr”操作给数据库系统带来了压力,可以使用Storm之类的实时数据处理框架。总之,要慎用长查询。
症状:文档数据库支持内联Map类型。将其中Map的Key当作数据库的主键来用。
Blog_id | content | Comment{} | …… |
0 | “…” | {"1"=“NoSQL反模式是好文章”, "2"=“是啊”} | …… |
这个反模式很容易犯,因为在编程语言中Map数据结构就是这么用的。但是对于数据库模型来说,这是不折不扣的反模式。
坏处:
解决方案:
使用数组+Map来解决。如:
Blog_id | content | Comment[] | …… |
0 | “…” | [{"id"=1,"content"=“NoSQL反模式是好文章”}, {"id"=2,"content"=“是啊”}…] | …… |
症状:使用String甚至更复杂数据结构作为的ID,或者全部使用数据库提供的自生成ID。如:
id(该ID系系统自生成) | Blog_id | content | …… |
0 | 0 | ... | …… |
坏处:
解决方案:
尽量使用有一定意义的字段做ID,并且不在其他字段中重复出现。不使用复杂的数据类型做ID,只使用int,long或者系统提供的主键类型做ID。
阐述了这么多的反模式,下面有个一览表,涵盖了上面所有的反模式。这个一览表,是按照文档数据库模型建立的。是个文档数据库模型的例子。
ID | 反模式名 | 问题 |
0 | 存在描述多对多的关系表 | [{ID:00 症状:文档数据库中存储在有纯粹的关系表 坏处:[破坏数据完备性,索引过多] 解决方案:加入一个List字段 },{ ID:01 症状:关系模型不区分“一对多”和“多对一” 坏处:额外索引 解决方案:合理的安排字段的位置 }] |
1 | 处处引用客户端Join | [{ ID:10 症状:查询的时候需要大量的手动Join操作 坏处:[手动Join,多次查询, 事务处理繁琐] 解决方案:适当使用内联数据结构。 }] |
2 | 滥用内联后患无穷 | [{ ID:20 症状:频繁查询一些内联字段,丢弃其他字段 坏处:[无ID约束,索引泛滥, 性能浪费] 解决方案:使用引用代替内联了,允许重复数据 },{ ID:21 症状:List,Map类型的内联字段不断膨胀,而且没有限制 坏处:[插入失败, 性能拖油瓶] 解决方案:设定最大数目或者使用引用。 },{ ID:22 症状:DBA想单独维护内联字段,但无法做到 坏处:[权限管理难, 切表难, 备份难] 解决方案:设计数据库模型的时候需要考量之后的维护操作 },{ ID:23 症状:应用可以非常好的运行在数据库上。但是当新的应用接入的时候会很麻烦。内联盯死了应用 坏处:[新应用接入难, 集成难, ETL难] 解决方案:使用范式设计数据库,即用引用代替内联。保证其和关系模型直接可以存在映射关系 }] |
3 | 在线计算 | [{ ID:30 症状:有一些运行时间很长的Query, 逐渐成为性能瓶颈。 坏处:[影响用户体验,影响数据库性能] 解决方案:取消不必要的聚合操作. 运行的时候,实时聚合结果.使用第三方实时或非实时工具。如Hadoop,Storm. }] |
4 | 把内联Map对象的Key当作ID用 | [{ ID:40 症状:文档数据库支持内联Map类型。将其中Map的Key当作数据库的主键来用。 坏处:[无法通过数据库做各种(><=)查询,无法通过索引查询] 解决方案:使用数组+Map来解决。 }] |
5 | 不合理的ID | [{ ID:50 症状:用String甚至更复杂数据结构作为的ID,或者全部使用数据库提供的自生成ID。 坏处:[ID混乱,索引庞大] 解决方案:尽量使用有一定意义的字段做ID。不使用复杂的数据类型做ID。 }] |
本文试图总结了笔者知道的重要的文档数据库的反模式。现在关于NoSQL数据模型设计模式的讨论才刚刚起步,将来也许会逐渐自成体系。对于列数据库和Key-Value的反模式,笔者等到有了足够积累的时候,再和大家分享。