では、ゲームを始めましょう
GO FOR IT !

事务

1. 事务是什么

事务是针对数据库的一系列操作条件下保证数据一致性与可靠性的概念, 定义了事务是一个不可分割的工作单元, 包含一系列数据库操作, 这些操作要么全部执行成功, 要么失败后全部回滚.

2. 事务解决什么问题

考虑一个场景:
账户转账任务, 从A账户转账100元到B账户:

  1. 第一步, 从A账户扣款100元
  2. 第二步, 向B账户添加100元

如果没有采用事务, 执行完第一步操作后网络或程序崩溃, 导致A账户扣款100元, 但是B账户没有添加100元, 这令整个账户系统中凭空消失了100元余额.

3. 事务如何解决问题

事务保证了事务中的一系列数据库操作, 要么全部执行成功, 要么失败全部回滚.

针对上述场景, 如果事务刚执行完第一步后网络或程序崩溃, 此时操作还未提交, A账户的余额未变更; 如果事务提交后, 操作B账户余额失败, 事务会通过undo log或者版本控制进行回滚.

事务通常通过以下步骤来执行:

  1. 开始事务(Begin Transaction):
    • 事务开始, 数据库记录事务的初始状态.
  2. 执行操作(Execute Operations):
    • 进行一系列的数据库操作(如插入, 更新, 删除等), 这些操作暂时不会提交到数据库.
  3. 检查一致性(Check Consistency):
    • 检查所有操作是否成功执行, 确保数据仍然满足一致性约束.
  4. 提交事务(Commit Transaction):
    • 如果所有操作都成功, 提交事务. 此时, 所有操作的结果被永久性地写入数据库.
  5. 回滚事务(Rollback Transaction):
    • 如果有任何操作失败, 回滚事务. 此时, 撤销所有已经执行的操作, 将数据库恢复到事务开始前的状态.

ACID属性

1. ACID是什么

ACID是事务具有的4个关键特性, 也是保证事务正确性的4个要求, 包含原子性, 一致性, 隔离性, 持久性.

2. ACID具体内容

2.1. 原子性(Atomicity)

事务的所有操作要么全部完成, 要么全部不完成. 事务一旦开始, 要么完全成功, 要么在出错时完全撤销, 系统不会处于不完整状态.

如果在事务执行过程中出现错误, 所有已执行的操作会被撤销, 数据回滚到事务开始前的状态.

2.2. 一致性(Consistency)

事务的一致性与"分布式数据一致性"含义不同:
事务的一致性强调处理前后结果应与需求预期保持一致, 是一种逻辑一致, 人为定义的规则.

事务在执行前和执行后, 数据库都必须保持一致的状态. 执行事务后, 所有的业务规则, 约束和触发器都必须被满足.

一致性确保事务执行后, 数据库从一个合法状态转移到另一个合法状态.

通过原子性, 持久性, 隔离性最终实现数据一致性.

2.3. 隔离性(Isolation)

事务的执行过程与其他事务隔离, 不会被其他事务干. 即使有多个事务同时执行, 它们的中间状态相互之间不可见, 也不会相互影响.

隔离性是一个最常放松的一个, 随着数据库隔离级别的提高, 数据的并发能力就会有所下降. 如何在并发性和隔离性之间做一个很好的权衡就成了一个至关重要的问题.

隔离性通过两段式锁协议或三级封锁协议来实现, 划分出多种隔离级别, 常见的四种隔离级别为: 读未提交, 读提交, 可重复读和可序列化.

2.4. 持久性(Durability)

一旦事务提交, 其对数据库的修改将永久保存, 即使系统崩溃也不会丢失.

数据库通过日志或其它机制来保证数据的持久性, 即事务的结果被永久性地写入存储设备.

并发一致性问题

1. 并发一致性问题是什么

并发一致性问题指的是在并发环境下, 由于事务的隔离性很难保证, 会出现4种数据一致性问题.

2. 并发一致性问题具体内容

2.1. 丢失修改

一个事务对数据进行了修改, 在事务提交之前, 另一个事务对同一条数据进行了修改, 覆盖了之前的修改;

考虑一个场景:
假设有一个网上购物平台, 有两个用户A和B, 他们几乎同时购买最后一件商品:

  1. 用户A的事务:
    1. 检查库存数量.
    2. 扣减库存.
    3. 确认订单.
  2. 用户B的事务:
    1. 检查库存数量.
    2. 扣减库存.
    3. 确认订单.
      不采用两段锁协议时可能发生如下情况:
  3. 用户A对库存数量加锁, 进行读取, 读到剩余数量为1, 读取完成后释放锁.
  4. 紧接着用户B拿到库存数量的锁, 读取剩余数量也为1, 读完后释放锁.
  5. 然后用户A和用户B都可以执行扣减库存, 因为都读取到剩余数量为1.
  6. 最后库存数量被减到-1; 或者库存被覆盖, 虽然显示为0, 但是A和B都下单成功.

