Java平台在其几乎整个生命周期中,都在煞费苦心地努力将数据库持久化功能无缝提供给开发人员。你是否已经尝试了早期的JDBC规范、EJB、O/R映射如Hibernate,或者最近的JPA规范,这一路上你不太可能没有遇到过关系型数据库。也许很可能你已经明白了面向对象建模与关系型数据库如何存储数据的区别(有时候开发人员称之为阻抗不匹配)。
然而最近,NoSQL数据库已经到来,从建模的视角看,在很多情况下,它提供了更自然的契合。尤其是面向文档的数据库(例如MarkLogic、MongoDB、CouchDB等等),它们的富JSON和/或XML持久化模型有效地消除了这种阻抗不匹配。当这变成对开发人员和生产率的一种恩惠,开发人员在某些情况下已经开始相信他们必须牺牲一些别的、已经习惯的特性,例如ACID事务支持。原因是很多NoSQL数据库不提供这样的功能,理由是要权衡更大的灵活性和传统关系型数据库所不具备的扩展性。对很多人来说,这种权衡的根本原因是CAP定理。
CAP定理
Eric Brewer在2000年提出了一个假定概念,现在被技术界称为CAP定理。他讨论了分布式数据库环境下的三个系统属性:
- 一致性:所有节点在同一时间看到的数据相同;
- 可用性:保证每个系统访问的请求都收到成功或失败的响应;
- 分隔容忍:系统中任意信息的丢失或失败不会影响系统的继续运作。
围绕CAP定理的共识是,对于上面的三个功能,一个分布式数据库系统只能提供最多2个。因此,绝大多数NoSQL数据库引用它作为基础,使用最终一致性模型(有时称为BASE-或基本可用、软状态,最终一致性)处理数据库更新。
然而一个常见的误解是,由于CAP定理,不可能创建一个具有ACID事务能力的分布式数据库。因此,许多人想当然地认为分布式NoSQL数据库和ACID事务是永远无法融合的一对。但实际情况并非如此,事实上Brewer本人澄清了他的一些声明,特别是关于一致性的概念,因为它适用于ACID。
事实证明,ACID属性是非常重要的,它们的适用性要么已经解决,要么正由更新的数据库技术市场解决。事实上,像Google这样的在分布式Web大规模数据存储的权威,Big Table白皮书和实现的作者,已经在通过Spanner项目实现分布式数据库事务能力。
因此,事务又回到了NoSQL的讨论范围。如果你是一名Java开发人员,正寻求NoSQL的敏捷性和规模化,又仍然想要ACID事务功能,这是个好消息。本文我们将探讨一种NoSQL数据库:MarkLogic,它如何向Java开发人员提供多语句事务能力,并且不牺牲其它NoSQL优势,例如敏捷性、跨硬件横向扩展能力。在继续之前,让我们再回顾一下ACID概念。
ACID支持
我们先看看ACID缩写的书本定义。我们将定义每个术语并讨论每个重要的上下文:
- 原子性:这个特性是事务概念的根基,描述了数据库必须为数据的组合动作提供便利,以“全部或者全无”的方式操作数据。因此举例来说,一个账户的借和另一帐户的贷所产生的事务,必须保证它们作为一个单元发生(或者不发生)。这种功能不仅在正常运行时要保证,同样在非预期的错误条件下也要保证。
- 一致性:这个属性与原子性紧密相关,表示事务处理必须将数据库从一个有效状态转换到另一个有效状态(从系统的观点)。因此举例来说,如果针对数据定义了参照完整性或者安全约束,一致性将保证事务处理不会违反任何一条约束。
- 隔离性:这个特性适用于并发时发生的围绕数据库事件所观察到的行为。它的目的是保障一个特定用户的数据库操作隔离另一个的操作。对于这个特别的ACID属性,通常有多种并发控制选项(即隔离级别),不同数据库的控制选项可能不同,而同一数据库系统有时候也有不同的选项。MarkLogic依赖于一种称为多版本并发控制(MVCC)的现代技术实现隔离能力。
- 持久性:它确保一旦事务已经提交到数据库,即使在正常的数据库操作意外中断的情况下(网络中断、断电等等),它们仍将持久保存。本质上这保证一旦数据库已提交数据,它将不会“丢失”数据。
对于一个完全支持ACID的数据库,上面所有的属性通常协同工作,依靠日志和事务检查点等概念防止数据损毁和其它不良副作用。
NoSQL和Java-基本的写操作
现在让我们抛开上面那些书本定义,开始一点具体的工作,探讨这些属性在Java代码中的形式。如前所述,我们的示例NoSQL数据库是MarkLogic。我们将先开始一些家务项目。
当使用Java编码时(或者甚至任何其它语言),要与数据库建立对话,我们要做的第一件事是打开一个连接,在MarkLogic中,由DatabaseClient对象处理。要获得这样一个对象,我们使用工厂模式并调用DatabaseClientFactory对象,示例如下:
一旦建立了连接,就有另一个抽象级别的工作。MarkLogic提供的Java类库包括很多特性,为了更好地组织这些特性,将它们进行了逻辑分组。我们这样做的方法之一是在DatabaseClient这一级将功能分到一些Manager类中。对于我们的第一个例子,我们将使用XMLDocumentManager对象执行一个基本的插入操作。要获得XMLDocumentManager实例,我再次使用工厂方法,但这次是从DatabaseClient,示例如下:
当处理数据时,MarkLogic被认为是“面向文档”的NoSQL数据库。这意味着从Java的观点看,不再依赖O/R映射序列化复杂对象到关系数据库的行和列,对象可以简单地序列化到语言中立并自描述的文档或者对象格式,不再需要经过复杂的映射。具体来说,这意味着只要你的Java对象可以序列化到XML(例如通过JAXB或者其它工具)或者JSON(例如通过Jackson或其它类库),它就可以原样持久化到数据库,不需要在数据库预定义模型。
让我们看看代码:
上面的例子使用JAXB,这是将POJO存储到MarkLogic的一种方式(其它还包括JDOM、未加工的XML字符串,JSON等等)。JAXB需要我们建立上下文如javax.xml.bind.JAXBContext类,也就是第一行代码。对于我们第一个例子,我们使用了一个JAXB注解的Customer类,创建一个实例并设置了一些数据(注:这只是个用于演示的例子,所以请勿就建模的好坏提出意见)。之后,我们回到MarkLogic细节。要保存Customer对象,我们首先得到一个Handle。因为我们选择了JAXB方法,所以我们使用之前实例化过的Context创建JAXBHandle。最后,我们使用XMLDocumentManager对象将文档写入数据库,并确保给它一个URI(也就是Key)用于标识。
当上面的操作完成,一个Customer对象将保存到数据库中。下面的截图展示了MarkLogic查询控制台中的对象:
值得注意的是(除了我们的第一个客户是一个著名的霍比特人),我们没有创建任何表,也没有配置和使用任何O/R映射。
一个事务示例
OK,我们已经看了一个基本的写操作,但事务能力呢?我们来看一个简单的用例。
比方说,我们有个电子商务网站叫做ABC商务网。在这个网站上,几乎可以买到任何第一个字母是A、B或C的东西。和很多现代电子商务网站一样,用户能看到最新的、准确的库存很重要。毕竟,要购买朝鲜蓟、手鼓或老爷车,消费者得知道你仓库里有哪些。
为了满足上面的需求,我们可以启用ACID属性,确保当产品购买后,库存能反映这次购买动作(即库存要减少),从数据库的视角来看就是要求“全部或者全无操作”。因此,不论购买事务成功与否,我们都能保证库存状态是准确的。
我们再来看看代码:
在上面的例子中,我们在一个事务上下文中做了很多事情:
- 从数据库读取相关客户和库存数据;
- 为指定客户创建一个订单,它包括三种商品;
- 对每种商品,减少相应的库存数量;
- 将所有事情作为一个事务提交(或者失败时回滚)。
代码在语义上来说,即使有多个Update操作,仍是一个全部或全无的工作单元。如果事务的任何部分出错,将会被回滚。此外,那些查询(获取客户和库存数据)同样在事务的可视范围内。这同时也强调了MarkLogic事务功能的另一个概念,即多版本并发控制(MVCC)。它的意思是截止那个时间点,那些数据库查询(例如查询库存)是有效的。此外,因为这是多语句事务,MarkLogic还做了一些它通常在读操作时不会做的事情,建立了文档级别的锁(通常读取操作是无锁的),因此在并发事务处理中防止了“陈旧读(Stale read)”的场景。
因此当我们成功运行代码后,将有以下输出结果:
数据库中的结果将是一张订单有三种商品,同时减少了库存商品的数量。为了说明,下面是订单XML和已经减少的其中一种库存商品(飞机)。
现在我们看到飞机的库存数量下降到了0,因为我们之前的库存只有一架。现在我们再次运行程序,强制一个事务处理异常(虽然是人为的),因为库存不够了。这种情况下,我们选择放弃整个事务,错误显示如下:
这是一件很酷的事,数据库没有更新,整个事务都回滚了。这就是所谓的多语句事务。如果你来自关系型世界,你已经习惯了这种行为。然而,在NoSQL世界,并不总是如此。而MarkLogic确实提供了这种能力。
上面的例子省略了真实世界场景的一些其它细节,因为针对库存不足我们可能会选择其它操作(例如订货)。然而,在很多业务场景中,原子性的需求是非常真实的,如果没有多语句事务的能力,将会非常困难并且很容易出错。
乐观锁
在上面的例子中,逻辑很简单也非常容易预测,事实上验证了所有ACID的四个属性。然而,细心的读者可能已经注意我提到“MarkLogic还做了一些它通常在读操作时不会做的事情”。作为MVCC的副作用,读操作通常是无锁的。它的实现是在特定时间点让文档对读取操作可见,即使此时有修改发生。就好像文档为读请求保留了一份,不需要通过锁的方式来禁止写操作。然而,在某些情况下,单个文档可能在读取时被锁定。例如上面的例子中,在事务上下文中执行读操作。为什么我们这样做?在高并发应用中,事务发生在毫秒间甚至更短,我们想确保当读取一个对象并可能修改它时,在我们完成操作前其它线程不会改变它的状态。换句话说,我们想隔离我们的事务。所以当我们在事务块中执行读取时,我们表达了想要修改的意图,因此有了锁来确保整个事务过程的一致性。
然而,大多数开发人员都知道,即使是单个文件,甚至当并发操作之间没有真正的锁争用时,锁也是有代价的。事实上,通过设计我们知道应用程序的行为和操作发生的速度,这种重叠的可能性是比较低的。然而,我们还是希望有故障保护,以防万一有这样的重叠。所以当我们想执行一个事务更新但又只想读取某个对象的状态,并且在读取过程中不想有锁定开销时,我们该如何做?一是将读操作放到事务上下文的外面,这样它不会隐式锁定。二是使用DocumentDescriptor对象。这个对象的目的是在某个时间点获得一个对象状态的快照,以使得服务能判断在对象被读取之后和修改请求之前,对象是否被修改。通过获得读操作的文档描述符,然后将这个描述符传递给后续修改操作,就可以实现这一点。下面是示例代码:
这样做将确保任何读操作都不会创建相应的锁,锁只会用于修改操作。然而在这个例子中,技术上仍然存在另一个线程“偷偷进来”,在我们开始读取和修改文件之间,修改同一个文件的可能性。但使用上面的技术,如果发生了这样的情况,会抛出异常让我们知晓。这就是乐观锁,技术上来说在读的过程中不加锁,因为我们比较乐观地认为在我们做后续的修改前不会发生变化。当我们这样做时,我们告诉数据库,我们相信绝大部分时间都不会有隔离违例,但如果有问题,我们希望能观察到。其好处是我们不会在读操作时加入锁。但在极少数情况(我们希望是)下,当我们已经读取某个对象,并且在修改它之前,另一个线程修改了同一个对象时,MarkLogic将在幕后跟踪修改版本号,并抛出FailedRequestException异常。
另一件需要注意的事是修改和删除需要明确声明乐观锁,实质就是告诉服务在幕后跟踪“版本”。这儿有一个完整的服务配置、练习乐观锁的例子。
使用软件版本控制工具(如CVS、SVN和Git)的软件开发人员在处理模块代码时,非常熟悉这样的行为。大部分时间我们“Check out”模块代码,但不用锁定它,我们知道其他人通常不会同时工作在同一个模块。然而,如果我们尝试提交一个变更,而数据库认为它已经是一个“过时”拷贝时,它将告诉我们不能完成此操作,因为在我们读取之后,其他人已经做了修改。
总结
上面这些例子都比较简单,但关于ACID事务、乐观锁的话题绝不简单,通常NoSQL数据库并未与它们有联系。然而,MarkLogic服务的目的是为开发人员提供易于使用的强大功能,并且不牺牲其自身的强大特性。要获取更详细的信息请访问这个网站。本文使用的多语句事务的例子,请访问GitHub。 |