轻量的分布式服务框架 Skynet - mikespook/skynet GitHub Wiki
随着互联网的持续发展和近年来移动互联网的爆发,开发者经常会陷入高昂的基础投入与业务高速扩张的矛盾中。所幸的是,云技术的兴起,让许多初创团队找到了合理的平衡点。同时,技术架构的平滑扩容能力就成为制约业务扩张速度的瓶颈。如何在不影响其他模块的情况下,将系统的某些子模块平滑的迁移到新的主机上,就变成了一件极为具有挑战性的工作。将应用打散为更小的组件进行解藕,并在多个服务器上部署多个服务实例的方式使得系统获得较好的扩容能力,是普遍使用的技术手段之一。但是当系统被拆分为相对独立的若干个组件后,必定会遇到的问题是:如何组织和管理这些服务。分布式服务框架就是能够有效解决这一困难的技术方案之一。但是传统分布式服务框架如企业服务总线过于“笨重”;另一方面,像 Zookeeper 这样的分布式框架,由于其持久化数据方面的要求,也不能很好的工作在像 AWS 这样的云平台上。
由 Brian Ketelsen 于 2011 年 6 月发起的 Skynet 是使用 Go 语言编写一个分布式服务框架,该框架设计初衷是 用于构建大规模分布式应用的服务通讯框架。截止 2012 年 9 月,该项目有 6 人全职维护。并且在 Brian 创立的提供信用卡信息服务的公司中,已经有两个数据中心部署了 Skynet 集群,用于提供信用卡相关服务。其中最大的应用已经使用超过 50 个 Skynet 独立服务。
Skynet 提供了统一的配置管理与监控服务,使得客户端不再担心服务在哪里,是否已经宕机。同时其内部的连接池会自动对负载进行均衡,以便适配集群中的变化。虽然 Skynet 是采用 Go 编写的,但作为分布式框架,并不仅限于 Go 语言。它的通信协议标准可以在多种语言环境下使用。
为了构建高可用的服务集群,Skynet 重度依赖 Paxos 算法的实现:Doozer。其设计思想非常类似 Zookeeper,但更为轻量,适合用于 AWS 这样的构建于虚拟化的云平台上。它用于存储小量、极端重要的数据,保证了高可用和完全一致性。当数据变化时,它立刻通知接入的客户端(无缓存)。对于那些很少更新,但是希望更新发生时实时性高的客户端来说是非常理想的。它的原始版本由 HeroKu 的工程师开发并开源,但是由于这个版本已经差不多已经 9 个月没有更新和维护,因此 Skynet 使用的是 Doozer 众多分支中的一个相对稳定的版本。
Skynet 其本质是利用 Doozer 的集群功能,同时对业务服务的发布、调用、切换加以约定。Skynet、服务进程、调用客户端都作为 Doozer 的客户端接入业务集群。也就是说 Skynet 的业务数据交换是在 Doozer 业务集群上完成的。因此每个 Skynet 集群都需要有其对应的 Doozer 业务集群。Skynet 还提供了 区域 和 版本 的划分。同一服务,可以指定在不同区域服务,并可以有多个版本用于调用。这就使得对于线上服务进行 A/B 测试,或不停机快速迭代变得更加容易。
在进行业务调整,架构升级或扩容时,不可避免的会涉及业务集群的接入点(IP 和端口号)的变更。为了便于维护,业务集群自身也可以由另一组 Doozer 服务提供配置信息。即 Doozer Name Service (DzNS),它有两个作用:a. 用于其他 doozerd 进程发现并加入已有集群;b. 创建新集群时,决定哪个节点作为初始节点。新启动的 doozerd 首先会连接到 DzNS。然后查询 /ctl/boot/<name> 中的地址。如果地址不存在,则尝试根据设置创建集群,并将自身作为初始节点。
因此,Skynet 集群的架构天然被划分成为三层不同的抽象:
- DzNS 用于平衡和协调整个集群的工作。并作为整个工作集群的固定入口,让各个服务进程接入集群。
- Doozer 业务集群由 DzNS 管理,Skynet、服务进程和调用服务的客户端都将接入在这个集群上进行通讯。该集群可能会随着业务的变更进行演化。但它并不涉及具体的业务逻辑或数据。
- Skynet 是业务逻辑生效的地方,它包括服务管理进程(
skydaemon)、用户编写的服务和调用服务的客户端。
集群架构概念图如下:
在这个架构下,只需要按照框架约定的接口编写和调用服务。而服务所在服务器,服务端口都将由建立在 Doozer 集群基础上的 DzNS 协调 Doozer 业务集群完成。这样就可以快速的按照某个规则快速扩容或调整某个服务,而不必担心服务的接口地址发生变化对依赖该服务的程序产生影响。
在一个好的应用架构中,无状态的服务调用能够很好的解藕系统。Skynet 跟大多数分布式服务框架一样是基于无状态的服务调用。但用户可以通过每个连接唯一的 UUID 来维护状态会话。Skynet 服务自己并不知道自己被部署在哪里,以及要为谁服务。它只需要告诉框架:“嗨,我在这!Z 区域由我负责,提供版本 X 的 A 服务、B 服务、C 服务。”当有客户端向框架请求 Z 区域中的 X 版本的 B 服务时,框架就找到服务,并转发业务所需数据。服务处理完成数据后,再将结果转发回客户端。
每个 Skynet 服务程序是一个 Doozer 客户端程序。它将自己发布在 Doozer 业务集群的 /services/<service name>/<version>/<region>/<host>/<port> 文件下。这里所说的文件并不是通常意义上的文件系统中的文件,而是 Doozer 服务器约定的,具有路径(Path)、内容(Value)和版本(Version)的数据结构。文件内容用 JSON 数据格式描述了当前 Skynet 服务的配置、状态等信息。状态包括最后一次请求的时间、已经响应过的次数、平均每次响应的时间等信息。Skynet 正是根据这些信息来进行负载均衡。当在同一服务器运行一个服务的多个实例的时候,端口号会在指定范围内自动增长。
虽然 Skynet 框架的每个服务都是自我完备的,即无需管理服务仲裁即可完成调用的负载均衡。但在更加复杂的业务环境下,skydaemon 提供了更加强大的服务控制功能。并为更细粒度的自动化管理提供了可能。严格意义上说 skydaemon 是一个特殊的 Skynet 服务程序。它会根据 Skynet 框架的约定将自己发布在 Doozer 业务集群的 /services/SkynetDaemon/<version>/<region>/<host>/<port> 文件下。文件内容也是 JSON 格式的配置与状态信息。它包括六个服务:Deploy、ListSubServices、RestartAllSubServices、RestartSubService、StopAllSubServices、StopSubService,它们被用于部署、遍历、管理各种服务。
Skynet 和 Doozer 都是 Go 语言开发的,因此需要有一个能够编译 Go 代码的开发环境。关于 Go 语言的安装,语言官方网站和网络上已经有大量的相关资料。限于篇幅不在赘述。Go 工具链的 go 命令将使得 Skynet 和 Doozer 的部署异常简单。使用 go 命令即可完成安装。详细信息可以参考 Skynet 文档设置开发环境。Skynet 官方提供了基于 Virtualbox 的部署环境 Vagrant 的脚本和文档。具体部署过程大家可以阅读脚本了解。
由于业务多变,不同需求会需要不同的服务来完成。同时每个服务对于请求和响应的数据的值和类型都有所不同。因此,统一服务接口对于分布式框架来说就至关重要。Skynet 提供了 service.ServiceDelegate 接口来约定。从这个名字可以看出,这里用到了委托的模式。接口原型如下:
type ServiceDelegate interface {
Started(s *Service)
Stopped(s *Service)
Registered(s *Service)
Unregistered(s *Service)
}当操作服务的时候,框架会分别调用这四个方法,完成服务的启动、停止、注册和注销。
服务调用的接口相对来说会更加简单,Skynet 框架会对实现了 ServiceDelegate 接口的类型进行反射。将这个类型中所有可导出的方法作为可调用的服务发布。但这些可导出的方法仍然需要符合方法原型:
func (srv ServiceDelegate)(ri *skynet.RequestInfo,
req interface{}, resp interface{}) error在 Go 语言中 interface{} 相当于 C 语言的 void *,那么 req 和 resp 就可以是任何类型。
例如,现在希望编写一个发送站内消息的服务。可以定义如下结构体作为请求和响应的数据:
type MsgRequest struct {
Sender, Receiver, Data string
}
type MsgResponse struct {
Code int
}在服务启动时,分别启动一个 goroutine 用于发送站内消息。这个 goroutine 在后台不断从 channel 中取出消息请求然后发送。当 channel 中没有消息时,则阻塞等待。
type MsgService struct {
msg chan *MsgRequest
}
func (s *MsgService) Started(service
*service.Service) { go s.sendingMsg() }
func (s *MsgService) Stopped(service
*service.Service) { close(s.msg) }
func (s *MsgService) sendingMsg() {
s.msg = make(chan *MsgRequest, 128)
for req := range s.msg {
// 发送消息,省略代码若干...
}
}消息的发送的服务则相对简单,只需要将收到的消息放入发送队列即可:
func (s *MsgService) SendMsg(ri
*skynet.RequestInfo, req *MsgRequest,
resp *MsgResponse) error {
s.mail <- req
resp.Code = Success
return nil
}Skynet 的设计目标是一个同语言无关的分布式集群框架,那么也就意味着客户端可能是其他语言开发的。如果想要了解 Skynet 的协议描述,可以阅读源代码目录下的 protocol.md 文件。可以根据这个协议描述编写其他语言的客户端库。除了 Go 语言的客户端库之外,Skynet 还有 Ruby 和 PHP 的客户端实现。
对于 Skynet 客户端,服务程序所在服务器、IP 和端口等相关信息是透明的。这些信息并没有被硬编码进客户端调用的代码中,而是统一由 DzNS 管理并自动提供给服务查询时使用。因此,只要所有的服务和客户端都向 DzNS 连接并查询,就可以保证任何扩容和部署架构的调整都无需修改客户端的配置或代码,这给服务迁移和切换带来了极大便利。
需要注意的是 Skynet 的服务调用是同步的。也就是说,如果一个服务的处理非常耗时,可能会阻塞整个应用的处理过程。对于此类服务,要么在客户端提供并发机制;要么封装为具有状态的服务。例如可以将服务拆分成两个独立的部分,如 Foobar、GerFoobarResult。然后使用服务接口的第一个参数 skynet.RequestInfo 中的 UUID 即可判断请求和其结果的对应关系。有了 UUID 就可以在无状态的服务请求中增加会话的实现。
Skynet 以其轻量的架构,合理的功能为互联网应用提供了良好的稳定性与扩容能力。尤其适用于以云平台、VPS 作为基础设施的小型团队和快速发展的初创产品使用。同时 Skynet 也展示了 Go 语言在开发后端服务,尤其是集群服务中的强大能力。它具有良好的开发效率和运行效率的平衡。作为一个较为新的语言,这是相当难得的。随着 Go 语言的继续发展,以及 Doozer/Skynet 框架的不断完善。我们有理由相信在互联网应用开发上,它们必将占有一席之地。
