MongoDB应用设计调优

发布于:2025-02-23 ⋅ 阅读:(11) ⋅ 点赞:(0)

应用范式设计

什么是范式

数据库范式概念是数据库技术的基本理论,几乎是伴随着数据库软件产品的推出而产生的。在传统关系型数据库领域,应用开发中遵循范式是最基本的要求。但随着互联网行业的发展,NoSQL开始变得非常流行,在许多的应用实践中也涌现出一些反范式的做法。

  1. 三范式的定义

(1)第一范式:数据库表的每一列都是不可分割的原子项。 如下表,所在地一列就是不符合第一范式的,其中对于“广东省、深圳市”这样的字符串,实际上应该拆分为省份、城市两个字段。

编号 所在地
001 广东省、深圳市

第1范式要求将列尽可能分割成最小的粒度,希望消除利用某个列存储多值的行为,而且每个列都可以独立进行查询。

(2)第二范式:每个表必须有且仅有一个主键,其他属性需完全依赖于主键。这里除了主键,还定义了不允许存在对主键的部份依赖。如下表中订单的商品信息表,每一行代表了一个订单中的一款商品。为了满足主键原则,我们将商品ID和订单ID作为联合主键,除此之外,每一行还存放了商品的名称、价格以及商品类别。对于商品类别这个属性,我们认为其仅仅与商品ID有关,也就是仅依赖于主键的一部分,因此这是违反了第二范式的。改善的做法是将商品类别存放于商品信息表中。

订单号 商品号 商品名称 单价(元) 商品类别
o1 g1 洗衣液 23 家居

(3)第三范式:数据表中的每一列都和主键直接相关,而不能间接相关。如在下表中同时补充了城市的信息。

编号 姓名 性别 城市 城市人口
001 张三 北京市 1300万人

这里的城市人口等属性都仅仅依赖于用户所在的城市,而不是用户,所以只能算作间接的关系。因此为了不违反第三范式,只能将城市相关的属性分离到一个城市信息表中。

  1. mongodb是反范式吗
    这种问题,笔者告诉大家一个标准的答案:因人而异、难形成定论。
  2. 优缺点
    (1)范式设计消除了冗余,因此需要的空间更少。而且,范式化的表更容易进行更新,有利于保证数据的一致性。但是,其缺点在于关联查询较慢,一些查询需要在数据库中执行多次查找,如果只考虑磁盘操作,则相当于增加了磁盘的随机I/O,这是比较昂贵的。
    (2)反范式的设计一般可以优化读取的性能,mongodb很少会使用数据库的关联查询,因此通过嵌套设计的方式还能减少客户端于数据库之间的调用此。此外,使用嵌入还能获得写入数据的原子性保证,即要么完全成功,要么完全失败。
    实际上,可以同时使用范式和反范式的做法,这也是大多数应用所能考虑的最佳实践。如果采用了反范式的做法,则务必要仔细考虑冗余和数据一致性的问题。如果是数据频繁变化,或者一致性要求非常高的场景,则建议使用范式设计。如果是读多写少,而且可以接受不一致性,则可以考虑反范式设计。
    还应该提到的一点是关于数据库的演讲,范式设计一般会更容易适应未来的一些变化。从管理角度看,建议将范式设计作为一种规范,而将反范式设计视为优化手段,并在使得的场景内使用。

嵌套设计

在文档内使用嵌套

尽管反范式的文档设计通常会使用嵌套设计,但并不代表两者是同一回事。正如前面所言,范式/反范式关注的是冗余问题,而嵌套则更多的是关注文档对象之间的结构化关系。通常,嵌套设计具有较强的表现力。
与嵌套设计相对的则是平铺式设计,但平铺式设计会使文档内的字段显得特别繁多。例如,为了对字段做出区分,我们可能会使用很多奇怪而冗长的命名,如label1,label2等。这些做法会导致后期很难维护,甚至还可能因为误用而产生一些bug。此外,在嵌套式的文档内查找属性还会更快一些。
在关于mongodb的设计模式种,一般鼓励使用嵌套设计,但不意味着可以滥用这种特性。初学者很容易出现的一种误区是,将大量无关的实体信息通过嵌套的方式堆砌到一个文档内。 这种做法不但破坏了文档的结构合理性,也为性能的扩展和数据库的管理带来了不少麻烦。正确的做法应该是根据业务有选择性地使用嵌套。