2.2. 脏读(Dirty Read)

一个事务读取了被另一个事务修改, 但未提交(回滚)的数据, 造成两个事务得到的数据不一致.

考虑一个场景:
A和B两个企业用户, 企业拥有共享余额, 该企业中用户可以共同进行充值或消费:

  1. 企业余额初始为0.
  2. 用户A进行充值操作, 充入100元, 企业余额增加为100元, 事务还没提交, 等待A个人余额扣款.
  3. 用户B此时想要使用企业余额消费50元, 读取到企业余额为100元, 充足, 发起扣款50元.
  4. 于此同时, 用户A个人余额扣款失败, 充值失败, 事务未提交, 数据回滚, 企业余额变回0元.
  5. 但是用户B发起了扣款50元的操作, 企业余额被减少到-50元.

2.3. 不可重复读(Nonrepeatable Read)

在同一个事务中, 某查询操作在一个时间读取某一行数据和之后一个时间读取该行数据, 发现数据已经发生修改(针对update操作)

考虑一个场景:
A和B两个企业用户, 企业拥有共享余额, 该企业中用户可以共同充值或消费:

  1. 企业余额初始为100元.
  2. 用户A想要进行消费, 第一步查询企业余额, 确定能够消费的上限.
  3. 用户B此时已经发起一个扣款操作, 消费100元, 令企业余额减为0.
  4. 用户A认为自己最多能消费100元, 发起了一个50元的扣款请求.
  5. 系统规定为了避免扣款透支, 扣款前会进行查询, 此时再次读取发现企业余额为0.

用户A的消费操作因为两次读取同一个数据发现数据被修改而被迫失败.

2.4. 幻读(Phantom Read)

当同一查询多次执行时, 由于其它事务在这个数据范围内执行插入操作, 会导致每次返回的结果集不同. 和不可重复读的区别: 针对的是一个数据整体/范围, 并且针对insert操作)

考虑一个场景:
企业要给员工发放工资, 需要统计出所有员工工资, 对企业账户进行扣款, 以及员工账户进行加款, 企业原本共10名员工, 每个员工固定工资3k:

  1. 事务A: 查询所有员工, 统计出总额10×3k=3w10\times 3k=3w, 对企业账户扣款3w.
  2. 此时另一个事务B: 新增两名员工, 工资同样3k, 更新员工表.
  3. 事务A: 对全体员工发放3k的工资, 查询所有员工, 读取到12名员工, 每人3k工资.

因为事务A查询员工返回的结果集改变, 员工得到的工资总额为12×3k=3.6w12\times 3k=3.6w.

对账户系统而言, 员工账户到账总额超出企业账户扣款, 发生错误.

事务的数据一致性保证

1. 两段锁协议

1.1. 两段锁协议是什么

两段锁协议(Two-Phase Locking, 2PL)是一种通过将事务执行过程分为两个阶段以确保数据库事务之间隔离性的方法, 用来防止并发一致性问题, 保证事务调度串行化.

1.2. 两段锁协议解决什么问题

两段锁协议没有规定锁类型, 采用最严格的表级排他锁, 能够解决全部并发一致性问题:

  • [[事务#2.1. 丢失修改]].
  • [[事务#2.2. 脏读 (Dirty Read)]].
  • [[事务#2.3. 不可重复读(Nonrepeatable Read)]].
  • [[事务#2.4. 幻读(Phantom Read)]].

1.3. 两段锁协议如何解决问题

两段锁协议规定事务执行过程分为"扩展阶段"与"收缩阶段":

  • 扩展阶段(Growing Phase): 事务申请和获取所有需要的锁, 在这个阶段不能释放任何锁.
  • 收缩阶段(Shrinking Phase): 扩展阶段结束后, 释放申请的所有锁, 不能再申请新的锁.

两段锁协议保证了在事务提交以前, 所有所需的数据都无法被其他事务进行读取或修改.

  • 丢失修改: 事务提交前其他事务无法修改当前事务占有的数据.
  • 脏读: 事务提交前其他事务无法读取当前事务修改的数据.
  • 不可重复读: 事务提交前其他事务无法修改当前事务占有的数据, 数据不可能发生变化.
  • 幻读: 事务提交前其他事务无法修改当前事务占有的数据范围, 数据不可能发生变化.

两段锁协议的两个阶段并没有限制对数据的操作, 拿到锁之后就可以进行进行数据的读写, 例如在扩展阶段的中途, 读写已经拿到锁的数据; 或者在收缩阶段的中途, 读写还未释放锁的数据.

如果事务遵循两段锁协议, 那么它们的并发调度是可串行化的: 两段锁是可序列化的充分条件, 但不是必要条件.

采用两段锁协议也有可能产生死锁, 这是因为每个事务都不能及时解除被它封锁的数据, 可能会导致多个事务互相都要求对方已封锁的数据不能继续运行.

2. 三级封锁协议

2.1. 三级封锁协议是什么

三级封锁协议是对两段锁协议的细分讨论, 划分了三级封锁级别, 实现比两段锁协议更细粒度的并发控制, 可以根据需求选择不同级别的封锁方式以换取并发性能.

2.2. 三级封锁协议解决什么问题

2.2.1. 一级封锁协议

可以解决丢失修改问题: [[事务#2.1. 丢失修改]].

2.2.2. 二级封锁协议

可以解决脏读问题: [[事务#2.2. 脏读 (Dirty Read)]].

2.2.3. 三级封锁协议

可以解决不可重复读问题: [[事务#2.3. 不可重复读(Nonrepeatable Read)]].
使用表级锁可以解决幻读问题: [[事务#2.4. 幻读(Phantom Read)]].

2.3. 三级封锁协议如何解决问题

2.3.1. 一级封锁协议

事务修改数据之前必须先加X锁(排他锁), 直到事务结束才释放.

一级封锁协议类似于两段锁协议, 但是仅规定了修改数据需要加锁, 没有对数据读取加以限制.

X锁(排他锁)不能与其他锁共存, 无法抢占加了S锁的数据, 加了X锁后其他事务无法再加S锁.

通过在事务期间持续持有排他锁, 避免了其他事务对当前事务占有数据进行修改, 避免了事务提交前数据修改被其他事务修改覆盖.

2.3.2. 二级封锁协议

在一级封锁协议基础上, 事务在读取数据前必须先加S锁(共享锁), 读取完成后即可释放.

S锁(共享锁)允许与其他事务的S锁共存, 允许对数据进行读取, 但是不能修改数据.

如果有其他事务想要修改数据, 就意味着已经对该数据添加了X锁, 当前事务就无法添加S锁, 也就无法读取数据, 避免读取到脏数据. 反过来说, 当前事务能够添加S锁, 说明没有其他事务准备对该数据进行修改, 指导释放S锁之前, 其他事务也无法添加X锁.

2.3.3. 三级封锁协议

在二级封锁协议基础上, 事务在读取数据前必须先加S锁, 并且直到事务提交才能释放.

针对事务期间可能存在的需要多次读取同一数据的场景, 事务期间持续持有S锁, 避免了其他事务对数据进行修改, 实现了对不可重复读问题的解决.

此外, 使用表级锁, 可以避免所需数据范围内发生插入或修改, 解决幻读问题.

3. 四种隔离级别

3.1. 四种隔离级别是什么

对于事务的ACID性质, 隔离性一般常被弱化, 因为隔离性越高, 事务的调度并发性能越低.

所以往往要在隔离性与并发性能之间进行权衡, 因此数据库系统划分出多种隔离级别, 用来描述事务的隔离性程度.

常见的方式将隔离级别划分为4种: 未提交读, 已提交读, 可重复读, 可序列化.

3.2. 四种隔离级别具体内容

3.2.1. 未提交读 (Read Uncommitted)

未提交读描述为事务能够读取其他事务还未提交的修改, 是最低的隔离级别.

一般而言, 丢失修改是数据库系统完全不可接受的, 所以实际场景种数据库至少采用了一级封锁协议保证不会发生丢失修改.

而未提交读则是在不存在丢失修改的基础上, 什么也不另外规定, 允许事务读取其他事务未提交的修改, 留下导致"脏读"的隐患, 是最低的隔离级别.

3.2.2. 已提交读 (Read Committed)

已提交读描述为事务只能读取到其他已提交事务的数据, 可以避免脏读问题.

已提交读是通过二级封锁协议实现的, 只有其他事务提交后才会释放X锁, 才能读取数据.

已提交读还存在"不可重复读"问题(同一事务内两次读取的数据不一致).

SQL Server, Oracle的默认级别为已提交读(read committed).

3.2.3. 可重复读 (Repeatable Read)

可重复读描述为事务可以多次读取同一数据且保持一致, 可以避免不可重复读问题,

可重复读是通过三级封锁协议实现的, 只有事务提交后才会释放S锁, 其他事务无法修改数据.

可重复度还存在“幻读”问题(事务前后读取的数据范围内条目增加).

MySQL的默认级别为可重复读(repeated read).

3.2.4. 可序列化 (Serializable)

可序列化描述为事务的调度可以实现序列化, 是最高级别的隔离, 可以避免幻读问题.

可序列化是通过三级封锁协议配合表级锁实现, 或者通过两段锁协议实现.

可序列化就解决了全部并发一致性问题, 但是事务调度序列化, 并发性能最差, 一般不使用.

总访问量 访问人数