事务

37 阅读8分钟

前言

文章主要参考的是《MySQL是怎样运行的--从根上理解MySQL》,之所以二次编辑,是为了增加部分自己理解的内容。

事务

背景

猫爷和狗哥是好朋友,他们到银行各自开设了一个账户,这样一来,两人在现实世界中拥有的资产就被记录在account表中了。比如狗哥现在有11元,猫爷现在有2元。对应的表格如下:

idnamebalance
1狗哥11
2猫爷2

起源

在某个特定的时刻,狗哥、猫爷在银行的资产是特定的值。这些特定的物理值是现实世界某个状态抽象后的体现。随着时间的流逝,狗哥、猫爷陆续向账户中存钱、取钱、转账等等。他们的每次操作都相当于现实世界中账户的一次状态变换。与现实世界状态转换对应的是数据库的变动。

我们引入下面一种情景:

有次猫爷急需用钱,急忙向狗哥借了10元。狗哥走向银行,操作ATM输入账号和转账金额,按下确认键......

对于数据库世界来说,则需要执行下面两条语句:

UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;

不巧的是,数据库仅执行了一条语句后,停电了。造成的后果就是狗哥的钱扣了,猫爷的钱没有增加,猫爷还是急需用钱。

怎么让猫爷摆脱现实的窘境呢?我们慢慢道来。

原子性(Atomicity)

在现实世界中,转账是一个不可分割的操作。要么转账成功,要么转账失败,不会存在中间状态(转了一半)。设计数据库的大叔把这种“要么不做,要么全做”的规则称为原子性。但是现实世界中的一个不可分割的操作可能对应数据库世界的若干条操作,数据库的一条操作也可能被分割为若干个步骤(比如像修改缓冲区内存数据,然后同步缓冲区数据到磁盘)。要命的是,任何一个时刻都有可能发生意想不到的异常,而使操作执行不下去。

为了保证数据库世界操作的原子性,设计数据库的大叔保证:如果在执行原子操作的过程中发生了错误,就把已经执行的操作恢复到执行之前的样子。

隔离性(Isolation)

在现实世界中,两次状态的改变应该是互不影响的。比如,狗哥向猫爷同时进行了两次金额为5元的转账,那么狗哥的账户肯定会少10元。但是对应到数据库世界中,事情又变得复杂了。为了简化问题,我们粗略地把狗哥向猫爷转账5元的过程描述为以下步骤:

  1. 读取狗哥账户余额到变量A中,简写为read(A);
  2. 将狗哥账户的余额减去转账金额,简写为A = A - 5;
  3. 将狗哥账户修改过的余额写到磁盘中,简写为write(A);
  4. 读取猫爷的账户余额到变量B中,简写为read(B);
  5. 将猫爷账户的余额加上转账金额,简写为B = B + 5;
  6. 将猫爷账户修改过的余额写到磁盘中,简写为write(B);

我们将狗哥向猫爷两次转账的操作称为T1和T2,在现实世界中T1和T2应该是没有关系的,可以先执行完T1,再执行T2,或者先执行完T2,再执行T1。理想状态的数据库操作如下图所示(T1或T2先执行,二者无交叉执行的操作):

sequenceDiagram
    autonumber
    participant T1 as Tx1
    participant DB as DB
    participant T2 as Tx2

    Note over DB: Init: A=11, B=2

    alt Case 1: Tx1 then Tx2
        %% --- Tx 1 First ---
        rect rgb(230, 240, 255)
        Note over T1, DB: Start Tx1
        T1->>DB: read(A)
        DB-->>T1: 11
        T1->>T1: A = 11 - 5
        T1->>DB: write(A, 6)
        T1->>DB: read(B)
        DB-->>T1: 2
        T1->>T1: B = 2 + 5
        T1->>DB: write(B, 7)
        Note over T1, DB: Commit Tx1
        end

        %% --- Tx 2 Second ---
        rect rgb(255, 240, 230)
        Note over T2, DB: Start Tx2
        T2->>DB: read(A)
        DB-->>T2: 6
        T2->>T2: A = 6 - 5
        T2->>DB: write(A, 1)
        T2->>DB: read(B)
        DB-->>T2: 7
        T2->>T2: B = 7 + 5
        T2->>DB: write(B, 12)
        Note over T2, DB: Commit Tx2
        end
        Note over DB: Final: A=1, B=12

    else Case 2: Tx2 then Tx1
        %% --- Tx 2 First ---
        rect rgb(255, 240, 230)
        Note over T2, DB: Start Tx2
        T2->>DB: read(A)
        DB-->>T2: 11
        T2->>T2: A = 11 - 5
        T2->>DB: write(A, 6)
        T2->>DB: read(B)
        DB-->>T2: 2
        T2->>T2: B = 2 + 5
        T2->>DB: write(B, 7)
        Note over T2, DB: Commit Tx2
        end

        %% --- Tx 1 Second ---
        rect rgb(230, 240, 255)
        Note over T1, DB: Start Tx1
        T1->>DB: read(A)
        DB-->>T1: 6
        T1->>T1: A = 6 - 5
        T1->>DB: write(A, 1)
        T1->>DB: read(B)
        DB-->>T1: 7
        T1->>T1: B = 7 + 5
        T1->>DB: write(B, 12)
        Note over T1, DB: Commit Tx1
        end
        Note over DB: Final: A=1, B=12
    end

但是很不幸,在真实的数据库中, T1和T2可能交替执行,如下图所示:

sequenceDiagram
    autonumber
    participant T1 as Tx1
    participant DB as DB
    participant T2 as Tx2

    Note over DB: Init: A=11, B=2

    %% 1. T1 读取 A
    T1->>DB: R(A)
    DB-->>T1: 11
    
    %% 2. T2 读取 A (读到了和 T1 一样的旧值)
    T2->>DB: R(A)
    DB-->>T2: 11

    %% 3-4. T1 更新 A
    Note right of T1: A = 11 - 5
    T1->>DB: W(A, 6)
    Note right of DB: DB A=6

    %% 5-7. T1 更新 B
    T1->>DB: R(B)
    DB-->>T1: 2
    Note right of T1: B = 2 + 5
    T1->>DB: W(B, 7)
    Note right of DB: DB B=7

    %% 8-9. T2 更新 A (关键问题点)
    Note left of T2: A = 11 - 5 (Local A is 11)
    T2->>DB: W(A, 6)
    Note over DB: 丢失修改! <br/>Tx1's update is overwritten

    %% 10-12. T2 更新 B
    T2->>DB: R(B)
    DB-->>T2: 7
    Note left of T2: B = 7 + 5 (Read committed B)
    T2->>DB: W(B, 12)
    Note right of DB: DB B=12

    Note over DB: Final: A=6 (Error), B=12 (Correct)

按照上图执行顺序来转账的话,最终狗哥的账户还剩6元钱,相当于只扣了5元,但是猫爷的账户却成了12元钱,相当于多了10元钱。这样一来,银行就要亏死啦。

所以对于现实世界中状态转换对应的数据库操作来说,不仅要保证这些操作以原子性地方式执行完成,而且要保证其他的状态转换不会影响到本次状态转换,这个规则称为隔离性。

一致性(Consistency)

我们生活中有很多客观规则,比如学生要听老师的话,高考分数是0-750,房价不能为负等等等等。

只有符合这些约束的数据才是有效的,比如有个小孩说他的高考成绩是1000000分,你一听就知道是假的。

数据库世界是现实世界的映射,现实世界存在的约束在数据库世界中也会存在。如果数据库中所有的数据全部符合现实世界的约束,我们就说数据是一致的。

如何保证数据一致性呢?

  • 数据库

    例如:MySQL本身提供了主键、唯一索引、外键,还可以声明某个列为NOT NULL。比如现实世界要求记录学生的学号,此时就可以将唯一性索引建立在该列上,数据库如果发现该列数据重复则会报错并拒绝插入。

    上面的举例只是为了说明数据库本身提供的功能可以用于满足现实世界的规则。辅助实现一致性。

  • 程序员

    数据库提供的功能用于辅助实现数据一致性是可以的,但是完全使用数据库实现数据一致性是不现实的。因为现实世界的规则五花八门,数据库提供的功能不一定能够完全实现该规则。另外使用数据库的约束过多会带来性能的问题。

    下面我们仅讨论性能问题。

    比如我们为account表建立来了一个触发器,每当插入或更新记录时都会校验balance列的值是否大于0,这会影响插入或更新的速度。仅仅校验一行记录倒是没有什么。但是有的一致性需求简直“变态”。比如,银行会建立一张代表账单的表,里面记录了每个账户的每一笔交易,而且每笔交易完成后,都要保证整个系统的余额等于所有账户的收入减去所有账户的支出。如果在数据库层面实现这个一致性需求的话,表单中有几亿条记录的时候,仅仅这个校验可能都要花费好几个小时。

持久性(Durability)

当现实世界的一个状态转换完成之后,这个转换的结果将永久保留,这个规则被设计数据库的大叔称为持久性。比如,狗哥向猫爷转账,ATM机提示转账成功时,就意味着这次账户的状态转换完成了,狗哥可以拔卡走狗了。如果狗哥走之后,银行又把这次转账操作给撤销掉,恢复到没有转账之前的样子,猫爷就惨了,所以持久性是很重要的。

现实世界的持久性映射到数据库世界就是指该次转换对应的数据库操作所修改的数据保存在磁盘中了。

事务的概念

设计数据库的大叔把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称为事务(Transaction)。

我们现在知道,事务是一个抽象的概念,它其实对应着一个或多个数据库操作。设计数据库的大叔根据这些操作所执行的不同阶段把事务大致划分为了下面几个状态。

  • 活动的(active):事务对应的数据库操作正在执行过程中时,我们就说该事务处于活动的状态。
  • 部分提交的(partially committed): 当事务中的最后一个操作执行完成,但是由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们说该事务处于部门提交的状态。
  • 失败的(failed): 当事务处于活动的状态或者部分提交的状态时,可能遇到了某些异常而无法继续执行,或者认为停止了当前事务的执行,我们说该事务处于失败的状态。
  • 中止的(aborted): 如果事务执行了半截而变成失败的状态,比如前面唠叨的狗哥向猫爷转账的事务,当狗哥账户的钱被扣除,但是猫爷账户的钱没有增加时遇到了错误,导致当前事务出在了失败的状态,那么就需要把已经修改的狗哥的账户余额调整为未转账之前的金额。换句话说就是要撤销失败事务对当前数据库的影响。这个撤销过程用书面一些的话来讲就是:回滚。当回滚操作结束后,也就是数据库回到了事务开始的状态,我们就说该事务处于中止的状态。
  • 提交的(commited): 当一个处于部分提交状态的事务将修改过的数据都刷新到磁盘中之后,我们就可以说该事务处于提交的状态。

参考文章

  • 小孩子1949.MySQL是怎样运行的--从根上理解MySQL[M].北京:人民邮电出版社,2020:294-300