两个人都收到「已预订」 — 一个 await 分开的东西

7 分钟
目标两个人同时抢同一个时段。用话说,这两个处理器做的是完全一样的事 — 看看空不空,空就占。一个给两个人都发了确认,另一个没有。差别就是中间那一个 await。

预订工具里最糟的失败不是订不上。

是给两个人都发「您已预订成功」。 订不上可以再订一次;两个人都被告知订上了,接下来就得靠人打电话去摆平。

两个处理器,同一段描述

这个样例里有两个「占用时段」的处理器。用话描述完全一样 —看看空不空,空就占。

一个是这样。

// 1. Look.
final free = slot.takenBy == null;

// 2. Anything at all in here — a database round trip, a payment check, a
//    log write, a lookup of the customer's name — hands control to
//    whatever else was in flight.
await Future<void>.delayed(Duration.zero);

// 3. Write, using what was true in step 1.
if (free) { slot.takenBy = who; ... }

另一个是这样。

if (slot.takenBy != null) {
  return '$at is already taken by ${slot.takenBy}';
}
slot.takenBy = who;
slot.confirmations.add(who);

await Future<void>.delayed(Duration.zero); // the slow part, after the claim

同一个 await,只是位置不同。 前者在「看」和「写」之间,后者在「写」之后。

而这个位置制造了重复预订。

先让它真的发生

unsafe -> Smith:  "CONFIRMED 10:30 for Smith"
unsafe -> Jones: "CONFIRMED 10:30 for Jones"
unsafe result: 1 slot confirmed to more than one person

竞争之后 —— 10:30 这一个时段有两个人都被告知 yes。TOLD YES TWICE 为 1,那一行里两个名字都还在

画面精确地展示了现实中的症状。 时段的主人只有 Jones 一个,而被告知「订上了」的人有两个。

这就是真实店铺里发生的样子。账上是一个人,门口来了两个。

所以服务器把所有发过确认的人都记着。

/// Everybody who has ever been told they got this slot. In a correct
/// booking system this never has more than one name in it, which is exactly
/// why it is worth keeping.
final confirmations = <String>[];

正常的话,它永远是一个名字的列表。永远只该有一条的列表,正是值得去数的列表。

然后让它不发生

同样两个人、同样的时段,只把那个缝隙关上。

--- cleared, same two people, same slot, gap closed ---
safe -> Smith:  "CONFIRMED 10:30 for Smith"
safe -> Jones: "10:30 is already taken by Smith"
safe result: nobody was told yes twice

同样的竞争,服务端一次只放行一个 —— 10:30 只属于 Smith,TOLD YES TWICE 为 0。页脚是 "One slot, one yes"

拒绝不是「失败了」,而是「Smith 订走了」。 第二个人需要知道的不是自己失败了,而是这个位子没了 — 这样他才能去挑下一个时间。

只检查「好情况」就会通过

这个样例的验证很特别。它检查 bug 有没有发生。

unsafe = s.call("book.stampede", {"at": "10:30", "first": "Smith", "second": "Jones", "mode": "unsafe"})
assert unsafe["doubleCount"] == 1, unsafe      # the race must actually happen, or this run proves nothing
safe = s.call("book.stampede", {"at": "10:30", "first": "Smith", "second": "Jones", "mode": "safe"})
assert safe["doubleCount"] == 0, safe

理由很简单。预订的测试如果只检查「只有一个人被确认」,那么在 bug 还活着的服务器上它照样通过。 因为竞争没真的发生时,请求会被依次处理,结果看起来是对的。

所以顺序是这样。

  1. 用不安全的那个确认竞争确实发生了
  2. 用安全的那个确认在同样的竞争下它不发生

没有第一步,第二步毫无意义。

还有一条。缝隙一旦消失,就判失败。

# The two handlers must stay near-identical apart from where the await sits.
# If somebody "fixes" the unsafe one, this sample stops teaching anything.
grep -q "await Future<void>.delayed(Duration.zero);" booking_server/bin/server.dart \
  || { echo "   the gap in the unsafe handler is gone — nothing is demonstrated"; exit 1; }

做的过程中,竞争没有发生

第一版根本没出 bug。

我们从测试装置里同时发了两个工具调用,结果却是正常的。

take_unsafe -> Smith:  "CONFIRMED 10:30 for Smith"
take_unsafe -> Jones: "10:30 is already taken by Smith"
take_unsafe result: nobody was told yes twice

是传输层在做串行化。 stdio 读一行请求,把那个处理器跑到底,然后才读下一行。根本没有可以重叠的区间。

到这里有两条路。一条是耸耸肩 —「这个传输上不会出,所以没问题」。另一条是把重叠造出来,然后确认。

我们选了后者。换成 HTTP 或 SSE,那个重叠不用谁去造也会送上门。传输碰巧替你守住的东西,不算被守住。

// The overlap is made here, inside the server, and that is on purpose.
// ...
// On an HTTP or SSE transport the overlap arrives by itself and nobody
// has to arrange it. So this tool arranges what that transport would have
// handed the server anyway.

这在这个系列里是头一种发现。 此前几篇的缺陷全都在我的代码里,而这一次是环境把缺陷遮住了。而遮住东西的环境随时会变。

这个样例的范围

重叠是我造的。 在服务器内部同时启动两个处理。这不是真正的并发请求经由传输进来所产生的竞争。「这个竞争在 stdio 上不可复现」正是这一篇查出来的事实,所以这个样例展示的是「如果是 HTTP 会是什么样」。 我们并没有在 HTTP 传输上实际确认过。

只处理了单进程内的竞争。 服务器一旦变成两台,这个解法整个失效。那时需要数据库的唯一约束或锁,而**在进程之外保证「看与写之间什么都没有」**要难得多。没有处理。

没有取消,也没有候补。 放掉已占的位子、把放掉的位子交给等着的人,这些都没有。真实预订工具的一半在那一侧。

没有支付。 一加上支付,就会出现「位子占住了但支付失败了」的状态,而那个状态要抓住多久就成了新问题。

剩下的

这一篇的代码不到十行。而这十行用话说是分不出来的。

「看看空不空,空就占」— 两个处理器都会被这么描述。它们都能过代码评审,写进规格书也会写成一样。

唯一看得见的地方是运行。 所以这类缺陷读不出来,只能让它发生才找得到。

而一旦让它发生过,那本身就成了检查。

练习任务

连续发送两次相同的预订。证明第二次不会再生成一个预订,并说明是哪一方保证了这一点。

相关文章Told Yes Twice — What One await Decides