社区/学习指南/小程序·云开发高级教程

数据库的设计

在我们要构建一个项目(应用程序)时,通常第一件事情就要设计数据库。和关系型数据库将数据存储在固定的表格(这些表格由行和列组成)里所不同的是,云开发的数据库使用结构化的文档来存储数据,不再是关系型数据库里每个行列交汇处都必须有且只有一个值,它可以是一个数组、一个对象,或者更加复杂的嵌套。

12.3.1 数据库的设计

1、设计数据库需要预先思考哪些问题

实现云开发数据库之前,需要了解存储的数据的性质,如何存储这些数据,以及将如何访问它们,这需要你预先就要做出决定,进而通过组织数据和页面数据交互来获得最佳性能。具体地说,你需要先预先思考如下问题:

  • 页面交互需要使用哪些基本对象,时间、地址、价格、富文本、图片、商品属性等等这些实际的信息在数据库里对应的是啥数据类型?

  • 不同对象类型之间的关系是一对一、一对多还是多对多?如商品分类、详情页、评论页、购物车、会员信息、用户信息、配送地址等等这些复杂的关系是怎么联系起来的?

  • 云数据库添加新对象的频率有多高?集合里添加记录的频次是怎样的?往内嵌文档里添加字段的值的频率又是怎样的?修改记录以及记录里的字段的值的频率有多高?从数据库中删除记录或记录里的字段的频率有多高?

  • 根据条件查询数据库的频率有多高?是查询记录列表,还是记录里某个字段的值?查询记录或记录的值你将打算通过什么方式,是通过 ID、字段、条件还是其他方式?

  • 创建的集合哪个是最重要的?哪个会放在首页?哪个集合用户访问并发量会比较大?并发量大的集合应该怎么设计才能提升性能?

  • 哪些操作对数据的一致性要求比较高,需要进行原子操作或事务操作?(后面的原子操作和事务会介绍)

  • 哪个集合或哪个集合的记录的数据会增长比较快,数据量会比较大?

  • 哪个集合或哪个集合的记录会随着业务的发展,字段会有很大的调整?

2、功能的背后也是数据库的设计

应用程序复杂业务功能的背后,都是简单的数据,在设计数据库的时候要清楚的知道哪些功能会执行什么样的数据操作,集合与集合、集合与字段之间有着什么关系。

  • 比如新闻应用都会有文章列表以及文章详情页,这是两个功能,文章列表强调查询的是符合条件的记录;而文章详情页则是单个记录下的字段;这两者之间有什么差异?

  • 比如用户除了有个人信息之外还有身份读者与作者,读者与作者的身份是怎么体现的?管理员、编辑等人的角色呢?不同的角色在处理数据上又有哪些不同?

  • 比如用户的点赞、收藏、评论等这些是应该放到用户的集合里,还是应该放到文章的集合里,或者是单独拿出一个集合来存储这些数据?选择这个方式的依据是什么?

  • 前端通过表单增删改查数据在数据库里是怎么体现的?浏览页面、上拉下滑、搜索、轮播、菜单等在数据库是怎么体现的?

  • 文件上传、图片下载、地图数据获取、服务器时间等 API 是怎么与数据库结合的?

12.3.2 反范式化与范式化设计

范式化(normalization) 是将数据像关系型数据库一样分散到不同的集合里,而不同的集合之间是可以通过唯一的 ID 来相互引用数据的。不过要引用这些数据往往需要进行多次查询或使用 lookup 进行联表查询。

反范式化(denormalization) 则是将文档所需的数据都嵌入到文档的内部,如果要更新数据,可能整个文档都要查出来,修改之后再存储到数据库里,如果没有更新指令这种可以进行字段级别的更新,大文档要新增字段性能会比较低下。反观范式化设计,由于集合比较分散,也就比较小,更新数据时可以只更新一个相对较小的文档。

