1.为什么要有事务
由于应用程序和数据库交互,可能出现各种各样的问题,但是数据库通过提供事务的能力使得交互的结果相对收敛为比较简单的两种情况(全部成功 or 什么也没做),所以应用程序可以比较简单地处理各种错误场景,包括但不限于:
- 应用程序与数据库交互中,数据库完成部分写入后崩溃
- 应用程序完成交互后,数据库也写入完成,一段时间后崩溃
- 多个应用程序与数据库并发交互过程中,操作结果的不确定性
对于中止的事务,错误处理可以是跑出异常信息并提示用户,或者重试。用户虽然得到了错误提示,但是之前的输入都会被抛弃,这绝不应该,支持安全的重试才是重点,但是重试也有以下问题:
- 重试可能产生重复数据
- 如果异常是超负荷导致,重试只会加重负载
- 非临时故障,重试没用
- 应用程序重试过程中也失败
- 重试有数据库之外的副作用,例如给用户重复发送邮件
2.事务的特性(ACID)
2.1 原子性
应用程序发起的一个包含一个或者多个写操作的请求时,要么都写入成功,如果发生故障了要中止后续操作,并撤销之前的变更。个对象的写操作也是需要原子性的,例如像某个字段写入20KB的数据,写入10KB的时候故障了,也要撤销。应该叫做可中止性更合理。
2.2 一致性
一致性是应用程序控制事务的范围以及包含的操作,其实不是数据库来管理的。
2.3 隔离性
解决并发问题,多个应用程序并发访问数据库,一个事务中的写操作结果对其他事务是否可见,什么时候可见。
2.4 持久性
事务提交后,发生故障,事务写入的数据也不会消失。一定程度上的保障。主要通过将数据写入非易失存储,支持远程复制的数据库,则将数据复制到其他副本节点。
3.不同场景的并发问题
读-提交隔离级别解决的问题(脏读,脏写)
3.1 脏读
case1(单对象) t1:事务A,B开启事务 t2: 事务A写入数据1 t3: 事务B读取数据,读到数据1 t4: 事务A中止,回滚
case2(多对象) 数据表中有两份数据,一份是记录未读邮件数目,还有一个是邮件 t1:事务A,B开启事务 t2: 事务A写入新的邮件 t3: 事务B读取未读邮件数目 t4: 事务A更新未读邮件数目 t5: 事务A提交
在上述示例中,事务A和B都访问同样的数据,且有个事务A对数据做了写操作,事务B读取到了事务A未提交的数据
解决方式
数据库对数据同时维护新值和旧值
3.2 脏写
case3(多对象) 买车的时候需要更新车主表和发票表 d=0 t1:事务A,B开启事务 t2: 事务A将车1的车主更新为Alice t3: 事务B将车1的车主更新为Bob t4: 事务B将车1的发票1更新为Bob t5: 事务A将车1的发票1更新为Alice 结果车主是Alice,发票却是Alice的
在上述示例中,事务A和B都有多个写操作,事务A和B都访问同样的数据,且事务B覆盖了事务A未提交的数据
解决方式
事务想要修改某个对象时,要获取对象上的锁,获取成功才能修改,直到事务提交或者回滚才能释放。
快照级别隔离解决的问题(读倾斜)
3.3 读倾斜(不可重复读)
case4(多对象) 转账,从自己的一张银行卡A转到另一张银行卡B 事务A转账,事务B去读数据 t1: 事务B查看银行卡A 余额 t2: 银行卡A扣钱 t3:银行卡B加钱 t4: 事务B查看银行卡B 余额 事务B发现两个账户的钱总额多了
其他场景: 备份场景:数据库备份时间长,期间还会写入数据,备份数据里同时包含新老数据 分析场景:不同的时间点分析结果不一样,不可追溯。
特征:只读事务遇到并发写
解决方式
MVCC 可见数据:
- 事务开始时,数据写入已提交的对象
- 数据没有被标记删除;或者被标记删除,但是事务开始时,删除事务还未提交。
3.4 更新丢失
特征(read-modify-write):
- 应用程序读取数据
- 应用程序修改
- 应用程序写入修改后的数据
解决方法:
- 原子写入 UPDATE counters SET value = value + 1 WHERE key = 'foo'; 2.显示加锁 应用程序显示锁定待更新的对象 3.冲突解决 对于无主节点或者多主节点的多副本集群,需要考虑冲突合并,常用的是最后写入获胜(LWW).
3.5 写倾斜(幻读)
case5 在一个值班系统中,要求每天至少一位医生值班,当前是Alice和Bob值班,但是他们都要请假 t1:事务A,B启动 t2:事务A,B同时查询当前值班的医生的数目,都返回2 t3:事务A更新Alice的值班状态为false,事务B更新Bob的值班状态为false t4:事务A,B提交事务 最终当天两个人都提交成功,导致没人值班
其他场景:
- 会议室预订系统,同一时间同一个会议室不能被预定两次,当有人想要预定时,先查指定时间指定会议室是否有预定记录,没有则预定
- 声明用户名,网站往往要求有唯一的用户名,创建时先查询是否存在该用户名,没有责占用
特征:两个事务更新同一组对象,但是更新的对象不同
解决方式:
- 实体化冲突:构造时间-房间表,一行对应某个房间的特定时间段(15分钟)
- 串行化:
- 严格按照串行执行,往往采用存储过程封装事务(如redis的lua写的脚本)
- 事务要短,只有少量的读写操作
- 事务所需的数据都在内存中
- 两阶段锁定
- 特征:
- 每个对象都有读写锁
- 读取加共享锁,修改需要独占锁
- 读后修改,共享锁升级独占锁
- 事务提交后释放锁
- 实现
- 谓词锁
- 不属于某个特定的对象,属于满足条件的所有查询对象
- 可以解决那种尚不存在,马上会被插入的对象
- 索引区间锁
-
简化的谓词锁,将所得范围扩大,当时更简单
-
- 谓词锁
- 特征:
- 乐观并发控制
- 主要思想:务可能冲突时,继续执行而不是中止。提交时检查是否发生了冲突。冲突则中止提交。
- 实现:
- 基于快照的可串行化
- 读取操作都基于一致性快照
- 增加了冲突检测算法
- 基于过期条件
- 是否读取了过期的MVCC对象(读取前是否已有未提交的写入)
- 检测写入是否影响之后的读取
- 基于过期条件
- 基于快照的可串行化
- 严格按照串行执行,往往采用存储过程封装事务(如redis的lua写的脚本)