如何建立Monzo银行后端系统?

Monzo需要从头开始构建一个银行后端体系,该系统必须全天候具备可用性,可扩展到为遍及世界各地的数以百万计客户提供服务。这篇文章解释了我们如何以开源的技术建立这样现代系统。

Monzo – Building a Modern Bank Backend

从一开始,我们就在后端建立分布式微服务架构,对于早期创业,这个架构是相当不寻常的,因为大多数企业会开始时使用众所周知的框架和关系数据库建立一种集中式的应用程序。

但是我们有一个野心,打造最好的一个典型案例,能够扩展到为数以百万计的客户服务,我们必须是拥有最好技术的银行。 每日发生间隙停机,单点故障或维护窗口在这里是不能接受的,客户希望全天候,不间断的能够访问他们的钞票账户,我们希望在几个小时内,而不是几个月时间不断推出新功能。

像亚马逊,Netflix和Twitter的大型网络公司已经表明,单片整体代码库是不能扩展到为大量用户服务的,而且关键的是无法扩展到大量的开发人员同时维护同一个整体代码库。 因此,如果我们要在许多市场中运作,每一个具有独特的要求,我们会需要很多团队,对产品的不同领域有不同的工作。 这些团队需要各自控制自己的开发,部署和规模 - 而不需要协调他们与其他球队的变化。 总之,开发过程中需要进行分配和去耦合,就像我们的软件架构一样。

开始时确实只有3个后端开发人员,这点我们必须务实。 我们必须选择正确的构建产品方向。

当我们推出了Monzo 公测时 ,我们的后端已发展到近100个服务(目前约150),我们的工程团队已长得太大。 我们知道我们的银行牌照颁发也迫在眉睫了,感觉这像一个好时机,重新考虑一些做出的架构选择。 我们举行了一个工程师会议,并确定了重点的几个方面:

1.集群管理
我们需要一个高效,自动化的方式来管理大量的服务器,其中有效地分配工作,并对机器故障作出反应。

使用Docker容器化集群方案,经过一年的Mesos和Marathon使用,决定改用Kubernetes。成本降低了1/4

2.多语种服务
Go是非常适合建设微服务,将可能作为Monzo的主要语言,但使用它仅仅并不意味着我们不能采取其他语言工具的优势。

共用抽象功能不是通过库包方式共享,而是直接通过服务方式共享,比如为了得到一个分布式锁功能,可以通过轻量RPC调用专门提供锁功能的服务,在所有共享基础设施比如数据库和消息队列之前建立相应服务,这样可以通过不同语言RPC客户端调用这些基础设施服务。


3.RPC传输
随着大量服务的跨主机分布,跨数据中心,甚至是跨大陆,我们系统的成功取决于有一个坚实的RPC层,可以定位组件故障线路,减少了等待时间,并帮助我们理解其运行时的行为。

RPC层有以下好处:
负载平衡:大多数HTTP客户端库可以基于DNS的平衡进行循环负载 ,但是这是一个相当生硬的手段。 理想情况下,负载平衡器将选择最合适的宿主主机,尽量减少故障和延迟,即使是在发生故障,但因为慢副本的存在,系统将保持快速恢复运行。

自动重试:在分布式系统中, 失败是必然的 。 如果下游服务处理失败的幂等请求时,它可以安全地重试另外不同的副本,这样以确保该系统即使存在发生故障的组件,也拥有一个整体的弹性。

连接池:为每个请求一个打开新的连接会有一个巨大延迟。 理想的情况下,请求将分配到预先存在的连接中以实现多路复用。

路由:它是非常强大的,能够改变RPC系统的运行时行为。 例如,当我们部署服务的新版本,我们可能要整体流量的一小部分发送给它以检查它的行为是否符合预期。 这个路由机制也可用于内部测试。

Finagle显然是最复杂的RPC系统。 它拥有所有我们希望更多的功能,而且它有一个非常干净的,模块化的架构。 已经在Twitter的生产中使用了好几年。 linkerd 是另外一个RPC代理。

我们不是直接服务器与服务器对话通讯,我们的服务是跟linkerd本地副本对话,然后请求被负载平衡器路由到下游节点,当幂等请求失败,它们会自动重试(使用budget )。 这种复杂的逻辑都没有被包含单个服务中,这意味着我们可以自由地写我们在任何语言服务,而无需维护许多这样与通讯对话有关的RPC库。

部署linkerd作为Kubernetes的 daemon set ,这样每个服务总是和localhost的linkerd交谈,它会转发请求。 在某些情况下,如果主机上不存在这两种需要对话交谈与通信的服务复制时。RPC实际上就不会遍历网络。

4.异步消息传递
为了使我们的后端高性能和弹性,我们使用消息队列来排队任务。这些队列必须提供强有力的保证,任务总是可以排队和出队,和信息永不丢失。

我们的后端的大部分工作都是异步的。 例如,即使终端到终端的支付处理时间不到一秒钟,我们还要让支付网络在几十毫秒内响应“批准”或“拒绝”Monzo银行卡上的交易。 这是通过让商家数据发送推送通知,甚至插入交易到用户的数据表中的工作都是异步发生。

尽管如此异步性,这些步骤是非常重要的, 绝不能跳过。 即使发生错误而不能自动恢复,也必须解决它,并恢复该过程。 这给了我们为我们的异步架构几个要求:

高可用:发送者必须能够对消息队列中“fire-and-forget”,无论失败的节点或下游消费者信息的状态是什么状态,都不影响发送者,发送者发送消息后就忘记了,不管后面结果。

可扩展性:我们必须能够对消息队列扩容,同时无需中断服务,无需升级硬件 - 消息队列本身必须是横向扩展,就像我们服务的其余部分。

持久性:如果消息队列的节点出现故障,我们不能丢失数据。 同样,如果消息消费者失败,它应该可以重新传递消息,然后再试一次。

再现:它是能够从历史上的一个点重播消息流,这对新的进程可以在旧数据运行(和重新运行)非常有用。

至少有一次交付:所有信息必须传递到他们的消费者,虽然确切地一次传递信息一般是不可能的,但是消息传递操作的“正常”模式就不应该是消息将被传递多次。

Kafka似乎是天然的适合这些要求。 它的架构是非常不寻常的一个消息队列-它实际上复制的是提交的日志 。它的复制,分区设计意味着它可以容忍节点故障,并且放大和缩小而无需中断服务。

其消费者的设计也很有意思:大多数系统可为每个消费者维护其自己的消息队列,卡夫卡一个消费者仅仅是对应到消息日志一个“游标”。这使得发布/订阅系统的成本要低得多,而且由于消息无论什么时候都会保存一段时间,我们就可以轻松播放早期事件回到某些服务。