如何设计一个分布式计数服务?

楔子

假设有这样的一个场景,你去 B 站面试架构师的职位,面试官让你设计一个计数服务,该服务能够对每个视频的观看数量进行实时的计数统计,或者对某个 UP 主的粉丝、获赞数进行统计等等。

当然很多服务其实都可以抽象成计数服务,比如采集系统、监控系统等等,所以计数问题是一个很普遍的问题。那么面对这样一个需求,我们要怎么做呢?首先,我们不应该上来就寻找解决方案,而是要先搞清楚具体的需求,因为当前这个问题其实是很模糊的,需要补充很多的细节,才能算需求明确。

但面试官就喜欢提出一些很粗的问题,主要是想借此考察候选人的主动性和沟通能力,包括在工作中也是,主管也喜欢提出一些很粗的问题(有可能是他懒得想细节,或者他也想不清楚),这个时候能够主动沟通、澄清需求的员工,和被动等待不知所措的员工相比,更受主管的青睐,面试也是同理。

此外,在需求不明确的情况下,针对同样的问题,不同背景的人往往会给出不同的解决方案。就拿统计视频观看数为例,有数据库背景的人可能会用 MySQL 或 Oracle 解决问题,有 NoSQL 背景的人可能会拿 MongoDB 或 Cassandra 解决问题,有大数据背景的人可能会用 Hadoop MapReduce 批处理或者是 Spark Flink 流处理来解决问题,诸如此类。

尽管解决方案有多种,但它们可能和实际需求是有偏差的,或者说并不能满足实际的需求,需要澄清的技术细节往往很多,主要有如下四点:

  • 场景用例:首先是谁在用这个服务,一般用户?还是业务的运营团队?大数据 BI 团队?还是第三方广告商?明确之后还要了解用户如何用这个系统,主要的接口功能有哪些等等
  • 量级规模:主要包含读写两个方面,比如每秒钟要支持多少个查询请求、每个查询请求要支持多少的数据量,每秒要处理多少个视频观看记录,网站的流量模式是怎么样的,是否有流量高峰等等
  • 性能:从写入到读取数据的延迟是多少,也就是用户观看了一个视频,要多久之后才能看到视频的观看数被更新了,是瞬间更新,还是隔一段时间更新,比如 1 秒钟、1 分钟、5 分钟,或者更高等等。这个问题很重要,因为它决定了这是一个线下的批处理系统,还是一个实时或近实时的处理系统。再有就是预期的 p99 读请求延迟是多少(99% 的读请求所能容忍的最大延迟),这个问题决定了是否要引入缓存。高可用也可以算作是性能的一方面,一般高可用是隐含条件,因为几乎所有的系统都需要高可用
  • 成本:最后要弄清楚这个服务开发和运维的成本需求,以及开发人员的平均水平,在公司开发人员紧张或开发人员水平比较一般的情况下,就不能考虑引入一些高大上的重武器,比如 Hadoop、HBase、Spark、Flink 等等

架构设计

功能需求如果明确下来,那么下面就把它的 API 也明确下来。API 主要包含两类,一类是写入处理的 API,一类是读取查询的 API。针对视频播放量计数这个场景,写入处理 API 可以这样设计:

  • countViewEvent(videoId):根据视频 ID 来对视频记录进行一次计数
  • countEvent(videoId, eventType):根据视频 Id 和事件类型(比如分享、喜欢、转发等等)来对视频进行一次计数
  • processEvent(videoId, eventType, aggFunc):根据 videoId、eventType、aggFunc 进行聚合运算
  • processEvents(listOfEvents):可以对一列事件进行批量计数

读取查询 API 可以这样设计:

  • getViewCount(videoId, startTime, endTime):查询某个时间段内某个视频的观看数
  • getCount(videoId, eventType, startTime, endTime):查询某个时间段内某个视频的某个事件的计数
  • getStats(videoId, eventType, aggFunc, startTime, endTime):对某个时间段内某个视频的某个事件进行聚合运算

当然一个系统除了有功能需求,还要有非功能需求,常见的非功能需求包括高性能、高可用、可扩展、成本和可维护性等等。非功能需求对系统的架构和设计是非常关键的,所以也必须和面试官沟通清楚,比如面试官告诉你如下一些非功能需求:

  • 每秒处理 1w+ 视频点击观看记录
  • 写入/读取 毫秒级延迟
  • 写入到读取更新分钟级延迟,近实时流处理,最终一致
  • 高可用,无单点失败
  • 容量不够的时候,可以水平按需扩展
  • 公司资源有限,尽量开源低成本的技术

到此我们的功能需求和非功能需求就非常明确了,在进入详细的设计之前,我们要先给一个简化的总体架构。

