数据流水线的成本自适应算子

简介

披露一个大数据处理技术。

如今我们构建很多的数据流水线(data pipeline)来把数据从一处移动到另一处,并且可以在其中做一些转换处理。数据的移动是有成本的,对此成本的优化可以为数据流水线的拥有者带来成本效益。

数据流水线一般至少包含一个Source组件和一个Sink组件,有时在Source和Sink中间还有一或多个依次执行的中间计算组件(Flume称之为Channel,Flink称之为Transform),这些中间计算组件一般被建模为函数式“算子”(Operator)。有一些算子能减少传给下一个组件的数据量,例如过滤器(Filter)算子,这就是成本优化的关键。在分布式数据库领域有一个查询优化技术叫做“谓词下推”(Predicate Push-down),我们所做的与之相像,是把算子往上游Source的方向推。或许可称之为“算子上推”,这与“谓词下推”不矛盾,因为在分布式数据库的查询中,数据是从下向上流动的,Source在下,下推也就是往Source推。

理论上来说,算子越靠近Source越好,因为能减少从Source一路传下来的数据量。但是实际上要考虑算子的效率。Source可能是可伸缩性不强的资源(例如数据库),部署太多的计算在上面会使其变慢,因此不应该把低效率的算子推到Source。

我们对算子效率的定义是:效率=选择度/成本。算子的选择度越高,成本越低,那么就认为其效率越高。选择度的定义是“数据量的减少率”:有的算子类型并不能减少数据量(例如大多数Transformer算子),而即使Filter算子也不一定能有效减少数据量(若数据100%通过Filter,就没有减少数据量)。若一个Filter只允许20%的数据通过,则它让数据减少了5倍,它的选择度是1/0.2=5。Filter不是唯一能减少数据量的算子类型,我们已知的还有Projector(通过去掉几个字段来减少数据量)和Compressor(通过压缩来减少数据量)。成本的定义是“计算所耗费的资源”,例如算子的执行所用的CPU时间。基于对算子效率的监控,很容易想到可以配置一个效率阈值,只把效率高于阈值的算子推到Source。

这么做的实际效果还不够好,因为效率不是唯一的考量,Source的资源利用率也很重要。如果Source的资源不够(例如CPU使用率100%),即使是高效率算子也可能要被推到下游去,以减轻Source的压力。如果Source的资源充裕,那么即使是低效率算子也可以被放在上游以提高整体效率。

设计

根据以上的需求,可以设计一个算子调度框架来动态管理数据流水线中的算子分布了。我会用一些示例代码来展示相应设计。

监控

第一步是监控。

对于如下的算子API:

interface Operator {
    R apply(T t);
}

作如下修改,添加一个SelectivityCalculator工厂方法,每一种算子可以提供一个适合自己的SelectivityCalculator实例:

interface Operator {
    R apply(T t);
    // 若返回null,表示此算子不支持计算选择度,也无法被调度
    default SelectivityCalculator selectivityCalculator() {
        return null;
    }
}

interface SelectivityCalculator {
    // 为每次调用的输入输出作记录
    void record(T input, R output);
    // 此方法会被定时调用,返回根据已记录数据计算得到的选择度
    double selectivity();
}

这个新方法怎么在算子上实现呢?例如Filter会这么定义SelectivityCalculator:

interface Filter extends Operator {
    FilterSelectivityCalculator selectivityCalculator() {
        return new FilterSelectivityCalculator<>();
    }
}

class FilterSelectivityCalculator implements SelectivityCalculator {
    private long inputTotal;
    private long outputTotal;

    void record(T input, Boolean output) {
      inputTotal++;
      if (output == true) {
        outputTotal++;
      }
    }
  
    double selectivity() {
      if (outputTotal == 0) {
        return Double.MAX_VALUE;
      }
      return ((double) inputTotal) / outputTotal;
    }
}

可以编写这样的监控程序,记录每一种算子的执行次数和累计执行时间:

// 某个算子的指标记录器
class MetricsRecorder {
    private long callCount;
    private long totalNanos;
    private SelectivityCalculator selectivityCalculator;
  
