有事务处理的 NoSQL 数据库

阅读数:4360 2014 年 6 月 20 日

话题:Java语言 & 开发架构AI

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对象,示例如下:

// Open a connection on localhost:8072 with username/password
// credentials of admin/admin using DIGEST authentication
DatabaseClient client = DatabaseClientFactory.newClient("localhost", 
                        8072, "admin", "admin", Authentication.DIGEST);

一旦建立了连接,就有另一个抽象级别的工作。MarkLogic 提供的 Java 类库包括很多特性,为了更好地组织这些特性,将它们进行了逻辑分组。我们这样做的方法之一是在DatabaseClient这一级将功能分到一些 Manager 类中。对于我们的第一个例子,我们将使用XMLDocumentManager对象执行一个基本的插入操作。要获得XMLDocumentManager实例,我再次使用工厂方法,但这次是从DatabaseClient,示例如下:

// Get a document manager from the client 
XMLDocumentManager docMgr = client.newXMLDocumentManager();

当处理数据时,MarkLogic 被认为是“面向文档”的 NoSQL 数据库。这意味着从 Java 的观点看,不再依赖 O/R 映射序列化复杂对象到关系数据库的行和列,对象可以简单地序列化到语言中立并自描述的文档或者对象格式,不再需要经过复杂的映射。具体来说,这意味着只要你的 Java 对象可以序列化到 XML(例如通过 JAXB 或者其它工具)或者 JSON(例如通过 Jackson 或其它类库),它就可以原样持久化到数据库,不需要在数据库预定义模型。

让我们看看代码:

// Establish a context object for the Customer class 
JAXBContext customerContext = JAXBContext.newInstance(
                                   com.marklogic.samples.infoq.model.Customer.class); 

// Get a new customer object and populate it 
Customer customer = new Customer(); 
customer.setId(1L); 
customer.setFirstName("Frodo")
        .setLastName("Baggins")
        .setEmail("frodo.baggins@middleearth.org")
        .setStreet("Bagshot Row, Bag End")
        .setCity("Hobbiton")
        .setStateOrProvince("The Shire"); 

// Get a handle for round-tripping the serialization 
JAXBHandle customerHandle = new JAXBHandle(customerContext); 
customerHandle.set(customer); 

// Write the object to the DB 
docMgr.write("/infoq/customers/customer-"+customer.getId()+".xml", customerHandle); 

System.out.println("Customer " + customer.getId() + " was written to the DB");

上面的例子使用 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 属性,确保当产品购买后,库存能反映这次购买动作(即库存要减少),从数据库的视角来看就是要求“全部或者全无操作”。因此,不论购买事务成功与否,我们都能保证库存状态是准确的。

我们再来看看代码:

client = DatabaseClientFactory.newClient("localhost", 8072, "admin", "admin", Authentication.DIGEST); 
XMLDocumentManager docMgr = client.newXMLDocumentManager(); 

Class[] classes = { 
      com.marklogic.samples.infoq.model.Customer.class, 
      com.marklogic.samples.infoq.model.InventoryEntry.class, 
      com.marklogic.samples.infoq.model.Order.class 
      }; 
JAXBContext context = JAXBContext.newInstance(classes); 
JAXBHandle jaxbHandle = new JAXBHandle(context); 

