分布式系统 - 关于异地多活的一点笔记 - overview

异地多活(multi-datacenter)是饿厂在16年立项,17年完成的重大技术项目。众所周知,多活的技术挑战相当大,是分布式系统技术设计的高峰。实施过程几乎动员所有技术同学。有幸,作为一个业务开发,参与了后半部(实施部分)。虽然有过项目经验的同学都知道,一个项目最大的挑战和有意思的部分,都是在前期讨论的时期。最近又再系统的了解一下内部的整个异地多活方案,和一些相关内部项目的实现,收获很多,在这里整理一下。

异地多活的意义

异地多活可以说是分布式系统里规模最大的replication和partition。它的思路和意义当然也是可以用CAP理论解释的:在一定程度内牺牲C,换取AP。具体的意义是这几点:

  1. 无限的scale out。在单一机房的分布式系统,是无法真正的无限量的scale out的。对于无状态的节点,可以无限增加资源实现scale out。但有状态的集群,如mysql,redis,如果沿用single-master的架构,最终必然会成为整个分布式系统的瓶颈。这时就需要考虑multi-master的架构。在我的理解中,互联网架构里的multi-master几乎就等同于multi-datacenter(如果你有两个master,还把它们部署在同一个机房,太不划算了,技术的ROI太低了)。
  2. 机房级别的fault tolerant。这是异地多活最最重要的价值。当发生机房级别的故障时,可以把所有流量从一个机房切到另一个机房,实现机房级别的failover。
  3. performance。性能的提高来自就近访问,让处理请求的机器更靠近用户。

当公司规模发展到一定阶段,多活是个不得不做的架构升级。这个架构比单机房的架构复杂得多,下面来看看异地多活引入了什么问题和技术挑战,解决的思路是什么。

新增了什么问题

先简单看看一个异地多活的高度抽象的架构图:

Alt

多活架构下,两个机房同时工作,同时承担流量。两个机房间无论是数据,还是进程,都应该是等价的。在外部流量和机房之间,有一个全局的router,它负责判断一个请求应该被哪个机房处理。

在集群没有发生故障的时候,一个请求应该无论被路由到A机房还是B机房,都能被正确处理。

在发生故障的时候。通过人肉操作router,调度流量到健康的机房,实现failover。当流量切换到其他机房的时候,怎么保证流量还能被正确处理?最起码的是:两边机房的数据库都保存所有记录,两个机房的状态应该尽可能的一致。尽管如此,流量切换的瞬间,如果数据没有同步完,还是会有数据不一致和其他的毛刺。在failback的时候,又如果保证原来机房的状态是追平最新状态的?

在实现异地多活的时候,需要好好想清楚这些问题。

流量的sharding / routing

异地多活被认为优于冷备的一点是:在正常状态下,多活中的多个机房总是同时承担流量的。那么,一个外网的流量进来,该路由到哪个机房处理,是个需要解决的问题。

在实现上,可以在外网流量入口加入全局的router去调度流量,和一个全局的服务(global zone service)来支持routing规则的发布和管理。

更麻烦的是sharding / routing的规则:一个请求,基于什么原则判断,应该由A机房处理而不是B机房呢?

饿了么核心业务是外卖-配送,仔细想想,业务很有个重要的特点:一个核心场景:用户找到附近的商家,下单,这个订单推给了商家,商家确定之后,把订单推给附近的骑手配送。整个下单-接单-配送的过程,用户-商户-骑手三者的地理位置都非常接近(<= 10km)。在考虑流量sharding的时候,结合业务特点,可以总结出一个原则:

让一个订单的履单过程可以在一个机房中完成。

同时可以发现,一个订单过程中的流量,几乎都来自相近的地理位置。

multi-master replication

说完流量的入口层面,说说数据库层面的问题。在上面的架构图上就可以看到了,在数据库层面,如果你有两个IDC,那么就需要两个master。那就涉及多主库的数据库的复制问题。具体问题可以看这一篇

多master带来的最麻烦的问题就是数据冲突,常用的一个解决方法就是避免冲突。也就是:对于同一条记录,只能有一个master可以进行写入。具体的做法可以看上一篇文章。在这里还是关注饿厂特定的场景:

对于同一条记录,只能一个IDC可以写,那么,这个记录应该让哪个IDC写呢?怎样保证一条记录只能有一个IDC可以写?

判断一条记录可以由哪一个IDC写入,以及拒绝不该处理的数据,这一部分的逻辑,数据库是无法实现。其实是由数据库中间件处理的,它在上游就把非法的流量拒绝掉。

