本篇以Postgresql为例,探讨数据库的事务、并发控制和锁机制。

ACID

在关系型数据库中,一个事务必须具备以下特性,简称ACID:

  • 原子性(atomicity):事务必须以一个整体单元的形式工作,对于数据的修改要么全部执行,要么全部不执行;
  • 一致性(consistency):事务在完成时,必须使所有的数据都保持一致状态。比如a+b=10,当a改变时,b也将改变;a+b=10不变。
  • 隔离性(isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
  • 持久性(durability):事务完成后,对系统的影响是永久的,即便之后机器重启,断电,数据也将一致保持

在Postgresql中使用多版本并发控制(MVCC)来维护数据的一致性。相较于锁定模型,MVCC的主要优点是在MVCC里对读数据的锁请求与写数据的锁请求不冲突,读不会阻塞写,而写也从不阻塞读。

此外,PG与其他数据库最大的区别在于,在PG中大多数DDL可以包含在一个事务中,并支持回滚;此功能使得PG尤其适合作为sharding分布式数据库系统中的底层数据库。

事务隔离级别

数据库中存在以下四种隔离级别:
read uncommitted: 读未提交
read committed :读已提交(PostgreSQL中的默认隔离级别)
repeatable read: 重复读
serializable: 串行化

隔离级别

脏读

不可重复读

幻读

序列化异常

read uncommitted

允许,但不在PG中

可能

可能

可能  

read committed

不可能

可能

可能

可能

repeatable read

不可能

不可能

允许,但不在PG中

可能

serializable

不可能

不可能

不可能

不可能

概念解释:

脏读
一个事务读取了另一个并行未提交事务写入的数据。
不可重复读
一个事务重新读取之前读取过的数据,发现该数据已经被另一个事务(在初始读之后提交)修改。主要针对update

begin;
select name from tab1 where id=111;
#得到name是“张三”
#此时另外一个事务对id=111的name更新成了“李四”
select name from tab1 where id=111;
#得到name是“李四”

幻读
一个事务重新执行一个返回符合一个搜索条件的行集合的查询, 发现满足条件的行集合因为另一个最近提交的事务而发生了改变。主要针对insert 、delete

begin;
select name from tab1 where id=111;
#得到name是“张三”
#此时另外一个事务对id=111的记录增加一条“李四”
select name from tab1 where id=111;
#得到name是“李四”、“张三”

序列化异常
成功提交一组事务的结果与这些事务所有可能的串行执行结果都不一致。

在PostgreSQL中,你可以请求四种标准事务隔离级别中的任意一种,但是内部只实现了三种不同的隔离级别,即 PostgreSQL 的读未提交模式的行为和读已提交相同。这是因为把标准隔离级别映射到 PostgreSQL 的多版本并发控制架构的唯一合理的方法。

探讨:

从上面可以看出不可重复读和幻读的区别在于:

不可重复读重点在于更新,在数据库控制方面只需要锁住满足条件的记录(可以理解为行锁);

幻读重点在于删除和插入,在数据库控制方面需要锁住满足条件及其相近的记录(可以理解为表锁);

如果使用锁机制来实现这两种隔离级别,在可重复读中,该SQL第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会 发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。(悲观锁机制

因此可以推断出不可重复读和幻读的最大区别在于数据库采用何种锁机制来解决他们产生的问题;

在PG中(mysql、oracle也是)为了更高的性能,乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题。

悲观锁
正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处 于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机 制,也无法保证外部系统不会修改数据)。

在悲观锁的情况下,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。

乐观锁
相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。

而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如 果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

 

两阶段提交

两阶段提交是分布式系统中保持事务原子性的关键

Postgresql中两段式提交步骤如下:

<第一阶段>

(1)应用程序先调用各台数据库做一些操作,但不提交事务;然后调用事务协调器(这个协调器可能由应用自己实现)中的提交方法。

(2)事务协调器将联络事务中涉及的每台数据库,并通知它们准备提交事务,这是第一阶段的开始,PG中一般调用“PREAPARE TRANSACTION”命令。

(3)各台数据库接受到“PREPARE TRANSACTION”命令后,如果要返回成功,则数据库必须将自己置于以下状态:确保后续能在被要求提交事务时提交事务,或者在被要求回滚事务时能够回滚。因此PG会将已经准备好提交的信息写入持久存储区中。如果数据库无法完成此事务,它会直接返回失败给事务协调器

(4)事务协调器接收到所有数据库的响应

<第二阶段>

如果任一数据库在第一阶段返回失败,则事务协调器将会发出“ROLLBACK RREPARED”命令给各个数据库进行回滚;如果所有数据库的响应都是成功,则发送“COMMIT PREPARED”命令进行提交。

 注:在实际操作中需要将PG的参数“max_prepared_transactions”设置为一个大于零的数字,否则会报错。

 MVCC

MVCC是为了解决读写并发时数据不一致的问题,MVCC的方法是写数据时,旧的版本数据不删除,并发的读还能读到旧版本的数据,这样就避免了数据不一致。

实现MVCC的方法有两种:

  • 第一种:写新数据时,将旧数据移到一个单独的地方,如回滚段中,其他人读数据就从回滚段中把旧数据读出来。
  • 第二种:写新数据时,旧数据不删除不移动,而是把新数据插入。

 Postgresql中使用的是第二种,而oracle和mysql中的innodb引擎使用的是第一种。

Postgresql中MVCC的实现

为了实现MVCC,每张表上都添加了四个系统字段:xmin、xmax、cmin、cmax

  • xmin:标记插入该行数据的事务ID。
  • xmax:标记删除该行数据的事务ID。
  • 新插入一行时,将新插入行的xmin填写为当前的事务ID,xmax填0。
  • 修改某一行时,实际上是新插入一行,旧行上的xmin不变,旧行上的xmax改为当前事务ID,新行上的xmin填为当前事务ID,新行上的xmax填为0。
  • 删除一行时,把被删除行上的xmax填为当前事务ID。
  • 关于事务ID:是一个32bit数字,从3开始递增到最大值,之后再从3开始。
  • cmin:事务内部插入类操作的命令ID。
  • cmax:事务内部删除类操作的命令ID。
  • 每个命令使用事务内一个全局命令标识计数器的当前值作为当前命令标识。
  • 事务开始时,命令标识计数器被置为初值0。
  • 执行更新性的命令(insert、update、delete、select…for update)时,在SQL执行后命令标识计数器加1。
  • 当命令标识计数器经过不断累加又回到初值0时,报错“cannot have more than 2^32-1 commands in a transaction”,即一个事务中的命令的个数最多为2^32-1个。

PG中的MVCC实现过程

  • 当两个事务同时访问记录时,通过参考xmin和xmax的标记可判断记录的版本,然后根据版本号与自己当前的事务标识进行比较,确定自己的数据权限。
  • 当删除数据时,记录并没有从数据块中删除,空间也没有立即释放。PostgreSQL通过运行vaccum进程来回收之前的存储空间。默认PostgreSQL数据库中的autovacuum是打开的,也就是说当一个表的更新达到一定数量时,autovacuum自动回收空间。
  • 在PostgreSQL中,并不会在事务提交时把这些数据标记成有效,在事务回滚时标记为无效,如果事务提交或回滚时再次标记了数据,那这些数据就有可能会被刷新到磁盘中,而再次导致另一次I/O,从而降低了性能。PostgreSQL是通过记录事务的状态到commit log中来实现的。

PostgreSQL把事务状态记录在commit log(clog)中,事务的状态有以下四种。

  • TRANSACTION_STATUS_INPROGRESS=0x00表示事务正在进行中。
  • TRANSACTION_STATUS_COMMITTED=0x01表示事务已经提交。
  • TRANSACTION_STATUS_ABORTED=0x02表示事务已经回滚。
  • TRANSACTION_STATUS_SUB_COMMITTED=0x03表示子事务已提交。

事务ID在PG中用xid表示,是一个32bit的数字,有以下三个特殊的事务ID给系统内部使用:

  • InvalidTransactionId=0:表小是无效的事务ID
  • BootstrapTransactionId:1:表示系统表初始化时的事务ID
  • FrozenTransactionId=2:冻结的事务ID

事务ID会一直递增,当达到最大值后再从头开始,此时就会遇到事务ID回卷的问题。在PG中当事务ID达到2^31时,旧的事务ID就会变成一个特殊的事务ID,即FrozenTransactionId;当正常的事务ID与冻结的事务ID进行对比时,会认为正常事务ID比冻结事务ID新。

 

参考文献

Postgresql从小工到专家