事务不是凭空产生的,是通过技术手段实现的,目的是为了简化应用层编程模型,屏蔽内部潜在的错误和复杂的并发问题,由数据库提供安全保证。哪些系统都需要事务呢?回答这个问题需要理解事务提供的安全保证和不足,事务虽然对很多人来说不陌生,但是有很多微妙的细节需要考虑,值得仔细研究。
1. 深入理解事务
很多关系型数据库(mysql、postgre)都普遍支持事务,后来非关系数据库兴起,旨在通过新的数据模型,以及内置的复制和分区(这是第5、6章的内容)来改善传统的关系模型,很多新一代的数据库都放弃了事务的保证或者进行了重新定义,提供比以前弱的保证,大规模系统为了性能和高可用性不得不放弃了对事务的支持。(elasticsearch、redis)
1.1 ACID含义
1.1.1 A(原子性)
定义:事务是最小的单元,不可分割,处于一个事务的多个语句要么全不成功,要么全部失败。
补充: 原子性不同语境有差异,在并发编程中一个线程执行原子操作,意味着其它线程无法看到中间结果。但是数据库多线程的场景是在
隔离 性
里面说明的。作用:在执行出错的时候可以无顾虑的重试,不用寻找执行成功的部分、失败的部分,所以这部分应该理解为终止性。
1.1.2 C(一致性)
- 定义:事务执行总是从一个稳定状态转换成另一个稳定状态,比如两个账户的钱可以相互转移,但是总数是固定的。
- 补充:一致性不太好理解,很多也都是通过上面的例子记住,但是一致性不同的场景含义不一样。
- 最终一致性:在异步复制模型中,数据副本和主节点数据状态同步不是实时的。
- 一致性哈希:某些系统用于动态分区在平衡的方法。
- CAP :
- ACID:应用程序对数据的
预期状态
,这个其实主要靠应用程序实现,应用程序有责任定义正确的事务保证事务的一致性,如果应用程序提供了违背恒等条件的修改数据库很难检查,但是可以检查一部分,比如通过创建外键、唯一索引。所以C不应该在ACID出现
。
1.1.3 I(隔离性)
- 定义:多个事务并发执行时不受到彼此干扰的特性。
- 补充:如果两个并发执行的事务修改的是不同数据,不会有问题。但是如果修改的是相同数据,会引入竞争条件,造成错误。数据库提供了不同的隔离级别:
未提交读
,已提交度
,重复读
,串行化
。 - 作用:多个事务并发提交的结果,与多个事务串行提交的结果相同。
1.1.4 D (持久性)
- 定义:事务一旦提交成功,数据就会被安全保存,不会丢失。
- 补充:对于单个节点来说,事务提交意味着写入了非易失的存储设备,同时还会通过WAL日志提供故障恢复,对于多个节点意味着成功复制到了多个节点,数据库比如在复制节点写入成功后才返回成功。
- 作用:不用担心数据丢失问题。
- 单节点写磁盘和多节点复制不是绝对完美的
- 写磁盘后发生了故障,数据虽然在,但是机器重启或者恢复前服务不可用,但是基于多节点复制的可以用。
- 异步复制系统中,主节点不可用时,最近的写入的数据可能丢失。
1.2 单对象和多对象事务操作
- 多对象操作
用户可以邮件列表提供未读邮件数量功能,对未读数量做了冗余,事务的原子性
和隔离性
能保证用户看到的始终是一致的,不会出现有未读邮件但是数量是0的问题。
- 单对象操作
单对象操作也支持原子性,如果需要写入20k的json数据到数据库里面,不会写到10k的时候失败了,导致只有部分写入成功。有些数据库提供了高级原子操作,比如自增、CAS,一般称为称为轻量级事务。
一般认为事务是针对多个对象,将多个操作聚合为一个执行单元。
- 多对象事务的必要性
许多分布式数据系统不支持多对象事务,主要是跨分区时难以实现,在高可用或者极致性能场景会带来负面影响。
- 处理错误和终止
事务的关键特性是如果发生了意外则完全放弃,而不是部分放弃,这样可以方便的重试。并不是所有的系统都遵循这个理念,在无主节点复制的存储系统,在出错的时候会尽可能多的重试,并不会撤销已完成的工作,恢复工作需要应用程序完成。
很多框架在事务出错后会直接抛出堆栈信息,但之前所有的输入都会被抛弃,但是事务重试也会有问题。
- 事务实际执行成功的,但是网络原因返回失败,重试会导致重复执行。
- 系统负荷引起的失败,重试会让系统更加糟糕。
- 临时故障引起的失败,比如死锁、网络抖动有意义,但永久故障比如违背了数据库约束,重试没有意义。
- 重试可能导致业务方逻辑被多次重试执行。
2.弱隔离级别
如果两个事务执行不存在依赖关系,则可能并发执行,如果修改数据是相同的数据,会带来安全隐患,所以数据库一直通过隔离界别来对应用程序隐藏内部的并发问题,但是隔离并不是非常简单的,串行化
隔离级别会有严重性能问题,所以一般不用,下面常用隔离级别的介绍。
2.1 Read Committed(已提交读)
2.1.1 实现的功能
这是最基本的隔离级别,提供了两个保证
- 读数据库时,只能看到已经提交的数据。(防止脏读)
未提交事务的数据其他事务看不见,这个很容易理解。
- 写数据库时,只会覆盖已成功提交的数据。(防止脏写)
两个事务并发修改同一个数据,会推迟第二个事务的更新,直到前面的事务完成。
一个事务涉及多个对象,比如抢购商品场景,更新买家信息,创建收据信息,防止脏写,可以在并发场景下不会出现买家信息和收据上买家信息不一致。
但是不能解决计数器增量问题(先查询原来的数量,然后加一,写数据库),虽然第二个事务依然是第一个执行后写入的,但是结果还是错误的,
这不属于脏写,属于数据丢失场景
.
2.1.2 实现方式
已提交读非常留下,在Postgre、Oracle、SQL Server是默认的配置。
防止脏写:通过对对象加锁实现,更新时必须先获取到锁,未获取到的事务会等待,时已提交度或者更高隔离界别在数据库内部自己实现的。
防止脏写:维护旧值和当前事务要设置的值两个版本。不采用锁,因为性能问题比较大。
2.2 repeatable read(重复读)
2.1 已提交的不足之处
已提交读不能解决不可重复读的问题,事务开始时读取了一个值,但是会再次读取,中间可能被其他事务修改过并已经提交了,两次读取的内容不一样,比如下面这个例子在转账期间可能看到两个账号加起来的钱是900.
虽然再次刷新余额总数会正常,但是有些场景这种是不可接收的,这里产生了读倾斜
(不可重复读)
- 数据库备份:需要数小时才完成,镜像可能包含部分新数据、部分旧数据。
- 分析查询和完整性检查:可能会扫描大半个数据库,会导致结果不准确。
所以需要更强的隔离级别:可重读读
2.2 快照级别隔离实现
同样是通过锁的方式实现脏写,对于读采用了MVCC机制。在已提交读隔离级别下,每一个不同的查询单独创建一个快照,快照隔离级别是使用一个快照来运行整个事务。
一致性快照度可见性条件,需要同时满足:
- 事务开始 的时刻,创建该对象的 事务已经完成了提交。
- 对象没有被标记为删除;或者即使标记了,但删除事务在当前 事务开 始时还没有 完成提交。
索引和快照级别隔离
- 方案一:索引直接指向所有的数据版本,然后过滤对当前事务不可见的版本,垃圾回收进程决定删除旧对象版本时,也要删除索引。
- 方案二:需要更新时,创建新的修改副本,拷贝必要内容,递归让父节点一直到根节点指向信息索引副本,不更新的页面不受影响。
2.3 防止更新丢失
两个不同的事务都读取原始数据,修改、重新写入,由于第二个事务并不包含第一个事务的修改内容,提交的修改会丢失第一个事务的改动,比如:
- 递增计数器、扣减库存。
- 复杂对象的一部分内容修改
- 编辑不同的文章界面。
2.3.1 解决方案
- 原子操作:数据库提供了原子操作,比如自增、自减,这些都是原子性的。
- 显示加锁: select xx for update,这个情况也要考虑应用层逻辑。
- 自动检查更新丢失:事务管理器去检测,postgre、oracle的可重复读实现了检测功能,mysql没有。
- 原子比较和设置:数据库更新的时候加断言条件,乐观锁。
对于多副本的数据库,多个节点会并发修改数据,需要单独考虑。
2.4 写倾斜与幻读
2.4.1 写倾斜:
场景:两名医生至少有一人值班,如果两个人同时点了调休按钮,产生两个事务,事务开始检查当前休假医生数,发现是0,进入下一阶段,然后更新状态,都提交成功了,这不是脏写、也不是更新丢失,这种异常被称为写倾斜。
- 事务更新多个对象:容易发生写倾斜
- 事务更新单个对象:脏写、数据丢失。
2.4.2 写倾斜产生原因
- 先查询数据库找出满足条件的行。
2.根据查询结果进行下一步操作,可能继续也可能报错终止。
3.如果继续会发起对数据库的update、insert、delete操作,但是3的执行会第二步做出决定的条件。
2.4.3 解决写倾斜的方案:
- 尝试依靠数据库约束,如外键唯一性索引、外键,但是上面例子涉及两个一般不支持。
- 数据库目前不支持写倾斜检测,涉及多个对象时,单对象原子操作无意义。
- 不能实现真正的串行化,可以考虑在数据库显示加锁。
select * from doctors where status = on_call for update;
2.4.2 其他写倾斜的例子
- 会议室预定系统。一个会议室在同一个时间段内不能被重复预定,快照隔离级别无法阻止用户并发预定。(postgre的范围类型可以完成,其他数据库不行)
- 多人游戏。可以通过加锁解决更新丢失问题,即玩家不能同时移动同一个数字。但是锁不能防止玩家将两个不同的数字移动到同一个位置。
- 声明一个用户名。网站要求用户提供的用户名不同,事务隔离级别在这里不起作用,可以创建唯一约束解决。
- 防止双重开支,支付或者积分系统检查用户花费不能超过限额,并发消费时单个消费不超过限额,但是加起来会超。
这些都需要串行执行,可以用redis提前加锁,比如加锁主体可以是会议室id、账户id、棋盘的位置、正在创建的用户名(没有必要,数据库约束已经足够),通过加锁实现了串行执行,但是也不是最优的,比如会议室不同的阶段其实是可以并发预定的、账户扣钱有限额,但是如果充钱其实是可以不收消费锁的限制的,要考虑锁的粒度和业务场景要求。除了redis锁,如果查询的条件是有符合条件的数据,比如上面的医生值班系统,可以用数据库的select for update,但是对于其他的例子不适合,因为其他例子是预期为空,然后写入数据,一个事务写入的数据对其他事务的原来查询造成影响就是幻读,
可重复读
隔离级别能够解决幻读问题,但是对幻读引发的写倾斜无能为力。
实体化冲突:
对于预期为空的条件无法select for update,必要的时候可以考虑创建一些记录,方便加锁,比如对于会议预定系统,提前创建好未来几个月的会议室-时间表,然后通过预定条件对具体的记录执行select for update加锁,这种方式容易出错,富有挑战性,不到万不得已不使用
。
3.串行化
事务提供弱隔离级别能够解决一些并发问题,但是对读倾斜
和写倾斜
、幻读
带来的问题很棘手,需要串行化,串行化保证多个事务在并发执行的情况下和单个逐个执行的结果一样。串行化实现会有严重性能问题,所以不会所有场景都用。实现串行化的方案有以下三种
3.1 实际串行执行
在一个线程上依照顺序逐个执行事务,这个直白的想法是2007年才被认为是可行的,因为过去一直是多线程提高性能,转向单线程执行的考量如下:
- 内存便宜了,许多应用可以将活动数据可以被完全加载到内存中,事务执行速度比磁盘等待速度快很多。
2.OLTP通常执行很快,只产生少量的读写操作,而运行时间较长的分析查询通常是只读的,可以在可重复读
隔离级别下执行,不需要串行化。(redis就是单线程执行,串行执行事务的)
3.1.1采用存储过程封装事务
数据库早期,事务操作设计希望包括用户的所有的操作序列,有查询操作,根据查询操作做不同的写入操作,这种属于交互式的事务处理,交互式的事务处理大量时间花费在应用程序和数据库之间的通信上,如果不并发会导致吞吐量低。单线程执行的数据库不支持交互式多语句事务,只支持一次性批量提交事务的存储过程,统一执行。
3.1.2 分区
串行执行可以更好的控制并发,但是对于高写入需求的应用容易成为瓶颈,为了扩展多个CPU和多个节点可以对数据进行分区,每个分区有单独的线程执行事务,但是在跨越分区的时候必须对所有分区相关数据加锁,保证串行化,性能会下降很多,且无法增加CPU提高性能。事务是否能只在单分区上执行很大程度上取决于应用层的数据结构。简单的键值数据比较容易切分,而带有多个二级索引的数据则需要大量的跨区协调
.
3.1.3 串行执行场景
- 事务必须简短,否则一个慢事务会影响其他事务执行。
- 仅限数据完全可以加载到内存场景。有些访问很少的数据如果被放在磁盘,在读取的时候会有严重性能问题。
- 写入吞吐量足够低,才能在单个CPU上执行。否则需要分区,最好没有跨分区事务或者跨分区事务比重很小。
3.2两阶段加锁
最广泛的串行化算法,在MySQL (InnoDB)和SQL Server中的“可串行化隔离”级别使用。两阶段加锁强制性比防止脏写更高。
- 如果事务A已经读取了某个对象,此时事务B想要写入该对象,那么B必须等到A 提交或中止之才能继续。以确保B不会在事务A执行的过程中间去修改对象。
- 如果事务A已经修改了对象, 此时事务B想要读取该对象, B必须等到A提交或 中止之后才能继续。对于 2PL,不会 出现读到旧值的情况
因此2PL不仅在井发写操作之间互斥,读取也会和修改产生互斥.
容易产生死锁、需要有检测机制,多个事务等待时间不确定,即使每个事务执行时间很短,多个并发执行等待时间会很长,导致大量的失败。
串行化也要解决幻读的问题
- 谓词锁:作用于满足搜索条件的所有对象。
- 索引区间锁:比谓词锁性能高,查询条件的近似值在某个索引上,如果另一个事务插入、更新、删除则会修改索引会引发共享锁冲突,会自动处于等待状态直到共享锁释放。