数据库事务小记

Posted technologyDaily

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据库事务小记相关的知识,希望对你有一定的参考价值。

前段时间同事推荐了《designing data-intensive applications》这本书,第二天就买了,看了几章感觉很不错,读完每章都有收获。目前正好看完第七章,就以笔记形式记录下在事务篇学到的东西。


数据系统中,很多事情会出错,硬件错误、并发写入覆盖彼此、网络中断等等,为了保证可靠性,必须要有足够的容错机制可以处理这些故障。

近些年来,事务是解决这些问题的首选机制,事务是应用程序将多个读取和写入操作组合成逻辑单元的一种方式,逻辑单元中的所有读写都是作为一个操作执行的,整个事务只有完全成功或者失败两种状态,成功会提交所有的操作,失败的话,所有的操作都会回滚,保证业务方可以安全的重试。

事务固然能带来一些便利,但是也会带来一些性能的损耗。什么时候需要事务呢?事务能够具体提供哪些安全保证呢?使用这些安全保证又需要付出哪些代价呢?

事务概念

事务的安全保证由 ACID 来描述,ACID 表示原子性、一致性、隔离性、持久性。单就隔离来说,就有很多模糊的语义,一个系统自称是符合 ACID 的,甚至不能确定它到底可以提供什么保证。想对事务有一个很好的理解,需要我们深入理解下 ACID 意义。

ACID 的意义

原子性:一般来说,原子是不能分割的最小单位。在计算机不同分支中它有不同的意义,在 事务中它描述了客户端想要多次写入时,如果中间发生故障(网络、硬件等等),会怎么处理。所有的写入会被当做一个事务处理单元,如果在写入过程发生了故障,之前的所有写入都会被回滚,保证整个事务单元要么所有的写入都成功了,要么所有的写入都失败了,不存在一部分失败、一部分成功的情况。业务方在失败时候就可以安全的重试。

一致性:一致性通常是和业务关联的,业务方对某个数据有个一致性的陈述,假设 A 账号500元,B 也是500元,我们可以定义一致性陈述:A 和 B 账号的总额。数据库不会对一致性做出任何保证,需要业务方自己控制,假设业务方自己破坏了一致性陈述,这是没法避免的。那么数据库做了什么工作呢?数据库提供了诸如原子性、隔离性这种机制,业务方通过这些机制来实现一致性。在上面的例子,就是业务方假设要使 A 和 B 账号的总额不变,可以把 A+B 的总额当做一个一致性的描述,然后通过数据库提供的诸如原子性、隔离性保证实现一致性。AID 都是数据库的保证,但是 C 确是和业务相关的。

隔离性:看下图(来自书中)一个例子

数据库事务小记

两个客户端同时需要对某个计数做出修改,先 get 到值,+1,再 set 回去,原来的值是 42,按理结果应该是 44,但是因为竞态条件,最后的结果却是 43。

隔离指什么?指的是同时执行的事务间是互相隔离的,但是具体怎么隔离,隔离到什么程度呢?一些书籍上把隔离定义为序列化,这是不对的,序列化指相同执行的事务按照先后顺序执行,这样确实不会出错,但是执行效率确实大大下降。很多数据库在隔离上定义了级别,不同级别提供的安全性保证不同,付出的性能损耗也不同。关于隔离级别详细的分类会在下面介绍。

持久性:持久性就是说一旦交易完成,即使存在硬件故障或者数据库崩溃,数据也不会丢失。单节点数据库中,持久性一般意味着数据被写入到 SSD 或者硬盘上,但是完美的持久性是不存在的。单节点数据库中硬盘或者 SSD 损毁,亦或者复制型数据库所有备份都损毁,数据一样会丢失,只是概率问题。再好的策略也只意味着风险的降低,不能提供百分百可靠的保证。

多对象操作

什么是多对象操作?假设有张邮件表,每次发送一封新的邮件,表里就会增加一条记录。还有一张表,记录用户的未读邮件数,每次我们在新增邮件的时候,需要先在邮件表插入一条记录,然后更新未读邮件数,假设第二步更新失败了,就会出现明明有未读邮件,但是未读计数却不对的情况。这两步操作操作了两个对象,是多对象的操作。

弱隔离级别

假设两个写入事务同时进行,如果他们操作的是不同的数据,可以安全的运行,但是当他们涉及相同的数据,比如同时写入,就可能会出现问题。数据库一直试图通过隔离级别来解决这个问题,对应用开发者屏蔽并发可能出现的问题,但是序列化的隔离级别带来的性能损耗太大,相当于把所有请求串行。为此,许多数据库系统提供了弱隔离级别,下面将会介绍各个隔离级别特点。

读已提交

它提供了两个基本保证:

  1. 从数据库读取数据时,只能读取已经提交的数据(没有脏读)

  2. 写入数据库时,只能覆盖已经写入的数据(没有脏写)

