【GoDance搜索引擎】搜索引擎集群模块实现笔记

前言

本文主要是为了记录我自己在编写GoDance集群模块时遇到的一些问题和当时的思考以及解决方案

正文

分布式一致如何去做?

当时有两种方式实现共识算法,一种是ES的默认发现模块(采用Gossip算法),一种采用强一致性的craft算法。
写设计方案的时候一开始是参照ES的来做的,但是发现了一些问题,网上也说明了一些问题,最后决定改用已经证明过正确性的Raft算法来实现分布式一致。

超时RPC如何去做?

利用管道和定时器,但是有gorutine泄露的问题,这让我想到能不能主动关闭gorutine,查询了资料后得到的答案是不能,这确实是合乎常理的

在 Go issues 中也有人提过类似问题,Dave Cheney 给出了一些思考:

  • 如果一个 goroutine 被强行停止了,它所拥有的资源会发生什么?堆栈被解开了吗?defer 是否被执行?
    • 如果执行 defer,该 goroutine 可能可以继续无限期地生存下去。
    • 如果不执行 defer,该 goroutine 原本的应用程序系统设计逻辑将会被破坏,这肯定不合理。
  • 如果允许强制停止 goroutine,是要释放所有东西,还是直接把它从调度器中踢出去,你想通过此解决什么问题?

所以gorutine只能主动去退出

停止 Goroutine 有几种方法? - 侃豺小哥 - 博客园 (cnblogs.com)

否定了这种方案后,我在网上又找到了其他思路,RPC通信基于底层网络通信,可以通过设置connection的读写超时时间,达到RPC读写超时的目的

翻了半天资料发现16年有一条冻结该包的issue

最后在他的标准库官方文档中发现,该包冻结不接受新功能,完美踩坑,我谢谢你!

image-20220530213712348

最终使用了rpcx

分包代码规范问题

我在我负责的集群模块下面分了好多模块,这造成了包管理的混乱(内部功能实现居然要将内部的用到的字段暴露)

网上搜索有关go分包的规范,发现这篇

Organize your code with Go packages — Master Tricks | by Inanc Gumus | Learn Go Programming

在我的cluster包下,不应该出现不同层次功能的包,要出现也应该是集群的不同实现的包

  • Keep your packages small
  • Put related packages into sub-directories
  • Create your packages as related clusters
  • Hide almost everything
  • Depend on things that don’t change often
  • Go has no sub-packages
  • Put tests into the same directory
  • Don’t put your code into ./src folders
  • Don’t export from the main
  • Smaller programs may not need many packages

Naming packages

  • Use simple names
  • Do not use double or more words in names
  • Let the package path speaks for what the package does
  • Don’t repeat the package names
  • Use common abbreviations
  • Avoid generic package names
  • Do not use underscores or camel-casing(Just use lowercase letters)
  • Note: Names can contain Unicode characters

几天后继续补充,由于之前是写Java的,所以很自然而然的就把Java那套给搬过来了。这里我就把模块和包弄混了,所以在一个项目里建了不同模块,实际上应该共用一个mod,这样有利于版本发布。而权限问题应该由包来解决

日志同步问题

由于我是用raft来实现分布式共识的,而在raft算法中日志同步是增量修改的,并不是修改状态。所以我需要对涉及到的操作进行定义,比如节点增加,增加的参数等等

元数据修改操作:

  • 节点增加(还有分片信息)
  • 节点删除(同时删除对应的分片)
  • 分片增加
  • 分片删除

GoDance是一款用go语言编写的分布式搜索引擎,同时也是一款分布式文档数据库。支持分布式搜索以及分布式存储功能,对外提供restful Api接口来操作GoDance。

GoDance整体采用主从架构,实现了Raft算法来保证元数据的一致,并有一系列机制比如tranlog、分片存储、分片迁移、故障转移等来保证集群的高可用和高拓展性;在存储方面,利用了段的思想来提升搜索和插入的性能,同时支持正排索引(B+树)和倒排索引(BST和MAP)来提升搜索能力,并自己实现了相应的数据结构和Linux系统上的持久化机制(MMAP);在路由方面,根据索引内存负载率和机器配置会优先路由效率高的分片节点,同时使用了rpcx来进行rpc调用;在搜索方面,实现了TF-IDF等搜索算法,并利用其实现了相关度搜索。


如何去抽象操作不同类型的日志(日志增加)

  • 类型嵌入
  • 空接口,实现多种类型的数据结构
  • 空接口字段定义操作参数(强制转换,接口+反射)
  • 使用1.18新加入的泛型

Go 1.18 泛型全面讲解:一篇讲清泛型的全部 - SegmentFault 思否

仔细想了想发现自己的这个场景还是不太一样的,无法用泛型

最终使用类型断言和反射解决问题


如何实现Raft的日志一致性

可以理解为二阶段提交,但是有个问题——当确定是否要提交之前(等待那个大多数),是否允许提交新的日志

经过一番推演,我的结论是要的,这样可以保证领导者的日志都是最终状态,有利于跟随者向领导者同步日志,同时也是为了保证日志顺序的一致性

如何设计内存中的状态日志的数据结构

需求:

  • 日志是一项一项递增的
  • 日志存在未提交和提交两种状态,提交的数据需要更新到集群中的状态机中
  • Leader的探活请求会携带当前最大的提交日志,跟随者需要根据此来提交之前的

节点删除策略

非主节点直接移除,非主节点保留该数据结构,修改其状态为Dead

go语言没有子包的概念,有点伤,自己写模块的时候会有点乱

go模块依赖问题

今天一早发现项目依赖爆红,跟我说reading tree note: note has no verifiable signatures