一个请求应该由哪个机房处理,和一条记录能由哪个master写入,其实是一回事(它们必须遵循相同的sharding规则)。所以,数据库中间件也会请求global zone service判断routing规则,实现将非法的写入请求拒绝掉。

两个机房间的数据同步(上面图中,两个master的双箭头曲线),是由另外一个中间件实现的。具体延迟在30ms左右,也就是:在机房A的写入,在30ms之后可以在机房B读取到。

failover时的数据一致性

考虑数据一致性问题,主要是两点:

  1. 在发生机房级别的failover时,如果目标机房的状态没有和故障机房完全同步,且同步的消息丢失,没有同步的状态就会丢失。
  2. 更麻烦的是:对同一条记录,在failover的时候没有完全同步,这时在切换后的机房有新的写入,那么这一个新的写入和复制的写入是并发写,就会产生诸如乱序写的问题。最终导致 数据无法修复

解决这一问题的思路又是CAP的选择。选择可用性的话,就是什么都不做,优先使大部分数据正常流转,异常数据事后修复。如果要保证一致性,那么一个方法是:当发生failover后,锁定老数据的修改(使用时间戳判断),直到所有数据复制之前,都不允许写入。在此期间,新数据可以产生,但是修改老数据时会直接在数据库中间件层报错。

在饿厂的实际操作中,是这样实现的:

  1. 每一张做多活的表都会增加一个timestamp字段,此字段业务是不感知的,纯粹给中间件使用。
  2. 在做流量切换的时候会记录一个timestamp为reshard_at。
  3. 此时需要做两件事,使两边机房的数据尽快同步:
    1. 阻止原机房的继续写入
    2. 阻止目标机房的写入
  4. 比较数据复制的timestamp是否追平了reshard_at。(由于数据复制中心是保证顺序一致性的,所以这个追平是可以保证的)
  5. 直到追平了reshard_at字段,再允许目标机房的写入流量。

就是要强一致

电商场景下必须要求强一致的场景是支付和交易。这种场景如果要做成多活,是弊大于利的。对有强一致要求的服务,采用单数据中心写入,其他数据中心只读的策略。

一个需要强一致的服务,它的部署架构是这样的:

Alt

如图可看到,和普通多活的区别是:是single-master的架构。在正常状态下,有一半的写流量会跨机房。这种路由是由中间件完成的,业务不感知。

在failover的时候,会把主库切到另一个机房,会出现的问题可见上一篇文章。

另一个更让业务开发人员讨厌的问题是:跨机房的主从延迟会增加到30ms以上,当时大部分的代码改造都在于,用各种手段避免主从延迟。

跨机房soa调用

在上图中,一个请求会在进入内网的时候就被路由到正确的机房。但实际情况中,总有例外:

  1. 集群内部发起的补偿路径,不是外网流量,那么要在请求处理的某一节点判断该由哪个机房处理。
  2. 批量操作的接口,比如要更新两个店铺,一个应该在机房A处理,一个应该在机房B处理,那必将产生跨机房的SOA调用。
  3. 非多活项目对多活项目的依赖。

这个问题的解决方案是通过新增一个中间件来实现的。具体的实现会在下一篇讨论一下。

最终方案

饿厂异地多活的最终架构可见下图:

Alt

上图是来自饿了么在QCon的演讲。其中,大部分的解决方案都在上文提到了。几个基础的组件都已经介绍了:

  • gzs是一个全局的(所有机房都可以访问的)服务,保存和推送sharding信息。
  • API router复制路由外网流量
  • SOAProxy实现跨机房调用
  • 强一致数据库 / 数据库层的拦截 由DAL实现(图上没展示)
  • DRC实现Mqsql的双向复制

图中还有两个概念,ezone / sharding。ezone和机房可以认为是一一对应的关系,sharding和ezone是逻辑mapping关系。

当发生多活切换时,实际上是切换sharding和ezone的mapping关系。比如在上图中,sharding 1 2 属于ezone 1,sharding 3 4 属于ezone 2。多活切换时,配置gzs,使sharding 1 2 3 4都属于ezone 1。即可实现流量调度。

下一篇,会讨论一下这些中间件的设计思路和需要突破的一些技术难点。

参考

  • https://blog.cdemi.io/design-patterns-cache-aside-pattern/
  • https://shadowbasesoftware.com/solutions/business-continuity/continuous-availability/
  • http://www.infoq.com/cn/presentations/the-infrastructure-construction-of-eleme