没有脏读

假设一个事务已经将一些数据写入到了数据库,但是还未提交,其他事务是否可以读取到这些未提交的数据呢?如果可以的话就是脏读。读提交的隔离级别避免了脏读,这意味着事务的任何写入操作只有在提交了之后才能被其他事务看到。看下图(来自书中):

数据库事务小记

user2 读取的数据在 user1 提交之前,始终是老的。

为什么避免脏读呢?

  1. 如果事务需要更新多个对象,脏读的话意味着其他事务可能会看到部分更新,比如邮件发送到了但是未读邮件数没有更新的情况。

  2. 如果事务执行过程中出现了问题,意味着需要回滚,如果脏读存在的话,意味着其他事务可能会看到已经回滚的一些数据,就会出现一些莫名其妙的问题。

没有脏写

我们先看下下图(来自书中)

数据库事务小记

我们可以看到同一时刻发生了两个事务,对同一数据进行更新,Alice 更新了但是没提交,这时候 Bob 更新了数据并提交,再之后 Alice 提交,这就出现了脏写,因为 Alice 写了   listings 之后,还未提交,Bob 却覆盖了。

问题来了,我们如何实现读已提交?

避免脏写,最容易想到的,自然是用行级锁,当一个事务想要修改一个特定的对象时,必须先获得该对象的锁,然后必须保持该锁,直到 commit 或者终止。读提交的隔离级别会实现这些,应用地方只要指定下隔离级别就好。

避免脏读,使用一个相同的行级锁的话,实践中运行效果不佳,因为长时间运行的写入事务使得许多只读事务只能等待,直到长时间运行的事务完成,会影响读的响应时间。大多数数据库现在使用下面的方式解决这个问题:数据库会记住当前持有写锁的事务设置的旧值和新值,当事务进行时,任何其他读取对象的事务读取到的都是旧的值。只有当新值提交,事务才会读取到新值。

快照隔离和可重复读

读已提交解决了一部分问题,但在某些并发环境下还是会出现问题的,看下图(来自书中):

数据库事务小记

Alice 一开始两个账号,各有 500 美元,现在一个往另一个转 100 美元,如果她正好在交易进行的时刻查看余额,第一个账号发现是 500 美元,第二个账号查的时候,恰好转账完成了,这时候就会发现第二个账号的余额是 400 美元,少了 100 美元?但是再过几秒钟再查看,数据就正确了,一个600, 一个400。这被称作不可重复读。

不可重复读与脏读的区别是?

脏读是指两个事务,一个事务读取了另一个事务未提交的写入。不可重复读是指一个事务内部两次读取一个数据,第一次读取的是老数据,第二次读取的时候,恰好数据进行了更新,读取的就是新数据,两个读取的数据既有新的、又有老的,就会出问题。

如何解决不可重复读?

快照隔离是一个常见的解决方案,其思想是:事务在执行期间,只能看到在事务开始时已经提交的所有数据,即使在事务执行期间又发生了数据的更新,该事务也只能看到最开始版本的所有提交。

如何实现快照隔离?

数据库需要保存一个对象的不同写入版本,因为正在进行的事务可能需要在不同的时间点看到数据的状态。因为维护了多个版本的对象,所以也称为多版本并发控制(MVCC)。PostgreSQL 中是这么实现的:每个事务在开始的时候,赋予一个全局的 id,当事务向数据库写入任何内容的时候,所写入的数据都会被标记一个事务 id,表中每行都有一个 created_by 字段,包含插入该条数据的事务 id。还有一个 deleted_by 字段,最初是空的,如果某个事务删除了一行,该行其实并没真正的删除,而只是将 deleted_by 字段标识一下,等之后确认没有其他事务会访问这个被删除的数据时,会把这个数据真正的删除掉。

MVCC 下索引如何正常工作?

一般来讲两个方式:

  1. 简单的让索引指向数据的所有版本,且需要索引查询过滤当前事务不可见的对象版本。

  2. 每个写入事务创建一个新的 B 树根,查询时候不需要根据事务 id 过滤,因为新事物产生只能生成新的 B 树根,需要一个压缩和垃圾回收的后台进程。

防止丢失更新

如果两个事务同时修改一个值,可能会出现更新丢失的情况。比如更改正好的余额,一开始500,两个事务都要减 10,一开始 A 事务读取了余额 500,要开始减 10 了,开始之前 B 事务也执行了减 10 的操作,这时候实际余额只剩下 490 了,但是 A 因为刚才读到的是 500,以为余额还是 500, 一减结果是 490。等于是 B 的更新丢失了。

脏写和更新丢失的区别?