表达关联

如果按照引用数量来划分,那么表之间地关系一般有一对一、一对多、多对多这几种。然而,对文档数据库而言,引用数量的大小会影响文档的具体模式,一般常见的做法如下。

  1. 内嵌文档
    对于少量存在包含关系的文档(one to few),可以采用完全嵌入的形式,代码如下:

    {
      name:"张三",
      addresses:[
         {xxxxx},
         {xxxxx}
      ]
    }
    

    嵌入设计提升了读性能,可以在查询表的同时获得其相关的地址列表,而且对于多个地址的更新也只需要一次性完成。但这样做的前提必须是:

    • “few”指向的文档数量必须是少量的,比如<1000个。
    • 整体文档的大小不能超过16MB。
    • 业务上是真正的包含,且总是以“one”作为主题在上下文出现,比如我们总是先查询用户,然后查看它的地址信息。
  2. 内嵌引用
    内嵌引用时内嵌文档的一个变种,不同点在于父文档只是记录子文档的ID字段引用,而不是全部内容。内嵌引用在查询关联文档时需要查找两次。
    使用内嵌引用的原因主要如下:

    • 内嵌文档的体积太大,可能超过16MB的限制。
    • 关联的子文档保持独立性,仅在父文档增加少量的引用,这样不需要在子文档种引入额外的索引。
  3. 引用模式
    引用模式类似外键(没有强制的约束),一般是以文档的某个字段(一般_id)作为引用。引用模式的好处是每个表相对独立,业务处理上也更加灵活;但性能会差一些,需要客户端指向多次数据查找。选择引用模式的原因主要如下:

    • 关联文档非常多,或者关联的增长是不受控制的,不再适合使用内嵌模式。例如在微博上,一条明星微博的评论数量是相当可观的,这就必须采取引用模式。
    • 业务实体关系层级过于复杂。
    • 多对多关系优先采用引用模式。
    • 对数据一致性要求很高,需要避免冗余的场景。

桶模式

桶模式是一种常见的“聚合式”的文档设计模式。简而言之,桶模式就是根据某个维度因子(通常是时间),将多个具有一定关系的文档聚合放到一个文档内的方式,具体实现时可以采用mongodb的内嵌文档或数组。
桶模式非常适合用于物联网、实时分析以及时间序列数据的场景。时间序列数据通常以时间为组织维度,并持续不断地流入系统中地一些数据,比如物联网平台所存储地传感器数据、运维系统对于虚拟机CPU、内存地监控数据等。随着时间的流逝,这些时序数据很容易达到非常大的量级。如果将这些时序数据以时间维度进行聚合存储(按时间分桶),则能达到明显的优化效果。

使用分桶方式有如下好处:

  • 文档高度内聚,查询操作一般只需要检索一个或少量的几个文档,可减少很多随机I/O操作。
  • 占用空间小,索引存储大小大幅度缩减,大大节省了mongodb的内存开销。
  • 易于使用,基于聚合文档之上可以做一些预聚合计算,减少实时计算消耗。

除此之外,桶模式在应用上仍然需要结合场景进行设计,除了增加开发复杂度,还需要考虑以下因素:

  • 避免文档的大小无限膨胀,一个BSON文档的大小不能超过16MB。
  • 相对于insert来说,update或upsert的性能有一点幅度的下降。
  • 多个数据的更新都发生在一个文档种,需考虑是否存在锁竞争的问题。

海量数据分页

传统分页模式

这是最常规的方案,假设我们需要对文章(articles)这个表进行分页展示,一般前端需要传递2个参数:

  • 页码(当前是第几页)。
  • 页大小(每页展示的数据个数)。

但是,这种方式随着页码的增多,skip操作跳过的条目也随之变多,而这个操作是通过cursor的迭代器来实现的,对于CPU的消耗会比较明显。而当需要查询的数据达到千万级时,响应时间就会变得非常长。

