代码的未来(txt+pdf+epub+mobi电子书下载)


发布时间:2020-08-17 15:14:42

点击下载

作者:(日)松本行弘 著

出版社:人民邮电出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

代码的未来

代码的未来试读:

图灵程序设计丛书代码的未来[日]松本行弘◎著日经Linux◎编周自恒◎译人民邮电出版社北京第5章 支撑大数据的数据存储技术5.1 键-值存储

键-值存储(Key-value store)是数据库的一种。在云计算愈发流行的今天,键-值存储正在受到越来越多的关注。以关系型数据库管理系统(RDBMS)为代表的现有数据库系统正接近其极限,而键-值存储则拥有超越这种极限的可能性。

键-值存储是通过由键对象到值对象的映像来保存数据的,具体原理我们稍后会详细讲解。[1]

例如,旅游预订网站“乐天旅游” 中可以显示“最近浏览过的酒店”,其数据中,键为“用户ID”,值为“酒店ID(多个)”,即通过和用户ID相关联,来保存用户浏览过的酒店ID。

对于熟悉Ruby的读者,可以将这种方式理解为和Ruby内建的Hash类具有相同的功能。但不同的是,Hash只能存在于内存中,而键-值存储是数据库,因此它具备将数据永久保存下来的能力。

使用键-值存储方式的数据库,大多数都在数据查找技术上使用了散列表这种数据结构。散列表是通过调用散列函数来生成由键到散列值(一个和原始数据一一对应的固定位数的数值)的映射,通过散列值来确定数据的存放位置。散列表中的数据量无论如何增大,其查找数据所需的时间几乎是固定不变的,因此是一种非常适合大规模数据的技术。

要讲解键-值存储,我们先从它的基本工作方式Hash开始讲起吧。Hash类

一般意义上说,Hash(散列表)指的是通过创建键和值的配对,由键快速找到值的一种数据结构。

作为例子,我们来看一看Ruby的Hash类。该类拥有147种方法,不过其本质可以通过下列3个方法来描述。

hash[key]

hash[key] = value

hash.each {|k,v| ...}

hash[key]方法用于从Hash中取出并返回与key对象相对应的value对象。当找不到与key相对应的对象时,则返回nil。hash[key] = value方法用于将与key对象相对应的value对象存放到Hash中。当已经存在与key相对应的对象时,则用value覆盖它。最后是hash.each方法,用于按顺序遍历Hash中的键-值对。

也就是说,Hash对象是用于保存key对象到value对象之间对应关系的数据结构。这种数据结构在其他编程语言中有时也被称为Map(映像)或者Dictionary(字典)。我觉得用字典这个概念来描述Hash的性质挺合适的,因为字典就是从一个词条查询其对应释义的工具。DBM类

Hash类中的数据只能存在于内存中,在程序运行结束之后就会消失。为了超越进程的范围保存数据,可以使用Ruby的“DBM”类这样的键-值存储方式。

DBM类的用法和Hash几乎一模一样,但也有以下这些区别:

key和value只能使用字符串。

创建新DBM对象时,需要指定用于存放数据的文件路径名称。

数据会被保存在文件中。

像这样,可以超越进程的范围来保存数据的特性,在编程的世界中被称为“永久性”(persistence)。数据库的ACID特性

下面我们来分析一下“为什么在云计算时代键-值存储模型会受到关注”。

问题的关键在于RDBMS数据库所具备的ACID这一性质,我们就从这里开始讲起。ACID是4个单词首字母的缩写,它们分别是:Atomicity(原子性)、Consistency(一致性)、Isolation (隔离性)和Durability(持久性)。

所谓Atomicity,是指对于数据的操作只允许“全部完成”或“完全未做改变”这两种状态中的一种,而不允许任何中间状态。因为操作无法进一步进行分割,所以用了“原子”这个词来表现。例如,银行在进行汇款操作的时候,要从A账户向B账户汇款1万元,假设当中由于某些原因发生中断,这时A账户已经扣掉1万元,而B账户中还没有存入这1万元,这就是一个中间状态。“从A账户余额中扣掉1万元”和“向B账户余额中增加1万元”这两个操作,如果只完成了其中一个的话,两个账户的余额就会发生矛盾。

所谓Consistency,是指数据库的状态必须永远满足给定的条件这一性质。例如,当给定“存款账户余额永远为正数”这一条件时,“取出大于账户余额的款项”这一操作就无法被执行。

所谓Isolation,是指保持原子性的一系列操作的中间状态,不能由其他事务进行干涉这一性质,由此可以保持隔离性而避免对其他事务产生影响。

所谓Durability,是指当保持原子性的一系列操作完成时,其结果会被保存并且不会丢失这一性质。

整体来看,ACID非常重视数据的完整性,而RDBMS正是保持着这样的ACID特性而不断进化至今的。

但近年来,要满足这样的ACID特性却变得越来越困难。这正是RDBMS的极限,也就是我们希望通过键-值存储来克服的问题。CAP原理

近年来,人类可以获得的信息量持续增加,如此大量的数据无法存放在单独一块硬盘上,也无法由单独一台计算机进行处理,因此通过多台计算机的集合进行处理成为了必然的趋势。这样一来,在实际运营时就会发生延迟、故障等问题。多台计算机之间的通信需要通过网络,而网络一旦饱和就会产生延迟。

计算机的台数越多,机器发生故障的概率也随之升高。在万台数量级的数据中心中,据说每天都会有几台计算机发生故障。当由于延迟、故障等原因导致“计算机的集合”之间的连接被切断,原本的集合就会分裂成若干个小的集合。

当组成计算环境的计算机数量达到几百台以上(有些情况下甚至会达到万台规模)时, ACID特性就很难满足,换句话说,ACID是不可扩展的。

对此,有人提出了CAP原理,即在大规模环境中:

Consistency(一致性)

Availability(可用性)

Partition Tolerance(分裂容忍性)

