来源团队|字节跳动直播运营平台
在持续建设基于 ES 的跨域数据聚合服务中发现 ES 的很多特性跟 MySQL 等常用数据库差别较大,本文会分享 ES 的实现原理、在直播平台中的业务选型建议及实践中遇到的问题和思考。
ES 简介与应用场景
Elasticsearch 是一种分布式的、近实时的海量数据存储、检索与分析引擎。我们常说的"ELK"就是指 Elasticsearch、Logstash/Beats、Kibana 组成的具备收集、存储、检索和可视化的数据系统。ES 在类似数据系统中发挥着数据存储与索引、数据检索、数据分析等作用。
ES 特性
每种技术选型有各自的特点,ES 整体特性亦受底层实现影响,本文第二部分会细述以下特性的根因。
Pros:
- 分布式:通过分片最高可支持 PB 级别数据、对外部屏蔽分片细节,用户不需要感知读写路由;
- 可伸缩:水平扩展容易,不需要像 MySQL 一样手动分库分表或借助第三方组件;
- 速度快:各分片并行计算,检索速度快;
- 全文检索:多项针对性优化,比如通过各种分词插件支持多语言全文检索,通过语义处理提高准确性;
- 丰富的数据分析功能。
Cons:
- 不支持事务:各分片的计算过程并行且独立;
- 近实时:从数据写入到数据可被查询有数秒延迟;
- 原生 DSL 语言较为复杂,有一定的学习成本。
常见用途
特性会影响组件的应用场景,直播运营平台在文档检索与分析部分通过使用 ES 聚合数亿主播的各类信息,并用于对应平台进行各类列表的展示;日志检索部分则是用于对 Argos 错误日志的搜索。
ES 实现与架构
接下来了解上述 ES 优点是如何实现的、缺点是怎么导致的,说起 ES 是一定要谈 Lucene 的,Lucene 是一个全文检索 Java 库,ES 以 Lucene 作为底层组件实现所有功能,下文主要介绍 Lucene 具有哪些功能,而 ES 相对于 Lucene 又新增了哪些能力。
Lucene 在单实例上实现了数据索引与检索,能够支持倒排索引,并且支持顺序写入数据,但不支持修改和删除,也无全局主键概念,无法使用统一方式标识 Document,也无法支持分布式操作。
所以 ES 相对于 Lucene 增加了一些新特性 , 主要包括在新增了全局主键字段"_id",使数据修改/删除、分片路由成为可能;并且使用单独文件标记被删除 Document,以"写入新 Document、标记旧 Document 被删除"的方式实现 Update 操作;通过将 Document 新增版本号,以乐观锁形式支持并发;实现分布式的过程是通过运行多个 Lucene 实例按主键 ID 路由读写请求、合并查询结果;也增加了聚合分析,可以实现对查询结果进行排序、统计等进行分析。下面将按照单实例到集群的顺序介绍具体的实现细节。
单实例
索引
索引存在的目的是加速检索过程,索引选型是所有数据库都无法回避的问题,ES 设计之初的目标场景是全文检索,所以支持"倒排索引",并对此进行了多项优化。除此之外,还支持 Block Kd Tree 等其他索引,ES 会按字段类型自动匹配对应的索引类型,为需要索引的字段构建索引。
倒排索引和 Block Kd Tree 也是分析常用的索引类型。对于字符串,有两种常见情况:Text 采用分词+倒排索引,而 Keyword 则使用不分词+倒排索引。对于数值类型,如 Long/Float 通常使用 Block Kd Tree。
倒排索引
在索引构建时,ES 会默认给每个字段建立索引。这个过程包括分词、语义处理和映射表的构建。首先,文本会被分割成词,分词方式与语言有关,比如英文按空格切割等。接着将无意义的词汇删除,同时进行语义归一化处理。最后构建映射表。如下例子中简要展示了主播15的 Name 字段处理过程:被分词为 allen、sara;进行转换为小写等操作;构建 allen->15、sara->15 映射。
json
// 主播1
{
"id": 1
"name":"ada sara"
... // 其他字段
}
// 主播15
{
"id": 15
"name":"allen sara"
}
查询过程
以查询名称为"allen sara"的主播为例,按分词结果分别查找到两个列表[12, 15]、[1, 15](实际应用还会按近义词进行查询);合并列表与打分,按优先级得到结果[15, 12, 1](这是搜索里的召回步骤,还会按算法进行精排)。
优化项
为了加快检索速度、降低内存/硬盘压力,ES 对倒排索引作以下优化,这也是 ES 相对于其他组件的优势。这里需要注意的是对存储空间的极致利用可能是所有数据库的共同特点,Redis 也是如此节省内存空间:尽可能少的 bit 位存储数据、小集合与大集合以不同方式存储。
- Term Index:使用前缀树加快对"Term"词的定位,解决词数量过多导致检索速度慢的问题;
- Term Dictionary:将相同前缀的词放到一个数据块并仅保留后缀,例如[hello,head] -> [lo, ad];
- Posting:有序+增量编码+分块存储,例如[9, 10, 15, 32, 37] -> [9, 1, 5, 17, 5], 每个元素可以使用 5bit 存储;
- Posting 合并优化:使用 Roaring Bitmap节省空间,使用多条件查询时需要对多个 Posting 求并;
- 语义处理:可以查询到语义相近的内容。
倒排索引的特点:
- 支持全文搜索:以不同的分词插件支持多种语言,例如 IK 分词插件实现中文全文搜索;
- 索引体积小:前缀树极大地压缩了空间、索引可以放到内存以加快检索速度;
- 对范围查找支持较差:受前缀树的选型限制;
- 适用场景:按词检索,非范围查找。ES非数值型字段采用该类型索引。
B lock K d Tree 索引
Block Kd Tree 索引的特点是对范围查找非常友好,ES 数值、geo、range 等字段类型均使用该索引类型。在业务选型中需要进行范围查找的数值字段应采用 Long 等数值类型,针对倒排索引,不需要全文检索的字段采用 Keyword 类型,否则采用 Text。
由于篇幅有限本文不在这里进行过多介绍,对 BKd Tree 感兴趣的朋友可以参考以下内容:
数据存储
本部分内容主要说明单实例内的数据是如何被存储在内存、硬盘中的。
分段存储 Segment
单个实例的数据高达数百 GB,存储在一个文件显然不合适。与 Kafka、Pulsar等需要存储 Append Only 数据的组件一样,ES 选择了将数据拆分成一个个分段 Segment 进行存储。
- Segment:每个 Segment 有自己的索引文件,并行查询后对结果进行合并;
- Segment 生成时机:定时生成或根据文件大小,时长可配置,一般为数秒;
- Segment 合并:因 Segment 是定时生成的一般都比较小,需要合并成大的 Segment。
延迟与数据丢失风险
- 检索延迟:条件检索是依赖索引的,而索引是 Segment 生成时才有,所以从写入到可检索一般有数秒延迟;
- 数据丢失风险:新生成的 Segment 默认数十分钟才刷盘,有数据丢失的风险;
- 数据丢失风险减小:额外使用 Translog 记录写入事件,默认每 5s 刷盘,但仍有丢失数秒数据的风险。
Delete/Update 的实现方式
- Delete:每个 Segment 对应一个del文件,记录被删除的 ID,检索结果需过滤掉;
- Update:写入新文档,并删除旧文档。
集群
单机数据库存在容量和吞吐量有限、容灾能力弱等问题,一般通过分片、数据冗余来解决,但两个操作一般会引入以下问题。我们先看看 ES 是如何分片与备份数据的,再看如何解决以下三个问题:读写请求如何路由到各分片?如何合并各分片的检索结果?主备实例如何选主?
分布式 Shard
每个索引的分片数量可以独立配置,下图以具有3个 Shard 的索引为例,通过水平扩展提升整体存储容量,通过各 Shard 并行计算提升检索速度。
路由策略根据主键对单个 Document 进行读写。哈希路由默认取 ID 为主键,对于写操作,如果业务方未指定主键 ID,ES 使用 Guid 算法自动生成。由于路由策略限制,分片数量的增减需要迁移全量数据。针对按条件检索的 Search 请求,通过协作者 Coordinate 和 Query Phase 查询阶段、Fetch Phase 获取阶段两个步骤实现。协作者将读请求发到任意一个实例,该实例将请求并行发送到每个分片,各分片执行本地 SQL 后向协作者返回 2000+100 个数据,每个数据包含 id 和 uid。协作者对所有分片数据排序,得到 100 个 Document 的 ID,再按 ID 获取数据并返回给客户端。
缺点是以上检索方式对客户端屏蔽了分片的概念,极大地便利了读写操作,不需要像 MySQL 一样感知分库分表,但也存在每个实例都需要开辟 from+limit 大小的空间,当发生深度翻页时,需要的空间会很大;协作者需要对 shard*(from+limit)个 document 进行排序等问题。
针对以上问题我们在实践中对 Search After 的条件项加上 uid>2200 之类每次请求都会变化的参数,可以将排序数量从 from+limit 降低为 Limit;对 Scroll Search After 的另一种形式,在 ES 内部维护每次请求的条件项并支持并发。
主从同步收益
收益主要包括通过数据冗余实现高可用和提高系统吞吐量。数据同步方式有集群内主从同步,一般部署在同地区不同机房以加快写操作,可选择同步或异步,Consistency 可选用 One、All 或 Quorum。此外,还有跨集群同步(CCR),用于多集群在不同地区容灾和就近访问,采用异步方式,索引级别可以是单向或双向复制数据。
适用场景
ES 的实现细节决定了其整体特性,进而影响适用场景。适用场景包括:数据量较大,PB 级别以下;需要全文检索、多字段灵活索引和排序;数据可视化(Kibana);对事务没有要求;对写入后查询延迟要求不高。但是不建议将 ES 作为重要数据的唯一存储,因为存在数秒延迟和数据丢失风险,且不像 MySQL 在各个细节都对高可用性进行了细致优化。
直播运营平台跨域数据聚合系统实践
应用场景
在直播公会和主播等角色的运营平台上,有较多数据查看和分析的场景,例如主播列表、主播和公会任务等,这类数据普遍具有以下特性:数据量较大,字段多且来自多个业务方,例如主播索引字段数量接近 200,数据来源多达 10+(如数据平台、安全平台、钱包等),并且支持按多字段进行检索与排序等操作。
在用户查看数据时实时到各业务方获取数据的耗时较大,难以按多个字段进行条件查询、排序,所以需要将数据提前聚合到单一数据库。MySQL、Redis 等数据库难以满足上述特性,ES 能较好地支持,所以我们基于 ES 构建了一套跨域数据聚合服务系统:消费上游数据源的变动并写到 ES 大索引,以满足查询需求。以"主播索引"为例说明数据聚合模式:
挑战
第一版实现使用了单一 PSM 作为消费者读取上游数据并写入 ES,由于写入未隔离,存在诸多问题。首先,所有接入方在同一 PSM 编写数据消费逻辑,导致数据处理逻辑耦合度高,维护困难。其次,存在多业务方写入同一字段的风险,可能引发业务异常。此外,全量覆盖的 ES 数据写入模式导致数据处理速度慢,MQ 消费速度低。同时,还存在资源竞争、慢查询无法与具体上游关联等问题。随着每个双月新增约 5 个字段和数据的持续增长,如不解决这些问题,未来将面临更大挑战。
对上述问题进行分析可以划分为三类:各数据源处理逻辑耦合度高,整体容易受单一业务方的影响;数据处理速度慢,加剧资源竞争;缺少读写治理能力:写隔离、慢查询统计。
解决方案
下图介绍了治理后的整体架构,基于此我们再来逐一分析治理过程中遇到的问题和考虑。
问题一:各数据源消费逻辑耦合强,维护难度高、彼此影响与抢占资源
这个问题的具体表现在于在同一 PSM 中实现了 10 多个 MQ 的数据消费逻辑,共用的数据处理逻辑及微小改动可能影响其他 MQ 处理,使得维护不便;服务监听多个 MQ 事件,Consumer Group 间存在资源竞争,单 MQ 事件的 Partition 分布不均,导致单机资源利用不均,横向扩展机器无法解决。因此会导致单个 MQ 的代码不稳定后会影响所有 MQ Topic 的消费,个别 MQ Topic 的 Partition 分布不均会使个别消费者实例 CPU 暴涨,从而影响其他 Topic 的消费。
优化策略:
- 提高单个事件的消费速度:ES 部分更新;重新审视所有 Topic 的限流配置;
- 建设更多数据写入方式,将非核心字段的写入分散到各个业务侧。例如提供写 SDK、引入 Dsyncer。
问题二: MQ 数据消费速度慢,业务数据更新有延迟
单个 MQ 消息的处理耗时高,以全量覆盖的 ES 数据写入模式为例,更新一个字段需要将其余不需要更新的字段一并写入,因为需要通过 RPC 实时获取近 200 个字段数据,整体耗时较高,MQ 消费速度慢;部分 MQ Topic 在单实例的消费 Worker 为 1。主要的影响是数据更新延迟高,用户信息变动后需要一段时间才能在下游平台展示。并且每次更新都需要从多个业务方获取近 200 个字段,单一数据源异常会导致整个 MQ 事件消费的失败与重试。
优化策略:
- 将 ES 集群的数据写入模式从全量覆盖修改为部分更新:可以按需更新单个字段,Consumer 不再需要从多个业务方获取近 200 个字段,既降低了数据处理耗时,也降低了代码维护难度;
- 将所有 MQ Topic 的 Worker 配置为多个,对顺序消费有要求的配置为按主键 ID 路由到同一 Worker。
问题三:写入未隔离/鉴权/限流
字段写入缺乏隔离与鉴权,存在多业务方写入同一字段的风险,可能引发业务异常。主要原因是写入方共享资源,一方过快写入会挤占其他方资源,导致写入延迟增大。因此需要对 ES 存储的核心字段更新严格把控,避免引发用户大量反馈。
优化策略:
- 新增字段级别的写鉴权,仅允许有权限的 PSM 写入某字段数据;
- 对 PSM、索引两个维度进行限流策略,这里使用了通用流量管理平台上可动态配置的组件。
问题四:缺少慢查询统计与优化方式
和 MySQL 等数据库一样,不规范 SQL 会导致不必要的扫盘,使查询延迟较大。ES 提供了查询耗时较大 SQL 的能力,但无法关联上游 PSM、Logid 等信息,排查难度较大。
优化策略:在读代理以中间层的形式将耗时超过阈值的 SQL、上游 PSM、Logid 等消息记录到 ES,每天报告慢查询情况。
问题五:易用性
优化策略:
- 在 ES 集群启用 ES SQL 插件,因 ES SQL 语法与 MySQL SQL 有细微差异,通过读代理服务额外支持:用户侧使用 MySQL 语法,读代理使用正则表达式将 SQL 改写为 ES SQL 标准;将 ScrollID 注入 ES SQL,用户侧不需要关心如何在 SQL 表达 Scroll 查询;
- 帮助用户将查询所得数据反序列化为结构体。
sql
// es dsl查询样例
GET twitter/_search
{
"size": 10,
"query": {
"match" : {
"title" : "Elasticsearch"
}
},
"sort": [
{"date": "asc"}
]
}
// 使用读sdk的等价sql
select * from twitter where title="Elasticsearch" order by date asc limit 10
治理结果
通过以上治理使写链路的堆积完全消除,消费能力提高了 150%,具体体现在使业务的 QPS 从 4k 提升至 10k,且并未达到系统性能上限。读 QPS 的高峰为 1500,SLA 长期稳定在 99.99%。目前写 SDK 已经有多个业务方在使用,业务方反馈接入耗时从原来的 2 天降低到 0.5 天。
后续规划
后续规划主要包括将 MVP 对账能力从个别场景扩展到所有场景;依据慢查询统计数据,推动上游业务优化 SQL;提供更多数据写入方式,如 FaaS 等。
基于字节跳动内部大规模最佳实践经验,火山引擎对外提供了内外一致的 ES 产品 ------云搜索服务企业级云产品。云搜索服务兼容 Elasticsearch、Kibana 等软件及常用开源插件,提供结构化、非结构化文本的多条件检索、统计、报表,可以实现一键部署、弹性扩缩、简化运维,快速构建日志分析、信息检索分析等业务能力。