使用偏移量

  • 选取一个唯一的有序的关键字段作为翻页的排序字段,比如_id。

  • 每次翻页时以当前页的最后一条数据_id值作为起点,将此并入查询条件中。

    db.articles.find({_id:{$lt:new ObjectId("xxxxxx")}}).sort({_id:-1}).limit(20)
    

折中处理

时间轴的模式通常是做成“加载更多”、上下翻页的形式,但无法自由地选择某个页码。那么为了实现页码分页,同时也避免传统方案带来的skip性能问题,我们可以采取一种折中的方案。这里参考Baidu搜索结果页作为说明。
在这里插入图片描述
通常,在数据量非常大的情况下,页码也会有很多,于是可以采用页码分组的方式。以一段页码作为一组,每一组内数据的翻页采用ID偏移量+少量翻页(skip)操作实现。

批操作

对于mongodb来说,使用批量化api的关键在于,减少了客户端和数据库之间的数据传送次数,将多个文档或多个请求放入一次TCP传送任务往往能获得更高的效率。

批量读

  • 场景一:使用multi get模式
    对于集合种多个_id字段的查询,可以使用$in操作符简化为一次操作。multi get是一种常用的模式,一般指的是合并多个key进行查询,这里的key除了_id,还可以使用任何一个拥有唯一索引的字段。
  • 场景二:调整batchSize的值
    在客户端执行查询命令时,可以通过batchSize来指定返回结果集的批次大小。如果不指定,那么默认首次返回101。一般情况下,在大批量读取的场景种,建议明确指定合适的batchSize的值,具体可以根据文档大小,生产环境的网络带宽等因素来选择。

批量写

mongodb所提供的insertMany、updateMany或者Bulk API都属于批量写的命令。其中Bulk API是最灵活的,它可以将同一集合中的多个不同写操作合并为一次操作。一次Bulk批操作在mongodb服务器上仍然会被拆分为一个个的子命令,根据命令的执行顺序不同,分为以下两种。

  • 有序Bulk,数据库会严格按照顺序执行每个写入操作。在数据库内部,有序批次会根据当前顺序和写类型(insert、update)进行分组,每个分组最多为1000个。如果超过1000个,则会再次拆分。数据库按序对分组进行提交,如果某一个命令执行出错,则整个批次将立即停止并返回错误。
  • 无序Bulk,数据库会并发执行批次中的命令。无序批次在内部同样会进行分组(大小为1000个),但并不会保证执行顺序。无论是否存在执行失败的命令,整个批次都会继续进行直到结束。最终,数据库会在返回响应信息种包含具体的信息,包括哪些命令发生了错误。

默认的Bulk是有序的,但只要条件允许,建议尽量使用无序Bulk进行批操作。无序的处理效率更高,尤其是在分片集合中,执行有序的Bulk操作会变得非常缓慢,mongodb会将每个命令进行排序以保证有序。

读写分离与一致性

无论何时,我们应当将数据库集群看作一个整体。由于在分布式环境中存在多种不确定性,我们可能无法确保所写入的数据是否会丢失,或者刚刚写入的数据是否能马上读取。线性的读写通常可以解决一致性问题,但无法满足高吞吐量的要求。为此,mongodb提供了一些“弹性”的手段,可以让我们在读写一致性和性能吞吐量方面做出细粒度的权衡。

读写分离

副本集实现了数据在多个节点间的复制和实时同步,因此基本可以人为这些节点都包含了可用的数据副本。
默认情况下,数据的读写都会在主节点上进行,但这样一来主节点会承担最多的工作。在某些情况下,我们可能希望将业务的读操作指派到一些从节点上,以此来降低主节点的压力。这就是传统的读写分离模式。
读写分离的做法已经经历了大量项目的实践,同时也取得了比较好的效果。当这种方案的适用场景仍然是有限的,这体现在如下两个方面。

  • 读写分离仅仅分离了读操作,对于数据的写仍然需要在主节点上进行,因此对于写操作频繁的场景并不能受益。
  • 客户端从从节点读取的数据,可能并不是最新的,这是由于主从节点的数据同步存在时延导致的。

所以,读写分离方案一般用于读多写少、对数据一致性要求不是很高的场景,比如社区帖子、商品详情等。