数据既可以内嵌(反范式化),也可以采用引用(范式化),两种策略并没有优劣之分,也都有各自的优缺点,关键是要选择适合自己应用场景的方案。完全反范式化的设计(将文档所需要的所有数据都嵌入到一个文档里面)可以大大减少文档查询的次数。如果数据更新更频繁那么范式化的设计是一个比较好的选择,而如果数据查询更频繁,而不需要怎么更新,那就没有必要把数据分散到不同的集合而牺牲查询的效率。对于复杂的应用比如博客系统、商城系统,只用一个集合(完全反范式化设计)会导致集合过大,冗余数据更多,数据写入性能差等问题,这时候就需要进行一定的范式化设计,也就是用更多的集合,而不是更大的集合

更适合内嵌 更适合引用 说明
内嵌文档最终会比较小 内嵌文档最终会比较大 一个记录的上限是16M,业务会持续不断增长的数据不适合内嵌,比如一个博客的文章会持续增长就不能内嵌到记录里,博客的评论虽然也会增长,但是增长量有限就可以内嵌
记录不会改变 记录经常会改变 当新建一个记录之后,如果业务只需要更新记录里的字段或嵌套里的字段,而不是更新整个记录,那可以用内嵌
最终数据一致即可 中间阶段的数据必须一致 内嵌会影响数据的一致性,但是大多数业务并不需要强一致,比如把用户评论内嵌在文章集合里,用户更改头像后以前评论的头像不会马上更改,这不会有太大影响
文档数据小幅增加 文档数据大幅增加 如果业务需要大幅度更新记录里的很多值或者大幅新增记录,比如有大量用户下订单,用户的订单数据就不要内嵌,而是以记录的形式存在
数据通常需要二次查询才能获得 数据通常不包含在结果中 内嵌文档的可以通过一次查询就能获取到嵌套的数组和对象,比如文章记录内嵌套评论,查询文章就能把该文章的评论全部获取到,减少了查询次数
需要快速查询 需要快速增删改 如果你的数据增删改等写入比较频繁,用嵌套数组和对象处理就会比较麻烦

像云开发数据库这种非关系型数据库,它的存储单位是文档,而文档的字段是可以嵌套数组和对象的,这种内嵌的方式把非关系型数据库的表与表之间的关系嵌套在了一个文档里,也就减少了需要跨集合操作的关联关系。

12.3.3 内嵌文档(内嵌数组或对象)

在前面我们了解到云开发数据库的一个文档里可以内嵌非常多的数据,甚至做到一个完整的应用只需一个集合。比如一个用户,只有一个购物车在关系型数据库里,我们需要建两张表来存储数据,一张表是存储所有客户信息的用户列表 User,还有一张存储所有用户订单的订单列表 Order,但是云开发数据库可以将原本的多张表内嵌成一张表。


{

  "name": "小明",

  "age": 27,

  "address":"深圳南山腾讯大厦",

  "orders": [{

    "item":"苹果",

    "price":15,

    "number":3

  },{

    "item":"火龙果",

    "price":18,

    "number":4

  }]

  }

}

采用这个内嵌式的设计模型,当我们要查询一个用户的信息和他的所有订单时,就可以只通过一次查询做到将用户的信息、所有的订单都获取到,而不像关系型数据库需要先在 User 表里查用户的信息,再根据用户的 id 去查所有订单。

同样一篇文章会有 N 个用户去评论产生 N 条评论数据,而这 N 条评论是只属于这一篇文章的,不存在评论既属于 A 文章,又属于 B 文章的情况。这种我们还是可以采用反范式化设计,将与该文章相关的评论都嵌入到这篇文章里:


{

  "title": "为什么要学习云开发",

  "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务",

  "comments": [{

    "name": "李东bbsky",

    "created_on": "2020-03-21T10:01:22Z",

    "comment": "云开发是微信生态下的最推荐的后台技术解决方案"

  }, {

    "name": "小明",

    "created_on": "2020-03-21T11:01:22Z",

    "comment": "云开发学起来太简单啦"

  }]

}

