4. 预处理阶段优化:数据采样与探查、自定义分区策略、Salting技术(加盐)原理

预处理阶段,说白了就是正式计算前的「排雷」工作。很多同学一上来就写业务逻辑,结果跑起来才发现数据倾斜,那时候再回头排查,成本就高了。我个人习惯,在写任何增量计算任务之前,先花 10% 的时间做预处理,这能省下后面 90% 的调优时间。

这一章我们聊三个核心手段:数据采样与探查、自定义分区策略、以及 Salting(加盐)技术。它们不是孤立的,而是一套组合拳。

4.1 数据采样与探查:先摸清数据的脾气

你想想看,如果连数据长什么样都不知道,你怎么知道它会不会倾斜?我遇到过好几次,开发同学拍着胸脯说「数据分布很均匀」,结果一跑任务,某个 Key 的数据量是其他 Key 的几百倍。

数据采样,就是用小成本摸清大数据的底细。在增量计算中,我们通常对历史存量数据做一次采样,而不是全量扫描。

4.1.1 采样方法

常用的采样方法有这几种:

方法 原理 适用场景
随机采样 按概率随机抽取记录 数据量极大,分布未知
分层采样 按 Key 分组后按比例抽取 已知 Key 类别,需保证各类别都有样本
水塘采样 流式数据中保持均匀采样 增量数据流,无法预知总量

我个人偏爱水塘采样。为什么?因为增量计算的数据是源源不断的,你没法一次性拿到全量。水塘采样可以在不预知数据总量的情况下,保证每个元素被选中的概率相等。

核心目标:通过采样,找出 Key 的分布直方图,识别出哪些 Key 是「热 Key」——也就是数据量远超平均水平的 Key。

4.1.2 探查什么

采样之后,我们需要探查三个关键指标:

  • Key 的基数:有多少个不同的 Key?如果 Key 太少,每个 Key 对应的数据量必然很大。
  • Key 的分布倾斜度:用「最大 Key 数据量 / 平均 Key 数据量」这个比值来衡量。比值超过 10,就要警惕了。
  • 数据的时间窗口分布:增量数据往往有时间属性,看看数据是否集中在某个时间段。

我曾经在一个日志处理项目中,采样后发现某个用户 ID 的数据量是其他用户的 500 倍。后来一查,是个爬虫程序在刷数据。如果不做探查,直接跑 Join 操作,那个 Reduce 任务铁定挂掉。

小技巧:采样比例不用太高。对于 TB 级数据,1% 的采样率通常就能反映出分布特征。采样结果可以缓存起来,作为后续任务的元数据。

4.2 自定义分区策略:把数据分均匀

默认的分区策略(比如 Hash 分区)在大多数情况下够用,但遇到数据倾斜就抓瞎了。因为 Hash 分区只认 Key 的哈希值,不认 Key 的数据量。如果一个 Key 的数据量特别大,它会被分到同一个分区,导致那个分区成为瓶颈。

自定义分区策略,就是让我们自己决定「哪个 Key 去哪个分区」。说白了,就是把那些「大 Key」单独拎出来处理。

4.2.1 实现思路

在 Spark 或 Flink 中,我们可以实现自定义的 Partitioner。核心逻辑是:

// 伪代码示例:自定义分区器
class CustomPartitioner(partitions: Int) extends Partitioner {
  // 预先从采样结果中获取的热 Key 列表
  val hotKeys: Set[String] = loadHotKeys()

  override def numPartitions: Int = partitions

  override def getPartition(key: Any): Int = {
    val keyStr = key.toString
    if (hotKeys.contains(keyStr)) {
      // 热 Key 单独分配一个分区,或者分散到多个分区
      // 这里简单示例:所有热 Key 放到最后一个分区
      partitions - 1
    } else {
      // 普通 Key 走正常 Hash
      keyStr.hashCode % (partitions - 1)
    }
  }
}

嗯,这里要注意:把热 Key 单独放到一个分区,只是权宜之计。如果热 Key 的数据量实在太大,一个分区也扛不住。这时候就需要结合 Salting 技术了。

4.2.2 分区策略的选型对比