    MetricsRecorder(SelectivityCalculator selectivityCalculator) {
      this.selectivityCalculator = selectivityCalculator;
    }

    void record(long nano, Object input, Object output) {
        callCount++;
        totalNanos += nano;
        selectivityCalculator.record(input, output);
    }
  
    // 计算当前状态的快照
    MetricsSnapshot snapshot() {
      return new MetricsSnapshot(callCount, totalNanos, selectivityCalculator.selectivity());
    }

    // getters ......
}

class MetricsSnapshot {
    private long callCount;
    private long totalNanos;
    private double selectivity;
    // constructor and getters ......
}

// 每个线程拥有这么一个算子执行器,它持有各种算子的指标记录器
class OperatorExecutor {
    // Because each thread will have an executor instance, no need to make it thread-safe
    private Map, MetricsRecorder> operatorMetricsRecorders = new HashMap<>();

     R execute(Operator operator, T t) {
        long startTime = System.nanoTime();
        R r = operator.apply(t);
        long duration = System.nanoTime() - startTime;
        var recorder = operatorMetricsRecorders.computeIfAbsent(operator.getClass(), k -> new MetricsRecorder(operator.selectivityCalculator()));
        recorder.record(duration, t, r);
    }

    // 计算当前状态的快照
    Map, MetricsSnapshot> snapshot() {
      Map, MetricsSnapshot> snapshotMap = new HashMap<>();
      operatorMetricsRecorders.forEach((k, v) -> {
        snapshotMap.put(k, v.snapshot());
      });
      return snapshotMap;
    }
  
    // 主动把快照写入指定的队列
    void offerSnapshot(SnapshotQueue queue) {
      queue.offer(snapshot());
    }
}

请注意这里并没有使用ConcurrentHashMap,也没有任何涉及多线程同步的处理。因为大多数算子是很轻量级的,监控程序也要做到足够轻量级,不拖慢算子的性能。大家如果深入用过Profiler就知道,对程序性能的细粒度的Profiling可能会减慢性能,使得性能测算结果不准确。在这里,我们的做法是不在记录数据时时做多线程同步,以免同步所致的锁定和缓存驱逐降低性能。会有多个线程来执行算子,我们让每个线程拥有一个独立的无需同步的OperatorExecutor实例。有一个专门的线程来定时通知每个OperatorExecutor实例,令其生成一份当前状态的快照并写入指定的队列,每个实例在自己的线程里生成快照是不需要额外做同步的。这种基于队列的异步通信比较像Go channel(Go社区的朋友很熟悉这风格吧,其实Java也可以做到)。这种定时获取指标数据的风格比较像Prometheus。

同时也定时获取CPU使用率,这个可以用JDK自带的OperatingSystemMXBean来实现,就不多讲了。

调度

第二步就是实现调度策略,有这些要点:

  1. 所谓的“移动”算子,其实是在两处都部署了算子,在另一处启用算子,同时在原处禁用算子,如果做不到“同时”,就先启用再禁用,算子在流水线上会”至少执行一次“,需要实现幂等性。
  2. 流水线上的算子大多是有执行顺序的(一步接一步),不可能只把前面的算子推到下游,却把后面的算子留在上游。这给我们的调度带来了约束。(关于这个可以有一些进一步的优化技术。)
  3. 调度规则最好能支持动态更新,这样有助于根据实际情况灵活调整。

虽然不是必须的,但我们建议部署一个专门的调度器(Scheduler)服务。它远程统一操控整个流水线的算子分布,调度规则也只需要由这个服务来集中管理,它与Source和Sink等节点通过REST API通信就可以了。例如,当Source CPU使用率太高,会引起一个性能事件(performance event),Scheduler收到此事件,根据调度规则决定把一个算子推到下游,它会先在下游节点启用此算子,再在Source节点禁用此算子。如果算子不是幂等的,我们需要给被传输的数据加一个标记,这个标记只需要一个序数值来说明它已到达流水线的第几步,这样下游收到它就可以直接从下一步开始。

待续……

你可能感兴趣的:(大数据)