数据库必知必会系列:数据库并发控制与事务隔离级别

发布于:2023-09-27 ⋅ 阅读:(77) ⋅ 点赞:(0)

作者:禅与计算机程序设计艺术

1.简介

什么是并发控制?

并发控制(Concurrency Control)指的是在多用户环境下对事务或者数据访问进行管理,以确保数据的完整性、正确性及有效性。它分为两个层次,即并发一致性控制和事务隔离。

为什么要做并发控制?

为了提高数据库的可用性、性能和安全性,应当在设计、开发和运维数据库系统时就考虑并发控制的问题。一个好的并发控制机制可以使数据库系统处理更多的并发请求而不出现性能下降或系统崩溃等现象;还可以防止数据库因并发请求过多而发生混乱甚至死锁。

什么是事务隔离?

事务隔离(Transaction Isolation)是并发控制的一种方法,它定义了数据库的哪些修改在同一时间内不能被其他事务看到。数据库提供三种事务隔离级别:读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。

为什么要区分事务隔离级别?

不同级别的事务隔离往往对应着不同的程度的并发控制。读已提交级别允许读取尚未提交的数据,会导致脏读问题。可重复读级别则通过阻止读取非最新版本的数据来解决脏读问题。串行化级别保证事务顺序执行,避免幻读问题。

在哪里能够找到更多关于并发控制和事务隔离的信息?

  • 有关并发控制,推荐阅读 Wikipedia 中的相关条目 https://en.wikipedia.org/wiki/Concurrency_control
  • 有关事务隔离级别,推荐阅读《Database System Concepts》第五章 Transaction Management,其中给出了详细的介绍。
  • 有关数据库并发控制的开源项目,包括 MySQL 的 InnoDB 和 PostgreSQL 的 MVCC (Multiversion Concurrency Control),都提供了详尽的文档。另外,并发控制可能与数据库查询优化、缓存策略、索引设计、网络通信等多个方面有密切关系。因此,理解并发控制背后的机制和原理十分重要。

2.基本概念术语说明

