周三凌晨三点,值班工程师被警报叫醒。订单库多了一条记录,但下游的库存服务静悄悄的——消息没发出去。他揉着眼睛回溯代码,那个创建订单的方法里,savesend紧挨着,看起来人畜无害。他没有做错任何事,但这种不一致偏偏就发生了,而且不是第一次。

这事可以追溯到几年前,团队刚把单体拆成微服务的时候。订单系统作为上游,负责把“订单已创建”的事件丢进Kafka,让下游各自发挥:扣库存、发短信、喂分析管道。最初的设计图里,箭头干净利落,像一条笔直的高速公路。直到第一个生产事故出现,人们才发现,数据库和消息代理之间连个红绿灯都没有。

打开网易新闻 查看精彩图片

问题的起点,在分布式事务课本里早有标注:关系型数据库有自己严丝合缝的原子提交,消息队列也有自己的发布确认。可这两个系统之间,并没有任何共享的原子边界。应用层代码里先写库、后发消息,顺序一清二楚,但故障可不看调用顺序的脸色。数据库提交成功而Kafka推送失败,订单就成了数据孤岛;反过来,消息发布成功而数据库回滚,下游就会对着一个从未真实存在的订单辗转反侧。这就是双写问题,事件驱动架构里最经典的绊脚石。

不少团队第一时间想到的,是让协调者介入。两阶段提交、分布式XA事务、紧密耦合的协调协议,这些方案在理论层面没毛病,画在白板上甚至有种秩序之美。可真到了生产环境下,运维痛苦指数很快追上了设计的美感。锁等待变长,吞吐量塌方,协作者自身成了新的单点。大规模系统逐渐开始绕道走,不是因为前人错了,而是发现用一个全局大锁来维护一致性,代价远比当初想象的沉重。

发件箱模式的兴起,正是在这个节骨眼上。它没有试图抹掉分布式系统的复杂性,而是换了一条更务实的路。核心思路简单得不起眼:别让应用层直接去通知消息队列,而是把事件先写进业务数据库同一当事务里的“发件箱”表。这样一来,订单表和发件箱表的插入被同一个本地事务包裹——关系库的原子性就是最可靠的保证。事务提交后,再由一个独立的转发进程,把发件箱表里的记录发到Kafka,标记完成。即使转发途中崩溃,发件箱记录还在库里,重启后可以继续重发,幂等性由消费端自己兜着。

这一进一退之间,开发团队的处境悄悄变了味。过去要小心翼翼地编排两个系统的失败模式,现在只需盯住一个数据库事务的胜负。发件箱模式没有消灭分布式系统那种“部分失败”的宿命感,但它把最脆弱的那段链路,从应用层抬到了数据库的日志里。很多架构师把它和CQRS搭配着用,恰好因为事件溯源和发件箱天然合拍——命令侧把事件可靠地记下来,查询侧再从消息流里重建视图,两边再也不用面对面较量一致性心跳。

回看那个凌晨被叫醒的工程师,天亮后他没有去调校Kafka的重试参数,而是加了一张发件箱表,删掉了控制器里那句直连消息队列的代码。重新部署之后,生产环境的风浪仍会再来,但至少双写那个路口,不再需要人工盯梢了。