一条写入路径,一条读取路径,在写入路径上面,用户通过浏览器观看视频,观看记录被写入计数服务,技术服务将结果写入数据库。在读取路径上面,用户通过浏览器读取视频信息,请求通过查询服务,再到数据库获取视频观看数据。

有了这个架构,我们就可以以它为基础,逐步细化设计它的每一个组件,从架构图上看, 我们后续需要重点细化数据库、计数服务和查询服务三个组件的设计。如果面试官问你应该从哪个组件开始设计,我们认为分布式系统的核心是存储,因此可以从数据存储开始设计。

存储设计

既然要存储,我们首先要明白究竟要存什么数据,针对 B 站视频观看数,可以有两种存储的方法。

  • 存储单个事件:每当用户点击一个视频,我们就存一个视频观看的事件,比如某个用户在某个时间观看了某个视频。采用这种方法我们可以快速的写入观看事件,而且后面可以按需做各种聚合运算,因为原始记录都存在。但是这种做法有一个问题,首先是每次查询都要做重复计算、会造成查询慢,其次是记录所有用户的点击事件,会耗费存储
  • 存储聚合好的数据:按照分钟级将每个视频的观看数先聚合好之后再存储,比如某个视频在某分钟内被观看了多少次,这种做法的好处是查询快、存储少。但是也有一些问题,首先只能按照聚合好的方式进行查询,其次要引入实时聚合的技术,最后如果聚合数据是有误的,会导致无法简单修复,因为没有原始记录

在实际企业中具体存什么数据,要根据场景需求综合判断,因此折中的做法是单个事件和聚合数据都分别进行存储,这样就可以同时获得两种做法的好处,当然耗存储是难免的。

知道了存什么,那么接下来就选择一种合适的数据库产品,面试官可能会问你,你认为哪种数据库合适呢?原因是什么?针对这个问题,我们仍然要以之前的需求,尤其是非功能需求,作为数据库选型的主要依据。

  • 1. 数据库是可扩展的,支持根据读写规模的按需扩展
  • 2. 数据库要求具备高性能,支持快速的读写
  • 3. 高可用,不丢数据,支持灾难恢复
  • 4. 一致性折中,现代数据库一般支持不同的一致性特性,比如传统的 SQL 数据库大都是强一致性的,而有些 NoSQL 数据库是最终一致性的。针对视频计数这个场景,强一致性和最终一致性都是可以考虑的,因为用户不关心视频观看的实时的准确性,只要最终正确即可
  • 5. 是否易于对数据模型进行升级,因为后续如果对系统升级,难免要调整数据模型
  • 6. 开发和学习成本
  • 7. 开发者的技能级(Skillset),如果选了一个冷门的数据库,不容易找到具有相关技能的开发者,后续维护的时候找不到人也是一个问题

针对视频观看计数这个场景,主流的 SQL 和 NoSQL 都是可以满足上述需求的,不过为了你技能的全面性,你可以同时向面试官分析这两种方案。

SQL 数据库 + 客户端嵌入代理

如果采用传统的数据库,比如 MySQL,那么单台 MySQL 可以满足一般的数据量和性能需求。但是对于 B 站的量级规模,单个 MySQL 肯定是扛不住的,为了满足扩展性需求,我们需要考虑对数据库进行分区存储,也就是我们常说的 Sharding(分片)。比如我们可以采用一致性哈希的方式,把不同的视频数据存储在不同的 MySQL 节点上。后续根据扩容需要,还可以进一步的 Sharding 分摊负载,也就是实现按需扩展(需要面临数据迁移)。

不过 Sharding 只是解决了分摊负载的问题,并没有解决高可用的问题,为此我们还要考虑数据复制,也就是 Replication 技术。将同一份数据存储在两个或两个以上的数据库中,这样即使一个数据库挂了,也能用另外一个数据副本进行恢复。而对于 MySQL 而言,主从复制是比较成熟的技术,而且支持自动故障切换,生产当中一般采用一主一从、一主二从。当然主从复制之后还可以实现读写分离,写入数据写到主库上面,读取数据会到从库上面读,因此可以进一步分摊负载,减轻主库的压力。因为目前大部分网站的流量模式都是读多写少,为了屏蔽 Sharding 和主从复制引入的复杂性,我们一般需要引入数据库访问代理,该代理可以直接以客户库的形式嵌入到我们的应用程序当中。

ShardingSphere 之前被称为 ShardingJDBC,就是支持这种模式的一个主流的开源数据库代理产品,在引入了 Sharding、主从复制和 ShardingSphere 嵌入式代理之后,每次这个计数服务有写入数据库的动作,ShardingSphere 会将请求正确路由到对应的主库上进行写入。每次查询服务有查询数据库的动作,ShardingSphere 也会将请求路由到对应的从库上进行查询。

SQL + 独立部署代理层

除了客户端嵌入代理模式,ShardingSphere 也支持独立部署对的代理模式,这种模式对应用程序更加透明,可以完全屏蔽后台 Sharding 和主从复制的细节。

不管是客户端嵌入代理模式,还是独立部署的代理层模式,ShardingSphere 都需要知道后台数据库的配置,所以它提供注册中心 Registry Center 来支持对后台数据库的配置管理,它相当于是一个配置中心加上服务发现这样一个角色。另外在独立部署代理层模式下,Sharding Proxy 本身需要高可用部署,所以前置需要引入一个负载均衡设备来支持,比如 F5 或者软件 HAPROXY。

NoSQL 数据库(Cassandra)

虽然传统的 SQL 数据库可以满足我们的需求,但是为了实现可扩展和高可用,它引入了很多的复杂性。比如需要做手工 Sharding、需要引入主从复制机制、还需要引入数据库访问代理和配置中心这些组件。另外对于传统数据库 Sharding 之后,后续如果要扩展再 Sharding 的话(Re-Sharding),需要手工导数据,非常麻烦。

为了解决上述问题,业界已经推出了相应的 NoSQL 解决方案,比如 Cassandra 就是一种比较主流的开源分布式 NoSQL 数据库,比较适合时间序列存储的场景。

在一个 Cassandra 集群中也有分区的概念,数据也会按照某种 hash 算法(比如一致性哈希)分散在不同的节点上,并且这些节点都是对等的,没有主从之分、也没有集中的代理角色。Cassandra 节点之间会定期的交换彼此的信息,通过 Gossip 协议(该协议提供三种数据传输模式:直接邮寄、反熵、谣言传播),该协议运行的最终效果是集群中的所有节点都会知道其它的节点,也知道每个节点具体负责哪个分区范围。

当计数服务要写入一个 A 视频的观看记录,它首先将请求发送到一个被称为协调者(Coordinator)的节点,比如图中的 Node4,该节点可以按照轮询的方式选出来,也可以按照某种就近策略选出来。Node 4 知道 A 视频的记录应该存在哪个节点上,于是便将记录写到对应的节点上。并且为了高可用,Node 4 也会进行复制(Replication)动作,它会再将这个记录写入另外的节点,比如 Node 2 和 Node 3 都会写一份。至于到底要复制多少分,在 Cassandra 中由「复制因子」决定,并且是可配置的,这里假定是 3。于是一个问题就产生了,如果要等到三次都写入成功再返回的话,性能就会有影响,所以 Cassandra 支持所谓的仲裁写(Quorum Write)。只要节点中的多数(比如两个)认为已经写入成功,那么 Node 4 就认为该记录写入成功、可以返回结果。

同样,当查询服务要读取 A 视频的观看记录,也要将请求先发送给协调者节点(Node 4),Node 4 会从多个节点上读取副本进行校验。为了提升性能,只要多数节点返回的数据是一致的,那么 Node 4 就可以将这个结果返回给查询服务,这个做法也叫做仲裁读(Quorum Read)。

因此从读写方式我们可以看出,Cassandra 是一个支持最终一致的数据库,实际上 Cassandra 的一致性模式可以根据需要进行调整,有兴趣可以参考 Cassandra 官方文档,这里不展开了。

而采用 Cassandra 数据库的一大好处就是它支持按需动态添加节点,可以线性扩容,无需人工干预。另外,Cassandra 是原生支持跨数据中心的,比如 X 数据中心的数据副本可以自动复制到 Y 数据中心。如果 X 数据中心挂了,但数据在另一个数据中心仍然存在。这些特性是 Cassandra 区别于传统 SQL 数据库的最大优势。

当然 Cassandra 的运维管理难度也不低,而且市场上具有 Cassandra 开发和运维经验的人也没有 MySQL 多。所以在实际企业当中,到底选择 MySQL 还是 Cassandra 需要根据具体情况进行权衡。

表设计

我们分析过,不管是采用类似 MySQL 的关系型数据库,还是类似 Cassandra 的 NoSQL 数据库,都可以满足分布式计数的需求,但是针对这两种方案的表设计模型是完全不同的。采用 MySQL 和 Cassandra 的数据表设计方式如下:

对于 MySQL 而言,需要设计三张表,video_info 记录视频的基本信息、所属用户等等,当然一个视频还有所属分区、所属空间等等;video_status 记录视频统计信息表;第三张表是用户信息表,记录这个视频所属哪一个用户、分区等等,这里不写那么复杂,尽量简化一下。我们看到对于一个视频计数而言,需要三张表,因为传统 SQL 数据库的设计目标就是尽可能的规范化(范式理论)。规范化的好处是可以减少数据的冗余,但不便的是查询的时候比较麻烦,因为会涉及到大量的 JOIN。

