数据库范式概念是数据库技术的基本理论,几乎是伴随着数据库软件产品的推出而产生的。在传统关系型数据库领域,应用开发中遵循范式是最基本的要求。但随着互联网行业的发展,NoSQL开始变得非常流行,在许多的应用实践中也涌现出一些反范式的做法。
(1)第一范式:数据库表的每一列都是不可分割的原子项。 如下表,所在地一列就是不符合第一范式的,其中对于“广东省、深圳市”这样的字符串,实际上应该拆分为省份、城市两个字段。
编号 | 所在地 |
---|---|
001 | 广东省、深圳市 |
第1范式要求将列尽可能分割成最小的粒度,希望消除利用某个列存储多值的行为,而且每个列都可以独立进行查询。
(2)第二范式:每个表必须有且仅有一个主键,其他属性需完全依赖于主键。这里除了主键,还定义了不允许存在对主键的部份依赖。如下表中订单的商品信息表,每一行代表了一个订单中的一款商品。为了满足主键原则,我们将商品ID和订单ID作为联合主键,除此之外,每一行还存放了商品的名称、价格以及商品类别。对于商品类别这个属性,我们认为其仅仅与商品ID有关,也就是仅依赖于主键的一部分,因此这是违反了第二范式的。改善的做法是将商品类别存放于商品信息表中。
订单号 | 商品号 | 商品名称 | 单价(元) | 商品类别 |
---|---|---|---|---|
o1 | g1 | 洗衣液 | 23 | 家居 |
(3)第三范式:数据表中的每一列都和主键直接相关,而不能间接相关。如在下表中同时补充了城市的信息。
编号 | 姓名 | 性别 | 城市 | 城市人口 |
---|---|---|---|---|
001 | 张三 | 男 | 北京市 | 1300万人 |
这里的城市人口等属性都仅仅依赖于用户所在的城市,而不是用户,所以只能算作间接的关系。因此为了不违反第三范式,只能将城市相关的属性分离到一个城市信息表中。
尽管反范式的文档设计通常会使用嵌套设计,但并不代表两者是同一回事。正如前面所言,范式/反范式关注的是冗余问题,而嵌套则更多的是关注文档对象之间的结构化关系。通常,嵌套设计具有较强的表现力。
与嵌套设计相对的则是平铺式设计,但平铺式设计会使文档内的字段显得特别繁多。例如,为了对字段做出区分,我们可能会使用很多奇怪而冗长的命名,如label1,label2等。这些做法会导致后期很难维护,甚至还可能因为误用而产生一些bug。此外,在嵌套式的文档内查找属性还会更快一些。
在关于mongodb的设计模式种,一般鼓励使用嵌套设计,但不意味着可以滥用这种特性。初学者很容易出现的一种误区是,将大量无关的实体信息通过嵌套的方式堆砌到一个文档内。 这种做法不但破坏了文档的结构合理性,也为性能的扩展和数据库的管理带来了不少麻烦。正确的做法应该是根据业务有选择性地使用嵌套。
如果按照引用数量来划分,那么表之间地关系一般有一对一、一对多、多对多这几种。然而,对文档数据库而言,引用数量的大小会影响文档的具体模式,一般常见的做法如下。
内嵌文档
对于少量存在包含关系的文档(one to few),可以采用完全嵌入的形式,代码如下:
{
name:"张三",
addresses:[
{xxxxx},
{xxxxx}
]
}
嵌入设计提升了读性能,可以在查询表的同时获得其相关的地址列表,而且对于多个地址的更新也只需要一次性完成。但这样做的前提必须是:
内嵌引用
内嵌引用时内嵌文档的一个变种,不同点在于父文档只是记录子文档的ID字段引用,而不是全部内容。内嵌引用在查询关联文档时需要查找两次。
使用内嵌引用的原因主要如下:
引用模式
引用模式类似外键(没有强制的约束),一般是以文档的某个字段(一般_id)作为引用。引用模式的好处是每个表相对独立,业务处理上也更加灵活;但性能会差一些,需要客户端指向多次数据查找。选择引用模式的原因主要如下:
桶模式是一种常见的“聚合式”的文档设计模式。简而言之,桶模式就是根据某个维度因子(通常是时间),将多个具有一定关系的文档聚合放到一个文档内的方式,具体实现时可以采用mongodb的内嵌文档或数组。
桶模式非常适合用于物联网、实时分析以及时间序列数据的场景。时间序列数据通常以时间为组织维度,并持续不断地流入系统中地一些数据,比如物联网平台所存储地传感器数据、运维系统对于虚拟机CPU、内存地监控数据等。随着时间的流逝,这些时序数据很容易达到非常大的量级。如果将这些时序数据以时间维度进行聚合存储(按时间分桶),则能达到明显的优化效果。
使用分桶方式有如下好处:
除此之外,桶模式在应用上仍然需要结合场景进行设计,除了增加开发复杂度,还需要考虑以下因素:
这是最常规的方案,假设我们需要对文章(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传送任务往往能获得更高的效率。
mongodb所提供的insertMany、updateMany或者Bulk API都属于批量写的命令。其中Bulk API是最灵活的,它可以将同一集合中的多个不同写操作合并为一次操作。一次Bulk批操作在mongodb服务器上仍然会被拆分为一个个的子命令,根据命令的执行顺序不同,分为以下两种。
默认的Bulk是有序的,但只要条件允许,建议尽量使用无序Bulk进行批操作。无序的处理效率更高,尤其是在分片集合中,执行有序的Bulk操作会变得非常缓慢,mongodb会将每个命令进行排序以保证有序。
无论何时,我们应当将数据库集群看作一个整体。由于在分布式环境中存在多种不确定性,我们可能无法确保所写入的数据是否会丢失,或者刚刚写入的数据是否能马上读取。线性的读写通常可以解决一致性问题,但无法满足高吞吐量的要求。为此,mongodb提供了一些“弹性”的手段,可以让我们在读写一致性和性能吞吐量方面做出细粒度的权衡。
副本集实现了数据在多个节点间的复制和实时同步,因此基本可以人为这些节点都包含了可用的数据副本。
默认情况下,数据的读写都会在主节点上进行,但这样一来主节点会承担最多的工作。在某些情况下,我们可能希望将业务的读操作指派到一些从节点上,以此来降低主节点的压力。这就是传统的读写分离模式。
读写分离的做法已经经历了大量项目的实践,同时也取得了比较好的效果。当这种方案的适用场景仍然是有限的,这体现在如下两个方面。
所以,读写分离方案一般用于读多写少、对数据一致性要求不是很高的场景,比如社区帖子、商品详情等。
对于采用了副本集的架构来说,客户端可以选择只读写主节点,或者写主节点、读从节点的方式。默认情况下,副本集采用仅读写主节点的模式,客户端可通过设置Read Preference来将读请求路由到其他节点,其中,Read Preference可以有多种选择,具体如下:
一些特殊行为
一般认为,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对读关注定义了多个级别,具体如下。
使用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版本引入了会话的概念,并基于全局时钟实现了分布式集群上的因果一致性。
因果一致性是分布式数据库的一致性模型,它保证了一系列逻辑顺序发生的操作,在任意视角中都能保证一致的先后关系(因果关系)。例如只有当问题是可见的情况下才会出现对问题的答复,这样问题与答复就形成了依赖性的因果关系。
因果一致性所涉及的特性主要如下:
为了支持因果一致性的全部特性,需要在会话中使用ReadConcern=majority,WriteConcern=majority的读写关注级别。另外,为了保证会话中的一组操作满足先后执行的因果一致性,客户端必须在同一个线程中执行这些操作。
因果一致性的上下文(逻辑时序)信息会被绑定到线程上下文中以实现跟踪。执行如下的命令,开启因果一致性会话,代码如下:
session=db.getMongo().startSession({causalConsistency:true,readConcern:"majority",writeConcern:"majority"})
db=session.getDatabase("appdb")
总结:应用如何选择合适的一致性级别呢?
事务(transaction)是传统数据库所具备的一项基本能力,其根本目的是为数据的可靠性与一致性提供保障。而在通常的实现中,事务包含了一个系列的数据库读写操作,这些操作要么全部完成,要么全部撤销。例如,在电子商城场景中,当顾客下单购买某件商品时,除了生成订单,还应该同时扣减商品的库存,这些操作应该被作为一个整体的执行单元进行处理,否则就会产生不一致的情况。
数据库事务需要包含4个基本特性,即常说的ACID,具体如下。
隔离级别
在隔离性方面,事务机制需要确保在多个事务并发执行时,其数据的中间状态是彼此不可见的。如果不考虑事务的隔离性,则可能会发生如下几个问题。
针对这些问题,标准的SQL规范为事务隔离性定义了4种级别。
(1)Read Unco mmitted(读未提交):事务在执行过程中,可能访问到其他事务未经提交的修改,这种级别是最弱的,无法避免“脏读”。(2)Read Committed(读已提交):事务在执行时,可以读取另一个事务已经提交到数据库的结果。该级别可以避免“脏读”,但事务中多次读取可能产生不一样的结果,因此会存在无法重复读的问题。
(3)Repeatable Read(可重复读):在同一个事务内,数据所呈现的状态将能持续保持一致(从事务的起始时间点开始),当前事务只能读取到本事务所做出的修改。但是该级别所定义的隔离范围并不包括插入操作,即事务还是会读取到其他事务提交的新增数据。
(4)Serializable(串行化):在该级别下,规定了事务只能串行化执行,而不能并发执行。该隔离级别可以有效防止“脏读”、不可重复读和幻读的问题,但实际应用中很少使用,因为会带来性能问题。
通常,事务的隔离级别越高,越能保证数据库的完整性和一致性。另外,隔离级别越高,对并发性能的影响也更加明显,应用上通常的选择是Read Committed(读已提交)、RepeatableRead(可重复读)这两种级别。而解决幻读问题的手段,一般是采用MVCC或者锁机制来实现。
如果此前对WiredTiger引擎有所了解,就不难理解为什么MongoDB特意将4.0版本的事务称之为多文档事务(multi document transaction)了。WiredTiger引擎本身是支持事务的,而MongoDB在内部实现中则使用了该引擎所提供的事务性API,从MongoDB 3.0版本开始便对单文档的操作提供了事务原子性的保证。在经过多个版本的迭代之后,MongoDB 4.0版本开始支持真正意义的多文档事务(基于副本集),如此命名只是便于区分。而从MongoDB 4.2版本开始,提供了跨分片的分布式事务,事务能力得到了进一步完善。
MongoDB的事务是基于逻辑会话(session)的,MongoDB 3.6版本便开始支持会话特性,会话提供了因果一致性的保证。对于事务来说,必须先创建会话才能使用事务,系统允许在任何时刻运行多个会话,但对于每个会话来说,同一时刻只能执行一个事务。这点可以类比多线程任务的场景,把会话看作一个线程,而事务则是绑定到线程上的一个任务单元。