这三个性质中,只能同时满足其中的两个。

在大规模数据库中如何保持CAP,很难从一般系统的情况进行类推。因为在大规模系统中,延迟、故障、分裂都是家常便饭。

在我们平常所接触的数台规模的网络环境中,计算机的故障是很少发生的,但对于数万、数十万台规模的集群来说,这样的“常识”是无效的。在这样的数量级上,就会像墨菲定律所说的一样,“只要存在故障的可能性就一定会发生故障(而且是在最坏的时间点上)”。

据说CAP原理已经通过数学方法得到了证明。CAP中的C是满足ACID的最重要因素,如果CAP原理真的成立的话,我们就可以推断,像RDBMS这样传统型数据库,在大规模环境中无法达到期望值(或者说无法充分发挥其性能)。这可真是个难题。CAP解决方案——BASE

根据CAP原理,C(一致性)、A(可用性)和P(分裂容忍性)这三者之中,必须要舍弃一个。

如果舍弃分裂容忍性的话,那么只有两个选择:要么根本不会发生分裂,要么在发生分裂时能够令其中一方失效。

根本不会发生分裂,也就意味着需要一台能够处理大规模数据的高性能计算机。而且,如果这台计算机发生故障,则意味着整个系统将停止运行。现代的数据规模靠一台计算机来处理是不可能完成的,因此从可扩展性的角度来看,这并不是一个有效的方案。

此外,当发生分裂时,例如大的计算机集群被分割为两个小的集群,要区分哪一个才是“真身”也并非易事。如果准备一台主控机,以主控机所在的集群为“真身”,这的确可以做到。但如果主控机发生故障的话,就等于整个系统发生了故障,风险也就大大增加了。

在分布式系统中,像这样“局部故障会导致整体故障”的要害,被称为Single point of failure (单一故障点),在分布式系统中是需要极力避免的。不能舍弃可用性

那么,舍弃A(可用性)这个选择又如何呢?这里的关键字是“等待”。也就是说,当发生分裂时,服务需要停止并等待分裂的恢复。另外,为了保持一致性,也必须等待所有数据都确实完成了记录。

然而,用户到底能够等待多长时间呢?仅仅作为一个用户的我,是相当没有耐心的,等上几秒钟就开始感到烦躁了,如果几分钟都没有响应,我想我就再也不会使用这个服务了。假设分裂和延迟的原因是由于机器故障,即便是准备了完善的备份机制,想要在几秒钟之内恢复也几乎是不可能的。所以结论就是,除非是不怎么用得上的服务,否则是不能舍弃可用性的。

那么现在就只剩下C(一致性)了,舍弃一致性是否现实呢?仔细想想的话,在现实世界中严密的一致性几乎是不存在的。例如,A要送个包裹给B,在现实世界中是不可能瞬间送到的。A需要将包裹交给物流公司,然后通过卡车等途径再送到B的手上,这个过程需要消耗一定的时间(无Atomicity)。而且,配送中的状态是可以追踪的(无Isolation),运输过程中如果发生事故包裹也可能会损坏(无Consistency)。再有,即便对损坏和遗失上了保险,此次运输交易行为本身也不可能“一笔勾销”。

即便现实世界如此残酷,我们却还是进行着各种交易(事务)。这样看来,即便是在某种程度上无法满足一致性的环境中,数据处理也是能够完成的。例如,网上商城的商品信息页面上明明写着“有货”,到实际提交订单的时候却变成了“缺货”,这种事已经是家常便饭了,倒也不会产生什么大问题。和银行汇款不同,其实大多数处理都不需要严格遵循ACID特性。

在这样的环境中,BASE这样的思路也许会更加合适。BASE是下列英文的缩写:

Basically Available

Soft-state

Eventually consistent

ACID无论在任何情况下都要保持严格的一致性,是一种比较悲观的模式。而实际上数据不一致并不会经常发生,因此BASE 比较重视可用性(Basically Available),但不追求状态的严密性(Soft-state),且不管过程中的情况如何,只要最终能够达成一致即可(Eventually consistent)。这种比较乐观的模式,也许更适合大规模系统。说句题外话,一开始我觉得BASE这个缩写似乎有点牵强,但其实BASE(碱)是和ACID(酸)相对的,这里面包含了一个文字游戏。大规模环境下的键-值存储

好,下面该进入正题——键-值存储了。键-值存储的一个优点,是可以通过“给定键返回对应的值”这一简单的模式,来支撑相当大规模的数据。键-值存储之所以适合大规模数据,除了使用散列值这一点外,还因为它结构简单,容易将数据分布存储在多台计算机上。

不过,在实际的大规模键-值存储系统中,还是存在一些必须要注意的问题。下面我们通过一个大体的框架,来探讨一下可扩展的键-值存储架构。

分布键-值存储的基本架构并不复杂。多台计算机(节点)组成一个虚拟的圆环,其中每个节点负责某个范围的散列值所对应的数据。

当应用程序(客户端程序)对数据进行访问时,首先通过作为键的数据(字符串)计算出散列值,然后找到负责该散列值的节点,直接向该节点请求取出或者存放数据即可(图1)。怎么样,很简单吧?然而,要实现一个具备实用性和可扩展性的键-值存储系统,需要注意的问题还有很多。下面我们来看看这个系统的具体实现。图1 键-值存储架构示例

通过散列值判断存放数据的节点并直接进行访问。为了提高可用性,两端相邻的节点都拥有数据的副本。

先声明一下,这里要讲解的可扩展键-值存储架构,基本上是以乐天开发的ROMA为基础的。可扩展键-值存储系统的实现有很多种,这里所介绍的架构并不是唯一一种。另外,为了讲解上方便,这里介绍的内容和实际的ROMA实现是有一些偏差的。访问键-值存储

我们先来看一个简单的应用程序实现。应用程序一侧要执行的处理并不多,大体上可以分成“初始化”和“访问(获取、保存等)”两个步骤。

