事务特性:
-
Atomicity (原子性) :
定义: 用户发起多次写请求: 中间某次写失败则所有写操作都被回滚实现方式: 灾害恢复日志
-
Consistency (一致性):
定义: 应用层面的不变性在事务中被保持 - Isolation (隔离性):
定义: 多个事务操作同一笔数据的时候效果互不影响, 不产生racing condition
比如多个事务同时从一个账号扣减金额,最终金额应该等于初始金额-总扣减金额
隔离级别: 解决事务并发对数据产生影响的问题
常见的并发问题:
- 脏读 (dirty read):事务2读到事务1未提交的写操作
- 脏写 (dirty write): 事务2覆盖事务1未提交的写操作. 问题: 多对象更新出现不一致
- 不可重复读 (non-repeatable read): 事务中和事务结束时读到的数据不同 (多数为统计查询并且短暂不一致). 但是有时这种不一致性无法容忍. 比如: 数据库复制备份. 大范围分析查询
- 幻影读 (phantom read): 事务一的写操作的结果影响事务二的查询结果
- 写偏差 (write skew): 两个事务同时检测某个前提条件满足,执行写操作 (未必是同一对象) 并提交, 结果导致前提条件不再被满足. 比如:医院规定最多不能超过两个医生同时请假, A和B同时申请假期. 前提条件都满足,但结果是两个医生同时请假了.
隔离级别 | 描述 | 脏读 | 不可重复读 | 幻影读 | 实现方式 |
---|---|---|---|---|---|
serializable | 并发的事务等同于事务一个个执行,完全没有并发问题,但是性能最差 | false | false | false | 实际序列化执行, 二段锁, 可序列化快照分离 |
repeatable read | 快照分离(snapshot isolation): 事务中读取事务开始前的提交数据,即使其他事务在期间改动数据并提交 | false | false | true | MVCC (Multi-version concurrency control) 记录数据的多个版本,找到事务开始前的数据版本 |
read committed | 事务提交的数据才能被读到, 写操作只能覆盖提交的数据 | false | true | true | 防止脏写: 对事务中写对象持有行级锁直到事务结束. 防止脏读: 记录数据的2个版本,无法解决计数器更新丢失问题 |
-
Durability (持久性):
定义: 事务提交之后数据被保存到持久化存储,不会因为宕机而丢失实现方式:
单机: 先写日志(write ahead log)
集群: 保证写操作被持久化到多个节点
单对象写:
处理复杂对象如JSON等, 需要保证原子性和隔离性
原子性通过先写日志 (write ahead log) 保证
隔离性通过锁或者compare and swap, 有的数据库提供原子性的read-modify-write操作如increment
不同于事务, 事务一般是针对多个不同对象的处理
许多分布式数据库因为实现上的困难只保证单对象写而放弃多对象事务
多对象事务仍然在以下场景需要:
关系型数据库:确保外键一致性被正确保持
文档型数据库: 由于数据被DENORMALIZE,通常一次更新会涉及多个文档,需要保证多个文档被同时正确更新
存在索引的情况下, 保证数据更新和索引的一致性
事务错误恢复:
放弃执行到一半的事务并重试
重试可能存在的问题:
事务成功执行但因为网络错误无法得到事务结果:
如果错误是因为系统超载,重试只会让问题变得更糟
重试一般能解决暂时问题, 死锁,网络异常,隔离级别错误等, 如果是永久性错误引起的问题(违反外键约束等), 重试没有意义
重试不能解决事务中数据库之外额外影响, 需要辅助二段式事务等方式
重试中如果客户进程崩溃会导致数据丢失
MVCC实现方式:
写数据时,修改的数据被TAG唯一自增交易ID(用于判定交易发生先后), 例如transactionID = 13的事务修改ID为1的数据从V1->V2, 再修改ID为2的数据从V1->V2, 为每次操作生成修改前和修改后两条交易版本数据:
id=1, dataValue=V1, createdBy=5, deleteBy=13
id=1, dataValue=V2, createBy=13, deleteBy=nil
id=2, dataValue=V1, createdBy=5, deleteBy=13
id=2, dataValue=V2, createdBy=13, deleteBy=nil
MVCC数据可见规则:
- 事务开始前,所有正在进行的事务对数据的修改不可见,即使最后事务被提交
- 取消的事务对数据的修改会被忽略
- 事务开始后,新的事务(transactionID >= currentTransactionID)对数据修改不可见, 即使最后事务被提交
- 其他写操作对读事务可见
换而言之, 如果一个对象对于读事务可见:
- 读数据的事务开始时,创建对象的事务已经提交.
- 对象没有被删除
- 对象已经被删除,但是删除对象的事务在读事务开始时还没提交
MVCC索引实现和优化:
- 索引叶节点需要指向数据所有版本,再通过transactionId过滤
- 如果更新的数据和原来在同一数据页,则可以避免更新索引 (POSTGRES)
- 复制有数据更新的索引页和直到根节点的所有索引页,查询时无需做数据过滤,但是需要定期做索引压缩
防止写丢失:
问题定义: 两个事务同时读取数据,进行更新,再将数据写回,其中一个事务的写结果被另一个覆盖. 比如计数器更新
解决方法:
- 原子化更新: update table set value = value + 1 where id = 1
- 显式加锁: select ... for update
- 自动写丢失检测, 取消并重试冲突事务 (PostgreSQL’s repeatable read, Oracle’s serializable, and SQL Server’s snapshot isolation) (MySQL repeatable read 不支持)
- 比较写入 (CAS): update table set value = V2 where id = 1 and value = V1 (如果数据库支持从旧快照读则无效)
在集群复制场景下(多主复制或无主复制): 比较写入方法不能正常工作, LAST WRITE WIN策略会造成写丢失. 原子化更新有效
防止写偏差:
冲突实体化 (materialize conflict): 将写冲突转化为一系列的主键锁. 比如创建预订房间记录(房间号+时间范围),并对预订记录加锁. 将并发控制处理渗漏到业务逻辑中,不推荐.
实际序列化执行 (actual serialization):
实现方式:
- 单线程按顺序处理事务. (REDIS, VOLTDB 等)
- 存储过程执行事务
- 数据分片: 对每个分片顺序执行事务 (跨分片事务性能有较大损失)
适合场景: - 事务影响的数据集不大,可存储在内存中
- 单个事务读写不多
- 存储过程无法实现用户交互式事务
- 对事务处理吞吐量要求不高
二阶段锁 (two phase locking):
实现方式:
- 事务读数据时必须获得读锁(共享模式),多个读锁可共存,但是如果有写锁,读操作必须等待
- 事务写数据时必须获得写锁(排他模式),如果有其他锁(读或写), 写操作必须等待
- 事务先读后写, 读锁必须升级为写锁
- 锁必须被持有直到事务结束
缺点:
- 并发度下降导致吞吐量下降
- 执行延迟不可预测
- 死锁更加容易发生
前提锁 (predicate lock):
锁符合查询条件的对象,读写锁获取和二阶段锁类似
索引范围锁 (index range lock):
比predicate lock范围大,不精确匹配所有查询条件而匹配其中某个索引查询,如下午1:00-2:00对某个房间的预订. 直接对房间号加锁或者对时间范围加锁.
如果没有找到合适的索引,对全表加共享锁.
可序列化快照分离 (serializable snapshot isolation):
- 兼顾性能和序列性
- 采取乐观并发控制,在事务提交的时候检查冲突,然后取消事务并重试
- 乐观并发控制适合数据争用不太多的场景,否则会导致大量事务取消重试
实现方式:
根据事务开始前预设定的前提决定事务是否能提交, (比如每个房间/时间段预订数<=1). 然后检查查询结果是否被改变.
事务1读到一个旧的MVCC版本(数据已经被另一个事务修改但未提交), 再提交时发现新的MVCC版本已经被事务2提交, 并且后续事务1也对这个数据进行了修改. 则事务1必须回滚
事务1开始后事务2修改了数据. 并早于事务2提交. 如果后续事务1也对这个数据进行了修改. 则事务1必须回滚
总结
个人阅读事务章节的一点感想:
事务控制无非两种: 乐观: 通过冲突检测决定事务是否回滚. 悲观: 通过加锁阻止并发发生.
从另一个维度看, 不同的隔离级别决定了两个事务对于数据库的读写操作是否阻塞,隔离级别越低,阻塞的场景越少
任何隔离级别一个事务的读操作不会阻塞读
读阻塞写, 写阻塞读 (Serializable级别 -- two phase locking)
写阻塞读, 但读不阻塞写
写阻塞写 ()