译者述
大部分开发者应该都应该接触过关系型数据库,或者说大部分使用文档数据库的开发者也是先有关系型数据库的经验,然后再接触文档数据库。可能设计思想上也会是关系型数据库先入为主,读了这篇文章后,了解了很多文档数据库相关的概念和优化技巧,通过一个完整的通俗的实例来了解了如何合理的设计这种nosql型数据库的数据模型,一步步的提高查询和写的性能,虽然这篇文章是专门针对Cosmos DB的优化,但我认为应该适用于所有的文档型数据模型的设计。这两篇文章来自微软官网的https://docs.microsoft.com/en-us/azure/cosmos-db/modeling-data,由于没有中文翻译,因此翻译出来,希望能帮助到大家,也加深自己的理解。第一篇着重与概念,第二篇是完整的实践,通过六大优化方法来优化读写效率。
写在前面
模式自由数据库(schema-free),像Azure Cosmos DB,存储和查询非结构化和半结构化数据非常容易,但我们需要从性能,可扩展性和低成本这几个方面来来考虑如何设计合适的数据模型。数据怎样被存储?你的程序将怎样获取和查询数据?你的应用是读重还是写重?读了这篇文章后,你将能回答下面的问题:
数据建模是什么,为什么我们需要关注数据建模
Cosmos DB的数据建模是哪些方面不同于传统的关系型数据库?
在非关系型数据库是如何表达数据关系的?
什么时候使用内嵌数据,什么时候使用引用数据?
内嵌数据(Embedding data)
当你在Cosmos DB里开始数据建模时,把数据实体作为自包含项表示在json文档中。相比较关系型数据库,下面是一个例子,person如何存储在关系型数据库中。当使用关系型数据库时,采取的策略是规范化所有的数据。规范化你的数据,典型的做法就是把一个实体,例如person,拆分成独立的组件。在上面的例子中,一个person有多个contact detail,也有多个address。contact details也可以进一步提取出公共的字段,比如type。同样address也可以被提取出type,比如home或者business。
规范化数据的指导方针是避免在每条记录上存储冗余数据,更倾向于使用数据引用,即主外键关系。在上面的例子中,为了读取person对象,并获取你的contact details和address,你需要用JOINS来获取关联的对象。比如下面的sql语句
SELECT p.FirstName, p.LastName, a.City, cd.Detail
FROM Person p
JOIN ContactDetail cd ON cd.PersonId = p.Id
JOIN ContactDetailType cdt ON cdt.Id = cd.TypeId
JOIN Address a ON a.PersonId = p.Id
更新一个person及contact details和address对象也需要跨多表来完成写操作。
下面来介绍在Cosmos DB中如何使用自包含(self-contained)实体来对相同的数据建模。
{
"id": "1",
"firstName": "Thomas",
"lastName": "Andersen",
"addresses": [
{
"line1": "100 Some Street",
"line2": "Unit 1",
"city": "Seattle",
"state": "WA",
"zip": 98012
}
],
"contactDetails": [
{"email": "thomas@andersen.com"},
{"phone": "+1 555 555-5555", "extension": 5555}
]
}
使用非规范化的(denormalized)方法,我们嵌入了所有的与person相关的信息到单个的文档中,例如他们的contact details和address信息。另外,因为我们没有被限制在一个固定的schema,我们可以灵活的设计schema,比如contact details中包含两个完全不同的对象。从Cosmos DB中获取一个person,包括address和contact details对象,现在只需要一个单独的针对单个容器和单个item的读操作。更新person对象也如此。依靠非规范化数据,你的应用只需要少数的查询和更新来完成一些常规的数据库操作。
什么时候嵌入
通常来讲,在以下场景用嵌入数据(embedded data)
实体之间有包含关系
实体之间是一对少的关系(译者注:比如一本书有多个作者,虽然作者是多个,但是数量是有限的,而不是无界的)
有不经常变化的数据
有不会无限增长的数据
那些经常一起被查询的数据
注释非规范化数据一般会提供较好的读取性能。
什么时候不要嵌入数据
在Cosmos DB中的经验法则是非规范化每个实体并且嵌入所有数据到一个单独的项中,但这可能导致一些场景我们也需要避免,以下面的json片段为例,
{
"id": "1",
"name": "What's new in the coolest Cloud",
"summary": "A blog post by someone real famous",
"comments": [
{"id": 1, "author": "anon", "comment": "something useful, I'm sure"},
{"id": 2, "author": "bob", "comment": "wisdom from the interwebs"},
…
{"id": 100001, "author": "jane", "comment": "and on we go ..."},
…
{"id": 1000000001, "author": "angry", "comment": "blah angry blah angry"},
…
{"id": ∞ + 1, "author": "bored", "comment": "oh man, will this ever end?"},
]
}
如果我们正在为一个典型的blog系统建模,一个post实体,嵌入了很多的comments,这个例子的问题是comments是一个无边界的数据,意味着一个单个的post可能有无限的comments,这会导致项目的无限扩大。由于项目的尺寸的增长也会影响到读取,更新的性能。在这种场景下,考虑使用下面的数据模型比较合适。
Post item:
{
"id": "1",
"name": "What's new in the coolest Cloud",
"summary": "A blog post by someone real famous",
"recentComments": [
{"id": 1, "author": "anon", "comment": "something useful, I'm sure"},
{"id": 2, "author": "bob", "comment": "wisdom from the interwebs"},
{"id": 3, "author": "jane", "comment": "....."}
]
}
Comment items:
{
"postId": "1"
"comments": [
{"id": 4, "author": "anon", "comment": "more goodness"},
{"id": 5, "author": "bob", "comment": "tails from the field"},
...
{"id": 99, "author": "angry", "comment": "blah angry blah angry"}
]
},
{
"postId": "1"
"comments": [
{"id": 100, "author": "anon", "comment": "yet more"},
...
{"id": 199, "author": "bored", "comment": "will this ever end?"}
]
}
上面的模型中,有最近三条comments嵌入在post容器中,它是一个固定长度的列表集合。其他的comments是按照100条comments分组存储在单个项目中。batch size选择100,是因为业务允许用户一次导入100条comments。另一个不适合使用嵌入数据的场景是经常改变的数据和经常跨多项被查询的数据。如下面的JSON片段为例,
{
"id": "1",
"firstName": "Thomas",
"lastName": "Andersen",
"holdings": [
{
"numberHeld": 100,
"stock": { "symbol": "zaza", "open": 1, "high": 2, "low": 0.5 }
},
{
"numberHeld": 50,
"stock": { "symbol": "xcxc", "open": 89, "high": 93.24, "low": 88.87 }
}
]
}
上面的实例表示的一个person的股票信息,我们选择嵌入股票信息到每个person中。但股票交易信息不断改变,那意味着每次股票信息更新时都需要更新person对象。比如股票zaza一天会被交易几千次,几千个用户持有zaza股票,意味着我们不得不每天更新几千次的股票信息。
引用数据(Referencing data)
在许多场景下当非规范化你的数据时,使用嵌入数据的方式能很好的工作,但也有些场景效果不是很理想,就像上面的股票例子,那我们应该做些什么呢?关系型数据库不是唯一可以在实体间使用关系的地方,在文档数据库中,我们也可以在一个文档中有信息关联其他文档中的数据。在Cosmos DB中,我们不推荐完全按照关系型数据的方式来建立系统,但一些简单的关系也是有借鉴意义的。如下面的json例子,我们选择了使用关系型的方式来进行数据建模。这种方式,当股票交易信息经常改变时,我们仅需要更新单个的股票项。
Person document:
{
"id": "1",
"firstName": "Thomas",
"lastName": "Andersen",
"holdings": [
{ "numberHeld": 100, "stockId": 1},
{ "numberHeld": 50, "stockId": 2}
]
}
Stock documents:
{
"id": "1",
"symbol": "zaza",
"open": 1,
"high": 2,
"low": 0.5,
"vol": 11970000,
"mkt-cap": 42000000,
"pe": 5.89
},
{
"id": "2",
"symbol": "xcxc",
"open": 89,
"high": 93.24,
"low": 88.87,
"vol": 2970200,
"mkt-cap": 1005000,
"pe": 75.82
}
如果你的应用是要求显示每个人持有的股票信息,这种设计的缺点是需要经过多轮的数据库查询才能完成。这种设计我们能改进写操作和更新操作的效率,但反过来在读操作性能上需要作出妥协。
注释规范化数据模型可能要求向服务器提交多轮的数据查询。
关于外键
因为当前没有类似于关系型数据库中的约束,外键的概念,在文档中的内部文档关系都是”弱连接“,不会被数据库本身验证。如果你想确保文档中引入的数据是真实的存在,在Cosmos DB中你需要在你的应用里去验证,或者通过服务端的触发器或者存储过程实现。
什么时候使用引用
通常来讲,下列场景使用规范化数据模型:
表示一对多的关系
表示多对多的关系
数据经常发生改变
被引用的数据是无边界的
注释
规范化数据模型可能要求向服务器提交多轮的数据查询。
关系放在哪里?
关系的增长将帮助决定在哪个文档里存储引用。看下面的model,publishers和books。
Publisher document:
{
"id": "mspress",
"name": "Microsoft Press",
"books": [ 1, 2, 3, ..., 100, ..., 1000]
}
Book documents:
{"id": "1", "name": "Azure Cosmos DB 101" }
{"id": "2", "name": "Azure Cosmos DB for RDBMS Users" }
{"id": "3", "name": "Taking over the world one JSON doc at a time" }
...
{"id": "100", "name": "Learn about Azure Cosmos DB" }
...
{"id": "1000", "name": "Deep Dive into Azure Cosmos DB" }
If the number of the books per publisher is small with limited growth, then storing the book reference inside the publisher document may be useful. However, if the number of books per publisher is unbounded, then this data model would lead to mutable, growing arrays, as in the example publisher document above.
模型稍微改变一点将导致模型仍代表相同的数据,但现在避免了大量的变化集合,如下,
Publisher document:
{
"id": "mspress",
"name": "Microsoft Press"
}
Book documents:
{"id": "1","name": "Azure Cosmos DB 101", "pub-id": "mspress"}
{"id": "2","name": "Azure Cosmos DB for RDBMS Users", "pub-id": "mspress"}
{"id": "3","name": "Taking over the world one JSON doc at a time"}
...
{"id": "100","name": "Learn about Azure Cosmos DB", "pub-id": "mspress"}
...
{"id": "1000","name": "Deep Dive into Azure Cosmos DB", "pub-id": "mspress"}
在上面的实例中,我们在Publisher文档里抛弃了无边界的books集合,在Book文档中,为每个book文档增加了对publisher的引用。
怎样为多对多关系建模
在关系型数据库中,多对多关系经常是使用关联表,关联表将把其他表关联在一起。实例中的BookAuthorLnk就是一个关联表。
你可能想尝试在文档中复制同样的方法,使用关联文档,产生一个类似于下面的数据模型。
Author documents:
{"id": "a1", "name": "Thomas Andersen" }
{"id": "a2", "name": "William Wakefield" }
Book documents:
{"id": "b1", "name": "Azure Cosmos DB 101" }
{"id": "b2", "name": "Azure Cosmos DB for RDBMS Users" }
{"id": "b3", "name": "Taking over the world one JSON doc at a time" }
{"id": "b4", "name": "Learn about Azure Cosmos DB" }
{"id": "b5", "name": "Deep Dive into Azure Cosmos DB" }
Joining documents:
{"authorId": "a1", "bookId": "b1" }
{"authorId": "a2", "bookId": "b1" }
{"authorId": "a1", "bookId": "b2" }
{"authorId": "a1", "bookId": "b3" }
上面方法可以工作,然而,加载author和他们的books,或者加载一本书和他的作者,总是要求至少两个额外的数据库查询,一个查询是对关联文档,另一个查询是获取被关联的实际文档。如果所有的关联表所做的是粘合两片数据在一起,那么为什么不完全抛弃它,考虑使用下面的模型
Author documents:
{"id": "a1", "name": "Thomas Andersen", "books": ["b1", "b2", "b3"]}
{"id": "a2", "name": "William Wakefield", "books": ["b1", "b4"]}
Book documents:
{"id": "b1", "name": "Azure Cosmos DB 101", "authors": ["a1", "a2"]}
{"id": "b2", "name": "Azure Cosmos DB for RDBMS Users", "authors": ["a1"]}
{"id": "b3", "name": "Learn about Azure Cosmos DB", "authors": ["a1"]}
{"id": "b4", "name": "Deep Dive into Azure Cosmos DB", "authors": ["a2"]}
现在,如果我有个author,我可以立即知道他已经写的所有书,反过来,如果我有本书,我也立即知道作者的ID,这样节省了对关联表的中间查询,减少了你的应用对服务的查询次数。
混合数据模型
我们已经看到了嵌入(或者非规范化)和引用(规范化)数据模型,每一个有它们各自的优点和缺点。这两种数据模型不一定总是必须是,或者是,我们可以混合在一起使用。基于你的应用场景和负载,将两者结合使用可以简化应用逻辑,降低服务请求次数,而且仍然保持一个好的性能。
Author documents:
{
"id": "a1",
"firstName": "Thomas",
"lastName": "Andersen",
"countOfBooks": 3,
"books": ["b1", "b2", "b3"],
"images": [
{"thumbnail": "https://....png"}
{"profile": "https://....png"}
{"large": "https://....png"}
]
},
{
"id": "a2",
"firstName": "William",
"lastName": "Wakefield",
"countOfBooks": 1,
"books": ["b1"],
"images": [
{"thumbnail": "https://....png"}
]
}
Book documents:
{
"id": "b1",
"name": "Azure Cosmos DB 101",
"authors": [
{"id": "a1", "name": "Thomas Andersen", "thumbnailUrl": "https://....png"},
{"id": "a2", "name": "William Wakefield", "thumbnailUrl": "https://....png"}
]
},
{
"id": "b2",
"name": "Azure Cosmos DB for RDBMS Users",
"authors": [
{"id": "a1", "name": "Thomas Andersen", "thumbnailUrl": "https://....png"},
]
}
上面的例子,我们看到大部分是嵌入模型,那些来自其他实体的数据是被嵌入在顶层的文档对象中,但也有引用数据。看book文档,我们看到一组的authors数组,每个author有一个id字段来自对author的引用,这是一个标准的引用模型,但我们看到name和thumbnailUrl字段,这是因为我们的应用在显示书的信息是也显示作者的name和thumbnailUrl,因此我们可以使用非规范化的数据来减少一次对服务的请求。
当然,如果作者更改了name或者他们想更新他们的照片,我们不得不更新他曾经出版的每本书,但对于我们的应用,基于的一个假设是作者不经常更新他们的名字和照片,这是一个可以接受的方案。
在上面的实例中,也有预计算的聚合字段来节省读操作的处理时间。在author文档,一些嵌入的数据是在运行时计算的。每次一本新的book被出版,一个新的book文档将被创建,并且该作者的book文档的数量,字段countOfBooks将被计算。在读取繁重的系统中,这种优化将是不错的选择,在这种系统中,我们可以承担对写入进行计算以优化读取的工作。
由于Azure Cosmos DB支持多文档事务,因此有预先计算的字段的模型的能力。由于许多NoSQL存储区无法跨文档进行事务处理,由于这种限制,因此主张“始终嵌入所有内容”这种设计。在Azure Cosmos DB,我们可以使用服务器端触发器或存储过程,在一个ACID事物里插入books和更新authors。现在,你只需确保数据保持一致即可,不必将所有内容都嵌入一个文档中。
区分不同的文档类型
在一些场景里,你可能想混合不同的文档类型在一个相同的集合中。通常当你想多个相关的文档放在相同的分区中。例如,你可能想放books和book reviews在同一个集合中,并且按照bookID作为分区。在如此场景下,我们可以增加一个type字段来表示不同的文档类型。如下实例。
Book documents:
{
"id": "b1",
"name": "Azure Cosmos DB 101",
"bookId": "b1",
"type": "book"
}
Review documents:
{
"id": "r1",
"content": "This book is awesome",
"bookId": "b1",
"type": "review"
},
{
"id": "r2",
"content": "Best book ever!",
"bookId": "b1",
"type": "review"
}
译者注:这是模式自由的一个最大的优点,在关系型数据库中没法将不同schema的表放在一张表中,但是文档数据库可以,因此利用此特性,可以将相同类型的数据放在同一个partition中,大大提高读写效率
下一步
从这篇文章中,最大的收获是理解模式自由(schema-free)的数据建模与以往关系型数据建模一样重要,而且不是一个“死标准”。就像没有一种方法可以在屏幕上表示一条数据一样,没有单个方法可以对数据进行建模。您需要了解您的应用程序以及它将如何产生,使用和处理数据。然后,通过应用此处介绍的一些准则,您可以着手创建一个满足应用程序需求的模型。当您的应用程序需要更改时,您可以利用模式自由数据库的灵活性来更改并轻松地演进你的数据模型。
网友评论