首先是初始化。应用程序在初始化时,需要指定几个构成键-值存储系统的节点。指定的节点并不需要是特殊节点,只要是参加键-值存储系统组成的节点都可以。之所以要指定多个,是考虑到在这其中至少应该有一个是存活的。

应用程序按顺序访问指定的节点,并向第一个应答的节点传达要访问键-值存储的请求。接着,建立连接的节点向客户端发送“哪个节点负责哪个范围的散列值”的信息(即路由表)。收到路由表之后,剩下的访问操作就比较简单了。根据要获取的键数据计算出散列值,然后通过路由表查询出负责该散列值的节点,并向该节点发送请求。请求的内容分为获取、保存等很多种类,在ROMA中所支持的请求如表1所示。比Hash要稍微复杂一些呢。表1 ROMA的访问请求

每次进行数据访问时,应用程序都需要与负责各个键(的散列值)的节点建立连接并进行通信。这个通信过程都是通过套接字来完成的。通过套接字与远程主机建立连接,实际上需要很大的开销。ROMA早期的原型中,每次都需要建立这样的连接,于是这个部分就成了瓶颈,导致系统无法发挥出期望的性能。

所幸,一般情况下,对键-值存储的访问都具有局部性,也就是说对同一个键的访问可能会连续发生。在这样的情况下,池(pooling)技术就会比较有效。所谓池,就是指对使用过的资源进行反复利用的技术。这个案例中,也就是指对一定数量的套接字连接进行反复利用。特别是在访问具有局部性的情况下,连接池的效果是非常好的。

在键-值存储的运用中,难免会遇到由于延迟、故障、分裂等导致某些节点无法访问的状况。在ROMA中,各应用程序都持有一张记载组成键-值存储系统所有节点信息的表(路由表),并直接对节点进行访问。在这种类型的系统中,保持路由表处于最新状态是非常重要的。

ROMA会定期对路由表进行更新。每隔一段时间,客户端会向路由表中的任意节点发出获取最新路由信息的请求。

此外,对各个节点的请求也设置了超时时间,如果某个节点未在规定时间内响应请求,则会被从路由表中删除。键-值存储的节点处理

与应用程序相比,组成系统的节点的行为十分复杂,特别是像ROMA这样不存在承担特殊工作的主节点,且各节点之间相互平等的P2P型系统。

节点的工作大体包括以下内容:

应对访问请求

信息保存

维护节点构成信息

更新节点构成信息

加入处理

终止处理

正如图1所示,系统中的节点构成了一个圆环,其中每个节点的结构如图2所示。图2 节点的结构存储器

存储器(storage)就是实际负责保存信息的部分。在ROMA中,存储器是作为插件存在的,通过启动时的设置可以对存储器进行切换。目前实现的存储器包括下列这些:

RH存储器:将信息保存在Ruby的Hash对象中的存储器。这种方式无法将信息保存到文件中,因此ROMA整体运行停止后信息就消失了。

DBM存储器:将信息保存在DBM(实际上是GDBM)中的存储器。

File存储器:将信息保存在文件中的存储器。每个键都会产生一个独立的文件,因此磁盘目录可以用作索引。

SQLite3存储器:将信息保存在SQLite3中的存储器。SQLite3是一种很有名的公有领域RDBMS。

TC存储器:将信息保存在Mixi开发的TokyoCabinet中的存储器。是在ROMA实际运用中最为常用的一种。

各个存储器都定义为“Roma::Storage::BasicStorage”的子类,其定义采用模板方法的形式。在运用中,ROMA可以根据数据库所需特性来选择合适的存储器。从实际来看,大多数案例都可以用TC存储器来解决。写入和读取

当应用程序发起写入请求时,在确认键的散列值属于本节点负责范围后,该节点会通过存储器对数据进行保存。

这个时候,如果数据只保存在一个节点上的话,万一这个节点发生故障,数据就会丢失。为了提高可用性和分裂容忍性,写入必须由多个节点共同完成。

如果重视响应速度的话,可以在本节点完成写入操作之后,马上对请求进行响应,剩下的写入操作则可以在后台完成。在ROMA中由于对响应速度并不是非常重视,而是需要追求可靠性,因此所有的写入操作是同步执行的。不过,如果由于某些原因导致数据复制写入失败,则会在后台重新尝试执行写入操作。

如果由于应用程序所持有的路由表信息过期等原因,导致请求写入的键不属于本节点负责的范围,该节点会将请求转发出去。

读取的处理和写入差不多,但由于不需要出于冗余的目的对其他节点发出请求,因此处理方式更加简单。节点追加

分布式键-值存储系统的优点就是运用的灵活性。当数据过多、访问速度下降时,只要追加新的节点就可以进行应对。

追加新节点时,需要指定一个现有的节点作为入口,然后启动新的节点。新启动的节点和指定的现有节点进行通信,申请加入环状结构,然后向全体节点发送对节点间数据分配进行调整的请求。刚刚启动的新节点并不包含任何数据(在启动时显式指定了已永久保存的数据库的情况除外),随着节点调整的进行,数据会逐步分配给新的节点。故障应对

作为可扩展的键-值存储系统来说,最重要的恐怕就是对故障的容忍性了。正如之前讲过的,随着组成系统的计算机台数的增加,发生故障的概率也会大幅度上升。在大规模系统中,即便组成系统的一部分计算机发生故障,系统也必须能够继续运行。

发生频率最高的,应该是单台计算机的故障。由于故障导致一台计算机从系统中消失,这样的例子十分常见。

当由于故障导致一个节点失去响应时,应用程序会尝试访问其他节点。在ROMA中,由于数据总是存放在多个节点中,因此通过路由表就可以找到其他的替代节点。

另一方面,出现无响应的节点,就意味着数据的冗余度下降了。为了避免这种情况,其他节点需要将消失的节点排除,然后重新组织节点的结构,根据需要向相邻节点复制数据,最终维持数据的平衡。新的节点结构信息,会通过定期更新发送给应用程序,而作为整个键-值存储来说,会像什么都没有发生一般继续运行。

比较麻烦的情况是,暂时“消失”的节点又复活了。这种情况的发生可能是由于网线被拔出(这是在运营工作中经常会出现的意外),或者由于网络故障导致大规模网络延迟,这些应该还是比较常见的。

在以简洁为信条的ROMA中,遇到这样的情况,会将已经分离的节点完全舍弃。如果出现“复活”的情况,该节点需要作为一个新节点重新加入ROMA系统。ROMA会将新加入的节点更新到路由表中,并重新对数据进行分配。

另一种故障也可能发生,那就是多个节点同时消失。在ROMA中,和一个节点消失的情况一样,会将这些节点舍弃。在多个节点同时消失的情况下,可能会发生冗余备份的数据同时丢失的问题。要找回丢失的数据是不可能的,因此系统就会报错。在这种情况下,就无法区分该数据是一开始就不存在,还是由于大量节点消失而导致的数据丢失。这当然会引发一些问题,但失去的东西总归无法复得,也就没必要进行任何特殊的处理了。

话虽如此,但丢失数据这种事,作为数据库来说确实是个不小的问题。为了拯救数据, ROMA中提供了一个命令,可以将切断前已经由存储器写入文件的数据重新上传回ROMA。这个操作只是将存储器数据读取出来并添加到ROMA中,基本的部分是非常简单的。不过,如果上传的数据中有一些键已经在ROMA中存在,且它们所对应的值不相同的情况下,必须决定选用其中某一个值来解决冲突。到底是分离之后ROMA一侧的数据被更新了,还是对节点的数据写入由于某些原因没有反映到ROMA一侧?仅凭键和值的数据是无法判断的。

实际上,在ROMA 中对每份数据都附加了一个叫做“逻辑时钟”(logical clock)的信息,它是一种在每次数据被更新时进行累进的计数器。当上传的数据和ROMA中已经存在的数据发生冲突时,通过逻辑时钟就可以判断应该以哪一方的数据为准。

在各种故障中,还可能发生分裂的情况,也就是在完全隔绝的网络中,还在继续独立工作的意思。在ROMA中,要应对这种故障,只能将分裂开的ROMA系统中的其中一个手动停止。虽然这种手段非常原始,但分裂这样的故障并不会经常发生,这样的应对应该已经足够了。从分裂故障中进行恢复,和上述情况一样,也是通过使用从存储器上传数据的功能来完成的。终止处理

出人意料的是,在P2P型结构中,最麻烦的操作居然是终止。要进行终止操作,首先要向任意节点发送终止请求,然后,该节点就自动成为负责终止的主节点,由它对全体节点发送“即将终止”的声明。收到声明之后,各节点停止接受新的请求,并在当前时间点正在处理的请求全部完成之后,对存储器执行文件的写入(内存存储的情况除外),完成后向终止主节点发送回复。回复完毕后,结束该节点的进程。

负责终止的主节点在收到全部节点(故障、无应答的节点除外)的回复后,结束自身进程。至此,ROMA系统的运行就全部停止了。其他机制

除了上述讲到的内容之外,ROMA中还有以下这些机制:

1. 有效期

ROMA中的数据都设置了有效期,因此如果要实现“这个数据仅在今天有效,明天就需要删掉”这样的规则是很容易的。

2. 虚拟节点

为了让节点之间因分配调整而进行的数据传输更加高效,系统中采用了将若干个键组织起来形成“虚拟节点”的机制。

3. 散列树

如图1所示,ROMA使用了环状节点分布和浮点小数散列值,实[2][3]际上的算法使用的是SHA-1 散列和Merkle散列树 ,这种方式的效率更高。性能与应用实例[4]

在乐天旅游的“最近浏览过的酒店”和乐天市场 的“浏览历史”等功能中,都采用了ROMA,每个用户的访问历史记录都是保存在ROMA中的。ROMA基本上都是用Ruby编写的,但是它所提供的性能却足够支持日本最大级网站的应用。小结

除了ROMA之外,还有很多键-值存储系统的实现方式,它们也都具备各自的特点。由于这些项目大多数都是开源的,因此通过阅读源代码来研究一下或许也是一件很有意思的事。5.2 NoSQL[5]

说起NoSQL,这里并不是指某种数据库软件叫这个名字 。所谓NoSQL,是一个与象征关系型数据库的SQL语言相对立而出现的名词,它是包括键-值存储在内的所有非关系型数据库的统称。不过,关系型数据库在很多情况下还是非常有效的,因此有人批判NoSQL这个词中所体现出的“不再需要SQL”这个印象过于强烈,主张应该将其解释为“Not Only SQL”(不仅是SQL)(图1)。图1 NoSQL数据库

属于NoSQL类的数据库,主要有ROMA(Rakuten On-Memory Architecture)这样的键-值存储型数据库,以及接下来要介绍的MongoDB这样的面向文档数据库等。RDB的极限

在大规模环境中,尤其是作为大流量网站的后台,一般认为关系型数据库在性能上存在极限,因为关系型数据库必须遵守ACID特性。

ACID是Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)和Durability(持久性)这四个单词首字母的缩写。

所谓Atomicity,是指对于数据的操作只允许“全部完成”或“完全未做改变”这两种状态中的一种,而不允许任何中间状态。因为操作无法进一步进行分割,所以用了“原子”这个词来表现。

所谓Consistency,是指数据库的状态必须永远满足给定的条件这一性质。当某个事务无法满足给定条件时,其执行就会被取消。

所谓Isolation,是指保持原子性的一系列操作的中间状态,不能由其他事务进行干涉这一性质,由此可以保持隔离性而避免对其他事务产生影响。

所谓Durability,是指当保持原子性的一系列操作完成时,其结果会被保存并且不会丢失这一性质。

