共计 6949 个字符,预计需要花费 18 分钟才能阅读完成。
本文作者
Yuanli,来自 Shopee Data Infra OLAP 团队。
摘要
Apache Druid 是一款高性能的开源时序数据库,它实用于交互式体验的低延时查问剖析场景。本文将次要分享 Apache Druid 在撑持 Shopee 相干外围业务 OLAP 实时剖析方面的工程实际。
随着 Shopee 业务一直倒退,越来越多的相干外围业务更加依赖基于 Druid 集群的 OLAP 实时剖析服务,越来越严苛的利用场景使得咱们开始遇到开源我的项目 Apache Druid 的各种性能瓶颈。咱们通过剖析研读外围源码,对呈现性能瓶颈的元数据管理模块和缓存模块做了相干性能优化。
同时,为了满足公司外部外围业务的定制化需要,咱们开发了一些新个性,包含整型准确去重算子和灵便的滑动窗口函数。
1. Druid 集群在 Shopee 的利用
以后集群部署计划是保护一个超大集群,基于物理机器部署,集群规模达 100+ 节点。Druid 集群作为相干外围业务数据我的项目的上游,能够通过批工作和流工作写入数据,而后相干业务方能够进行 OLAP 实时查问剖析。
2. 技术优化计划分享
2.1 Coordinator 负载平衡算法效率优化
2.1.1 问题背景
咱们通过实时工作监控报警发现,很多实时工作因为最初一步 segment 公布交出(Coordinate Handoff)期待超时失败,随后陆续有用户跟咱们反映,他们的实时数据查问呈现了抖动。
通过考察发现,随着更多业务开始接入 Druid 集群,接入的 dataSource 越来越多,加上历史数据的累积,整体集群的 segment 数量越来越大。这使得 Coordinator 元数据管理服务的压力加大,逐步呈现性能瓶颈,影响整体服务的稳定性。
2.1.2 问题剖析
Coordinator 一系列串行子任务分析
首先咱们要剖析这些串行是否能够并行,但剖析发现,这些子工作存在逻辑上的前后依赖关系,因而须要串行执行。通过 Coordinator 的日志信息,咱们发现其中一个负责均衡 segment 在历史节点加载的子工作执行超级慢,耗时超过 10 分钟。正是这个子工作拖慢了整个串行工作的总耗时,使得另一个负责安顿 segment 加载的子工作执行距离太长,导致后面提到的实时工作因为公布阶段超时而失败。
通过应用 JProfiler 工具剖析,咱们发现负载平衡算法中应用的蓄水池采样算法的实现存在性能问题。剖析源码发现,以后的蓄水池采样算法每次调用只能从总量 500 万 segment 中采样一个元素,而每个周期须要均衡 2000 个 segment。也就是说,须要遍历 500 万的列表 2000 次,这显然是不合理的。
2.1.3 优化计划
实现批量采样的蓄水池算法,只须要遍历一次 500 万的 segment 元数据列表,就能实现 2000 个元素的采样。优化之后,这个负责 segment 负载平衡的子工作的执行耗时只须要 300 毫秒。Coordinator 串行子工作的总耗时显著缩小。
Benchmark 后果
Benchmark 后果比照发现,批量采样的蓄水池算法性能显著优于其余选项。
社区单干
咱们曾经把这个优化奉献给 Apache Druid 社区,详见 PR。
2.2 增量元数据管理优化
2.2.1 问题背景
以后 Coordinator 进行元数据管理的时候,有一个定时工作线程默认每隔 2 分钟从元数据 MySQL DB 中全量拉取 segment 记录,并在 Coordinator 过程的内存中更新一个 segment 汇合的快照。当集群中 segment 元数据量十分大时,每次全量拉取的 SQL 执行变得很慢,并且反序列化大量的元数据记录也须要很大的资源开销。Coordinator 中一系列 segment 治理的子工作都依赖于 segment 汇合的快照更新,所以全量拉取 SQL 的执行太慢会间接影响到整体集群数据(segment)可见性的及时性。
2.2.2 问题剖析
咱们首先从元数据增删改的角度,分 3 种不同的场景剖析 segment 元数据的变动状况。
元数据减少
dataSource 的数据写入会生成新的 segment 元数据,而数据写入形式次要分为批工作和 Kafka 实时工作。Coordinator 的 segment 治理子工作及时感知并治理这些新减少的 segment 元数据,对于 Druid 集群写入数据的可见性十分要害。通过 Druid 外部自带 metric 指标,剖析发现 segment 单位工夫内的增量远远小于总量 500w 的记录数。
元数据删除
Druid 能够通过提交 kill 类型的工作来清理 dataSource 在指定工夫区间内的 segment。kill 工作会首先清理元数据 DB 中的 segment 记录,而后删除 HDFS 中的 segment 文件。而曾经 download 到历史节点本地的 segment,则由 Coordinator 的 segment 治理子工作负责告诉清理。
元数据更改
Coordinator 的 segment 治理子工作中有一个子工作会依据 segment 的版本号,标记革除版本号比拟旧的 segment。这个过程会更改相干元数据记录中代表 segment 是否无效的标记位,而曾经 download 到历史节点本地的旧版本 segment,也是由 Coordinator 的 segment 治理子工作负责告诉清理。
2.2.3 优化计划
通过对 segment 元数据增删改 3 种状况的剖析,咱们发现,对新减少的元数据进行及时感知和治理十分重要,它会间接影响新写入数据的及时可见性。而元数据的删除和更改次要影响数据清理,这块的及时性要求绝对低一些。
综上剖析,咱们的优化思路是:实现一种增量的元数据管理形式,只从元数据 DB 中拉取最近一段时间新减少的 segment 元数据,并与以后的元数据快照合并失去新的元数据快照,进行元数据管理。同时,为了保证数据的最终一致性,实现优先级绝对低一些的数据清理,每隔较长一段时间会进行一次全量拉取元数据。
原来全量拉取的 SQL 语句:
SELECT payload FROM druid_segments WHERE used=true;
增量拉取的 SQL 语句:
-- 为了保障 SQL 执行效率,提前在元数据 DB 中为新加的过滤条件创立索引
SELECT payload FROM druid_segments WHERE used=true and created_date > :created_date;
增量功能属性配置
# 增量拉取最近 5 分钟新加的元数据
druid.manager.segments.pollLatestPeriod=PT5M
# 每隔 15 分钟全量拉取元数据
druid.manager.segments.fullyPollDuration=PT15M
上线体现
通过监控零碎指标发现,启用增量治理性能之后,拉取元数据和反序列化耗时显著升高。同时也升高了元数据 DB 的压力,用户反馈的写入数据可读性慢的问题也失去了解决。
2.3 Broker 后果缓存优化
2.3.1 问题背景
在查问性能调优过程中,咱们发现,很多查问利用场景不能很好地利用 Druid 提供的缓存性能。以后 Druid 外面存在两种缓存形式,别离是后果缓存和 segment 级别的两头后果缓存。第一种后果缓存只能利用于 Broker 过程,而 segment 级别的两头后果缓存能够利用于 Broker 和其余数据节点。然而以后这两种缓存性能都存在显著的局限性,如下方表格所示。
缓存计划 / 应用场景 / 是否可用 | 场景一:应用 group by v2 引擎 | 场景二:仅扫描历史 segment | 场景三:同时扫描历史 segment 和实时 segment | 场景四:高效缓存大量 segment 的后果 |
---|---|---|---|---|
segment 级别缓存 | ✗ | ✓ | ✓ | ✗ |
后果缓存 | ✗ | ✓ | ✗ | ✓ |
2.3.2 问题剖析
应用 group by v2 引擎的状况下缓存不可用
group by v2 引擎在过来很长时间的很多稳固版本中,都是 groupBy 类型查问的默认引擎,在可预感的将来很长一段时间也一样。而且 groupBy 类型的查问又是最常见的查问类型之一,另外两种类型是 topN 和 timeseries。group by v2 引擎不反对缓存的问题直到 0.22.0 版本仍然存在,见缓存不反对场景。
通过跟踪社区的变更记录,咱们发现 group by v2 引擎不反对缓存的起因是,segment 级别的两头后果没有排序可能会导致查问合并后果不正确,具体细节见社区的这个 issue。
上面简略总结一下,为什么 Druid 社区抉择通过禁用性能来修复这个 Bug:
- 如果排序 segment 级别的两头后果,而后再把排序后果缓存起来的话,当 segment 数量很多的时候,会减少历史节点的负载;
- 如果不排序 segment 级别的两头后果间接缓存,那么 Broker 须要对每个 segment 的两头后果进行从新排序,会减少 Broker 的累赘;
- 如果间接禁用这个性能的话,那么不仅历史节点不会受到任何影响,而且 Broker 合并后果不对的 bug 也解决了。:)
社区修复计划同时还误伤了后果缓存的性能,使得修复之后的版本应用 group by v2 引擎时,Broker 下面的后果缓存也不可用了,见缓存不反对场景。
后果缓存的局限性
后果缓存要求查问每次扫描的 segment 汇合统一,并且所有 segment 都是历史 segment。也就是说,只有查问条件须要查问最新的实时数据,那么后果缓存就不可用。
对于 Druid 这种实时查问剖析利用场景见长的服务来说,后果缓存的这个局限显得尤为突出。很多业务场景的查问面板都是查问最近一天 / 一周 / 一月的时序聚合后果,包含最新实时数据,然而这些查问都不反对后果缓存。
segment 级别两头后果缓存的局限性
segment 级别两头后果缓存的性能能够同时在 Broker 和其余数据节点下面启用,次要实用于历史节点。
Broker 上启用 segment 级别两头后果缓存,当扫描 segment 数量很大的状况下,存在如下局限性:
- 提取缓存后果的反序列化过程会给 Broker 减少额定开销;
- 减少 Broker 节点合并两头后果的开销,没法利用历史节点来合并局部两头后果。
在历史节点上启用 segment 级别两头后果缓存,其工作流程图如下:
在理论利用场景中,咱们发现,当 segment 的两头缓存后果很大的时候,序列化和反序列化缓存后果的开销也不可漠视。
2.3.3 优化计划
通过上述剖析,咱们发现以后两种缓存性能都存在显著的局限性。为了更好地进步缓存效率,咱们在 Broker 下面设计并实现了一种新的缓存性能,该性能会缓存历史 segment 的两头合并后果,能很好地补救以后两种缓存的有余。
新缓存属性配置
druid.broker.cache.useSegmentMergedResultCache=true
druid.broker.cache.populateSegmentMergedResultCache=true
实用场景比照
缓存计划 / 应用场景 / 是否可用 | 场景一:应用 group by v2 引擎 | 场景二:仅扫描历史 segment | 场景三:同时扫描历史 segment 和实时 segment | 场景四:高效缓存大量 segment 的后果 |
---|---|---|---|---|
segment 级别缓存 | ✗ | ✓ | ✓ | ✗ |
后果缓存 | ✗ | ✓ | ✗ | ✓ |
segment 合并两头后果缓存 | ✓ | ✓ | ✓ | ✓ |
工作原理
Benchmark 后果
通过 benchmark 后果能够发现,segment 合并两头后果缓存性能不仅首次查问不存在显著额定开销,而且缓存效率显著优于其余缓存选项。
上线体现
启用新的缓存性能后,集群总体查问提早升高约 50%。
社区单干
咱们筹备把这个新的缓存性能奉献给社区,以后该 PR 还在期待更多的社区反馈。
3. 定制化需要开发
3.1 基于位图的准确去重算子
3.1.1 问题背景
不少要害的业务须要统计准确的订单量和 UV,而 Druid 自带几种去重算子都是基于近似算法实现,在理论利用中存在误差。因而,相干业务都心愿咱们能提供一种准确的去重实现。
3.1.2 需要剖析
去重字段类型剖析
通过剖析收集到的需要,发现急迫需要中的订单 ID 和用户 ID 都是整型或者长整型,这就使得咱们能够思考省掉字典编码的过程。
3.1.3 实现计划
因为 Druid 社区短少这块的实现,于是咱们选用罕用的 Roaring Bitmap 来定制新的算子(Aggregator)。针对整形和长整型别离开发相应的算子,都反对序列化和反序列化用于 rollup 导入模型。于是咱们很快公布了这个性能的第一个稳定版,它能很好地解决数据量比拟小的需要。
算子 API
// native JSON API
{
"type": "Bitmap32ExactCountBuild or Bitmap32ExactCountMerge",
"name": "exactCountMetric",
"fieldName": "userId"
}
-- SQL support
SELECT "dim", Bitmap32_EXACT_COUNT("exactCountMetric") FROM "ds_name" WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY GROUP BY key
局限性剖析和优化方向
以后的简略实现计划,面对数据量很大的需要,它的性能瓶颈也裸露进去了。
两头后果集太大导致的性能瓶颈
新的算子内存空间占用过大,缓存写入和提取都存在显著的开销,并且这类算子次要用于 group by 查问,所以以后现有的缓存都不能施展应有的作用。这也进一步驱动咱们设计开发了一个新的缓存选项,segment 合并两头后果,详见前文所述。
通过无效缓存 segment 合并的两头后果,大大降低了 segment 级别两头后果太大带来的序列化和反序列化开销。另外,将来也会思考通过从新编码的形式,升高数据分布的离散水平,进步 bitmap 对整型序列的压缩率。
内存估算艰难的问题
因为 Druid 查问引擎次要通过堆外内存 buffer 解决两头结算后果来缩小 GC 影响,这就要求算子外部数据结构反对比拟精确的内存估算。然而这类基于 Roaring bitmap 的算子不仅难以估算内存,而且在运算过程中只能在堆内存中结构对象实例。这使得这类算子在查问中内存开销不可控,极其查问状况下甚至可能呈现 OOM 的状况。
针对这类问题,短期内咱们次要通过联合上游数据处理来缓解,比方从新编码,正当分区分片等等。
3.2 灵便的滑动窗口函数
3.2.1 问题背景
Druid 外围查问引擎仅反对固定窗口大小的聚合函数,短少对灵便滑动窗口函数的反对。一些要害业务方心愿每日统计近 7 天的 UV,这就要求 Druid 反对滑动窗口聚合函数。
3.2.2 需要剖析
社区 Moving Average Query 扩大的局限性
通过考察,咱们发现社区已有的扩大插件 Moving Average Query 反对一些根本类型的滑动窗口计算,然而短少对其余简单类型(对象类型)的 Druid 原生算子的反对,比方广泛应用的 HLL 类型近似算子等。同时,这个扩大也短少对 SQL 的反对适配。
3.2.3 实现计划
通过研读源码,咱们发现这个扩大还能够更加通用和简洁。咱们减少了一个 default 类型的算子实现,它能依据根底字段的类型,实现对根底字段的滑动窗口聚合。也就是说,通过这一个 default 类型的算子就能够让所有 Druid 原生算子(Aggregator)反对滑动窗口聚合。
同时,咱们为这个通用的算子适配了 SQL 函数反对。
算子 API
// native JSON API
{
"aggregations": [
{
"type": "hyperUnique",
"name": "deltaDayUniqueUsers",
"fieldName": "uniq_user"
}
],
"averagers": [
{
"name": "trailing7DayUniqueUsers",
"fieldName": "deltaDayUniqueUsers",
"type": "default",
"buckets": 7
}
]
}
-- SQL support
select TIME_FLOOR(__time, 'PT1H'), dim, MA_TRAILING_AGGREGATE_DEFAULT(DS_HLL(user), 7) from ds_name where __time >= '2021-06-27T00:00:00.000Z' and __time < '2021-06-28T00:00:00.000Z' GROUP BY 1, 2
社区单干
咱们筹备把这个新性能奉献给社区,以后该 PR 还在期待更多的社区反馈。
4. 将来架构演进
为了更好地从架构层面解决稳定性问题,实现降本增效,咱们开始摸索和落地 Druid 的云原生部署计划。后续咱们还会分享对于这一块的实践经验,敬请期待!
🔗 参考链接
- Apache Druid
- Reduce method invocation of reservoir sampling
- Add segment merged result cache for broker
- Scenarios where caching does not increase query performance
- groupBy v2: Results not fully merged when caching is enabled on the broker#3820
- Moving Average Query
- Add default averager to support general trailing aggregates #11387