Transaction transaction = client.openTransaction();
try 
{ 

// get the artichoke inventory 
String artichokeUri="/infoq/inventory/artichoke.xml"; 
docMgr.read(artichokeUri, jaxbHandle); 
InventoryEntry artichokeInventory = jaxbHandle.get(InventoryEntry.class); 
System.out.println("Got the entry for " + artichokeInventory.getItemName()); 

// get the bongo inventory 
String bongoUri="/infoq/inventory/bongo.xml"; 
docMgr.read(bongoUri, jaxbHandle); 
InventoryEntry bongoInventory = jaxbHandle.get(InventoryEntry.class); 
System.out.println("Got the entry for " + bongoInventory.getItemName()); 

// get the airplane inventory 
String airplaneUri="/infoq/inventory/airplane.xml"; 
docMgr.read(airplaneUri, jaxbHandle); 
InventoryEntry airplaneInventory = jaxbHandle.get(InventoryEntry.class); 
System.out.println("Got the entry for " + airplaneInventory.getItemName()); 

// get the customer 
docMgr.read("/infoq/customers/customer-2.xml", jaxbHandle); 
Customer customer = jaxbHandle.get(Customer.class); 
System.out.println("Got the customer " + customer.getFirstName()); 

// Prep the order 
String itemName=null; 
double itemPrice=0; 
int quantity=0; 

Order order = new Order().setOrderNum(1).setCustomer(customer); 
LineItem[] items = new LineItem[3]; 
// Add 3 artichokes 
itemName=artichokeInventory.getItemName(); 
itemPrice=artichokeInventory.getPrice(); 
quantity=3; 
items[0] = new 
LineItem().setItem(itemName).setUnitPrice(itemPrice).setQuantity(quantity).setTotal(itemPrice*quantity); 
System.out.println("Added artichoke line item."); 
// Decrement artichoke inventory 
artichokeInventory.decrementItem(quantity); 
System.out.println("Decremented " + quantity + " artichoke(s) from inventory."); 

// Add a bongo 
itemName=bongoInventory.getItemName(); 
itemPrice=bongoInventory.getPrice(); 
quantity=1; 
items[1] = new 
LineItem().setItem(itemName).setUnitPrice(itemPrice).setQuantity(quantity).setTotal(itemPrice*quantity); 
System.out.println("Added bongo line item."); 
// Decrement bongo inventory 
bongoInventory.decrementItem(quantity); 
System.out.println("Decremented " + quantity + " bongo(s) from inventory."); 

// Add an airplane 
itemName=airplaneInventory.getItemName(); 
itemPrice=airplaneInventory.getPrice(); 
quantity=1; 
items[2] = new LineItem().setItem(itemName)
                         .setUnitPrice(itemPrice)
                         .setQuantity(quantity)
                         .setTotal(itemPrice*quantity); 
System.out.println("Added airplane line item."); 
// Decrement airplane inventory 
airplaneInventory.decrementItem(quantity); 
System.out.println("Decremented " + quantity + " airplane(s) from inventory."); 

// Add all line items to the order 
order.setLineItems(items); 
// Add some notes to the order 
order.setNotes("Customer may either have a dog or is possibly a talking dog."); 
jaxbHandle.set(order); 
// Write the order to the DB 
docMgr.write("/infoq/orders/order-"+order.getOrderNum()+".xml", jaxbHandle);
System.out.println("Order was written to the DB"); 

jaxbHandle.set(artichokeInventory); 
docMgr.write(artichokeUri, jaxbHandle);
System.out.println("Artichoke inventory was written to the DB"); 

jaxbHandle.set(bongoInventory); 
docMgr.write(bongoUri, jaxbHandle); 
System.out.println("Bongo inventory was written to the DB"); 

jaxbHandle.set(airplaneInventory); 
docMgr.write(airplaneUri, jaxbHandle);
System.out.println("Airplane inventory was written to the DB"); 

// Commit the whole thing
transaction.commit(); 
} 
catch (FailedRequestException fre) 
{ 
transaction.rollback(); 
throw new RuntimeException("Things did not go as planned.", fre);
} 
catch (ForbiddenUserException fue) 
{ 
transaction.rollback(); 
throw new RuntimeException("You don't have permission to do such things.", fue); 
} 
catch (InventoryUnavailableException iue) 
{ 
transaction.rollback(); 
throw new RuntimeException("It appears there's not enough inventory for something. You may want to do something about it...", iue); 
}

在上面的例子中,我们在一个事务上下文中做了很多事情:

  • 从数据库读取相关客户和库存数据;
  • 为指定客户创建一个订单,它包括三种商品;
  • 对每种商品,减少相应的库存数量;
  • 将所有事情作为一个事务提交(或者失败时回滚)。

代码在语义上来说,即使有多个 Update 操作,仍是一个全部或全无的工作单元。如果事务的任何部分出错,将会被回滚。此外,那些查询(获取客户和库存数据)同样在事务的可视范围内。这同时也强调了 MarkLogic 事务功能的另一个概念,即多版本并发控制(MVCC)。它的意思是截止那个时间点,那些数据库查询(例如查询库存)是有效的。此外,因为这是多语句事务,MarkLogic 还做了一些它通常在读操作时不会做的事情,建立了文档级别的锁(通常读取操作是无锁的),因此在并发事务处理中防止了“陈旧读(Stale read)”的场景。

因此当我们成功运行代码后,将有以下输出结果:

Got the entry for artichoke 
Got the entry for bongo 
Got the entry for airplane 
Got the customer Rex 
Added artichoke line item. 
Decremented 3 artichoke(s) from inventory. 
Added bongo line item. 
Decremented 1 bongo(s) from inventory. 
Added airplane line item. 
Decremented 1 airplane(s) from inventory. 
Order was written to the DB 
Artichoke inventory was written to the DB 
Bongo inventory was written to the DB 
Airplane inventory was written to the DB

数据库中的结果将是一张订单有三种商品,同时减少了库存商品的数量。为了说明,下面是订单 XML 和已经减少的其中一种库存商品(飞机)。

现在我们看到飞机的库存数量下降到了 0,因为我们之前的库存只有一架。现在我们再次运行程序,强制一个事务处理异常(虽然是人为的),因为库存不够了。这种情况下,我们选择放弃整个事务,错误显示如下:

Got the entry for artichoke 
Got the entry for bongo 
Got the entry for airplane 
Got the customer Rex 
Added artichoke line item. 
Decremented 3 artichoke(s) from inventory. 
Added bongo line item.
Decremented 1 bongo(s) from inventory. 
Added airplane line item. 
Exception in thread "main" java.lang.RuntimeException: Things did not go as planned. 
       at   com.marklogic.samples.infoq.main.TransactionSample1.main(TransactionSample1.java:148) 
Caused by: java.lang.RuntimeException: It appears there's not enough inventory for something. You may want to do something about it... 
       at  com.marklogic.samples.infoq.main.TransactionSample1.main(TransactionSample1.java:143) 
Caused by: com.marklogic.samples.infoq.exception.InventoryUnavailableException: Not enough inventory. Requested 1 but only 0 available. 
       at   com.marklogic.samples.infoq.model.InventoryEntry.decrementItem(InventoryEntry.java:61)
       at   com.marklogic.samples.infoq.main.TransactionSample1.main(TransactionSample1.java:103)

这是一件很酷的事,数据库没有更新,整个事务都回滚了。这就是所谓的多语句事务。如果你来自关系型世界,你已经习惯了这种行为。然而,在 NoSQL 世界,并不总是如此。而 MarkLogic 确实提供了这种能力。

上面的例子省略了真实世界场景的一些其它细节,因为针对库存不足我们可能会选择其它操作(例如订货)。然而,在很多业务场景中,原子性的需求是非常真实的,如果没有多语句事务的能力,将会非常困难并且很容易出错。

乐观锁

在上面的例子中,逻辑很简单也非常容易预测,事实上验证了所有 ACID 的四个属性。然而,细心的读者可能已经注意我提到“MarkLogic 还做了一些它通常在读操作时不会做的事情”。作为 MVCC 的副作用,读操作通常是无锁的。它的实现是在特定时间点让文档对读取操作可见,即使此时有修改发生。就好像文档为读请求保留了一份,不需要通过锁的方式来禁止写操作。然而,在某些情况下,单个文档可能在读取时被锁定。例如上面的例子中,在事务上下文中执行读操作。为什么我们这样做?在高并发应用中,事务发生在毫秒间甚至更短,我们想确保当读取一个对象并可能修改它时,在我们完成操作前其它线程不会改变它的状态。换句话说,我们想隔离我们的事务。所以当我们在事务块中执行读取时,我们表达了想要修改的意图,因此有了锁来确保整个事务过程的一致性。

