事务提交之后,再处理外部副作用
卷中目录
本地数据库事务能够保证一批写入同时成功或同时失败。这个保证停在事务边界上。发短信、调用支付、写另一套系统的接口、投递消息到外部队列,都不在这批写入的原子范围内。若在事务还开着的时候去做这些事,会同时得到两个麻烦:外部调用拉长了锁和连接的占用时间;外部调用已经成功而事务随后回滚,于是世界里留下了一封不该存在的短信、一笔已经发出的扣款请求,或一条没有对应业务数据的消息。反方向也同样糟:事务已经提交,外部调用失败,用户以为操作完成,下游却从未知道。

把外部副作用挪到提交之后,是为了先确定本地事实,再通知外部。本地事实可以回滚,外部事实往往只能补偿。顺序一旦反过来,系统就要为“也许发生过”的外部动作准备大量对账。提交之后再做,失败模式变成一种更单一的情况:本地已成功,外部通知可能迟到或重复,但可以通过重试和幂等补齐。这仍然不是恰好一次,只是把不确定从两个方向收成一个方向。
不要在事务里呼叫外部世界
事务中的语句应当只碰受这个事务控制的资源。在里面发起 HTTP、等待第三方回执、写本地文件再指望随回滚删除,都会破坏这个前提。连接池是有限的。一个等待短信网关的事务可能占着连接数秒,期间行锁不放,其他更新同一批数据的请求开始排队。网关偶尔变慢时,数据库先被等锁的事务拖垮,业务超时之后又重试,形成新的事务继续等。把外部调用移出事务,本地提交就可以在很短的时间内结束,锁得以释放。外部慢,慢的是可重试的通知,不是数据库连接。
只在代码里把调用写在提交语句后面,仍然不够。进程可能在提交成功和调用外部之间崩溃。重启后内存里的“还要发短信”丢失了,数据库却显示订单已创建。因此,提交的那一次事务里就应该写下“还需要通知谁”的事实,而不是只留在内存。这个事实和业务写在同一个事务里,要么一起存在,要么一起不存在。提交之后,异步工作者读取这些待通知记录并执行。工作者崩溃也不丢意图,因为意图已经落库。
待发送记录怎么写
这种记录常被叫做发件箱。它不是日志的替代品,而是业务意图的一部分。一张订单创建时,同一事务写入订单行和一条“订单已创建”的发件箱记录。记录里包含业务键、事件类型、发生时间、足够下游处理的载荷,以及发送状态。载荷应是当时已经确定的事实,不要只存一个编号、等发送时再去读可能已被修改的订单。若下游需要的是创建瞬间的金额,而发送前订单被改价,后读就会发出另一种事实。需要最新状态的下游,应当消费“状态已变更”事件,而不是复用创建事件。
状态至少包括待发送、发送中、已发送、放弃。工作者领取时把待发送改成发送中,并写上租约到期时间,避免多个工作者拿同一条。发送成功后再标成已发送,并记下外部回执编号。发送失败则按错误类型处理:短暂失败回到待发送并增加尝试次数,等待下一次;明确的参数错误进入放弃,并告警,不要无限重试同一份坏载荷。尝试次数和间隔要有上限,间隔应逐步拉长并加入抖动,避免故障恢复的瞬间把积压全部打向下游。
领取和发送不能放进一个大事务里空等外部响应。领取是一次短事务,发送发生在事务外,标记结果是另一段短事务。若标记结果时发现记录已被别的工作者接手,就要停下来核对外部是否已经接受。这里再次需要下游幂等。事件编号或业务键应作为下游的去重依据。没有这个依据,发件箱只能降低丢失概率,不能消除重复。至少送达一次加上接收方幂等,才是这条链路的完整契约。
一个订单通知的例子
用户提交订单。应用在一个本地事务中写入订单、扣减可售数量,并插入两条发件箱记录:一条给库存中心做异地确认,一条给消息中心发通知。事务提交,接口就可以返回“已受理”,不必等短信真正送到手机。工作者随后发送库存确认。库存中心按订单号去重,重复消息不会二次占用。若库存中心拒绝,因为可售数量在两地之间已有差异,订单进入待补偿状态,另写一条补偿记录,而不是在工作者进程里直接改订单却不留痕迹。
消息中心失败则只影响通知那一条。订单仍然成立,用户可以在订单页看到状态。通知重试成功即可。不要因为短信失败就回滚已经提交的订单,那会让数据库去追一个已经对外公布的事实。也不要在同一条发件箱记录里捆绑所有下游,让短信失败拖住库存确认。不同下游的失败语义不同,投递状态就应该分开。
查询侧要能看到待发送的积压。某一类事件的待发送数量持续上升,说明下游或工作者异常。只监控业务接口的成功率会误判,因为接口在本地提交时已经返回成功。积压的年龄比数量更有用。数量可能因为业务高峰自然变多,最老的一条若已经超过承诺的通知时限,才是真正的延迟。管理端应允许在核对后重放放弃的记录,重放仍走原有幂等键。手工改成已发送而不去下游确认,会把问题从队列里抹掉,对账时更难发现。
提交之后的可见性
事务刚提交,异步通知还没发生,用户却可能立刻查询。若查询依赖下游索引或缓存,会出现主库已有、下游还没有的窗口。产品上要接受这种短暂的不一致,并用文案或状态表达“处理中”;或者让读取走主库里已经提交的数据,不把下游当成唯一事实来源。把外部系统当成更权威的一方,又要求本地事务等待它,通常会把系统重新拖回双写。权威应该明确:订单是否存在,以本地事务为准;短信是否送达,以消息中心为准。两者不要写成同一个布尔值。
有的团队用事务消息或数据库提交钩子在提交时自动投递。钩子若执行失败,仍然需要发件箱兜底,否则钩子只是另一个容易丢的内存回调。数据库的变更捕获也能从事务日志生成事件,业务代码不必双写发件箱。它的边界是:日志里要有足够的业务含义,下游要能处理快照和更新的顺序,捕获组件本身要可监控。表结构里的技术字段变化也会进入日志,需要过滤。捕获延迟同样是一种待发送积压,只是积压藏在日志位点之后。
跨多个服务的长流程不要伪装成一个大事务。订单、支付、库存分属不同数据库时,没有一个提交点能覆盖全部。每个服务提交自己的事实,再通过事件推进下一步。某一步失败,就发出补偿命令,例如取消预占、发起退款。补偿本身也是外部副作用,同样要落在各自的发件箱里,并且补偿操作必须幂等。退款调用两次不应当退两次。流程状态要能查询,不能只存在某个协调进程的内存里。
什么仍然不该异步
不是所有外部动作都能推迟。若用户必须在这一次响应里拿到支付跳转地址,而地址只能由渠道当场返回,调用就不得不发生在响应之前。即便如此,也不必把数据库事务开在等待渠道的全程。可以先在短事务里创建处于“待支付”的本地单并写入幂等键,提交后再调用渠道,渠道返回后再用第二个短事务把地址和渠道单号记下来。渠道超时则保留待支付记录,按幂等键查询渠道或重试。避免的是“长事务包着外部等待”,不是“响应前绝不能访问网络”。
审计日志若必须和业务同时可见,可以写在同一个本地事务里,因为它仍是本地事实。把审计发给外部日志平台,则属于提交后的投递。文件生成、搜索引擎更新、统计聚合,多数都可以异步,并允许短暂落后。涉及钱和库存的外部确认可以异步,但要有明确的中间状态,不能在确认前就对用户显示“已完成”。状态机比一个成功标志更能描述这种分阶段事实。
测试要覆盖进程在这些时刻退出:事务提交前、发件箱写入后但提交前、提交后尚未发送、发送成功但尚未标记、标记前重复领取。退出后再启动,业务要么完全没有,要么恰好有一份,且外部最终只接受一次有效副作用。只在快乐路径上看到短信发出去,不能证明顺序是对的。故障注入比增加日志更有说服力。
收束
本地事务负责确定本地事实,发件箱把“还要通知外部”变成同一事实的一部分,提交之后再由可恢复的工作者执行。外部调用放在事务里,会拖住锁和连接,并在回滚后留下无法一并撤销的副作用。提交后执行则要接受延迟和重复,用租约、重试上限、下游幂等和可查询的中间状态把它们管住。权威数据放在哪一侧要事先说清。能异步的通知不要绑在用户请求的长事务上;必须当场拿到的外部结果,用短事务夹住前后两次本地写入。这样,提交点才是事实成立的时刻,而不是一次碰运气的远程调用。
见字如晤