11

《数据密集型应用系统设计》读书笔记 - 第七章 事务

 3 years ago
source link: https://zhuanlan.zhihu.com/p/61622925
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

《数据密集型应用系统设计》读书笔记 - 第七章 事务

JavaScript话题下的优秀回答者

这一章主要讲的是单机数据库系统中,多步骤事务的实现方式。

一、ACID

事务所提供的保证就是ACID,即下面四点:

  • 也可以叫做“可终止性”,指事务一旦中途被终止,可以回退到事务发生前的状态,而不是停留在中间态
  • 中间态不可被外部读取,否则就是“脏读”
  • 从某个有效状态开始,事务的操作如果符合约束条件,那么结果应该依然是有效状态
  • 比如银行转账,单个账户的余额和已转出金额要时刻保持一致,不能有中间态
  • 这点更多地需要在应用层面保证,而不是数据库本身
  • 又叫做“可串行化”,指多个事务应该相互隔离
  • 即使多个事务底层是并行运行的,但外面看起来要像它们是串行运行的一模一样
  • 很少数据库用串行(强隔离)的方法,因为性能太差,大多数数据库都是用“弱隔离”的方式
  • 事务一旦提交成功,那么数据就不会丢失
  • 不存在绝对的持久性

二、弱隔离

为了保证性能,多个事务一般是并行运行的,这就需要实现事务间的“弱隔离”,下面是实现弱隔离的方法:

2.1、读-提交

  • 读-提交保证两点:没有脏写和脏读
  • 脏读:读到了尚未提交的数据
  • 脏写:覆盖了尚未提交的数据

实现方法:

  • 防止脏读:可以用行级锁,但性能会很差,所以一般是数据库维护两个版本的数据:旧值和尚未更新的值
  • 防止脏写:行级锁,保证只有一个事务可以拿到对象的写锁

2.2、快照级别隔离

读-提交虽然可以解决大部分事务并发的问题,但依然有部分问题无法解决:

读倾斜(read skew)

比如A账户转账给B账户,存在一个中间态:A账户扣钱了,但B账户还没有收到钱。对于事务来讲,不应该读到这个中间态。

解决方法

使用快照级别隔离,保证单个事务读取到的是某个确定时间点的数据

实现方式

多版本并发控制(MVCC),即保存对象的多个不同的提交版本,根据事务id确定读到的应该是哪个版本的数据。

如何确定?(可见性原则)

  • 正在进行中的事务,尚未提交的数据绝对不可见
  • 更后面的事务,做出的任何修改都不可见(即使已经提交)
  • 被终止的事务任何修改绝对不可见

2.3、防止丢失更新

读-提交和快照隔离都只是解决了只读事务的问题,没有解决写事务隔离的问题(虽然读-提交中解决了脏写,但脏写只是写事务并发的一个特例)

更新丢失的定义

两个写事务,第二个写事务的结果没有包含第一个写事务修改后的值,比如两个自增某个字段的事务。

解决方法:

原子写操作

大多数数据库都实现了 Update 的原子化,即对于一个读-修改-写这样的操作,在读之前就加上独占锁(而不是在写之前)

显式加锁

数据库不支持内置原子操作的话,那么就用一个字段来显式加锁

原子比较和设置

写入前先确定,数据的最新值是否和事务开始时一致,如果不一致,则说明事务执行期间发生了改动,那么更新失败。

2.4、写倾斜(write skew)和幻读

定义

一些“读-修改-写入”这样的事务,写入的东西可能是由读的结果决定的(比如申请会议室,要先读会议室有没有被占用,再决定写入是否成功),这时查询的结果可能是“幻读”,即这个查询结果已经被其它事务改变了,这时就会产生写倾斜。

解决方法

实体化冲突:把幻读的冲突问题实体化为数据库的表,具体做法就是把数据的读写关系维护在一张表中,人工解决幻读的问题,这不是推荐的做法,推荐用可串行化隔离。

2.5、弱隔离级别总结

弱隔离级别可以做到高性能,但依然存在并发下的边际问题,括号中是解决方法:

并发读:脏读(锁)、读倾斜(快照级别隔离)、幻读(可串行化隔离)

并发写:脏写(锁)、写倾斜(可串行化隔离)


三、可串行化隔离

上面说的全都是“弱隔离级别”,它无法彻底解决写倾斜、幻读这样的边缘条件,所以我们需要更加严格的“可串行化隔离”,实现方式如下:

3.1、串行执行事务

不解释,就是实现上把每个事务真正地串行执行,简单粗暴,这样就能保证绝对不会有并发冲突,现在之所以可以这样做,是因为:

  • 内存越来越便宜,把数据加载到内存中,串行执行事务的速度大大提高。
  • 只读事务(比如数据分析相关的事务),可以运行在一致性快照上,不需要阻塞主线程。

串行执行事务有性能问题,但是有一些方法优化:

存储事务的过程

串行执行事务的数据库,如果等到某个事务经过多次IO才提交,会严重拖慢性能(这个事务一直占用着线程),所以串行执行事务的数据库一般都不支持IO式的多语句事务。

一个事务有很多步骤,而这些步骤可能是和用户IO产生的,比如选座订票系统,这时就可以把事务过程存储下来,事务提交时再一起发送给数据库。这中间需要一个存储过程的语言,比如T-SQL

分区

对数据进行合理的分区,可以大大提高串行运行事务的性能,即保证每个事务只在单个分区内运行,这样就只会阻塞那个分区内的事务线程。而跨分区事务,可以支持但是性能会比较差。

3.2、两阶段加锁(two-phase locking, 2PL)

  • 锁分为两种:共享锁和独占锁;
  • 获取共享锁时,如果对象的独占锁已经被拿走了,那么需要等待独占锁释放;
  • 获取独占锁时,如果对象有其它任何锁被拿走,那么需要等待释放;
  • 如果事务要读取对象,那么必须获得对象的共享锁(这样就保证了对象不会在事务期间被别的事务改动);
  • 如果事务要修改对象,那么必须获得独占锁(保证不会有脏写);
  • 如果事务先读后改,那么读时获得的共享锁升级为独占锁,步骤和获取独占锁一致。

“两阶段加锁”这个名字的由来就是,事务有两个阶段,先获取一些锁,然后释放它们,不会交替进行。

两阶段加锁的性能问题

“两阶段加锁”的核心思想是,如果两个事务试图做任何可能会引发竞争条件的事情,那么其中一个必须等另一个完成,这就会大大降低性能,并且可能发生死锁。

3.3、可串行化的快照隔离

“两阶段加锁”实际上是一种“悲观锁”,也就是只要有竞争条件的可能(哪怕实际没有),就会绝对串行执行。

可串行化的快照隔离是一种最新的算法,实际上是一种“乐观锁”,它假设没有冲突,然后到提交阶段检查是否产生了冲突,如果有,那么终止事务并且重试。

那么如何检查是否产生了冲突呢?这需要看事务是不是包含了过期的信息

检查是否读取了过期的MVCC对象

如果一个事务包含读写,并且在提交时,发现它读的对象已经被其它修改过了,那么就需要让这个事务提交失败。(避免上文提到的写倾斜)

检查写是否影响了之前的读

对于写入操作,如果检查发现写入会影响其它事务的读结果,那么需要通知其它事务,从而终止或者重试。

可串行化快照隔离的性能

性能比两阶段加锁更高,因为不需要等待其他事务所持有的锁释放;与串行执行相比,更能利用多个CPU核心,也可以在多个分区上运行。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK