最近的一次开发,实现Mu调度器多实例部署。这块是项目底层架构的最后一块拼图。回想从去年下旬萌生一个想法,到现在做的这么复杂。觉得有必要记录一下这个过程。顺便水一篇博文。

当时在学习Go语言,我秉持学习的最佳方式就是实践,决定写个小项目。那时,自己经常看今日热榜这个网站,觉得挺有帮助的,功能也不复杂。但是有一些不太满意的地方

  • 只支持一个网站一个榜单。很多网站有节点的概念,可能会有很多榜单。
  • 内容太单一,基本是榜单。除了榜单以外,还有一些相对新的信息流。这样不至于,刷完榜单就没有内容看了。

于是,经过一些考虑,就有了一个大致的产品原型。界面方面,一个原则,简洁。

v1.0

1.0版本很快就上线了,功能也非常简单。一个后端的爬虫,定时爬各个站点的数据,存入到Redis。另一个服务,从Redis中拿数据展示。没有登录,没有后台。

开发方面也是很简单,没有用到任何Go语言Web框架,只使用了robfig/cron这个包。总而言之,能跑起来了。

1.*版本的后面开发也是调整项目结构,优化Go代码。

v2.0

开发完1.*版本后,马上着手2.*版本的开发了。显然,前一个版本太简单了。新的版本主要解决

  • 前后端分离。1.*版本虽然是前端渲染的,但不是前后端分离的。
  • 爬取服务支持多实例部署。因为网站那么多,有些网站因为网络原因,服务器没办法访问。支持多节点的话,就可以不同网站用不同的采集器了。
  • 不同网站不同的采集频率。这个很好理解,有的网站榜单更新快,有的慢。不同频率,可以保证数据实时性。

第一个功能很好实现。拆分一下前端页面即可。第二个功能相对复杂,但是也比较好实现。采用的是gRpc库实现主节点和采集器的通信。主服务会开启一个定时任务,到了指定的时间以后,会调用配置的采集器采集数据,存入Redis。后台调整采集频率时,会通过接口,动态的修改cron任务。第三个功能,需要配置站点的采集频率。于是,需要增加一个后台管理。有后台管理就需要考虑登录。所以,引入了Github oAuth做第三方登录。主要是自己懒,不想做注册这一套。

为了开发效率,引入了Gin框架。也调整了目录结构,向标准Go项目结构看齐。

最终的架构图如下

图片

这个架构是有一些问题的。它的主服务涵盖了接口服务和调度服务。这样,就没办法进行多实例部署。而做Web开发,最讨厌单点问题了。虽然有这么一些问题,但是这个架构确是持续了很长的时间。

v3.0

前面提到,2.*版本基本实现了底层的架构问题。3.*版本,则是在此之上,完善了功能和体验。

首先就是收藏功能,我当时觉得收藏功能是必须的吧。因为我经常看到一些好的帖子,最后都找不到了。但是从实际使用来看,收藏这个功能并没有多少用处。其次是移动端响应式,PWA,黑夜模式等等。主要是针对功能和体验。

这个版本还做了一个事情,就是支持前后端分离部署。前面的版本做了前后端分离,但是,并不支持前后端分离部署。因为前后端分离部署,可能服务地址和前台是不一样的域名。之前不需要考虑这个问题,所以代码耦合比较严重。理论上也是需要支持的。

v4.0

2.*版本的时候,提到了,服务端调度器和接口服务是耦合在一起的。这样的一个弊端是限制了接口扩容。因为调度器只能单实例部署,多实例部署,就会有冲突,任务重复执行。所以,这一个版本的主要工作是,将这块拆分。

拆分的思路是,将调度器拿出来。后台修改cron参数时,服务端通过gRpc接口去修改调度器的cron配置。

完成后的架构图如下

图片

到这里就还差一步了,就是支持调度器的多实例部署。但是调度器多实例比较特殊,所以拖了一段时间,迟迟没有动手。

v5.0

5.*版本最重要的一件事就是支持调度器的多实例部署。可能会很奇怪,为什么我这么执着于多实例部署。首先,项目所有的节点都是运行在Docker中的,Docker扩容非常方便。但是对于调度器这种,在多实例情况下,扩容就会出现问题。所以,这是让人不爽的地方。其次,基于单点的调度器,在生产中也是不可能接受的,尤其是关键的服务。

然后思考一下,多实例的调度器会遇到哪些问题呢?

  1. 首先,需要确定调度方式。是所有的节点都能够调度任务,还是有主从关系。如果是所有的节点都能调度任务,就会面临任务抢占。如果是主从的方式,由主节点调度,就没有那么复杂,但是会有其他新的问题。这两种方式对应了截然不同的处理过程。目前其他产品对于这种分布式调度,一般都是主从模式。我也选择的主从模式。

  2. 主从模式,当后台修改cron配置时,怎么同步到主节点。

  3. 如何保证主节点高可用。主节点挂了怎么处理。

基本上,要实现一个最基础的调度器多实例,需要考虑这么些问题。业界很多产品采用raft协议实现分布式一致性。我只是初步了解,还没充分理解。所以,自己对照着上面的问题,实现了一个简单的版本。

  1. 首先是选举。怎么选择leader?我使用的最简单的方法,谁先启动,谁就是leader。当实例启动的时候,会分配一个唯一id。然后,使用Redis的锁机制,抢占leader_key。谁设置成功了,谁就是leader

  2. 当抢占成功时,当前实例就是leader,其他都是workerleader的职责就是,定时上报自己的健康状态。上报健康状态,就是设置leader_key为自己的ID,并设置过期时间。定时上报的周期肯定要小于这个过期时间。另外,leader还需要监听一个更新队列。所有节点接收到修改cron请求时,将更新操作放到Redis队列。leader从队列中获取更新操作,并更新cron配置。leader最后的一个职责就是,启动cron定时调度了。

  3. 怎么管理leader的健康状态呢。所有节点都会定时去查看leader的健康状态,如果leader_key过期了,则可以默认leader挂了。这时候,节点依然会执行抢占机制,将自己设置为leader。然后执行上面的一系列流程。但是有个问题,leader可能不是真的挂了,可能是网络因素导致它没有成功上报自己的健康状态。但是无所谓了,它的位置已经被抢占了。它会检查当前leader是不是自己。如果不是自己,则关闭自己的cron调度,以及停止监听队列。

至此,最后的架构如图

图片

最后

其实主要是在于学习,才有那么多东西想去实现。如果按照能用的标准,也没必要折腾这么复杂。上面的调度器方案也不够完美,可能会有其他的问题,后面还会思考怎么优化。另外,也会学习下raft的实现逻辑。看能不能用上。