基于 Elasticsearch 的通用搜索是蚂蚁内部最大的搜索产品,目前拥有上万亿文档,服务了上百个业务方。

一、源动力:架构复杂、运维艰难

和大多数大型企业一样,蚂蚁内部也有一套自研的搜索系统,我们称之为主搜。但是由于这种系统可定制性高,所以一般业务接入比较复杂,周期比较长。

而对于大量新兴的中小业务而言,迭代速度尤为关键,因此难以用主搜去满足。

主搜不能满足,业务又实际要用,怎么办呢?那就只能自建了。蚂蚁内部有很多小的搜索系统,有 ES,也有 solr,甚至还有自己用 Lucene 的。

1、业务痛点
Elasticsearch在蚂蚁金服的实践经验
然而业务由于自身迭代速度很快,去运维这些搜索系统成本很大。就像 ES,虽然搭建一套很是简单,但是用在真实生产环境中还是需要很多专业知识的。作为业务部门很难去投入人力去运维维护。

并且由于蚂蚁自身的业务特性,很多业务都是需要高可用保证的。而我们都知道 ES 本身的高可用目前只能跨机房部署了,先不谈跨机房部署时的分配策略,光是就近访问一点,业务都很难去完成。

因为这些原因,导致这类场景基本都没有高可用,业务层宁愿写两套代码,准备一套兜底方案。觉得容灾时直接降级也比高可用简单。

2、架构痛点
Elasticsearch在蚂蚁金服的实践经验
从整体架构层面看,各个业务自行搭建搜索引擎造成了烟囱林立,各种重复建设。

并且这种中小业务一般数据量都比较小,往往一个业务一套三节点集群只有几万条数据,造成整体资源利用率很低,而且由于搜索引擎选用的版本,部署的方式都不一致,也难以保证质量,在架构层面只能当做不存在搜索能力。

二、低成本,高可用,少运维的Elasticsearch平台

基于以上痛点,我们构建一套标准搜索平台,将业务从运维中解放出来,也从架构层面统一基础设施,提供一种简单可信的搜索服务。『低成本,高可用,少运维』的 Elasticsearch 平台应运而生。

1、架构

如何做低成本,高可用,少运维呢?我们先来一起看一下整体架构,如图:
Elasticsearch在蚂蚁金服的实践经验
首先说明一下,图中中部的两个黑色框框代表两个机房,我们整体就是一种多机房的架构,用来保证高可用:

最上层是用户接入层,有 API、Kibana、Console 三种方式,用户和使用 ES 原生的 API 一样可以直接使用我们的产品;
中间为路由层(Router),负责将用户请求真实发送到对应集群中,负责一些干预处理逻辑;
下面每个机房中都有队列(Queue),负责削峰填谷和容灾多写。
每个机房中有多个 ES 集群,用户的数据最终落在一个真实的集群中,或者一组对等的高可用集群中;
右边红色的是 Meta,负责所有组件的一站式自动化运维和元数据管理;
最下面是 Kubernetes, 我们所有的组件均是以容器跑在 k8s 上的,这解放了我们很多物理机运维操作,使得滚动重启这些变得非常方便;

2、低成本:多租户

看完了整体,下面就逐点介绍下我们是怎么做的。第一个目标是低成本。

在架构层面,成本优化是个每年必谈的话题。那么降低成本是什么意思?实际上就是提高资源利用率。提高资源利用率方法有很多,比如提高压缩比,降低查询开销。但是在平台上做简单有效的方式则是多租户。

今天我就主要介绍下我们的多租户方案:

多租户的关键就是租户隔离。租户隔离分为逻辑隔离和物理隔离:

1)逻辑隔离

也就是让业务还是和之前自己搭ES一样的用法,也就是透明访问,但是实际上访问的只是真实集群中属于自己的一部分数据,而看不到其他人的数据,也就是保证水平权限。

而 ES 有一点很适合用来做逻辑隔离,ES 的访问实际上都是按照 index 的。因此我们逻辑隔离的问题就转化为如何让用户只能看到自己的表了。

我们是通过 console 保存用户和表的映射关系,然后在访问时通过 router,也就是前面介绍的路由层进行干预,使得用户只能访问自己的 index。

具体而言,我们路由层采用 OpenResty+Lua 实现,将请求过程分为了下图右侧的四步:
Elasticsearch在蚂蚁金服的实践经验
Dispatch

在 Dispatch 阶段我们将请求结构化,抽出其用户、app、index、action 数据。

Filter

然后进入 Filter 阶段,进行写过滤和改写,filter 又分为三步:

Access 进行限流和验权这类的准入性拦截;
Action 对具体的操作进行拦截处理,比如说 DDL ,也就是建表、删表、修改结构这些操作,我们将其转发到 Console 进行处理,一方面方便记录其 index 和 app 的对应信息,另一方面由于建删表这种还是很影响集群性能的,我们通过转发给 console 可以对用户进行进一步限制,防止恶意行为对系统的影响。
Params 则是请求改写,在这一步我们会根据具体的 index 和 action 进行相应的改写。比如去掉用户没有权限的 index,比如对于 kibana 索引将其改为用户自己的唯一 kibana 索引以实现 kibana 的多租户,比如对ES不同版本的简单兼容。
在这一步我们可以做很多,不过需要注意的有两点:
一是尽量不要解析 body, 解 body是一种非常影响性能的行为,除了特殊的改写外应该尽力避免,比如 index 就应该让用户写在 url 上,并利用ES本身的参数关闭 body 中指定 index 的功能,这样改写速度可以快很多。
二是对于_all 和 getMapping 这种对所有 index 进行访问的,如果我们替换为用户所有的索引会造成 url 过长,我们采用的是创建一个和应用名同名的别名,然后将其改写成这个别名。

Router

进行完 Filter 就到了真实的 router 层,这一层就是根据 filter 的结果做真实的路由请求,可能是转发到真实集群也能是转发到我们其他的微服务中。

Reprocess

最后是 Reprocess,这是拿到业务响应后的最终处理,我们在这边会对一些结果进行改写,并且异步记录日志。

上面这四步就是我们路由层的大致逻辑,通过 app 和 index 的权限关系控制水平权限,通过 index 改写路由进行共享集群。

2)物理隔离

做完了逻辑隔离,我们可以保证业务的水平权限了,那么是否就可以了呢?显然不是的,实际中不同业务访问差异还是很大的,只做逻辑隔离往往会造成业务间相互影响。这时候就需要物理隔离。

不过物理隔离我们目前也没有找到非常好的方案,分享下我们的一些尝试:

首先我们采用的方法是服务分层,也就是将不同用途,不同重要性的业务分开,对于关键性的主链路业务甚至可以独占集群。

对于其他的,我们主要分为两类,写多查少的日志型和查多写少的检索型业务,按照其不同的要求和流量预估将其分配在我们预设的集群中。不过需要注意的是申报的和实际的总会有差异的,所以我们还有定期巡检机制,会将已上线业务按照其真实流量进行集群迁移。

做完了服务分层,我们基本可以解决了低重要性业务影响高重要性业务的场景,但是在同级业务中依旧会有些业务因为比如说做营销活动这种造成突发流量。对于这种问题怎么办?

一般而言就是全局限流,但是由于我们的访问都是长连接,所以限流并不好做。

如下图右侧所示,用户通过一个 LVS 访问了我们多个 Router,然后我们又通过了 LVS 访问了多个 ES 节点,我们要做限流,也就是要保证所有 Router 上的令牌总数:
Elasticsearch在蚂蚁金服的实践经验
一般而言全局限流有两种方案:

第一种方案是以限流维度将所有请求打在同一实例上,也就是将同一表的所有访问打在一台机器上,但是在 ES 访问量这么高的场景下,这种并不合适,并且由于我们前面已经有了一层lvs 做负载均衡,再做一层路由会显得过于复杂。
第二种方案就是均分令牌,但是由于长连接的问题,会造成有些节点早已被限流,但是其他节点却没有什么流量。

那么怎么办呢?
Elasticsearch在蚂蚁金服的实践经验
既然是令牌使用不均衡,那么我们就让其分配也不均衡就好了呗。所以我们采用了一种基于反馈的全局限流方案。什么叫基于反馈呢?就是我们用巡检去定时采集用量,用的多就多给一些,用的少就少给一点。

那么多给一些少给一点到底是什么样的标准呢?

这时我们就需要决策单元来处理了,目前我们采取的方案是简单的按比例分配。

这边需要注意的一点是当有新机器接入时,不是一开始就达到终态的,而是渐进的过程。所以需要对这个收敛期设置一些策略,目前因为我们机器性能比较好,不怕突发毛刺,所以我们设置的是全部放行,到稳定后再进行限流。

这里说到长连接就顺便提一个 nginx 的小参数,keepalive_timeout。用过 nginx 的同学应该都见过,表示长连接超时时间,默认有75s。

但是这个参数实际上还有一个可选配置,表示写在响应头里的超时时间,如果这个参数没写的话,就会出现“在服务端释放的瞬间,客户端正好复用了这个连接,造成 Connection Reset 或者 NoHttpResponse ”的问题。

出现频率不高,但是真实影响用户体验,因为随机低频出现,我们之前一直以为是客户端问题,后来才发现是原来是这个释放顺序的问题。

至此服务分层,全局限流都已经完成了,是不是可以睡个好觉了呢?

很遗憾,还是不行。

因为ES语法非常灵活,并且有许多大代价的操作,比如上千亿条数据做聚合,或者是用通配符做个中缀查询,写一个复杂的script都有可能造成拖垮我们整个集群,那么对于这种情况怎么办呢?

我们目前也是处于探索阶段,目前看比较有用的一种方式是事后补救,也就是我们通过巡检去发现一些耗时大的 Task,然后对其应用的后继操作进行惩罚,比如降级,甚至熔断。这样就可以避免持续性的影响整个集群。

但是一瞬间的rt上升还是不可避免的,因此我们也在尝试事前拦截,不过这个比较复杂。

3、高可用:对等多集群
Elasticsearch在蚂蚁金服的实践经验
讲完了低成本,那么就来到了我们第二个目标,高可用。

正如我之前提到那样,ES 本身其实提供了跨机房部署的方案,通过打标就可以进行跨机房部署,然后通过preference可以保证业务就近查询。

但是这种方案需要两地三中心,而我们很多对外输出的场景出于成本考虑,并没有三中心,只有两地两中心,因此双机房如何保证高可用就是我们遇到的一个挑战。

我们提供了两种类型、共三种方案分别适用于不同的业务场景。

类型:

1)单写多读

单写多读我们采用的是跨集群复制的方案,通过修改 ES,我们增加了利用 translog 将主集群数据推送给备的能力。就和 6.5 的 ccr 类似,但是我们采用的是推模式,而不是拉模式,因为我们之前做过测试,对于海量数据写入,推比拉的性能好了不少。

容灾时进行主备互换,然后恢复后再补上在途数据。由上层来保证单写,多读和容灾切换逻辑。

这种方案通过 ES 本身的t ranslog 同步,部署结构简单,数据也很准确,类似与数据库的备库,比较适合对写入 rt 没有过高要求的高可用场景。
Elasticsearch在蚂蚁金服的实践经验
2)多写多读

我们提供了两种方案:

第一种方案比较取巧,就是因为很多关键链路的业务场景都是从DB同步到搜索中的,因此我们打通了数据通道,可以自动化的从DB写入到搜索,用户无需关心。

那么对于这类用户的高可用,我们采用的就是利用DB的高可用,搭建两条数据管道,分别写入不同的集群。这样就可以实现高可用了,并且还可以绝对保证最终一致性。

第二种方案则是在对写入rt有强要求,有没有数据源的情况下,我们会采用中间层的多写来实现高可用。我们利用消息队列作为中间层,来实现双写。

就是用户写的时候,写成功后保证队列也写成功了才返回成功,如果一个不成功就整体失败。然后由队列去保证推送到另一个对等集群中。用外部版本号去保证一致性。

但是由于中间层,对于Delete by Query的一致性保证就有些无能为力了。所以也仅适合特定的业务场景。

最后,在高可用上我还想说的一点是对于平台产品而言,技术方案有哪些,怎么实现的业务其实并不关心,业务关心的仅仅是他们能不能就近访问降低rt,和容灾时自动切换保证可用。

因此我们在平台上屏蔽了这些复杂的高可用类型和这些适用的场景,完全交由后端去判断,让用户可以轻松自助接入。并且在交互上也将读写控制,容灾操作移到了自己系统内,对用户无感知。

只有用户可以这样透明拥有高可用能力了,平台才真正成为了高可用的搜索平台。

4、少运维

最后一个目标,少运维。简单介绍一下我们在整体运维系统搭建过程中沉淀出的四个原则:自包含、组件化、一站到底、自动化。

自包含ES做的就很不错了,一个jar就可以启动,而整套系统也都应该和单个ES一样,一条很简单的命令就能启动,没有什么外部依赖,这样就很好去输出。
组件化是指我们每个模块都应该可以插拔,来适应不同的业务场景,比如有的不需要多租户,有的不需要削峰填谷。

一站到底是指所有组件,router、queue、es,还有很多微服务的管控都应该在一个系统中去管控,万万不能一个组件一套自己的管控。

下图右侧就是我们的一个大盘页面,展现了router,es和queue的访问情况。当然,这是mock的数据:
Elasticsearch在蚂蚁金服的实践经验