在我们要进入文章的详情页时,除了需要获取文章的信息,还要一次性把评论都读取出来,这种反范式化内嵌文档就能做到,也就是可以通过一次查询就能获取到所有需要的数据。但是如果文章都是属于大 V 一样的热点,经常会有几千条几万条的评论,将所有的评论都内嵌到文章记录里可能会存在记录溢出(比如超过 16M)、增删改查效率也会下降,这个时候就不适合用内嵌的方式,而是引用。

12.3.4 引用文档

有时候数据与数据之间的关系会比较复杂,不再是一对一或者一对多的关系,比如共享协作时,一个用户可以发 N 个文档,而一个文档又有 N 个作者(用户),这种 N 对 N 的复杂关系,使用内嵌文档就不那么好处理了。

试想一下如果你只创建一个用户表,把 A 所参与编辑的文档都内嵌到相应记录的字段里,B 用户的也是,如果 A,B 用户都参与编辑过同一份文档,那么一份文档就被内嵌到了连个用户的记录了,如果这个文档有 N 个作者,就会被重复内嵌 N 次。如果我们只需要查用户编辑过哪些文档,这种方式就没有问题,但是如果要查一份文档被多少个作者编辑过,就比较困难了;如果文档更新比较频繁,那操作起来就更加复杂了,这时内嵌文档显然不合适,应该采用范式化的设计。

比如我们将用户存储到 user 集合里,将所有的文档存储到 file 集合里,集合与集合的会通过唯一的_id来连接,下面 user 集合主要存储用户的信息,而把需要引用的 files 集合记录的_id也写到 user 集合里,


{

  "_id": "author10001",

  "name": "小云",

  "male":"female",

  "file": ["file200001","file200002","file200003"]

}

{

  "_id": "author10002",

  "male":"male",

  "name": "小开",

  "books": ["file200001","file200004"]

}

而在 files 集合里,则存储所有文档的信息,在 files 集合里只需要有 user 集合引用的_id即可:


{

  "_id": "file200001",

  "title": "云开发实战指南.pdf",

  "categories": "PDF文档",

  "size":"16M"

}

{

  "_id": "file200002",

  "title": "云数据库性能优化.doc",

  "categories": "Word文档",

  "size":"2M"

}

{

  "_id": "file200003",

  "title": "云开发入门指南.doc",

  "categories": "Word文档",

  "size":"4M"

}

{

  "_id": "file200004",

  "title": "云函数实战.doc",

  "categories": "Word文档",

  "size":"4M"

}

如果我们想一次性查询用户参与编辑了哪些文件以及相应的文件信息,可以在云函数端使用聚合的 lookup,这样相当于两个集合整合到一个集合里面了。

const cloud = require("wx-server-sdk");

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV,
});

const db = cloud.database();

const _ = db.command;

const $ = db.command.aggregate;

exports.main = async (event, context) => {
  const res = db
    .collection("user")
    .aggregate()

    .lookup({
      from: "files",

      localField: "file",

      foreignField: "_id",

      as: "bookList",
    })

    .end();

  return res;
};

而如果我们要修改某个指定文档的信息,直接根据 files 集合的_id 来查询就可以了。文档更新一次,所有参与编辑该文档的信息都会更新,保证了文件内容的一致性。

值得一提的是,尽管我们将复杂的关系通过范式化设计把数据分散到了不同的集合,但是和关系型数据库、Excel 一个字段一列还是不一样,我们还是可以把关系不那么复杂的数据用数组、对象的方式内嵌。

如果每个用户参与编辑的文档特别多而每个文档参与共同编辑的用户又相对比较少,把 file 都内嵌到 user 集合里就比较耗性能了,这时候可以反过来,把 user 的 id 嵌入 files 集合里,所以数据库的设计与实际业务有着很大的关系。


//由于file数组过大,user集合不再内嵌file了

{

  "_id": "author10001",

  "name": "小云",

  "male":"female",

}

//把用户的id嵌入到files集合里,相当于以文档为主,作者为辅

{

  "_id": "file200001",

  "title": "云开发实战指南.pdf",

  "categories": "PDF文档",

  "size":"16M",

  "author":["author10001","author10002","author10003"]

}

