支持事务的nosql数据库
Java平台在几乎整个生命周期中都经历了极大的痛苦,以使数据库持久性对开发人员尽可能地无缝。 无论您是在最早的JDBC规范,EJB,Hibernate之类的O / R映射器上还是在最近的JPA规范上,您都可能遇到过关系数据库。 也许同样有可能,您也已经了解了如何从面向对象的角度对数据建模与数据如何存储在关系数据库中之间的区别(有时被开发人员称为阻抗不匹配 )。
但是,最近出现了NoSQL数据库,从建模的角度来看,在许多情况下,NoSQL数据库提供了更自然的契合度。 特别是,面向文档的数据库(例如MarkLogic,MongoDB,CouchDB等)及其丰富的JSON和/或XML持久性模型有效地消除了这种阻抗不匹配的情况。 尽管这对开发人员和生产效率是一个福音,但在某些情况下,开发人员已开始相信他们需要牺牲自己已习惯的其他功能,例如ACID事务支持。 原因是许多NoSQL数据库不提供此类功能,但需要权衡取舍,以实现传统关系数据库无法提供的更大的敏捷性和可伸缩性。 对于许多人而言,进行这种折衷的理由根植于所谓的CAP定理。
CAP定理
早在2000年,埃里克·布鲁尔(Eric Brewer)提出了现在在技术界称为CAP定理的概念。 在其中,他讨论了分布式数据库上下文中的三个系统属性,如下所示:
- 一致性:所有节点同时看到相同数据的概念。
- 可用性:确保对系统的每个请求均收到有关其是否成功的响应。
- 分区容限:一种质量说明,即使系统部分故障,系统仍可继续运行。
围绕CAP定理的共识是,分布式数据库系统最多只能提供上述3种功能中的2种。 因此,大多数NoSQL数据库都将其作为在处理数据库更新方面采用最终一致性模型(有时称为BASE –或基本上可用的,软状态,最终一致性)的基础。
但是,一个常见的误解是,由于存在CAP定理,因此无法创建具有ACID事务功能的分布式数据库。 结果,许多人认为使用分布式NoSQL数据库和ACID事务永远不会满足。 但实际上并非如此,而且Brewer本人也澄清了他的一些声明,特别是围绕一致性的概念,因为一致性概念适用于InfoQ上的 ACID 。
事实证明,对于较新的数据库技术,ACID属性非常重要,以至于它们的适用性已被解决或正在被市场解决。 实际上, Big Table白皮书和实现的作者与Google一样,对分布式Web规模数据存储的授权也不少于通过Spanner项目证明和实现分布式DB事务功能的过程。
结果,事务已经回到了NoSQL的讨论中。 如果您是寻求NoSQL敏捷性和可扩展性但仍希望企业期望ACID事务的Java开发人员,那么这是个好消息。 在本文中,我们将探讨一个NoSQL数据库(特别是MarkLogic),以及它如何为Java开发人员提供多语句事务功能,同时又不牺牲NoSQL所带来的其他好处,例如敏捷性和跨商品的横向扩展能力。硬件。 但是,在开始之前,我们先退后一步,重新熟悉ACID的概念。
酸支持
我们将从缩写ACID的教科书定义开始。 我们将定义每个术语,并讨论每个术语都很重要的上下文:
- 原子性 :此功能提供了事务概念的基础,它指出数据库必须提供对可能以“全有或全无”方式发生的数据进行分组操作的工具。 因此,例如,如果创建了一项交易,其中一个操作记入一个帐户,而相关操作从另一个帐户记入借方,则必须保证它们作为一个整体发生(或不发生)。 此功能不仅在正常的运行时操作中而且在意外的错误情况下都必须为真。
- 一致性 :与原子性紧密相关的属性,它指出在数据库上执行的事务必须将数据库从一个有效状态转换到另一个有效状态(从系统的角度来看)。 因此,例如,如果先前已在受事务影响的部分数据上定义了引用完整性或安全性约束,则一致性可确保不会因预期的事务而违反那些定义的约束。
- 隔离 :此功能适用于以并行方式发生的有关数据库事件的观察到的行为。 它旨在为如何将一个特定用户的数据库操作与另一个用户的数据库操作隔离提供一定的保证。 对于此特定的ACID属性,通常存在多种并发控制选项(即隔离级别),这些选项不仅在一个数据库与另一个数据库之间不同,而且有时在同一数据库系统内也有所不同。 MarkLogic依靠一种称为多版本并发控制(MVCC)的现代技术来实现隔离功能。
- 耐用性 :这确保一旦将事务提交到数据库,即使在正常数据库操作的意外中断(例如,网络故障,断电等)的情况下,它们也将保持不变。 本质上,这可以确保数据库一旦确认提交数据,就不会“丢失”数据。
在具有完全ACID支持的数据库中,上述所有属性通常都将结合使用,它们依赖于日记和事务检查点之类的概念来防止数据损坏和其他不良影响。
NoSQL和Java-基本的写操作
既然教科书定义部分已经过去,让我们更具体一些,并以Java代码的形式探索其中的一些属性。 如前所述,我们的示例NoSQL数据库将是MarkLogic。 我们将从一些家政用品开始。
当使用Java(或几乎任何其他语言)进行编码时,要与数据库建立对话,我们要做的第一件事就是打开一个连接。 在MarkLogic的世界中,这是通过DatabaseClient对象完成的。 为了获得这样的对象,我们采用工厂模式并如下查询数据库客户端 工厂对象:
// 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,这是向MarkLogic呈现POJO以实现持久性的一种方式(其他包括JDOM,原始XML字符串,JSON等)。 JAXB要求我们根据javax.xml.bind.JAXBContext类建立上下文,这在代码的第一行中完成。 对于我们的第一个示例,我们正在使用带有JAXB注释的Customer类,并创建了一个实例,并在其中填充了一些数据(注意:该示例仅用于说明目的,因此请避免对最好/不是最好的建模方式提出批评班上)。 之后,我们回到MarkLogic的细节。 要坚持我们的客户对象,我们必须首先处理它。 由于我们在示例中选择了JAXB方法,因此我们使用先前实例化的上下文创建了JAXBHandle 。 最后,我们仅使用先前创建的XMLDocumentManager对象将文档写入数据库,并确保为身份提供一个URI(即密钥)。
完成上述操作后,客户对象将保留在数据库中。 下面的屏幕快照显示了MarkLogic的查询控制台中的对象:

值得注意的是(除了我们的第一个客户是著名的霍比特人之外),没有要创建的表,也没有可以配置和使用的O / R映射器。
交易示例
好的,我们已经看到了基本的写操作,但是事务处理能力如何? 为此,让我们考虑一个简单的用例。
假设我们有一个名为ABC-commerce的电子商务网站。 只要商品以字母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);
}
在上面的示例中,我们在单个事务的上下文中做了很多事情,如下所示:
- 从数据库中读取相关的客户和库存数据
- 为给定客户创建包含三个行项目的订单记录
- 对于每个订单项,还应减少相应项目名称和数量的库存
- 将整个事情作为一个事务提交(或在失败的情况下回滚)
即使有多个更新,代码语义也将其作为一个全有或全无的单一工作单元来完成。 如果事务的任何部分出了问题,将执行回滚。 此外,完成的查询(以获取客户和库存数据)也在交易可见性的范围内。 这也突出了围绕MarkLogic事务功能的另一个概念,特别是围绕多版本并发控制(MVCC)的概念。 这意味着所执行的查询视图(例如,在这种情况下为获取清单)从数据库中的那个时间点开始一直有效。 此外,由于这是一个多语句事务,因此MarkLogic还会执行通常不执行读取操作的操作,并实际上创建文档级锁定(通常读取是无锁的),以防止出现“过时读取”情况在并发事务处理中。
因此,如果我们成功运行上面的代码,将得到以下输出:
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)的开发人员在处理代码模块时会熟悉这种行为。 大多数情况下,我们知道其他人通常不会同时在同一个模块上工作,因此我们“签出”一个代码模块而不锁定它。 但是,如果我们确实尝试对数据库认为是“旧”副本的内容进行更改,它将告诉我们无法完成该操作,因为自从我们读取它以来,其他人对其进行了更新。
结论
上面提供的示例很简单,但是主题-ACID事务,乐观锁定-绝非易事,通常不与NoSQL数据库关联。 但是,MarkLogic服务器的目标是以一种易于开发人员利用的方式提供这些非常强大的功能,而又不牺牲功能本身的功能。 有关这些主题和其他主题的更多信息,请随时访问本网站 。 有关本文中使用的多语句交易示例,请访问GitHub 。
支持事务的nosql数据库