当数据量和访问频率增加时,ACID特性就成了导致性能下降的原因,因为随着数据量和访问频率的增加,维持ACID特性所带来的开销就会越来越明显。

例如,为了保持数据的一致性,就需要对访问进行并发控制,这样则必然会导致能接受的并发访问数量下降。如果将数据库分布到多台服务器上,则为了保持一致性所带来的通信开销也会导致性能下降。

当然,如果以适当的方式将数据库分割开来,从而在控制访问频率和数据量方面进行优化的话,在一定程度上可以应对这个问题。在大规模环境下使用关系型数据库,一般有水平分割和垂直分割两种分割方式。

所谓水平分割,就是将一张表中的各行数据直接分割到多个表[6]中。例如,对于像mixi 这样的社交化媒体(SNS)网站,如果将用户编号为奇数的用户信息和编号为偶数的用户信息分别放在两张表中,应该会比较有效。

相对地,所谓垂直分割就是将一张表中的某些字段(列)分离到其他的表中。用SNS网站举例的话,相当于按照“日记”、“社区”等功能来对数据库进行分割。

通过这样的分割,可以对单独一个关系型数据库的访问量和数据量进行控制。但是这样做,维护的难度也随之增加。NoSQL数据库的解决方案

NoSQL之所以受到关注,就是因为它可以成为解决关系型数据库极限问题的一种方案。和关系型数据库相比,NoSQL数据库具有以下优势(图2):图2 NoSQL的优点

限定访问数据的方式

在大多数NoSQL数据库中,对数据访问的方式都被限定为通过键(查询条件)来查询相对应的值(查询对象数据)这一种。由于存在这样的限定,就可以实现高速查询。而且,大多数NoSQL数据库都可以以键为单位来进行自动水平分割。

此外,也有像memcached这样不永久保存数据,只是作为缓存来使用的数据库。这也算是一种对数据访问方式的限定吧。

放宽一致性原则

要保持大规模数据库,尤其是分布式数据库的一致性,所需要的开销十分显著。因此大多数NoSQL数据库都遵循“BASE”这一原则。

所谓BASE,是Basically Available、Soft-state和Eventually consistent的缩写,ACID无论在任何情况下都要保持严格的一致性,而实际上数据不一致并不会经常发生,因此BASE比较重视可用性(Basically Available),但不追求状态的严密性(Soft-state),且不管过程中的情况如何,只要最终能够达成一致即可(Eventually consistent)。

如果遵循BASE原则,那么用于保持一致性的开销就可以得到控制,而标榜ACID的关系型数据库则很难做出这样的决断。形形色色的NoSQL数据库

NoSQL数据库只是一个统称,其中包含各种各样的数据库系统。大体上,可以分为以下三种:

键-值存储数据库

面向文档数据库

面向对象数据库

键-值存储是一种让键和值进行关联的简单数据库,查询方式基本上限定为通过键来进行,可以理解为在关系型数据库中只能提供对“拥有特定值的记录”进行查询的功能,而且还是有限制的。在UNIX中从很早就提供的DBM这种简单数据库,从分类上来看也可以算作键-值存储,但是在NoSQL这个语境中,所谓键-值存储一般都指的是分布式键-值存储系统。符合这样条件的键-值存储数据库包括“memcached”、“ROMA”、“Redis”、“TokyoTyrant”等。

所谓面向文档数据库,是指对于键-值存储中“值”的部分,存储的不是单纯的字符串或数字,而是拥有结构的文档。和单纯的键-值存储不同,由于它可以保存文档结构,因此可以基于文档内容进行查询。

举个例子,一张会员清单包括姓名、地址和电话号码,现在要从中查找一个名字叫“松本”的会员。也许乍看之下这和关系型数据库的应用方式是一样的,但是不同之处在于,在面向文档数据库中,对于存放会员信息的文档来说,每个会员的文档结构可以是不同的。因此要查找名字叫“松本”的会员,实际上相当于对“具备名字这个属性,且该属性的值为松本的文档”进行查询。这种情况下的文档,通常采用的是XML(eXtended Markup Language)和JSON (JavaScript Object Notation)格式。面向文档数据库包括CouchDB、MongoDB 以及各种XML数据库等。

所谓面向对象数据库,是将面向对象语言中的对象直接进行永久保存,也就是当计算机断电关机之后对象也不会消失的意思。键-值存储和面向文档数据库给人的感觉还像是个数据库,但大多数面向对象数据库看起来只是一个将对象进行永久保存的系统而已。当然,面向对象数据库也提供对对象的查询功能。面向对象数据库的例子有Db4o、ZopeDB、ObjectStore等。

在我跳槽到现在的公司之前,经常使用ObjectStore。那时候的工作内容是用C++和ObjectStore编写一个CAD软件,真是怀念啊。说起来,那个时候ObjectStore还不支持分布式环境,对于跨越多数据库创建对象的功能,以及对不再使用的对象进行回收的分布式垃圾回收功能等,都是靠自己的力量实现的,不知道现在是不是有了正式的支持呢。

从“非关系型数据库”的角度来看,在这里我们暂且将面向对象数据库也算作是NoSQL的一种,至少从我(有些过时)的经验来说,面向对象数据库的主要目的,是提升一些数据结构比较复杂的小规模数据库的访问速度,而和其他NoSQL数据库相比,在可扩展性方面并不是很擅长。面向文档数据库

下面我们来介绍一下面向文档数据库。所谓面向文档数据库,可以理解为是将JSON、XML这样的文档直接进行数据库化的形式,其特点包括:不需要schema(数据库结构定义),支持由多台计算机进行并行处理的“水平扩展”等。

1. CouchDB

CouchDB可以说是面向文档数据库的先驱。CouchDB的特点是RESTful接口以及采用Erlang进行实现。

