笔者在写上一篇文章Java并发简介 中脑子里面同时也闪烁着,程序中有并发问题,那数据库中也有类似问题吗? 让我们一起看一下吧!

事务是将一组读写操作组合在一起形成一个逻辑单元。这些操作要么全部执行成功提交(commit),要么全部中止失败(abort,rollback),不会留下一个中间状态的烂摊子。所以,失败后程序可以安全的重试,分析原因等。 相反,如果没有对事务的支持,数据库可能持久化很多中间状态,留下无法解释的业务,开发人员处理起来也很麻烦。所以,事务是为了简化编程,提供数据安全/正确性/一致性。当然,任何便利都是有代价的,事务也有一些问题,所以NoSQL数据库,分布式数据库在某种程度上会弱化事务。有些甚至完全放弃事务。Let's dig into most of the aspects of transaction!

ACID特性

谈到事务,都想到ACID。每个字母分别代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性 (Durability)。搞清楚了ACID,就相当于搞清楚了事务的精髓。

原子性(Atomicity)

和JAVA并发中的原子性不同,程序中的原子性代表被规定的原子操作的中间状态不会被别的并发线程看到。而这里的原子性更多的是表达失败和成功的结果。并发问题是在隔离性(Isolation)里面谈及的。当有多次写入的时候,中间可能会出现各种问题(进程崩溃,断电,网络故障,硬盘满,违反约束等),如果这些操作被分配到一个事务中, 那么提交(commit)动作会失败,事务随即中止,但是数据库会保证故障之前对系统做的任何修改,写入都被撤销。随后,可以安全地重试失败的事务。 如果没有原子性保障,而把这种失败处理交给开发人员或者客户端处理,那么将是非常困难的,客户端很难知道哪些被写入了数据库,而重试则更是雪上加霜,让错误进一步扩大。

一致性(Consistency)

有人认为,一致性是强加在ACID里凑数的。因为这个东西不是数据库要保证的,而是应用程序需要定义和关注的。有一些道理。
我们经常认为,主外键约束达成了某种一致性,你不能在子表里面插入父表没有的键值,这是数据库给保证了一致性,但是这种一致性也是根据业务由开发人员定义的。如果你向数据库插入违反业务逻辑的假数据,数据库并没有这种约束阻止你。所以,一致性是通过事务的其他特性(原子性,隔离性)达成的,它并不属于数据库和事务的属性。

隔离性(Isolation)

当多个客户端同时访问数据库的同一对象时,就会有并发问题,或者叫竞态条件(race condition)。下图1的简单计数器例子说明了此问题,当两个客户端同时给计数器加1的时候,我们期望的结果如同他们串行化完成一样,可是在并发环境中没有一些保证的话,结果会像是丢失更新(lost update)一样,实际只增加了一次。

事务性和关系型数据库 关系数据库事务概念_持久性

隔离性保证同时执行的事务是相互隔离的,它们不能互相影响。简言之,一个事务只能看到另一个事务开始之前或者结束之后的结果,不能看到任何中间状态,反之亦然。结果就如同他们串行化(Serializability)完成一样,尽管实际上它们是并发运行的。隔离性分好几种级别,每一种隔离级别都在权衡性能和某种安全保障,This is a kind of trade-off. 我们在下一篇文章会分析这些隔离级别。

持久性(Durability)

持久性保证当用户提交事务并完成后,数据最终会被永久安全地保存到磁盘中,而不管是否发生故障或者系统崩溃。在单节点的数据库中,通过日志,可以保证系统在崩溃之后起来,依然能够自动完成一致性和持久化的要求(rollforward,rollback)。通过归档日志,当磁盘损坏后,还能恢复到某个时间点。为了进一步保证持久性,对日志和归档日志可以进行多副本设置。

当然,没有完美的持久性,如果由于机房起火,所有数据(备份,日志等)都销毁。所以,更严格的保证可以通过异地备份实现,但也不是完美的,不抬杠了。

单对象和多对象操作

我们举例子来说明一下事务在单对象和多对象操作中的作用。

多对象操作

下图(图2)是一个关于未读消息数量的例子,当用户有新邮件时,则会使相应的计数器加1,用户看了邮件后,则计数器减1。 消息和计数器为两个不同对象。我们假设初始状态没有新邮件,计数器为0。 如果没有事务的话,用户2看到了自己有一份新邮件,但是未读邮件数量却是0. 因为用户2看到了用户1未提交的写入(插入的新邮件),称作脏读。事务的隔离性可以解决这种问题。

事务性和关系型数据库 关系数据库事务概念_客户端_02

如果新邮件到来时,用户1在更新计数器的时候,发生某种崩溃而失败,那么邮箱和计数器则会不一致,失去同步(见图3)。 事务的原子性可以保证:如果计数器更新失败,事务会中止,插入的新邮件会被撤销(回滚). 介于BEGIN TRANSACTION 和 COMMIT之间的代码被认为在同一事务之中。

事务性和关系型数据库 关系数据库事务概念_客户端_03

另一方面,许多非关系数据库并没有将多个操作组合一起的方法,即使存在表面的多对象API(例如,键值存储可能具有在一个操作中更新多个键的操作),但并不意味它具有事务的特性,该操作可能在一些键上更新成功,在其他键上失败,这种部分更新也就体现在了数据库端。

单对象写入

对于单对象的写入,原子性和隔离性是毋庸置疑的,如果你正在向数据库写入一个20KB的JSON文档:

  • 如果在发送第一个10KB之后网络中断,数据库是否存储不可解析的10KB JSON片段?
  • 如果在覆盖前一个文档的过程中断电,是否最终将新旧值拼在一起?
  • 如果另一个客户端在写入的过程中读取文档,是否看到部分更新的值?

这些问题非常令人困惑,所以存储引擎几乎都实现了:对单节点上的单个对象(比如键值对)上提供原子性和隔离性。原子性可以通过日志来实现崩溃恢复,通过每个对象上的锁来实现隔离(每次只允许一个线程访问对象)。Apache HBase就是一个例子。

一些数据库也提供了复杂一些的原子操作,如自增操作(定义的自增主键)。还有CAS操作(比较和设置),当值没有被其他人修改过时,才允许执行写操作。这些保证很有用,防止在多客户端同时写入一个对象时丢失更新。但它们不是通常意义上的事务。这些单一对象保证被称作“轻量级事务”,甚至出于营销目的被称为“ACID”,是有误导性的。事务通常被理解为:将多个对象上的多个操作合并为一个执行单元的机制。   

仔细想想,如果从事务的角度去理解多线程编程中的计数器自增问题,更多的不是因为没有原子性,而是因为没有隔离性,所以才靠同步(锁),volatile等机制。本质上,都是一样的(并发引起的问题)。

在你的应用中,单对象的保证是否足够,多对象操作的协同是否必须?  我们不能简单地实现功能来交差,更重要的是要正确地实现。错误虽然不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。

事务中止重试

之前我们说过,事务中止会回滚事务开始到中止之前的写入,因此可以安全地重试,但也不够完美

  • 如果事务执行成功,但是由于网络故障,造成成功消息没有被返回给客户端(客户端认为事务没有成功),那么事务会被导致执行两次。(通过应用排除)
  • 如果错误是由于负载过大造成,重试会将问题变得更糟糕。可以限制重试次数,并单独处理与过载相关的错误。
  • 在发生永久性错误(例如违法约束)后重试是毫无意义的。仅在临时性错误(死锁,网络故障等)后才重试。
  • 如果事务对外部系统有影响,比如发送邮件。 重试则很可能造成重复给用户发邮件

 下一节我们讨论隔离级别