分布式数据系统设计会面对很多的问题,整体系统出错的地方有很多例如:硬件问题或者断电问题,部署在不同节点上软件程序会出现 Bug 故障,由于节点之间依赖于网络会出现随时中断连接的情况,多个客户端操作某一条数据记录如果没有控制好可能会产生脏数据情况,客户端之间的操作可能会发生边界条件竞争的问题。如何提高系统的高可用性和数据可靠性的目标,就必须要设计一个完整的方案解决相应的问题,本篇将注重分布式系统中事务处理技术一些实现细节剖析。


何为事务

在前面的文章讲过数据被分成多个分区如果需要操作可能就需要执行多次操作才能完成一次完整的查询请求,随之而来的问题就是如何保证操作的多个分区节点都完成对应操作,如果其中一个分区节点执行操作失败了怎么办?事务机制就是一种简化这些问题的首选机制方案,事务机制可以把客户端发起多个读写请求操作捆绑成一次逻辑操作单元,这样我们就将多个写操作或者读操作视为一个执行的整体,这就是事务,事务执行要么成功要么就失败,没有如何的中间状态。如果执行失败程序还能回滚状态,也可以重试操作,有这样的设计客户端操作起来就变的很简单。

要理解事务机制就要了解它做些什么?传统的关系数据库都是支持 ACID,这里的ACID分别为:Atomiciy、Consistency、Lsolation、Durability;这4个单词的首写字母组成的,这早期的数据库相关论文中提出,描述一个数据库的容错机制的定义。 传统关系型数据库大部分都已经支持这些功能,只是具体的实现细节每个不同厂商可能略有不同但是提供功能是符合ACID定义的。但是在NoSQL相关的实现就会有所差异,大部分为了更好支持系统的扩展性和高可用性放弃严格的ACID功能实现,弱化的了ACID的一些功能,而相应却使用另外一套BASE理论去实现数据库相关的功能,BASE的定义提倡分布式系统基本可用性和软状态、数据最终一致性,这就使得某些分布式系统在事务上支持并不是很完善,例如Redis在集群模式下的事务操作支持。

原子性: 很多第三方资料描述的就是一些列操作集能一次性执行完成,结果只有两种状态为成功或失败。原子性在 ACID 中最为重要,一个客户端发送了包含多个写操作的请求,当服务器收到请求执行如果发生了错误,硬件故障等情况,这时就视为本次事务操作不成功,则会中止本次事务执行,并且回滚和撤销局部的修改,使得失败后的数据和未执行事务之前的状态保证一致。

一致性: 数据库中的数据在操作修改的时候必须满足状态约束,对数据的修改必须是预期状态变更,例如我们在操作微信转账的时候就会产生2次数据记录状态的变更,A 向 B 转账 100 块钱,对应的事务操作就是要保证其A账户会减去 100 元,而对应的B账户能添加 100 元,事务执行开始是从一个有效状态转换成另外一个有效状态,并且其更新操作没有违背约束条件,结果状态符合预期并且是有效的状态。一致性是有应用层保证的,因为如何定义事务的内容,这时有客户端决定的,如果客户端故意向服务器提交了一下些错误的数据,这里一个例子就拿上面转账来说,如果客户端把 100 写成 1000 这就是无法保证的,但是数据库可以保证操作数据类型是否合规。

隔离性: 传统关系数据库都会支持多个客户端会话连接,每几个客户端可能在某一段相同的时间内发起多个写操作事务,操作变更相同的记录,很有可能就会发生数据竞争问题,例如下图:

这时就需要将其两个操作串行化来操作避免造成数据混乱情况,但是如果是串行化的操作会使得事务会排队很多商业数据库已经放弃这样的实现方案,Oracle 数据库采用的快照隔离

持久性: 对于一个单机的关系数据库来说操作的事务数据记录能安全可靠得被持久化存储起来,它能保证一断事务操作成功提交,数据就会被持久化起来不丢失,即使发生了硬件故障。对于分布式数据库来说还要考虑到数据副本的复制和持久化,要保证其他远程的节点已经成功持久化数据记录,事务才能算成功执行持久化。

假设一个邮件系统事务应用场景,例如现在有一个需求用户1和用户2同时操作邮件数据库,用户1向数据库中写入邮件信息和更新邮件未读邮件数,因为这2个操作没有组成事务,可能会出现这种情况,当操作写操作的时候用户2正在读取未读邮件,此时用户2读取到邮件内容,但是未读邮件数却还是为0,这就因为没有事务原子性导致数据混乱的情况发生。


单对象和多对象事务

有些第三方资料容易把放到多个客户端并发同时修改一个对象的时候问题解决方案称之为事务,例如 CAS 的方式轻量级事务,这种严格意义上说不能说 ACID,通常意义上的事务是对多个对象的,将多个操作聚合为一个逻辑执行单元,而 CSA 严格意义上来说只是多线程并发下的术语。

在关系型数据库中一次事务处理所需要的数据可能由多个表组合而成,表与表之间的关系可能存在某种约束,例如外键,这时如果操作一条数据记录就可能涉及到多个对象,多对象事务要保证就是当事务操作完成之后能确保这些外键的引用的正确性。而在文档型数据库执行一次事务大部分场景针对的当个文档对象操作,更新某个文档一个字段,就被视为单对象事务,而如果操作文档涉及到多个文档对象,就为多对象事务,因为文档模型不支持关系模型中的Join对表联合操作,没有规范化的数据时就得一次性更新多个文档对象,此时多对象的事务就能保证非规范化的数据更新出现不同步的情况。

而针对分布式数据库中涉及到问题是在此基础之上延伸出来的其他问题,分区索引涉及到多个节点,如果没有事务会出现部分索引更新的情况;例如事务已经在各个节点上执行成功,但是由于网络问题导致客户端没有确认,那么客户端则认为事务执行失败,会重新执行事务导致额外的重复数据记录;如果事务执行失败是因为系统负载导致,那么事务重试机制会导致系统更加糟糕;如果是客户端问题导致事务失败,如果没有重试机制可能会出现本次事务丢失情况。

综上所述的一部分问题可以在没有事务的情况执行,可以依靠着应用层的软件编写代码逻辑完成,但是不能保证出错之后的错误处理和错误恢复能变得简单,因为事务有提供相应的保证就可以简化一些问题,但是事务不是一个天然型的东西,虽然概念也很简单,但是数据库开发者们要实现这些功能,事务机制是被开发者们编写出来的,这对相关的开发者技能要严格意义上的要求,这时笔者的看法。


弱隔离级别

通过上面的了解不难推理出如果是两个不同的事务进行操作会发生竞争的情况,但是还一种情况是两个事务操作的数据记录互不干涉,例如一些个人信息更新场景,每个事务操作都是属于自己的数据记录,这时的完全可以使用并行执行事务不需要隔离机制。这种说法只能是对于应用层程序是透明的,因为程序开发者知道做了些什么,而大型数据库系统完全不知道这个问题的,并且一般大型分布式数据库系统会有多个客户端连接并且执行事务,并发出现数据竞争肯定是必会出现的。

对于一个数据库来说完全不知道连接的客户端正在执行什么样的代码?有哪些代码正在被执行访问数据库?因此数据库的开发者就要对其执行事务代码进行隔离,连接数据库执行 SQL 客户端可以不用关心这些问题,而数据库实现的开发者却要实现对于应用层的程序隐藏内部的并发数据竞争问题。

目前主流的数据库在解决这些问题上采用的弱隔离机制,因为如果对某些并发操作的事务想要不出现数据竞争情况,必须将其事务执行排队或者串行化执行,串行化执行带来一个问题如果是高并行情况下这些是完全接受不了的,串行化的隔离方式和之前我在并发锁那篇文章讲解的排号锁🔐类似,严重影响整体系统性能,这是不可取的。