和关系型数据不同,NoSQL 数据表的设计目标是尽可能的反规范化,它是为了方便查询而设计的,允许必要的冗余。比如在 Cassandra 的数据表中,我们可以将一个视频一天的访问计数建模在一行当中,这一行同时存储空间和视频名称。而且 Cassandra 还支持所谓的宽行(wide row)特性,我们可以将每一分钟的观看数依次记录在一个个的 column 当中,按这种方式建模,在行中添加(Append)某分钟的观看数会非常快,后续查询某个视频在某个时间段内的观看计数也会非常快,并且无需 JOIN。

到此,我们的存储设计就完成,下面来开始计数服务的设计。

计数服务设计

计数服务是一个写入计算服务,在正式开展设计之前我们仍然要回顾一下面试官提出的需求。这些需要主要包括:

  • 可扩展:可以根据写入规模按需扩展
  • 高性能:快速写入,高吞吐,在单位时间内能够同时处理的请求数要尽可能的多
  • 高可用:不丢数据,即使数据库慢或者不可用、如果出现灾难性的故障,数据要能够恢复

我们说计数服务是一个典型的数据处理问题,那么该如何实现可扩展、高性能和高可用的数据处理呢。实际上根据系统设计的一般性思路,要实现可扩展,我们首先要对写入的数据进行分区(Partitioning / Sharding);要实现高性能,一般需要借助内存缓存技术,实现高吞吐,一般要适当的进行 batch 处理;要实现高可靠不丢失数据,一般要对这个数据做持久化,而且要借助复制技术 Replication 以及检查点 Checkpoint 技术。

数据聚合

另外在正式开展设计之前,我们需要先补充一些数据聚合相关的基础知识。

首先对于计数这个问题,我们通常有两种处理思路,一种是每次把用户观看一个视频,我们就更新一下数据库,将计数加 1。比如三个用户同时观看视频 A,每个请求都会对数据库中的观看数加 1,最终 A 的计数就是 3。这种做法简单,而且实时性好,但是当写入规模很大的时候,因为频繁写入 DB,会造成 DB 的性能问题。

另外一种做法是现在计数服务的内存里面进行批量聚合运算,再定期将这个结果异步写入 DB。比如三个用户同时观看视频 A,这些请求现在计数服务的内存当中进行预聚合得到结果 3,一段时间后(比如以 1 分钟为周期),后台线程再一次性的将结果写入 DB。这种方式稍微复杂一些,而且会引入一定的批量延迟,但是可以大大地减轻 DB 的压力,提升总体的吞吐量。而在大规模数据处理的时候,为了提升总体的吞吐量,我们通常采用预聚合加批处理的方式。

其次关于数据处理,还有一个是采用「推(push)」还是采用「拉(pull)」的问题,怎么理解呢?假设用户请求直接写入计数服务,计数服务再将结果写入数据库,没有任何中间缓冲环节,那么当出现流量高峰导致计数服务繁忙不响应,或者计数服务被流浪冲垮了,这个时候后续的请求就写不进来,就会丢数据,从而不满足高可用。用户直接将请求推到计数服务,计数服务将计算结果推到 DB,可以认为这是一种全程推的模式。

为了解决这个问题,业界实践的做法通常是在用户请求和计数服务之间增加一个消息队列 MQ,MQ 可以先把用户的请求先缓冲起来,也就是所谓的「流量削峰」,后台计数服务再从 MQ 中拉取请求进行计算,所以就变成了一中「拉模式(pull)」。在大规模的消息处理当中拉模式可以缓冲生产与消费速度不匹配的问题,起到一个流量削峰的作用,同时引入 MQ 还可以将生产和消费尽心解耦,即便后台计数服务需要下线、维护升级,MQ 仍然可以缓冲消息,不会丢消息,也就是实现高可用。

消息队列

既然我们的服务要引入消息队列,那么我们就先来介绍一下消息队列相关的基础知识,我们就以 kafka 为例。

我们知道 kafka 是业界主流的一款开源消息队列产品,在大数据和微服务等场景下,kafka 都非常广泛的引用。这里先介绍一下 kafka 两个重要的概念:

kafka 的队列可以简单抽象理解为一个数组,这个数组是有下标的,从 0 1 2 3 开始依次递增,每个消息依次进入数组。比方说 A 先进入占据下标 0 的位置,其次 B 再进入占据下标 1 的位置,以此类推。下标在 kafka 中也被称作偏移量 Offset,在 kafka 中的消息消费,其实底层对应的是一个消费指针,简单可以理解为一个变量。这个变量存储下一个要被消费的元素的下标,比如图中的下标指向 3,说明前面的三个元素 A、B、C 已经被消费了,每次消费一个元素,消费指针都会递增 1。

