点击关注公众号👆,探索更多Shopee技术实践
目录
1. 背景
2. 名词介绍
3. 架构介绍
4. 可用性设计
5. 一致性设计
6. 正确性保证
7. 升级过程
8. 未来探讨
在云原生架构下,大部分服务都是以微服务的形式容器化部署,为上游提供服务。服务间的通信依赖服务注册中心,因此对注册中心的可用性提出了很高的要求。
目前市面上已经有的服务注册与发现解决方案有两类:
前者优势在于线性一致性,不同服务可以获取到相同的下游服务信息;后者的优势在于包含了限流熔断等服务治理的套件,可以快速完成接入。
ShopeePay 早先使用 etcd 作为服务注册中心,在运维过程中,发现 etcd 作为配置中心存在一些问题:
我们需要一个更简单纯粹的名字服务,因此,我们自研了一套云原生高可用的服务注册中心,结合 sRPC 服务框架,满足服务不间断通信的需求。
在介绍我们的服务注册中心之前,先来了解一下接下来会涉及到的一些名词术语及其含义。
整个系统分为 3 个组件:Naming Client、Naming Service 和 Coordinator。其中:
Naming Service 在数据一致性与可用性之间选择牺牲强一致,实现高可用与最终一致性。
在设计之初,Naming Service 就支持云原生部署,能够支持快速扩缩容与容器调度而不影响服务通信。
关键设计:
Naming Service 和 Naming Client 都会存储服务的路由信息,两者都采用内存存储而非持久化存储。目的是为了满足容器平台的调度。
一方面,一旦 Naming Service crash,与其连接的 Naming Client 会重新连接到其他节点,不会影响服务间通信。另一方面,使用内存存储可以满足容器平台的调度,不需要与宿主机进行绑定,从而减少宿主机不可用对 Naming Service 的影响。
Naming Client 同样采用内存缓存,因为 Naming Client 作为业务服务的 SDK 被引入,不能改变原来业务服务的部署方式。而且当业务服务重启之后,重新获取路由信息也是一个合理的逻辑。
基于上述讨论,我们采用纯内存存储来简化系统设计。
与 etcd/ZooKeeper 不同,Naming Service 不保证强一致,而是采用异步复制的方式保证最终一致性。
因此,Naming Service 不需要 leader 来实现线性一致性读写。无论对于 Naming Client 还是 Naming Service 节点,每个 Naming Service 节点都是对等的。这种设计可以很大程度上简化 Naming Client 的寻址。
首先是 Naming Client 可以连到任意一台 Naming Service,当 Naming Client 心跳失败时,只要重连到任意一台 Naming Service 即可。Coordinator 可以很方便地进行负载均衡,只需要让一部分 Naming Client 重连到低负载的 Naming Service。
其次,Naming Service 新节点加入时可以连接到任意一个 Naming Service 节点。只要有一个 Naming Service 可以连上,就可以完成新节点加入。
节点对等设计使得扩缩容和重启的处理逻辑都变得简单且一致,是整个可用性设计里面最重要的一点。
实现高可用最重要的一点就是避免单点。无论其他模块可用性多高,单点故障会导致整个系统不可用。因此我们在设计服务注册中心的时候,从 Client 到 Server,都尽量避免出现单点的情况。
由于 Naming Service 基于容器部署,并且支持任意扩缩容与容器调度。Naming Client 通过传统固定 IP 方式是不现实的。
我们采用了多种方式来尽可能保证 Naming Client 能够访问到 Naming Service。
1)通过域名访问
正常情况下,Naming Service 节点启动后会注册到 DNS,Naming Client 可以通过域名连接到 Naming Service。
2)通过配置服务访问
当 DNS 系统不可用时,Naming Client 可以通过访问配置服务获取 Naming Service 的 IP 列表。
配置服务是一个部署在物理机的固定 IP 的服务,会定时主动探测以及缓存 Naming Service 的 IP。在 DNS 系统不可用时,Naming Client 依旧能够通过配置服务获取 Naming Service 的 IP,访问 Naming Service。
Naming Client 持有一个 LRU HashMap,用于缓存访问过的服务的路由信息。
一方面,可以通过监听服务变更事件来更新缓存;另一方面,Naming Client 启动了一个定时器,会定时对缓存进行全量刷新。
考虑到一个服务访问下游服务数量有限,定时批量刷新不会对 Naming Service 造成太大的负担,这种方式是可以接受的。
Naming Client 有很多内部配置参数,比如心跳间隔、缓存大小配置等。这些参数可能需要经常进行调整,不能写死在代码中。由于这些配置是内部配置,通过用户配置传入也是不合理的。一个可行的做法是通过配置中心获取,但是这样会引入多一个依赖,增加系统复杂性,并不是最好的做法。
我们想到,既然 Naming Client 需要从 Naming Service 获取路由信息,那么配置也可以通过 Naming Service 下发。因此 Naming Client 在服务注册的时候,从 Naming Service 获取相关的配置。当配置有变更时,Naming Service 通过 gRPC stream 通知 Naming Client 进行更新。从而实现配置动态变更。
Naming Service 采用多副本来提升可用性,多副本带来的一个问题是如何实现多个副本间的数据一致性。Naming Service 采用了类似 Gossip 的一致性协议,通过广播心跳的方式,将每个节点的数据同步给其他节点,实现数据最终一致。
Naming Service 不明确区分续约心跳和注册信息,收到心跳信息后,如果服务实例不存在,则自动触发注册逻辑,如果存在则执行续约。
统一的处理方式能够简化 Naming Service 设计,无需处理各种异常 case。
Naming Service 之间会通过通信同步全量的实例信息。传统的做法是,Naming Sevice 节点间同步各自持有的实例数据,接收方更新自己实例数据。这样做有个问题,即需要针对数据进行去重。
我们采用更简单的方法,Naming Service 节点间相互同步收到实例心跳数据,相当于服务实例连接到所有 Naming Service,这样 Naming Service 统一处理了来自 Replica 的同步请求与服务实例的心跳请求。
默认情况下,如果在一定时间内没有接收到某个服务实例的心跳,Naming Service 将会移除该实例。
但是当网络分区故障发生时,微服务与 Naming Service 之间、微服务所在的 Naming Service 与不同网络分区的 Naming Service 之间无法正常通信,而微服务本身是正常运行的,此时不应该移除这个微服务。
为了解决这个问题,我们引入了自我保护机制。
心跳 Payload
Payload 是一个心跳 Entry 的数组,如果发送方是 Naming Client,则数据长度为 1。
每一个 Entry 包含实例 ID、服务名、IP、端口、时间和一些自定义的额外数据。
前提:通过上一步可知,每个 Naming Service 拥有全量存活 Naming Service 列表。
当 Naming Service 收到 Naming Client 的注册、下线、心跳请求时,向其余服务器发送心跳广播,此时这两台服务器之间数据会出现短暂的不一致。
注意:即使消息广播失败,但只要 Naming Service 继续收到 Naming Client 的心跳,仍会再次向所有的 Naming Service(包括失联的 Naming Service)广播心跳任务,因此不必对心跳失败做重试,简化了系统的实现。下线的情况将在下一节展开。
这里的心跳有两层含义:
服务主动下线
Naming Client 主动向 Naming Service 发起下线请求,Naming Service 接收到请求后从 Instance Table 中移除该 service,并向其他 Naming Service 广播下线消息。其他 Naming Service 收到执行相同操作。
服务异常下线
奔溃与恢复
脑裂
自我保护机制
我们认为 Naming Service 不会经常变动,当 Naming Service 在连续 3 个心跳周期时间内,丢失超过 1/N 比例节点的心跳(N 为已知的 Naming Service 数量
)并且丢失大于等于 1 个 Naming Service 心跳时,Naming Service 进入自我保护模式。
触发自我保护机制后,Naming Service 不再从 Instance Table 中移除由于长时间没收到心跳而应该过期的服务。但它仍然能够接受新服务的注册和查询请求,保证当前节点依然可用,只是新注册的服务实例不会被同步到其他节点上。
当网络恢复稳定后,当前 Naming Service 新的注册信息会被同步到其他节点中。
我们在测试环境中部署了一套多节点的 Naming Service 与 Coordinator,并随机启动了一些模拟的 sRPC 服务,持续地重启、注册、下线;并且随机 kill 掉或者重启 Naming Service 节点,通过 Coordinator 全量对账验证数据一致性。
Coordinator 启动定时任务执行分钟级对账。按照对比数据量大小与准确性,分为以下几类:
Coordinator 每 5 分钟调用 Naming Service 的 API Service,获取其中 Instance Table 的概要信息。包含了注册的服务数量、实例数量以及 Instance Table 数据校验值。
这三个数据的获取不需要对 Instance Table 进行遍历,因此可以频繁进行对比。通过对比这三个数据,可以快速发现不一致的问题。
但是快速对账只能发现是否有问题,不能发现是哪些服务不一致。因此,我们需要一个更完整的全量数据对账。
Coordinator 每小时会调用 Naming Service 的 API Service 获取 Snapshot 进行全量对账。输出结果为一个二维表格,包含了每个 Naming Service 节点与其他节点不一致的数据。
这个流程也可以通过 Web 端人工发起,主要用于当快速对账发现数据不一致时,人工通过 Web 界面定位具体不一致服务。
在灰度阶段,sRPC 进行服务注册时,会双写 etcd 和 Naming Service,Coordinator 会在全量对账时,获取 etcd 的服务注册信息与 Naming Service 各个节点进行比对。
全量对账需要获取 Instance Table 的快照,Instance Table 本质上是一个 HashMap,如果对每次 Snapshot 都需要全表加锁,则会阻塞 Instance 租约的更新和淘汰,从而影响一致性。
因此我们对 Instance Table 进行了优化,采用了分段锁结构,避免对整个 Table 进行加锁。另外实现了 iterator 接口,将加锁的过程分散到每个服务的迭代过程,从而避免快照对实时流程的影响。
ShopeePay 早期使用 etcd 作为服务注册发现中心。升级过程中,必然存在部分服务注册新 Naming Service,部分服务注册在 etcd 的情况。
我们采用的是双写注册的方案,在 sRPC 服务框架注册发现模块中,同时持有 Naming Client 和 etcd Client,服务启动后注册到 Naming Service 和 etcd。服务发现时也会从两个服务注册中心获取数据并进行整合。
当服务基本上在 Naming Service 注册发现之后,可以停止对 etcd 的依赖。
目前每个 Naming Service 节点都保存了全量实例数据,随着服务数和实例数的增加,数据量逐渐增大,存储和同步的开销也会变大。
一个更优雅的做法是,将 Naming Service 进行分组,每个组只保存部分分片数据,这样就能很好地满足水平扩展的需求。
当 Naming Client 访问到不属于当前节点负责的数据时,Naming Service 可以将请求转发到正确的节点再返回给 Naming Client,Naming Client 无需感知分片细节。
目前 Naming Service 的健康检查只是简单的探活,没有感知服务的负载情况。负载均衡相关的能力由 sRPC 框架提供。但是实际上,Naming Client 可以与 sRPC 框架更好地结合,提供一些负载信息到 sRPC,帮助 sRPC 更合理地选择下游服务节点。
本文作者
Xin,来自 ShopeePay 团队。
技术编辑
Yinhang,来自 Engineering Infrastructure 团队,Shopee 技术委员会 BE 通道委员。
团队介绍
线上支付正逐渐成为人们日常生活中重要的一部分。作为 Shopee 和 SeaMoney 业务的关键环节,ShopeePay 团队致力于打造核心资金管理支付产品,助力推动当地市场的支付环境升级,为当地人民提供更加便利的生活。