1.Flink CDC介绍
1.1定义:
CDC 是变更数据捕获 (Change Data Capture) 技术的缩写,它可以将源数据库 (Source) 的增量变动记录,同步到一个或多个数据目的 (Sink)。在同步过程中,还可以对数据进行一定的处理,例如分组 (GROUP BY)、多表的关联 (JOIN) 等。
简单来说就是对于数据库的变更进行一个探测,因为数据库的更改对于客户端来说是没有感知的,你需要开启线程去查询,才知道数据有没有更新。主流的CDC机制有以下两种
基于查询CDC,如果是直接select * from ....,这样获取的结果还要和上次获取的结果对比,才知道数据有没有发生变化,耗时大、与业务耦合度大。其次定时查询数据库也会造成数据库的压力。
基于日志的CDC,这种方式可以完美解决上述方法的缺点,做到低延迟、高吞吐、精准度高。mysql的binlog记录了数据更改的日志。增删改一条数据,这个日志就会记录一条数据,日志的设计是为了数据库故障恢复而设计的,其实他的另一大用处就是应用于cdc。现在客户端要感知数据库的变更,不需要去查询数据库的具体数据了,而是直接查询日志。删除(更新、增加)一条数据,日志就增加一条数据,那就直接把这个增加的数据发给客户端,客户端就知道数据发生变化了,然后对数据进行合并。
我们知道日志是追加模式的,这种模式非常适合流数据。kafka也是append-only模式,也就是追加模式,其实kafka本质上的设计就是基于日志特点设计的。binlog本质就是把数据库这种批数据的模式,转换为流数据模式。所以想要知道数据库数据是否变更,直接查询日志就可以了,这样达到低延迟、高吞吐。
1.2 CDC来源
业务系统经常会遇到需要更新数据到多个存储的需求。例如:一个订单系统刚刚开始只需要写入数据库即可完成业务使用。某天 BI 团队期望对数据库做全文索引,于是我们同时要写多一份数据到 ES 中,改造后一段时间,又有需求需要写入到 Redis 缓存中。
很明显这种模式是不可持续发展的,这种双写到各个数据存储系统中可能导致不可维护和扩展,数据一致性问题等,需要引入分布式事务,成本和复杂度也随之增加。我们可以通过 CDC(Change Data Capture)工具进行解除耦合,同步到下游需要同步的存储系统。通过这种方式提高系统的稳健性,也方便后续的维护。
1.3 CDC应用场景及概述
数据迁移:常用于数据库备份、容灾等;
数据分发:将一个数据源分发给多个下游,常用于业务解耦、微服务;
数据采集:将分散异构的数据源集成到数据仓库中,消除数据孤岛,便于后续的分析
核心特性:
1.是通过增量快照读取算法,实现了无锁读取,并发读取,断点续传等功能。
增量快照读取算法的核心思路就是在全量读取阶段把表分成一个个 chunk 进行并发读取,在进入增量阶段后只需要一个 task 进行单并发读取 binlog 日志,在全量和增量自动切换时,通过无锁算法保障一致性。这种设计在提高读取效率的同时,进一步节约了资源。实现了全增量一体化的数据同步。
2.是设计上对入湖友好,提升了 CDC 数据入湖的稳定性。
3.是支持异构数据源的融合,能方便地做 Streaming ETL的加工。
4.是支持分库分表合并入湖。
1.4 CDC优势:
1.能够捕获所有数据的变化,捕获完整的变更记录。在异地容灾,数据备份等场景中得到广泛应用,如果是基于查询的 CDC 有可能导致两次查询的中间一部分数据丢失。每次 DML 操作均有记录无需像查询 CDC 这样发起全表扫描进行过滤,拥有更高的效率和性能,具有低延迟,不增加数据库负载的优势
2.无需入侵业务,业务解耦,无需更改业务模型
3.捕获删除事件和捕获旧记录的状态,在查询 CDC 中,周期的查询无法感知中间数据是否删除
原始方案:
但是这个架构有个缺点,我们可以看到采集端组件过多导致维护繁杂,这时候就会想是否可以用 Flink SQL 直接对接 MySQL 的 binlog 数据呢
形成同步方案
Flink 基于日志CDC优势:
而基于查询的方式是很难做到增量同步的。
对比全量同步能力,基于查询或者日志的 CDC 方案基本都支持,除了 Canal。
而对比全量 + 增量同步的能力,只有 Flink CDC、Debezium、Oracle Goldengate 支持较好。
从架构角度去看,该表将架构分为单机和分布式,这里的分布式架构不单纯体现在数据读取能力的水平扩展上,更重要的是在大数据场景下分布式系统接入能力。例如 Flink CDC 的数据入湖或者入仓的时候,下游通常是分布式的系统,如 Hive、HDFS、Iceberg、Hudi 等,那么从对接入分布式系统能力上看,Flink CDC 的架构能够很好地接入此类系统
在数据转换 / 数据清洗能力上,当数据进入到 CDC 工具的时候是否能较方便的对数据做一些过滤或者清洗,甚至聚合?
在 Flink CDC 上操作相当简单,可以通过 Flink SQL 去操作这些数据;
但是像 DataX、Debezium 等则需要通过脚本或者模板去做,所以用户的使用门槛会比较高。
另外,在生态方面,这里指的是下游的一些数据库或者数据源的支持。Flink CDC 下游有丰富的 Connector,例如写入到 TiDB、MySQL、Pg、HBase、Kafka、ClickHouse 等常见的一些系统,也支持各种自定义 connector。
字节测试,在 presto没有做优化的前提下,flink batch是可以媲美presto的,而且他广泛的connect可以链接各种数据源。
2.源码分享
以mysql为例,以下代码只保留了核心部分,以说明流程,通过GitHub下载完整源码。
MySqlConnectorTask的ChainedReader包含多个Reader,这些reader就是用来获取全量数据和增量数据,这些数据会放进抽象类AbstractReader中的BlockingQueue中 在Debezium的run方法中会从task中poll数据 task会从Reader中的blockingQueue拿数据 数据拿到之后会交给DebezinumConsumer,DebezinumConsumer会先反序列化数据,然后emit给下游
1.Mysql-cdcflink已经帮我们封装了connect连接器 ,里面主要实现MySQLSource。
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
EnvironmentSettings envSettings = EnvironmentSettings.newInstance()
.useBlinkPlanner()
.inStreamingMode()
.build();
env.setParallelism(3);
// note: 增量同步需要开启CK
env.enableCheckpointing(10000);
StreamTableEnvironment tableEnvironment = StreamTableEnvironment.create(env, envSettings);
tableEnvironment.executeSql(" CREATE TABLE mysql_source (\n" +
" `year` INT ,\n" +
" `month` INT ,\n" +
" `province` STRING,\n" +
" `bci_brand` STRING,\n" +
" `bci_cnt_total` INTEGER\n" +
" ) WITH (\n" +
" 'connector' = 'mysql-cdc',\n" +
" 'hostname' = ' localhost',\n" +
" 'port' = '3306',\n" +
" 'username' = '**',\n" +
" 'password' = '**',\n" +
" 'database-name' = 'china_bi',\n" +
" 'table-name' = 'bci_test'," +
" 'scan.startup.mode' = 'initial' " +
" )");
tableEnvironment.executeSql("select * from mysql_source").print();
2.包括table模块中的MysqlTableSource也会调用MysqlSource来build一个DebeziumSourceFunction。Debezium是一个开源的分布式平台,用于捕捉变化数据(change data capture)的场景。它可以捕捉数据库中的事件变化(例如表的增、删、改等),并将其转为事件流,使得下游应用可以看到这些变化,并作出指定响应。
public DebeziumSourceFunction<T> build() {
//这里主要是返回DebeziumSourceFunction
return new DebeziumSourceFunction<>(
deserializer,
props,
specificOffset);
}
3.进入DebeziumSourceFunction类继承了RichSourceFunction,并且实现了CheckpointedFunction接口,也就是说这个类是flink的一个SourceFunction,会从源端(run方法)获取数据,发送给下游。此外这个类还实现了CheckpointedFunction接口,也就是会通过checkpoint的机制来保证exactly once语义。他的run方法里创建了一个DebeziumChangeConsumer,以及用properties和DebeziumChangeConsumer创建了DebeziumEngine,最后用线程池来执行DebeziumEngine,但是看看DebeziumChangeConsumer和DebeziumEngine两个类,就知道这个sourcefunction很简单。
public void run(SourceContext<T> sourceContext) throws Exception {
this.properties.setProperty("database.history.instance.name", this.engineInstanceName);
String dbzHeartbeatPrefix = this.properties.getProperty(Heartbeat.HEARTBEAT_TOPICS_PREFIX.name(),
Heartbeat.HEARTBEAT_TOPICS_PREFIX.defaultValueAsString());
this.debeziumConsumer = new DebeziumChangeConsumer(sourceContext, this.deserializer,
this.restoredOffsetState == null, this::reportError, dbzHeartbeatPrefix);
//接下来定一个DebeziumEngine对象,这个对象是真正用来干活的,它的底层使用了kafka的connect-api来进行获取数据,
//得到的是一个org.apache.kafka.connect.source.SourceRecord对象。
//通过notifying方法将得到的数据交给上面定义的DebeziumChangeConsumer来来覆盖缺省实现以进行复杂的操作。
this.engine = DebeziumEngine.create(Connect.class).using(this.properties)
.notifying(this.debeziumConsumer)
.using(OffsetCommitPolicy.always())
.using((success, message, error) -> {
if (!success && error != null) {
this.reportError(error);
}
}).build();
if (this.running) {
//接下来通过一个线程池ExecutorService来异步的启动这个engine。
this.executor.execute(this.engine);
try {
//做了一个循环判断,当程序被打断,或者有错误的时候,打断engine,并且抛出异常。
while(this.running && !this.executor.awaitTermination(5L, TimeUnit.SECONDS)) {
if (this.error != null) {
this.running = false;
this.shutdownEngine();
ExceptionUtils.rethrow(this.error);
}
}
} catch (InterruptedException var4) {
Thread.currentThread().interrupt();
}
}
}
4.DebeziumChangeConsumer类的实现接口只有一个方法handleBatch,可以看到这个逻辑非常简单,就是先把debenium获取到的cdc数据先反序列化一波,直接emit到下游了,那问题来了,handleBatch中的参数数据是如何获取的呢,既然是debenium获取的,而且只有一个DebeziumEngine(这个是个runnable)。那咱们就先看看DebeziumEngine,因为DebeziumEngine是debezium的组件跟flink没关系,刚才咱们知道DebeziumEngine是个Runnable(其实也是个接口,默认实现为EmbeddedEngine)。既然是runnable那就主要看看他的run方法。
public void handleBatch(List<ChangeEvent<SourceRecord, SourceRecord>> changeEvents, RecordCommitter<ChangeEvent<SourceRecord, SourceRecord>> committer) throws InterruptedException {
this.currentCommitter = committer;
try {
Iterator var3 = changeEvents.iterator();
//循环执行
while(var3.hasNext()) {
ChangeEvent<SourceRecord, SourceRecord> event = (ChangeEvent)var3.next();
SourceRecord record = (SourceRecord)event.value();
if (!this.isHeartbeatEvent(record)) {
this.deserialization.deserialize(record, this.debeziumCollector);
if (this.isInDbSnapshotPhase) {
if (!this.lockHold) {
MemoryUtils.UNSAFE.monitorEnter(this.checkpointLock);
this.lockHold = true;
LOG.info("Database snapshot phase can't perform checkpoint, acquired Checkpoint lock.");
}
if (!this.isSnapshotRecord(record)) {
MemoryUtils.UNSAFE.monitorExit(this.checkpointLock);
this.isInDbSnapshotPhase = false;
LOG.info("Received record from streaming binlog phase, released checkpoint lock.");
}
}
//执行Records
this.emitRecordsUnderCheckpointLock(this.debeziumCollector.records, record.sourcePartition(),
record.sourceOffset());
}
}
} catch (Exception var6) {
LOG.error("Error happens when consuming change messages.", var6);
this.errorReporter.reportError(var6);
}
}
5.DebeziumEngine的run方法里首先会创建一个task,然后启动他,明显是启动task去获取任务。接下启动任务之后就会在循环里面poll数据,说明task里面肯定有一个组赛队列,接着handler会处理获取的数据,还记得刚才咱们说的DebeziumChangeConsumer吗,他就是咱们的handler,正好刚才咱们还愁着handleBatch中的参数从哪里来,现在看到了吧,就是从这里来。现在咱们知道原来数据是task的阻塞队列里面的,那么,task启动之后肯定是把数据放到阻塞队列中了,基于这样的猜想咱们来看看task。这里咱们主要看看task的start做了啥
public void run() {
if (this.runningThread.compareAndSet((Object)null, Thread.currentThread())) {
String engineName = this.config.getString(ENGINE_NAME);
String connectorClassName = this.config.getString(CONNECTOR_CLASS);
Optional<io.debezium.engine.DebeziumEngine.ConnectorCallback> connectorCallback = Optional.
ofNullable(this.connectorCallback);
this.latch.countUp();
try {
Configuration var10000 = this.config;
Set var10001 = CONNECTOR_FIELDS;
Logger var10002 = LOGGER;
var10002.getClass();
if (!var10000.validateAndRecord(var10001, var10002::error)) {
this.fail("Failed to start connector with invalid configuration (see logs for actual errors)");
} else {
SourceConnector connector = null;
String offsetStoreClassName = this.config.getString(OFFSET_STORAGE);
OffsetBackingStore offsetStore = null;
ConnectorContext context = new ConnectorContext() {
public void requestTaskReconfiguration() {
}
public void raiseError(Exception e) {
EmbeddedEngine.this.fail(e.getMessage(), e);
}
};
connector.initialize(context);
OffsetStorageWriter offsetWriter = new OffsetStorageWriter(offsetStore,
engineName, this.keyConverter, this.valueConverter);
final OffsetStorageReader offsetReader = new OffsetStorageReaderImpl(offsetStore,
engineName, this.keyConverter, this.valueConverter);
Duration commitTimeout = Duration.ofMillis(this.config.getLong(OFFSET_COMMIT_TIMEOUT_MS));
try {
connector.start(this.config.asMap());
connectorCallback.ifPresent(io.debezium.engine.DebeziumEngine.
ConnectorCallback::connectorStarted);
//第一次执行会创建一个task,于似于spark的Application一样功能
List<Map<String, String>> taskConfigs = connector.taskConfigs(1);
Class<? extends Task> taskClass = connector.taskClass();
if (taskConfigs.isEmpty()) {
String msg = "Unable to start connector's task class '" + taskClass.getName() + "' with no task configuration";
this.fail(msg);
} else {
this.task = null;
try {
this.task = (SourceTask)taskClass.getDeclaredConstructor().newInstance();
} catch (InstantiationException | IllegalAccessException var398) {
this.fail("Unable to instantiate connector's task class '" + taskClass.getName() + "'", var398);
return;
}
this.task.initialize(taskContext);
//task启动后,他会把数据都拉过来
this.task.start((Map)taskConfigs.get(0));
connectorCallback.ifPresent(io.debezium.engine.DebeziumEngine.
ConnectorCallback::taskStarted);
} catch (Throwable var396) {
Configuration config = Configuration.from((Map)taskConfigs.get(0))
.withMaskedPasswords();
String msg = "Unable to initialize and start connector's task class '"
+ taskClass.getName() + "' with config: " + config;
this.fail(msg, var396);
return;
}
this.recordsSinceLastCommit = 0L;
Throwable handlerError = null;
try {
this.timeOfLastCommitMillis = this.clock.currentTimeInMillis();
EmbeddedEngine.RecordCommitter committer = this.buildRecordCommitter(
offsetWriter, this.task, commitTimeout);
//循环获取task数据
while(this.runningThread.get() != null) {
List changeRecords = null;
try {
LOGGER.debug("Embedded engine is polling task for records on thread {}",
this.runningThread.get());
//这里会获取之前set到changeRecord的数据
changeRecords = this.task.poll();
LOGGER.debug("Embedded engine returned from polling task for records");
} catch (InterruptedException var392) {
LOGGER.debug("Embedded engine interrupted on thread {} "+
" while polling the task for records", this.runningThread.get());
if (this.runningThread.get() == Thread.currentThread()) {
Thread.currentThread().interrupt();
}
return;
}
try {
if (changeRecords != null && !changeRecords.isEmpty()) {
LOGGER.debug("Received {} transformed records from the task",
changeRecords.size());
try {
//这里将调用DebeziumChangeConsumer的handleBatch,
//将committer信息传入执行
this.handler.handleBatch(changeRecords, committer);
} catch (StopConnectorException var391) {
return;
}
} else {
LOGGER.debug("Received no records from the task");
}
} catch (Throwable var394) {
handlerError = var394;
return;
}
}
}
}
}
} finally {
this.latch.countDown();
this.runningThread.set((Object)null);
this.completionCallback.handle(this.completionResult.success(),
this.completionResult.message(), this.completionResult.error());
}
}
}
6.task.start的实现类MySqlConnectorTask,看类名明显知道这是处理mysql的,其实在start里面会创建好多Reader(BinlogReader用于增量获取,SnapshotReader用于第一次全量拉取),然后放到ChainedReader中。
if (startWithSnapshot) {
//增量更新的时候
chainedReaderBuilder.addReader(snapshotReader);
if (this.taskContext.isInitialSnapshotOnly()) {
this.logger.warn("This connector will only perform a snapshot, and will stop after that completes.");
chainedReaderBuilder.addReader(new BlockingReader("blocker", "Connector has completed all of its work but will continue in the running state. It can be shut down at any time."));
chainedReaderBuilder.completionMessage("Connector configured to only perform snapshot, "+
"and snapshot completed successfully. Connector will terminate.");
} else {
if (!rowBinlogEnabled) {
if (!binlogFormatRow) {
throw new ConnectException("The MySQL server is not configured to use a ROW binlog_format,"+
" which is required for this connector to work properly. Change the MySQL configuration "+
" to use a binlog_format=ROW and restart the connector.");
}
throw new ConnectException("The MySQL server is not configured to use a FULL binlog_row_image,"+
" which is required for this connector to work properly. Change the MySQL configuration "+
"to use a binlog_row_image=FULL and restart the connector.");
}
BinlogReader binlogReader = new BinlogReader("binlog", this.taskContext, (HaltingPredicate)null);
chainedReaderBuilder.addReader(binlogReader);
}
} else {
//全量更新数据
source.maybeSetFilterDataFromConfig(config);
if (this.newTablesInConfig()) {
if (this.taskContext.getConnectorConfig().getSnapshotNewTables() == SnapshotNewTables.PARALLEL) {
MySqlConnectorTask.ServerIdGenerator serverIdGenerator = new MySqlConnectorTask.
ServerIdGenerator(config.getLong(MySqlConnectorConfig.SERVER_ID), config
.getLong(MySqlConnectorConfig.SERVER_ID_OFFSET));
//创建ParallelSnapshotReader接受多个Reader
ParallelSnapshotReader parallelSnapshotReader = new ParallelSnapshotReader(config,
this.taskContext, getNewFilters(offsets, config), serverIdGenerator);
MySqlTaskContext unifiedTaskContext = createAndStartTaskContext(config, getAllFilters(config));
unifiedTaskContext.source().completeSnapshot();
//读取mysql的binlog
BinlogReader unifiedBinlogReader = new BinlogReader("binlog", unifiedTaskContext,
(HaltingPredicate)null, serverIdGenerator.getConfiguredServerId());
ReconcilingBinlogReader reconcilingBinlogReader = parallelSnapshotReader.
createReconcilingBinlogReader(unifiedBinlogReader);
chainedReaderBuilder.addReader(parallelSnapshotReader);
chainedReaderBuilder.addReader(reconcilingBinlogReader);
chainedReaderBuilder.addReader(unifiedBinlogReader);
unifiedBinlogReader.uponCompletion(unifiedTaskContext::shutdown);
}
} else {
BinlogReader binlogReader = new BinlogReader("binlog", this.taskContext, (HaltingPredicate)null);
chainedReaderBuilder.addReader(binlogReader);
}
}
3.版本升级优化
1.Flink1.14已经对拓扑结构引入分组,优化与拓扑相关对计算逻辑,包括作业对初始化、task调度已经故障恢复时计算需要重启的task 等。引入了缓存机制来优化任务部署,比1.12需要的内存少而且更快。
2.针对不同的主键分布,引入动态分片算法。对主键是非数值、Snowflake ID、稀疏主键、联合主键等场景,通过动态分析源表的主键分布的均匀程度,根据分布的均匀程度自动地计算分片大小,让切片更加合理,让分片计算更快。动态分片算法能够很好地解决稀疏主键场景下分片过多的,联合主键场景下分片过大等问题,让每个分片包含的行数尽量维持在用户指定的 chunk size,这样用户通过 chunk size 就能控制分片大小和分片数量,无需关心主键类型。
3.支持百亿级超大规模表。在表规模非常大时,以前会报 binlog 分片下发失败的错误,这是因为在超大表对应的 snapshot 分片会非常多,而 binlog 分片需要包含所有 snapshot 分片信息,当 SourceCoordinator 下发 binglog 分片到 SourceReader 节点时,分片 size 超过 RPC 通信框架支持的最大 size 会导致分片下发失败。虽然可以通过修改 RPC 框架的参数缓解分片 size 过大问题,但无法彻底解决。2.1 版本里通过将多个 snapshot 分片信息划分成 group 发送,一个 binlog 分片会切分成多个 group 逐个发送,从而彻底解决该问题。
4.引入连接池管理数据库连接。通过引入连接池管理数据库连接,一方面降低了数据库连接数,另外也避免了极端场景导致的连接泄露。
5.支持分库分表 schema 不一致时,缺失字段自动填充 NULL 值
6.用户可以在 Flink DDL 中通过 db_name STRING METADATA FROM 'database_name' 的方式来访问库名(database_name)、表名(table_name)、变更时间(op_ts)等 meta 信息。这对分库分表场景的数据集成非常使用。
7.在 2.0 版本中,无锁算法,并发读取等功能只在 SQL API 上透出给用户,而 DataStream API 未透出给用户,2.1 版本支持了 DataStream API,可通过 MySqlSourceBuilder 创建数据源。用户可以同时捕获多表数据,借此搭建整库同步链路。同时通过 MySqlSourceBuilder#includeSchemaChanges 还能捕获 schema 变更。
8.支持 currentFetchEventTimeLag,currentEmitEventTimeLag,sourceIdleTime 监控指标
9.currentEmitEventTimeLag 指标记录的是 Source 发送一条记录到下游节点的时间点和该记录在 DB 里产生时间点差值,用于衡量数据从 DB 产生到离开 Source 节点的延迟。用户可以通过该指标判断 source 是否进入了 binlog 读取阶段:
10.flink1.14之前都是粗粒度的资源管理,每个slot并不能动态划分资源,为了削峰填谷,不同task可以在一个slot上运行。即使只有少数个slot,但是taskmanager可能有很多资源无法给到。但是在复杂场景下可能会导致资源利用低。在1.14加入了细粒度资源管理。所以动态划分后,slot manager可以动态像TM上申请资源,小于free manager即可。
11.相对比与1.13,CDC 2.0,核心 feature 包括:
并发读取,全量数据的读取性能可以水平扩展;
全程无锁,不对线上业务产生锁的风险;
断点续传,支持全量阶段的 checkpoint。
测试显示:用 TPC-DS 数据集中的 customer 表进行了测试,Flink 版本是 1.13.1,customer 表的数据量是 6500 万条,Source 并发为 8,全量读取阶段:MySQL CDC 2.0 用时 13 分钟;MySQL CDC 1.4 用时 89 分钟;读取性能提升 6.8 倍。重点提升了 MySQL CDC 连接器的性能和生产稳定性,重磅推出 Oracle CDC 连接器和 MongoDB CDC 连接器。
4.个人总结
1.具体情况需要根据业务的复杂度、数据量和集群情况合理分配slot ytm tjm p,其实并行度的设置可以根据算子里面的不同情况各自设置并行度,但是最大的并行度是由 [(slot * jobmanager的数据 ) * nodemanager数量 ]决定的,jobmanager的数量=(可申请的最大内存 - yjm ) / ytm 。其实有的时候slot越大并不会性能越高,集群的资源需要留一部分给hbase hive等数据仓库来做缓存使用,在代码层无法优化后,还是需要根据实际情况测试调整集群资源和运行资源。
2.原生的yarn更适合MR、spark等批或者微批作业,对于flink常驻的作业资源分配都是单个work或者task分配,往往是后面的几个task资源分配不到,然后数据不能拉起,flink1.14已经提供了资源动态分配,可以考虑K8S。
3.over code to data 或者存储计算分离两种模式,可以混合使用。对于任务要求高的实用第一种,否则使用存储计算分离,可以分一部分资源弹性分配临时任务。
4.原始的数据存储和计算都是在数据库进行的,但是Hadoop出来了以后,存储和计算就是分离了,以后也是个趋势。
5.通过引入 cumulate window解决数据更新先下降后上升的凹坑情况。优点;数据严格按照事件事件划分,回溯场景下数据曲线平滑,而且各个时间点上分维度的累计值加和等于总维度的累计值。
6.state在修改后不能兼容,通过修改mate的version方式向前兼容。
7.数据倾斜问题,aggregate算子数据倾斜使用 local- global优化;distinct算子数据倾斜使用split distinct优化;join算子数据倾斜如果是大小表的话,可以将小表进行广播。
参考: