Saga 的每一步都是已经提交的本地事务,所以别的事务能读到它的中间状态,补偿也可能和别人的更新交错。这篇把脏读、补偿覆盖并发更新、中间状态造成的丢单三种异常逐个复现,再试语义锁和相对增量两种对策。
30 个 saga 依次跑完,每个 saga 里“已扣款、还没补偿”的窗口 100 ms,共 6.3 秒;审批服务一共读了 1007 次,读到过的余额值 [200, 1000] 其中读到中间状态 200 并因此“拒绝”贷款的有 506 次(50%)—— 这些拒绝都是错的:每个 saga 最后都被补偿了,账户余额从来没有真的低过 1000 最终余额: 1000
每一步都已提交,中间状态对所有人可见。审批服务一半的读取看到被扣掉的 800,于是拒绝了本来能批准的贷款。
补偿写法 | 试验次数 | 终态不是 1005 的次数(丢了利息) | 比例 ----------------+------+---------------------+---- 写法 A:恢复成扣款前读到的值 | 200 | 90 | 45% 写法 B:加回扣掉的 100 | 200 | 0 | 0% 利息更新落在“扣款之后、补偿之前”的窗口里时,A 把它覆盖掉了(丢失更新);B 是相对增量,先后顺序不影响结果
补偿写“恢复成旧值”会抹掉扣款与补偿之间别人写入的更新;写“加回扣掉的数”则与别的更新可交换。
60 次试验,买家到达时刻在 0 到 0.4 秒之间均匀随机;预留窗口是 0.1 到 0.3 秒 买家被拒绝(库存不足)的有 27 次;其中到达时刻落在预留窗口里的 27 次;到达时落在窗口里的买家共 27 个 被拒绝的买家全部落在预留窗口里 窗口里到达的买家被拒了,可是 0.2 秒后 saga 补偿把这 1 件放回去了:这些订单本来是卖得出去的(丢单)。晚于 0.3 秒到的买家买到了
库存被预留的那段时间,买家被拒;预留释放后这些单本可成交。
同样的 30 个 saga:读 balance + pending 的审批服务读了 1006 次,其中 505 次读到了 saga 进行中的状态(pending > 0),错误拒绝 0 次 “等待型”读者(看到 pending > 0 就等到 saga 收尾再决定):等待了 30 次,每次平均等 102 ms,最长 106 ms;等待换来的是它永远只看到已收尾的状态
在 pending 列里标出“正在被 saga 处理”的量,读者用 balance + pending 判断总资产就不被中间状态误导;等待型读者则等到 saga 收尾。代价是读者要理解 pending。
做法 | 审批被错误拒绝(第一节的 saga 窗口,次) | 丢失的更新(第二节,200 次里) ----------------------+-------------------------+------------------ 无防护:读 balance + 绝对值补偿 | 506 | 90 只改补偿:相对增量 | 506 | 0 相对增量 + 语义锁(pending 列) | 0 | 0 (第一列的数字是第一节“只读 balance”的错误拒绝次数,第三行是第四节读 balance + pending 的结果;丢失更新数第一行来自写法 A,后两行来自写法 B)
相对增量解决丢失更新,语义锁解决脏读误判,两者各管一类。没有通用开关。