LECTURE 11 · 2026-03-17 · 事务与规模化

分布式事务与两阶段提交

Distributed Transactions

分布式事务把多个参与者的局部更新绑成原子决定;两阶段提交能统一 commit/abort,却可能在协调者失效时阻塞。

144 MIN进阶02 SOURCESFULL ARCHIVE

这讲要解决什么

开始前先确认
  • 能区分网络延迟、节点崩溃与部分失败
  • 会用状态机和不变量描述协议
  1. 解释原子性与隔离性的核心问题
  2. 按协议顺序推演2PC 的两个阶段
  3. 评估工程取舍:2PC 提供清晰的原子提交边界,但 prepared 状态会锁住资源,并把协调者可达性带入延迟。

先从单机事务的承诺出发,再看网络把哪一步撕开

单机事务把多次读写包进一个原子决定:日志、锁和恢复都在同一故障域。跨分片后,A 可能已经把更新持久化,B 还未收到;协调者可能在发出部分 commit 后崩溃。客户端想要的“全有或全无”必须由多个独立日志共同维持。

2PC 把决定分成资格与结论。Prepare 阶段每个参与者取得锁、检查约束、写下足以在重启后履行决定的 prepared 记录;投 YES 是耐久承诺,不是“现在看起来可以”。协调者只有收到全部 YES 才持久化 COMMIT,否则 ABORT。第二阶段只是传播已经不可撤销的结论。

本讲要始终分开原子性与隔离性。2PC 让参与者一起 commit/abort,却不替它们选择并发事务顺序;strict 2PL 或 OCC 等并发控制仍负责可串行化。把两者混在一起,会误以为“两阶段”自动解决所有事务问题。

原子性与隔离性

事务的原子性要求所有参与者要么都提交,要么都中止;隔离性要求并发执行等价于允许的串行结果。分布式事务还要面对参与者各自持久化、锁和故障恢复。仅在内存里交换 yes/no 不够,因为节点重启后必须恢复同一个决定。

2PC 的两个阶段

Prepare 阶段协调者询问所有参与者能否提交。参与者写入 prepare 日志、锁定资源并回复 yes,等于承诺将来可以提交;任一 no 导致 abort。若全部 yes,协调者持久化 commit 决定,再在第二阶段广播。参与者收到最终决定后写日志、执行并释放锁。

阻塞与恢复

参与者已 prepare 后不能单方面 abort,因为其他参与者可能已收到 commit;若协调者失联,它只能等待决定,资源锁也可能长期保留。协调者重启后从日志恢复并重发决定。把协调者状态复制到共识组可减少单点故障,但并没有把 2PC 的跨组原子决定自动变成无阻塞协议。

并发控制与死锁

严格两阶段锁让写锁持有到事务结束,易于恢复和保证可串行化,却可能死锁。系统用 wait-for 图检测、超时或全局锁顺序中止某个事务。分片越多,跨分片事务的消息、日志和锁等待成本越高,因此数据布局是事务性能的一部分。

2PC 的两阶段究竟承诺了什么

分布式事务把多个参与者上的更新变成全有或全无。协调者先发送 PREPARE;参与者检查约束、取得所需锁、把 redo/undo 与“prepared”状态写入稳定存储,只有确认自己以后即使重启也能执行任一最终决定,才投 YES。任一 NO 使协调者决定 ABORT;全部 YES 才记录 COMMIT。

第二阶段广播决定。参与者收到 COMMIT 后安装/完成写并释放锁,收到 ABORT 则回滚并释放。消息可重复,所以 prepare/commit/abort handler 必须幂等;协调者在收到确认前持续重发。事务的原子性依赖每个 YES 都是耐久承诺,而非内存中的暂时意愿。

锁通常从执行阶段持有到最终决定,形成 strict two-phase locking,从而既隔离并发事务又避免其他事务看到将来可能 abort 的值。2PC 解决原子提交,不自己决定串行顺序;若参与者局部并发控制不足,所有分片一起 commit 也可能产生非串行化结果。

协调者写下 COMMIT 后,决定不可撤销,即使还没通知任何参与者。客户端何时收到成功是另一个边界;协调者恢复要从日志重发决定。若未记录决定就对外成功,崩溃后可能无法证明该 commit,违反原子性与耐久性。

DIAGRAM IN CONTEXT

把上面的机制落到消息、状态与失败路径中。

跨分片事务事务同时修改 A、B 两个参与者,由 TC 运行原子提交。
Client

transaction

A shard

locks + prepared record

Transaction coordinator

prepare / decision

B shard

locks + prepared record

2PC 时间图只有所有参与者都持久化 YES,协调者才记录 COMMIT。
TCPREPAREA / B
A / Bforce PREPARED → YESTC
TCforce COMMITlog
TCCOMMITA / B

用协调者和参与者状态表走完正常路径

协调者状态可写成 INIT→PREPARING→COMMIT/ABORT→DONE,参与者为 ACTIVE→PREPARED→COMMITTED/ABORTED。每条箭头都标注先写哪条 WAL、何时 fsync、何时发送消息、重复收到如何幂等。没有这张表,“写日志后回复”很容易在代码中被反过来。

事务 T 修改 A、B。TC 发送 PREPARE;A、B 各自锁定记录、写 redo/undo 和 PREPARED,刷盘后 YES。TC 收齐后写 COMMIT 并刷盘,此刻决定已经存在,即便一个参与者尚未得知。TC 广播 COMMIT,参与者安装更新、写最终状态、释放锁并确认。TC 可在确认后回收记录。

若相同 PREPARE/COMMIT 重复到达,handler 返回相同结果而不重复副作用。客户端超时查询必须使用 transaction ID;不能直接发一笔“反向事务”,因为原事务可能已经 commit 只是回复丢失。

纸上推演时把“知道决定”和“决定已经成立”分开,和 Paxos chosen/known 的区别相同。协调者日志中 COMMIT 的耐久写是系统级事实,消息只是把事实传播给参与者。

prepared 状态为什么会阻塞

参与者投 YES 后既不能自行 commit,也不能自行 abort,因为它不知道其他参与者投票与协调者是否已耐久决定。若协调者失联,它必须保持锁和 prepared 数据,等待恢复或外部协议查明决定。这是经典 2PC 的 blocking 性质。

参与者之间询问有时能解决:若有人已知 COMMIT,可传播;若有人在 prepare 前就 abort,可推断 abort。但若所有可达者都处于 uncertain/prepared,没有信息区分“协调者已 commit”与“尚未决定”,任何单方面选择都会在某个可能执行中出错。

复制协调者日志可提高可用性,例如用 Raft 保存事务决定;这没有把 2PC 变成一阶段,而是让决定服务不再是单机故障点。每个参与者分片也可能自身由共识复制,Spanner 正是 Paxos 组加 2PC 的组合。

超时在这里是故障怀疑,不是安全决定。客户端可以收到“结果未知”,后台继续完成;业务若需要补偿,也应先通过事务 ID 查询最终状态,避免已 commit 后再做相反副作用。

DIAGRAM IN CONTEXT

把上面的机制落到消息、状态与失败路径中。

阻塞窗口prepared 参与者知道自己不能独立 abort,却可能不知道最终决定。
Participant

PREPARED + locks

Coordinator unavailable

decision unknown

Recovery

等待 TC 或可靠复制的决定

逐个崩溃点判断:谁能独立做决定

参与者在 PREPARE 前崩溃,恢复后没有 prepared 记录,可按未参与处理;投 NO 后可 abort。参与者已 PREPARED 后崩溃,恢复必须继续持锁/意向并询问决定,不能自行 abort,因为 TC 可能已 commit。收到 COMMIT 后崩溃则按日志重做。

TC 在收齐 YES 前崩溃,若没有最终决定,恢复可选择 ABORT 并通知;TC 写 COMMIT 后崩溃,只能重发 COMMIT。最困难窗口是所有参与者 prepared、TC 不可达:参与者知道自己不能决定,却没有信息区分“TC 已 commit”与“尚未决定”,所以经典 2PC 会阻塞。

参与者互询并非总能解除:有人已知 COMMIT 就可传播;有人明确未 prepare 可支持 abort;但所有可达者都 uncertain 时仍无答案。复制 TC 日志能缩短单机故障,却仍要用共识保证唯一决定;这不是把 2PC 魔法变成无阻塞。

故障矩阵的判断原则只有一个:当前持久证据是否在所有可能执行中都支持同一结论。超时、猜测和“多数参与者想 abort”都不是原子决定证据。

用日志状态机审查每个崩溃点

协调者可建模为 INIT→PREPARING→COMMIT/ABORT→DONE,参与者为 ACTIVE→PREPARED→COMMITTED/ABORTED。为每条转移标明:写哪条日志、何时 fsync、何时发送消息、重启后如何重放。没有这张表,很容易在“写日志前回复”或“释放锁后才记录”处留下窗口。

写前日志(WAL)的原则是恢复动作所需信息先于数据页落盘。redo 让已提交更新可重做,undo 让未提交更新可撤销;不同系统可选择 force/no-force、steal/no-steal,但必须与恢复算法匹配。仅把 SQL/命令写日志未必足够,还要能唯一识别事务和重复消息。

只读事务可避免写集 prepare,但仍需获得一致快照或锁,否则跨分片读会混合不同时间。分布式死锁检测也比本地复杂:T1 在 A 持锁等 B,T2 在 B 持锁等 A,等待图跨机器。超时强制 abort 是一种活性策略,必须确保 abort 决定按协议传播。

