第7章 事务

概述

一个苛刻的数据存储环境中,会有许多可能出错的情况,例如:

  • 数据库软件或硬件可能会随时失效(包括正在执行写操作的过程中)。
  • 应用程序可能随时崩溃(包括一系列操作执行到中间某一步)。
  • 应用与数据库节点之间的链接可能随时会中断,数据库节点之间也存在同样问题。
  • 多个客户端可能同时写入数据库,导致数据覆盖。
  • 客户端可能读到一些无意义的、部分更新的数据。
  • 客户端之间由于边界条件竞争所引人的各种奇怪问题。

为了系统高可靠的目标,我们必须处理好上述问题,万一发生类似情况确保不会导致系统级的失效。然而,完善的容错机制需要大量的工作,要仔细考虑各种可能出错的可能,并进行充分的测试才能确保方案切实可靠。

事务技术一直是简化这些问题的首选机制。事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元。即事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。如果失败,应用程序可以安全 地重试。这样,由于不需要担心部分失败的情况(无论出于何种原因),应用层的错误处理就变得简单很多(依赖事务机制)。

有了事务,应用程序可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都可以交给数据库来负责处理(我们称之为安全性保证)。

概念

  • 脏读
    • 客户端读到了其他客户端尚未提交的写入。读-提交以及更强的隔离级别可以防脏读。
  • 脏写
    • 客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写。
  • 读倾斜(不可重复读)
    • 客户在不同的时间点看到了不同值。快照隔离是最用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发控制(MVCC)来实现快照隔离。
  • 更新丢失
    • 两个客户端同时执行读·修改·写入操作序列,出现了其中一个覆盖了另一个的写人,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE)
  • 写倾斜
    • 事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异
  • 幻读
    • 事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理。

深入理解事务

数据库系统本质是提供一个安全可靠的地方来存储数据而不用担心数据丢失等。

处理错误或中止:

当事务出现异常时,有些系统不会进行重试而是抛出堆栈信息,应该考虑支持安全的重试机制。

重试中止的事务作为一个简单有效的错误处理机制,也存在一些缺点:

  • 如果事务实际已经执行成功,但返回给客户端的消息在网络传输时发生意外(所以在客户端看来事务是失败),那么重试就会导致重复执行,此时需要额外的应用级重复数据删除机制。

  • 如果错误是由于系统超负荷所导致,则重试事务将使情况变得更糟。为此,可以设定一个重试次数上限,例如指数回退,同时要尝试解决系统过载本身的问题。

  • 由临时性故障(例如死锁,隔离违例,网络闪断和节点切换等)所导致的错误需要重试。但出个出现了永久性故障(例如违反约束),则重试毫无意义。

  • 如果在数据库之外,事务还产生其他副作用,即使事务被中止,这些副作用可能已事实生效。例如,假设更新操作还附带发送一封电子邮件,肯定不希望每次重试时都发送邮件。

  • 如果客户端进程在重试过程中也发生失败,没有其他人继续负责重试,则那些待写入的数据可能会因此而丢失。

弱隔离级别

关系型数据库系统(通常被认为是ACID兼容)其实也采用的是弱级别隔离,未必能解决数据处理异常问题。

读-提交

读-提交是最基本的事务隔离级别,它只提供以下两个保证:

  1. 读数据库时,只能看到已成功提交的数据(防止“脏读”)。
  2. 写数据库时,只会覆盖已成功提交的数据(防止“脏写”)。

防止脏读

某个事务完成部分数据写入,但事务尚未提交(或中止),此时另一个事务如果可以看到尚未提交的数据,那就是脏读。

防止脏写

如果两个事务同时尝试更新相同的对象,会发生什么情况呢?我们不清楚写入的顺序,但可以想象后写的操作会覆盖较早的写入。

但是,如果先前的写入是尚未提交事务的一部分,是否还是被覆盖?如果是,那就是脏写。读·提交隔离级别下所提交的事务可以防止脏写,通常的方式是推迟第二个写请求,直到前面的事务完成提交(或者中止)。