对于采用了副本集的架构来说,客户端可以选择只读写主节点,或者写主节点、读从节点的方式。默认情况下,副本集采用仅读写主节点的模式,客户端可通过设置Read Preference来将读请求路由到其他节点,其中,Read Preference可以有多种选择,具体如下:

  • Primary:默认规则,所有读请求发送到Primary。
  • PrimaryPreferred:Primary优先,如果Primary不可达,则请求Secondary。
  • Secondary:所有的读请求都发送到Secondary。
  • SecondaryPreferred:Secondary优先,当所有的Secondary不可达时,请求Primary。
  • Nearest:读请求发送到最近的可达节点上。(通过ping命令探测出最近的节点)

一些特殊行为

  • 限制延迟读,通过设置maxStalenessSeconds参数来控制读取的延迟,一旦该节点落后主节点的时间超过该值,则放弃从该节点上读取,这个值设置必须大于90s,否则会报错,mongodb3.4及以上版本支持该特性。
  • 定向范围读,可以为从节点成员设定一些标签(TagSet),在读取时指定对应的TagSet,这样读取行为会指向对应的节点。TagSet是一种灵活的成员分组机制,可以根据需要来设定,比如按计算能力,或是地理位置。

读写关注

一般认为,mongodb的读写模式是弱一致性的。在默认配置下的确如此,为了保证性能优先,应用可能允许自己读取的数据并不是最新的,或者刚刚写入的数据存在极小的丢失风险。但是,这些仅限于默认行为的讨论。如果希望获得更强的一致性保证,还可以对写关注(WriteConcern),读关注(ReadConcern)进行调整。

  • 写关注
    客户端通过设置写关注来设置写入成功的规则。默认情况下WriteConcern的值为1,即数据只要写入主节点即认为成功并返回。可以将WriteConcern设置为majority来保证数据必须在大多数节点上写入成功。
    对于成功写入大多数节点的数据,即使发生主从节点切换,仍然保证新的主节点包含该数据,这意味着持久性又饿更高的保证。执行下面的命令,可以实现大多数节点写关注。

    db.users.insert(
    {name:"xxx",},
    {writeConcern:{w:majority,wtimeout:5000,j:true}}//wtimeout:表示写入等待的超时时间,单位为ms。j:保证数据成功写入磁盘的jouma日志,当w为majority时,如果没有明确指定j选项,则默认是true,可以通过writeConcernMajorityJournalDefault来控制该行为。
    )
    
    w 说明
    0 无须等待任何节点写成功,不保证可靠性
    1 等待主节点写成功
    n 等待n个节点写成功
    majority 等待大多数节点写成功
  • 读关注
    读关注是mongodb3.2版本新增的一个特性,主要用来解决“脏读”的问题。例如,客户端从主节点上读了一条数据,但此时主节点发生宕机,由于这条数据没有同步到其他节点上,在主节点恢复后就会进行回滚。从客户端的角度看便是读到了“脏数据”(可能被回滚)。当ReadConcern设置为majority时,mongodb可以保证客户端读到的数据已经被大多数节点所接受,这样的数据可以保证不会回滚,从而避免脏读问题。
    mongodb对读关注定义了多个级别,具体如下。

    • local
      本地读级别,仅读取本地可用的数据,不确保读取的数据是否被大多数节点接受。对主节点的读操作、从节点在因果一致性会话中的读操作默认采用local级别。
    • available
      本地可用读级别,仅读取本地可用的数据,不确保读取的数据是否被大多数节点接受。该级别和local级别的区别在于,可能会返回分片迁移产生的“孤儿文档”。对从节点在非因果一致性会话中的读操作默认采用available级别。
    • majority
      大多数渡劫别,可读取已同步到大多数节点的数据,可确保读取到的数据不会被回滚。
    • linearizable
      线性读级别(mongodb3.4版本提供),该级别保证能读取到WriteConcern为majority,并且返回确认时间在当前读请求开始之前的数据。linearizable级别只支持单文档操作的线性关系,而且只在读主节点时有效。这意味着如果上一个节点对该文档的写入还未满足大多数写入时,mongodb会进行等待。因此linearizable通常要和maxTimeMS一起使用,以避免长时间的阻塞。linearizable级别中性能下降比较明显,而且其同时也不适用于因果一致性会话、事务等特性。
    • snapshot
      快照读级别,仅可用于多文档事务中,snapshot级别保证了集群级别的快照一致性。

