【整理】互联网服务端技术体系:存储抽象之事务概念与实现
ACID。
综述入口见:“互联网应用服务端的常用技术思想与机制纲要”
引子
数据存储与操作是软件的基石和核心。除基本的 CRUD 操作之外,事务是关于数据操作的最重要的抽象。
概念及思路
抽象
事务简化了应用的数据读写模型。事务的抽象是 ACID。
- A:原子性。指一个事务中的所有操作要么全部成功,要么什么都不做。原子性体现的是容错性。也就是说,当事务写入过程中,无论发生什么错误( DB 出错,应用宕机,网络不稳定等),事务会回滚,恢复到事务开启之前的状态,不会处于结果不确定的事务中间状态;
- C: 一致性。指事务完成之后,始终满足数据的不变性约束。比如,一个人有两个账户各 500 块,当从一个账户转账到另一个账户,无论转多少次,无论转多少钱(不超过 1000),则两个账户最终的和数应当始终是 1000 ;
- I:隔离性。一个未提交事务对于另一个运行中事务的可见性。有四种隔离级别:串行、不可重复读、可提交读、无隔离。默认是可重复读。
- D:持久性。事务完成并提交之后,数据已保存到某个非易失存储,不会因为掉电或其它错误而丢失已提交的数据和改动。
隔离性之可提交读:
- 两个保证:1. 只能读取已提交的数据;2. 只能写已提交的数据;
- 避免脏读:不会读到某个未提交事务的中间数据状态;使用 MVCC 机制实现;
- 避免脏写:必须等待前一个事务提交或终止之后再写入,不会写未提交数据;使用行锁实现。
要注意的是:
- 事务的原子性与并发的原子性不同。并发的原子性是指多个线程并发读写同一个共享可变变量的问题,体现在事务的隔离性上;
- 事务的一致性与 CAP 的一致性不同。事务的一致性强调的是数据约束的不变性,而不是拷贝的相同;
- 事务的原子性和持久性涉及的是一个事务里的多个操作,而事务的隔离性和一致性涉及的是多个事务之间的可见性;
- 事务并不是数据存储系统的天然的性质,而是根据需求设计出来的;可以弱化某些事务保证从而提供更好的选择;
- 不同数据存储系统实现的事务保证的语义并不完全一致。
思路
- A: undo 日志;
- C: 需要应用来保证。因为只有应用才知道数据项的关联约束及不变性;
- I: 锁和 MVCC;
- D: redo 日志。
考量
- 容错:发生错误时,需要保证 ACID。错误可能来自于数据库软件或硬件失败、应用宕机、网络中断等;
- 性能:有并发读写时,不受长耗时的写影响,保证读写的高吞吐量;
- 准确:多个事务并发读写同一个数据时,必须保证最终结果的准确性;防止脏读脏写、不可重复读、幻读、丢失更新等问题。脏读等问题还需要考虑关联数据,比如未读邮件及计数。
- 安全:多个并发写时,要防止死锁。当一个操作同时更新两个表,且两个表的加锁顺序可以不一致时,或者一个操作要加锁操作同一张表的两行数据且加锁顺序可以不一致时,可能会发生死锁。
- 灵活:当事务发生错误需要回滚时,可以回滚到指定的保存点。
不可重复读的影响:
- 备份:在备份数据过程中,备份数据可能有更新;这可能导致备份数据存在不一致。
- 分析和完整性检查: 在一个大的数据集上执行分析命令,而这个数据集的某些数据可能在变化而不一致,会导致分析结果不准确;在多个相关数据集上执行完整性检查,由于新的多个更新可能无法同时体现在所有的数据集上,导致完整性检测失败。
当事务出现错误回滚之后,可以重试事务。在某些情况下,重试事务是有影响的:
- 事务成功了,但是网络中断了,重试事务会导致执行两次,需要幂等保证;
- 错误是因为 DB 或网络负荷过重导致。重试会加重负荷,导致更多错误。可以采用二进制退避算法重试;
- 偶然性错误(死锁、网络波动等)的情况下重试有效,必然性错误(约束违反)的情况下重试无效;
- 分布式操作的重试,会有副作用。
数据库的事务实现
缓冲池
为了保证读写数据性能,MySQL 读写提供了缓冲池(Buffer Pool,BP)。 事务开启后,数据更新数据先写入 BP 里,在提交事务后再通过后台线程定期同步到磁盘文件中。当事务执行中发生异常时, BP 的数据可能未能刷入磁盘,或者刷入了一部分,因此存在数据丢失或不一致的风险。这样,就需要在事务提交前记录 redo 日志和 undo 日志,这样就能在断电后重新执行事务保证数据不丢失,或者回滚事务保证数据原子性。
undo日志
记录事务更新操作的逆向逻辑的日志,用来保证事务的原子性。开启事务后,每次更新执行都会记录 undo 日志到 undo log buffer ,并将数据更新改动保存到缓冲池 BP ;提交事务后,通过后台线程同步到磁盘的 redo 日志文件和 undo 日志文件,并将缓冲池 BP 中的更新改动写入到磁盘中。如果事务提交之前发生错误,则什么都不会发生;如果事务执行失败,通过 undo 日志中的回滚语句,完成回滚操作。
redo日志
记录事务执行过程中对页的物理修改的日志(512 字节、块形式),用于保证事务的持久性。开启事务后,每次更新执行都会分别记录 redo 日志到 redo log buffer,并将数据更新改动保存到缓冲池 BP ;提交事务后,通过后台线程同步到磁盘的 redo 日志文件,并将缓冲池 BP 中的更新改动写入到磁盘中。如果事务提交之后断电,则可以通过读取 redo 日志来重做事务。
锁
使用锁技术来防止事务的脏写。当事务要写一行数据时,必须先获得该行数据的排它锁,然后进行读写;其它事务必须等待该行数据的排它锁释放之后,再来获取该行数据的锁进行写。
数据库的锁主要有表锁和行锁。由于表锁的范围大,对业务影响很大,大多数情况下使用的是行锁。行锁算法有 Record 锁、Gap 锁和 Next-Key 锁。使用合适的行锁,可以有效避免并发写带来的冲突和吞吐量下降。
MVCC
快照隔离技术,实现高并发读,防止不可重复读。读不阻塞写,写不阻塞读。始终读一致的快照。基本思想:当事务启动时,只能看到启动时刻点的已提交数据。实现机制如下:
- 维护一个数据对象的多个版本;
- 每一个事务开启时都会分配一个递增的事务 ID。数据库会记录所有已提交的、运行中且未提交的事务 ID;
- 每行会隐含一个 create_by 和 delete_by 的列,列值是操作的事务 ID。每次插入一条记录时,create_by 记录是哪个事务插入的;每次删除一条记录时,delete_by 记录时哪个事务删除的;每次更新一条记录时,会新增一条 delete_by 和 一条 create_by 记录。
- 可见性:当前事务能够看到的其它事务对同一行数据的写操作的可见性规则。
写操作可见性规则:
- 当前事务启动时,会记录在这个时刻点之前,所有已启动未提交的事务 ID 列表,这些事务的所有写操作被忽略(通过比较 create_by 和 这些事务 ID 列表);
- 未完成已终止和回滚的事务的写操作被忽略;
- 在当前事务之后启动的事务(事务 ID 大于当前事务 ID)的写操作被忽略,无论是否已提交;
- 除上述以外的其它写操作对读可见。
- 这些规则适用于插入/删除/更新操作。
快照隔离与索引:
- 思路一:让索引指向数据的多个版本,查询的时候根据当前事务 ID 过滤掉不合适的版本;
- 思路二: append-only B-tree/ copy-on-write 机制。创建一个被修改页的新拷贝,更新好后,父页面会指向这些子页面的新的版本。需要后台进程进行压缩和 GC.
并发写
并发写可能会面对如下问题(快照隔离技术会诱发更大的发生概率):
- read-after-write 模式下的丢失更新;
- 破坏数据约束性。这种情况下并非脏写或丢失更新,而是涉及多个不同数据对象之间的共有约束。比如医院至少需要一人 oncall ;如果两个人同时休假,同时 set oncall = false ,并发情况下,可能导致没有人处于 oncall 状态。如果存在隐式的数据约束,应该在操作完成之后进行约束性 check ,及时报错、回滚或报警。数据约束性检测可以通过触发器或者在应用代码层实现。
可用方案:
- 数据库行锁实现原子写操作。比如更新操作的条件中带有索引列;
- SELECT ... FOR UPDATE 加锁;
- 在应用层加锁,将多个操作包装成一个原子写;
- CAS:检测-更新,如果检测到丢失更新,则终止并回滚整个事务;如果不存在,则进行更新;
- 串行化:让并发操作顺序进行;
- 引入一张表,这张表的数据可以作为加锁使用,尤其是在 read-after-write 模式下查不到相关数据又需要加锁操作的时候。并发锁机制对应用的代码侵入性强,且不通用,作为备底方案;
串行隔离技术:
- 串行执行:RAM 更便宜、装得下事务的所有数据集,比如 Redis ; OLTP 操作是短暂的数量少的,长时分析可以基于快照隔离技术或使用 OLAP 技术;存储过程,使用高级语言、装载内存、单线程;分区,将数据分布在多个分区上,每个线程只读写单独分区;
- 2PL:无写时可并发读;读写相互阻塞;读写都要加锁,读锁 Share mode ,写锁 Exclusive mode ,读后写 Share mode 要升级到 Exclusive mode ;事务执行的全过程中都会持有锁,事务完成并提交后释放锁;2PL 可能会导致死锁,数据库会自动检测死锁,并终止某个被死锁的事务,以让其它事务进展下去;2PL 的问题是延迟不稳定、吞吐量低、死锁频率较高、长事务需重复执行;
Savepoint
通知系统记录指定执行点的状态集。当事务执行出错时,可以回滚到指定 Savepoint 的状态,而不必回滚到事务前。比如旅行计划,是不能全部回滚的。扁平事务可以看做是只有一个 Savepoint (事务执行开始前记录)。 Savepoint 的编号是单调递增的。
分布式事务
- 核心问题:1. 如何将分布式事务拆分为多个本地事务; 2. 如何组合多个本地事务,并尽可能保证一致性。
- 基本思路:实现绑定关系。
- 解决方案:2PC、TCC、本地消息表、消息事务、Sagas 事务模型。
2PC
通过一个全局协调者来组合本地事务。分准备和提交两个阶段。当要提交事务时:
STEP1: 准备阶段,协调者向所有参与者发送 prepare request,询问参与者事务是否可以提交事务;参与者向协调者回复 Y 或 N 。如果有任一参与者回复 N,则整个事务终止; 如果所有参与者都回复 Y, 则进入提交阶段;
STEP2: 提交阶段,协调者向所有参与者发送 commit request ,通知参与者提交事务并执行。所有参与者必须确保事务完成,如果失败则要反复重试直至成功(兑现承诺)。
2PC 的主要问题在于不确定性和性能。
- 在提交阶段,当某个参与者繁忙或不响应时,整个事务执行处于同步阻塞状态;
- 任一参与者失败,全部回滚,代价巨大;
- 协调者的单点问题。在提交阶段,若协调者宕机,则参与者无法确定是 commit 还是 abort ,整个事务执行处理不确定的状态,或者进入不一致状态。
TCC
Try-Confirm-Cancel。对每个操作都注册一个确认和撤销操作。Try -- 主要是对业务系统做检测及资源预留; Confirm -- 业务确认提交;Cancel -- 业务执行出错时执行撤销操作。TCC 本身实现简单、代价稍小,不过业务层要写很多补偿代码(有些场景可能难以做补偿),数据一致性弱于 2PC。
本地消息事务
通过本地消息表来组合事务。将分布式事务拆分成多个本地事务,本地事务与本地消息表关联。当一个事务执行后,在本地消息表存储一条消息。业务操作和消息存储都放在一个事务里。另一个本地事务消费这条消息,执行相应的事务操作。若执行成功,则修改这条消息状态为成功,事务均执行成功;若执行失败,要么重试直至成功(可支持幂等),要么修改消息状态为失败,回滚事务,并通知消息生产方进行回滚或补偿。需要处理事务级联问题、事务与本地消息表的耦合问题,需要封装好。
其它方式
- 事务消息:通过消息系统来组合事务。消息系统支持事务。
- Sagas 事务模型:通过工作流引擎来组合事务。将长时运行事务拆分为多个短的本地事务,并通过工作流引擎来进行管理和执行。