性能成本来自两轮跨分片通信、稳定存储与锁持有时间。批量日志刷盘、并行 prepare、就近协调者可降低常数,却不能省掉所有参与者的耐久 YES 或唯一最终决定。

把事务思维带回 Lab 4 的等待、去重与恢复

Lab 4 虽不是完整 2PC,却同样要求区分请求已提交、已应用、客户端已获知。handler 等待日志位置时可能遇到 leadership change;超时只能返回 unknown/retry,不能把尚未证明的结果当失败。clientID/seq 与结果缓存要随 KV 状态一起 snapshot。

对每个等待条件建立状态机:创建 waiter、对应 index/term、apply 唤醒、领导变化或超时回收。不要让持锁 handler 阻塞等待 applier;也不要留下无人清理的 channel。重复请求进入不同日志位置时,状态机去重保证只执行一次。

事务课训练的是“承诺先耐久、消息后传播、恢复依证据”。同样检查 Raft KV:何时对客户端成功、成功证据是否跨重启保留、旧 leader 回复是否可能冒充当前决定。能回答这些问题,服务语义才不是靠测试碰巧成立。

教案覆盖地图

100%教师材料入库
11中文教学单元
03机制 / 板书图
02一手资料

覆盖口径:教师 notes/讲义原文逐行完整保留;中文教学单元覆盖课堂机制、失败路径与工程取舍;3/3 个显式板书占位已重绘;论文另设“问题—机制—证据—边界”阅读导航。覆盖不是用摘要替代原文,任何细节都可在页面末尾回查。

教师教案notes/l-2pc.txt

333 行 · 2,091 词 · 完整可搜索文本

论文 / FAQpapers/chapter9-faq.txt

329 行 · 2,500 词 · 完整可搜索文本

展开中文教学单元映射(11 项)
  1. 01先从单机事务的承诺出发,再看网络把哪一步撕开
  2. 02原子性与隔离性
  3. 032PC 的两个阶段
  4. 04阻塞与恢复
  5. 05并发控制与死锁
  6. 062PC 的两阶段究竟承诺了什么
  7. 07用协调者和参与者状态表走完正常路径
  8. 08prepared 状态为什么会阻塞
  9. 09逐个崩溃点判断:谁能独立做决定
  10. 10用日志状态机审查每个崩溃点
  11. 11把事务思维带回 Lab 4 的等待、去重与恢复

论文要读到哪里

READING TARGETnotes/l-2pc.txt + papers/chapter9-faq.txt
核心问题

跨分片事务如何让所有参与者一起提交或一起中止?

机制主线

协调者收集 prepare;参与者持久化锁与意向;全体 yes 后持久化 commit 决定,否则 abort。

必读证据

追踪 A、TC、B 时间图,逐个插入协调者/参与者崩溃并判断谁能独立恢复。

适用边界

2PC 是原子提交而非共识;协调者失联且参与者已 prepared 时可能阻塞。

把直觉校准成不变量

误区

参与者 Prepare 后若协调者超时,可以安全地自己 abort。

它可能已经投 yes,而其他参与者已获知 commit;单方面 abort 会破坏原子性。

误区

只记住正常路径就足以实现协议。

分布式协议的正确性主要由超时、重试、重排、崩溃恢复和旧消息路径决定。

知识检查

参与者回复 Prepare=yes 之前必须做什么?

下列哪项最准确概括本讲的主要工程取舍?

为什么“参与者 Prepare 后若协调者超时,可以安全地自己 abort。”是错误的?

离开本讲前,你应能复述

  • 事务的原子性要求所有参与者要么都提交,要么都中止;隔离性要求并发执行等价于允许的串行结果。
  • 2PC 提供清晰的原子提交边界,但 prepared 状态会锁住资源,并把协调者可达性带入延迟。
  • 它可能已经投 yes,而其他参与者已获知 commit;单方面 abort 会破坏原子性。

完整官方资料附录

以下是本讲对应官方材料的可搜索离线文本。中文精读负责解释;资料附录保留原始细节、例子、问答与代码,不以摘要替代原文。

课堂讲义notes/l-2pc.txt333 行 · 2,091 词 · 完整收录
6.5840 2026 Lecture 11: Distributed Transactions

Topics:
  distributed transactions = concurrency control + atomic commit

where are we in the course?
  so far, mostly distribution for fault tolerance
    multiple servers trying to look like one reliable server
  now, many servers for performance
    [diagram: clients, servers, data sharded by key]
    split the data up (shard) over multiple servers, for parallelism
    fine as long as clients use data items one at a time
    what if an application operation involves records in different shards?
      failures? atomicity?
    important problem, we'll see a number of approaches

