阅读20190508:《揭秘!现代IM系统的消息架构如何设计?》、《在阿里,我们如何管理测试环境?》

阅读 Quarterback 25℃ 0评论

《揭秘!现代IM系统的消息架构如何设计?》

  • 推荐,介绍IM系统架构设计的好文。
  • IM系统(Instant Messaging),也就是即时通信系统(比如我们平时用的微信,QQ等)。
  • IM系统中最核心的部分是消息系统,消息系统中最核心的功能是:消息的同步、存储、检索
    • 消息的同步
      • 同步方式:pull还是push,在线推送或离线推送,多端同步
      • 系统指标:消息传递的实时性、完整性、能支撑的消息规模
      • 同步模型:读扩散、写扩散、混合模式
        • 读扩散:
          • 数据存一份,订阅者采用pull的方式。
          • 优缺点:优点是数据发布时写入少,缺点是订阅者同步时读取多
        • 写扩散:
          • 数据向每个订阅者写一份,采用数据提供者push的模式
          • 优缺点:缺点是数据发布时写入负担重,优点是订阅者只需读取自己的
        • 混合模式:
          • 根据数据订阅者数量分情况决定使用读扩散还是写扩散,数量大于某个阈值使用读扩散,小于阈值使用写扩散。
    • 消息的存储
      • 消息的本地存储:传统消息系统采用这种方式。
      • 消息的服务端在线存储:现代的消息系统通常支持该方式,基于服务端在线存储,可实现消息的多端漫游(任意端都可以查看全量消息)。
    • 消息的检索
      • 消息的本地检索:传统的消息系统通常只支持本地检索
      • 消息的在线检索:现代消息系统在支持消息的在线存储同时,通常也支持了在线检索。
  • 传统的消息系统 vs 现代消息系统
    640_2

    • 传统消息系统:
      • 消息是先同步后存储。
        • 对于在线的用户,消息会直接实时同步到在线的接收方,消息同步成功后,并不会在服务端持久化。
        • 而对于离线的用户或者消息无法实时同步成功时,消息会持久化到离线库,当接收方重新连接后,会从离线库拉取所有未读消息。当离线库中的消息成功同步到接收方后,消息会从离线库中删除。
      • 传统的消息系统,服务端的主要工作是维护发送方和接收方的连接状态,并提供在线消息同步和离线消息缓存的能力,保证消息一定能够从发送方传递到接收方。服务端不会对消息进行持久化,所以也无法支持消息漫游。消息的持久化存储及索引同样只能在接收端本地实现,数据可靠性极低。
    • 现代消息系统:
      • 消息是先存储后同步。
      • 先存储后同步的好处是,如果接收方确认接收到了消息,那这条消息一定是已经在云端保存了。并且消息会有两个库来保存:
        • 一个是消息存储库,用于全量保存所有会话的消息,主要用于支持消息漫游。
        • 另一个是消息同步库,主要用于接收方的多端同步。
        • 消息从发送方发出后,经过服务端转发,服务端会先将消息保存到消息存储库,后保存到消息同步库。
      • 消息同步(推送):完成消息的持久化保存后,对于在线的接收方,会直接选择在线推送。但在线推送并不是一个必须路径,只是一个更优的消息传递路径。对于在线推送失败或者离线的接收方,会有另外一个统一的消息同步方式。接收方会主动的向服务端拉取所有未同步消息,但接收方何时来同步以及会在哪些端来同步消息对服务端来说是未知的,所以要求服务端必须保存所有需要同步到接收方的消息,这是消息同步库的主要作用。
      • 消息存储(漫游):对于新的同步设备,会有消息漫游的需求,这是消息存储库的主要作用,在消息存储库中,可以拉取任意会话的全量历史消息。
      • 消息检索:消息检索的实现依赖于对消息存储库内消息的索引,通常是一个近实时(NRT,near real time)的索引构建过程,这个索引同样是在线的
  • 阿里云TableStore提出的Timeline模型:
    640_3

    • 这是一个对消息系统内消息模型的一个抽象,能简化和更好地让开发者理解消息系统内的消息同步和存储模型
    • Timeline 可以简单理解为是一个消息队列,但这个消息队列有如下特性:
      • 每个消息拥有一个唯一的顺序ID(SequenceId),队列消息按 SequenceId 排序。
      • 新消息写入能自动分配递增的顺序 ID,保证永远插入队尾:
        • Timeline 中是根据同步位点也就是顺序 ID 来同步消息,所以需要保证新写入的消息数据的顺序 ID 绝对不能比已同步的消息的顺序 ID 还小,否则会导致数据漏同步
      • 支持根据顺序 ID 的随机定位:
        • 可根据 SequenceId 随机定位到 Timeline 中的某个位置,从这个位置开始正序或逆序的读取消息,也可支持读取指定顺序ID的某条消息
      • 新消息写入也能自定义顺序 ID,满足自定义排序需求:
        • 上面提到的自动分配顺序 ID,主要是为了满足消息同步的需求,消息同步要求消息是根据『已同步』或是『已写入』的顺序来排序。而消息的存储,通常要求消息能根据会话顺序来排序,会话顺序通常由端的会话来决定,而不是服务端的同步顺序来定,这是两种顺序要求。
    • 基于Timeline模型实现消息的同步,存储和检索:
      • 同步
        • 消息同步可以基于 Timeline 很简单的实现,图中的例子中,消息发送方是A,消息接收方是B,同时B存在多个接收端,分别是B1、B2和B3。A向B发送消息,消息需要同步到B的多个端,待同步的消息通过一个 Timeline 来进行交换。A向B发送的所有消息,都会保存在这个 Timeline 中,B的每个接收端都是独立的从这个 Timeline中拉取消息。每个接收端同步完毕后,都会在本地记录下最新同步到的消息的SequenceId,即最新的一个位点,作为下次消息同步的起始位点。服务端不会保存各个端的同步状态,各个端均可以在任意时间从任意点开始拉取消息。
      • 存储
        • 和消息同步唯一的区别是,消息存储要求服务端能够对 Timeline 内的所有数据进行持久化,并且消息采用会话顺序来保存,需要自定义顺序 ID
      • 检索
        • 检索基于 Timeline 提供的消息索引来实现
    • 消息存储模型:
      640_4

      • 消息存储要求每个会话都对应一个独立的 Timeline 。
      • 如图例子所示,A与B/C/D/E/F均发生了会话,每个会话对应一个独立的 Timeline,每个 Timeline 内存有这个会话中的所有消息,消息根据会话顺序排序,服务端会对每个 Timeline 进行持久化存储,也就拥有了消息漫游的能力。
    • 消息同步模型:
      • 消息的同步一般有读扩散(也叫拉模式)和写扩散(也叫推模式)两种不同的方式,分别对应不同的 Timeline 物理模型(按图中的示例,A作为消息接收者,其与B/C/D/E/F发生了会话,每个会话中的新的消息都需要同步到A的某个端):640_5
      • 读扩散:消息存储模型中,每个会话的 Timeline 中保存了这个会话的全量消息。读扩散的消息同步模式下,每个会话中产生的新的消息,只需要写一次到其用于存储的 Timeline 中,接收端从这个 Timeline 中拉取新的消息。优点是消息只需要写一次,相比写扩散的模式,能够大大降低消息写入次数,特别是在群消息这种场景下。但其缺点也比较明显,接收端去同步消息的逻辑会相对复杂和低效。接收端需要对每个会话都拉取一次才能获取全部消息,读被大大的放大,并且会产生很多无效的读,因为并不是每个会话都会有新消息产生
      • 写扩散:写扩散的消息同步模式,需要有一个额外的 Timeline 来专门用于消息同步,通常是每个接收端都会拥有一个独立的同步 Timeline(或者叫收件箱),用于存放需要向这个接收端同步的所有消息。每个会话中的消息,会产生多次写,除了写入用于消息存储的会话 Timeline,还需要写入需要同步到的接收端的同步 Timeline。在个人与个人的会话中,消息会被额外写两次,除了写入这个会话的存储 Timeline,还需要写入参与这个会话的两个接收者的同步 Timeline。而在群这个场景下,写入会被更加的放大,如果这个群拥有N个参与者,那每条消息都需要额外地写N次。写扩散同步模式的优点是,在接收端消息同步逻辑会非常简单,只需要从其同步 Timeline 中读取一次即可,大大降低了消息同步所需的读的压力。其缺点就是消息写入会被放大,特别是针对群这种场景
      • 针对 IM 这种应用场景,消息系统通常会选择写扩散这种消息同步模式。IM 场景下,一条消息只会产生一次,但是会被读取多次,是典型的读多写少的场景,消息的读写比例大概是10:1。若使用读扩散同步模式,整个系统的读写比例会被放大到100:1。一个优化的好的系统,必须从设计上去平衡这种读写压力,避免读或写任意一维触碰到天花板。所以 IM 系统这类场景下,通常会应用写扩散这种同步模式,来平衡读和写,将100:1的读写比例平衡到30:30。当然写扩散这种同步模式,还需要处理一些极端场景,例如万人大群。针对这种极端写扩散的场景,会退化到使用读扩散。一个简单的IM系统,通常会在产品层面限制这种大群的存在,而对于一个高级的IM系统,会采用读写扩散混合的同步模式,来满足这类产品的需求。采用混合模式,会根据数据的不同类型和不同的读写负载,来决定用写扩散还是读扩散。
  • 典型架构设计:
    640_6

    • 如图是一个典型的消息系统架构,架构中包含几个重要组件:
      • 端:作为消息的发送和接收端,通过连接消息服务器来发送和接收消息。
      • 消息服务器:一组无状态的服务器,可水平扩展,处理消息的发送和接收请求,连接后端消息系统。
      • 消息队列:新写入消息的缓冲队列,消息系统的前置消息存储,用于削峰填谷以及异步消费。
      • 消息处理:一组无状态的消费处理服务器,用于异步消费消息队列中的消息数据,处理消息的持久化和写扩散同步。
      • 消息存储和索引库:持久化存储消息,每个会话对应一个 Timeline 进行消息存储,存储的消息建立索引来实现消息检索。
      • 消息同步库:写扩散形式同步消息,每个用户的收件箱对应一个 Timeline,同步库内消息不需要永久保存,通常对消息设定一个生命周期。
    • 新消息会由端发出,通常消息体中会携带消息ID(用于去重)、逻辑时间戳(用于排序)、消息类型(控制消息、图片消息或者文本消息等)、消息体等内容。消息会先写入消息队列,作为底层存储的一个临时缓冲区。消息队列中的消息会由消息处理服务器消费,可以允许乱序消费。消息处理服务器对消息先存储后同步,先写入发件箱 Timeline (存储库),后写扩散至各个接收端的收件箱(同步库)。消息数据写入存储库后,会被近实时的构建索引,索引包括文本消息的全文索引以及多字段索引(发送方、消息类型等)。对于在线的设备,可以由消息服务器主动推送至在线设备端。对于离线设备,登录后会主动向服务端同步消息。每个设备会在本地保留有最新一条消息的顺序ID,向服务端同步该顺序ID后的所有消息。