脏写指的是一个事务的写入和提交之间,其他事务进行了对相同数据的写入。而更新丢失指的是一个事务要更新一个数据,先读取在更新,读取完了之后,另外一个事务修改了数据的值。

如何避免更新丢失问题?

  1. 使用数据库提供的原子操作

  2. 显示使用锁,如 mysql 的 for update

  3. 有的数据库的某些隔离级别可以支持更新丢失检测,并进行一些处理措施

  4. CMS,更新时候看下数据是否没有被改过,没有的话再去执行更新,这个操作有可能不安全,比如数据库允许从旧的快照读取数据,使用这个前需要确认下 CMS 在当前数据库的隔离级别设置下是否安全。

幻影读

假设事务 A 根据一些检索条件从数据库读取了一些数据,此时 A 未提交,事务 B 新增了一条数据,这些数据会影响 A 的检索结果,这时候 A 再检索,发现和之前的读取结果不一样了,就和出现了幻影一样,这就是幻影读。

序列化

最强隔离级别,一般有三种实现方式:

  1. 串行顺序执行事务

  2. 2PL

  3. SSL

下面主要介绍下前两个:

串行执行

避免并发问题最简单的方式就是完全去除并发,在一个线程上按顺序一次执行一个事务,通过这样做,避免了事务冲突的可能。redis 就是采用的这种方式,性能不错,主要原因如下:

  1. RAM 价格越来越便宜,redis 完全内存式操作,效率很高

  2. 单线程避免了上下文切换以及锁

  3. epoll 模型

事务串行处理的缺点

  1. 每笔事务都需要小而快,一个事务慢就可能拖慢所有的事务

  2. 数据集都可以放到内存情况下性能还好,但是如果把部分数据放到磁盘上,在单线程事务访问中效率会变得比较差

  3. 写入吞吐量比较低才可以在单核 CPU 上运行,否则可能需要考虑数据分区

2PL

30年内,使用的最常见的解决序列化的手段是 2PL,2PL 规则如下:

  • 如果事务 A 读取了一个对象,并且 B 想要修改这个对象,则 B 必须等 A 读取完了之后才能修改

  • 如果事务 A 写入了一个对象,并且事务 B 想要读取一个对象,则 B必须等到 A 写入完成这个对象之后才能读取。

2PL 的实现

  1. 如果事务要读取一个对象,则必须先以共享模式获取一个锁,多个事务可以同时保持共享模式的锁定,但是如果一个事务已经以独占模式持有锁,则其他事务必须等待。

  2. 如果一个事务想要写入对象,则必须先获取独占锁,没有其他事务可以同时持有锁,所以如果其他事务持有任何锁,都必须等待

  3. 事务获得锁之后,必须保持到事务结束

  4. 如果事务首先读取再写入对象,共享锁会升级成为独占锁。

2PL 性能

和弱隔离级别比,事务吞吐量和响应时间受影响更严重,如果多个事务想要读取同一个对象,就可能形成队列,所以一个事务可能需要等待其他事务才能完成操作,这就导致 2PL 可能具有不太稳定的响应时间。

谓词锁

再说幻象读,举个例子,现在有个 123 会议室,A 和 B 都想预定,事务 A 先执行了查询,发现在 13:00-14:00 有空余的,这时候 B 也执行了查询,发现有空余,并且预定了,这时候 A 再进行操作,发现会议室没了,前后两次读取结果不一样,就和出现幻象一样,这个怎么解决呢?

还是锁定,但是我们要锁定确是某个时间范围内的某个会议室, A 操作之前,看一下自己检索的条件,找出符合自己搜索范围的对象集合,在这里就是 13:00-14:00 的 123 会议室,尝试获取共享锁,如果这时候发现锁定范围内任何一个对象有独占锁,就等待,直到对方执行结束,这个范围内的锁就叫谓词锁。同样,别的事务在写入的时候,也要检查操作的对象是否和现有任何谓词锁匹配,有的话就等待。

谓词锁性能及改进?

如果活动的事务有很多锁定,则检查匹配的锁定比较耗时,解决方案是使用索引范围锁定,是一种近似的锁定。

假设之前的锁定匹配的是房间 123 在下午 1 点到 2 点时间范围,其实锁定一个更广的范围也是可以的,比如锁定房间 123 所有时间,或者 1 点到 2 点的所有房间,都可以避免幻象读的问题。假设数据库有根据时间查询的索引,那就可以在索引上加上共享锁信息,其他事务想写入的时候,只要是在锁定的时间范围内有任何对象占有锁,就要等待。





以上是关于数据库事务小记的主要内容,如果未能解决你的问题,请参考以下文章

一致性小记

数据仓库小记

JanusGraph 图数据库安装小记 ——以 JanusGraph 0.3.0 为例

数据库事务系列1 事务概述 事务分类

Spring 事务相关

事务日志的用途是啥