时间分两种,处理时间和事件时间。大部分情况,数据处理都会选择事件时间。
以离线的天级 Hive 表任务为例,我们看看是如何产出 T+1 的数据的。
T+1 00:00 是处理时间,假定 A 表 Tday 的数据在 00:05 完全到达,B 表 T-day 的数据在 01:05 完全到达。
数据完全到达后,开始 merge Tday 的增量数据,然后根据需要生成全量表或者拉链表。可能的时间轴:
这里为离线数仓任务的开发建立了一个非常友好的模型:SQL 处理的是需要的全量数据。
基于这个易用且成熟的模型,基础设施上只需要确保两点:
离线是处理1天的数据,微批处理时间间隔更小,比如10分钟。因此面对实时的需求,从离线过渡到微批,似乎更加顺理成章一些。
[08:00, 08:10] 区间的数据,A 表在 08:11 完全到达,B 表在 08:16 完全到达。如果完全复用离线处理的思路:(A-base + A-inc) JOIN (B-base + B-inc),但是数据量就太大了,包含了很多的无用计算&存储,同时对下游,也不是 Micro Batch,而是全量数据。
因此,构建的目标应当是产出该 Mirco Batch 内产生变化的数据。
一个初步的想法是:(A-inc JOIN B-base) UNION (B-inc JOIN A-base)
,不过 A-inc 和 B-inc 的数据可能也有关联,因此修正为:
(A-inc JOIN (B-base + B-inc)) UNION (B-inc JOIN (A-base + A-inc)). 这样 SQL 产出的数据就是需要给到下游的全部增量数据。
基于这个想法,具体的落地需求:
使用事件时间的好处,是系统对外清晰,当前批次处理完成,那么 <= 08:10 的数据就都处理完成。如果放到生产环境,还需要考虑使用事件时间时,一次性要处理的数据可能过多。
反过来,如果 T+1 关注的是昨天(<今天00:00)的指标,当开始追求微批处理,我们关注的其实是时效性,而不是这个数据是否一定 <= 08:10。这种情况下,也可以考虑使用处理时间。
上一节在处理 A-inc 时,还有一个默认对用户屏蔽的点,就是删除数据。在当前系统,需要 A 表只能 markDel,如果物理删除的话依赖从 binlog 读取增量数据。因此,基于 Micro Batch 模型,SQL 用户也需要考虑删除数据如何传递的问题。
如果数据在时间周期内多次更新,Micro Batch 和 T+1 都有一个好处,就是可以仅用最新数据触发一次。
从微批到流,我觉得变化最大的是模型。
举一个计算用户历史订单的例子,微批处理时,计算 10 分钟内新增订单的用户,所有历史订单:
SELECT uid, COUNT(1)
FROM trade_table
WHERE update_time IN [current_time, current_time - 10min)
trade_table 存储在 OLAP 引擎,用户开发的 SQL,跟 T+1 的区别不大。如果这个 SQL 交给流计算引擎解释,至少两点是模糊不清的:
我们会发现单纯靠流计算引擎里的 state,解决这个问题是完全不靠谱的。进一步,假设 trade_table_a 是订单的更新流,存储在 Kafka;table_table_b 是历史所有订单,存储在 hbase。
SELECT uid, COUNT(1)
FROM trade_table_a
JOIN(Temporal) table_table_b
ON trade_table_a.uid = table_table_b.uid
如果从全量模型的角度,这里的 COUNT(1) 是 JOIN 到的所有历史订单的总数。但是对于流计算,从 SQL 语义上,其实是 trade_table_a 流上的 COUNT,每次更新 uid,都会触发历史数据在当前基础上再 COUNT 一次。
对于流上 JOIN、AGGREGATE 的处理过程,特别像是后端的一次点查。其 SQL 也跟离线在全量模型上的写法,变化较大。
因此,随着时间边界的不同,模型、思维上都发生了很大变化。
T+1 的调度,前提条件是“全部数据”,也就是任务 DAG . 因此对离线任务来讲,任务调度平台是不可或缺的。流处理任务的“调度”概念要轻的多,我觉得叫做托管更合适一些。
对批处理和流处理任务,对调度的要求更多是稳定性。
但是调度也都会影响到正确性,这点容易被忽略。
两者的异同:
功能上,开发平台都会需要考虑草稿箱、权限、任务组、集群注册、任务回滚、审批审计、运行日志等。
一套平台可以提供统一的使用习惯、权限、报警等,因此无论是实时还是离线,都应该通过一套平台开发和管理任务。而底层调度系统,对于一次性(批)还是 long running(流)的任务,统一管理还有些难度,但是我觉得也应该尽量统一。这点资源调度上,K8S 做的非常靠前,提供了 Job、Crontab、Deployment 等多种资源对象。
]]>最近要落地 Native Flink On Kubernetes,但是公司的容器团队支持力度很小。因此开始看 Kubernetes 相关书籍,这本书是从春节前看的,早上、周末、陆陆续续持续了一个月左右,收获很大。适合入门,非常推荐。
Kubernetes 集群由主节点和工作节点组成:
VM 是 VMware 调度的原子单位,容器是 Docker 调度的原子单位,Pod 则是 Kubernetes 调度的原子单位。简单的使用方式,就是一个 Pod 里只运行一个容器;如果有共享 IPC 命名空间、内存、磁盘、网络等的场景,也可以在一个 Pod 里运行多个容器。
举个 Native Flink On Kubernetes 的例子:
% kubectl describe svc my-first-application-cluster-rest
Name: my-first-application-cluster-rest
Namespace: default
Labels: app=my-first-application-cluster
type=flink-native-kubernetes
Annotations: <none>
Selector: app=my-first-application-cluster,component=jobmanager,type=flink-native-kubernetes
Type: NodePort
IP Family Policy: SingleStack
IP Families: IPv4
IP: x
IPs: x
Port: rest 8081/TCP
TargetPort: 8081/TCP
NodePort: rest 31492/TCP
Endpoints: x:8081
Session Affinity: None
External Traffic Policy: Cluster
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal EnsuringService 13m service-controller Deleted Loadbalancer
每个 Service 对象维护了一个 Endpoint 对象,Endpoint 内部维护了会变化的 Pod IP,这个列表是通过 selector 筛选的。
每一个节点上都运行着一个 kube-proxy,它能够为新的 Service 和 Endpoint 创建 IPVS 规则,从而到达 Service 的 ClusterIP 的流量会被转发至匹配 Label 筛选器的某一个 Pod 上:
Kubernetes 将集群 DNS 作为服务注册中心使用,几个重要的组件:
FQDN 里包含:$object-name.$namespace.svc.cluster.local,例如 ent.prod.svc.cluster.local
举个例子:
这是 dns 解析到了不同 ip.同样的,容器外部访问不了该名字,但是容器内部机器可以:
% curl my-first-application-cluster-rest:8081
curl: (6) Could not resolve host: my-first-application-cluster-rest; Unknown error
% kubectl exec -it -n default my-first-application-cluster-taskmanager-1-1 -- /bin/bash
root@my-first-application-cluster-taskmanager-1-1:/opt/flink# curl my-first-application-cluster-rest:8081
dns 修改了 pod 的 /etc/resolve.conf,比如同一个 flink 任务的 jobmanager、taskmanager:
root@my-first-application-cluster-6647497579-79dr5:/opt/flink# cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver x.y.z
options ndots:5
root@my-first-application-cluster-taskmanager-1-1:/opt/flink# cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver x.y.z
options ndots:5
这个 x.y.z 正是 kube-dns service 的 CLUSTER-IP
kind=PersistentVolumen kind=PersistentVolumeClaim
挂载外部存储的方式,预计当前阶段用的不多,没有细看。后续实践里,打算用于挂载用户 Flink 任务未打包到镜像里的文件。
应用和配置解耦,其优点如下:
ConfigMap 包含了多个 key/value 格式的数据。具体创建和使用的流程
通过卷导入ConfigMap:
spec:
volumes:
- name: volmap
configMap:
name:multimap
3.将 volmap 挂载到 /etc/name
spec:
containers:
- name: ctr
image: nginx
volumeMounts:
- name: volmap
mountPath: /etc/name
这样的效果,就是 /etc/name 目录下有了文件,文件名是 multimap 的 key,文件内容是对应的 value
再看看实际场景里,flink jobmanager 是如何使用 ConfigMap 的:
spec:
containers:
- args:
- native-k8s
- ...
volumeMounts:
- mountPath: /opt/flink/conf
name: flink-config-volume
volumes:
- configMap:
defaultMode: 420
items:
- key: logback-console.xml
path: logback-console.xml
- key: log4j-console.properties
path: log4j-console.properties
- key: flink-conf.yaml
path: flink-conf.yaml
name: flink-config-zlink-202402201833
name: flink-config-volume
集群运维是最为复杂和考验熟悉程度的:
但实际上,我在做后端服务的时候,也曾调研过能否使用大数据的组件,比如 Flink、Kafka。很多后端服务也会用到大数据的存储,比如 Hbase 来存储数据。
还记得刚从后端转到大数据开发时,对各种差别感到疑惑。如今做了几年大数据,有的疑惑逐渐解开,有的疑惑依旧看不清,有必要阶段性的总结一下。当然,工程师不应该限制自己是大数据、前端还是后端还是算法,但是试图理清区别和联系,能够让我们的视野看的更高。
以在大数据离线任务开发中,常见的 Apache DolphinScheduler 工作流调度系统为例。DolphinScheduler 架构是一套典型的去中心化的 Master-Worker 架构。相同的架构,我之前做后端服务时也实现过一版,用来更新搜索词典、数据删除、Key白名单等操作,区别仅仅是总控模块不叫 master 而是 center.
系统的关注点是类似的,比如 master/center 依赖 zk 实现高可用;worker 处理能力线性扩展;RPC的选型、request/response 的数据结构;数据库读写控制在总控模块;数据库的优化等等。
Flink 里的 JobManager、TaskManager 也是如此:JobManager 负责资源管理、Checkpoint、RestEndpoint这些服务,TaskManager 则负责具体执行用户代码。两者之间通过 akka RPC 通信,采用内存队列缓存 RPC 消息。同时心跳机制、HA的实现、服务发现等等,也都是后端服务实现时需要重点考虑的。
┌───────────────────────────────┐
│ │
│ master/center/jobmanager │ HA/Service Discovery/Storage/Coordinate/...
│ │
└─────┬────────────────────┬────┘
│ │
│ │
RPC/pool/queue/ACK/... │ │ RPC/pool/queue/ACK/...
│ │
│ │
┌───────────────────────────┴───┐ ┌─┴─────────────────────────────┐
│ │ │ │
│ worker/taskmanager │ │ worker/taskmanager │
│ │ │ │
└───────────────────────────────┘ └───────────────────────────────┘
部署上,大数据的组件 Flink Spark MapReduce 都通过 YARN 部署,后端服务往往通过 K8S 部署。而这几年的发展趋势,大数据这些组件,也支持部署到 K8S 了。
至于分组隔离、存算分离、冷热存储等,也都屡见不鲜。
因此,大数据组件的技术,本质上还是后端服务。
由于面向不同的用户,大数据的组件往往追求简单的使用方式。
还是以第一节的例子来说。
我在实现 center-worker 这套系统时,重点考虑 center. 至于 worker,则是开放了 RPC 协议,按需实现不同的功能。center 按照业务逻辑顺序,调用不同的 worker。系统面向的用户是 RD,这样的好处,是不同小组的 RD 可以开发不同的 worker,代码、语言甚至都可以不同。worker 的代码变化频繁,每组 worker 单独评估性能、稳定性以及上线。
而在 dolphinscheduler,worker 的能力是完全相同的,不同的需求通过内置的 plugin 实现,例如执行 SparkSQL、DorisSQL、FlinkSQL 等等。worker 的能力也就比较固定,一旦成型代码变化不大。系统面向的用户大部分是数仓,用户关注的是 SQL 执行的结果,至于上一节提到的架构、RPC的协议这些,都不是用户关心的。这是相同系统架构时,后端和大数据关注点的不同。
因此,大数据的技术,使用上需要尽量简单。
这种易用性的追求,也会导致一些常见的错误。以 Flink 的 DataStream API 为例,这段 scala 代码目的是过滤 >threshold 的数据 :
object ATestStream extends App {
val env = StreamExecutionEnvironment.getExecutionEnvironment
private val threshold = 5
env.fromSequence(1, 10)
.filter(_ > threshold)
.print("after filter:")
env.execute()
}
程序输出的内容,不熟悉的 RD 大概率会认为是after filter: 6, after filter: 7, ...
.
但实际上是:
after filter:> 1
after filter:> 2
after filter:> 3
after filter:> 4
after filter:> 5
after filter:> 6
after filter:> 7
after filter:> 8
after filter:> 9
after filter:> 10
究其原因,是因为 DataStream API 和 MapReduce 一样,都是声明式的:
threshold
这个变量,是 main 方法的局部变量,而 filter/print/… 方法,是运行在 TaskManager 上的。进一步的,main
方法的执行,可能是在 client 端,也可能是在 JobManager.因此在 TaskManager,threshold
实际是个未初始化的变量。当然,这里也跟是否是 Standalone 运行环境有关,就不在技术层面展开了。
类似的,在平时为实时计算平台的用户答疑时,也会看到这样出问题的 case:
// kafka
val prop = new Properties()
prop.setProperty("bootstrap.servers", "...")
// sasl_ssl 参数
prop.setProperty("ssl.truststore.location", localFile.getPath)
// 初始化kafka
val kafkaConsumer = new FlinkKafkaConsumer[String](topics, new SimpleStringSchema(), prop)
但是毫无疑问,这种声明式的使用方式,易用性是非常高的。我看到很多 qps 达到百万的实时任务,用户只需要实现 map/filter/sink 等方法,就可以轻松实现。
这种只需要关注输入输出,不用考虑多线程的竞争关系,也不用考虑词典(大数据称为维表 Temporal Join)是如何加载和查询的,用户只需要在单线程里根据输入实现输出逻辑。我们在封装后端模块时,理想的状态就是这样的。
因此从这点上,大数据的技术是靠前的。基于 K8S 的函数计算,出现的晚,但是使用上,还是这些声明式的 API 更加简单。
对易用性的追求,也是大数据离线、实时计算都支持 SQL 的一个原因,目的还是要简化开发。
系统整体的复杂度是守恒的,用户看到的简单,底层的复杂度就会变高。就跟冰山一样,露在海面上的,只是极小的一部分。
比如大数据实时任务的开发者,很多对自己任务的单并发性能并不了解,更别说数据倾斜的影响了。
这背后也有原因:
因此,默认值在大数据里是个非常常见的东西。CPU个数、内存大小都有默认值,用户只写 SQL/Stream API,运行起来有问题,往往需要技术在底层优化,或者给出调整的参数。任务的并发数也是如此,所以在 Spark/Flink 里都会看到资源自动调优 这种概念。类似的事情,在后端开发是绝对禁止的。默认值这个东西,使用者感觉不到。但是一旦多了,修改的成本成倍增加,牵一发而动全身,一旦修改,影响全部任务。
再看个 traceid 的例子,假定调用流程为:
后端服务: 后端模块A -> 后端模块B -> 后端模块C -> 后端模块D -> ...
大数据 : 实时任务A -> 实时任务B -> 实时任务C -> 实时任务D -> ...
两者相似的点都是数据的流转过程,我们想要通过 trace 了解各个模块/任务处理的时间,某个模块/任务是否丟数等。
对于后端模块,可以方便的加入 traceid:
// 收到 RPC 数据 module_input
module_input = (meta_data, input_data)
// 处理数据逻辑,只关注 input_data
output_data = f(input_data)
// 补充 medata_data,RPC 发送到下一个模块
module_output = (meta_data, output_data)
这样的好处:
f(...)
方法,单线程处理输入输出即使有数据扩散或者主动丢弃,业务方也完全意识不到 meta_data 的存在,专心实现 f 方法处理数据就行,除非也有 trace 日志的需要。
换到 flink 的 DataStream API,假定业务方实现了如下代码:
sourceStream.map(input_data -> middle_data)
.keyBy(middle_data_1.key)
.map(middle_data -> output_data)
.addSink(sink)
无论是修改转换 transformation 的过程,还是添加类似 watermark、latencymark 的标记,都非常复杂。主要是本身已经是声明式的 API,不是后端开发习惯的线程模型。
当然,对大数据处理过程增加 trace,本身数据量、计算量也都是个挑战。
我觉得大数据的开发非常简单,而对于异常处理、脏数据的处理、case的追查这类运行中的问题,要比后端服务差一些。但是,实时任务、高优看板数据的稳定性,实际上也是非常高的,不低于后端服务。
这里面很多边界条件的处理,以配置项/DDL properties/声明式 API 的方式暴露给了用户,复杂度留到了 runtime 阶段。
这里带来的另外一个问题,就是权责不清:本应该用户关注到的点,以一种近乎可以忽略的易用方式提供出来。good case 被视作理所当然;bad case,则因为“历史上没有关注”陷入扯皮的境地。如何划出一道权责清晰的边界,是在权衡易用性和运维复杂度时,一个重要的考量点。云厂商的大数据基建 SLA,是一个很好的参考点。可以多翻翻,有多少、在什么样的环境下,可以承诺到任务级别的 SLA.
因此,大数据的技术运维上更加复杂。
技术的本质是为了解决问题,技术演变的过程也是问题逐步解决的过程。由于高度的封装带来易用性的提高,大数据可以轻松写出百万级别qps、秒级延迟的任务,而之后可能出现的问题,则是被分摊在了漫长的运维过程中。
很长一段时间,大数据依旧会在各种新名词、易用性(标准SQL、批流、融合、统一)上追逐,运维能力上追赶后端服务。而对于后端服务,则是逐步借鉴大数据的思想,通过K8S这个强大的资源编排系统,将通用的能力落地到资源调度和执行引擎这两层,使得后端开发更加简单、标准化。
]]>Fabric8 是一个 Java 的 Kubernetes 客户端,使用一套自定义的 DSL 跟 REST API 交互。
我们可以使用链式调用方式访问和操作集群资源,例如:
ListOptions options = new ListOptions();
options.setLabelSelector("type=flink-native-kubernetes");
client.pods().inNamespace("default")
.list(options)
.getItems().forEach(pod -> {
System.out.println("pod name: " + pod.getMetadata().getName());
System.out.println("pod: " + pod);
});
这段代码会查询 namespace=default 下的所有 flink pod.
提交 JobManager TaskManager 流程大致相同,因此 Flink 封装了一些公共类:
KubernetesParameters
: 有两个子类KubernetesJobManagerParameters KubernetesTaskManagerParameters
,存储 JobManager Pod、TaskManager Pod 的参数。KubernetesJobManagerFactory KubernetesTaskManagerFactory
: 这两个工厂类分别提供了 JobManager Pod、 TaskManager Pod 的定义Fabric8FlinkKubeClient
: 提供createJobManagerComponent createTaskManagerPod
创建 JobManagerPod、TaskManager Pod,实现时通过成员变量io.fabric8.kubernetes.client.KubernetesClient internalClient
跟 Kubernetes 集群交互。提交 JobManager 入口在KubernetesClusterDescriptor.deployClusterInternal
:
public class KubernetesClusterDescriptor implements ClusterDescriptor<String> {
private ClusterClientProvider<String> deployClusterInternal(
String entryPoint, ClusterSpecification clusterSpecification, boolean detached)
throws ClusterDeploymentException {
// ...
final KubernetesJobManagerParameters kubernetesJobManagerParameters =
new KubernetesJobManagerParameters(flinkConfig, clusterSpecification);
final KubernetesJobManagerSpecification kubernetesJobManagerSpec =
KubernetesJobManagerFactory.buildKubernetesJobManagerSpecification(
kubernetesJobManagerParameters);
client.createJobManagerComponent(kubernetesJobManagerSpec);
return createClusterClientProvider(clusterId);
}
}
主要分为三步:
KubernetesJobManagerFactory.buildKubernetesJobManagerSpecification
: 初始化 JM 配置client.createJobManagerComponent(kubernetesJobManagerSpec)
: 创建 JM PodcreateClusterClientProvider(clusterId)
: 封装了 service,即 Flink WebUI初始化 JM 使用了装饰器模式:
public class KubernetesJobManagerFactory {
private static final Logger LOG = LoggerFactory.getLogger(KubernetesJobManagerFactory.class);
public static KubernetesJobManagerSpecification buildKubernetesJobManagerSpecification(
KubernetesJobManagerParameters kubernetesJobManagerParameters) throws IOException {
FlinkPod flinkPod = new FlinkPod.Builder().build();
List<HasMetadata> accompanyingResources = new ArrayList<>();
final KubernetesStepDecorator[] stepDecorators =
new KubernetesStepDecorator[] {
new InitJobManagerDecorator(kubernetesJobManagerParameters),
new EnvSecretsDecorator(kubernetesJobManagerParameters),
new MountSecretsDecorator(kubernetesJobManagerParameters),
new JavaCmdJobManagerDecorator(kubernetesJobManagerParameters),
new InternalServiceDecorator(kubernetesJobManagerParameters),
new ExternalServiceDecorator(kubernetesJobManagerParameters),
new HadoopConfMountDecorator(kubernetesJobManagerParameters),
new KerberosMountDecorator(kubernetesJobManagerParameters),
new FlinkConfMountDecorator(kubernetesJobManagerParameters)
};
for (KubernetesStepDecorator stepDecorator : stepDecorators) {
flinkPod = stepDecorator.decorateFlinkPod(flinkPod);
List<HasMetadata> hasMetadataList = stepDecorator.buildAccompanyingKubernetesResources();
accompanyingResources.addAll(hasMetadataList);
}
final Deployment deployment =
createJobManagerDeployment(flinkPod, kubernetesJobManagerParameters);
return new KubernetesJobManagerSpecification(deployment, accompanyingResources);
}
private static Deployment createJobManagerDeployment(
FlinkPod flinkPod, KubernetesJobManagerParameters kubernetesJobManagerParameters) {
final Container resolvedMainContainer = flinkPod.getMainContainer();
final Pod resolvedPod =
new PodBuilder(flinkPod.getPod())
.editOrNewSpec()
.addToContainers(resolvedMainContainer)
.endSpec()
.build();
return new DeploymentBuilder()
.withApiVersion(Constants.APPS_API_VERSION)
.editOrNewMetadata()
.withName(
KubernetesUtils.getDeploymentName(
kubernetesJobManagerParameters.getClusterId()))
.withLabels(kubernetesJobManagerParameters.getLabels())
.endMetadata()
.editOrNewSpec()
.withReplicas(1)
.editOrNewTemplate()
.withMetadata(resolvedPod.getMetadata())
.withSpec(resolvedPod.getSpec())
.endTemplate()
.editOrNewSelector()
.addToMatchLabels(labels)
.endSelector()
.endSpec()
.build();
}
}
buildAccompanyingKubernetesResources
方法在传入 Pod 的基础上,通过 fabric8/kubernetes-client 的方法添加 Pod、Service、Secret、ConfigMap 等资源。例如:
InitJobManagerDecorator
使用PodBuilder
定义了 Image、ImagePullPolicyExternalServiceDecorator
使用ServiceBuilder
定义了 RestServiceExposedTypeHadoopConfMountDecorator FlinkConfMountDecorator
使用VolumeBuilder
加载 Hadoop、Flink 等配置。常见的属性,例如 command、args、env、ports、resources、volumeMounts 都是在这里配置的,经过层层装饰,完成 JobManager Pod 的目标定义。
createJobManagerDeployment
创建 JobManager 部署的 deployment,例如 Pod 的副本数(1)、labels 等。
Fabric8FlinkKubeClient.createJobManagerComponent
完成创建 JobManager Deployment:
public class Fabric8FlinkKubeClient implements FlinkKubeClient {
public void createJobManagerComponent(KubernetesJobManagerSpecification kubernetesJMSpec) {
final Deployment deployment = kubernetesJMSpec.getDeployment();
final List<HasMetadata> accompanyingResources = kubernetesJMSpec.getAccompanyingResources();
// create Deployment
final Deployment createdDeployment =
this.internalClient
.apps()
.deployments()
.inNamespace(this.namespace)
.create(deployment);
// Note that we should use the uid of the created Deployment for the OwnerReference.
setOwnerReference(createdDeployment, accompanyingResources);
this.internalClient
.resourceList(accompanyingResources)
.inNamespace(this.namespace)
.createOrReplace();
}
}
提交 TaskManager 入口是在KubernetesResourceManagerDriver.requestResource
public class KubernetesResourceManagerDriver
extends AbstractResourceManagerDriver<KubernetesWorkerNode> {
@Override
protected void initializeInternal() throws Exception {
kubeClientOpt =
Optional.of(kubeClientFactory.fromConfiguration(flinkConfig, getIoExecutor()));
log.info("initializeInternal labels:{}", KubernetesUtils.getTaskManagerLabels(clusterId));
podsWatchOpt =
Optional.of(
getKubeClient()
.watchPodsAndDoCallback(
KubernetesUtils.getTaskManagerLabels(clusterId),
new PodCallbackHandlerImpl()));
recoverWorkerNodesFromPreviousAttempts();
}
@Override
public CompletableFuture<KubernetesWorkerNode> requestResource(
TaskExecutorProcessSpec taskExecutorProcessSpec) {
final KubernetesTaskManagerParameters parameters =
createKubernetesTaskManagerParameters(taskExecutorProcessSpec);
final KubernetesPod taskManagerPod =
KubernetesTaskManagerFactory.buildTaskManagerKubernetesPod(parameters);
// ...
// When K8s API Server is temporary unavailable, `kubeClient.createTaskManagerPod` might
// fail immediately.
// In case of pod creation failures, we should wait for an interval before trying to create
// new pods.
// Otherwise, ActiveResourceManager will always re-requesting the worker, which keeps the
// main thread busy.
final CompletableFuture<Void> createPodFuture =
podCreationCoolDown.thenCompose(
(ignore) -> getKubeClient().createTaskManagerPod(taskManagerPod));
// ...
}
}
可以看到 TaskManager Pod 的创建,跟 JobManager 流程是类似的:先初始化 parameters,然后定义 Pod,最后通过 kubeClient 创建。
KubernetesTaskManagerFactory.buildTaskManagerKubernetesPod
跟KubernetesJobManagerFactory.buildKubernetesJobManagerSpecification
类似,也是采用装饰器的模式;但是相对简单一些,因为 TaskManager 不需要对外暴露服务,也就不需要InternalServiceDecorator ExternalServiceDecorator
了。
Fabric8FlinkKubeClient.createTaskManagerPod
完成创建 TaskManger Pod:
public class Fabric8FlinkKubeClient implements FlinkKubeClient {
@Override
public CompletableFuture<Void> createTaskManagerPod(KubernetesPod kubernetesPod) {
return CompletableFuture.runAsync(
() -> {
final Deployment masterDeployment =
this.internalClient
.apps()
.deployments()
.inNamespace(this.namespace)
.withName(KubernetesUtils.getDeploymentName(clusterId))
.get();
// Note that we should use the uid of the master Deployment for the
// OwnerReference.
setOwnerReference(
masterDeployment,
Collections.singletonList(kubernetesPod.getInternalResource()));
this.internalClient
.pods()
.inNamespace(this.namespace)
.create(kubernetesPod.getInternalResource());
},
kubeClientExecutorService);
}
}
注意initializeInternal
方法里,还监听了 TaskManager Pod 的变化。当 Pod 变化时,回调PodCallbackHandlerImpl
方法。
由于 Fabric8 提供了一套 DSL 用于操作 Kubernetes 资源,因此使用是比较简便的。但是同时也注意,由于 DSL 的限制,有些接口报错只有在运行态才会观察到。这里举一个创建 Pod 的例子,更多可以参考kubernetes-examples2
public class CreatePodExample {
void createPodSample(String fileName) {
System.setProperty(KUBERNETES_KUBECONFIG_FILE, "/data/homework/.kube/config");
try (final KubernetesClient client = KubernetesUtils.initKubernetesClient()) {
logger.info("namespace:{}", client.getNamespace());
client.pods().list().getItems().forEach(pod -> {
logger.info("pod name:{}", pod.getMetadata().getName());
});
logger.info("config:{} {} {}", client.getConfiguration().getFile()
, client.getConfiguration().getClientCertFile()
, client.getMasterUrl());
// created by yml
List<HasMetadata> resources = client.load(Files.newInputStream(Paths.get(fileName)))
.items();
logger.info("resources'len:{}", resources.size());
logger.info("resources:{}", resources);
HasMetadata resource = resources.get(0);
if (resource instanceof Pod) {
Pod pod = (Pod) resource;
Pod createdPod = client.pods()
.inNamespace(client.getNamespace())
.resource(pod)
.create();
logger.info("created pod name:{}", createdPod.getMetadata().getName());
logger.info("created pod:{}", createdPod);
}
// created by builder
Pod pod = new PodBuilder()
.withNewMetadata()
.withGenerateName("example-pod-")
.addToLabels("app", "example-pod")
.addToLabels("version", "v1")
.addToLabels("role", "backend")
.endMetadata()
.withNewSpec()
.addNewContainer()
.withName("nginx")
.withImage("nginx:1.7.9")
.withPorts(Collections.singletonList(new ContainerPort(80, null, null, "http", null)))
.endContainer()
.endSpec()
.build();
Pod createdPod = client.pods().inNamespace("default").create(pod);
logger.info("created pod name:{}", createdPod.getMetadata().getName());
} catch (Exception e) {
logger.info("exception", e);
}
}
}
Flink 在跟 Kubernetes 集群交互时,底层使用 Fabric8. 创建 JobManager、TaskManager 代码有一些相似之处,Pod 资源的配置,使用了装饰器模式。这段代码定义了 JM/TM 的镜像、环境变量、启动命令、参数、配置,之后就是通过 Fabric8 创建 Pod 了。
JM 和 TM 的 label:
label | JobManager | TaskManager |
---|---|---|
app | ${clusterId} | ${clusterId} |
component | jobmanager | taskmanager |
type | flink-native-kubernetes | flink-native-kubernetes |
同时可以观察到,JobManager 是通过 deployment 创建,能够“自愈”;而 TaskManager 是通过 pod 创建的,失败后依赖 restart-strategy 配置恢复。
这本书是一本人工智能简史,前面几章科普效果很好,后面介绍到算法,看不懂的很多。等再打打数学根基后,再读一遍。
人工智能学科包含了三大学派:
符号主义学派不关注大脑如何思考的,希望通过构建规则、决策树,来解决问题。利用逻辑推理、搜索来匹配输入输出。
对比现在最热门的基于神经网络的机器学习,以决策树学习为代表的基于符号的机器学习有一个很好的性质:它生成的模型是一个白盒模型,输出结果的含义很容易通过模型的结构来解释,而神经网络输出的是一个黑盒模型,最终结果往往是模型确实可以解决问题,甚至是工作得非常好,但是人类无法根据模型去解释为什么会如此。可解释性是符号主义思想先天决定的,这也是基于规则学习算法对比起现在流行的基于神经网络学习算法的一个巨大优势
连接主义学派着重于研究大脑是如何处理信息的,希望通过诸如感知机这类的研究解决问题。
1949年,赫布出版了《行为组织学》(Organization of Behavior)一书。在该书中,赫布总结提出了被后人称为“赫布法则”(Hebb’s Law)的学习机制。他认为如果两个神经元细胞总是同时被激活的话,它们之间就会出现某种关联,同时激活的概率越高,这种关联程度也会越高。
行为主义学派,我理解更加复杂,包含了控制科学、人工生命、机器人学等与人工智能有密切关系的各类交叉学科。
给考察对象以某种刺激,观察它的反馈,通过研究反馈与刺激的关系来了解对象的特性,而不去纠结对象内部的组织结构,这就是行为主义方法。
书里关于正确率、精确率、召回率的定义,也有必要摘抄出来。 假定我们开发了垃圾邮件识别算法,那么无外乎以下几种情况:
1)将垃圾邮件识别为垃圾邮件,这种0-1分类器将正面结果识别为真的称为“真正”(True Positive,TP)
2)将垃圾邮件识别为有效邮件,这种0-1分类器将正面结果识别为假的称为“假正”(FalsePositive, FP)
3)将有效邮件识别为垃圾邮件,这种0-1分类器将负面结果识别为假的称为“假反”(False Negative, FN)
4)将有效邮件识别为有效邮件,这种0-1分类器将负面结果识别为真的称为“真反”(True Negative, TN)
正确率的含义是对于给定的测试数据集,分类器正确分类的样本数与总样本数之比。即:(TN + TP) / (TN + TP + FN + FP). 比如假设测试集中包含有效邮件8000封,垃圾邮件2000封,共计有10000封邮件。现在使用某个模型从中一共挑选出5000封垃圾邮件,经核实,模型挑选的邮件中有2000封确实是垃圾邮件,另外还错误地把3000封有效邮件也当作垃圾邮件挑选出来了。 那么正确率=70%,换句话说,错误率=30%.
但是只单纯的通过正确率评估是片面的。
比如假设测试集的10000封电子邮件,其中只包含150封垃圾邮件,其余都是有效的。
这就是正确性悖论。 此时就依赖精确率了,精确率度量反映出来的是模型的“查准比例”,通俗点的说法就是“你的预测有多少是对的?”。精确率=TP / (TP + FP) 。 召回率通俗地解释就是“样本的正例里面,有多少正例被正确预测了”,它度量的是模型的“查全比例”,因此也叫“查全率”。召回率=TP / (TP + FN)。
我在读这本书的时候,有几个人的事迹让人印象深刻:
回顾人工智能的发展历史,当学科刚开始,人们的思考是天马行空的,不受任何的束缚;当然,在今天看来小的进步,也会描述的夸大其词。 这不是故意宣传的噱头,实际上,因为不确定边界,所以过度乐观。同样的,从圆点出发,各个方向似乎都有可能,不知道在哪里碰壁。而一旦碰壁停下来,又会长达数年甚至数十年之久,过度悲观。
有很多问题,我们一旦了解了这些行为背后的原理,它立刻就变得代表不了智能,归入可机械化的任务了。人工智能这门学科,理论和实验两方面,待解释的问题都很多。
这篇笔记的题目,关于机器能否思考这个问题,引用书里的一句话:
]]>乔姆斯基再次被问到了“机器能思考吗?”这个问题,他反问道:“潜艇能够游泳吗?”
文章结构化的数据,可以粗略提炼出以下结构:
直接上代码:
case class PostMeta(url: String, title: String, date: String, tags: String) {
def toArray: Array[AnyRef] = Array(url, title, date, tags)
}
object BlogPostMetaExtractor {
def extractMetaInformation(filePath: String): Seq[PostMeta] = {
val dir = Paths.get(filePath)
if (!Files.isDirectory(dir)) {
Seq.empty
} else {
val paths = Files.walk(dir).iterator().asScala.toSeq
paths.filter{i => {
Files.isRegularFile(i) && i.getFileName.toString.endsWith(".markdown")
}}
.flatMap { path =>
try {
val fileName = path.getFileName.toString
val url = fileName.substring(11, fileName.length - ".markdown".length)
val source = Source.fromFile(path.toFile)
val lines = source.getLines().dropWhile(_ != "---").drop(1).takeWhile(_ != "---") // Drop the first "---" and stop at the second "---"
source.close()
val title = lines.find(_.startsWith("title"))
.map(i => i.substring(i.indexOf(":") + 1).trim.stripPrefix("\"").stripSuffix("\""))
.head
val date = lines.find(_.startsWith("date"))
.map(i => i.substring(i.indexOf(":") + 1).trim.stripPrefix("\"").stripSuffix("\""))
.head
val tags = lines.find(_.startsWith("tags:"))
.map(i => i.substring(i.indexOf(":") + 1).trim.stripPrefix("\"").stripSuffix("\""))
.head
Some(PostMeta(url, title, date, tags))
} catch {
case e: Exception => {
println(s"error with path:${path} e:${e}")
throw e
}
}
}
}
}
}
参考之前的例子Calcite-1:Tutorial,也是官网的 CsvTable,整体结构上需要定义:
这里我们定义一个简单的支持遍历的表:
class BlogTable(blogPostPath: String) extends BlogAbstractTable with ScannableTable {
override def scan(root: DataContext): Enumerable[Array[AnyRef]] = {
new AbstractEnumerable[Array[AnyRef]] {
override def enumerator(): Enumerator[Array[AnyRef]] = {
new BlogEnumerator(blogPostPath)
}
}
}
}
实际遍历是通过BlogEnumerator
:
class BlogEnumerator(blogPostPath: String) extends Enumerator[Array[AnyRef]] {
private val blogPostMetaList: Seq[PostMeta] = BlogPostMetaExtractor.extractMetaInformation(blogPostPath)
private var currentIndex: Int = -1 // 使用索引来跟踪当前元素,初始值为 -1
override def current(): Array[AnyRef] = {
if (currentIndex >= 0 && currentIndex < blogPostMetaList.size) {
blogPostMetaList(currentIndex).toArray
} else {
throw new NoSuchElementException("No current element")
}
}
override def moveNext(): Boolean = {
if (currentIndex + 1 < blogPostMetaList.size) {
currentIndex += 1
true
} else {
false
}
}
override def reset(): Unit = {
currentIndex = -1 // 将索引重置为 -1,从而实现重置遍历的效果
}
override def close(): Unit = {
// 在这里实现资源释放逻辑,如果有的话
}
}
通过SchemaPlus
注册函数,例如:
parentSchema.add("BLOG_SUBSTR", ScalarFunctionImpl.create(classOf[BlogScalarFunction], "blogSubstr"))
完整代码就不贴了,放在了Bigdata-Systems/calcite
yaml 文件指定 SchemaFactory:
version: 1.0
defaultSchema: BLOG
schemas:
- name: BLOG
type: custom
factory: cn.izualzhy.blog.BlogSchemaFactory
operand:
directory: sales
通过 sqlline 连接:
➜ calcite git:(main) ✗ ./src/sqlline (6518s)[14:43:14]
sqlline version 1.12.0
sqlline> !connect jdbc:calcite:model=src/main/resources/blog.yaml admin admin
BLOG_SUBSTR
Transaction isolation level TRANSACTION_REPEATABLE_READ is not supported. Default (TRANSACTION_NONE) will be used instead.
查看表:
0: jdbc:calcite:model=src/main/resources/blog> !tables
+-----------+-------------+------------+--------------+---------+----------+------------+-----------+---------------------------+----+
| TABLE_CAT | TABLE_SCHEM | TABLE_NAME | TABLE_TYPE | REMARKS | TYPE_CAT | TYPE_SCHEM | TYPE_NAME | SELF_REFERENCING_COL_NAME | RE |
+-----------+-------------+------------+--------------+---------+----------+------------+-----------+---------------------------+----+
| | BLOG | BLOG | TABLE | | | | | | |
| | metadata | COLUMNS | SYSTEM TABLE | | | | | | |
| | metadata | TABLES | SYSTEM TABLE | | | | | | |
+-----------+-------------+------------+--------------+---------+----------+------------+-----------+---------------------------+----+
统计 tag,年份:
0: jdbc:calcite:model=src/main/resources/blog> select tags, count(1) from BLOG group by tags;
+-------------+--------+
| TAGS | EXPR$1 |
+-------------+--------+
| Patronum | 13 |
| protobuf | 11 |
...
0: jdbc:calcite:model=src/main/resources/blog> select blog_substr(pub_date, 0, 4), count(1) from blog group by blog_substr(pub_date, 0, 4);
+--------+--------+
| EXPR$0 | EXPR$1 |
+--------+--------+
| 2019 | 42 |
| 2018 | 28 |
| 2017 | 19 |
| 2016 | 30 |
| 2015 | 14 |
| 2014 | 23 |
| 2024 | 5 |
| 2023 | 22 |
| 2022 | 27 |
| 2020 | 11 |
+--------+--------+
10 rows selected (0.466 seconds)
Flink 任务的启动流程,简言之分为三步:
由于这些开源项目代码变动很快,因此我尽量从 why 的角度,同时忽略代码间类调用的细节,通过一些重点代码介绍。
无论是 PerJob 还是 Application 模式提交到 YARN/K8S,都是通过 bin/flink 脚本,该脚本的入口类即org.apache.flink.client.cli.CliFrontend
.
main 首先调用logEnvironmentInfo
打印当前用户、JVM内存、hadoop等信息,该方法在 JM TM 启动时都会调用,以输出当前的基础信息。
然后调用parseAndRun
方法解析参数,run 对应 PerJob ,本地生成StreamGraph JobGraph,提交到资源调度中心。
run-application 对应 Application,用户main
在 JobManager 执行。因此本地流程非常简单,仅调用runApplication
方法部署。
public class CliFrontend {
//...
protected void runApplication(String[] args) throws Exception {
//...
final ApplicationDeployer deployer =
new ApplicationClusterDeployer(clusterClientServiceLoader);
final ApplicationConfiguration applicationConfiguration =
new ApplicationConfiguration(
programOptions.getProgramArgs(), programOptions.getEntryPointClassName());
deployer.run(effectiveConfiguration, applicationConfiguration);
}
}
ApplicationClusterDeployer
将参数转化到configuration
,举几个常见的例子:
参数 | configuration | 解释 |
---|---|---|
-C | pipeline.classpaths | classpath |
-c | $internal.application.main | 用户代码入口类 |
-D | key=value | 用户指定flink conf |
末尾文件 | pipeline.jars | 用户代码入口jar |
末尾参数 | $internal.application.program-args | 用户代码入口类参数 |
ApplicationClusterDeployer.run
调用clusterDescriptor.deployApplicationCluster
.
clusterDescriptor
由不同的ClusterClientFactory
创建,这里实际创建的子类对象是KubernetesClusterDescriptor
.Flink 里大量使用了 factory 模式,比如不同的 Resource-Provider-Client(YARN/K8S/Standalone)。
Flink在官方文档里,ApplicationMode 不支持用户传入外部文件,只能使用镜像里的 jar.3在 KubernetesUtils.checkJarFileForApplicationMode
这一步的提交检查里会给出提示。这点我觉得对于从 Yarn 迁移到 K8S 的用户是很不方便的,有用户提过issue-and-pr4,在高版本里得到了解决。
KubernetesClusterDescriptor.deployClusterInternal
构造 JobManager 的 Specification,通过fabric8io/kubernetes-client1提交到对应的 K8S 集群。
Specification 里指定了 JobManager 的入口类:
kubernetes.internal.jobmanager.entrypoint.class: KubernetesApplicationClusterEntrypoint
JM Pod 的启动脚本是 /docker-entrypoint.sh,运行KubernetesApplicationClusterEntrypoint
方法。
查看该方法的实现前,不妨先想想 JM 提供了哪些功能:
public final class KubernetesApplicationClusterEntrypoint extends ApplicationClusterEntryPoint {
public static void main(final String[] args) {
PackagedProgram program = null;
try {
program = getPackagedProgram(configuration);
} catch (Exception e) {
LOG.error("Could not create application program.", e);
System.exit(1);
}
// ...
final KubernetesApplicationClusterEntrypoint kubernetesApplicationClusterEntrypoint =
new KubernetesApplicationClusterEntrypoint(configuration, program);
ClusterEntrypoint.runClusterEntrypoint(kubernetesApplicationClusterEntrypoint);
}
}
PackagedProgram
:封装了用户代码相关,例如入口类、参数、用户 jar、classpath 等runClusterEntrypoint
调用runCluster
:启动 JobManager,包含各类 Server,运行用户代码,同时申请 TaskManager 资源以及初始化public class PackagedProgram {
private final URL jarFile;
private final String[] args;
private final Class<?> mainClass;
private final List<File> extractedTempLibraries;
private final List<URL> classpaths;
private final ClassLoader userCodeClassLoader;
public void invokeInteractiveModeForExecution() throws ProgramInvocationException {
callMainMethod(mainClass, args);
}
private static void callMainMethod(Class<?> entryClass, String[] args)
throws ProgramInvocationException {
Method mainMethod;
mainMethod = entryClass.getMethod("main", String[].class);
mainMethod.invoke(null, (Object) args);
}
}
Per-Job Mode,PackagedProgram
是在 client 端生成的。 Application Mode,PackagedProgram
在 JM 生成。
invokeInteractiveModeForExecution
即调用用户的入口类 main 方法。
public abstract class ClusterEntrypoint implements AutoCloseableAsync, FatalErrorHandler {
private void runCluster(Configuration configuration, PluginManager pluginManager)
throws Exception {
// 启动 commonRpcService(akka)、haServices、
// blobServer(存储例如 JobGraph 里的 jar 包,能够被 JobManager、TaskManager 访问)、
// heartbeatServices、metricRegistry(metrics reporter初始化)
// 端口号占用:blobServer、akka*2、rest
initializeServices(...)
// 创建 dispatcher resource-manager 等组件
clusterComponent = dispatcherResourceManagerComponentFactory.create
}
}
create
主要做了3件事情:
submitJob(JobGraph jobGraph, Time timeout)
负责作业的启动和分发public class DefaultDispatcherResourceManagerComponentFactory
implements DispatcherResourceManagerComponentFactory {
// 三大组件工厂类
@Nonnull private final DispatcherRunnerFactory dispatcherRunnerFactory;
@Nonnull private final ResourceManagerFactory<?> resourceManagerFactory;
@Nonnull private final RestEndpointFactory<?> restEndpointFactory;
public DispatcherResourceManagerComponent create(...) {
// 创建 jobmaster.MiniDispatcherRestEndpoint 用于提供 Restful 响应,启动
webMonitorEndpoint = restEndpointFactory.createRestEndpoint(...)
webMonitorEndpoint.start();
//这里 factory 类型为 KubernetesResourceManagerFactory ,创建对应的 ResourceManager<?>
resourceManager = resourceManagerFactory.createResourceManager(...)
// 创建 dispatcherRunner, 打印日志:Starting Dispatcher.
dispatcherRunner = dispatcherRunnerFactory.createDispatcherRunner(...)
// 启动 resourceManager, 打印日志:Starting ResourceManager.
// RpcEndpoint.start 后会调用 ResourceManager.onStart
resourceManager.start()
return DispatcherResourceManagerComponent(..)
}
}
RestEndpoint 不难理解,重点介绍下 ResourceManager 和 Dispatcher.
由于 Flink 支持多种 HA(ZK/Kubernetes),因此 ResourceManager 和 Dispatcher 都需要先获取 Leadership 再具体工作。
public abstract class ResourceManager<WorkerType extends ResourceIDRetrievable>
extends FencedRpcEndpoint<ResourceManagerId>
implements ResourceManagerGateway, LeaderContender {
@Override
public void grantLeadership(final UUID newLeaderSessionID) {
// ...
}
}
public final class DefaultDispatcherRunner implements DispatcherRunner, LeaderContender {
@Override
public void grantLeadership(UUID leaderSessionID) {
// ...
}
}
对于 ResourceManager,grantLeadership
主要做两件事情:
注:
Dispatcher 的封装比较复杂,封装的层次很深,各类 Driver Factory Gateway 的类命名层出不穷。
核心的调用链路终点统一到PackagedProgram.invokeInteractiveModeForExecution
:
class DefaultDispatcherRunner grantLeadership
│
▼
startNewDispatcherLeaderProcess
│
▼
class DispatcherLeaderProcess start
│
▼
class SessionDispatcherLeaderProcess onStart
│
▼
createDispatcherIfRunning
│
▼
createDispatcher
│
▼
class ApplicationDispatcherGatewayServiceFactory create
│
▼
new ApplicationDispatcherBootstrap
│
▼
ClientUtils.executeProgram
│
▼
progam.invokeInteractiveModeForExecution
如在更早的笔记里介绍的,用户的代码都是声明式的,执行过程是:
map/reduce/filter/... -> transformation -> stream graph -> job graph
然后提交生成的JobGraph
结构:
public class EmbeddedExecutor implements PipelineExecutor {
private CompletableFuture<JobClient> submitAndGetJobClientFuture(...) {
final JobGraph jobGraph = PipelineExecutorUtils.getJobGraph(pipeline, configuration);
final JobID actualJobId = jobGraph.getJobID();
// ...
final CompletableFuture<JobID> jobSubmissionFuture =
submitJob(configuration, dispatcherGateway, jobGraph, timeout);
}
}
提交之后,就回到了Dispatcher
最重要的一个方法submitJob(JobGraph jobGraph, Time timeout)
了。
不同提交方式有不同的实现,DispatcherGateway
统一定义了接口:
public interface DispatcherGateway extends FencedRpcGateway<DispatcherId>, RestfulGateway {
CompletableFuture<Acknowledge> submitJob(JobGraph jobGraph, @RpcTimeout Time timeout);
CompletableFuture<Collection<JobID>> listJobs(@RpcTimeout Time timeout);
CompletableFuture<Integer> getBlobServerPort(@RpcTimeout Time timeout);
CompletableFuture<ArchivedExecutionGraph> requestJob(JobID jobId, @RpcTimeout Time timeout);
default CompletableFuture<Acknowledge> shutDownCluster(ApplicationStatus applicationStatus) {
return shutDownCluster();
}
}
submitJob
启动JobManagerRunnerImpl
public class JobManagerRunnerImpl
implements LeaderContender, OnCompletionActions, JobManagerRunner {
private final JobMasterService jobMasterService; // JobMaster -> JobMasterService
public JobManagerRunnerImpl() {
this.jobMasterService =
jobMasterFactory.createJobMasterService(
jobGraph, this, userCodeLoader, initializationTimestamp);
jobMasterCreationFuture.complete(null);
}
public void grantLeadership(final UUID leaderSessionID) {
jobMasterCreationFuture.whenComplete(...)
}
}
jobMasterService.start
里开始调度任务 DAG,主要实现是在
public class JobMaster extends FencedRpcEndpoint<JobMasterId>
implements JobMasterGateway, JobMasterService {
private final JobGraph jobGraph;
private SchedulerNG schedulerNG;
// ...
}
public abstract class SchedulerBase implements SchedulerNG {
private final JobGraph jobGraph;
private final ExecutionGraph executionGraph;
private final SchedulingTopology schedulingTopology;
}
也就是在这里,JobGraph 转换为真正可以执行的 ExecutionGraph,JobMaster 以此将 DAG 调度到不同的 TaskManager 上。
JobManager 的SlotManagerImpl.start
方法里,会调用ActiveResourceManager.requestNewWork
,进而使用对应的ResourceManagerDriver
启动 TaskManager.
对于 K8S 环境,/docker-entrypoint.sh 的入口类是KubernetesTaskExecutorRunner
public class JavaCmdTaskManagerDecorator extends AbstractKubernetesStepDecorator {
private String getTaskManagerStartCommand() {
// ...
return getTaskManagerStartCommand(
kubernetesTaskManagerParameters.getFlinkConfiguration(),
kubernetesTaskManagerParameters.getContaineredTaskManagerParameters(),
confDirInPod,
logDirInPod,
kubernetesTaskManagerParameters.hasLogback(),
kubernetesTaskManagerParameters.hasLog4j(),
KubernetesTaskExecutorRunner.class.getCanonicalName(),
mainClassArgs);
}
}
对 TaskManager,核心功能有:
重点看下执行算子这部分,FLIP-62里的图描述了这个交互过程:
TaskManagerRunner
负责启动 akka system、metrics reporter、blobCacheService 等服务,同时启动TaskExecutor
,整个交互流程的核心逻辑也都通过该类实现。
TaskExecutorRegistration
:connectToResourceManager
establishResourceManagerConnection
registerNewJobAndCreateServices
taskManagerGateway.submitTask(deployment)
给 TM 分配任务org.apache.flink.streaming.runtime.tasks.SourceStreamTask
,创建Task
对象,启动工作线程task.startTaskThread
至此,TaskManager 开始执行用户代码的实现。
注:RpcTaskManagerGateway
在 RPC 的接口实现里,基本都是调用TaskExecutor
的方法,典型的方法:
JobManager TaskManager 是典型的 Master-Worker 架构,进程入口类固定。
启动了多个服务,用于支持任务提交、Rest接口、文件服务、metrics监控等等。用户的代码,在经过 transformation -> stream graph -> job graph -> execute graph 的多次转换后,包装成了不同算子的逻辑实现部分。
JobManager 负责跟 Resource Provider 申请资源,分配给 TaskManager 执行,这里通过多次 RPC 交互完成。JobManager 同时负责执行过程中的协调、容错、资源回收等。
Flink 使用 Fabric8 跟 Kubernetes 集群交互创建 JobManager TaskManager,相关代码分析在Flink - fabric8 的使用
2023年花了很大的精力,把自研的任务调度系统迁移到了 DolphinScheduler.
原有的调度系统使用 PHP 开发,非常古老,可能比公司成立时间还要早。如果放到十年前看,有很多可圈可点之处。但是近几年只能勉强维护,新的功能需求,开发两周,再加两周补开发带来的 bug.
由于离线任务调度系统的高峰是在凌晨,迁移过程熬夜挺多。也总结了一些经验,发表在了 https://mp.weixin.qq.com/s/smsNDH2MYpoys-qWz4O0Sg
22年总结实时计算时,担心 Flink 任务动态扩缩这个项目,很多收尾的工作,由于人力撤出导致盘点不全。23年这块还是出了一些问题,值得反思的地方很多。进一步的,如果提前处理了,问题就不会发生,但是价值如何自证?
整体上,调度引擎升级到 DolphinScheduler,算是在做一件难而正确的事情,还需要再坚持半年收尾,有始有终。
今年读书很少,按照时间顺序做个总结。
推荐《麒麟之翼》,父爱如山,父亲对孩子的爱,永远是伟大的,让人怀念,让人铭记于心。
书名 | 一句话总结 |
---|---|
史记的读法 | 这本书像是在讲如何读史记,又像是在讲如何读历史,如何读懂司马迁。 |
白鸟与蝙蝠 | 白鸟变成了蝙蝠,蝙蝠又是白鸟 |
流星·蝴蝶·剑 | 偶尔重新看看冷兵器时代的故事 |
品人录 | 易中天老师有思想、说真话 |
统计学关我什么事 | 讲的很通俗、很有意思的统计学 |
为什么精英都是时间控 | 每年找一本时间管理的书看看,自省 |
中台落地手记 | 中台相关的书,常看常新。 |
太白金星有点烦 | 神仙的工作日常,很羡慕作者的文字功底,能将打工人的日常甩锅、吐槽、烦闷,写的淋漓尽致。 |
崔老道传奇 | 像是相声的一本书,还给人看饿了 |
窦占龙憋宝:七杆八金刚 | 古代读书门槛高,说书场面向的则是三教九流,因此有教化世人的作用,最讲究个因果循环、报应不爽。这世上的道理,只有信的人多了,道理才是道理 |
暗黑者外传:惩罚 | 谁是那个最公平的执法者? |
麒麟之翼 | 教给孩子爱,教给孩子做一个正直的人。 |
碧血洗银枪 | 前面很精彩,结尾一般。 |
Java Database Connectivity (JDBC) 定义了一套访问数据库的 API.
Java Database Connectivity (JDBC) is an application programming interface (API) for the Java programming language which defines how a client may access a database.1
好处是几乎可以使用完全相同的代码,访问不同的数据库:MySQL、Hive、Doris、Presto 等等。
定义在 package java.sql,主要包含了 DriverManager Driver Connection Statement ResultSet
.
JDBC 的接口类设计,使用起来非常方便,值得学习。看个读取 MySQL 表的例子:
public class MySQLJDBCSample {
public static void main(String[] args) throws SQLException {
String url = "jdbc:mysql://127.0.0.1:3306/quartz_jobs?serverTimezone=Asia/Shanghai";
String user = "izualzhy";
String passwd = "izualzhy_test";
try (Connection connection = DriverManager.getConnection(url, user, passwd)) {
try (Statement statement = connection.createStatement()) {
String sql = "SHOW TABLES";
try (ResultSet resultSet = statement.executeQuery(sql)) {
for (int i = 1; i <= resultSet.getMetaData().getColumnCount(); i++) {
System.out.printf("%-32s\t", resultSet.getMetaData().getColumnName(i));
}
System.out.println("\n" + String.join("", Collections.nCopies(32, "-")));
while (resultSet.next()) {
for (int i = 1; i <= resultSet.getMetaData().getColumnCount(); i++) {
System.out.printf("%-32s\t", resultSet.getString(i));
}
System.out.println();
}
}
}
}
}
}
整体上接口分了几个步骤:
getConnection
获取连接createStatement
在连接上创建 statementexecuteQuery
发送 SQL 到服务端执行ResultSet
遍历获取结果DriverManager.getConnection
遍历所有注册的 driver,找到适合连接串的:
DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/quartz_jobs?serverTimezone=Asia/Shanghai")
trying org.apache.hive.jdbc.HiveDriver
trying org.apache.derby.jdbc.AutoloadedDriver40
trying org.apache.calcite.jdbc.Driver
trying com.facebook.presto.jdbc.PrestoDriver
trying com.mysql.cj.jdbc.Driver
getConnection returning com.mysql.cj.jdbc.Driver
例如这里找到的就是com.mysql.cj.jdbc.Driver
.
com.mysql.cj.jdbc.Driver
的注册,主要依赖三步:
DriverManager.loadInitialDrivers
方法里,加载 1 里所有的 Driver 类:ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class)
java.sql.DriverManager.registerDriver(new Driver())
类似的使用方式很多,例如 flink-formats 里的 org.apache.flink.table.factories.Factory2、logback 里的 org.slf4j.spi.SLF4JServiceProvider3
Connection
是 JDBC 里定义的基类,代表跟数据库的连接。不同 Driver 返回的类型不同,例如com.mysql.cj.jdbc.ConnectionImpl
TrinoConnection
HiveConnection
ProxyConnection
等。
Statement
代表在连接上执行的具体指令。实际生产环境,更应该使用PreparedStatement
,有两个好处:
ProxyConnection
是 HIKARI 实现的连接池对象,不过需要注意不是所有的连接都适合使用连接池,比如 Hive 连接。连接不关闭的话,会复用 YARN 的 appcliationId,导致无法区分不同 SQL 调起的任务。
例子里使用了executeQuery
,用于执行 DQL 语句,返回ResultSet
,存储了查询结果。
executeUpdate
用于执行 DML/DDL 语句,返回int
,表示影响的行数。
实际更推荐使用execute
,可以用于执行任意 SQL 语句,返回boolean
表示是否有ResultSet
。然后getResultSet
or getUpdateCount
分别处理。
Connection
Statement
ResultSet
都继承了这两个类:
Wrapper
常出现在使用连接池的场景,例如通过poolConnection.unwrap(KyuubiConnection.class)
来获取包装的KyuubiConnection
,从而调用一些自定义方法。注意不是子类的关系,实际上是 HikariProxyConnection wrapping org.apache.kyuubi.jdbc.hive.KyuubiConnectionAutoCloseable
:用于资源回收,例如HiveConnection.close()
方法,会调用client.CloseSession(closeReq);transport.close();
,否则就会造成连接泄露。因此需要养成在 try-with-resources 语句里使用的习惯。上一节介绍了各个接口类,是通用的封装。不同的数据库驱动,也有自己单独的方法。大数据使用 JDBC 的比较典型的场景是访问 Hive,因此专门介绍下。
比如获取日志, presto 是通过回调的方式:
public class PrestoStatement implements Statement {
public void setProgressMonitor(Consumer<QueryStats> progressMonitor) {
this.progressCallback.set(Optional.of(Objects.requireNonNull(progressMonitor, "progressMonitor is null")));
}
}
Hive 则需要单独线程里主动获取日志:
public class HiveStatement implements java.sql.Statement {
public boolean hasMoreLogs() {
return isLogBeingGenerated;
}
public List<String> getQueryLog() throws SQLException, ClosedOrCancelledStatementException {
return getQueryLog(true, fetchSize);
}
}
注意当检测到连接关闭,也应当再获取一次日志,避免日志不全的问题,具体可以参考 beeline4
Hive同样不支持在execute
里一次传入多条语句,SQL 语句的拆分也可以参考 beelin.
HiveStatement 的实现里有这么一处:
public class HiveStatement implements java.sql.Statement {
private TCLIService.Iface client;
public boolean execute(String sql) throws SQLException {
// 调用 client.ExecuteStatement 提交 sql,如果 timeout 过小,这里可能提交超时
runAsyncOnServer(sql);
// while 循环,不断调用 client.GetOperationStatus 获取 HQL 执行状态
TGetOperationStatusResp status = waitForOperationToComplete();
...
}
public List<String> getQueryLog(boolean incremental, int fetchSize)
throws SQLException, ClosedOrCancelledStatementException {
...
TFetchResultsResp tFetchResultsResp = null;
try {
if (stmtHandle != null) {
tFetchResultsResp = client.FetchResults(tFetchResultsReq);
}
client
是随HiveConnection
初始化时构造的:
client = newSynchronizedClient(client);
newSynchronizedClient
实际上返回了Iface
代理类的实例:
public static TCLIService.Iface newSynchronizedClient(
TCLIService.Iface client) {
return (TCLIService.Iface) Proxy.newProxyInstance(
HiveConnection.class.getClassLoader(),
new Class [] { TCLIService.Iface.class },
new SynchronizedHandler(client));
}
而SynchronizedHandler.invoke
在 v2.3 之前的实现,client 的每个方法都会加锁:
private static class SynchronizedHandler implements InvocationHandler {
private final TCLIService.Iface client;
SynchronizedHandler(TCLIService.Iface client) {
this.client = client;
}
@Override
public Object invoke(Object proxy, Method method, Object [] args)
throws Throwable {
try {
synchronized (client) {
return method.invoke(client, args);
}
} ...
}
}
由于waitForOperationToComplete
里一直在 while 循环,因此就可能导致getQueryLog
无法获取到锁,导致日志一直获取不到。
Hive-161725通过引入ReentrantLock
公平锁解决了这个问题。
ResultSet
是单线程遍历,因此拉取数据的效率不高。对于较大的数据量,应当充分利用集群并行的能力,将数据写到目标存储或者分布式文件系统上。HiveConnection
初始化时,使用了DriverManager.getLoginTimeout
作为 socket Connect/Read/Write 的超时时间,但是这个值是全局的,需要注意issue6。Apache ORC、Apache Parquet 都是典型的列存储格式,大数据的场景,为何偏爱列存储?
首先无论场景如何变化,从单机到大数据,面临的磁盘性能是一致的,引用 Jeff Dean 演讲的数据1:
Latency Comparison Numbers (~2012)
Operation | Time in Nano Seconds |
---|---|
L1 cache reference | 0.5 ns |
Branch mispredict | 5 ns |
L2 cache reference | 7 ns |
Mutex lock/unlock | 25 ns |
Main memory reference | 100 ns |
Compress 1K bytes with Zippy | 3,000 ns |
Send 1K bytes over 1 Gbps network | 10,000 ns |
Read 4K randomly from SSD | 150,000 ns |
Read 1 MB sequentially from memory | 250,000 ns |
Round trip within same datacenter | 500,000 ns |
Read 1 MB sequentially from SSD* | 1,000,000 ns |
Disk seek | 10,000,000 ns |
Read 1 MB sequentially from disk | 20,000,000 ns |
Send packet CA->Netherlands->CA | 150,000,000 ns |
也就是磁盘顺序读性能远远大于 seek.
提到列存储,普遍认知第一个优势是 IO。大数据表列数很多,但是查询时往往只用到少数几列,只需要读取更少的列,因此 IO 效率更高。
SIGMOD 2008 有一篇论文: Column-Stores vs. Row-Stores: How Different Are They Really?2 3,总结了以下四点。
首先是 Block iteration: 块遍历,每次读取的数据格式相同,可以充分利用现代 CPU 的 SIMD 指令集加速计算。当然这一点需要跟计算引擎的向量化充分结合。后续各类计算引擎的向量化实现、Vector/Array 数据结构的设计,也都证明了这一点。
其次是 Column-specific compression techniques: low information entropy,相同格式的数据压缩比更高。即使不采用压缩,一些算法例如 run-length encoding. 在存储(Disk/Mem)空间更小的前提下不会增加常见算法的复杂度,如果某一列的数据有序、或者前缀相同,那么效果会更好。
然后是 Late materialization: 列存储能够更好的应用延迟物化技术,例如 Traditional Query Plan VS Late Materialized Query Plan:
VS
放到现在看主要就是 bitmap、谓词下推等方案。 优势在于:
最后一点是 Invisible Join,看着还是延迟物化思想的进一步阐述。
这几点总结的都很有道理,不过反过来,Row-Stores 可以模拟出 Column-Stores 的这些效果么?文章总结了几个方案:
但是由于存储、I/O、应用场景限制等,很难达到 Column-Stores 的性能。
两者各有擅长的场景:
ORC(Optimized Row Columnar)是在 Hive 项目里引入的4,文件结构包括三部分:
ORC 文件有三个级别的索引:文件级别、stripe级别、行级别(每10000行)。
更多细节可以参考 ORCv15
ORC 提供了多种写入方式, Spark、Hive、PyArrow 等,这里以 Core Java 为例:
public static void orcWriterSample() throws IOException {
Path testFilePath = new Path("/tmp/people.orc");
// 文件结构
TypeDescription schema = TypeDescription.fromString(
"struct<name:string,location:map<string,string>,birthday:date,last_login:timestamp>"
);
Configuration conf = new Configuration();
FileSystem.getLocal(conf);
Faker faker = new Faker();
try (Writer writer =
OrcFile.createWriter(testFilePath,
OrcFile.writerOptions(conf).setSchema(schema))) {
// 创建 row batch,每一列通过单独的 ColumnVector 写入
VectorizedRowBatch batch = schema.createRowBatch();
BytesColumnVector name = (BytesColumnVector) batch.cols[0];
MapColumnVector location = (MapColumnVector) batch.cols[1];
LongColumnVector birthday = (LongColumnVector) batch.cols[2];
TimestampColumnVector last_login = (TimestampColumnVector) batch.cols[3];
BytesColumnVector mapKey = (BytesColumnVector) location.keys;
BytesColumnVector mapValue = (BytesColumnVector) location.values;
// Each map has 2 elements
final int MAP_SIZE = 2;
final int BATCH_SIZE = batch.getMaxSize();
System.out.println("BATCH_SIZE : " + BATCH_SIZE);
// Ensure the map is big enough
mapKey.ensureSize(BATCH_SIZE * MAP_SIZE, false);
mapValue.ensureSize(BATCH_SIZE * MAP_SIZE, false);
// 写入 1500 行数据
for (int i = 0; i < 1500; i++) {
int row = batch.size++;
name.setVal(row, faker.name().fullName().getBytes());
birthday.vector[row] = DateWritable.dateToDays(new java.sql.Date(faker.date().birthday(1, 123).getTime()));
last_login.time[row] = faker.date().past(10, TimeUnit.DAYS).getTime();
location.offsets[row] = location.childCount;
location.lengths[row] = MAP_SIZE;
location.childCount += MAP_SIZE;
mapKey.setVal((int) location.offsets[row], "country".getBytes());
mapValue.setVal((int) location.offsets[row], faker.country().name().getBytes());
mapKey.setVal((int) location.offsets[row] + 1, "address".getBytes());
mapValue.setVal((int) location.offsets[row] + 1, faker.address().streetAddress().getBytes());
if (row == BATCH_SIZE - 1) {
writer.addRowBatch(batch);
batch.reset();
}
}
if (batch.size != 0) {
writer.addRowBatch(batch);
batch.reset();
}
}
}
orc-tools 可以读取数据、元数据等一系列信息:
➜ Downloads java -jar orc-tools-1.9.2-uber.jar meta /tmp/people.orc (0s)[12:52:40]
Processing data file /tmp/people.orc [length: 41072]
Structure for /tmp/people.orc
File Version: 0.12 with ORC_517 by ORC Java
[main] INFO org.apache.orc.impl.ReaderImpl - Reading ORC rows from /tmp/people.orc with {include: null, offset: 0, length: 9223372036854775807, includeAcidColumns: true, allowSARGToFilter: false, useSelected: false}
[main] INFO org.apache.orc.impl.RecordReaderImpl - Reader schema not provided -- using file schema struct<name:string,location:map<string,string>,birthday:date,last_login:timestamp>
Rows: 1500
Compression: ZLIB
Compression size: 262144
Calendar: Julian/Gregorian
Type: struct<name:string,location:map<string,string>,birthday:date,last_login:timestamp>
Stripe Statistics:
Stripe 1:
Column 0: count: 1500 hasNull: false
Column 1: count: 1500 hasNull: false bytesOnDisk: 11857 min: Aaron Hamill II max: Zona Roob sum: 21912
...
File Statistics:
Column 0: count: 1500 hasNull: false
Column 1: count: 1500 hasNull: false bytesOnDisk: 11857 min: Aaron Hamill II max: Zona Roob sum: 21912
...
Stripes:
Stripe: offset: 3 data: 40195 rows: 1500 tail: 148 index: 240
Stream: column 0 section ROW_INDEX start: 3 length 12
Stream: column 1 section ROW_INDEX start: 15 length 53
...
Encoding column 0: DIRECT
Encoding column 1: DIRECT_V2
...
File length: 41072 bytes
Padding length: 0 bytes
Padding ratio: 0%
此外就是 Header、Footer、Index 等索引数据。
static void parquetWriterSample() throws IOException {
Types.MessageTypeBuilder schemaBuilder = Types.buildMessage();
schemaBuilder.addField(new PrimitiveType(Type.Repetition.REQUIRED, PrimitiveType.PrimitiveTypeName.BINARY, "name"));
schemaBuilder.addField(new PrimitiveType(Type.Repetition.REQUIRED, PrimitiveType.PrimitiveTypeName.INT64, "last_login"));
MessageType schema = schemaBuilder.named("record");
Configuration conf = new Configuration();
FileSystem.getLocal(conf);
GroupWriteSupport.setSchema(schema, conf);
GroupWriteSupport writeSupport = new GroupWriteSupport();
writeSupport.init(conf);
Path testFilePath = new Path("/tmp/people.parquet");
Faker faker = new Faker();
try (ParquetWriter<Group> writer = new ParquetWriter<Group>(testFilePath,
writeSupport,
CompressionCodecName.SNAPPY,
ParquetWriter.DEFAULT_BLOCK_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE,
ParquetWriter.DEFAULT_IS_DICTIONARY_ENABLED,
ParquetWriter.DEFAULT_IS_VALIDATING_ENABLED,
ParquetWriter.DEFAULT_WRITER_VERSION,
conf)) {
for (int i = 0; i < 1500; i++) {
Group group = new SimpleGroupFactory(schema).newGroup();
group.add("name", Arrays.toString(faker.name().fullName().getBytes()));
group.add("last_login", faker.date().past(10, TimeUnit.DAYS).getTime());
writer.write(group);
}
}
}
parquet-cli 可以读取 parquet 文件:
✗ parquet-tools inspect /tmp/people.parquet (2s)[17:12:28]
############ file meta data ############
created_by: parquet-mr version 1.10.0 (build 031a6654009e3b82020012a18434c582bd74c73a)
num_columns: 2
num_rows: 1500
num_row_groups: 1
format_version: 1.0
serialized_size: 437
############ Columns ############
name
last_login
############ Column(name) ############
name: name
path: name
max_definition_level: 0
max_repetition_level: 0
physical_type: BYTE_ARRAY
logical_type: None
converted_type (legacy): NONE
compression: SNAPPY (space_saved: 60%)
############ Column(last_login) ############
name: last_login
path: last_login
max_definition_level: 0
max_repetition_level: 0
physical_type: INT64
logical_type: None
converted_type (legacy): NONE
compression: SNAPPY (space_saved: 24%)
文中的代码都上传到了Bigdata-Systems/fileformats.
关于 ORC 和 Parquet 的比较7:
总的来说,ORC 的压缩比更高,而 Parquet 跟 Spark 结合的更好一些。
关于列存文件,SO 上有个问题很有意思。Row Stores vs Column Stores: 既然列存储的优势在于读取部分列,那我这几个 SQL 如何?
SELECT * FROM Person;
SELECT * FROM Person WHERE id=5;
SELECT AVG(YEAR(DateOfBirth)) FROM Person;
INSERT INTO Person (ID,DateOfBirth,Name,Surname) VALUES(2e25,'1990-05-01','Ute','Muller');
可以想到除了第3个SQL,列存的效果都一般。
归根结底,现代存储格式的设计,无论列存还是行存,都有其擅长的场景,同时无法覆盖 100% 的 SQL. Hybrid-Store8的出现,也只是做了一定程度的折衷。这种形式,恐怕在新的硬件出来之前都不会改变。