client application actions often involve multiple reads and writes
  bank transfer: debit and credit
  install bi-directional links in a social graph
  insert new record, add to index
  we'd like to hide interleaving and failure from application writers

this is an old problem in databases
  the traditional solution: transactions
  programmer marks beginning/end of sequences of code as transactions
  the system automatically provides good behavior

example transactions
  x and y are bank balances -- records in database tables
    both start out as $10
  T1 and T2 are transactions
    T1: transfer $1 from y to x
    T2: audit, to find the total amount of money in the bank
  T1:             T2:
  BEGIN-X         BEGIN-X
    add(x, 1)       tmp1 = get(x)
    add(y, -1)      tmp2 = get(y)
  END-X             print tmp1, tmp2
                  END-X

the "END-X" indicates that the transaction would like to commit
  it asks the transaction system to attempt to commit
  as we'll see, the commit may succeed, or it may fail

what is correct behavior for a transaction?
  usually called "ACID"
    Atomic -- all writes or none, despite failures
    Consistent -- obeys application-specific invariants
    Isolated -- no interference between xactions -- serializable
    Durable -- committed writes are permanent
  ACID transactions are magic!
    programmer writes straightforward serial code
    system automatically adds locking!
    system automatically adds fault tolerance!
  of course we need to implement this magic

a note on trends
  some storage systems provide transactions, some don't
  some applications benefit a lot from transactions, some don't
  SQL databases provide transactions
  but transactions are slow, particularly for sharded data
    so for a while simple key/value stores gained popularity
    just put and get on single records
  but transactions are coming back

today: ACID for distributed transactions
  with data sharded over multiple servers

What does serializable mean?
  you execute some concurrent transactions, which yield results
    "results" means both output and changes in the DB
  the results are serializable if:
    there exists a serial execution order of the transactions
    that yields the same results as the actual execution
  (serial means one at a time; wait for one to finish before starting the next)
  (this definition should remind you of linearizability)

You can test whether an execution's result is serializable by
  looking for a serial order that yields the same results.
  for our example, the possible serial orders are
    T1; T2
    T2; T1
  so the correct (serializable) results are:
    T1; T2 : x=11 y=9 "11,9"
    T2; T1 : x=11 y=9 "10,10"
  the results for the two differ; either is OK
  no other result is OK for a serializable system
  the implementation might have executed T1 and T2 in parallel
    but it must still yield results as if in a serial order

what if T1's operations run entirely between T2's two get()s?
  would the result be serializable?
  T2 would print 10,9
  but 10,9 is not one of the two serializable results!
what if T2 runs entirely between T1's two adds()s?
  T2 would print 11,10
  but 11,10 is not one of the two serializable results!
what if x's server does the increment but y's server can't?
  x=11 y=10 is not one of the serializable results!

a transaction can "abort" if something goes wrong
  an abort un-does any modifications
  the transaction might voluntarily abort,
    e.g. if the account doesn't exist, or y's balance is <= 0
  the system may force an abort, e.g. to break a locking deadlock
  server failure can result in abort
  result of abort should be as if xaction never executed!!!
    must un-do, or not apply, all updates
  the application might (or might not) try the transaction again

serializable transactions are nice for application programmers
  write transaction as if there is nothing else going on
  ordinary serial code, programmer doesn't need to worry about locks

now: implementing distributed transactions

two main components:
  concurrency control (to provide isolation/serializability)
  atomic commit (to provide atomicity despite failure)

first, concurrency control
  the goal: isolated/serializable execution of concurrent transactions
  for now, on a single DB server (not distributed)

two classes of concurrency control for transactions:
  pessimistic:
    lock records before use
    conflicts cause delays (waiting for locks)
  optimistic:
    use records without locking
    commit checks if reads/writes were serializable
    conflict causes abort+retry
    called Optimistic Concurrency Control (OCC)
  pessimistic is faster if conflicts are frequent
  optimistic is faster if conflicts are rare

today: pessimistic concurrency control
in a few weeks: optimistic concurrency control (FaRM)

"Two-phase locking" is one way to implement serializability
  each database record has a lock
  2PL rules:
    a transaction must acquire a record's lock before using it
    a transaction must hold its locks until *after* commit or abort

2PL for our example
  suppose T1 and T2 start at the same time
  the transaction system automatically acquires locks as needed
  so first of T1/T2 to use x will get the lock
  the other waits until the first completely finishes (reaches END-X)
  this prohibits the non-serializable interleavings

details:
  an executing transaction acquires locks as needed, at the first use
    add() and get() implicitly acquire record's lock
    END-X commits or aborts, then releases all locks
  all locks are exclusive (for this discussion, no reader/writer locks)
  the full name is "strong strict two-phase locking"
    strong strict = hold locks until after commit
  much more structured than e.g. Go's Mutexes:
    programmer must supply BEGIN-X/END-X
    DB locks automatically, on first use of each record
    DB unlocks automatically, at transaction end
    DB may automatically abort to resolve deadlock
  (consider: could Go mutexes usefully be automatic like this?)