验证签名失败,搜了半天,大概明白是mod机制的验证问题,最后受不了了,干脆把pkg依赖全删了重新导,最后成功解决

后来搜索资料发现

image-20220603112551203

大概是我某个依赖包被无意篡改了,或者官方同个版本的依赖包更新,hash变了。总之就是校验失败了

go的程序初始化问题

因为我负责的集群模块依赖配置模块的导入,所以在编写init方法是有一些顾虑

搜了下才发现

golang程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:

  1. 初始化导入的包(包的初始化顺序并不是按导入顺序(“从上到下”)执行的,runtime需要解析包依赖关系,没有依赖的包最先初始化,与变量初始化依赖关系类似,参见golang变量的初始化);
  2. 初始化包作用域的变量(该作用域的变量的初始化也并非按照“从上到下、从左到右”的顺序,runtime解析变量依赖关系,没有依赖的变量最先初始化,参见golang变量的初始化);
  3. 执行包的init函数;

所以为了达到效果,config包尽量不要依赖于其他包

节点间自动发现的初始机制

raft算法解决的是分布式共识问题,利用一系列机制来确保日志的一致性。而最开始的节点发现问题并没有解决,所以我需要设计一种机制来促使初始节点的自动发现。

这里总体借鉴ES的设计思路,将集群节点分为Master节点和Data节点

Master节点可以参与Leader的竞选,Data节点主要参与数据存储,一个节点既可以是Master节点,也可以是Data节点,这可以在配置文件中cluster.master 和cluster.Data 进行配置。

为了使自动发现成为可能,我们加入了种子节点的概念,种子节点可以通过cluster.seedNodes进行配置,它是一个数组,每个字符串代表了一个可访问的节点地址。

在节点启动时,开启心跳检查,如果发现自己不在集群中,会向种子节点们发送rpc查询,获取当前的Leader节点地址。如果有Leader节点地址,说明现在已经有Leader,则向该Leader节点发JoinLeader RPC调用;否则则根据自己是否是Master节点来决定自己是否要参与竞选还是不断向种子节点获取当前Leader的地址。

PS:这里要注意就算是参与竞选也不能无脑一直参与,而是得判断自己是否在集群当中,如果不在,则意味着自己根本不在集群,人家根本不知道当前节点存在,所以在这种情况下,竞选失败需要尝试tryJoin的逻辑

  • 成功选举,但是种子节点发送失败,这就意味着后期其他节点节点加入时,Leader永远是无法被发现
  • 新增一个节点但是该节点没有在clients中就无法发送心跳

Leader删除一个节点同时需要把对应client给删除,同时sendHeart需要判定当前节点是否存在nodes中,如果不存在,则停止发送信息

集群模块的测试问题

在完成了集群模块的功能后,发现测试成了一个问题。

它不像普通的程序那样简单的测试方法,它主要有以下难点:

  • 集群模块需要多台机器进行运行测试
  • 集群模块中涉及大量的并发编程和网络调用

Raft算法问题思考

由于Raft算法比较复杂,看论文看的晕乎乎的,看网上资料质量又参差不齐,所以在理解这个算法的时候踩了很多坑。其中一个就是term对于选举的影响,当时我忽略了在拉票请求中节点无论投不投票都会更新为更大的term。所以在后面思考问题的时候绕了进去,发现了算法不成立的地方(其中一个节点因为网络延时开始竞选,但此时其他节点又接受到Leader的日志更新,根据不给日志完整性低的节点投票的原则,当前节点陷入无限选举的情况),当时还想着能不能竞选失败去重新尝试加入主节点等其他方案。

明白了term更新的机制也就解开了疑惑,但随之而来还有一些问题,当一个Follower节点仅仅因为网络延时导致开始竞选,那么必然会导致新一轮的竞选,因为term变大的,尽管这个Leader可能不是那个网络时延的节点,但肯定是最完整的节点,这样容易在

raft算法的局限: 1、强领导模型对于写功能基本退化单机性能,量大任然会出现性能瓶颈,适得其反。 2、选举期间会集群将出现短暂不可用现象,影响时长与选举时间相关。

日志完整性问题

如果新领导者不包含这部分日志,这部分日志会覆盖,即“以领导者日志为准,实现各节点日志的一致”,需要我们注意的是,复制到大多数节点的日志项,是不会丢失和改变的,而只被成功复制到少数节点的日志项,可能会被覆盖,也可能最终会被提交。

这里的大多数并不保证提交的大多数,所以在判断日志完整性时,我们不该以Committed的日志Id为标准,而应该以最新插入的日志Id为标准。

这里为了需要有个机制来保证新领导者有大多数的committed:

领导者选举能保证新领导者一定包含这条committed的日志项,但这条日志项在新领导者上处于未提交状态,这时新领导者,会尝试从最新日志项开始,向其他节点复制日志,如果,某条uncommitted的日志项,被发现已经成功复制到大多数节点上,这时这条日志项将处于提交状态,并被应用到状态机,通知其他节点提交这条日志。

此时集群会短暂不可用

如何理解“大多数”

集群配置不是随时变化的,需要按照一定的算法,比如联合共识、单节点变更,来添加和移除节点,也就是集群当前的节点数是已知的

采用哪种数据分片方式?

hash

一致性hash

range hash

function hash+range hash

img

最终还是参考MongoDB的分片方案——hashfunction+ hash range

分布式ID如何生成?

暂时采用UUID的解决方案

特征值如何选定?

特征值由用户选定

猜你喜欢

转载自blog.csdn.net/qq_46101869/article/details/125235631
今日推荐