CouchDB 提供了遵循REST(Representational State Transfer,表征状态转移)模型的接口,因此,即便没有特殊的客户端和库,使用HTTP也可以对数据进行插入、查询、更新和删除操作。和关系型数据库不同,其中每条数据不必拥有相同的结构,可以各自拥有一些自由的元素。在CouchDB中,是通过JSON来对记录进行描述的。

此外,在CouchDB中,一部分逻辑可以用JavaScript编写并插入到数据库中,从整体上看,数据库和应用程序之间的区别并不是那么明确。大多数人都习惯于“数据库负责数据,应用程序负责逻辑”,但此时也许需要让自己从这种模式中跳出来。

出人意料的是,像数据表的连结(Join)之类,在传统数据库中通过SQL可以轻松完成的查询,在CouchDB中是做不到的。因此用惯了传统关系型数据库的人可能会觉得四处碰壁。但是,如果能够完全运用CouchDB的功能,应用程序的设计可以变得十分简洁。

这种数据库是用Erlang来实现的,这一点也很值得关注。Erlang是一种为并行计算特别优化过的函数型语言,分布式计算和并行计算方面的程序设计一直是它的强项,因此在CouchDB这样需要通过多台机器的分布和协调应对大量访问的场景中,应该说能够充分发挥Erlang的性能。

2. MongoDB

和CouchDB相比,MongoDB大概更接近传统的数据库。MongoDB的宣传口号是Combining the best features of document databases, key-value stores, and RDBMSes,即要结合(像CouchDB这样的)文档数据库、键-值存储数据库和关系型数据库的优点,这真是个颇具挑战的目标。

MongoDB除了不具备事务功能之外,确实提供了和关系型数据库非常接近的易用性。此外,它还为C++、C#、JavaScript、Java、各种JVM语言、Perl、PHP、Python、Ruby等语言提供了访问驱动程序,这一点也非常重要。有了这样的支持,在语言的选择上也就没有什么顾虑了。MongoDB的安装

如果你所使用的操作系统发行版本中提供了MongoDB的软件包,那么安装就非常容易了。在Debian中该软件包的名字叫做mongodb。

即便没有提供软件包,安装它也并非难事。只要访问MongoDB官方网站的下载页面:http://www.mongodb.org/downloads,找到对应的二进制包并下载就可以了。提供官方预编译版本的系统平台如表1所示。表1 MongoDB提供预编译版本的系统平台

我选用的是Linux 32 位版本。将下载好的tar.gz 文件解压缩后,其目录结构如下:

GNU-AGPL-3.0(许可协议)

README(说明文件)

THIRD-PARTY-NOTICES(第三方依赖关系信息)

bin/(二进制文件)

include/(头文件)

lib/(库文件)

MongoDB的许可协议是GNU-AGPL-3.0。AGPL这种协议可能大家没怎么听说过,它是AFFERO GENERAL PUBLIC LICENSE的缩写,简单讲,基本条款和GPL是差不多的,区别只有一点,就是在该软件是通过网络进行使用的情况下,也需要提供源代码。在用于商业用途的情况下,如果不想公开源代码,貌似也可以购买商用许可。

bin目录中包含了MongoDB的数据库服务器、客户端、工具等可执行文件。只要将这些文件复制到/usr/bin等Path能搜索到的目录中就可以完成安装了。如果需要自行编译客户端和驱动程序的话,还需要安装include目录中的头文件和lib目录中的库文件。

如果没有和你所使用的操作系统或CPU相对应的预编译版本,则需要下载源代码自行编译。不过,MongoDB所依赖的库有很多,准备起来也有点麻烦。如果要在Ubuntu下用源代码进行编译,可以参考这里的资料(英文):http://www.mongodb.org/display/DOCS/Building+for+Linux。

接下来我们需要用Ruby来访问MongoDB,因此还需要安装Ruby的驱动程序。用RubyGems就可以轻松完成安装。RubyGems是为Ruby的各种库和应用程序设计的软件包管理系统,使用起来非常方便。如果你还没有安装RubyGems的话,趁这个机会赶紧安装吧。在Debian或Ubuntu中,输入下列命令进行安装:

$ sudo apt-get install ruby rubygems

用RubyGems来安装MongoDB的Ruby驱动程序,可以输入下列命令:

$ sudo gem install mongo启动数据库服务器

启动数据库服务器的命令是mongod,作为参数需要指定数据库存放路径以及mongod监听连接的端口号,默认的端口号为27017。指定数据库路径的选项为“--dbpath”,指定端口号的选项为“--port”。例如,如果创建一个“/var/db/mongo”目录并希望将数据库存放在此处,可以用下面的命令来启动数据库服务器(假设mongod所在的路径能够被Path找到,如果不能的话则需要指定绝对路径):

$ sudo mongod --dbpath /var/db/mongo

服务正常启动后会显示“waiting for connections on port 27017”这样一条消息(屏幕截图1)。屏幕截图1 MongoDB启动时的样子

对MongoDB进行操作需要使用mongo命令。如果为数据库服务器指定了非默认的端口号,则mongo命令也需要指定--port参数。打开一个新的终端控制台,用下列命令来启动mongo:

$ mongo

MongoDB shell version: 1.3.1

url: test

connecting to: test

type "exit" to exit

type "help" for help

>

这样就连接成功了。这个命令可以通过交互的方式对数据库进行操作,对于学习MongoDB很有帮助。此外,对于数据库的小规模调整和修改也十分方便。

不过mongo命令没有提供行编辑功能,如果配合使用支持行编辑功能的rlwrap命令则会更加方便。

$ rlwrap mongo

用上述格式启动,就可以为mongo命令增加行编辑功能。这样不仅能对输入行进行编辑,还可以查询输入历史,非常方便。

在Debian和Ubuntu中,可以用下列命令来安装rlwrap:

$ sudo apt-get install rlwrapMongoDB的数据库结构

MongoDB的结构分为数据库(database)、集合(collection)、文档(document)三层。在mongo命令中输入“show dbs”可以显示当前连接的数据库服务器所管理的数据库清单。

