DDIA笔记-第七章-分布式数据-事务
回顾
对于我自己目前的水平,日常工作以 CURD 来实现业务功能,
向下采用不同的 DB 存储数据,向上定义和提供不同的 API 来提供服务,
偶尔遇到一些重要数据需要复制,
偶尔遇到一些数据量较大的,需要考虑分区,
事务虽然有使用,但是并未深究其实现原理。
本书前面的部分基本总结了我实际的经历,
接下来是更高一个层次的问题的讨论,
先回顾一下前面的内容
- 数据系统的指标/目标是什么?
- 数据如何存储以及如何查询?包括理论模型和实现方案
- 我把关系模型/网状模型,查询语言等等称为理论
- 我把 SSTable/B 树等存储方案等称为实现方案
- 除了存储在磁盘的数据之外,每个环节都需要传递数据,他们就需要编码
- 数据安全和数据量变大,如何应对,复制和分区
这些内容已经可以构成一个系统,已经不会轻易的回头打破前面讨论的结果,更多是做出选择,
接下里讨论的是在这个系统内遇到的麻烦,以及解决方案
基本认识
事务就是组合多个读写操作,
- 要么提交 (commit)成功表示全部操作成功,
- 要么中止 (abort)或 回滚 (rollback)表示全部失败,
这是为了简化应用编程模型 而创建的。
ACID
ACID = 原子性(Atomicity) , 一致性(Consistency) ,隔离性(Isolation) 和 持久性(Durability)
事实上,现在所谓的符合 ACID 只是营销术语,各家的实现中都一些坑在里面。
- 原子性
- 物理上表示这个东西无法再切分
- 多线程编程中,原子性操作表示只能看到操作之前或之后的状态,不会看到一半的状态
- 经常通过锁来实现,操作中锁住不让访问,但并没有回滚这种概念
- 而数据库中表示:如果发生错误,将丢失整个事务所有操作。
- 个人强行理解为:错误是原子性的,要么成功,要么全失败,不存在失败一半的情况。
- 一致性
- “一致性”这个词汇可能因为听起来很厉害,被赋予太多含义,在 ACID 也很“怪”
- ACID 一致性的概念是, 对数据的一组特定约束必须始终成立 ,即 不变式(invariants)
- 这更多是应用程序的属性,表示我这个数据应该符合怎么样的业务条件
- 但数据库只负责存储,提供数据的约束非常有限,程序写入一个脏数据时数据库根本阻止不了
- 原文提出:应用可能依赖数据库的原子性和隔离性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID。
- 隔离性
- 概念上,隔离性意味着, 同时执行的事务是相互隔离的 :它们不能相互冒犯。
- 传统的数据库教科书将隔离性形式化为 可串行化(Serializability),则并行结果和串行结果一致
- 但事实上各家的实现都未必如此,Oracle 实现了一个名为“可串行的”隔离级别,但其实是“快照隔离”的功能,比可串行化更弱的保证。
- 持久性
- 事务返回成功,保证已经写入磁盘,包括写好了预写日志等用于恢复的数据
- 有时还提供保证:在复制的数据库中,保证数据已经复制到其他节点(一些?至少一个?)
- 没有真正意义持久性,比如异步复制中丢失最近的写操作,比如所有机器一起丢失数据
总结一下:
一致性实际上是应用程序基于原子性和隔离性来实现的,
而持久性是存储安全的保证,业务编程时不需要太关心,
所以对于我们编程来说,考虑更多的是原子性和隔离性。
单对象和多对象操作
- 单对象
- 原子性
- 可以通过日志来实现,这在第三章 B 树里讨论过方法
- 自增操作,一些数据库提供了,实现上相对复杂
- 比较和设置(CAS, compare-and-set),检查值没有被并发修改过时,才执行写。是一种乐观锁。
- 而隔离性可以通过锁来实现
- 注意:严谨来说,单个对象的保证算不上事务乃至 ACID
- 原子性
- 多对象
- 事务通常被理解为, 将多个对象上的多个操作合并为一个执行单元的机制 。
- 原文列举了一些场景,证明事务在很多情况下是需要的
- 处理错误和中止
- ACID 的处理方式是,失败则全部放弃的方式
- 但有些系统,特别是无主复制的数据存储,更倾向于“尽力而为”
弱隔离级别
首先编程时考虑单个用户的业务流程已经很麻烦了,
如果要考虑并发,数据随时都在改变,就极其困难了,
所以数据库提供事务隔离,是试图让并发变成“假装没有并发发生”,
但事实上没那么简单,不同隔离级别对应不同的性能损失。
接下来,先讨论在弱隔离级别之下,如何规避掉一些问题,确认哪些问题是对事务隔离的刚需
读已提交(Read Committed)
最基本的事务隔离级别是 读已提交(Read Committed),它提供了两个保证:
- 从数据库读时,只能看到已提交的数据(没有 脏读 ,即 dirty reads)。
- 写入数据库时,只会覆盖已提交的数据(没有 脏写 ,即 dirty writes)。
- 脏写:事务 A 比 B 早开始但晚结束。修改了同一个数据。
- 操作结束先后是程序本身速度和并行之间的调度等问题,实际上事件 A 发生早于 B。
- 一般认为应该用最后修改的数据,常理应该是事务 B 的值。
- 但是由于事务 A 比较晚提交,导致数据最后采用了事务 A 的值,称为脏写。
- 例子:事务 A 和 B 修改同一个数据 new = old + 1
- 先不要考虑
x = x + 1这种优化,而是简单的,读取数据库的值,代码计算+1,写入数据库。 - 他们各自都认为自己是 old 都是 1,然后+1,两个事务提交都是 set = 2,但实际代码需要最后结果为 3 才合理
- 这不算脏写,从数据库的角度它没有做错什么,但最终结果仍然是错误的。应用程序需要自己想办法处理。
- 先不要考虑
- 脏写:事务 A 比 B 早开始但晚结束。修改了同一个数据。
脏写的实现一般行锁,脏读也可以锁,但是太影响只读事务的响应时间。
脏读一般在内部实现,数据库同时持有已提交的值 A,和未提交的新值 B,写事务内部返回 B,而其他事务返回 A。直到写事务提交。
快照隔离和可重复读
这里简单文字可能无法快速理解案例了,可能需要翻原文结合图片了。
还是简单试试描述,想象我两个银行卡互相转账:
- 事务 A 读取账号 1 值为 500
- 事务 B 对账号 1 操作 500-100 = 400
- 事务 B 对账号 2 操作 500+100 = 600,转账完成,提交事务
- 事务 A 读取账号 2 值为 600,两个账号的总额为 1100?
在这个案例里视乎只需要刷新一下就能获得准确数据,
但是如果在备份或者分析统计里发生这样的错误,这个错误就变成了永久性的。
并且因为不确定哪个数据会发生,数据库也变得不可靠,不可信赖。
问题根源是,事务 A 两次读取的是两个时间不一致的数据库,
想要解决,则需要让事务 A 读取到的是“事务 A 开始时的数据库”。
快照隔离(snapshot isolation) 是最常见的方案。
实现快照隔离
脏写同样是用锁来确保的。
脏读问题,只要我们实现了快照隔离,那么事务读取的是事务开始时的整个数据库版本,则不存在脏读了。
我们先考虑“读己提交”中的机制:数据库持有一个对象的多个版本,已提交的版本和被覆盖但未提交的版本。
这种机制称为多版本并发控制(MVCC, multi-version concurrency control)。
然后把这种机制使用的范围从“每个查询使用单独的快照”扩大到“整个事务都使用相同的快照”
具体实现方案和细节要翻原文,这里用自己的话描述一次:
首先,当我们要在事务内修改数据时,在对应的数据上添加一份记录,
记录事务的 id,修改的内容,当前的值,
这时我们已经拥有了查询这个数据不同版本的的能力了,
结合事务 ID 是“永远增长”的,可以判断当前事务应该访问哪个版本,
其实这个记录也很像 SSTable 等日志结构存储,SSTable 根据“日志”查询一个 key 的最新值,这里根据“记录”查询特定版本的值。
这样的思路就实现了快照,至于细节如何实现,看原文。
可重复读与命名混淆
概念还要结合历史来说明为什么命名会混淆,我们只需要大概知道,
Oracle 中可串行化(Serializable) 就是快照隔离,
PostgreSQL 和 MySQL 中可重复读(repeatable read) 也是。
于是可重复读从提出,到具体实现,到宣传使用该词汇,
他们都包含了不同的意义,或者没有完全实现功能,
导致其实没人真正知道可重复读的意思。
我们就当可重复读=快照隔离即可,通过快照这个概念,通过实现原理来得出他提供的特性。
防止丢失更新
上面举例到:事务 A 和 B 修改同一个数据 new = old + 1
这不算脏写,但这是错误的,可以概括为丢失了一些更新操作。
- 原子写
- 则提供
set a = a + 1这种语法 - 一般是通过排他锁来实现
- 也有更简单的方案,就是单线程
- 如果使用 ORM 框架,非常容易“没有用上原子写”而造成数据不安全的读写。
- 则提供
- 显示锁定
- FOR UPDATE 加写锁
- LOCK IN SHARE MODE / FOR SHARE 加读锁,不能解决上面举例的错误
- 锁的分类
- 悲观/乐观
- 所谓的悲观锁,在 SELECT 语句中显示加上 FOR UPDATE 来加锁
- 读锁不能解决上面举例的错误
- 注意这里几乎全文都在事务内讨论,在不开事务时加锁没有任何意义。
- 乐观锁算不上真的锁,是在 UPDATE 是加上值得判断,确认符合业务一开始查询的情况
- 不开事务使用乐观锁策略是有意义的
- 而原子写操作是数据库内部用锁实现了,但是这个实现对于编程人员是隐藏的,我更倾向于这是绕过了锁
- 所谓的悲观锁,在 SELECT 语句中显示加上 FOR UPDATE 来加锁
- 读/写
- 读锁=共享锁,可以同时加读锁,不能加写锁。此时写锁要等没人读才能进入。
- 写锁=排他锁,不可加读锁,不可加写锁,是独享的。
- 锁本身的定义确实是不可加读锁,但是数据库有 MVCC,所以数据可读,读的是已提交的版本。
- 悲观锁不在这个维度,他只是表达业务中修改一个数据时考虑悲观的情况而去加锁,这种策略。
- 悲观/乐观
- 不加 FOR UPDATE 的 SELECT
- 在“读已提交”和“快照隔离”等级下
- 默认不加锁,因为没必要加锁,他们有 MVCC 版本控制
- 但 MVCC 只保证你当前的程序数据一致性
- 你自己需要预判“丢失更新”的情况,加锁是应对并发的问题
- 也可以简单理解为,在弱隔离等级下,利用锁,小范围地显式地获得可串行化的特性
- SERIALIZABLE(可串行化)等级下
- 默认加上 LOCK IN SHARE MODE,得到共享锁/读锁
- 在“读已提交”和“快照隔离”等级下
- 自动检测丢失的更新
- 原子写和锁,其实都是实现了小范围串行化
- 也有方法允许它们并行执行,事务本身能检测丢失更新的发生,中止事务,让应用程序重新读取新值再计算和写入。
- 原文提及 MySQL 的可重复读不会检测丢失更新,所以也可以说 MySQL 严格来说不提供快照隔离(这些定义和概念真烦)
- PostgreSQL/Oracle/SQLServer 都支持,但是他们对应的隔离级别不同,命名也不同,要专门查一下
- 这个功能是极好的,因为它不需要开发人员担心,减少出错。
- 比较并设置(CAS)
- 就是乐观锁,上面已经提了一句了
- 但是但是,要注意使用的数据库的 where 语句内读取的内容是否快照隔离中的“快照”
- 常用的 MySQL 和 PostgreSQL 在 update … where 语句提供都是当前读
- 一致性读=快照读,不加锁的 select 查询
- 当前读=锁定读,
UPDATE、DELETE、INSERT,以及加锁的SELECT ... FOR UPDATE/LOCK IN SHARE MODE
- 冲突解决和复制
- 上面说的锁和 CAS 的技术都基于一个数据副本的情况,
- 一旦进入到多主和无主复制的数据库,锁和 CAS 手段都失效了。
- 可用的手段有:
- 检测并发写入(上面提及了),冲突了记录下来,再去解决和合并冲突
- 原子操作可以很好的工作
- 虽然“最后写入胜利 LWW”的方法很容易丢失更新,但是也有很多数据库使用 LWW 作为默认方案
写入偏差与幻读
结合上面已经提到的概念,区分一下
- 脏写,两个业务更新相同对象,没有事务,因为写的时序问题,导致结果是不合理的
- 事情 A 先发生,B 后发生,但是处理 A 的程序运行的慢,导致最后入库的值是 A,期望是 B。
- 丢失更新,两个业务更新相同对象,有事务,但因为读是并发的,导致结果是不合理的
- 事务 A/B 同时做+1,但是他们读取时都是 0,+1 后写入都是 1,结果是 1,期望是 2。
- 写入偏差,两个业务更新不同对象,有事务,但因为读是并发的,导致结果不合理
- 和丢失更新区分开来,是因为原子操作,(快照隔离的实现中)自动检测丢失更新都无法避免写入偏差
- 经典的例子是:关注点是更新不同对象
- 医生值班,至少留 1 人值班
- 两个人都查询了当前有两个人,自己可以下班
- 各自计算好之后,写入自己的数据,操作的是不同对象
- 幻读
- 定义:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 幻读
- 快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
- 幻读一种技术现象,写入偏差是一种业务逻辑错误,他们不同一个维度,他们可能同时存在,也可能单独存在
- 先和脏读区分开,脏读是读了别人未提交的数据,这个数据没提交,是脏的
- 幻读是:读+写的事务中,如果写之前再读一次,则读+读+写,这时两次读取结果不一样
- 经典例子是:关注点是多次读取的结果不同
- 会议室预定,两个人都查询了当前可以预定
- A 预定写入前,B 预定提交了
- 如果 A 多查询一下,会发现条件变了,不可预定了
- MVCC 解决了只读时的幻读问题,他们直接读取事务开始时的版本
- MVCC 解决不了读写的事务,当你写的时候,之前读到的数据状态已经改变了
- 又可以细分,两次读之间,其他事务做的是插入还是更新
- 如果是更新,会比较容易找到可以“物化冲突”的对象
- 如果是插入,你可能没办法“物化冲突”,因为即使你加锁,新插入的行不在锁的范围里
- 经典例子是:关注点是多次读取的结果不同
- 物化冲突
- 我自己想到的“找共同数据”的思路在书里就叫“物化冲突”
- 自己的说法:可以根据业务,找到一个共同的数据,通过悲观锁来处理
- 记忆:写入偏差指的是更新不同对象的情况,方案是把问题变成类似丢失更新,然后悲观锁
- 有时未必能轻松找到“共同数据”,比如会议室的预定
- 因为预定还需要包含时间范围的信息,他一般都是一个独立的预定记录表
- 很难在会议室对象上直接记录预定信息,也很难在人身上记录
- 你的查询很可能是
select count(*) from bookings where id = xxx and time ..... - 隔离等级开可串行化性能不接受,这个查询也不好加锁,锁的范围会很广
- 让应用程序串行化就好了大概就是分布式锁,让这个业务单线程等等手段,就不是数据库层面的事情了
- 书中的说法:物化冲突,则人为在数据库引入一个锁对象
- 实际上就是上面的
select for update,即使时 count 也能锁,只是范围可能大,要根据业务情况判断 - 但实际上业务是复杂的,实际处理起来很难:
- 意识到幻读了,然后找到合适的对象来物化冲突
- 还要考虑代码的优雅,尤其是在用 ORM
- 而且其实未必有这样的数据可以加锁
- 比如你的判断条件就是“找不到符合条件的数据”,那就没有数据可以利用来加锁
- 应用程序做分布式锁,既然没有现成数据可以利用,就额外创建一个专用的数据来“物化”
- 最终手段其实就是可串行化的隔离级别
- 实际上就是上面的
- 我自己想到的“找共同数据”的思路在书里就叫“物化冲突”
可串行化
上面讨论的问题虽然已经可以很好的描述清楚,但是实际情况是很难解决的,
即使在限定条件下很容易理解每个概念,但在实际情况下,所谓的“限定条件”可能都难以确定,
更别说出现问题时,也因为这里提及的“难”,大家都更倾向于先排查其他问题,
比如业务逻辑是不是本身有问题,程序本身有 BUG。
分析写入偏差和幻读的难点在于:
- 隔离级别难以理解,并且在不同的数据库中实现的不一致(例如,“可重复读” 的含义天差地别)。
- 光检查应用代码很难判断在特定的隔离级别运行是否安全。特别是在大型应用程序中,你可能并不知道并发发生的所有事情。
- 没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助【26】,但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时序下才会出现问题。
最终手段是可串行化隔离级别,相当于不并发,就防止所有可能的竞争条件。
实现思路有:
- 字面意义上地串行顺序执行事务(请参阅 “真的串行执行”)
- 两阶段锁定(2PL, two-phase locking) ,几十年来唯一可行的选择(请参阅 “两阶段锁定”)
- 乐观并发控制技术,例如 可串行化快照隔离 (serializable snapshot isolation,请参阅 “可串行化快照隔离”)
真的串行执行
经典例子就是 Redis
为什么可行,为什么以前不行?
- 因为内存便宜了,以前数据库要考虑在磁盘上存储并提供足够的性能,但现在内存能把需要的数据全部存下来了
- 人们意识到 OLTP 事务通常很短,每个事务都很快,串行起来的性能也不觉得差
- OLTP/OLAP = 事务/分析,后者也对应数据仓库
存储过程
应用程序,包括客户端和服务端,再把指令发送到数据库执行,这些交互过程耗费了大量时间,
如果应用程序只做数据的输入,然后把整个事务代码作为存储过程提交给数据库,
如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待网络和磁盘 IO。
以前来说,这样的思路把原本各种语言编写的业务逻辑都转换成了 SQL 语言,除非团队都是数据库开发的高手,否则…
但是现在像 Redis 使用 lua 实现存储过程,虽然比 SQL 好(书中列举了 SQL 写存储过程的问题所在)
但是依然有很大的限制,除了语言的限制,另外也把业务压力堆积到数据库里了,一旦代码没写好,业务之间就没有隔离可言。
分区
串行化后,顺序执行让整个事情简单很多,但是性能上被限制在单机单核了,
这时,复制,可以提高只读事务的吞吐量,
而,分区,可以提高写入吞吐量,但实际上真的要利用起来,要求很高。
支持这样做的是 VoltDB,我们设置好分区,找到一种分区办法,
这种分区办法要满足:事务只需要在单个分区中读写数据,这样吞吐量就可以和 CPU 核心数量保持线性伸缩。
一旦需要跨分区,为了保证串行化,需要加锁等等协调操作,性能远不如单分区的情况。
所以采用这种方案很大程度取决于应用数据的结构,
只有把功能拆分得很小才有可能,一般的业务系统都有多种复杂的数据结构。
小结
- 每个事务都必须小而快,否则会拖累到别的事务
- 只访问内存,不访问磁盘,否则不能快速处理完,性能就跟不上
- 如果单个 cpu 的性能不足以承担,就要考虑分区,还要找到“不跨区”的“分法”
- 跨分区事务也是可能的,但是限制很大,对于常见系统来说,很难一般就等于不可行了
两阶段锁定(2PL,two-phase locking)
2PL = 两阶段锁定(不是 2PC 两阶段提交,第九章)
个人-超简陋理解(不正确)
想象这样的代码就行了:读写操作都采用同一个排他锁。
这在学习并发编程时基本都已经写过这样的代码,一般都是先读写同锁,再仔细分为读锁和写锁的。
实现
当然上面是非常简化的说法,实际操作上:
第一阶段只加锁,第二阶段只解锁。也就是说多个对象的加锁和解锁不能穿插。
一般业务都是先读取,加锁可以先读锁,后面要更新时可以升级为写锁,
升级为写锁和直接加写锁一样,需要独占,这里的独占是包含读锁,则一个对象有读锁时也不能上写锁,
锁必须在事务结束时才能释放全部锁,不能中途释放。(严格两阶段锁)
上面逻辑可能会发生死锁,下面举例会出现,而数据库需要自动检测死锁,
中止其中一个,另一个继续执行,被中止的应用程序要重试。
个人-所以 2PL 提供了什么
听起来好像业务代码不用改动,那当然,这是数据库实现可串行化的内部实现原理,学习只是为了参考其设计思路。
按照这个规则,再去看之前的案例,都是解决了的,
这个方法更像是反推的,是灵感得到这个方法然后发现解决了问题(虽然发明者可能是推导出来的,我不知道)
我们也来观察一下之前的一个案例里,两个客户计数+1,发生了什么:
- 首先如果只考虑 2PL 的定义,我自己直接想的版本是:
- A 获取读锁,B 获取读锁
- A 要写所以想上写锁但药等待
- B 要写所以想上写锁,这时检测到死锁,中止事务 B,也就解锁了
- A 等待结束,获得写锁,完成事务
- 所以其实没有人这样“显式获取读锁”(for share),而是 for update
- A 获取写锁
- B 获取写锁,要等
- A 更新数据
- B 等待结束,读取并更新数据
- 两个事务都顺利完成,并且结果合理
- 不显式获取锁的情况(mysql)
- A 获取,MVCC,B 获取,MVCC
- A 更新,成功,B 更新,等待
- A 提交,B 等待结束,B 更新
- 最后是丢失更新的情况
- 为什么要注明 mysql
- 就是因为各家数据库采用不同的实现方案
- mysql 采用了 mvcc+2pl
- 也表明 mysql 采用 2pl 并没有想解决所有问题
- 他只想解决上面提到写入偏差和幻读的问题(配合 for update)
- 不同数据库只能根据他们各自的特性去重新分析
到了这里,可以看到 2PL 并不是可串行化的全部,各家数据库的实现也不同,
我们应该记住的是什么?是上面第二个方案:2PL + 显式获得写锁,能解决写入偏差和幻读问题。
脏读/脏写在弱隔离等级已经解决了,然后丢失更新的问题利用 for update 解决,
2PL 是让 for update 的方案也能解决剩下的写入偏差和幻读问题。
两阶段锁定的性能
总之开启 2PL 或者说开启可串行化,性能会大大下降,
所有的并行竞争都需要等待,等待的范围/加锁的范围,都扩大了很多。
直接的效果是高百分位点处的响应会非常的慢,
简单理解就是最热门的功能会变得很卡。
结合性能和死锁的问题,实际上在生产环境开启 2PL 的系统是不太可行的。
谓词锁(predicate lock)
既然读锁/写锁再结合 2PL 还不能解决问题,
那又想出了一个叫谓词锁,谓词这个说法大概想说明锁的是一个动作,
则锁的是一个查询条件,锁当前符合这个查询条件的数据,也要锁将来插入的“符合这个条件的”数据。
但是同样性能不佳。这里性能消耗在于条件判断很频繁。
索引范围锁
事实上大多数 2PL 实现了索引范围锁(index-range locking,也称为 next-key locking )
这是一个简化的近似版谓词锁。
原理就是扩大判断范围,扩大到一个索引上,这样利用上索引的性能就“可用”了。
我们可能搜索的是 01:02 <t< 03:04,假设索引建立出来按 00:00 到 05:00 分割的,
那就直接把这个索引包含的范围都锁了,而不是找到精准的对象一个个加锁。
当插入/更新/删除时,也会需要修改这个索引,那自然就会等待锁了。
这种锁最后的问题是没有可以挂载范围锁的索引,比如数据库根本还没有这个范围的数据,
那数据库将会锁整个表,这虽然性能很差,但是稍加注意代码逻辑,可以避免这种情况,发生概率不高。
可串行化快照隔离(SSI, serializable snapshot isolation)
这是 2008 年首次被描述的,是很新的技术。PostgreSQL9.1 以后的可串行化隔离级别支持 SSI。
简单总结就是检测可能发生的写入偏差和幻读问题,然后中止这些事务。
–
上面 2PL 等尝试处理好他们,也有一定的机制可以发现问题并且处理,问题是处理的性能代价太大。
而检测和中止他们,可以减少非常多锁的使用,提高并发。
但是中止的事务需要重试,这是另一种消耗的增加,具体如何选择,依然取决于具体业务的情况。
–
因为太新,大多实际使用的数据库,和网络资料,都不是基于这个技术的,
所以很难深刻记住其实现细节,回头再来翻书吧。