kafka 的消费指针其实对应了数据处理当中一个很重要的概念,叫检查点(Checkpoint),它的值必须被保存起来, 必须记住已经消费到哪个下标了,或者记住下一个要消费的元素的下标。下次如果消费者重启了,它可以从 Checkpoint 开始重新消费,不会丢失消息。但是问题来了,kafka 怎么知道某个消费者的 Checkpoint,因此这个 Cehckpoint 是需要提交的,比如 1 号消费者消费到了第 100 条消息,那么它的 Checkpoint 就是 100 也就是指向第 101 条消息,但是 1 号消费得告诉 kafka 自己的 Checkpoint,说简单点就是要将自己即将消费的消息的偏移量提交上去,让 kafka 记录下来,这样消费者重启之后才可以从指定的 Checkpoint 处开始消费。

提交 Checkpoint,或者说偏移量,有两种方式。第一种是自动提交,但这是有频率的,在 kafka 中为了提升消费性能,消费指针并不是实时同步提交(每消费一条消息就提交一次),而是定期异步提交(相当于消费一定数量的消息之后再提交)。所以,如果消费者消费了一定数量的消息之后,但没有提交就因为某种原因挂掉了,那么重启可能会出现消息重复消费的情况。第二种方式是手动提交,就是什么时候提交你可以自己决定,可操控行更强一些。

kafka 中另一个重要的概念就是分区(Partition),这个比较好理解,如果数据量大,消息队列也要对消息进行分区存储。比如我们可以创建 3 个分区,当用户有视频请求输入,我们可以按照某种负载均衡策略把消息分别存放在三个分区当中进行负载分摊。并且后续还可以根据容量的需要增加更多的分区,当然针对不同的分区需要不同的消费者分别进行消费处理。

计数消费者(设计)

有了分区队列,下面让我们把视角移到消费者端,看看一个典型的数据消费者该如何来设计,首先消费者内部也需要一个小型的数据处理的流水线(Pipeline)。

流水线上的第一个角色叫分区消费者,它负责拉取对应分区中的消息,并且缓冲在内存的队列当中。分区消费者角色一般由 MQ 的客户端来承担,比方说 kafka client,kafka client 还具有自动提交偏移量的功能,也就是 Checkpoint 的功能。

流水线上的第二个角色是聚合运算器(Aggregator),这个需要开发者自己开发,它消费队列中的消息实现聚合运算逻辑。对于计数服务的 Aggregator,它就负责对视频的每分钟的观看量进行累加计算,并且消费运算可以用多线程并发来做。这个 Aggregator 会定期(比如每分钟)将计算结果写入到内部的另一个队列,但问题来了,为什么还要引入一个队列,而不是直接写入 DB 呢?其实原因还是考虑到高可用,因为后台的 DB 可能会慢或者暂时不可用,如果没有内部的队列做缓冲,那么计算的结果可能会丢失。注意内部的队列也是需要考虑高可用的,否则消费者挂了,计算结果也有可能丢失。一般内部队列可以考虑采用磁盘持久化的队列,或者采用嵌入式的 DB,甚至还可以把计算结果再发送到 kafka 队列中,因为 kafka 是持久化的。

流水线上的最后一个角色就是 DB Writer,它负责将内部队列存储的计算结果,最终写入到 DB 当中。尽管我们引入了独立的 DB Writer,但是后台 DB 慢或暂时不可用的问题还是存在的。为了解决这个问题,我们需要引入所谓的死信队列(Dead-letter Queue)。如果 DB Writer 暂时无法写入 DB,它可以将结果写入私信队列,然后再引入后台线程单独处理私信队列,重试写入 DB,这样才可以做到不丢失数据。死信队列可以驻留在消费者的本地,也可以是 kafka 上的一个单独的队列,在 DB Writer 将数据写入 DB 之前,常常还需要做一些所谓的数据填充工作(Data Enrichment)。那么什么是 Data Enrichment 呢?前面我们提到,假设采用 Cassandra 数据模型,那么行记录里面除了有 video_id、时间、计数等数据之外,还需要视频名称、用户信息、空间信息等相关数据,这些数据在原始请求中一般是没有的,为了获得这些数据,我们需要到其它相关数据库中进行查询,把数据填充完善之后再写入 DB,这个过程就叫做 Data Enrichment。显然 DB Writer 每次都到其它 DB 中查询数据会导致性能变慢,所以我们可以考虑引入缓存 Enrich Data Cache。

这个缓存可以是集中的 Redis 缓存,也可以是消费者本地嵌入式的缓存。

最后说明一下,虽然我们演示的是每分钟的聚合,但只要理解流式计算的思想,我们很容易实现每小时、甚至每天的聚合。比方说我们可以把每分钟聚合的结果再发送给 kafka 队列,再按照每小时进行聚合;同样的道理,再按照每小时聚合的结果可以得到每天的聚合结果。流式计算的原理,其实就是 kafka stream 这类产品它的背后的原理。另外呢,对于每小时、每天,甚至每月和每年的聚合,因为实时性要求不高,完全可以通过线下批处理的方式进行计算,比如用 MapReduce,或者简单的数据库存储过程也是可以的。

