# 事务

:transaction ,指用户对数据库的一次操作。

  • 一个事务可能是一个简单的读操作、写操作,也可能包含多个命令。

# ACID

为了避免执行事务时出错,数据库应该实现事务的 ACID 四种特性。

  • 原子性(Atomicity)
    • :一个事务是像原子一样的基本单元,不能拆分。要么完成,要么不完成,不存在其它状态。
    • 为了保证原子性,当一个事务执行失败时,数据库应该恢复到执行之前的原状态。
  • 一致性(Consistency)
    • :当多个用户同时访问数据库时,读取到的数据是完全相同的。
    • 通过并发锁可以让并发事务变成串行事务,保证一致性,但会降低并发性能。
    • 数据库本身可以阻止用户查看尚未同步的数据,从而保证一致性。
  • 隔离性(Isolation)
    • :各个事务之间相互隔离。当一个事务执行失败时,不会影响其它事务。
    • 隔离执行失败的事务时,有两种策略:乐观、悲观。
  • 持久性(Durability)
    • :一个成功完成的事务会对数据库造成持久的影响(主要是指写入的数据不会丢失),只可能被后续事务的影响覆盖。

# 并发事务

# 常见问题

多个事务同时操作同一个数据时,可能引发以下并发问题:

  • 丢失更新(Lost Update)
    • :多个事务同时修改数据时,后提交的事务覆盖了先提交的事务的修改。
    • 第一类情况:事务 A 读取到数据的值为 10 ,加一之后提交为 11 。同时事务 B 也读取到该数据的值为 10 ,经过任意修改之后又回滚为 10 ,并且在事务 A 之后提交,使得该数据最终保存为 10 。因此事务 A 像没做出修改一样。
    • 第二类情况:事务 A 读取到数据的值为 10 ,加一之后提交为 11 。同时事务 B 也读取到该数据的值为 10 ,减一之后提交为 9 ,并且在事务 A 之后提交,使得该数据最终保存为 9 。因此事务 A 的修改结果出错。
  • 脏读(Dirty Read)
    • :如果事务 A 修改了数据但尚未提交时,其它事务读取到该数据修改之后的值,但事务 A 放弃提交,将数据回滚到之前的值,则其它事务读取到的数据就是错的。
  • 不可重复读(Unrepeatable Read)
    • :如果事务 A 对同一条数据进行了两次读操作,但在这两次读操作之间,该数据被其它事务修改了,则会导致事务 A 两次读取到的数据不同。
  • 幻读(Phantom Read)
    • :如果事务 A 对同一范围的数据(比如 id<100 的所有数据)进行了两次读操作,但在这两次读操作之间,该范围新增了数据,则会导致事务 A 第二次读取到突然出现的新数据。
    • 幻读与不可重复读相似,但不是影响一条数据,而是影响某一范围的数据。

# 隔离级别

SQL 标准定义了 4 种并发事务的隔离级别,从低到高如下:

  • 读取未提交内容(Read Uncommitted)
    • :当事务修改了数据但尚未提交时,允许其它事务读取该数据修改之后的值。
    • 这种隔离级别最不安全,能避免 "丢失更新" 的问题,但不能避免 "脏读" 、 "不可重复读" 、 "幻读" 的问题。
  • 读取已提交内容(Read Committed)
    • :当事务修改了数据且提交之后,才允许其它事务读取该数据修改之后的值,否则读取到的是修改之前的值。
    • 不能避免 "不可重复读" 、 "幻读" 的问题。
    • 这是大部分数据库的默认隔离级别。
  • 可重复读(Repeatable Read)
    • :保证一个事务在执行期间,对同一数据的多次读取结果相同。
    • 不能避免 "幻读" 的问题。
    • 这是 MySQL 的默认隔离级别,并且 InnoDB 引擎通过多版本并发控制(Mutil-Version Concurrency Control ,MVCC)解决了 "幻读" 的问题。
  • 可串行化(Serializable)
    • :当一个事务对数据进行写操作时,不允许其它事务对该数据进行读操作、写操作。(即加上排它锁)
    • 这种隔离级别最安全,但是事务的执行速度最慢。

# 数据库锁

为了避免并发事务产生冲突,一个事务可以在读写数据之前对数据加锁,限制其它事务对该数据的操作,等操作完数据之后再释放锁。

  • 使用场景:
    • 如果当前事务是读取数据,其它事务也只是读取数据,则一般不需要加锁。
    • 如果当前事务是读取数据,其它事务可能修改数据,则应该加锁来保护当前事务。
    • 如果当前事务是修改数据,则应该加锁来保护其它事务。
  • 如果数据库没有提供合适的锁机制,则需要用户自己编程,限制客户端的行为。
  • 数据库的权限、外键、锁等功能主要是用于限制用户的操作。但一般的数据库软件只能提供少量的限制功能,不能满足复杂的业务逻辑。
    • 应该尽量限制客户端的行为,只将合理的操作请求发送给数据库,从而降低数据库的复杂度、开销。

按严格程度分类:

  • 共享锁(Share Locks):又称为 S 锁、只读锁。
    • 允许其它事务读取该数据、加 S 锁,禁止其它事务修改该数据、加 X 锁。
  • 排它锁(Exclusive Locks):又称为 X 锁、写锁。
    • 禁止其它事务读取、修改该数据、加锁。
    • 例如:事务 A 对数据加 X 锁之后,其它事务必须要等待事务 A 释放锁,才能操作该数据。

按使用策略分类:

  • 悲观锁:每次读取、修改数据时都加锁。
    • 适用于经常修改数据的情况。
  • 乐观锁:仅修改数据时加锁。
    • 适用于很少修改数据的情况。

按控制范围分类:

  • 行级锁(row lock)
  • 表级锁(table lock)

# 封锁协议

:指 S 锁、X 锁的一些基本用法。

  • 一级封锁协议
    • :要求事务在修改数据之前必须加 X 锁,直到事务结束才释放锁。
    • 能避免 "丢失更新" 的问题。
  • 二级封锁协议
    • :在一级的基础上,要求事务在读取数据之前必须加 S 锁,直到读取结束才释放锁。
    • 能避免 "丢失更新" 、 "脏读" 的问题。
  • 三级封锁协议
    • :在二级的基础上,要求事务在读取数据之前必须加 S 锁,直到事务结束才释放锁。
    • 能避免 "丢失更新" 、 "脏读" 、 "不可重复读" 的问题。
  • 两段锁协议
    • :将事务的执行过程分为前后两个阶段,在前一个阶段只能加锁、不能释放锁,在后一个阶段只能释放锁、不能加锁。
    • 两段锁协议是实现 "可串行化" 隔离级别的充分条件。
    • 例如 MySQL 的 InnoDB 引擎采用两段锁协议,根据隔离级别自动加锁,并同时释放所有锁。

# 活锁、死锁

使用数据库锁时可能产生活锁、死锁的问题。

  • 活锁
    • 例:事务 A、B、C 先后请求对数据加锁,数据库系统先把锁分配给事务 A ,然后分配给较晚申请的事务 C ,导致事务 B 多等待了一段时间。
    • 解决方法:
      • 要求数据库系统按先来后到的顺序分配锁。
  • 死锁
    • 例:事务 A 对数据 m 加锁之后,还申请对数据 n 加锁,事务 B 对数据 n 加锁之后,还申请对数据 m 加锁。这会导致两个事务都在等待获取对方数据的锁,一直等待下去,可能导致客户端卡死、不能继续执行事务。
    • 解决方法:
      • 如果总共需要锁住的数据不多,可以让每个事务都一次性申请所有锁。
      • 如果总共需要申请的锁不多,可以让每个事务都按特定顺序申请各个锁。
      • 检查事务的执行时间,如果超过一定时长,就可能是产生了死锁,需要人工排查。