写在前面的话
本章主要是ddia的第七章,事务
开篇明义的阐述了事务的基本概念和必要性,在之后讨论了各种不同的隔离级别,从最低的读已提交到最高的序列化方法。讨论了很多常见的问题。从这里看之前在做baas时更新区块高度时遇到的主要问题其实就是写入偏差,当时的处理方法是在应用程序侧加入了锁,现在想想这其实是一种悲观锁的思路,如果用version的乐观锁也许是个更好的选择。
事务
事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式,概念上讲,整个事务要么成功,要么失败,不会出现部分失败的情况。
事务的概念
ACID的含义
事务的安全保证,简单来说就是ACID
原子性(Atomicity)
能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力
一致性(Consistency)
对数据的一组特定陈述必须始终成立。即不变量(invariants)。
例如:在会计系统中,所有账户必须借贷相抵,当一个事务开始时,系统满足这些不变量,并且事务期间的所有操作都保持这种有效性,那么整个事务完成时,系统仍然也满足这些不变量。
P.S. 一致性是应用程序的属性,不取决于数据库
隔离性(Isolation)
隔离性意味着同时执行的事务是相互隔离的,它们不能互相侵犯,可以假装认为在数据库中每个事务都是唯一在整个数据库上运行的事务,实际上它们可能是并发运行的。
持久性(Durability)
一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
P.S. 完美的持久性是不存在的
单对象与多对象操作
多对象操作:同时修改多个对象(行、文档、记录)
举例:
某邮件应用,使用单独的字段存储未读邮件的数量,当消息写入时也必须增长计数器,当消息被已读时,也必须减少计数器
- 原子性:如事务过程中发生错误,邮箱和未读计数器可能失去同步,事务将中止,插入邮件和更新的计数器均会被回滚
- 隔离性:避免其他用户看到执行一半的中间结果(邮件数量增加而计数器未更新或邮件读取后计数器未更新)
单对象写入
举例:
向DB写入一个20KB的JSON文档
- 原子性:
- 发送10KB后网络中断,数据库是否存储了10KB不可解析的JSON?
- 通过使用日志来实现崩溃恢复
- 隔离性:
- 另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
- 使用每个对象上的锁来实现隔离
P.S. 通常多个对象上的多个操作合并为一个执行单元的机制被称为:事务
多对象事务的需求
虽然在很多分布式数据存储中放弃了多对象事务,但是我们仍然需要多对象事务
- 关系型数据模型中的外键,需要确保外键有效正确更新
- 需要一次性更新多个文档时,可以防止非规范化的数据不同步
- 具有二级索引的数据库,每次更改值都需要更新索引
处理错误和中止
ACID的处理哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品
重试一个中止的事务:简单有效,而不完美:
- 事务实际成功过,但是服务端向客户端确认提交成功时发生网络故障,重试事务会导致事务被执行两次:需要有额外的应用级除重机制
- 错误是由负载过大造成的,重试事务会导致问题更糟,此处可以限制重试次数
- 仅在临时性错误(死锁,异常情况,临时网络中断,故障转移)后才值得重试
- 事务在数据库外也有副作用。例如需要发送电子邮件来触发某个事务,当然不希望每次重试事务时都要重新发送电子邮件,这个问题在
二阶段提交
中会讨论 - 客户端进程在重试中失效,尝试写入数据库的数据都会丢失
弱隔离级别
并发问题的出现:当一个事务读取由另一个事务同时修改的数据时,或当两个事务试图同时修改相同的数据时。
由于可序列化的性能损失,许多数据库会采取一些更弱级别的隔离方式
读已提交
提供的保证:
- 从数据库读时,只能看到已提交的数据(没有脏读)
- 写入数据库时,只会覆盖已经写入的数据(没有脏写)
没有脏读
脏读:
一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据
防止脏读的原因:
- 如事务需要更新多个对象,脏读意味着另一个事务可能会只看到一部分更新
- 如果事务中止,则所有写入操作都需要回滚。
没有脏写
脏写:
后面的写入覆盖一个尚未提交的事务中的值。
防止脏写的原因:
- 事务更新多个对象,脏写会导致不好的结果,来自不同事务的冲突写入可能会混淆在一起。
实现读已提交
Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL和其他许多数据库的默认设置
- 防止脏写
- 数据库通过行锁(row-level lock)防止脏写
- 事务想要修改特定的对象(行或文档),必须获得该对象的锁,并持有该锁知道事务被提交或中止。
- 防止脏读
- 选择使用相同的锁:然而长时间的读锁效果不好,可能会被长时间的写入事务拖累
- 对于写入的每个对象,DB记住旧的已提交值,和当前持有锁的新写入值,只有当新值提交后,事务才会切换到读取新值,如下图所示。
快照隔离和可重复读
读已提交可以解决一些问题,但是并不能解决所有问题,如下图所示
一种悲伤的情况,恰好两次读事务的间隔内完成了一次转账的事务,看上去两个账号的钱就少了一些。
这种被称为:不可重复读
或读取偏差
在读已提交的隔离情况下,不可重复读被认为是可接受的。
然而在大型备份(需要大量的时间,备份进程中包含旧和新的部分)这种不一致是不可接受的。
同样,对于分析查询和完整性检查中,不可重复读会导致分析和检查没有意义。
此处需要引入快照隔离
。
每个事务都从数据库的一致快照中读取,即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支持快照隔离
。
实现快照隔离
关键原则
读不阻塞写,写不阻塞读
- 脏写:通常使用写锁来防止脏写
- 脏读:DB记住旧值的一般化,并排维护着多个版本的对象,被称为多版本并发控制(MVCC,multi-version concurrentcy control)
一种典型的方法是读已提交对每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。
其中,create_by
字段包含将该行插入到表中的事务ID, deleted_by
设置为请求删除的事务ID来标记为删除,当确定没有事务再访问已删除的数据时,垃圾回收会将所有带有删除标记的行移除,释放空间。
观察一致性快照的可见性规则
事务从DB中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象,具体流程如下:
- 每次事务开始时,DB列出当时所有其他(尚未提交或中止)的事务清单,会忽略这些事务的写入
- 被中止事务所执行的任何写入都将被忽略
- 由具有较晚事务ID的事务所做的任何写入都被忽略
- 所有其他写入,对应用都是可见的。
简而言之,对于某事务,可见对象需要满足以下条件:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
索引和快照隔离
简单的办法是使索引简单的指向对象的所有版本,并且需要索引查询来过滤掉当前食物不可见的任何对象版本,当垃圾回收时删除相应的索引条目。
优化:
- PostgreSQL:同一对象的不同版本可以放入同一页面,可以避免更新索引
- CouchDB,Datomic和LMDB:每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照
可重复读与命名混淆
不同的DB实现了快照隔离,然而却使用了不同的名字
- Oracle中称为可序列化
- PostgreSQL和MySQL中称为可重复读
防止丢失更新
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。
整体可能发生的场景如下:
- 增加计数器或更新账户余额
- 在复杂值中进行本地修改
- 两个用户同时编辑wiki页面,每个用户通过发送整个页面到服务器保存修改。
原子写
数据库提供了原子更新功能,例如如下的并发安全指令
1 | UPDATE counters SET value = value + 1 WHERE key = 'foo'; |
原子操作的实现:
- 读取对象时,获取其上的排他锁来实现,又被称为游标稳定性
- 简单的强制所有的原子操作在单一线程上执行
P.S. ORM框架很容易意外的执行不安全的读取-修改-写入序列
显式锁定
让应用程序显式锁定将要更新的对象,如果其他事务想要同时读取同一个对象,需要等待第一个事务完成。
1 | BEGIN TRANSACTION; |
自动检测丢失的更新
允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。
- PostgreSQL的可重复读,Oracle的可串行化和SQLServer的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。
- MySQL/InnoDB的可重复读并不会检测丢失更新
比较并设置(CAS)
只有当前值从上次读取时一直未改变,才允许更新发生,如果不匹配,则更新不起作用,必须重试读取-修改-写入序列。 (CAS, Compare And Set)
冲突解决和复制
场景:
多主或无主复制数据库并发被修改
问题:
目前的锁和CAS操作都需要假定有一个最新的数据副本,而在这种场景中可能是无法保证的,因此不适用。
方法:
- 允许并发写入创建多个版本的值,再事后合并这些版本。
- 具有可交换性的原子操作(不同的顺序执行,可以得到相同的结果),可以自动合并更新
- 最后写入为准(LWW),不过可能会丢失更新。
写入偏差与幻读
写入偏差的例子,如下图所示:
在这个例子中,应用需要检测是否有两个以上的医生在值班,如果有,则允许医生轮休,然而DB使用快照隔离,两个事务同时执行,两次检查都返回了2,都进入了下一阶段,导致了杯具。
写偏差的特征
如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。
已有的方法受到限制
- 涉及多个对象,单对象的原子操作不起作用
- 自动检测丢失更新对此没有帮助
- 某些数据库允许配置约束,然后由数据库强制执行,然而这里涉及到多个对象的约束,只能使用触发器,或者物化视图来实现他们
- 显式锁定事务所依赖的行,如下所示
1
2
3
4
5
6
7
8
9BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = TRUE
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = FALSE
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;
写偏差的更多例子
- 会议室重复预定
- 快照隔离不能防止另一个用户同时插入冲突的会议室
多人游戏
- 锁定不能防止玩家将两个不同的棋子移动到棋盘的同一位置
抢注用户名
- 两个用户尝试同时创建具有相同用户名的账户。唯一约束可以简单的解决。
防止双重开支
- 两个项目同时对一个账号进行支出,导致了双重开支(区块链中的双花攻击)
导致写入偏差的幻读
模式
SELECT
查询符合条件的行,并且检查是否符合要求- 按照第一个查询结果,决定应用是否继续
- 应用决定继续操作,就执行写入,并提交事务
物化冲突
人为的在数据库中引入一个锁对象
这种方法被称为物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突。
然而物化冲突应被视为最后的手段,大多数情况下应该使用可序列化的隔离级别。
可序列化
可序列化(Serializability)隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执 行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。
串行执行
避免并发问题的最简单方法就是完全不要并发:在单线程上按顺序一次只执行一个事务。
使用:
串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现
在存储过程中封装事务
交互式的事务方式中,应用程序与数据库之间的网络通信耗费了大量时间,如果不允许并发,吞吐量将会非常糟糕。
单线程串行事务处理系统不允许交互式的多语句事务,应用程序必须提前将整个事务代码作为存储过程提交给数据库,如上图所示。
存储过程的优点和缺点
缺点:
- 各个厂商都有自己的存储过程语言(Oracle有PL/SQL,SQL Server有T-SQL, PostgreSQL有PL/pgSQL等),然而并没有发展,这些语言相当丑陋
- 数据库中的存储过程运行管理困难,调试困难,版本控制和部署也困难,更难用于监控。
- 据库通常比应用服务器对性能敏感的多,一个写的不好的存储过程会造成更多的麻烦。
解决问题:
-现代的存储过程实现使用通用的变成语言
- VoltDB使用Java或Groovy
- Datomic使用Java或Clojure
- Redis使用Lua
- 存储过程与内存存储,使得在单个线程上执行所有事务变得可行
- VoltDB:使用存储过程进行复制,在每个节点上执行相同的存储过程。
分区
找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的CPU核,事务吞吐量就可以与CPU核数保持线性扩展
小结
- 每个事务必须小而快。
- 仅限于活跃数据集可以放入内存的情况。
- 写入吞吐量必须低到能在单个CPU核上处理
- 跨分区事务是可能的,但是使用程度会有很大的限制
- 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步将数据提到内存中,同时继续处理其它事务,等到载入完成后再重新启动事务(反缓存)
两阶段锁定(2PL)
允许多个事务同时读取一个对象,但是只要对象有写入,就需要独占访问权限
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续
在2PL中,写入不仅会阻塞其它吸入,还会阻塞读。
实现两阶段锁
第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁
读写的阻塞是通过为数据库中每个对象添加锁来实现的,锁的使用如下:
- 若事务要读取对象,则须先以共享模式来获取锁。允许多个事务同时持有共享锁,但是如果有一个事务持有排他锁,那么其他事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁,如果该对象上存在任何锁,该事务必须等待。
- 若事务先读取再写入对象,则它可能会将共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)
- 数据库会自动检测事务之间的死锁,并中止其中一个。
两阶段锁的性能
两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
- 由于获取和释放锁的开销
- 并发性的降低
2PL具有相当不稳定的延迟
谓词锁
类似于共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,例如:
1 | SELECT * FROM bookings |
谓词锁使用如下:
- 如果事务A想要匹配某些条件的对象,就需要获取查询条件上的共享谓词锁,如果事务B持有,则A需要等待B释放之后才允许进行查询。
- 如果A想要更新任何对象,必须首先检查新值或旧值是否与任何现有的谓词锁匹配,如果事务B持有匹配的谓词锁,那么A必须等到B提交或中止后才能继续。
索引范围锁
简化近似版谓词锁,也称间隙锁(next-key locking)
搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分。在 这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
如果没有可以挂载间隙锁的索引,那么可以退化到使用整个表上的共享锁
序列化快照隔离(SSI)
提供了完整的可序列化隔离级别,但与快照隔离相比只有只有很小的性能损失
悲观与乐观的并发控制
悲观并发控制
如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情
加锁。
乐观的并发控制
如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来
简单的处理是加个版本号,如果版本号相同则更新,不同则重试。
基于过时前提的决策
在写入偏差中,基本流程如下:
- 事务从数据库读取数据,检查查询的结果
- 根据结果决定采取一些操作
然而在快照隔离时,事务提交时可能不再是最新结果了,因为数据可能在同一时间修改。
关键点在于数据库如何知道查询结果是否可能已经改变了
监测旧MVCC读取(读之前存在未提交的写入)
如上图所示,事务43在提交时发现前提已经发生变化,因此提交失败。
监测影响先前读取的写入(读之后发生写入)
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。并且通知其他事务:你们读过的数据可能不是最新的。
如上图所示,事务43提交时,发现42的冲突写入已经提交,所以事务43必须中止
可序列化的快照隔离性能
优点:一个事务不需要阻塞等待另一个事务所持有的锁
SSI要求同时读写的事务尽量短