并发控制

并发控制

在并发读写数据库时,读操作可能会不一致的数据(譬如脏读);并发控制旨在针对数据库中对事务并行的场景,保证 ACID 中的一致性(Consistency)与隔离性(Isolation)。假如所有的事务都仅进行数据读取,那么事务之间并不会有冲突;而一旦某个事务读取了正在被其他事务修改的数据或者两个事务修改了相同的数据,那么数据库就必须来保证事务之间的隔离,来避免某个事务因为未见最新的数据而造成的误操作。解决并发控制问题最理想的方式就是能够每当某个事务被创建或者停止的时候,监控所有事务的所有操作,判断是否存在冲突的事务,然后对冲突事务中的操作进行重排序以尽可能少地减少冲突,而后以特定的顺序运行这些操作。绝大部分数据库会采用锁(Locks)或者数据版本控制(Data Versioning)的方式来处理并发控制问题。

目前数据库技术中主流的三种并发控制技术分别是:Multi-version Concurrency Control (MVCC), Strict Two-Phase Locking (S2PL), 以及 Optimistic Concurrency Control (OCC),每种技术也都有很多的变种。其中并发控制最简单的方式就是加锁访问。加锁会将读写操作串行化,避免出现不一致的状态;但是读操作会被写操作阻塞,大幅降低读性能。实际上很多数据库中都会采用 Multi-Version Concurrent Control(MVCC) 机制或者其变种。

在 MVCC 协议下,每个读操作会看到一个一致性的 Snapshot,并且可以实现非阻塞的读。MVCC 允许数据具有多个版本,这个版本可以是时间戳或者是全局递增的事务 ID,在同一个时间点,不同的事务看到的数据是不同的。在 MVCC 中,每次写操作都会在旧的版本之上创建新的版本,并且会保留旧的版本。当某个事务需要读取数据时,数据库系统会从所有的版本中选取出符合该事务隔离级别要求的版本。MVCC 的最大优势在于读并不会阻塞写,写也不会阻塞读;而像 S2PL 这样的系统,写事务会事先获取到排他锁,从而会阻塞读事务。PostgreSQL 以及 Oracle 等 RDBMS 实际使用了所谓的 Snapshot Isolation(SI)这个 MVCC 技术的变种。Oracle 引入了额外的 Rollback Segments,当写入新的数据时,老版本的数据会被写入到 Rollback Segment 中,随后再被覆写到实际的数据块。PostgreSQL 则是使用了相对简单的实现方式,新的数据对象会被直接插入到关联的 Table Page 中;而在读取表数据的时候,PostgreSQL 会通过可见性检测规则(Visibility Check Rules)来选择合适的版本。

锁管理器(Lock Manager)

数据库使用锁是为了对共享资源进行并发访问控制,从而保证数据的完整性和一致性。在根据加锁的范围(Lock Granularity),可以分为:全局锁、表级锁、行锁。全局锁会把整个数据库实例加锁,将使数据库处于只读状态,其他数据写入和修改表结构等语句会阻塞,一般在备库上做全局备份使用。而表级锁有两种,一种是表锁,和读写锁一样,另外一种是元数据锁,也叫意向锁,不需要显示申明,当执行修改表结构,加索引的时候会自动加元数据写锁,对表进行增删改查的时候会加元数据读锁。这样当两条修改语句的事务之间元数据锁都是读锁不互斥,但是修改表结构的时候执行更新由于互斥就需要阻塞。还有一种行级锁称为间隙锁,他锁定的是两条记录之间的间隙,防止其他事务往这个间隙插入数据,间隙锁是隐式锁,是存储引擎自己加上的。

表锁更适用于以查询为主,只有少量按索引条件更新数据的应用;行锁更适用于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用。

基于锁的方式基础理念为:如果某个事务需要数据,则对数据加锁,操作完毕后释放锁;如果过程中其他事务需要锁,则需要等到该事务释放数据锁,这种锁也就是所谓的排他锁(Exclusive Lock)。不过使用排他锁会带来极大的性能损耗,其会导致其他那些仅需要读取数据的事务也陷入等待。从锁的策略上,可以分为共享锁与排他锁:

  • 共享锁又称读锁,是读取操作创建的锁。其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。
  • 排他锁又称写锁,如果事务 T 对数据 A 加上排他锁后,则其他事务不能再对 A 加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。

在《MySQL-Notes》中我们也讨论了如何触发锁机制,譬如查询加锁,select * from testlock where id=1 for update;,即查询时不允许更改,该语句在自动提交为 off 或事务中生效,相当于更改操作,模拟加锁;而更新类操作 update testlock name=name; 则是会自动加锁。

MVCC

在《Concurrent-Notes》中我们讨论了两种不同类型的锁:乐观锁(Optimistic Lock)与悲观锁(Pessimistic Lock),前文介绍的各种锁即是悲观锁,而 MVCC(Multiple Version Concurrency Control) 这样的基于数据版本的锁则是乐观锁,它能够保证读写操作之间不会相互阻塞:

  • 每个事务都可以在同一时间修改相同的数据;
  • 每个事务会保有其需要的数据副本;
  • 如果两个事务修改了相同的数据,那么仅有单个更改操作会被接收,另一个操作会被回滚或者重新执行。

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

image.png

总结而言,MVCC 能够为我们解决如下的问题:

  • 保证了事务周期内数据的一致性。事务 A 开启后,即使事务 B 对数据做了修改/新增/删除,不管事务 B 有没有提交,这些变更对于事务 A 的 SELECT 语句都是不可见的,因为这些变更是其它事务发起的,并且是在事务 A 开启后发生的。也就是说,它解决了不可重复读和幻读的问题。

  • 提高了数据库的并发性能,试想,如果一个数据只有一个版本,那么多个事务对这个数据进行读写是不是需要读写锁来保护? 加锁的话就会造成阻塞,阻塞就会降低并发性能。而在 MVCC 里,每一个事务都有对应的数据版本,事务 A 开启后,即使数据被事务 B 修改,也不影响事务 A 那个版本的数据,事务 A 依然可以无阻塞的读取该数据,当然,只是读取不阻塞,写入还是阻塞的,如果事务 A 也想修改该数据,则必须要等事务 B 提交释放所有锁后,事务 A 才可以修改。所以 MVCC 解决的只是读-写的阻塞问题,写-写依然还是阻塞的。

PostgreSQL 中是依赖于 txid 以及 Commit Log 结合而成的可见性检测机制来实现 MVCC,详情可以参考《Database-Notes/PostgreSQL》中关于并发控制相关的介绍。