Poseidon 系统是由 360 开源的日志搜索平台,目前已经用到了生产环节中,可以在数百万亿条、数百 PB 大小的日志数据中快速分析和检索特定字符串。因为 Golang 得天独厚的支持并发编程,Poseidon 的核心搜索引擎、发报器、查询代理是用 Golang 开发的,在核心引擎查询、多天查询、多天数据异步下载中大量使用了 goroutine+channel 。
大家上午好,我是郭军,很高兴今天在这里和大家交流。我今天演讲题目,Golang 在百万亿搜索引擎中的应用。Poseidon在希腊意思是海神,在这里是海量数据集的主宰者。
之前我的工作一直面向海量用户,去年年中我接触大数据以及海量数据这样的场景,在今天的演讲中,主要会涉及以下几方面内容:
设计目标
Go 应用场景与遭遇的挑战
怎样应对?
开源的改变
总结
设计目标
首先说一下为什么要做这个系统。这是一个安全公司,APT ( 高危威胁持续性事件)。在追查APT事件的时候,我们通常会找一个样本在某一样时间之内到底做了什么事情。在海量日志中找这些信息的话,运气好不堵塞的时候,大约两、三小时可以跑出来,如果运气不好,跑的任务太多堵塞的话就要修复,可能一天两天才能出来数据,显然这样的效率是不高的。
我们的设计目标,我们总的数据量保留三年的历史数据,一共有一百万亿条,大小有 100 PB。秒级交互式搜索响应,从前端发起请求到某一天数据,我们会在几秒钟之内给你返回。我们之前设定秒级60秒返回就可以,实际上做完之后测试的结果都在3秒到5秒之内,90%请求在10秒之内。每天要支持两千亿数据量灌入,原始数据仅存一份,对现有 MR 任务无侵略。ES 原始数据不止存一份,会再存一份,我们这么大数据量来说,再存副本的话,维护成本以及代价是非常大的。ES 支持不了百万亿级数据量,现在业界做到一千亿,我们只做到300多G。然后自定义的分词策略,我们每一个业务的日志格式都不一样,分词策略需要特别灵活;然后故障转移节点负载均衡,自动恢复,支持原始日志的批量下载。
图1
图1是我们总体流程,这个图比较复杂,我们之前有同事分享过这个架构。如果今天再分享架构可能时间会不够,图2是它的一个非常简单的粗略图。
图2
Go 应用场景与遭遇的挑战
首先原始日志。 在转化的时候我们把每 128 行原始日志抽取出来作为一个文档,多个文档联结在一起形成一个文件。这里会有人问为什么选择 128 行,我们每天日志量是700亿,按照每一行一个文档我们有700 亿文档。一行日志一个文档,700 亿文档占用空间太大;700 亿数据会膨胀。选择 128 行是因为:第一,700 亿除 128 ,大约是 5.46 亿左右,在一定范围内可以承受;第二,因为我们的ID都是数字形式,以发号器形式发出来的,我们压缩数字的时候,肯定要采取各种各样的压缩办法,我们在这个地方用的插分,对于128 数字的压缩是比较好的。压缩 128 行日志对比压缩1行日志高很多。我们每天原始日志,我说的业务每天原始日志有 60 ,压缩之后我们能打成 10 左右,这是每天的数据。我们在输出的时候,这个是原始的日志,最后就要到原始日志里面找,最后就要构建数据。因为我们要存入进去的时候,刚刚我说的一句话,很多人不明白,多个连接起来形成一个文件。有一个非常大的优势,里面的数据我放到另外一个文件里面,我一直叠加,最后这个文件可以被解压。换一种方式来说,把文件都输出到一个文件里面,作为这一个文件,我从这个文件里面取出某一段来,我就可以解压出来,这是一个非常大的特性。因为我需要读一段日志,我肯定要知道这个我从哪个地方读到哪个地方,我要知道我读的压缩文件,解压出来就是128行日志。我们把整个原数据放到这里面,去建索引以及原数据,大体就是这样一个流程。首先看一下离线引擎,客户端请求日志,包括 PC 卫士、网络以及浏览器等等,这块相当于传统搜索引擎的爬虫。下面会具体讲到,离线生成 DocGz 、DocGzmeta ,然后构建原数据。在线引擎,web 我们做简单的页面开发,到 proxy 集群,再发到 searcher 集群,然后走到 readHDFS ,readHDFS这个服务是用 Java开发,用 Java 开发有很多坑,但是又不得不用,因为java仍然是操作hadoop最合适的语言。
来说一下数据结构。 我们用 ProtrBuffer 描述核心数据结构。每一个 ID 下面分为两段,那个 docID 就是我这个文档的编号;第二是 rowIndex,每个里面都会对应多行日志,我这里面对应 128 行里面哪一行日志,就是这个做的定位。我们用 map 的形式描述出来,这个是由 DocID 形成的列表,每一个里面会对应多个DocIDList。map 和 string 里面,我要先找到 map ,然后再把数据拿出来。如图3所示。
图3
说一下搜索引擎的核心技术。 首先倒排索引,倒排索引有一个趋势,DocidList 非常长。我们一个分词会先计算出来 hashid ,知道 hashid 之后要查询的时候我们要做一个平台,给出要查询哪一个业务,比如我要查网络等等这些,我们以业务的简写拼接上hashid,然后要查询的时间,查询哪一天的数据,我们引擎不是实时,因为数据量太大做不了实时,只能做到今天查昨天。然后解析 invertedindex 拿到对应的文档信息在里面,找到这个位置之后,把我们所有的需要的原数据抽出来,然后解压。我们就知道某一个分词对应着 DocidList 是哪一个,根据 DocidList 去查要查的 map 信息在哪个地方,获取之后再拼一个路径,把原始数据拿出来。拿出原始数据之后,一个文件里面会有 128 行日志,这 128 行日志Doc里面rowindx 找到文档在哪一行,做过滤就可以了。用非常简单的话来总结一下,因为 Docid 比较长,我们存一个位置,我们的 DocidList 每一个 Docid 对应的文档也比较多,我们读原始文档的时候,也会存一个位置,在计算机领域中,各种难以解决的问题都可以添加一个间接的中间层来解决这个问题。如图4所示。这句话在我们系统中有了很好的尝试,不仅是这一块。
图4
再来说一下 idgeneratror 。 按照每天业务 27700 亿来算,分词以后是 100 亿,每一个分词对应 277 行日志,这是平均数,每天 Docid 有 27700 亿个。按照每个 4 字节来计算,光是 Docid 数字将近 11TB。在这里进行了处理,采用分段区间获取降低 qps,每天的 id 重新从 0 开始分配。我们每天 Docid 倒排索引量在2.4T。每天 27700 亿我们做起来也稍微有点发怵,我们想了一个办法,我们业务名加时间作为 key,每天id 从零开始重新分配,这样就可以保证我每天的量不至于太高,而且分出来的 Docid 不用太大,如果太大的话,可能数据就会比较膨胀。我现在建了索引是哪个业务,什么时间段,哪一天的,我这次要请求哪一个区段,如果说我请求了 1 到 100 个这个区段,在 idgeneratro 会提前预留出 1 到 100 这个空隙。
Proxy/Searcher详细设计。 Searcher核心引擎就是走四级索引里面做的事情,其中包括过滤和模糊查询等等,这些不是主干业务我没有说。从里面拿出map数据,然后再取原始数据,取完数据以后,我们有很多原始数据非常大,大约有几十兆左右,如果放在处理器前端,前面会直接卡死,我们会把原始数据比较大的业务,在页面上面给大家展示,点击查看原始数据这么一个链接,点了以后再过来请求一遍,这是一个非常简单的架构。如图5所示。
图5
Searcher并发模型。 因为读 四级索引的时候,读 Docid 的过程一模一样,所以我在这里用读 Docid 举例子,比如我拿到 DocidList 的数据,我会给每一个 Docid 分配一个 Goroutine ,拼接出来 doc path ,读取原始日志,然后做过滤,最后返回给前端。如图6所示。
图6
怎样应用
第一个瓶颈。我们团队的基础组件全是 c++,我们团队核心业务,以及在线引擎、核心引擎都是c++ 来做的。我们用到 gdb 进行调试,进程过多,用 c++ 组件一开始想偷懒,然后编辑进C,再放到 Go 里面去。每一个读取 Docid 中,每一个文件都会去读,我们的运用程序经常就挂,当时也没有原因,最后我们才看到执行 CGO 的时候,我们收到一个信号,就是 signal exit,然后我们进行GDB调试,说是进程太多,因为CGO在执行的时候会新建一个M。
解决方案:用Go重新实现一遍,将组件作为http服务,Go Client调用,做集中式处理。
第二个瓶颈。在系统中,我们大量使用 Goroutine,子写程 panic 在主写程不能被处理掉。
解决方案:我们在通道类型里面为struct,封装正常数据和error,在主协程取取出数据,统一做处理。
经验小结。
即使精通很多语言,最好不要混用,要非常谨慎引入其他语言的解决方案。
不要完全相信recover,它不能恢复runtime的一些panic。
看一下我们的Proxy多天并发查询设计。 如图7所示。要做 多天查询有两种方案。第一种方案把多天查询加上,这样使我们核心查询引擎变得非常臃肿,我们还是那句话,加一个中间层。把多天变成单天,然后在Proxy 拿到所有的单天数据,就形成了多天查询。
图7
我们还有另外一个项目,请求Poseidon的数据,我们想到两种解决方案,第一种解决方案,你在自己第三方系统里面做缓存,要不我们做缓存,我们是这样取舍。如果第三方系统里面做缓存,所有的查询,缓存只能在第三方系统里面用。如果在我们这里缓存,他们发了请求到我们这来,其他所有第三方里面都有可能能用上。我们是这样做的,首先请求 Searcher 拿到当天的数据,比如查一个月的数据,请求 Searcher 单天的数据,如果每一个Goroutine 去查一天,每一个 Goroutine 拿到 Searcher 单天数据之后,把它解出来,看一下是不是错误数据。如果是错误数据的话,直接给客户端把这条数据返回错误,并不是给客户端整个错误,因为只是这一天某一条数据有错误。而不至于我们在查询 30 天数据的时候,里面只要某一天某一条数据有错误,就直接返回给用户,我这个系统不可用。如果不是错误数据,会根据请求参数,请求参数有很多。除了这些之外,还有查询的时间,根据这个来做一个Cace Key,然后打回给前端。
我们遇到一个问题,每一个用户会把整个索引流程都跑一遍,也就是说用户会给我们实时测试。在同一个时间之内,同一份数据在缓存时间之内不会走完整个 readhdfs 流程。build index 程序化,我们会有监控,如果程序化我们会知道,程序挂了会报警感知,但是数据错误却是未知,我们现在还没有做到这种监控。但是这个数据错误是未知的,我们修复索引就会花费大量时间,去重新写日志,跑 Docid,还要解决漏洞。
我们的解决方案,第一个减少缓存时间,在可容忍错误数据时间之内,用户查询能及时发现问题,恢复一天两天数据还可以,不至于缓存 30 天或者一、两个月,到最后错误数据会越来越多。第二个解决方案,参考 NSQ,利用 for+select 的不确定性来分馏,随机流量到 chanel 和 hdfs 做热测试。缺点,就是开发成本相对第一种方案来说有点高。这块要注意,开发成本并不是非常高,因为 select 而只能从 chanel 拿数据。
第二个经验小结。 不要选择非常高大上的一些技术,或者说一些我们所说的黑科技,简单、有效、够用能解决问题完全可以。利用 Goroutine 设计并发程序很方便,但是并发运行模型一定要 hold 住。我们之前Gopher 群里面发过一个博客,里面发了很多动态图,一些 Go 的 Goroutine 和 channel 如何并发,动态图画的非常炫。我们在写自己业务的时候,我们看了 Goroutine 以及 Goroutine 和 channel 怎么联动,我们自己有概念。我要表达观点的时候,我一时也找不到非常恰当的名词来描述,我不知道这个名词之前有没有,或者有没有其他的意义。
Proxy多天异步下载。 如图8所示。前端发起请求,要选择下载多少天,下载多少数据,服务端接受到请求之后,马上给客户端返回,我已经收到了,把这个消息写到channel。刚开始我们已经说过在readHDFS是是用JAVA写的,Goroutine太多,底层挂掉。两个Searcher到HDFS的时候,一个分词对应上百个Docid,可能对应着上百个文件,因为每一个Docid不一定在一个文件里面。在Searcher里面的时候,看起来进来一个请求,实际上往后会越来越大,到最后可能就是指数级的增长,像我们滚雪球一样。
图8
首先JAVA做了简单的连接池,然后有熔断机制,如果超出一定的连接数,直接返回error。像我们很早之前的时候,保险丝,家里面的电率大的时候,保险丝是用铅丝做的,铅丝会熔化掉。
再说一下GC的变化。 首先我说一下GC在我们整个系统中,从来都不是瓶颈。在这里说的几点,是我们升级之后简单做的测试,在这里和大家交流一下。如果有其他做测试比我们更细的同学,可以交流一下。
Go 1.7。 我们之前用的 1.5,升级到 1.7 之后,我们的 GC 下降到了三分之一。
nginx 代理问题,之前我做分享的时候,有同学问我在 Go 前端要不要加nginx代理。我之前做的系统面向海量用户,我们只把 GoServer 打包成二进制的可执行包,请求打到 lvs 的80 端口然后再转发到 GoServer 8080,非常简单。在这个项目我们用了 nginx,我们有用它的理由。
访问控制和负载均衡。 负载均衡我们可以用 LVS 做,我们这个项目的场景,使用的人非常少。第一我们是一个内部项目,权限问题,我们所在前端端口只能让开放的一些机器来访问,除了我们自己的前端器会访问以外,其实还有其他的一些团队,会过来直接写脚本请求我们的数据。我们nginx里面直接用了这两个,这样我不需要在Go里面做,前面就可以直接用nginx做了简单的负载均衡。要不要nginx,完全取决于自己业务的场景。因为在这个场景中,加了nginx也只是给运维稍微增加了负担,但是ip限制和负载均衡不需要重新开发了,之前没有用因为它没有在里面起到任何作用,而且之前是对外的服务,不需要有任何的限制,任何人都可以过来请求。
开源的改变
我们考虑开源。 在去年11月份的时候,我们开源了系统,系统有66%代码是用Golang写的。我们有两个问题需要解决,第一个问题第三方依赖的问题,我们开源主体方案没有用到我们自己的内部依赖包,这些第三方的组件,我们应该如何维护它,我当时和很多人交流过,这种方式也比较多,但是他们各有各的优点和缺点,几乎没有一个非常完美的方案,能解决到依赖里面再套依赖,以及多层依赖关系,至少我没有找到,既然没有的话,就选择最大众化,最简单的方案,用这个方式来解决。
在我们整个服务里面,我们自己开发了几个服务,一共有五个。我们当时考虑过,如果让用户部署五个服务,即使我们写好了脚本,部署起来在每个用户端操作系统不同,CPU位数不同等等,都会出各种各样的问题。排查起问题来,不知道排查哪一个服务,对于我们这些开发者来说,我们排查问题的时候,也会根据日志一个服务一个服务去找。我们考虑到,我们把所有的服务打成一个ALL in One一个包。在实际交流试用中,我们了解到有很多人没有选择All in One而选择这五个服务独立部署。
我们开源有五个月,有很多人想让我们把模糊查询以及过滤开源出来。模糊查询我们做的非常简单,我们用了一个数据库,有并发能力。我们先把我们需要模糊查询的分词给分出来,放到数据库里面,在数据库里面我就可以操作,我们平常用到的模糊查询关键词,也就是几十亿左右,几十亿的量做一个操作,那简直太简单了,查到之后就知道关键词,拿到关键词之后,接下来的方案就是一个用多个关键词查询多天的场景,用多个关键词和单个关键词是一样的。多个关键词去查询和用多天查询是一样的,每个关键词分一个Goroutine去查询,就可以解决问题了。
总结回顾
首先Go的开发体验比较好,性能比较高,服务很稳定,我们除了线上有一次事故之后,好像就再也没有过。我们线上是用自己写的做监控,如果它挂掉就会自动拉起来,当然这是一种比较low的方式,因为它可能没有挂,但是它的确死掉了。可以满足大部分的需求场景,GO语言程序开发需要在代码可读性和性能之间做平衡取舍,应用程序并发模型需要在控制之内。我们有很多人在群里面问连接池以及对象池,连接池我们不说,因为很多客户端都会实现连接池这个功能,我们考虑对象池。对象池优点的确很大,因为它可以复用对象减轻压力,这是最核心的功能。复用对象解决了gc压力,但还有一个代码可读性的问题,引进对象池,对象池和业务没有关系,你要看对象池怎么做,代码可读性会非常差。还要说的是,对象池这种解决方案,在Go1.2的时候,用起来很爽,但是目前为止1.4到1.7的时候,对象池这种方案已经远远用不到了,因为gc已经不是那么明显。除非在非常极端的情况下,我们可能会用到这种非常极端的方式解决问题,但是我想大部分的公司都不太会遇到这种问题。我们知道Go在开发安卓,我们现在用的最多就是它和c++以及c的配合然后在用CGO引入到GO,谨慎与其他语言合用,即使对语言都非常熟,你也并不知道他们两个结合起来说不定引发一个问题,可能是你永远解决不了的问题。要合理引进第三方解决方案,在运维成本和系统维护成本要做平衡。