DDIA第七章- 与事务打交道

754 阅读13分钟

什么是事务?

飞书文档同步发表到掘金后发现有很多内容因格式不兼容缺失了,如每个部分的思维导图和高亮块的内容。 有兴趣的朋友们可移步bytedance.feishu.cn/docx/doxcnz…

在残酷的现实中,有很多事情都会出错,比如软件硬件发生故障、网络中断等等,这会让最后的结果不符合预期。

事务是将多个对象上的多个操作合并为一个执行单元的机制

整个事务要么成功 提交(commit),要么失败 中止(abort)或 回滚(rollback)。


隔离级别的定义

  • 通过“是否存在特定的异常现象”来界定隔离级别并不是理想的方法。

  • 通过“特定的锁策略”来界定隔离级别也会带来一些困惑,因为同一隔离级别可以通过多种锁策略来实现。

深入理解事务

特性

  • 原子性(Atomicity)

    在出错时中止事务,并将部分完成的写入全部丢弃

  • 一致性(Consistency)

    任何数据更改必须满足某种状态约束,其实并不是数据库可以保证的事情。

  • 隔离性(Isolation)

并发执行的多个事务相互隔离,它们不能互相交叉。

  • 持久性(Durability)

多对象与单对象事务操作

多对象写入

image

实现方案:

多对象事务要求数据库知道事务包含了哪些读写操作,因而对于某个连接,SQL语句在 BEGIN TRANSACTION和COMMIT之间的所有操作都属于同一个事务

image

单对象写入

多对象的事务操作基于单对象的事务操作完成的。

存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性

🌰 假设正在向数据库写入一个20KB的JSON文档

实现方案

通过提供原子操作,CAS,WAL预写式日志等方式实现原子性

实现方案

对每个对象采用加锁的方式(每次只允许一个线程访问对象)来实现隔离

多对象事务的必要性

Q: 是否真的需要多对象事务?

A: 需要。有许多其他情况要求写入多个不同的对象并进行协调:

  • 在关系数据模型中,表中的某行可能是另一个表中的外键。

  • 在图数据模型中,顶点具有多个边连接到其他的顶点。

  • 在文档数据模型中,更新非规范化的信息时需要一次更新多个文档。

  • 对于带有二级索引的数据库,每次更改值时都需要同步更新索引, 从事务角度来看,这些索引是不同的数据库对象。

处理错误和中止

Q:对中止的事务进行重试是完美的方案么?

A:仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。

在发生永久性错误(例如,违反约束)之后重试是毫无意义的。

重试也并不是完美的方案

  • 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障,那么重试会导致事务被执行两次。

  • 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟。

  • 除此之外,如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。

弱隔离级别

多个事务同时读-写、写-写同一个对象

读-提交

防止脏读

🌰 用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。

image

为什么要防止脏读?

  • 另一个事务可能会看到部分更新的数据,这会让用户感到困惑

  • 如果事务发生中止,则所有写入操作都需要回滚。 如果发生了脏读,这意味着它可能会看到一些稍后被回滚的数据。

防止脏写

  • 二手车销售网站:车主被改为Bob(因为他成功地抢先更新了车辆表单),但发票却寄送给了Alice(因为她成功更新了发票表单)。

  • 更严重的问题:事务回滚

防止脏写可以避免以下并发问题:

  • 如果事务更新多个对象,脏写会带来非预期的错误结果。

  • 不能避免计数器增量的竞争情况--将在之后的“防止更新丢失”中探讨

实现读-提交

防止脏读

  • 使用相同的锁

  • 对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。

防止脏写

  • 使用 行锁(row-level lock)

可重复读

image

不能容忍这种暂时的不一致的一些场景

快照级别隔离对于长时间运行的只读查询非常有用。

  • 备份

  • 分析查询和完整性检查

实现可重复读

当一个事务开始时,它被赋予一个唯一的、单调递增的事务 ID(txid)

image

操作规则

  • created_by 字段代表将该行插入到表中的的事务 ID

  • 通过将 deleted_by 字段设置为请求删除的事务的 ID 来标记为删除,初始为空。

  • UPDATE 操作在内部翻译为 DELETE 和 INSERT

可见性规则

  • 事务开始的时刻,创建该对象的事务已经完成了提交。

  • 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交。

innodb通过mvcc实现RC:

  • 每次读取时创建一个新的read view

  • RC会比RR创建快照的开销大

那为什么我们一般还是使用RC隔离级别呢?

因为实际上RCRR的并发度更好

首先,RC 在加锁的过程中,是不需要添加Gap Lock和 Next-Key Lock 的,只对要修改的记录添加行级锁就行了。

另外,因为 RC 还支持"半一致读",可以大大的减少了更新语句时行锁的冲突;对于不满足更新条件的记录,可以提前释放锁,提升并发度。

RC还可以减少死锁现象。因为RR这种事务隔离级别会增加Gap Lock和 Next-Key Lock,这就使得锁的粒度变大,那么就会使得死锁的概率增大。

参考阅读:

innoDB的mvcc 一致性实现:www.modb.pro/db/75331

innoDB的mvcc 索引实现:www.cnblogs.com/stevenczp/p…

更新丢失

总结一下,到目前为止已经讨论的 读已提交快照隔离 级别,主要保证了只读事务在并发写入时可以看到什么

却忽略了两个事务并发写入的问题 —— 我们只讨论了脏写,一种特定类型的写 - 写冲突是可能出现的。

image

case:

  • 增加计数器或更新账户余额

  • 解析并本地修改一个复杂的值

  • 两个用户同时编辑 wiki 页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效 。

防止更新丢失

  • 原子写操作

    许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作,

  • 显示加锁

    由应用程序显式锁定待更新的对象,然后,应用程序可以执行“读-修改-写回”这样的操作序列。

  • 自动检测更新丢失

原子操作和锁都是通过强制“读-修改-写回”操作序列串行执行来防止丢失更新。

先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退。

InnoDB的可重复读却并不支持检测更新丢失

  • **原子比较和设置 **CAS

    只有在上次读取的数据没有发生变化时才允许更新(ABA)

写倾斜与幻读

image

这里的写冲突并不那么直接,但如果两笔事务是串行执行,则第二个医生的申请肯定被拒绝。

更多写倾斜的例子

  • 会议室预订系统

  • 多人游戏

  • 抢注用户名

  • 双重开支

为何产生写倾斜

所有这些写倾斜的例子都遵循类似的模式:

  1. 一个 SELECT 查询有满足条件的行

  2. 根据查询的结果,应用层代码来决定下一步的操作

  3. 如果应用程序决定继续执行,它将发起数据库写入( INSERT,UPDATE或DELETE)并提交事务。

实现方案

由于更新的可能是多个对象,防止写倾斜的可选方案有很多限制:

  • 单对象的原子操作和CAS不起作用

  • 大多数的数据库在可重复读隔离级别都不支持自动检测写倾斜问题

  • 大多数数据库不支持涉及多个对象的约束

  • 对事务依赖的行来显式的加锁[无法解决查询对象为空的问题]

实体化冲突

显示加锁不能解决的问题:查询结果中没有对象(空)可以加锁

把幻读问题转变为针对数据库中一组具体行的锁冲突问题

eg:提前构造一张表,表中预先写好可能会插入的数据。

这样就人为引入一些可加锁的对象

串行化

保证最终的结果与串行执行结果相同即可

实际串行执行

解决并发问题最直接的方法是避免并发

redis其实蕴含了一点这种思想

数据库设计人员直到2007年才完全确信,采用单线程循环来执行事务是可行的:

  • 内存越来越便宜了

  • OLTP 事务通常很短,只产生少量的读写操作。 运行时间较长的分析查询则通常是只读的,不需要运行在串行主循环里

提高性能

采用存储过程封装事务

对于交互式的事务处理,大量时间花费在应用程序与数据库之间的网络通信。

采用存储过程封装事务,应用程序必须提交整个事务代码作为存储过程打包发送到数据库,数据库可以把事务所需的所有数据全部加载在内存中,使存储过程高效执行,而无需等待网络或磁盘I/0 。

image

优点

  • 不需要等待 I/O

  • 可以得到相当不错的吞吐量

  • 使单线程上执行所有事务变得可行

  • 避免加锁等复杂的并发控制机制带来的开销

缺点

  • 难以调试

  • 生态较差

  • 因为数据库实例往往被多个应用服务器所共享,数据库中一个设计不好的存储过程(例如,消耗大量内存或CPU时间)要比同样低效的应用服务器代码带来更大的麻烦。

但是这些问题也是可以克服的,如最新的存储过程使用现有的通用编程语言,例如Java、lua

通过分区伸缩至多个CPU核心

为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区。