这里再说明一下,跨表查询联表查询是两码事,跨表查询我们可以通过集合与集合之间有关联的字段(意义相同的字段)多次查询来查找结果;而联表查询则是通过关联的字段将多个集合的数据整列整列的合并到一起处理。如果你不需要返回跨集合的整列数据,就不建议用联表查询,更不要妄图联 N 张表,能跨表查询就跨表查询。

12.3.5 数据库设计的注意事项

1、数据库的数据模式

云开发数据库的数据模式比较灵活,关系型数据库要求你在插入数据之前必须先定义好一个表的模式结构,而云数据库的集合 collection 则并不限制记录 document 结构。关系型数据库对有什么字段、字段是什么类型、长度为多少等等,而云数据库既不需要预先定义,而且记录的结构也没有限制,同一个集合的记录的字段可以有很大的差异。

这种灵活性让对象和数据库文档之间的映射变得很容易。即使数据记录之间有很大的变化,每个文档也可以很好的映射到各条不同的记录。当然在实际使用中,同一个集合中的文档最好都有一个类似的结构(相同的字段、相同的内嵌文档结构)方便进行批量的增删改查以及进行聚合等操作。

随着应用程序使用时间的增长和需求变化,数据库的数据模式可能也需要相应地增长和改变。最简单的方式就是在原有的数据模式基础之上进行添加字段,这样就能保证数据库支持所有旧版的模式。比如用户信息表,由于业务需要需要增加一些字段,比如性别、年龄,云数据库可以很轻松添加,但是这会出现一些问题,就是以往收集的用户信息性别、年龄这些字段是空的,而只有新添加的用户才有。如果业务的数据变动比较大,文档的数据模式也会存在版本混乱的冲突,这个在数据库设计之初也是要思考的。

2、预填充数据

如果已经知道未来要用到哪些字段,在第一次插入的时候就将这些字段预填充了,以后用到的时候就可以使用更新指令进行字段级别的更新,而不再需要再给集合来新增字段,这样的效率就会高很多。


{

  "_id":"user20200001",

  "nickname": "小明",

  "age": 27,

  "address":"",

  "school":[{

    "middle":""

  },{

    "college":""

  }]

}

比如简历网站的用户信息表的 address、school,用户登录的时候不必填,但是投递简历前这些信息必填,如果没有预先设置这些字段,收集这些信息时就需要使用 doc 对文档进行记录级别的更新。

db.collection("user")
  .doc("user20200001")

  .update({
    data: {
      address: "深圳",

      school: [
        {
          middle: "华中一附中",
        },
        {
          college: "清华大学",
        },
      ],
    },
  });

但是如果预先设置了这些字段,就是使用更新操作符进行字段级别的更新,当集合越大,修改的内容又比较少时,使用更新操作符来更新文档,性能会大大提升。

db.collection("user")
  .doc("user20200001")

  .update({
    data: {
      address: _.set("深圳"),

      "school.0.middle": _.set("华中一附中"),

      "school.1.college": _.set("清华"),
    },
  });

3、考虑文档的增长

采用内嵌文档这种反范式化设计在查询时是有很大的好处的,但是有一些文档的更新操作,会在内嵌文档的数组里增加元素或者增加一个新字段,如果随着业务的需求这类操作导致文档的大小变大,比如我们为了方便把评论内置到内嵌文档里,早期这样的设计是没有问题的,但是如果评论常年累积的增加会导致内嵌文档过大,越是往后新增的评论会越是影响性能,而且云数据库的一个记录的上限是 16M。如果出现这种数据增长的情况,也会影响到反范式化的设计模式,那么你可能要重新设计下数据模型,在不同文档之间使用引用的方式而非内嵌的数据结构。

由于更新指令不仅可以对数据进行字段级别的微操(增删改),而且还是原子操作,因此它不仅性能优异还支持高并发。更值得一提的是,通过反范式化设计内嵌文档的方式,更新指令的原子操作可以替代一部分事务的功能,这个在原子操作和事务章节会有介绍。

本文出自 李东bbsky