> show dbs

admin

local

我们可以看到,这台服务器所管理的数据库有admin和local这两个。对数据库的操作是针对当前数据库进行的。在连接时显示的消息中,“connecting to:”所表示的就是当前数据库。查看当前数据库可以使用“db”命令:

> db

test

在这里,数据库包含若干个集合,而集合则相当于关系型数据库中“表”的概念。关系型数据库中的表,都拥有各自的结构定义(schema),结构定义决定了表中各行(记录)包含怎样的数据,以及这些数据排列的顺序。因此每条记录都遵循schema的定义而具备完全相同的结构。

但对于无结构的MongoDB数据库来说,虽然集合中包含了相当于记录的文档,但每一个文档并不必具备相同的结构,而是能够存放可以用JSON进行描述的任意数据。一般来说,在同一个集合中倾向于保存结构相同的文档,但MongoDB对此并非强制。

这就意味着,随着应用程序开发的进行,对于数据库中数据的结构变化,可以灵活地做出应对。在Ruby on Rails的开发中,一旦数据库结构发生变化,就必须花很大精力来编写数据迁移脚本,而这样的苦差事在MongoDB中是完全可以避免的。数据的插入和查询

在关系型数据库中,要创建新的表,需要对表结构进行明确的定义并执行显式的创建操作,而在更加灵活的MongoDB中则不需要这么麻烦。在mongo命令中用use命令可以切换当前数据库,如果use命令指定了一个不存在的数据库,则会自动创建一个新数据库。

> use linux_mag

switched to db linux_mag

而且,如果向不存在的集合中保存文档的话,就会自动创建一个新的集合。

> db.articles.save({

... title: "技术的剖析",

... author: "matz"

... })

其中“…”是mongo命令中表示折行的提示符。通过这样的命令,我们就向linux_mag数据库的articles集合中插入了一个新文档。

> show collections

articles

system.indexes

下面我们来查询一下这个文档。查询文档需要使用集合的find方法。

> db.articles.find()

{ "_id" : ObjectId("4b960889e4ffd91673c93250"), "title" : "技术的剖析",

"author" : "matz" }

保存的数据会被自动分配一个名为“_id”的唯一ID。find方法还可以指定查询条件,如:

> db.articles.find({author: "matz"})

{ "_id" : ObjectId("4b960889e4ffd91673c93250"), "title" : "技术的剖析",

"author" : "matz" }

如果指定一个JavaScript对象作为find方法的参数,则会返回与其属性相匹配的文档。在这里我们的数据库中只有一个文档,如果有多个匹配的文档的话,自然会返回多个结果。

如果希望只返回一个符合条件的文档,则可以用findOne方法来代替find方法。用JavaScript进行查询

mongo命令最重要的一点,是可以自由地运行JavaScript。mongo所接受的命令,除了help、exit等一部分命令之外,其余的都是JavaScript语句。甚至可以说,mongo命令本身就是一个交互式的JavaScript解释器。刚才的例子中出现的:

db.articles.find()

等写法,正是JavaScript的方法调用形式。由于支持JavaScript,因此我们可以自由地进行一些简单的计算,将结果赋值给变量,甚至用for语句进行循环。

> 1 + 1

2

> print("hello")

hello

下面我们就用JavaScript来为数据库填充一定规模的数据。

> for (var i = 0; i < 1000000

; i++) { ... db.bench.save( { x:4, j:i } ); ... }

在花了相当长一段时间之后,我们就创建了一个包含100万个文档的bench集合。接下来,我们来试试看查询。

> db.bench.findOne({j:999999})

{ "_id" : ObjectId("4b965ef5ffa07ec509bd338e"), "x" : 4, "j" : 999999 }

在我的电脑上查询到这个结果用了差不多1秒的时间。因为要将100万个文档全部查询一遍,所以这个速度不是很快,于是我们来创建一个索引。

> db.bench.ensureIndex({j:1}, {unique: true})

这样我们就对j这个成员创建了一个索引,再查询一次试试看。

> db.bench.findOne({j:999999})

{ "_id" : ObjectId("4b965ef5ffa07ec509bd338e"), "x" : 4, "j" : 999999 }

在创建索引之前,按下回车键到返回结果总觉得会卡一会儿,现在则是瞬间就可以得到结果,索引的效果是非常明显的。

在mongo命令中,可以使用JavaScript这样一种完全编程语言来对数据库进行操作,感觉真是不错。也许是因为我没有在工作中使用过SQL的原因吧,总觉得需要用SQL这样一种不完全语言来编写算法和进行操作的关系型数据库让我觉得不太习惯,相比之下还是MongoDB感觉更加亲近一些。从我个人的喜好来说,自然希望在mongo命令中也可以用Ruby来对数据库进行操作。话说,2012年4月,[7]AvocadoDB 宣布要集成mruby,这很值得期待。高级查询

在MongoDB中,使用find或者findOne方法并指定对象作为条件时,会返回成员名称和值相匹配的文档。严格来说,find方法返回的是与符合条件的结果集相对应的游标,而findOne则是仅返回第一个找到的符合条件的文档。

不过,说到查询,可并不都是这么简单的用法。下面我们通过将SQL查询转换为MongoDB查询的方式,来学习一下MongoDB的查询编写方法。刚才出现过的查询符合条件记录的例子,用SQL来编写的话应该是下面这样:

SELECT * FROM benchWHERE x == 4

这条查询写成MongoDB查询则是这样:

> db.bench.find({x: 4})

如果希望只选出特定的成员(字段),用SQL要写成:

SELECT j FROM bench WHERE x == 4

MongoDB的话则是:

> db.bench.find({x: 4}, {j: true})

刚才我们的查询条件都是“等于”,如果要比较大小当然也是可以的。例如,“x大于等于4”这样的条件,用SQL查询可以写成:

SELECT j FROM bench WHERE x >= 4

而MongoDB的话则是:

> db.bench.find({x: 4}, {j: {$gte: 4}})

当比较条件不是等于时,要像上面这样使用以“$”开头的比较操作符来表达。MongoDB中可以使用的比较操作符如表2所示。表2 比较操作符

我们刚才已经讲过,在find方法中,返回的并不是文档本身,而是游标(cursor)。当执行的查询得到多个匹配结果时,某些情况下返回的结果数量可能会超乎想象。这时,我们可以使用count、limit、skip、sort等方法。

count方法可以返回游标所关联的结果集的大小。

> db.bench.find().count()1000000

limit方法可以将结果集的大小限制为从游标开始位置起指定数量的文档(图3)。

skip方法可以使游标跳过指定数量的记录(图4)。配合使用limit和skip,就可以像Google搜索页面一样,轻松实现以n个结果为单位将结果进行分页的操作。图3 limit方法的执行结果图4 skip方法的执行结果

sort方法可以按指定成员对查询结果进行排序(图5)。图5 sort方法的执行结果

这样我们就完成了按成员j降序排列的操作。和前面的skip(10).limit(10)的结果相比,j的值是不同的。由于sort方法是对整个查询结果进行排序,因此对于查询结果来说,这些方法的执行顺序和实际的调用顺序无关,总是按照①sort②skip③limit的顺序来执行。数据的更新和删除

只有文档的插入和查询并不能构成数据库的完整功能,我们还需要进行更新和删除。文档的插入我们使用了save方法,保存好的文档会被赋予一个_id成员,因此,当要保存的文档的_id已存在时,就会覆盖相应_id的文档。

也就是说,用find或findOne方法取出文档后,对取出的文档(JavaScript对象)进行修改并再次调用save(只有_id成员是不能修改的)的话,就会覆盖原来的文档。

在MongoDB中不存在事务的概念,因此总是以最后写入的数据为准。MySQL在最开始不支持事务的时候还是非常有用的,由此可见,Web应用中的数据库系统,即便不支持事务,貌似也不是很大的问题。MongoDB 中虽然不支持事务,但可以支持原子操作(atomic operation)和乐观并发控制(optimistic concurrency control)。要实现原子操作和乐观并发控制,可以使用update方法。

update所支持的原子操作如表3所示,原子操作的名称都是以“$”开头的。例如,要将j为0的文档的x值增加1,可以写成下面这样:

> db.bench.update({j:0},{$inc:{x:1}})表3 update的原子操作乐观并发控制

然而,当需要进行并发操作时,仅凭原子操作还不够。在关系型数据库中,一般是通过事务的方式来处理的,但MongoDB中没有这样的机制。MongoDB中进行并发操作的步骤如下。

(1) 通过查询获取文档。

(2) 保存原始值。

(3) 更新文档。

(4) 将原始值(包含_id)作为第一参数,将更新后的文档作为第二参数,调用update方法。如果文档已经被其他并发操作修改时,则update失败。

(5) 如果update失败则返回第(1)步重新执行。

这样的方式,也就是利用了update方法可以进行原子更新这一点,通过同时指定事务开始时和更新后的值,来手动实现相当于关系型数据库中事务处理的功能。这种方法的前提,是基于“同一个文档基本上不会被同时修改”这一预测,也就是一种乐观的(近似的)事务机制。

需要显式创建数据的副本这一点有些麻烦,但忽略这一点的话,实际上还是很实用的。作为一个简单的例子,我们将刚才讲过的$inc那个例题,用乐观并发处理进行实现,如图6所示。图6 乐观并发处理5.3 用Ruby来操作MongoDB

关系型数据库为了保持其基本的ACID原则(原子性、一致性、隔离性、持久性),需要以付出种种开销作为代价。而相对地,MongoDB这样的面向文档数据库由于可以突破这一局限,因此工作起来显得比较轻快。

MongoDB具有下列这些主要特点:

以JSON(JavaScript Object Notation)格式保存数据

不需要结构定义

支持分布式环境

乐观的事务机制

通过JavaScript进行操作

支持从多种语言进行访问

MongoDB最重要的特点就是不需要结构定义。很少有应用程序在开发之前就能确定数据库中需要保存的数据项目。由于开发过程中的疏漏,或者是需求的变化等,经常导致数据库结构在开发中发生改变。在关系型数据库(RDB)中,遇到这种情况每次都需要重做数据库。Ruby on Rails中可以通过migration方法对RDB结构迁移提供支持,但即便如此,这个过程依然相当麻烦。

而MongoDB本来就没有结构定义,即便数据库中保存的项目发生变化,只要程序做出应对就可以了。当然,已经存在的数据中不包含新增的项目,但要做出应对也很容易。使用Ruby驱动

MongoDB的另一个特点,就是可以由多种语言进行访问。为各种语言访问MongoDB所提供的库被称为驱动(driver)。MongoDB分别为JavaScript、C++、C#、Java、JVM语言、Perl、PHP、Python和Ruby提供了相应的驱动。

MongoDB的服务器中集成了JavaScript解释器,因此回调等服务器端的处理只能用JavaScript来编写。不过,因为有了支持各种语言的驱动,客户端(除了发送给服务器的程序以外)则可以用自己喜欢的语言来编写。

在5.2中我们使用mongo命令访问数据库,并使用JavaScript对数据库进行操作,不过如果可以用我们所习惯的Ruby来操作数据库就好了。RubyGems中提供了相应的Ruby驱动,使用gem命令就可以轻松完成安装(以下命令均以Debian为例):

% sudo gem install mongo[8]

此外,最好也一并安装用于加速访问的C语言库 ,通过这个库可以提升与MongoDB服务器通信需要用到的“二进制JSON”(BSON)的处理速度。

% sudo gem install mongo_ext

要使用MongoDB的Ruby驱动,需要在程序中对mongo库进行require。此外,在Ruby中还需要在mongo库之前对rubygems库进行require。

require 'rubygems'

require 'mongo'

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载