事务机制:把工作流状态和数据库放一块儿到底香不香?

把工作流状态和业务数据放在同一个数据库里,听着像把洗衣粉和洗衣液倒一块儿,其实这招能用数据库事务搞定分布式系统里最头疼的重复执行和数据不一致问题,省掉一堆复杂代码和额外维护成本。

工作流状态和业务数据为啥非要挤在一个数据库里

大家平常做系统设计,脑子里第一反应就是拆分。数据库分好几个,缓存来一套,消息队列再整一套,各管各的,感觉这样才专业才稳当。工作流引擎管工作流状态,业务数据库管业务数据,分工明确,看着特别清爽。

但分布式系统这玩意儿吧,它不讲武德。网络会断,服务器会崩,进程会莫名其妙挂掉。你代码写得再漂亮,也扛不住这些物理世界的随机打击。这时候你就发现,分得越开,出事儿的时候越难收拾。

把工作流状态和业务数据塞进同一个Postgres数据库,听起来有点反直觉,但这么干有个巨大好处:所有更新都能塞进同一个数据库事务里。事务这东西,要么全成要么全挂,不存在中间状态。这就把分布式系统里最烦人的部分失败问题给一锅端了。

事务怎么给幂等性这个老大难问题做减法

先说说幂等性。这词儿听着高级,其实就是同一个操作执行一次和执行一百次,结果必须一样。举个生活例子,你按电梯按钮,按一次电梯来了,狂按一百次电梯也不会来一百台,这个按钮就是幂等的。

放到银行转账场景就头疼了。系统要给用户账户加一百块钱,这操作本身不幂等。加一次是一百,加两次就成了两百。工作流引擎为了保证系统容错,会在每一步执行完记个检查点。万一工作流中途挂了,恢复的时候就从最后一个检查点接着跑。

问题出在时间差上。步骤执行完了,账也加了,但还没来得及记检查点,系统崩了。恢复的时候一看,没检查点记录,以为这步没跑过,又来一遍。完了,账户多了一百块,财务对不上账了。

常规解法是在应用层做记账。搞个额外的已付款记录表,每次加钱之前先查这个表,看这笔钱是不是已经加过了。代码复杂度直接飙升,还得小心翼翼处理并发问题。

把工作流引擎嵌进同一个数据库就简单多了。引擎给步骤执行提供一个数据库事务,步骤在这个事务里改数据,引擎在这个事务里写检查点,然后整个事务一起提交。要么数据库更新和检查点都存下来,要么一起回滚啥都不剩。

这么一来,事务型步骤就有了精确一次执行的保证。事务提交了,更新和检查点都在,这步永远不会再跑。事务没提交,更新和检查点一起消失,恢复后从头再来。中间那个“更新成了但检查点没记”的危险窗口直接没了,应用层那些防重复的记账表和判断逻辑都可以删掉。

事务型工作流出站箱怎么把多个系统的操作绑在一起

分布式系统里另一个经典场景:更新完数据库还得通知别的系统。比如用户下了订单,得通知仓库去发货。两个操作必须原子化,要么都成要么都不成。但数据库和通知服务是两个独立系统,网络一抖或者进程一崩,就容易出现订单存了但仓库没收到通知,或者仓库收到通知但订单根本没存进去的尴尬情况。

业界对付这个问题有个标准套路叫事务型出站箱。在数据库里建一张出站箱表,更新业务数据的时候,在同一个事务里往这张表插一条消息。然后有个后台进程轮询这张表,把消息发给目标系统。

这招管用,但运维成本不低。你得搭轮询服务,得处理消息重发,得监控发送失败的情况,还得定期跑对账任务去查哪些业务记录更新了但消息没发出去。工作流引擎要是独立部署,跟数据库之间还会出现状态不同步的问题。