Why hold locks until after commit/abort?
  why not release as soon as done with the record?
  example of a resulting problem:
    suppose T2 releases x's lock after get(x)
    T1 could then execute between T2's get()s
    T2 would print 10,9
    not a serializable execution: neither T1;T2 nor T2;T1

Two-phase locking can produce deadlock, e.g.
  T3      T4
  get(x)  get(y)
  get(y)  get(x)
The system must resolve deadlocks, usually by aborting
  Detect waits-for cycles -- maybe too hard when locks are distributed
  Time out
  Wound-wait (pre-emptively abort newer xaction rather than waiting)

Performance with 2PL
  good: parallelism for transactions that use disjoint records
  bad: locking has overhead even if lock is available
  bad: locking conflicts require waiting

The Question: describe a situation where Two-Phase Locking yields
higher performance than Simple Locking. Simple locking: lock *every*
record before *any* use; release after abort/commit.

Next topic: distributed transactions versus failures

how can distributed transactions cope with failures?
  suppose, for our example, x and y are on different storage servers
  suppose x's server adds 1, but y's crashes before subtracting?
  or x's server adds 1, but y's realizes the account doesn't exist?
  or x and y both can do their part, but aren't sure if the other will?
  it's a hard problem!

We want "atomic commit":
  A bunch of computers are cooperating on some task
  Each computer has a different role
  We want atomicity: all execute, or none execute
  Challenges: failures, performance

We're going to look at a protocol called "two-phase commit"
  Used by distributed databases for multi-server transactions

The setting
  Data is sharded among multiple servers
  Each transaction runs on a "transaction coordinator" (TC)
  For each read/write, TC sends RPC to relevant shard server
    Each shard server is a "participant"
    Each participant manages locks for its shard of the data
  There may be many concurrent transactions, many TCs
    TC assigns unique transaction ID (TID) to each transaction
    Every message, every piece of xaction state tagged with TID
    To avoid confusion

Two-phase commit without failures:
  [time diagram: A, TC, B]
  [state (just for B): DATA, TID LOCKS TEMP PREPARED]
  TC sends put(), get(), &c RPCs to A, B
    A and B lock records (and wait if already locked).
    Modifications are tentative, on a copy, only installed if commit.
  TC gets to END-X.
  TC sends PREPARE messages to A and B.
  If A is able to commit,
    A responds YES.
    then A is in "prepared" state.
  otherwise, A responds NO.
  Same for B.
  If both A and B said YES, TC sends COMMIT messages to A and B.
  If either A or B said NO, TC sends ABORT messages.
  A/B commit if they get a COMMIT message from the TC.
    I.e. they copy tentative records to the real DB.
    And release the transaction's locks on their records.
  A/B send ACK (acknowledge) to say they have finished committing.

Why is this correct so far?
  Neither A nor B can commit unless they both agreed.

What if B crashes and restarts?
  If B crashed *before* it got the PREPARE, it can forget the transaction.
  If B crashed *after* sending YES, B must remember (despite crash)!
    Because A might have received a COMMIT and committed.
    So B must be able to commit (or not) even after a reboot.

PREPARE requires participants to save state that survives a crash.
  Typically by writing to disk (slow).
  B must save xaction state on disk before saying YES,
    including locks and tentative data updates.
  If B reboots, and disk says PREPARED but didn't receive COMMIT from TC,
    B must ask TC, or wait for TC to re-send.
  And meanwhile, B must continue to hold the transaction's locks.