使用ReadConcern=majority需要开启选择replication.enableMajorityReadConcern,从mongodb3.6版本开始,该选项默认是true。

读自身的写入

在一些严谨的业务流程中往往存在这样的需求。如果客户端开启了读写分离模式,那么大概率会读不到上一次写入的数据。

  • 写主节点、读从节点(w:1,rc:available)

    db.orders.insert({orderId:"10001",price:69})
    db.orders.find({orderId:"10001"}).readPref("secondary")
    

    主节点写入后,由于从节点可能未同步到该数据,因此这里读取到的数据可能是空的。

  • 写主节点,读主节点(w:1,rc:local)

    db.orders.insert({orderId:"10001",price:69})
    db.orders.find({orderId:"10001"})
    

    可以说,在绝大多数情况下,find操作会返回数据。但意外仍可能存在,假设在写入主节点成功之后,主节点宕机发生主从节点切换,此时新的主节点并不一定具有orderId:"10001"这条数据,可能返回null。

  • 写主节点,读从节点(w:majority,rc:majority)

    db.orders.insert({orderId:"10001",price:69},{writeConcern:{w:"majority"}})
    db.orders.find({orderId:"10001"}).readPref("secondary").readConcern("majority")
    

    写操作w:"majority"保证了大多数节点都收到了该数据,readConcern:"majority"保证了从从节点读取到的数据已经同步到了大多数节点,但是这并不能保证当前从节点一定包含刚刚写入的数据。

  • 写主节点,读主节点(w:majority,rc:local)

        db.orders.insert({orderId:"10001",price:69},{writeConcern:{w:"majority"}})
    	db.orders.find({orderId:"10001"})
    

    写操作w:"majority"保证了大多数节点都收到了该数据,就算在下一次读之前发生了主从节点切换,也可以保证新的主节点一定包含了该条数据,因此可以保证读自身的写入。但是这种操作,由于读写操作都是针对主节点的,一旦读压力增加便无法兼用读写分离方案。为了在任意节点上也实现这种读自身的写入的特性,最好的办法是使用因果一致性会话。

因果一致性

mongodb3.6版本引入了会话的概念,并基于全局时钟实现了分布式集群上的因果一致性。
因果一致性是分布式数据库的一致性模型,它保证了一系列逻辑顺序发生的操作,在任意视角中都能保证一致的先后关系(因果关系)。例如只有当问题是可见的情况下才会出现对问题的答复,这样问题与答复就形成了依赖性的因果关系。

因果一致性所涉及的特性主要如下:

  • Read your writes,读自身的写入,读操作必须能够反映出在其之前的写操作。
  • Monotonic reads,单调读,如果某个读取操作已经看到过数据对象的某个值,那么任何后续访问都不会返回那个值之前的值。
  • Monotonic writes,单调写,如果某些写操作必须先于其他写操作执行,那么它们会确实先于那些写操作执行。
  • Writes follow reads,读后写,如果某些写操作必须发生在读操作之后,那么它们会确实在那些读操作之后执行。

为了支持因果一致性的全部特性,需要在会话中使用ReadConcern=majority,WriteConcern=majority的读写关注级别。另外,为了保证会话中的一组操作满足先后执行的因果一致性,客户端必须在同一个线程中执行这些操作。
因果一致性的上下文(逻辑时序)信息会被绑定到线程上下文中以实现跟踪。执行如下的命令,开启因果一致性会话,代码如下:

session=db.getMongo().startSession({causalConsistency:true,readConcern:"majority",writeConcern:"majority"})
db=session.getDatabase("appdb")

总结:应用如何选择合适的一致性级别呢?

  1. 如果系统数据并非不可丢失,对于单条信息丢失敏感度不大,则可使用readconcern:loal,writeconcern:1。
  2. 一些重要的ETL过程强调写入持久性,可使用writeconcern:majority。
  3. 金融应用中的订单、交易业务通常需要更高的一致性,可使用writeconcern:majority,readconcern:majority。在关键的流程操作中,使用因果一致性会话可以保证持久性和可见性。