如何设计一个事务隔离级别呢?目前几种设计方案如下:

  • 读提交: 防止脏读,读取数据库时只能读取到已经成功提交的数据,可以防止脏读;写数据库时只能覆盖已经成功提交的数据,可以防止脏写,这种方式称之为读提交的方式。

  • 实现读提交: 此种方式要针对表行进行加锁或者对操作对象进行加锁,持有对象锁的事务才能执行,其他事务退出等待,这种方式对写事务优好;但是对读事务就不那么理想因为读取的事务比写多,可能因为在执行写事务的时候很多读事务被阻塞,所以大部分实现都维护原操作对象的两个副本,当被未提交时读操作返回旧的版本,当写操作提交合并留住最新版本,此种方式称之为实现读提交的方式。

  • 多版本并发控制: 这个要求事务有版本快照隔离级别,可以防止读取不完整的结果和防止并发写的混合情况,因为时间差异导致的度倾斜等问题...让在某一段时间内执行的多个事务能处理好数据之间的一致性问题,正在执行的事务可能在不同时间并发查询数据的状态,如果对其做版本控制的话可以保留不同操作时间的数据状态,这种技术为多版本并发控制。

  • 索引与快照隔离: 通过索引的方式让数据的索引指向多个版本的数据记录,过滤当前事务不可见的那些版本,当后台的垃圾回收进程会决定删除某个旧对象版本,索引也会更新;典型的例子就是B-Tree数通过写时复制技术来说实现更新操作,不会修改其数据页面而是创建一个新的数据记录版本让索引指向它,脏页面会被后台回收掉。

  • 防止更新失效CAS操作: 某些场景会从数据库中读取某个对象,将其修改,在写回去,当两个事务操作其对象时会发生,后者覆盖前者写入的值,最终导致第一次写入的值丢失;解决办法通过原子操作或者并行自动检测更新丢失功能,相对于原子操作要强制加锁,而自动检测更新失败可以人程序并行执行,自动中断某个执行失败的事务。

  • 串行化程序: 事务竞争最大问题就是多个并行执行对某个资源进行修改,如果把这些事务通过串行的方式进行执行就不会出现竞争的情况,目前最强的事务隔离实现就是让异步的程序变成同步的;实现串行化的方式有很多例如 Redis 使用的单线程,通过将事务包装成数据库的存储过程来执行,但是缺点是不同的数据库用的存储过程可能不一样不兼容,最新的一些数据库采用第三方脚本语言去实现例如 Redis 使用的 Lua,而 Oracle 未来可能会使用 GraalVM 来做。

  • 数据分区: 串行化发挥不了多核 CPU 的性能,对应数据库这种应用程序来说要面对多个客户端连接处理事务,单线程的设计很容易造成事务处理的瓶颈;要支持多个 CPU 可以将整体数据进行分区,然后将分区绑定到单个线程上执行,但是如果是跨分区事务一样会回归到竞争问题的本质上。

  • 两阶段加锁: 两阶段锁可以也是算是事务实现中隔离级别最高的机制,允许多个事务同时读取一个对象,但是只要出现了如何写操作则必须加锁来保证事务的安全性,2PL 实现的效果能达到类似于同步原语,但是缺点也很明显其实事务吞吐量和响应时间相比于其他的弱隔离级别下降非常多,性能不好,因为2PL必须有一个前提事务要操作的对象必须等待前一个事务完成之后才能工作,所有受时间影响比较大。

  • 索引区间锁: 所为的区间锁就是只在索引上锁住其范围,例如预定某个时间段内的房间情况,可以对其房间的索引ID进行加锁,其他事务看到索引上有锁得等待其他事务执行完成再继续执行。

小 结

无法什么方式来实现事务,事务都是一个数据库必备的功能,而事务中的隔离性是整个数据库中关键,市面上关于事务的隔离性的研究有很多论文,最主要的问题就是解决多个事务操作单元操作对象冲突解决,目前就两大类悲观锁和乐观锁派系,两阶段锁就是典型的悲观锁实现对事务完成采用同步执行的方式,来保证多个事务操作同一个对象的绝对安全性;而相比衍生快照隔离是乐观锁的实现,相当于皮特森算法一样,数据库之后检测潜在的冲突会继续执行事务会继续执行而不是中断,而当事务提交时是串行同步的方式才能被提交。

其他资料

便宜 VPS vultr
最后修改:2023 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !