🪟 第15章 事务
📄 一句话概括

跨两个库就没有单库事务了:没有一个「记账的人」知道两边都改了什么。 两阶段提交补上这个角色:准备阶段把活干完但不提交,提交阶段才真正落盘。 最难受的是「准备好了、结论没来」——参与者攥着锁等一句话,那句话可能永远不来。

📝 本章和前面几章的关系

15.1~15.4 讲的是一个库内部的事务。 这一节跨出去:两个库、甚至两个服务之间怎么保证一致。 教材 15.5 分三小节:分布式事务处理、分布式事务的步骤、两阶段提交, 「小结」和「实践与练习」按惯例并进本篇末尾。 沙箱没有 MS DTC,也没有链接服务器,所以这一节的演示是用 Python 复刻的语义模型: 两个真的 SQLite 文件库当参与者、日志真的写成文件、三种结局真的跑出来。 复刻的是「流程」,不是 DTC 的实现细节。

一、为什么跨库就没有事务了

单库事务能成,是因为只有一个「记账的人」:它清楚谁改了、能不能撤、日志写到哪了。 跨两个库时没有这个角色——订单库提交了、库存库失败了,谁来负责把订单撤掉? 两阶段提交(2PC)就是这个角色的最小形态:一个协调者 + 若干参与者。 协调者不存数据,只负责「问一遍、再统一发话」。

阶段 协调者做什么 参与者做什么
准备(prepare) 问每个参与者「能提交吗」 做完全部改动、写好日志、但先不提交
提交(commit) 全都答应了才发 commit 真正提交,回一个「好了」
中止(abort) 有一个不答应就发 abort 回滚,回一个「撤了」

关键在「准备阶段就把活干完,只是不提交」。 这样第二阶段只剩「提交」或「回滚」两个动作,不会出现「中途才发现干不了」的尴尬。 这也解释了它为什么慢: 从准备到提交, 参与者一直攥着锁——锁的时间等于「最慢那个参与者的响应时间」。 参与方越多、越慢,锁得越久。

二、场景一:两边都答应,全部提交

两个库各有 100 件库存,跨库扣 30:

对象 结果
协调者结论 提交
siteA 库存 70
siteB 库存 70
两边日志最后一行 tx1 COMMIT / tx1 COMMIT

两个库都扣了 30,一致。 但要注意顺序: 协调者先把「决定提交」写进自己的日志,再去通知参与者。 这一步很关键。 如果它先通知、后记日志, 通知到一半自己崩了,重启后就不知道该怎么办——参与者来问「该提交还是回滚」,它自己也没答案。 所以 2PC 的日志顺序是有讲究的:先记决定,再执行。 这跟单库事务「先写日志、再改数据」是同一个道理——日志是唯一能穿越崩溃的东西。

三、场景二:有一个不答应,全局回滚

这次 siteB 只有 10 件,扣 30 会变成负数:

对象 结果
协调者结论 中止
siteA 库存 100(退回去了)
siteB 库存 10
两边日志最后一行 tx2 ABORT / tx2 REFUSE

siteA 在准备阶段已经扣掉了 30,但结论是中止,所以又被回滚回去了。 这就是「准备阶段只干活不提交」的价值:只要还没提交,随时能撤。 注意两个参与的日志不一样: 一个记 ABORT(被通知回滚), 一个记 REFUSE(自己拒绝了)——它俩角色不同,日志当然不同。 这也是一种「一致」: 一个拒绝,全都回滚,数据回到起点,没有任何中间状态。

四、场景三:准备好了、结论没来

两个参与者都 prepare 成功了,协调者还没来得及写结论就崩了:

siteA.log:  tx3 PREPARE -30
siteB.log:  tx3 PREPARE -30
coord.log:  tx3 BEGIN          <- 没有 COMMIT,也没有 ABORT

这就是 2PC 最难受的时刻:参与者卡在「准备好了、但不知道该怎么办」。 它们攥着锁、等着协调者的下一句话——而那句话可能永远不会来。 恢复的做法: 重启后读协调者日志, 只看到 BEGIN、没有结论,就按「没结论即回滚」处理—— 两个参与者都 ABORT,库存回到 100 / 100,一致。 但要清楚这只是「运气好」的一种处理方式。 如果协调者在写完 COMMIT 之后崩、通知只到了一半, 参与者就会分成两派:一边提交了、一边回滚了,数据不一致。 这时候只能靠人工对账,或者让恢复程序反复重试直到两边都提交。 这就是 2PC 被叫「阻塞协议」的原因: 它在最坏情况下会把系统卡住,等一个可能永远不来的消息。

五、真系统里怎么选

做法 代价
2PC(MS DTC、XA) 强一致,但锁到第二阶段才放,吞吐低、怕协调者单点
最终一致(消息队列 + 重试) 不保证立刻一致,靠补偿和对账兜底
业务上避开跨库事务 最省事:能放一个库就放一个库

工程上最常见的选择是第三条:把「必须一起成功」的数据放进同一个库。 分库带来的好处是扩展性,代价就是「没有跨库事务」——这笔账要在设计阶段就算清。 第二条(最终一致)现在更流行,因为它不阻塞:先扣库存、发消息,失败了靠重试和补偿。 代价是要接受「一段时间内不一致」,而且补偿逻辑要写对(这是最难的部分)。 教材给的 SQL Server 写法长这样(本沙箱不执行,因为没有链接服务器):

BEGIN DISTRIBUTED TRANSACTION;
UPDATE linked_server.db.dbo.stock SET qty = qty - 30 WHERE id = 1;
COMMIT TRANSACTION;
GO

BEGIN DISTRIBUTED TRANSACTION 会自动把 MS DTC 拉进来当协调者。 它管的事跟你上面看到的模型一模一样:先问所有参与者,再统一发话。

六、动手 Demo:两阶段提交:三种结局,一个能跑的模型

这一段用两个真的 SQLite 文件库当参与者、日志写成文件, 把两阶段提交的三种结局都跑一遍:全提交、全回滚、以及「协调者崩在中间」。 这不是 MS DTC——事务上下文怎么传、日志格式长什么样,这里都没有; 但「准备—决定—通知」这套骨架是同一套。

import io, os, sqlite3


class Site(object):
    """参与者:库存库。日志写成文件。"""

    def wlog(self, rec):                  # 日志必须落盘(fsync)
        with io.open(self.log, u"a", encoding=u"utf-8") as f:
            f.write(rec + u"\n")
            f.flush()
            os.fsync(f.fileno())

    def prepare(self, txid, delta):
        self.wlog(u"%s PREPARE %d" % (txid, delta))
        self.c = sqlite3.connect(self.db, isolation_level=None)
        self.c.execute(u"BEGIN IMMEDIATE")
        self.c.execute(u"UPDATE stock SET qty = qty + ? WHERE id = 1", (delta,))
        left = self.c.execute(u"SELECT qty FROM stock WHERE id = 1").fetchone()[0]
        if left < 0:                      # 干不了:自己拒绝
            self.c.execute(u"ROLLBACK")
            self.wlog(u"%s REFUSE" % txid)
            return False
        return True                       # 干完了,但不提交

    def commit(self, txid):
        self.wlog(u"%s COMMIT" % txid)
        self.c.execute(u"COMMIT")


class Coord(object):
    def run(self, txid, sites, delta):
        self.wlog(u"%s BEGIN" % txid)
        ok = [s for s in sites if s.prepare(txid, delta)]
        if len(ok) < len(sites):          # 有一个不答应
            self.wlog(u"%s ABORT" % txid)
            for x in ok:
                x.abort(txid)
            return u"中止"
        self.wlog(u"%s COMMIT" % txid)    # 先记决定,再通知
        for s in sites:
            s.commit(txid)
        return u"提交"

实跑输出:

--------------------------------------------------------------------
分布式事务:两阶段提交,一个能跑的最小模型
--------------------------------------------------------------------


  ① 为什么跨库就没有事务了
  --------------------------------------------------------------------
  单库事务能成,是因为只有一个「记账的人」:它清楚谁改了、能不能撤。
  跨两个库时没有这个角色——订单库提交了、库存库失败了,谁来负责把订单撤掉?
  两阶段提交(2PC)就是这个角色的最小形态:一个协调者 + 若干参与者。

| 阶段 | 协调者做什么 | 参与者做什么 |
|---|---|---|
| 准备(prepare) | 问每个参与者「能提交吗」 | 做完全部改动、写好日志、但先不提交 |
| 提交(commit) | 全都答应了才发 commit | 真正提交,回一个「好了」 |
| 中止(abort) | 有一个不答应就发 abort | 回滚,回一个「撤了」 |

  关键在「准备阶段就把活干完,只是不提交」。 这样第二阶段只剩「提交」或「回滚」两个动作,
  不会出现「中途发现干不了」的尴尬。

  ② 参与者:写日志、干活、等指令
  --------------------------------------------------------------------
  这里是 Python 复刻的语义模型,真正跑在两个 SQLite 文件库上,日志写成文件。
  MS DTC 的细节(事务上下文怎么传、日志格式)没有,但两阶段的骨架是同一套。


  ③ 场景一:两边都答应,全部提交
  --------------------------------------------------------------------

  协调者结论:提交
  siteA 库存 70,siteB 库存 70
  两边日志最后一行:tx1 COMMIT / tx1 COMMIT
  两个库都扣了 30。 注意顺序:协调者先把「决定提交」写进自己的日志,再去通知参与者。
  这一步很关键——如果它先通知、后记日志,通知到一半自己崩了,重启后就不知道该怎么办了。

  ④ 场景二:有一个不答应,全局回滚
  --------------------------------------------------------------------

  协调者结论:中止
  siteA2 库存 100,siteB2 库存 10
  两边日志最后一行:tx2 ABORT / tx2 REFUSE
  siteA2 已经在准备阶段扣掉了 30,但结论是中止,所以又被回滚回去了。
  这就是「准备阶段只干活不提交」的价值:只要还没提交,随时能撤。

  ⑤ 场景三:协调者在准备完、通知前崩了
  --------------------------------------------------------------------

  两个参与者都准备好了,但协调者还没来得及写结论就「崩」了。
  此刻两个参与者的日志最后一行:tx3 PREPARE -30 / tx3 PREPARE -30
  这就是 2PC 最难受的时刻:参与者卡在「准备好了、但不知道该怎么办」。
  它们攥着锁、等着协调者的下一句话——而那句话可能永远不会来。

  恢复:重启后读协调者日志,只看到 tx3 BEGIN、没有 COMMIT
  于是按「没结论就回滚」处理:
  siteA3 库存 100,siteB3 库存 100
  两边日志最后一行:tx3 ABORT / tx3 ABORT
  回滚之后两边都没变,一致。 但要注意:这是「运气好」的一种处理方式。
  如果协调者在写完 COMMIT 之后崩、通知到一半,参与者就会分成两派——
  一边提交了、一边回滚了,数据不一致。 这时候只能靠人工对账,或者让恢复程序反复重试。

  ⑥ 所以真系统里怎么做
  --------------------------------------------------------------------

| 做法 | 代价 |
|---|---|
| 2PC(MS DTC、XA) | 强一致,但锁到第二阶段才放,吞吐低、怕协调者单点 |
| 最终一致(消息队列 + 重试) | 不保证立刻一致,靠补偿和对账兜底 |
| 业务上避开跨库事务 | 最省事:能放一个库就放一个库 |

  工程上最常见的选择是第三条:把「必须一起成功」的数据放进同一个库。
  分库带来的好处是扩展性,代价就是「没有跨库事务」——这笔账要在设计阶段就算清。

  [教材原码 · 本沙箱不执行]
  | BEGIN DISTRIBUTED TRANSACTION;
  | UPDATE linked_server.db.dbo.stock SET qty = qty - 30 WHERE id = 1;
  | COMMIT TRANSACTION;
  | GO

场景一跑出 70 / 70,场景二跑出 100 / 10,场景三跑出 100 / 100。 三个结果都是「一致」的——但一致的路径完全不同:一个是提交、一个是回滚、一个是恢复后回滚。 wlog 里那句 os.fsync 是整套东西的地基。 不刷盘的话, 「我先记下来了」这句话就是假的——断电之后日志没了,恢复程序什么都读不到。 场景二里 siteB 的日志是 REFUSE 而不是 ABORT。 它自己拒绝了,所以不需要被通知回滚;siteA 是被通知的,所以记 ABORT。 同一笔事务,两种角色,两份日志——这不矛盾。 场景三最值得琢磨: 参与者日志里只有 PREPARE,没有结论。 它没本事自己决定提交还是回滚——因为这个决定必须全局统一, 自己猜「大概该提交」就可能导致两个库不一致。

30 秒实验

  1. 把场景三改成「协调者写完 COMMIT 才崩」,恢复时按日志提交。 库存变成 70 / 70——同一份「崩在中间」的状态,按不同策略恢复,结果可以完全相反。 这就是 2PC 必须把日志顺序写对的原因。
  2. 给 prepare 里加上「随机延迟」再跑两个参与者,看总耗时。 总耗时约等于最慢那个参与者的准备时间——这就是 2PC 的短板:木桶效应。
  3. 想一想:如果业务是「下单扣库存」,你更愿意用 2PC 还是「先扣库存 + 消息重试」? 多数互联网业务选后者——宁可接受几分钟不一致,也不要让下单接口等两个库都点头。

常见误区

  1. 「分布式事务就是「开个开关」」。 开关是有的(BEGIN DISTRIBUTED TRANSACTION),但代价跟着来: 锁到第二阶段才放、吞吐掉、协调者是单点。 不是免费的。
  2. 「两阶段提交能保证一定一致」。 只能保证「正常情况下一致」。 协调者崩在通知中间,参与者会分裂成两派, 这时候要靠人工对账或者反复重试,协议本身救不了。
  3. 「最终一致就是「不保证一致」」。 它是「保证最终一致」——中间可以不一致,但必须有补偿机制把它拉回来。 没有补偿的「最终一致」就是「永远不一致」。
自测三问(点开对答案) 1. 两阶段提交的两个阶段各做什么? —— **准备阶段:每个参与者把活干完、写好日志、但不提交;** **提交阶段:协调者统一发话,大家提交或回滚。** **关键在于「准备完就能保证提交得了」。** 2. 为什么协调者要「先记日志、再通知」? —— **因为通知到一半可能崩。** 先记下来,重启后才知道「本来的决定是什么」; **反过来的话,参与者来问,协调者自己也不知道答案。** 3. 2PC 最坏的结局是什么? —— **协调者崩溃后参与者长时间卡在「准备好了」**,攥着锁等一个可能永远不来的消息。 **如果它崩在写完 `COMMIT` 之后、通知到一半,两边还会分裂**——一边提交一边回滚。

第 15 章到此结束。 下一篇进第 16 章 16.1,讲 SQL Server 的用户管理: 「登录名」和「数据库用户」为什么是两把钥匙——一个管「能不能连进来」, 一个管「进来后能碰什么」。

相关笔记