《在阿里,我们如何管理测试环境?》

  • 小作坊型产品团队的测试环境:本地环境->集成测试环境->正式环境
    • 问题:只适合小型项目和团队,当系统规模和团队扩大,很难实现在本地环境对完整系统和组件的联调,测试环境稳定性和可用性将很难保证。
    • 一种大型项目的复杂交付流程:
      640_3
    • 解决方案:小企业会通过约束和规范去尝试避免一些问题。大企业做法是通过增加测试环境副本,隔离故障影响范围去化减问题。理想情况下,每位开发者都应该得到独占且稳定的测试环境,各自不受干扰的完成工作。然而由于成本因素,现实中在团队内往往只能共享有限的测试资源,不同成员在测试环境相互干扰成为影响软件开发质量的隐患。增加测试环境副本数本质上是一种提高成本换取效率的方法:
      640_4
  • 大型项目和开发团队测试环境管理面临的问题:
    • 测试环境种类的管理
      • 不同类型的环境对运行服务种类需求不同
      • 不同类型的环境对运行资源的需求不一样
      • 不同类型的环境对数据源的要求不同(可能需要线上的真实数据)
      • 不同类型的环境面向的使用者不同(比如灰度环境的使用者是小部分真实用户)
      • 不同的开发团队,对不同类型的环境了解程度不同
    • 测试环境成本管理
      • 管理环境所需的人工成本
        • 通过自动化及自服务化的工具可降低人工成本
      • 购买基础设施所需的资产成本
        • 硬件:硬件发展带来的成本大幅下降,通常来自于新的材料、新的生产工艺、以及新的硬件设计思路
        • 软件:软件发展带来的基础设施成本大幅下降,目前看来,大多来自于虚拟化(即资源隔离复用)技术的突破
          • 从早期的OpenVZ, LXC, 到现在流行的Docker,容器虚拟化技术甩掉了虚拟机的硬件指令转换和操作系统开销,运行在容器中的程序与普通程序之间只有一层薄薄的内核 Namespace 隔离,完全没有运行时性能损耗
    • 服务集群调试
      • 配合 AoneFlow (阿里的分支管理模式)的特性分支工作方式,倘若将几个服务的不同特性分支部署到同一个特性环境,就可以进行多特性的即时联调,从而将特性环境用于集成测试。不过,即使特性环境的创建成本很低,毕竟服务是部署在测试集群上的。这意味着每次修改代码都需要等待流水线的构建和部署,节约了空间开销,却没有缩短时间开销。为了进一步的降低成本、提高效率,阿里团队又捣鼓出了一种开脑洞的玩法:将本地开发机加入特性环境。在集团内部,由于开发机和测试环境都使用内网IP地址,稍加变通其实不难将特定的测试环境请求直接路由到开发机。这意味着,在特性环境的用户即使访问一个实际来自公共基础环境的服务,在后续处理链路上的一部分服务也可以来自特性环境,甚至来自本地环境。现在,调试集群中的服务变得非常简单,再也不用等待漫长的流水线构建,就像整个测试环境都运行在本地一样
  • 阿里交付流程上两种特殊类型的测试环境:
    640_6

    • 公共基础环境:
      • 公共基础环境是一个全套的服务运行环境,它通常运行一个相对稳定的服务版本,也有些团队将始终部署各服务的最新版本的低级别环境(称为“日常环境”)作为公共基础环境
    • 特性环境
      • 特性环境是虚拟的环境。从表面上看,每个特性环境都是一套独立完整的测试环境,由一系列服务组成集群,而实际上,除了个别当前使用者想要测试的服务,其余服务都是通过路由系统和消息中间件虚拟出来的,指向公共基础环境的相应服务。
    • 如何使公共基础环境和特性环境能实现数据双向正确路由和投递:640_7
      • 假设此时有两套特性环境在运行,一套只启动了交易服务,另一套启动了交易服务、订单服务和结算服务。对于第一套特性环境的使用者而言,虽然除交易服务外的所有服务实际上都由公共基础环境代理,但在使用时就像是自己独占一整套完整环境:可以随意部署和更新环境中交易服务的版本,并对它进行调试,不用担心会影响其他用户。对于第二套特性环境的使用者,则可以对部署在该环境中的三个服务进行联调和验证,倘若在场景中使用到了鉴权服务,则由公共基础环境的鉴权服务来响应。
      • 咋看起来,这不就是动态修改域名对应的路由地址、或者消息主题对应的投递地址么?实事并没那么简单,因为不能为了某个特性环境而修改公共基础环境的路由,所以单靠正统路由机制只能实现单向目标控制,即特性环境里的服务主动发起调用能够正确路由,若请求的发起方在公共基础环境上,就无法知道该将请求发给哪个特性环境了。对于HTTP类型的请求甚至很难处理回调的情况,当处于公共基础环境的服务进行回调时,域名解析会将目标指向公共基础环境上的同名服务。
      • 如何才能实现数据双向的正确路由和投递呢?不妨先回到这个问题的本质上来:请求应该进入哪个特性环境,是与请求的发起人相关的。因此实现双向绑定的关键在于,识别请求发起人所处的特性环境和进行端到端的路由控制。这个过程与“灰度发布”很有几分相似,可采用类似的思路解决。得益于阿里在中间件领域的技术积累,和鹰眼等路由追踪工具的广泛使用,识别请求发起人和追溯回调链路都不算难事。如此一来,路由控制也就水到渠成了。当使用特性环境时,用户需要“加入”到该环境,这个操作会将用户标识(如 IP 地址或用户ID )与指定的特性环境关联起来,每个用户只能同时属于一个特性环境。当数据请求经过路由中间件(消息队列、消息网关、HTTP 网关等),一旦识别到请求的发起人当前处在特性环境中,就会尝试把请求路由给该环境中的服务,若该环境没有与目标一致的服务,才路由或投递到公共基础环境上。特性环境并不是孤立存在的,它可以建立在容器技术之上,从而获得更大的灵活性。正如将容器建立在虚拟机之上得到基础设施获取的便利性一样,在特性环境中,通过容器快速而动态的部署服务,意味着用户可以随时向特性环境中增加一个需要修改或调试的服务,也可以将环境中的某个服务随时销毁,让公共基础环境的自动接替它。
  • DIY简单的体验版特性环境
    • 阿里的特性环境实现了包括HTTP调用、RPC调用、消息队列、消息通知等各类常用服务通信方式的双向路由服务级虚拟化。要完成这样的功能齐全的测试环境有点费劲,从通用性角度考虑,咱不妨从最符合大众口味的HTTP协议开始,做个支持单向路由的简易款。

      为了便于管理环境,最好得有一个能跑容器的集群,在开源社区里,功能齐全的Kubernetes 是个不错的选择。在 Kubernetes 中有些与路由控制有关的概念,它们都以资源对象的形式展现给用户。

      简单介绍一下, Namespace 对象能隔离服务的路由域(与容器隔离使用的内核Namespace 不是一个东西,勿混淆),Service 对象用来指定服务的路由目标和名称, Deployment 对象对应真实部署的服务。类型是 ClusterIP (以及 NodePort 和 LoadBalancer 类型,暂且忽略它们)的 Service 对象可路由相同 Namespace 内的一个真实服务,类型是 ExternalName 的 Service 对象则可作为外部服务在当前 Namespace 的路由代理。这些资源对象的管理都可以使用 YAML 格式的文件来描述,大致了解完这些,就可以开始动工了。

      基础设施和 Kubernetes 集群搭建的过程略过,下面直接进正题。先得准备路由兜底的公共基础环境,这是一个全量测试环境,包括被测系统里的所有服务和其他基础设施。暂不考虑对外访问,公共基础环境中的所有服务相应的 Service 对象都可以使用ClusterIP 类型,假设它们对应的 Namespace 名称为 pub-base-env 。

      这样一来, Kubernetes 会为此环境中的每个服务自动赋予 Namespace 内可用的域名“服务名 .svc.cluster ”和集群全局域名“服务名 .pub-base-env.svc.cluster ”。有了兜底的保障后,就可以开始创建特性环境了,最简单的特性环境可以只包含一个真实服务(例如 trade-service ),其余服务全部用 ExternalName 类型的 Service 对象代理到公共基础环境上。假设它使用名称为 feature-env-1 的 Namespace,其描述的 YAML如下(省略了非关键字段的信息):
      微信图片编辑_20190511120417

      若在特性的开发过程中,开发者对 order-service 服务也进行了修改,此时应该将修改过的服务版本添加到环境里来。只需修改 order-service 的 Service 对象属性(使用 Kubernetes 的 patch 操作),将其改为 ClusterIP 类型,同时在当前Namespace 中创建一个 Deployment 对象与之关联即可。

      由于修改 Service 对象只对相应 Namespace (即相应的特性环境)内的服务有效,无法影响从公共基础环境回调的请求,因此路由是单向的。在这种情况下,特性环境中必须包含待测调用链路的入口服务和包含回调操作的服务。例如待测的特性是由界面操作发起的,提供用户界面的服务就是入口服务。即使该服务没有修改,也应该在特性环境中部署它的主线版本。

      通过这种机制也不难实现把集群服务局部替换成本地服务进行调试开发的功能,倘若集群和本地主机都在内网,将 ExternalName 类型的 Service 对象指向本地的 IP 地址和服务端口就可以了。否则需要为本地服务增加公网路由,通过动态域名解析来实现




喜欢 (0)or分享 (0)
Quarterback.cn 打赏作者
发表我的评论
取消评论
表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址