读-提交就是为了防止脏写问题导致的车主是Bob,发票却给了Alice的问题。

实现读-提交

脏写:通过行级锁来防止脏写

脏读:读操作前,先申请锁,事务完成后释放锁。

然而,读锁的方式在实际中并不可行,因为运行时间较长的写事务会导致许多只读的事务等待太长时间,这会严重影响只读事务的响应延迟,且可操作性差:由于读锁,应用程序任何局部的性能问题会扩散进而影响整个应用,产生连锁反应。

数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。

快照级别隔离与可重复读

读-提交级别隔离可能存在以下场景:

Alice所看到的账户余额的确都是账户当时的最新值。重新加载银行页面时,可能就能看到一致的账户余额。

读倾斜:Alice观察数据库处于不一致的状态

采用多版本技术实现快照级别隔离,当事务开始时,首先赋予一个唯一的、单调递增的事务ID(txid)。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务ID。

防止更新丢失

并发写事务冲突

  • 读-修改-写回操作序列串行执行
    • 原子写操作
    • 显示加锁 ———— for update
  • 自动检测更新丢失
    • 先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的 读-修改-写回方式

写倾斜

你正在开发一个应用程序来帮助医生管理医院的轮班。通常,医院会安排多个医生值班,医生也可以申请调整班次(例如他们自己生病了),但前提是确保至少一位医生还在该班次中值班。

现在场景是,Alice和Bob是两位值班医生。两人碰巧都感到身体不适,因而都决定请假。不幸的是,他们几乎同一时刻点击了调班按钮。接下来发生的事情如图所示。

每笔事务总是首先检查是否至少有两名医生目前在值班。如果是的话,则有一名医生可以安全里离开。由于数据库正在使用快照级别隔离,两个检查都返回有两名医生,所以两个事务都安全地进入到下一个阶段。接下来Alice更新自己的值班记录为离开, 同样,Bob也更新自己的记录。两个事务都成功提交,最后的结果却是没有任何医生在值班,显然这违背了至少一名医生值班的业务要求。这种异常情况称为写倾斜

在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照级别隔离可以避免只读查询时的幻读,但是对于上面所讨论那些读-写事务,它却无法解决棘手的写倾斜问题。

串行化

目前大多数提供可串行化的数据库都使用了以下三种技术之一:

  1. 严格按照串行顺序执行
  2. 两段锁定
  3. 乐观并发控制技术

串行执行

单线程执行有时可能会比支持并发的系统效率更高,尤其是可以避免锁开销。但是,其吞吐量上限是单个CPU核的吞吐量。

当满足以下约束条件时,串行执行事务可以实现串行化隔离:

  • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能。
  • 仅限于活动数据集完全可以加载到内存的场景。
  • 写入吞吐量必须足够低,才能在单个CPU核上处理;否则就需要采用分区,最好没有跨分区事务。
  • 跨分区事务虽然也可以支持,但是占比必须很小。

两阶段加锁

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

两阶段加锁(two-phase commit, 2PC)

实现两阶段加锁

  • 如果事务要读取对象,必须先以共享模式获得锁。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待。
  • 如果事务要修改对象,必须以独占模式获取锁。不允许多个事务同时持有该锁(包括共享或独占模式),换言之,如果对象上已被加锁,则修改事务必须等待。
  • 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。升级锁的流程等价于直接获得独占锁。
  • 事务获得锁之后,一直持有锁直到事务结束(包括提交或中止)。这也是名字“两阶段”的来由,在第一阶段即事务执行之前要获取锁,第二阶段(即事务结束时)则释放锁。

使用锁机制,需要注意死锁的问题,数据库系统会自动检测事务之间的死锁

两阶段加锁虽然可以保证串行化,但性能差强人意且无法扩展(由于串行执行);弱级别隔离虽然性能不错,但容易引发各种边界条件(如更新丢失,写倾斜,幻读等)。

引出 可串行的快照隔离

悲观与乐观的并发控制

基于过期的条件做决定

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

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