然而,大多数开发人员都知道,即使是单个文件,甚至当并发操作之间没有真正的锁争用时,锁也是有代价的。事实上,通过设计我们知道应用程序的行为和操作发生的速度,这种重叠的可能性是比较低的。然而,我们还是希望有故障保护,以防万一有这样的重叠。所以当我们想执行一个事务更新但又只想读取某个对象的状态,并且在读取过程中不想有锁定开销时,我们该如何做?一是将读操作放到事务上下文的外面,这样它不会隐式锁定。二是使用DocumentDescriptor对象。这个对象的目的是在某个时间点获得一个对象状态的快照,以使得服务能判断在对象被读取之后和修改请求之前,对象是否被修改。通过获得读操作的文档描述符,然后将这个描述符传递给后续修改操作,就可以实现这一点。下面是示例代码:

JAXBHandle jaxbHandle = new JAXBHandle(context); 

// get the artichoke inventory 
String artichokeUri="/infoq/inventory/artichoke.xml";
// get a document descriptor for the URI 
DocumentDescriptor desc = docMgr.newDescriptor(artichokeUri); 
// read the document but now using the descriptor information 
docMgr.read(desc, jaxbHandle); 

// etc… 
try 
{ 
      // etc…
      // Write the order to the DB 
      docMgr.write("/infoq/orders/order-"+order.getOrderNum()+".xml", jaxbHandle); 
      System.out.println("Order was written to the DB"); 

      // etc…. 

      jaxbHandle.set(artichokeInventory); 
      docMgr.write(desc, updateHandle); // NOTE: using the descriptor again 

      // etc…. 

      transaction.commit(); 
} 
// etc… 
catch (FailedRequestException fre) 
{
      // Do something about the failed request 
}

这样做将确保任何读操作都不会创建相应的锁,锁只会用于修改操作。然而在这个例子中,技术上仍然存在另一个线程“偷偷进来”,在我们开始读取和修改文件之间,修改同一个文件的可能性。但使用上面的技术,如果发生了这样的情况,会抛出异常让我们知晓。这就是乐观锁,技术上来说在读的过程中加锁,因为我们比较乐观地认为在我们做后续的修改前不会发生变化。当我们这样做时,我们告诉数据库,我们相信绝大部分时间都不会有隔离违例,但如果有问题,我们希望能观察到。其好处是我们不会在读操作时加入锁。但在极少数情况(我们希望是)下,当我们已经读取某个对象,并且在修改它之前,另一个线程修改了同一个对象时,MarkLogic 将在幕后跟踪修改版本号,并抛出 FailedRequestException 异常。

另一件需要注意的事是修改和删除需要明确声明乐观锁,实质就是告诉服务在幕后跟踪“版本”。这儿有一个完整的服务配置、练习乐观锁的例子

使用软件版本控制工具(如 CVS、SVN 和 Git)的软件开发人员在处理模块代码时,非常熟悉这样的行为。大部分时间我们“Check out”模块代码,但不用锁定它,我们知道其他人通常不会同时工作在同一个模块。然而,如果我们尝试提交一个变更,而数据库认为它已经是一个“过时”拷贝时,它将告诉我们不能完成此操作,因为在我们读取之后,其他人已经做了修改。

总结

上面这些例子都比较简单,但关于 ACID 事务、乐观锁的话题绝不简单,通常 NoSQL 数据库并未与它们有联系。然而,MarkLogic 服务的目的是为开发人员提供易于使用的强大功能,并且不牺牲其自身的强大特性。要获取更详细的信息请访问这个网站。本文使用的多语句事务的例子,请访问GitHub

关于作者

Ken Krupa,MarkLogic 公司首席架构师,拥有 25 年专业 IT 经验。Krupa 先生在 IT 架构的几乎所有方面都有独特的广度和专业的深度。在加入 MarkLogic 之前,Ken 在经济困难时期为北美最大的一些金融机构提供咨询服务,为高级和 C 级主管提供建议。在那之前,他作为 Sun 公司的直接合作伙伴提供咨询,并担任 GFI 集团的首席架构师,该集团是一家华尔街同业经纪公司。如今,Ken 继续追求个人和社区为基础的工程活动。当前的追求包括社会科学,以及应用纯声明式、基于规则的逻辑框架到复杂的业务和 IT 问题。可以通过 @kenkrupa 和博客kenkrupa.wordpress.com与他联系。

查看英文原文:Transactional NoSQL Database