用数据库自带的工作流机制,这事儿就简单了:Postgres支持用户自定义函数,你在更新业务数据的同一个事务里调用一个入队工作流的函数就行。这个函数在数据库里插一条工作流记录,包含要跑的流程名称和输入参数。业务数据更新和工作流入队在同一个事务里提交,原子性自然就有了保证。

事务提交之后,后台的工作线程把这条工作流记录取出来异步执行,该通知仓库通知仓库,该干啥干啥。出站箱表不用手动维护了,轮询服务不用自己写了,对账任务也省了,所有逻辑都在数据库事务的保护下自动完成。

数据库事务为啥是分布式系统的秘密武器

很多人觉得数据库事务就是用来保证数据一致性的,这理解没错但太窄了。事务其实是分布式系统里最靠谱的协调机制,它能把跨越多个步骤、多个系统的操作绑成一个不可分割的整体。

你仔细想想,分布式系统里所有让人头疼的问题,归根结底都是状态不一致。幂等性问题本质是执行状态和业务数据不一致,出站箱问题本质是业务数据和消息发送状态不一致。而事务天生就是解决状态不一致的。

把工作流状态和业务数据放一起,就是把所有需要保持一致的状态都塞进事务的保护范围。工作流执行到哪一步了、业务数据改成啥样了、下一步该触发啥操作,所有这些信息都在同一个数据库里,由同一个事务管理器来协调。

这就好比你要同时搬三件家具,一件一件搬容易丢。但你要把它们全绑在一个板车上,推着板车走,要么三件一起到,要么一件都别动。数据库事务就是这个板车。

这套玩法到底能省多少事儿

咱们算算账。传统做法里,要实现可靠的分布式工作流,你得维护的东西有这些:工作流引擎自己的数据库存执行状态;业务数据库存业务数据;出站箱表和配套的轮询服务;应用层的幂等性检查逻辑和记账表;定期跑的对账修复任务;处理各种部分失败情况的异常补偿代码。

每个组件都有自己的配置、监控、告警、扩容逻辑,出一回故障可能得查好几个系统的日志才能定位问题。运维这套东西的精力足够一个小团队全职干。

把工作流状态和业务数据共存在一个Postgres数据库里,上面这一大堆全砍掉。工作流引擎不需要独立数据库了,出站箱表和轮询服务不需要了,幂等性检查代码和记账表可以删掉大部分,对账任务基本上不用跑了,异常补偿代码也大幅简化。

数据库本身就提供了事务、持久化、并发控制这些能力,工作流引擎直接坐在这上面享用就行。不用重复造轮子,不用维护一堆胶水代码把不同系统粘在一起,整个系统的复杂度和故障率都降下来了。

这事儿真能这么干吗

肯定有人嘀咕了,把工作流状态和业务数据塞同一个库,业务数据量那么大,工作流记录又不停地增删改,会不会把数据库干崩了。这担心有道理但有点过时了,Postgres单表几亿条记录轻松驾驭,索引设计合理的话查询毫秒级响应,事务吞吐量只要不是变态级别基本扛得住。

还有人担心工作流引擎和业务耦合太紧,以后想换引擎或者换数据库就麻烦了。这问题得换个角度看,把状态放一起本来就是为了利用事务的原子性保证,你要是为了将来可能发生的迁移而牺牲现在每天都要面对的可靠性,这笔账划不来。而且核心业务逻辑和工作流定义本身还是可以抽象解耦的,只是底层存储共用而已。

数据库挂了怎么办。这问题问得好,但数据库挂了的情况下,独立的数据库加独立的工作流引擎也好不到哪去,只会更糟因为涉及两个系统的恢复和状态对齐。共用一个数据库,挂了就挂了一致地挂,恢复也一致地恢复,处理起来反而简单。


总结

分布式系统把状态分开放是为了隔离,但共用一个数据库事务能让状态更新变成原子操作,消除了部分失败导致的数据不一致问题。这种方案用数据库自身能力替代了大量复杂的应用层协调代码。