如果账户A中的余额大于100,那么从账户A中转账50元到账户B。
这是个非常简单的两个账户之间进行转账的例子。
数据库对于这样的事务已经有了一个核心的范式,也就是原子性,一致性,隔离性和持久性(ACID)。这是能够让用户放心使用事务的几个基本保证。有了他们,用户不用担心钱在转账过程中会丢失或者其他问题。让我们用这个例子来放到流处理应用中,来让流处理应用也能提供和数据相同的ACID支持:
原子性要求一个转账要不就完全完成,也就是说转账金额从一个账户减少,并增加到另一个账户,要不就两个账户的余额都没有变化。而不会只有一个账户余额改变。否则的话钱就会凭空减少或者凭空增加。
一致性和隔离性是说如果有很多用户同时想要进行转账,那么这些转账行为之间应该互不干扰,每个转账行为应该被独立的完成,并且完成后每个账户的余额应该是正确的。也就是说如果两个用户同时操作同一个账户,系统不应该出错。
持久性指的是如果一个操作已经完成,那么这个操作的结果会被妥善的保存而不会丢失。
我们假设持久性已经被满足。一个流处理器有状态,这个状态会被checkpoint,所以流处理器的状态是可恢复的。也就是说只要我们完成了一个修改,并且这个修改被checkpoint了,那么这个修改就是持久化的。
让我们来看看另外三个例子。设想一下,如果我们用流处理应用来实现这样一个转账系统会发生什么。我们先把问题简化一些,假设转账不需要有条件,仅仅是将50元从账户A转到账户,也就是说账户A的余额减少50元而账户B的余额增加50元。我们的系统是一个分布式的并行系统,而不是一个单机系统。简单起见我们假设系统中只有两台机器,这两台机器可以是不同的物理机或者是在YARN或者Kubernetes上不同的容器。总之它们是两个不同的流处理器实例,数据分布在这两个流处理器上。我们假设账户A的数据由其中一台机器维护,而账户B的数据有另一台机器维护。
现在我们要做个转账,将50元从账户A转移到账户B,我们把这个请求放进队列中,然后这个转账请求被分解为对账户A和B分别进行操作,并且根据键将这两个操作路由到维护账户A和维护账户B的这两台机器上,这两台机器分别根据要求对账户A和账户B的余额进行改动。这并不是事务操作,而只是两个独立无意义的改动。一旦我们将转账的请求改的稍微复杂一些就会发现问题。
下面我们假设转账是有条件的,我们只想在账户A的余额足够的情况下才进行转账,这样就已经有些不太对了。如果我们还是像之前那样操作,将这个转账请求分别发送给维护账户A和B的两台机器,如果A没有足够的余额,那么A的余额不会发生变化,而B的余额可能已经被改动了。我们就违反了一致性的要求。
我们看到我们需要首先以某种方式统一做出是否需要更改余额的决定,如果这个统一的决定中余额需要被修改,我们再进行修改余额的操作。所以我们先给维护A的余额的机器发送一个请求,让它查看A的余额。我们也可以对B做同样的事情,但是这个例子里面我们不关心B的余额。然后我们把所有这样的条件检查的请求汇总起来去检验条件是否满足。因为Flink这样的流处理器支持迭代,如果满足转账条件,我们可以把这个余额改动的操作放进迭代的反馈流当中来告诉对应的节点来进行余额修改。反之如果条件不满足,那么余额改动的操作将不会被放进反馈流。这个例子里面,通过这种方式我们可以正确的进行转账操作。从某种角度上来说我们实现了原子性,基于一个条件我们可以进行全部的余额修改,或者不进行任何余额修改。这部分依然还是比较直观的,更大的困难是在于如何做到并发请求的隔离性。
假设我们的系统没有变,但是系统中有多个并发的请求。我们在之前的演讲中已经知道,这样的并发可能达到每秒钟几十亿条。如图,我们的系统可能从两个流中同时接受请求。如果这两个请求同时到达,我们像之前那样将每个请求拆分成多个请求,首先检查余额条件,然后进行余额操作。然而我们发现这会带来问题。管理账户A的机器会首先检查A的余额是否大于50,然后又会检查A的余额是否大于100,因为两个条件都满足,所以两笔转账操作都会进行,但实际上账户A上的余额可能无法同时完成两笔转账,而只能完成50元或者100元的转账中的一笔。这里我们需要进一步思考怎么样来处理并发的请求,我们不能只是简单地并发处理请求,这会违反事务的保证。从某种角度来说,这是整个数据库事务的核心。数据库的专家们花了一些时间提供了不同解决方案,有的方案比较简单,有的则很复杂。但所有的方案都不是那么容易,尤其是在分布式系统当中。