前提是能找到一个方法,使得****每个事务只在单个分区内读写数据,这样每个分区都可以有自己的事务处理线程且独立运行。

对于跨分区的事务,存储过程需要跨越所有分区加锁执行,以确保整个系统的可串行化。

使用实际串行执行的约束条件

  • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能。

  • 仅限于活动数据集完全可以加载到内存的场景。

  • 写入吞吐量必须足够低,才能在单个CPU核上处理。

  • 跨分区事务虽然也可以支持,但是占比必须很小。

两阶段加锁

两阶段锁定(2PL)和两阶段提交(2PC)是完全不同的东西。

实现两阶段加锁

通过共享模式或独占模式获取锁,2PL不仅在并发写操作之间互斥,读取也会和修改产生互斥 。

  • 若事务要读取对象,则须先以共享模式获取锁。

  • 若事务要写入一个对象,它必须以独占模式获取该锁。

  • 如果事务先读取再写入对象,则它需要将共享锁升级为独占锁

性能问题

  • 容易出现死锁现象,这将导致应用层就必须从头重试

  • 如果存在严重竞争,数据库的访问延迟会很大

如何防止幻读问题

谓词锁
SELECT * FROM bookings
WHERE room_id = 123 AND
      end_time > '2018-01-01 12:00' AND
      start_time < '2018-01-01 13:00';
  • 一个事务获取查询条件上的共享谓词锁时,会和持有任何满足这一查询条件对象的排它锁互斥。

  • 一个事务想写入对象,则必须首先检查****旧值或新值是否与任何现有的谓词锁匹配。 如果事务 B 持有匹配的谓词锁,那么必须等到 B 已经提交或中止后才能继续。

PostgreSQL谓词锁实现

PostgreSQL使用“PREDICATELOCKTARGET”表示一个加谓词锁的对象,然后在此对象上施加“PREDICATELOCK”表示的谓词锁

typedef struct PREDICATELOCKTARGET  //“对象”上的谓词锁

{
    /* hash key,一个标志,唯一表示一个对象 */
    PREDICATELOCKTARGETTAG tag; /* unique identifier of lockable object */

    /*谓词锁对象列表 */
    SHM_QUEUE    predicateLocks; //“PREDICATELOCK”对象的列表,其结构体如下

} PREDICATELOCKTARGET;

** “PREDICATELOCK”**对象的定义如下

  • 一个唯一的使用“PREDICATELOCKTAG”定义的标识tag,

  • 两个列表

typedef struct PREDICATELOCK  //谓词锁
{

    PREDICATELOCKTAG tag;        //谓词锁的唯一的标识,被当作hash key以唯一标识谓词锁

    SHM_QUEUE    targetLink;     /* list link in PREDICATELOCKTARGET's list of predicate locks */

    SHM_QUEUE    xactLink;       /* list link in SERIALIZABLEXACT's list of predicate locks */

    SerCommitSeqNo commitSeqNo;  /* only used for summarized predicate locks */

} PREDICATELOCK;

一个谓词锁对象有一个唯一的使用“PREDICATELOCKTAG”定义的标识tag。

所以, PostgreSQL 使用“ PREDICATELOCK ”结构体表示一个谓词锁,然后用一个对象和事务作为此谓词锁的标识( tag )来标识一个谓词。

typedef struct PREDICATELOCKTAG  //谓词锁的标识
{
    PREDICATELOCKTARGET *myTarget;
    SERIALIZABLEXACT *myXact;
} PREDICATELOCKTAG;

这表明谓词锁是数据库对象和事务间的一个特定关系,只是这样的关系是用以表示读写冲突的。

索引区间锁(next-key locking)

(-∞,5](5,10](10,15](15,20](20,25](25,+supernum]

如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。

可串行化的快照隔离 (SSI, serializable snapshot isolation)

  • 两阶段加锁是一种典型的悲观并发控制机制 : 如果某些操作可能出错(例如与其他并发事务发生了锁冲突),那么直接放弃,采用等待方式直到绝对安全。

  • 串行化的快照隔离是一种乐观并发控制技术:如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事,当一个事务提交时再进行检查。

串行化是为了解决写倾斜,导致写倾斜的更直接的原因是基于过时前提的决策

基于过期的条件做决定

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

image

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

image

性能

image

更多阅读

: 用例子说明了物化冲突、谓词锁、索引范围锁以及innodb的RR级别

pingcap.com/zh/blog/tra…