数据模型设计
多年以来,关系型数据库已经成为了企业数据管理的基础,很多工程师对于关系模型和 6 个范式都比较了解,但是如今来构建和运行一个应用,随着数据来源的越发多样和用户量的不断增长,关系数据库的限制逐渐成为业务的瓶颈,因此越来越多的公司开始向其他 NoSQL 数据库进行迁移。
TDS 的数据存储后台大量采用了 MongoDB 这种文档数据库来存储结构化数据,正因如此我们才能提供面向对象的、海量的、无需创建数据表结构即存即用的存储能力。从传统的关系型数据库转换到 TDS 或者 MongoDB 存储系统,最基础的改变就是「数据建模 Schema 设计」。
首先来梳理一下关系型数据库、MongoDB 和 TDS 数据存储的对应术语:
RDBMS | MongoDB | TDS 数据存储 |
---|---|---|
Database | Database | Application |
Table | Collection | Class |
Row | Document | Object |
Index | Index | Index |
JOIN | Embedded,Reference | Embedded Object, Pointer |
在 TDS 上进行数据建模设计需要数据架构师、开发人员和 DBA 在观念上做一些转变:之前是传统的关系型数据模型,所有数据都会被映射到二维的表结构「行」和「列」;现在是丰富、动态的对象模型,即 MongoDB 的「文档模型」,包括内嵌子对象和数组。
文档模型
后文中我们有时候采用 TDS 数据存储的核心概念 Object(对象),有时候提到 MongoDB 中的名词 Document(文档),它们是等同的。
我们现在使用的大部分数据都有比较复杂的结构,用「JSON 对象」来建模比用「表」会更加高效。通过内嵌子对象和数组,JSON 对象可以和应用层的数据结构完全对齐。这对开发者来说,会更容易将应用层的数据映射到数据库里的对象。相反,将应用层的数据映射到关系数据库的表,则会降低开发效率。而比较普遍的增加额外的对象关系映射(ORM)层的做法,也同时降低了 schema 扩展和查询优化的灵活性,引入了新的复杂度。
例如,在 RDBMS 中有父子关系的两张表,通常就会变成 TDS 里面含有内嵌子对象的单文档结构。以下图的数据为例:
PERSON 表
Person_ID | Surname | First_Name | City |
---|---|---|---|
0 | 柳 | 红 | 伦敦 |
1 | 杨 | 真 | 北京 |
2 | 王 | 新 | 苏黎世 |
CAR 表
Car_ID | Model | Year | Value | Person_ID |
---|---|---|---|---|
101 | 大众迈腾 | 2015 | 180000 | 0 |
102 | 丰田汉兰达 | 2016 | 240000 | 0 |
103 | 福特翼虎 | 2014 | 220000 | 1 |
104 | 现代索纳塔 | 2013 | 150000 | 2 |
RDBMS 中通过 Person_ID 域来连接 PERSON 表和 CAR 表,以此支持应用中显示每辆车的拥有者信息。使用文档模型,通过内嵌子对象和数组可以将相关数据提前合并到一个单一的数据结构中,传统的跨表的行和列现在都被存储到了一个文档内,完全省略掉了 join 操作。
换成 TDS 来对同样的数据建模,则允许我们创建这样的 schema:一个单一的 Person 对象,里面通过一个子对象数组来保存该用户所拥有的每一部 Car,例如:
{
"first_name":"红",
"surname":"柳",
"city":"伦敦",
"location":[
45.123,
47.232
],
"cars":[
{
"model":"大众迈腾",
"year":2015,
"value":180000
},
{
"model":"丰田汉兰达",
"year":2016,
"value":240000
}
]
}
文档数据库里的一篇文档,就相当于 TDS 平台里的一个对象。这个例子里的关系模型虽然只由两张表组成(现实中大部分应用可能需要几十、几百甚至上千张表),但是它并不影响我们思考数据的方式。
为了更好地展示关系模型和文档模型的区别,我们用一个博客平台来举例。从下图中可以看出,依赖 RDBMS 的应用需要 join 五张不同的表来获得一篇博客的完整数据,而在 TDS 中所有的博客数据都包含在一个文档中,博客作者和评论者的用户信息则通过一个到 User 的引用(指针)进行关联。
文档模型的优点
除了数据表现更加自然之外,文档模型还有性能和扩展性方面的优势:
- 通过单一调用即可获得完整的文档,避免了多表 join 的开销。TDS 的 Object 物理上作为一个单一的块进行存储,只需要一次内存或者磁盘的读操作即可。RDBMS 与此相反,一个 join 操作需要从不同地方多次读取操作才可完成。
- 文档是自包含的,将数据库内容分布到多个节点(也叫 Sharding)会更简单,同时也更容易通过普通硬件的水平扩展获得更高性能。DBA 们不再需要担心跨节点进行 join 操作可能带来的性能恶化问题。
定义文档 Schema
应用的数据访问模式决定了 schema 设计,因此我们需要特别明确以下几点:
- 数据库读写操作的比例以及是否需要重点优化某一方的性能;
- 对数据库进行查询和更新的操作类型;
- 数据生命周期和文档的增长率;
以此来设计更合适的 schema 结构。
对于普通的「属性名:值」来说,设计比较简单,和 RDBMS 中平坦的表结构差别不大。对于「一对一」或「一对多」的关系会很自然地考虑使用内嵌对象:
- 数据「所有」和「包含」的关系,都可以通过内嵌对象来进行建模。
- 同时,在架构上也可以把那些经常需要同时、原子改动的属性作为一个对象嵌入到一个单独的属性中。
例如,为了记录每个学生的家庭住址,我们可以把住址信息作为一个整体嵌入 Student 类里面。
- Unity
- Android
- iOS
LCObject studentTom = new LCObject("Student");
studentTom["name"] = "Tom";
var addr = new Dictionary<string, object>();
addr["city"] = "北京";
addr["address"] = "西城区西长安街 1 号";
addr["postcode"] = "100017";
studentTom["address"] = addr;
await studentTom.Save();
AVObject studentTom = new AVObject("Student");
studentTom.put("name", "Tom");
HashMap<Object, Object> addr = new HashMap<>();
addr.put("city", "北京");
addr.put("address", "西城区西长安街 1 号");
addr.put("postcode", "100017");
studentTom.put("address", addr);
studentTom.saveInBackground().subscribe(new Observer<AVObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(AVObject studentTom) {
// 成功保存之后,执行其他逻辑
System.out.println("保存成功。objectId:" + studentTom.getObjectId());
}
public void onError(Throwable throwable) {
// 异常处理
}
public void onComplete() {}
});
LCObject *studentTom = [LCObject objectWithClassName:@"Student"];
[studentTom setObject:@"Tom" forKey:@"name"];
NSDictionary *addr = [NSDictionary dictionaryWithObjectsAndKeys:
@"北京", @"city",
@"西城区西长安街 1 号", @"address",
@"100017", @"postcode",
nil];
[studentTom setObject:addr forKey:@"address"];
[studentTom saveInBackgroundWithBlock:^(BOOL succeeded, NSError *error) {
if (succeeded) {
// 成功保存之后,执行其他逻辑
NSLog(@"保存成功。objectId:%@", studentTom.objectId);
} else {
// 异常处理
}
}];
但并不是所有的一对一关系都适合内嵌的方式,对下面的情况后文介绍的「引用」(等同于 MongoDB 的 reference)方式会更加合适:
- 一个对象被频繁地读取,但是内嵌的子对象却很少会被访问。
- 对象的一部分属性频繁地被更新,数据大小持续增长,但是剩下的一部分属性基本稳定不变。
- 对象大小超过了 TDS 当前最大 16 MB 限制。
接下来我们重点讨论一下在 TDS 上如何通过「引用」机制来实现复杂的关系模型。
数据对象之间存在 3 种类型的关系:「一对一」将一个对象与另一个对象关联,「一对多」是一个对象关联多个对象,「多对多」则用来实现大量对象之间的复杂关系。
我们支持 3 种方式来构建对象之间的关系,这些都是通过 MongoDB 的文档引用来实现的:
- Pointers(适合一对一、一对多关系)
- 中间表(多对多)
Arrays(一对多、多对多)不建议使用,请参考 何时使用数组。
一对多关系
Pointers 存储
中国的「省份」与「城市」具有典型的一对多的关系。深圳和广州(城市)都属于广东省(省份),而朝阳区和海淀区(行政区)只能属于北京市(直辖市)。广东省对应着多个一级行政城市,北京对应着多个行政区。下面我们使用 Pointers 来存储这种一对多的关系。
为了表述方便,后文中提及城市都泛指一级行政市以及直辖市行政区,而省份也包含了北京、上海等直辖市。
- Unity
- Android
- iOS
LCObject guangZhou = new LCObject("City");
guangZhou["name"] = "广州";
LCObject guangDong = new LCObject("Province");
guangDong["name"] = "广东";
guangZhou["dependent"] = guangDong;
// 广东无需单独保存,因为在保存广州时自动保存了广东
await guangZhou.Save();
AVObject guangZhou = new AVObject("City");
guangZhou.put("name", "广州");
AVObject guangDong = new AVObject("Province");
guangDong.put("name", "广东");
guangZhou.put("dependent", guangDong);
guangZhou.saveInBackground().subscribe(new Observer<AVObject>() {
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(AVObject guangZhou) {
// 广州、广东保存成功(广东无需单独保存)
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
LCObject *GuangZhou = [LCObject objectWithClassName:@"City"];
[GuangZhou setObject:@"广州" forKey:@"name"];
LCObject *GuangDong = [LCObject objectWithClassName:@"Province"];
[GuangDong setObject:@"广东" forKey:@"name"];
[GuangZhou setObject:GuangDong forKey:@"dependent"];
[GuangZhou saveInBackgroundWithBlock:^(BOOL succeeded, NSError *error) {
if (succeeded) {
// 广州、广东保存成功(广东无需单独保存)
}
}];
保存关联对象的同时,被关联的对象也会随之被保 存到云端。
执行上述代码后,在应用控制台可以看到 dependent
字段显示为 Pointer 数据类型,而它本质上存储的是一个指向 Province
这张表的某个 AVObject 的指针。
要关联一个已经存在于云端的对象,例如将「东莞市」添加至「广东省」(假设广东的 objectId 为 56545c5b00b09f857a603632
),方法如下:
- Unity
- Android
- iOS
LCObject guangDong = LCObject.CreateWithoutData("Province", "56545c5b00b09f857a603632");
LCObject dongGuan = new LCObject("City");
dongGuan["name"] = "东莞";
AVObject guangDong = AVObject.createWithoutData("Province", "56545c5b00b09f857a603632");
AVObject dongGuan = new AVObject("City");
dongGuan.put("name", "东莞");
dongGuan.put("dependent", guangDong);
LCObject *GuangDong = [LCObject objectWithClassName:@"Province" objectId:@"56545c5b00b09f857a603632"];
LCObject *DongGuan = [LCObject objectWithClassName:@"City"];
[DongGuan setObject:@"东莞" forKey:@"name"];
[DongGuan setObject:GuangDong forKey:@"dependent"];
注意,为了节约篇幅,以上代码中省略了保存对象的代码。