策略 优点 缺点
Hash 分区 实现简单,均匀性好 无法处理数据倾斜
范围分区 适合排序和范围查询 需要预知数据分布
自定义分区 灵活,可针对热 Key 优化 需要采样数据支持
Salting 分区 彻底解决单 Key 倾斜 需要改造业务逻辑

避坑指南:我曾经在自定义分区中犯过一个错——把热 Key 全部分到同一个分区,结果那个分区的数据量还是太大,导致 OOM。后来我改成把热 Key 打散到多个分区,才解决问题。记住:自定义分区不是终点,Salting 才是终极方案。

4.3 Salting 技术(加盐)原理

Salting,中文叫「加盐」。这个名字很形象——就像做菜时撒盐一样,给数据加一点「调料」,让它们分散开。

它的核心思想很简单:把一个大的 Key,拆成多个小的虚拟 Key。

4.3.1 加盐的原理

假设有一个热 Key 叫 user_123,它的数据量是 100 万条。如果不加盐,这 100 万条数据会全部进入同一个分区。加盐之后,我们给这个 Key 加上一个随机后缀,比如 user_123_0、user_123_1、user_123_2…… 这样原本的一个 Key 就变成了多个 Key,数据自然就分散到不同分区了。

// 加盐示例:在 Map 阶段给 Key 加随机后缀
// 原始 Key: user_123
// 加盐后 Key: user_123_0, user_123_1, user_123_2, ...

// 假设加盐数量为 10
val saltCount = 10
val saltedKey = originalKey + "_" + (Random.nextInt(saltCount))

你可能会问:加盐之后,数据是分散了,但后续的聚合操作怎么办?嗯,这里有个关键点:加盐只在需要分散数据的阶段使用,聚合时需要去盐。

4.3.2 加盐的完整流程

我画了一张图,帮你理解加盐的完整流程:

Salting 加盐处理流程 Salting 加盐处理流程 原始数据 采样识别热 Key(如 user_123) 加盐:user_123 → user_123_0..9 分区 0 (user_123_0) 分区 1 (user_123_1) 分区 9 (user_123_9) 说明:加盐后,原本集中在单个分区的数据被分散到多个分区,每个分区只处理 1/10 的数据量

4.3.3 加盐的两种模式

根据业务场景,加盐有两种常见模式:

  • 随机加盐:给 Key 加一个随机数后缀。适合不需要精确聚合的场景,比如简单的计数、去重。
  • 确定性加盐:根据某个业务字段(如时间、地域)生成固定的后缀。适合需要按业务维度聚合的场景。

举个例子,我在处理电商订单数据时,对用户 ID 加盐用的是确定性加盐——根据订单的省份编码生成后缀。这样同一个省份的订单会落到同一个分区,后续做地域维度的聚合时,不需要跨分区合并数据。

4.3.4 加盐数量的确定

加盐数量(即拆分成几个虚拟 Key)怎么定?我一般按这个公式估算:

加盐数量 = 热 Key 数据量 / (总数据量 / 分区数) * 2

简单说,就是让热 Key 分散后的每个虚拟 Key 的数据量,不超过平均每个分区数据量的 2 倍。当然,这个值不是死的,你可以根据实际运行情况调整。

经验之谈:加盐数量不是越大越好。加盐太多,会导致 Map 阶段输出大量小文件,增加 Shuffle 开销。我一般控制在 10-100 之间,具体看数据量级。

4.4 三种手段的配合使用

这三种手段不是互相替代的,而是层层递进的关系:

  1. 数据采样与探查:先摸清数据分布,找出热 Key。
  2. 自定义分区策略:对普通 Key 做常规分区,对热 Key 做特殊处理。
  3. Salting 技术:对热 Key 进行拆分,彻底解决单 Key 倾斜。

我习惯把这套流程做成一个预处理 Pipeline,每次跑增量计算任务之前自动执行。这样既不用人工干预,又能保证数据倾斜被提前发现和处理。

总结一句话:采样探查是「诊断」,自定义分区是「分流」,Salting 是「拆解」。三者结合,基本能应对 90% 以上的数据倾斜场景。