数据接收路径设计

上面我们引入了消息队列 MQ,也完成了计数消费者(Counting Consumer)的设计,在此基础上,我们在前端还要再引入两个组件。

一个是 Counting Service,它负责接收用户的写入请求,并通过 MQ 的 client 将消息发送到对应的分区。那为什么需要这个服务呢?很显然,用户不能直接将消息写入消息队列,需要 Counting Service 中间代理进行转发。

另外呢,还需要引入一个 API Gateway,也就是所谓网关。因为用户也不能直接访问 Counting Service,而是要通过网关进行转发。网关在微服务架构中也是一个比较重要的组成部分,我们知道微服务大致可以分为如下几层:

Dao:

数据访问层,封装对数据库的访问,比如增删改查,不涉及业务逻辑,只是按照某个条件获取指定数据并返回。

 

Service:

专注于业务逻辑的子服务,对于其中需要的数据库操作,都通过 Dao 去实现。

 

BFF:

用来做数据组装,那为什么要有这一步呢?因为微服务讲究单一职责,每一个子服务都是原子服务、独立进程、隔离部署、去中心化,一个服务只干一件事情。要想满足某个用户场景,可能需要调用十几个微服务,才能将数据组装出来。如果没有 BFF,那么这一步只能让客户端来做,但这其实会非常难,原因如下。

1)客户端到每个微服务之间直接通讯,强耦合,这种情况下很难做到微服务的大规模重构。浏览器还好,如果是移动端的话,你的接口外网在用,有人请求的时候要保证他的服务质量。否则的话,你只能强迫用户升级 APP,但这会让用户的体验变差。一个好的 APP,应该是不管什么版本都能正常使用,不能因为升级,就导致老版本的 APP 无法使用。所以,如果客户端到微服务之间如果强耦合,那么你在升级服务的时候就要小心了,要保证能够兼容老版本的 APP。

2)需要多次请求,客户端聚合数据,工作量大,要调用这么多的服务,每个部门的接口长得还都不一样,沟通成本太大了,不符合康威定律。另外客户端聚合数据会导致延迟高,因为客户端是直接面向用户的,一定要讲究效率。

3)不同的机型需要单独进行适配,像手机、平板,操作系统有安卓、IOS,每个手机也有不同的尺寸,不同的尺寸某个字段有的需要、有的不需要,非常恶心。面向「端」的 API 适配,耦合到了内部服务。

4)统一逻辑无法收敛,比如安全认证、限流等等。

因此以上四个原因就决定了数据组装这一步必须由后端、也就是服务端全部做掉,然后面向不同的用户场景提供一个统一的 API,以后客户端只需要和 BFF 团队对接即可, BFF 来统一调用底层的微服务。所以 BFF 的全称是 Backend For Frontend,也就是面向前端的后端,还是很形象的。