首先,我们需要熟悉并发控制和事务隔离的基本概念和术语,如图1所示。 上图中,事务(Transaction)是一个不可分割的工作单位,由一组SQL语句组成,这些语句对数据库进行读写操作。事务具有四个属性:原子性、一致性、隔离性、持久性。

  • 原子性(Atomicity):事务是一个不可分割的工作单位,事务中的所有操作要么全部完成,要么全部失败回滚。
  • 一致性(Consistency):事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是相互关联的。
  • 隔离性(Isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是完全隔离的,并发执行的各个事务之间不能互相干扰。
  • 持久性(Durability):持续性也称永久性,指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

在数据库系统中,事务隔离用于实现并发控制。通过控制事务对于数据库中数据的访问方式,数据库管理员可以最大限度地提升并发量和系统整体性能。

由于并发控制与事务隔离是两个独立但相关的概念,所以它们之间的关系也是模糊的。如果把事务隔离看作一套完整的规则集合,那么并发控制就是要实现这个规则集合中要求的各种并发特性。

在讨论具体的并发控制机制之前,我们需要先了解数据库系统中使用的一些术语和概念。以下简要介绍一下。

术语及概念

锁(Lock)

在并发控制中,数据库对象一般都处于加锁状态。任何时候只能有一个事务持有锁,其他事务必须等待前一个事务释放锁后才能继续。锁又分为两种:共享锁(Shared Lock)和排他锁(Exclusive Lock)。

  • 共享锁(S Lock):允许一个事务读一个资源,其他事务只能获得相同资源的共享锁,不能获得排他锁。
  • 排他锁(X Lock):允许获得排他锁的事务独占访问一个资源。当事务获取排他锁时,它会阻塞其他事务对该资源的访问。

封锁协议(Blocking Protocol)

在封锁协议中,锁的申请和释放过程遵循一定的顺序和规则。主要包括以下三个方面:

  1. 一级封锁协议(First-Level Locking Protocol):这是基于锁的并发控制中最简单和最传统的一套协议。按照这种协议,当某个事务请求某些资源上的锁时,只需检查是否已经被占用,如果没有被占用,则立刻分配锁,否则,将请求加入等待队列。如果待请求的资源已经被其他事务占用,则该事务进入等待直到资源可用。
  2. 二级封锁协议(Second-Level Locking Protocol):这是基于锁的并发控制中较复杂的一套协议。根据这一协议,事务需要在两个阶段进行封锁请求和释放。第一阶段为快照读(Snapshot Read),此时只需检查待请求资源上是否有其他事务的更新,如果没有,则分配共享锁;第二阶段为排他写(Exclusive Write),此时需要检查待请求资源上是否有其他事务的更新,如果没有,则分配排他锁。如果待请求的资源已经被其他事务占用,则该事务进入等待直到资源可用。
  3. 第三级封锁协议(Third-Level LockingProtocol):这是基于锁的并发控制中目前最复杂的一套协议。其理论基础为多版本并发控制(MVCC),这种控制机制允许事务同时存在多个版本,每个版本记录了数据库在某个时间点的快照。在封锁时,并不是真正给资源上锁,而是给当前版本号加上偏移量,以便在提交时统一检查是否有冲突。

两阶段锁协议(Two-Phase Locking Protocol)

两阶段锁协议(Two-Phase Locking Protocol,2PL)是一种并发控制协议。它规定了一个事务的两步锁过程:第一步是加锁阶段,在该阶段,事务仅需要获得满足条件的锁;第二步是解锁阶段,事务释放所有锁,保证事务对资源的独占性。该协议的关键就是“保持事务的隔离性”,即允许多个事务并发访问同一资源,但是禁止多个事务同时对资源做增删改查。

死锁(Deadlock)

当多个事务互相持有对方需要的资源,形成互相等待的循环,称之为死锁。死锁发生时,数据库管理系统必须检测出死锁的发生,并采取适当的方式来解除死锁。

可串行化调度(Serializability Scheduling)

可串行化调度(Serializability Scheduling)是指为了保证事务的串行执行,必须按照一定顺序来调度事务的执行。例如,银行转账过程中,银行需要按顺序依次对两个账户进行交易,要么都成功,要么都失败,不存在并行执行的情况。

并发控制机制分类

基于锁的并发控制机制可以分为乐观锁和悲观锁两种类型。

悲观锁(Pessimistic Locking)

悲观锁(Pessimistic Locking)认为事务之间的数据并发存取是互斥的,也就是说在执行数据读写的时候都会加锁。如果资源已经被其它事务占用,就会在当前事务开始之前阻塞等待。

悲观锁优缺点

悲观锁优点:实现简单,数据完整性得到保障。 悲观锁缺点:在很多情况下可能会造成性能问题,因为事务在执行过程中,需要花费大量的时间加锁和释放锁,这样会导致其他事务长期得不到执行机会。并且在大量的事务并发执行的时候,效率很低。

乐观锁(Optimistic Locking)

乐观锁(Optimistic Locking)认为事务之间的数据并发存取是不冲突的,也就是说在执行数据读写的时候不会加锁,而是在提交更改前,会校验当前数据是否有变化。如果发现数据已被修改,那么本事务回滚,否则提交更改。

乐观锁优缺点

乐观锁优点:操作更高效,并发性好。 乐观锁缺点:在更新数据前无法确定数据是否发生变化,可能会导致更新丢失。

并发控制算法分类

并发控制算法可分为两种:基于锁的算法和基于消息传递的算法。

基于锁的算法

基于锁的算法(Lock-Based Algorithms)依赖于数据库中的锁机制,主要包括2PL和三级封锁协议。

2PL

在2PL中,事务遵循两个规则:

  • 普通规则(Regular Rule):如果事务T1对数据对象A加了X锁,而事务T2想要加S锁,则需要等T1释放锁。
  • 推迟规则(Earlier Rule):如果事务T1对数据对象A加了X锁,事务T2想要加S锁,而T1刚好释放了X锁,那么T2可以马上获得S锁。
三级封锁协议

在三级封锁协议中,事务遵循三个规则:

  • 第一级封锁规则(First-Level Locking Rule):一个事务可以在一个时刻只持有一把锁。
  • 二级封锁规则(Second-Level Locking Rule):事务必须按申请锁的顺序获得锁。
  • 三级封LOCKING RULE(Three-Level Locking Rule):如果事务T对数据对象A加了S锁,其他事务只能获得该对象的S锁或X锁,不能获得其它的任何锁。

基于消息传递的算法

基于消息传递的算法(Message-Passing Algorithms)依赖于消息传递机制,主要包括2PC和PGM算法。

2PC

在2PC中,事务分为准备阶段(Prepare Phase)和提交阶段(Commit Phase)。在准备阶段,事务协调者向所有的参与者发送事务准备消息,表示事务即将执行,但还未提交。各参与者收到消息后,执行事务,将 Undo 或 Redo 信息写入磁盘。各参与者向事务协调者反馈事务执行结果。只有在所有参与者反馈事务执行成功之后,事务协调者才向所有参与者发送提交消息。

PGM

在PGM中,引入了准备和提交请求(Prepare and Commit Request)来实现分布式事务,它的基本思路是把事务拆分为多个子事务,然后由多个参与者分别执行子事务,并汇总结果。如果有参与者失败,就把整个事务回滚到一个一致的状态。

3.核心算法原理和具体操作步骤以及数学公式讲解

2PL

普通规则

当事务T1对数据对象A加了X锁,事务T2想要加S锁,则需要等T1释放锁。

说明:如果存在不满足以上规则的情况,比如T1先开始锁定A,但是T2要读A,这时就可以直接进行读取,而不需要等待。

普通规则的数学描述如下:

$$\forall T_{i}, T_{j} \in TS.T_{i}(R_{i}), T_{j}(W_{j}) \Rightarrow [ T_{i}: A ]$$

这里$TS$代表事务集合,$R_i$代表事务$T_i$所需的资源集合,$W_i$代表事务$T_i$想要加的锁类型。 例如,假设存在一个数据项$A$,初始值为$v$,两个事务$T_1$和$T_2$分别需要读和写$A$,同时允许其他事务进行读操作:

  1. $T_1$: 请求资源$R(T_1)$上锁$X$,$T_2$阻塞。
  2. $T_2$: 请求资源$R(T_2)$上锁$S$,$T_1$阻塞。

在此时,两者都在等待,直到$T_1$释放锁。

  1. $T_1$: 释放资源$R(T_1)$上的锁$X$。
  2. $T_2$: 获得资源$R(T_2)$上的锁$S$。

在这之后,$T_1$和$T_2$都获得了资源$A$上的锁,所以满足了规范。

推迟规则

如果事务T1对数据对象A加了X锁,事务T2想要加S锁,而T1刚好释放了X锁,那么T2可以马上获得S锁。

推迟规则的数学描述如下:

$$\forall T_{i}, T_{j} \in TS.\left(\neg[ T_{i}: A ], T_{j}(W_{j})\right) \Rightarrow (\neg[ T_{i}: A ]) \vee (T_{i}(S))$$

推迟规则的要求是,如果事务T1的持有时间比T2短,那么T2可以忽略T1对A的锁的释放时间,直接加S锁。

例如,假设存在一个数据项$A$,初始值为$v$,两个事务$T_1$和$T_2$分别需要读和写$A$,同时允许其他事务进行读操作:

  1. $T_1$: 请求资源$R(T_1)$上锁$X$。
  2. $T_2$: 请求资源$R(T_2)$上锁$S$,$T_1$阻塞。

在此时,$T_2$试图申请$S$锁,但是$T_1$仍在持有$X$锁。$T_2$发现事务$T_1$持有的锁不是比自己早的,于是直接获得$S$锁。

三级封锁协议

一级封锁规则

一个事务可以在一个时刻只持有一把锁。

说明:在一级封锁规则下,在同一时间内只允许一个事务访问数据,这样可以防止多个事务同时修改相同的数据引起冲突。

二级封锁规则

事务必须按申请锁的顺序获得锁。

说明:在二级封锁规则下,一个事务不能获得比其申请的更严格的锁,否则会产生死锁。

三级封锁规则

如果事务T对数据对象A加了S锁,其他事务只能获得该对象的S锁或X锁,不能获得其它的任何锁。

说明:在三级封锁规则下,一个事务在释放一个锁之前,必须先获得所有被该锁锁定的对象上的锁,这样可以避免死锁。

ACID

ACID是指原子性、一致性、隔离性和持久性。原子性指事务是一个不可分割的工作单位,事务中包括的诸如insert、update、delete等操作要么全部成功,要么全部失败回滚。一致性指事务必须是使数据库从一个一致性状态变到另一个一致性状态。隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是完全隔离的,并发执行的各个事务之间不能互相干扰。持久性是指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。

在并发控制中,除了保证数据库的数据完整性外,还要保证事务的原子性、一致性和持久性。ACID中的原子性、一致性和隔离性,都是为了并发控制而建立的。隔离性保证了事务在并发环境中执行的正确性,而原子性和一致性则保证了事务执行的完整性。为了保证原子性,数据库必须将多个操作在一个事务当中封装起来,以确保其不可分割。

分布式事务

分布式事务(Distributed Transaction)是指事务的参与者,分布在不同的数据库服务器上,涉及到的操作共同完成,事务的执行必须保证事务的ACID特性。许多关系型数据库的厂商(例如MySQL、PostgreSQL等)支持跨节点事务,允许一次跨越多个数据库服务器的多个操作作为一个逻辑单元,自动提交或回滚。

分布式事务提供了一种基于多个数据库服务器的原子性、一致性和持久性的机制。Apache的Hedwig消息系统通过提供原子提交和回滚功能,实现了跨越多个数据库服务器的分布式事务。与此同时,通过在网络通信、机器故障等异常场景下的恢复能力,分布式事务也保证了其高可用性。

MVCC

MVCC(Multiversion Concurrency Control)是一种并发控制机制,其利用数据库的多版本并发控制(Multi-Version Concurrency Control)功能实现数据库事务的隔离性。

多版本并发控制(MVCC)是指允许多个事务同时对同一份数据进行读和写,而不加锁,通过保存数据历史版本(版本链)实现隔离性。数据历史版本包括创建时的版本和任意更新后的版本。

在MVCC下,每一个事务都可以看到自己的操作与其他事务的操作隔离开来。因此,MVCC能确保事务的隔离性,但不能完全消除脏读、不可重复读和幻影读的问题。

MVCC通过增加额外的索引来支持,这些索引跟物理主键一样,以保证数据的唯一性和原子性。MVCC还需要增加额外的数据结构和算法来维护事务的视图,以确保数据的正确性。

2PC

提交协议

在2PC中,事务分为准备阶段(Prepare Phase)和提交阶段(Commit Phase)。

  1. Prepare Phase:事务协调器向所有的参与者发送事务预备请求(PREPARE TRANSACTION request);参与者收到请求后,执行事务的 redo 操作,并将 undo 操作写入日志;参与者向事务协调器反馈事务执行结果(成功或失败)。
  2. Commit Phase:若所有参与者反馈事务执行成功,事务协调器向所有参与者发送提交请求(COMMIT request);参与者接收到提交请求后,执行提交操作,并删除事务的 undo 信息。

回滚协议

如果在准备阶段,某一个参与者宕机、重启、失去响应或者主动放弃了事务,那么整个分布式事务就会回滚,这就是2PC的回滚协议。

超时协议

为了防止系统长时间运行的事务长期占用资源,2PC引入超时机制,一旦事务超过指定时间未完成,则自动回滚。

2PC优缺点

在2PC中,如果一个事务失败,则所有的参与者都需要回滚,这样会导致性能较差,特别是在大规模集群环境下。

另外,如果在事务提交过程中,协调者出现故障,事务参与者也不会收到通知,导致一直阻塞在事务提交阶段。

综合来看,2PC的优点是简单,成本低,适用于绝大多数场景。但也存在一些局限性,需要业务人员注意并发控制的设计。

Paxos算法

Paxos算法是一个基于消息传递的容错算法,其主要思想是通过让一组进程在超时后达成一致意见的方式,以完成分布式一致性问题。

Leader选举

选举一个领导者(Leader),事务协调器只接受来自领导者的请求,并将其应用到状态机中。

执行请求

领导者会将命令序列(Command Sequence)发送给其他进程,进程根据命令序列依次执行指令。

投票阶段

当一个进程收到一条请求时,它会询问自己是否可以成为领导者。进程可以通过两种方式投票:

  1. 单一投票法:如果进程自身觉得自己合适的话,就给予+1票;如果觉得自己不合适的话,就给予-1票。
  2. 多数派投票法:如果超过半数进程给予+1票,就宣布自己是领导者。

回滚阶段

当领导者失败或者不能正常运行时,协调器会通过定时器机制触发超时(Timeout),并回滚已提交的事务。

拜占庭将军问题

拜占庭将军问题(Byzantine Generals Problem)是指存在多名将军(进程)并行攻击同一个目标城市,希望大家公平的竞争。在Paxos算法中,解决拜占庭将军问题的方法是随机数,使得一部分将军站在明面上行事,另一部分将军隐瞒自己的行踪。

Paxos算法优缺点

Paxos算法实现简单,理论上成本低。但是,实际中由于采用随机数,拜占庭将军问题仍然是可能发生的,需要进一步完善。

BASE理论

BASE理论是对CAP原则的一个扩展,包括基本可用(Basically Available)、软状态(Soft State)和最终一致性(Eventual Consistency)。

BA

基本可用(Basically Available)是指分布式系统在出现网络分区或结点故障的时候,允许损失少量的可用性。

ST

软状态(Soft State)是指允许系统中的数据存在中间状态,并不强求所有数据都在线上。

EC

最终一致性(Eventual Consistency)是指系统中的数据经过一段时间的复制后,一致性达到一个高度。

4.具体代码实例和解释说明

死锁示例

死锁(Deadlock)是指多个事务纠缠在一起,无限制的等待下去,而无法继续执行的现象。在死锁发生时,多个事务都处于一种僵局,它们也无法继续前进,只能一直等待下去。

为了演示死锁,我们需要一个互斥锁和两个线程。第一个线程持有锁A,第二个线程持有锁B,试图同时获取锁A和锁B。

import threading

mutex = threading.Lock()
locks = {'A': threading.Lock(), 'B': threading.Lock()}

def acquire_lock(l):
    locks[l].acquire()
    print("Thread %s acquired lock %s" % (threading.current_thread().name, l))

def release_lock(l):
    locks[l].release()
    print("Thread %s released lock %s" % (threading.current_thread().name, l))

def thread_function():
    while True:
        mutex.acquire()
        if locks['A'].locked() == False and locks['B'].locked() == False:
            # both locks are available - let's proceed
            acquire_lock('A')
            acquire_lock('B')
            time.sleep(random.uniform(0, 5))
            release_lock('B')
            release_lock('A')
        else:
            # one or both locks are already held by another thread - wait for them to be released
            release_lock('A')
            release_lock('B')
        mutex.release()

        time.sleep(random.uniform(0, 3))

threads = []
for i in range(2):
    t = threading.Thread(target=thread_function)
    threads.append(t)
    t.start()

while True:
    pass

在这个示例中,acquire_lock() 函数尝试获取给定锁 lrelease_lock() 函数释放给定锁 lthread_function() 函数尝试获取两个互斥锁,并释放两个互斥锁。为了避免死锁,函数使用互斥锁来确保每次只允许一个线程持有锁。

我们创建两个线程,每个线程都调用 thread_function() 函数。线程间的同步由 mutex 变量控制,这个变量用来确保只有一个线程可以访问共享资源。

如果线程发现这两个锁都已经被占用,则线程等待,直到这两个锁被释放。如果线程发现这两个锁是可用的,则线程尝试获取这两个锁,并做一些延时操作,然后释放这两个锁。

最后,我们启动所有的线程,并进入无限循环,这样可以一直保持运行状态。

这个示例应该会打印出类似于下面这样的输出:

Thread Thread-1 acquired lock A
Thread Thread-2 acquired lock B
Thread Thread-2 released lock B
Thread Thread-1 released lock A
Thread Thread-2 acquired lock A
Thread Thread-1 acquired lock B
Thread Thread-1 released lock B
Thread Thread-2 released lock A
Thread Thread-1 acquired lock A
Thread Thread-2 acquired lock B
Thread Thread-1 released lock A
Thread Thread-2 released lock B
Thread Thread-1 acquired lock A
Thread Thread-2 acquired lock B
Thread Thread-1 released lock A
...

可以看到,所有线程都被卡住了,无法继续运行。这就是一个死锁的例子。


网站公告

今日签到

点亮在社区的每一天
去签到