什么是事务?
飞书文档同步发表到掘金后发现有很多内容因格式不兼容缺失了,如每个部分的思维导图和高亮块的内容。 有兴趣的朋友们可移步bytedance.feishu.cn/docx/doxcnz…
在残酷的现实中,有很多事情都会出错,比如软件硬件发生故障、网络中断等等,这会让最后的结果不符合预期。
事务是将多个对象上的多个操作合并为一个执行单元的机制
整个事务要么成功 提交(commit),要么失败 中止(abort)或 回滚(rollback)。
通过“是否存在特定的异常现象”来界定隔离级别并不是理想的方法。
通过“特定的锁策略”来界定隔离级别也会带来一些困惑,因为同一隔离级别可以通过多种锁策略来实现。
深入理解事务
特性
-
原子性(Atomicity)
在出错时中止事务,并将部分完成的写入全部丢弃
-
一致性(Consistency)
任何数据更改必须满足某种状态约束,其实并不是数据库可以保证的事情。
-
隔离性(Isolation)
并发执行的多个事务相互隔离,它们不能互相交叉。
- 持久性(Durability)
多对象与单对象事务操作
多对象写入
实现方案:
多对象事务要求数据库知道事务包含了哪些读写操作,因而对于某个连接,SQL语句在 BEGIN TRANSACTION和COMMIT之间的所有操作都属于同一个事务
单对象写入
多对象的事务操作基于单对象的事务操作完成的。
存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性
🌰 假设正在向数据库写入一个20KB的JSON文档
实现方案
通过提供原子操作,CAS,WAL预写式日志等方式实现原子性
实现方案
对每个对象采用加锁的方式(每次只允许一个线程访问对象)来实现隔离
多对象事务的必要性
Q: 是否真的需要多对象事务?
A: 需要。有许多其他情况要求写入多个不同的对象并进行协调:
在关系数据模型中,表中的某行可能是另一个表中的外键。
在图数据模型中,顶点具有多个边连接到其他的顶点。
在文档数据模型中,更新非规范化的信息时需要一次更新多个文档。
对于带有二级索引的数据库,每次更改值时都需要同步更新索引, 从事务角度来看,这些索引是不同的数据库对象。
处理错误和中止
Q:对中止的事务进行重试是完美的方案么?
A:仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。
在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
重试也并不是完美的方案
如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障,那么重试会导致事务被执行两次。
如果错误是由于负载过大造成的,则重试事务将使问题变得更糟。
除此之外,如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。
弱隔离级别
多个事务同时读-写、写-写同一个对象
读-提交
防止脏读
🌰 用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。
为什么要防止脏读?
-
另一个事务可能会看到部分更新的数据,这会让用户感到困惑
-
如果事务发生中止,则所有写入操作都需要回滚。 如果发生了脏读,这意味着它可能会看到一些稍后被回滚的数据。
防止脏写
-
二手车销售网站:车主被改为Bob(因为他成功地抢先更新了车辆表单),但发票却寄送给了Alice(因为她成功更新了发票表单)。
-
更严重的问题:事务回滚
防止脏写可以避免以下并发问题:
-
如果事务更新多个对象,脏写会带来非预期的错误结果。
-
不能避免计数器增量的竞争情况--将在之后的“防止更新丢失”中探讨
实现读-提交
防止脏读
-
使用相同的锁
-
对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。
防止脏写
- 使用 行锁(row-level lock)
可重复读
不能容忍这种暂时的不一致的一些场景
快照级别隔离对于长时间运行的只读查询非常有用。
-
备份
-
分析查询和完整性检查
实现可重复读
当一个事务开始时,它被赋予一个唯一的、单调递增的事务 ID(txid)
操作规则
created_by 字段代表将该行插入到表中的的事务 ID
通过将 deleted_by 字段设置为请求删除的事务的 ID 来标记为删除,初始为空。
UPDATE 操作在内部翻译为 DELETE 和 INSERT
可见性规则
事务开始的时刻,创建该对象的事务已经完成了提交。
对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交。
innodb通过mvcc实现RC:
-
每次读取时创建一个新的read view
-
RC会比RR创建快照的开销大
那为什么我们一般还是使用RC隔离级别呢?
因为实际上RC比RR的并发度更好
首先,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…
更新丢失
总结一下,到目前为止已经讨论的 读已提交 和 快照隔离 级别,主要保证了只读事务在并发写入时可以看到什么。
却忽略了两个事务并发写入的问题 —— 我们只讨论了脏写,一种特定类型的写 - 写冲突是可能出现的。
case:
-
增加计数器或更新账户余额
-
解析并本地修改一个复杂的值
-
两个用户同时编辑 wiki 页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效 。
防止更新丢失
-
原子写操作
许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作,
-
显示加锁
由应用程序显式锁定待更新的对象,然后,应用程序可以执行“读-修改-写回”这样的操作序列。
-
自动检测更新丢失
原子操作和锁都是通过强制“读-修改-写回”操作序列串行执行来防止丢失更新。
先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退。
InnoDB的可重复读却并不支持检测更新丢失
-
**原子比较和设置 **CAS
只有在上次读取的数据没有发生变化时才允许更新(ABA)
写倾斜与幻读
这里的写冲突并不那么直接,但如果两笔事务是串行执行,则第二个医生的申请肯定被拒绝。
更多写倾斜的例子
-
会议室预订系统
-
多人游戏
-
抢注用户名
-
双重开支
为何产生写倾斜
所有这些写倾斜的例子都遵循类似的模式:
-
一个 SELECT 查询有满足条件的行
-
根据查询的结果,应用层代码来决定下一步的操作
-
如果应用程序决定继续执行,它将发起数据库写入( INSERT,UPDATE或DELETE)并提交事务。
实现方案
由于更新的可能是多个对象,防止写倾斜的可选方案有很多限制:
-
单对象的原子操作和CAS不起作用。
-
大多数的数据库在可重复读隔离级别都不支持自动检测写倾斜问题
-
大多数数据库不支持涉及多个对象的约束
-
对事务依赖的行来显式的加锁[无法解决查询对象为空的问题]
实体化冲突
显示加锁不能解决的问题:查询结果中没有对象(空)可以加锁
把幻读问题转变为针对数据库中一组具体行的锁冲突问题
eg:提前构造一张表,表中预先写好可能会插入的数据。
这样就人为引入一些可加锁的对象
串行化
保证最终的结果与串行执行结果相同即可
实际串行执行
解决并发问题最直接的方法是避免并发
redis其实蕴含了一点这种思想
数据库设计人员直到2007年才完全确信,采用单线程循环来执行事务是可行的:
-
内存越来越便宜了
-
OLTP 事务通常很短,只产生少量的读写操作。 运行时间较长的分析查询则通常是只读的,不需要运行在串行主循环里
提高性能
采用存储过程封装事务
对于交互式的事务处理,大量时间花费在应用程序与数据库之间的网络通信。
采用存储过程封装事务,应用程序必须提交整个事务代码作为存储过程打包发送到数据库,数据库可以把事务所需的所有数据全部加载在内存中,使存储过程高效执行,而无需等待网络或磁盘I/0 。
优点
-
不需要等待 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对象
检测写是否影晌了之前的读
性能
更多阅读
: 用例子说明了物化冲突、谓词锁、索引范围锁以及innodb的RR级别