BFF 可以认为是一种适配服务,将后端的微服务进行适配(主要包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好统一的 API,方便无线端设备介入访问后端服务。说白了就是做数据编排的。

 

API Gateway:

我们说 BFF 业务集成度很高,专门用来做数据组装,但目前像安全认证、日志监控、熔断限流等等这些通用逻辑,专业一点话叫跨横切面逻辑,也和 BFF 集合在一起了。而这些功能如果要升级,会导致很多的 BFF 也要一起更新,这显然是不合理的,因为 BFF 就是面向业务场景做数据组装的。

于是就引入了网关,把跨横切面的功能可以全部上沉到网关(无状态、无业务逻辑),将业务集成度高的 BFF 层和通用功能服务层进行分层处理。所以网关承担了重要的角色,它是解耦拆分和后续升级迁移的利器。在网关的配合下,单块 BFF 实现了解耦拆分,各业务团队可以独立开发和交互自己的微服务,研发效率大大提升。另外,把跨横切面逻辑从 BFF 上剥离开之后,BFF 的开发人员可以更加专注于业务逻辑交互,实现了架构上的关注分离(Separation of Concerns)。

因此我们的业务流量实际为:移动端 ->  4/7 层负载均衡 -> API Gateway -> BFF -> Mircoservice,网关是所有流量的必经之处,任何流量首先要经过网关。

好,我们回到主题,扯远了。我们说用户不能直接访问 Counting Servcie,而是要通过 API Gateway 进行转发,因为可以屏蔽后台服务的这种可能的变化,另外 API Gateway 还可以实现监控日志,限流容错等功能。到这里,我们一个完整的数据接收和处理链路就设计出来了。

用户点击观看视频,请求先通过负载均衡器被转发到 API 网关,API 网关将请求转发到 Counting Service,Counting Service 通过 MQ client 将请求转发到 MQ 的某个分区队列。在后台 Counting Consumer 消费对应的 MQ 分区中的消息进行聚合运算,最终将结果写入 DB。

数据接收路径相关的面试题

在我们刚刚设计的这条数据接收和处理路径上,有很多的面试题可以问,这些问题可能和计数服务本身并不直接相关,面试管只是想考察你的知识面。

首先是服务发现和负载均衡的问题,这个是微服务架构的一个根本问题,面试官可能会问你如何实现对 API Gateway 的负载均衡访问。实际上 API Gateway 也是要以集群方式部署的,才能实现高可用访问,所以它的前置还需要负载均衡器,比方可以采用软件 Nginx 或者硬件 F5、阿里云的 SLB 等等。

面试官还可能问你 API Gateway 如何去发现这个 Counting Service 的实例,这里其实也是一个服务发现和负载均衡的问题。可以采用传统的集中式的做法,比如采用 Nginx + DNS,也可以采用微服务注册中心的做法,比如采用 Eureka 注册中心加上 Ribbon 客户端。

其实 API 网关上也可以问出很多问题,比方说网关如何实现容错限流,一种常见的做法是引入 Hystrix 容错限流组件;另外一个问题是如何通过网关实现实时的防爬虫等等,总之面试者需要有充分的知识和经验储备,才能灵活系统设计面试的各种挑战。

查询服务设计

看完计数服务之后,我们进入查询服务、也就是读取服务的设计,相比数据接收路径,数据获取路径要相对简单。

我们假定视频的每分钟、每小时、每月还有每年的观看量已经通过流式计算或者后台批处理计算的方式聚合好,并存储在 DB 当中。当用户查询某个视频的观看量时,请求会先到 API Gateway,API Gateway 将请求转发到查询服务 Query Service,查询服务直接查询数据库并进行简单的聚合运算,计算出到最近一分钟的观看总量,就可以将结果返回给客户端。这个结果是近实时的,可以准确到面试官要求的分钟级,但是还有两个问题需要考虑。

一个是老数据归档的问题,如果所有分钟小时级的数据一直存在这个 DB 当中,那么 DB 的存储空间会被不断的消耗,性能也会不断地下降。所以一旦相关数据聚合完成,我们就可以把一些老的原始数据,比如两周或一个月以前的数据从 DB 当中搬出去,挪到适合长期存储的地方,比方说放在对象存储中,这个就是老数据归档的问题。这样做的原因是大部分场景下用户关注的都是近期数据,偶尔会查询两周或一个月以前的详细数据。

另外还需要考虑的一个问题是对近期频繁访问的数据,我们也要进行缓存,这样可以进一步提升近期查询的性能。从图上可以看出,基于对用户访问模式的分析,为了有效提升查询性能,我们引入了一个类似三级缓存的架构。最上面的是分布式缓存,缓存近期频繁访问的数据(比如一个热门视频,很多人观看);中间的是 DB 存储的近期数据;下面是对象存储所存储的长期数据。通常我们把缓存和 DB 中的数据称为「热数据」,把长期存储当中的数据称为「冷数据」。

 

那么到目前为止我们的整个设计就差不多完成了,接下来看看具体技术栈的选型,不过在做技术栈选型之前,我们把总体的写入和查询流程再梳理一遍。

先来看写入处理路径,假设有三个用户在最近一分钟内同时观看了视频 A,那么请求在通过 API Gateway 时会被转发到 Counting Service,Counting Service 先将请求缓存在内存当中,后台以一分钟为周期将请求写入 MQ 的某个分区。计数消费者从对应分区中拉取消息,缓存在内部的队列当中,Aggregrator 从队列当中拉取消息,聚合计算每分钟的观看量,并且以一分钟为周期将结果写入到第二个缓冲队列。最后 DB Writer 拉取结果并写入到 DB 当中。

再来看看查询路径,假设某用户点击查看视频 A 的观看量,请求通过 API Gateway 被转发到 Query Service。Query Service 先访问分布式的缓存,如果没有缓存数据,就去后台数据库当中查询,查询以后先将结果缓存在分布式缓存当中,再将结果返回给客户端。

进一步考量

到目前为止,是不是就完事了呢?其实还没有,后面还有更挑战的问题,这些问题才是真正考验架构师水平的。

第一个问题是如何定位是如何定位系统的性能瓶颈,显然我们需要对这个系统进行性能和压力测试。这里测试有两个目的,一个是定位程序的性能、内存和多线程问题,这类问题在正常的情况下并不明显,所以我们必须对核心的服务,比如计数服务、消费者服务、查询服务,分别进行性能和压力测试,找出潜在的性能、内存和多线程问题,同时在基准性能的基础上还要做一定的性能调优。测试第二个目的是为生产部署进行容量估算,通过对小规模集群进行性能测试,我们就可以获得基准性能数据,从而估算出为了应对生产的流量,包括考虑高峰期的流量,我们需要申请多少的软硬件资源。

第二个问题是如何监控系统的健康状况,显然我们需要对系统进行细粒度的埋点和监控,并且对核心服务的调用量、调用延迟错误数都要监控起来,对硬件资源(包括 CPU、内存)的利用率也要监控起来。另外,由于我们在系统中引入了很多的队列,那么这些队列也要监控起来,尤其是对消息的堆积情况要进行监控和告警,因为消息堆积是系统需要扩容的一个重要信号。而监控的手段有很多,比方说日志监控、Metrics 监控、调用链监控和健康检查等等。

第三个问题是如何确保线上系统运行结果正确,显然我们需要对系统进行全面的线下功能测试,确保系统功能正确。但是互联网的线上流量变化无常,普通的线下测试常常不能完全覆盖线上的真实流量。另外有些场景对这个数据的准确性要求是非常高的,比方说视频播放量涉及到广告费和作者的分成,那么计数服务的准确性就不能有误差。为了确保系统在线上运行的准确性,当前业界有两种主要的做法。一种做法是开发一套线上的模拟程序,它定期生成用户的一些视频点击事件,写入到生产系统,同时它也查询生产系统,然后后台对查询结果进行实时的校验。这个做法并不是很复杂,对保证系统正确性有一定的效果,但它的流量是模拟的,还不能覆盖真实用户的流量。为此呢,业界还有一种更重量级的做法,就是在实时流计算的基础上再引入一套线下的批处理系统,两套系统同时做相同计算。也就是说,用户的视频点击事件,即进入实时流计算系统,也进入线下的批处理系统,然后后台有一个校验系统,会对两套系统产生的结果进行校验。这种做法可以进一步确保系统功能的正确性,当然成本复杂性也更高,而该做法在大数据领域有一个专门的称谓:Lambda Architecture。

第四个问题是如何解决热分区的问题,我们知道视频类网站经常会出现一些热点视频,也就是用户频繁点击的视频。对于热点视频,如果我们不进行特别处理的话,我们的系统就会有热分区的问题,具体表现为某些 MQ 的分区或者后台数据库的分区读写特别频繁,严重的时候可能造成系统的性能问题。为此我们需要对热分区的数据进行一些特殊的处理,比如给视频 ID Key 这个部分加一个时间戳,这样可以把热点视频按时间(比如每小时)分摊到不同的分区中。

第五个问题是如何监控慢消费者,如果出现慢消费者该如何解决。目前关于这个问题,我们就不展开了,它属于消息队列的内容,后续介绍消息队列的时候再说吧。

总结

到此我们的分布式计数服务已经设计完成,为了加深对分布式设计主要步骤的理解,下面再来总结一下。

分布式系统设计服务可以分为 5 个步骤:

第一步是功能需求,先要弄清楚用例场景,这个系统是给谁用的,用户怎么用这个系统。然后要设计出这些初步的 API 接口,包括输入和输出参数等。

第二步是非功能需求,非功能需求对系统的架构和设计非常重要,对于大部分的分布式系统,主要考虑的非功能需求包括高性能、高可用、可扩展,另外还有成本和可维护性这些需求。注意:前面这两部是非常重要的,因为我们必须先搞清楚做什么,然后才是怎么做。

第三步是总体设计,在理解完需求之后要先给出一个简化的总体设计,说明系统由哪些组件构成,写入路径和和读取路径分别是什么样的。

第四步是详细设计,在总体设计的基础上我们就可以开始对其中的组件分别进行详细设计。一般先从存储设计开始,包括数据库的选型、数据模型的设计,然后再是写入处理路径和查询读取路径的设计。详细设计需要综合利用各种分布式技术,权衡利弊,最终设计出满足需求的方案,当然我们最好还要给各部分组件的选型。

第五步是评估,因为系统设计完之后不能保证百分百正确,还需要对设计提出一些额外的评估考量,包括如何定位性能瓶颈、如何监控系统的健康状况、如何保证线上系统运行的正确、如何保证后续能够第对系统进行平滑的升级和扩容等等,这些环节都是需要考虑到的

目前的案例虽然是基于视频计数服务的,但是它的思想可以应用到众多场合,比如说微服务监控系统,我们需要对微服务的调用量和错误数进行计数;欺诈检测系统,我们需要对用户账户的近期使用情况进行计数;限流系统,我们需要对访问网站的 IP 或者用户的 ID 进行计数;推荐系统,我们需要对用户浏览和购买的商品进行计数,计数的结果还要输入推荐系统进行训练加工;今日热点、今日趋势等等,我们需要头条的新闻、文章、视频或者是微博等等进行计数,诸如此类。

posted @ 2020-04-02 11:16  古明地盆  阅读(2752)  评论(0编辑  收藏  举报