What if TC crashes and restarts?
  If TC might have sent COMMIT before crash, TC must remember!
    Since one participant may already have committed.
  Thus TC must write COMMIT to disk before sending COMMIT msgs.
  And repeat COMMIT if it crashes and reboots,
    or if a participant asks (i.e. if A/B didn't get COMMIT msg).
  Participants must filter out duplicate COMMITs (using TID).

What if TC never gets a YES/NO from B?
  Perhaps B crashed and didn't recover; perhaps network is broken.
  TC can time out, and tell participants to abort,
    since TC has not sent any COMMIT msgs.
  Good: allows servers to release locks.

What if B times out or crashes while waiting for PREPARE from TC?
  B has not yet responded to PREPARE, so TC can't have decided commit
  so B can unilaterally abort, and release locks
  respond NO to future PREPARE

What if B replied YES to PREPARE, but doesn't receive COMMIT or ABORT?
  Can B unilaterally decide to abort?
    No! TC might have gotten YES from both,
    and sent COMMIT to A, but crashed before sending to B.
    So then A would commit and B would abort: incorrect.
  B can't unilaterally commit, either:
    A might have voted NO.

So: if B voted YES, it must "block": wait for TC decision.

When can TC completely forget about a committed transaction?
  If it sees an ACK from every participant for the COMMIT.
  Then no participant will ever need to ask again.

When can participant completely forget about a committed transaction?
  After it ACKs the TC's COMMIT message.
  If it gets another COMMIT, and has no record of the transaction,
    it must have already committed and forgotten, and can send ACK (again).

Two-phase commit perspective
  Used in sharded DBs when a transaction uses data on multiple shards
  But it has a bad reputation:
    slow: multiple rounds of messages
    slow: disk writes
    locks are held over the prepare/commit exchanges; blocks other xactions
    TC crash can cause indefinite blocking, WITH LOCKS HELD
  Thus usually used only in a single small domain
    E.g. not between banks, not between airlines, not over wide area
  Faster distributed transactions are an active research area.

Raft and two-phase commit solve different problems!
  Use Raft to get high availability by replicating
    i.e. to be able to operate when some servers are crashed
    the servers all do the *same* thing
  Use 2PC when each participant does something different
    And *all* of them must do their part
  2PC does not help availability
    since all servers must be up to get anything done
  Raft does not ensure that all servers do something
    since only requires a majority to execute.

What if you want high availability *and* atomic commit?
  Here's one plan.
  [diagram]
  The TC and servers should each be replicated with Raft
  Run two-phase commit among the replicated services
  Then you can tolerate failures and still make progress
  Spanner uses this arrangement (after break).

Next:
  midterm on Thursday! in Walker.
  read the papers, review lecture notes, look at old exams, look at your lab code

---

http://dbmsmusings.blogspot.com/2019/01/its-time-to-move-on-from-two-phase.html
论文 FAQpapers/chapter9-faq.txt329 行 · 2,500 词 · 完整收录
Distributed Transactions FAQ

Q: How does this material fit into 6.5840?

A: When data is distributed over many computers, it's common for a
single operation to need to read and/or modify multiple data items,
perhaps stored on different computers. How such multi-step operations
interact with concurrent operations on the same data, and what happens
if a crash occurs in the middle of such an operation, are usually
critical questions for the system's robustness and ease of
programming. The gold standard for good behavior is transactions,
often provided by database systems. Transactions are usually
implemented with two-phase locking and logging; distributed
transactions usually add two-phase commit. Today's reading from the
6.1800 (6.033) textbook explains those ideas.

Most of the storage systems we've looked at so far provide operations
like put() and get() that involve only single records. In constrast,
transactions usually refer to multi-record operations (e.g., bank
transfers from one account to another). When the records in involved
in a transaction are stored in different places (e.g. in a sharded
storage system), then we're talking about distributed transactions,
for which two-phase commit is helpful. These ideas will show up in
some of the upcoming papers we read (e.g. Spanner and FaRM).

Q: Why is it so important for transactions to be atomic?

A: What "transaction" means is that the entire sequence of steps
inside the transaction occurs atomically with respect to failures and
other transactions. Atomic here means "all or none". Transactions are
a feature provided by some storage systems to make programming easier.
A situation where transactions are helpful is bank transfers. If the
bank wants to transfer $100 from Alice's account to Bob's account, it
would be awkward if a crash left Alice debited by $100 but Bob *not*
credited by $100. If your storage system supports transactions, you
can write something like

BEGIN TRANSACTION
  decrease Alice's balance by 100;
  increase Bob's balance by 100;
END TRANSACTION

and the transaction system will make sure the transaction is atomic.
Either both happen, or neither, even if there's a failure; and no
other transaction will observe the intermediate situation where only
one balance has been modified.

Q: Could one use Raft instead of two-phase commit?

A: No: two-phase commit and Raft solve different problems.

Two-phase commit causes different computers to do *different* things
(e.g. Alice's bank debits Alice, Bob's bank credits Bob), and causes
them *all* to do their thing, or none of them. Two-phase commit cannot
make progress if any participant can't be reached: it has to wait for
all participating computers to perform their part of the transaction.

Raft causes a majority of the peers to all do the *same* thing (so
they remain replicas). It's OK for Raft to wait only for a majority,
since the peers are replicas, and therefor Raft-based systems can be
available in the face of failures.

Q: What is the difference between two-phase locking and two-phase commit?

A: 2PL is a scheme for acquiring locks for records in a transaction,
to ensure that different transactions that use the same records don't
interfere with each other; it is useful in both non-distributed and
distributed settings.

2PC is a scheme to execute a transaction across multiple machines,
where each machine has some of the records used in the transaction;
2PC ensures that each machine does its part of the transaction.

2PC systems often use 2PL.

Q: In two-phase commit, why would a worker send an abort message,
rather than a PREPARED message?

A: Perhaps the participant crashed and rebooted after it did some of
its work for the transaction but before it received the prepare
message; during the crash it will have lost the record of tentative
updates it made and locks it acquired, so it cannot complete the
transaction. Another possibility (depending on how the database works)
is if the worker detected a violated constraint on the data (e.g. the
transaction tried to write a record with a duplicate key in a table
that requires unique keys). Another possibility is that the worker is
involved in a deadlock, and must abort to break the deadlock.

Q: Can two-phase locking generate deadlock?

A: Yes. If two simultaneous transactions both use records R1 and R2,
but in opposite orders, they may each acquire one of the locks, and
then deadlock trying to get the other lock. Databases detect these
deadlocks and break them. A database can detect deadlock by timing out
lock acquisition, or by finding cycles in the waits-for graph among
transactions. A deadlock can be broken by aborting one of the
participating transactions.

Q: What is serializability?

A: It's a common criterion for correctness of concurrent transactions
in databases. An execution of a bunch of transactions is serializable
if it yields the same results as some serial (one-at-a-time) execution
of those transactions. Results include both the content of records in
the database and any outputs from the transactions. So if transactions
T1 and T2 execute concurrently, the results are serializable if they
are the same as executing T1, waiting for T1 to complete, and then
executing T2; or executing T2, waiting for it to complete, and then
executing T1.

Q: Why does it matter whether locks are held until after a transaction
commits or aborts?

A: If transactions release locks before they commit, it can be hard to
avoid certain non-serializable (incorrect) executions due to aborts or
crashes. In this example, suppose T1 releases the lock on x after it
updates x, but before it commits or aborts:

  T1:           T2:
  BEGIN
  x = x + 1
                BEGIN
                y = x
                END

  END

It can't be legal for y to end up greater than x. Yet if T1 releases
its lock on x, then T2 acquires the lock, writes y, and commits, but
then T1 aborts or the system crashes and cannot complete T1, we will
end up with y greater than x.

It's to avoid having to cope with the above that people use the
"strong strict" variant of 2PL, which only releases locks after a
commit or abort.

Q: What is the point of the two-phase locking rule that says a
transaction isn't allowed to acquire any locks after the first time
that it releases a lock?

A: Acquiring after releasing can lead to non-serializable executions.

  T1:         T2:
  x = x + 1
              z = x + y
  y = y + 1

Suppose x and y start out as zero, and both transactions execute, and
successfully commit. The only final values of z that are allowed by
serializability are zero and 2 (corresponding to the orders T2;T1 and
T1;T2). But if T1 releases its lock on x before acquiring the lock on
y and modifying y, T2 could completely execute and commit while T1 is
between its two statements, giving z a value of 1, which is not legal.
If T1 keeps its lock on x while using y, as two-phase locking demands,
this problem is avoided.

Q: How does two-phase commit solve the dilemma of the two generals (or
the Byzantine Generals Problem)?

A: It doesn't. Two-phase commit doesn't encounter the two generals
problem, and thus doesn't need to solve it.

One difference is that, in two-phase commit, there's just one entity
making the decision (the TC), and it can't disagree with itself.
Whereas in the two generals problem, there are two independent
deciders who have trouble communicating, so there's room for
disagreement. Another difference is that two-phase commit has no
real-time requirement (nothing like the requirement that the generals
agree by dawn); it's OK for workers to wait for as long as needed to
receive the TC's decision.

Q: Are the locks exclusive, or can they allow multiple readers to have
simultaneous access?

A: By default, "lock" in 6.5840 refers to an exclusive lock. But there
are databases that can grant locking access to a record to either
multiple readers, or a single writer. Some care has to be taken when a
transaction reads a record and then writes it, since the lock will
initially be a read lock and then must be upgraded to a write lock.
There's also increased opportunity for deadlock in some situations; if
two transactions simultaneously want to increment the same record
(i.e. read, add one, write), they might deadlock when upgrading a read
lock to a write lock on that record, whereas if locks are always
exclusive, they won't deadlock.

Q: How should one decide between pessimistic and optimistic
concurrency control?

A: If your transactions conflict a lot (use the same records, and one
or more transactions writes), then locking is better. Locking causes
conflicting transactions to wait, whereas most OCC systems deal with
conflict by aborting; aborts (really the consequent retries) are
expensive.

If your transactions rarely conflict, then OCC is preferable to
locking. OCC doesn't spend CPU time acquiring/releasing locks and, as
long as conflicts are rare, OCC rarely aborts. The "validation" phase
of OCC systems often uses locks, but they are usually held for shorter
periods of time than the locks in pessimistic designs.

Q: What should two-phase commit workers do if the transaction
coordinator crashes?

A: If a worker has told the coordinator that it is ready to commit,
then the worker cannot later change its mind. The reason is that the
coordinator may (before it crashed) have told other workers to commit.
So the worker has to wait (with locks held) for the coordinator to
reboot and send (or re-send) its decision.

Waiting indefinitely with locks held is a real problem, since the
locks can force a growing set of other transactions to block as well.
So people tend to avoid two-phase commit, or they try to make
coodinators reliable. For example, Google's Spanner replicates
coordinators (and all other servers) using Paxos.

Q: Why don't people use three-phase commit, which allows workers to
commit or abort even if the coordinator crashes?

A: Three-phase commit only works if workers can reliably distinguish
between the coordinator being dead and the network not delivering
packets. For example, three-phase commit won't work correctly if
there's a network partition. In most practical networks, it's not
possible to distinguish a dead computer from a network failure, so
three-phase commit can't safely be used.

Q: Can there be more than one transaction active? How do participants
know which transaction a message refers to?

A: There can be many concurrent transactions, managed by many TCs. A
TC assigns a unique transaction ID (TID) to each transaction. Every
message includes the TID of the relevant transaction. TCs and
participants tag entries in their tables with the TID, so that (for
example) when a COMMIT message arrives at a participant, it knows what
tentative records to make permanent, and what locks to release.

Q: How does a two-phase commit system undo modifications if a
transaction has to abort?

A: Each participant performs modifications to temporary copies of the
records. If the participant answers "yes" to the TC's prepare message,
the participant must first save the temporary record values to its log
on disk, so it can find them if it crashes and restarts. If the TC
decides to commit, the participant must copy the temporary values to
the real database records; if the TC decides to abort, the participant
must discard the temporary records.

Q: How does serializability relate to linearizability?

A: They are similar notions, arising from different communities. Both
require the final outcome to be the same as some serial execution.
Serializability refers to entire transactions, each involving multiple
operations and multiple records. Linearizability usually refers to
single operations, each on a single record. It's also the case that
linearizability requires that the equivalent serial execution be
consistent with the real-time order of the actual execution, while
serializability usually does not.

Q: Why do logs appear so often in the designs we look at?

A: A log is a good way to capture the serial order that the system has
chosen for operations, so that e.g. all replicas perform the
operations in the same order, or a server considers operations in the
same order after a crash+reboot as it did before the crash.

Many distributed systems keep multiple operations in flight (often
called a window or pipeline of operations). Often the fate of
operations is not known until some time after they are received. A log
is a good way to keep track of such pending operations. Raft is an
example of this arrangement: a follower receives a stream of commands
from the leader, but doesn't hear that they are committed until later,
and must have a place to store commands until they are committed.

A log is an efficient way to write data to hard disk or SSD, since
both media are faster at sequential writes (i.e. appends to the log)
than at random writes.

A log is a convenient way for crash-recovery software to see how far
the system got before it crashed, and whether the last transactions
have complete records in the log and thus can safely be replayed. That
is, a log is a convenient way to implement crash-recoverable atomic
transactions, via write-ahead logging.

Q: Are there structures other than logs that would work as well?

A: There's nothing as general-purpose as logs.

You can record order by storing data in some other way (e.g. a b-tree)
and storing sequence numbers with the data (Frangipani does this for
meta-data, in addition to using logs).

You wouldn't have to worry about performance if you used a persistent
storage system that was as fast for random updates as for sequential,
for example battery-backed RAM. However, such systems are often more
expensive and less robust than hard drives or SSDs.

For the write-ahead property, you could store a mini-log for each data
record. However, it might then be time-consuming for the
crash-recovery software to find the full set of incomplete mini-logs.

A different way to get crash-recoverable atomic operations is to
prepare an entire new data structure in fresh storage, and then use a
single commiting write to substitute it for the original data
structure. This makes the most sense with tree- shaped data
structures. The NetApp WAFL file system uses that idea:

https://atg.netapp.com/wp-content/uploads/2000/01/file-system-design.pdf

This arrangement may make it hard to support concurrent transactions.

Q: What is the Lock Manager?

A: The software module that implements acquire() and release(). It may
also implement deadlock detection by constructing a waits-for-graph as
users of the module acquire locks using acquire().  In some cases the
module is a separate service running on a machine (e.g., the lock
service in Franginpani).

Q: Why does Section 9.5.3 of the reading say that two-phase locking
forbids this sequence?

T1: READ X
T2: WRITE Y
T1: WRITE Y

A: Perhaps the text means that T2 goes on to do other unrelated things
before committing. In that case two-phase locking would force T1 to
wait until T2 completely finished. However, in fact it would be
correct (serializable) for T1 to commit before T2 finished. That is,
there are correct executions that two-phase locking forbids.