You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@flink.apache.org by ja...@apache.org on 2021/04/01 02:22:21 UTC

[flink] branch master updated: [FLINK-21191][upsert-kafka] Support buffered sink function for upsert-kafka

This is an automated email from the ASF dual-hosted git repository.

jark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new ec9b0c5  [FLINK-21191][upsert-kafka] Support buffered sink function for upsert-kafka
ec9b0c5 is described below

commit ec9b0c5b60290697769415eb3e1b1ed2052460ac
Author: Shengkai <10...@qq.com>
AuthorDate: Tue Mar 30 22:40:31 2021 +0800

    [FLINK-21191][upsert-kafka] Support buffered sink function for upsert-kafka
    
    This closes #15434
---
 .../docs/connectors/table/upsert-kafka.md          |  17 ++
 docs/content/docs/connectors/table/upsert-kafka.md |  22 ++
 .../kafka/table/BufferedUpsertSinkFunction.java    | 313 +++++++++++++++++++
 .../table/DynamicKafkaSerializationSchema.java     |   2 +-
 .../connectors/kafka/table/KafkaDynamicSink.java   |  56 +++-
 .../kafka/table/KafkaDynamicTableFactory.java      |   2 +
 .../connectors/kafka/table/KafkaOptions.java       |  26 ++
 .../kafka/table/SinkBufferFlushMode.java           |  79 +++++
 .../table/UpsertKafkaDynamicTableFactory.java      |  44 ++-
 .../table/BufferedUpsertSinkFunctionTest.java      | 330 +++++++++++++++++++++
 .../kafka/table/KafkaDynamicTableFactoryTest.java  |   2 +
 .../table/UpsertKafkaDynamicTableFactoryTest.java  |  94 ++++--
 12 files changed, 951 insertions(+), 36 deletions(-)

diff --git a/docs/content.zh/docs/connectors/table/upsert-kafka.md b/docs/content.zh/docs/connectors/table/upsert-kafka.md
index ea4dc64..d269e67 100644
--- a/docs/content.zh/docs/connectors/table/upsert-kafka.md
+++ b/docs/content.zh/docs/connectors/table/upsert-kafka.md
@@ -185,6 +185,23 @@ of all available metadata fields.
       <td>Integer</td>
       <td>定义 upsert-kafka sink 算子的并行度。默认情况下,由框架确定并行度,与上游链接算子的并行度保持一致。</td>
     </tr>
+    <tr>
+      <td><h5>sink.buffer-flush.max-rows</h5></td>
+      <td>可选</td>
+      <td style="word-wrap: break-word;">0</td>
+      <td>Integer</td>
+      <td>缓存刷新前,最多能缓存多少条记录。当 sink 收到很多同 key 上的更新时,缓存将保留同 key 的最后一条记录,因此 sink 缓存能帮助减少发往 Kafka topic 的数据量,以及避免发送潜在的 tombstone 消息。
+      可以通过设置为 '0' 来禁用它。默认,该选项是未开启的。注意,如果要开启 sink 缓存,需要同时设置 <code>'sink.buffer-flush.max-rows'</code>
+      和 <code>'sink.buffer-flush.interval'</code> 两个选项为大于零的值。</td>
+    </tr>
+    <tr>
+      <td><h5>sink.buffer-flush.interval</h5></td>
+      <td>可选</td>
+      <td style="word-wrap: break-word;">0</td>
+      <td>Duration</td>
+      <td>缓存刷新的间隔时间,超过该时间后异步线程将刷新缓存数据。当 sink 收到很多同 key 上的更新时,缓存将保留同 key 的最后一条记录,因此 sink 缓存能帮助减少发往 Kafka topic 的数据量,以及避免发送潜在的 tombstone 消息。
+        可以通过设置为 '0' 来禁用它。默认,该选项是未开启的。注意,如果要开启 sink 缓存,需要同时设置 <code>'sink.buffer-flush.max-rows'</code>
+        和 <code>'sink.buffer-flush.interval'</code> 两个选项为大于零的值。</td>
     </tbody>
 </table>
 
diff --git a/docs/content/docs/connectors/table/upsert-kafka.md b/docs/content/docs/connectors/table/upsert-kafka.md
index 6bf75e9..03d87da 100644
--- a/docs/content/docs/connectors/table/upsert-kafka.md
+++ b/docs/content/docs/connectors/table/upsert-kafka.md
@@ -196,6 +196,28 @@ Connector Options
       <td>Integer</td>
       <td>Defines the parallelism of the upsert-kafka sink operator. By default, the parallelism is determined by the framework using the same parallelism of the upstream chained operator.</td>
     </tr>
+    <tr>
+      <td><h5>sink.buffer-flush.max-rows</h5></td>
+      <td>optional</td>
+      <td style="word-wrap: break-word;">0</td>
+      <td>Integer</td>
+      <td>The max size of buffered records before flush.
+       When the sink receives many updates on the same key, the buffer will retain the last record of the same key.
+       This can help to reduce data shuffling and avoid possible tombstone messages to Kafka topic. Can be set to '0' to disable it.
+       By default, this is disabled. Note both <code>'sink.buffer-flush.max-rows'</code> and
+       <code>'sink.buffer-flush.interval'</code> must be set to be greater than zero to enable sink buffer flushing.</td>
+    </tr>
+    <tr>
+      <td><h5>sink.buffer-flush.interval</h5></td>
+      <td>optional</td>
+      <td style="word-wrap: break-word;">0</td>
+      <td>Duration</td>
+      <td>The flush interval mills, over this time, asynchronous threads will flush data.
+       When the sink receives many updates on the same key, the buffer will retain the last record of the same key.
+       This can help to reduce data shuffling and avoid possible tombstone messages to Kafka topic. Can be set to '0' to disable it.
+       By default, this is disabled. Note both <code>'sink.buffer-flush.max-rows'</code> and
+       <code>'sink.buffer-flush.interval'</code> must be set to be greater than zero to enable sink buffer flushing.</td>
+    </tr>
     </tbody>
 </table>
 
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunction.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunction.java
new file mode 100644
index 0000000..4c2c455
--- /dev/null
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunction.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.streaming.connectors.kafka.table;
+
+import org.apache.flink.api.common.functions.RuntimeContext;
+import org.apache.flink.api.common.state.CheckpointListener;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.runtime.util.ExecutorThreadFactory;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.types.RowKind;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import static org.apache.flink.streaming.connectors.kafka.table.DynamicKafkaSerializationSchema.createProjectedRow;
+import static org.apache.flink.types.RowKind.DELETE;
+import static org.apache.flink.types.RowKind.UPDATE_AFTER;
+import static org.apache.flink.util.Preconditions.checkArgument;
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/**
+ * The wrapper of the {@link RichSinkFunction}. It will buffer the data and emit when the buffer is
+ * full or timer is triggered or checkpointing.
+ */
+public class BufferedUpsertSinkFunction extends RichSinkFunction<RowData>
+        implements CheckpointedFunction, CheckpointListener {
+
+    private static final long serialVersionUID = 1L;
+
+    private static final Logger LOG = LoggerFactory.getLogger(BufferedUpsertSinkFunction.class);
+
+    // --------------------------------------------------------------------------------------------
+    // Config
+    // --------------------------------------------------------------------------------------------
+
+    private final RichSinkFunction<RowData> producer;
+    private final int batchMaxRowNums;
+    private final long batchIntervalMs;
+    private final DataType physicalDataType;
+    private final int[] keyProjection;
+    private final TypeInformation<RowData> consumedRowDataTypeInfo;
+    private boolean closed;
+
+    // --------------------------------------------------------------------------------------------
+    // Writer and buffer
+    // --------------------------------------------------------------------------------------------
+
+    private int batchCount = 0;
+    private transient Map<RowData, Tuple2<RowData, Long>> reduceBuffer;
+    private transient WrappedContext wrappedContext;
+    private transient Function<RowData, RowData> keyExtractor;
+    private transient Function<RowData, RowData> valueCopier;
+
+    // --------------------------------------------------------------------------------------------
+    // Timer attributes
+    // --------------------------------------------------------------------------------------------
+
+    private transient ScheduledExecutorService scheduler;
+    private transient ScheduledFuture<?> scheduledFuture;
+    private transient volatile Exception flushException;
+
+    public BufferedUpsertSinkFunction(
+            RichSinkFunction<RowData> producer,
+            DataType physicalDataType,
+            int[] keyProjection,
+            TypeInformation<RowData> consumedRowDataTypeInfo,
+            SinkBufferFlushMode bufferFlushMode) {
+        checkArgument(bufferFlushMode != null && bufferFlushMode.isEnabled());
+        this.producer = checkNotNull(producer, "Producer must not be null.");
+        this.physicalDataType =
+                checkNotNull(physicalDataType, "Physical data type must not be null.");
+        this.keyProjection = checkNotNull(keyProjection, "key projection must not be null.");
+        this.consumedRowDataTypeInfo = consumedRowDataTypeInfo;
+        this.batchMaxRowNums = bufferFlushMode.getBatchSize();
+        this.batchIntervalMs = bufferFlushMode.getBatchIntervalMs();
+    }
+
+    @Override
+    public void open(Configuration parameters) throws Exception {
+        // init variable
+        reduceBuffer = new HashMap<>();
+        wrappedContext = new WrappedContext();
+        closed = false;
+
+        // create keyExtractor and value copier
+        List<LogicalType> fields = physicalDataType.getLogicalType().getChildren();
+        final RowData.FieldGetter[] keyFieldGetters =
+                Arrays.stream(keyProjection)
+                        .mapToObj(
+                                targetField ->
+                                        RowData.createFieldGetter(
+                                                fields.get(targetField), targetField))
+                        .toArray(RowData.FieldGetter[]::new);
+        this.keyExtractor = rowData -> createProjectedRow(rowData, RowKind.INSERT, keyFieldGetters);
+
+        TypeSerializer<RowData> typeSerializer =
+                consumedRowDataTypeInfo.createSerializer(getRuntimeContext().getExecutionConfig());
+        this.valueCopier =
+                getRuntimeContext().getExecutionConfig().isObjectReuseEnabled()
+                        ? Function.identity()
+                        : typeSerializer::copy;
+
+        // register timer
+        this.scheduler =
+                Executors.newScheduledThreadPool(
+                        1, new ExecutorThreadFactory("upsert-kafka-sink-function"));
+        this.scheduledFuture =
+                this.scheduler.scheduleWithFixedDelay(
+                        () -> {
+                            synchronized (BufferedUpsertSinkFunction.this) {
+                                if (!closed) {
+                                    try {
+                                        flush();
+                                    } catch (Exception e) {
+                                        flushException = e;
+                                    }
+                                }
+                            }
+                        },
+                        batchIntervalMs,
+                        batchIntervalMs,
+                        TimeUnit.MILLISECONDS);
+
+        producer.open(parameters);
+    }
+
+    @Override
+    public void setRuntimeContext(RuntimeContext t) {
+        producer.setRuntimeContext(t);
+    }
+
+    @Override
+    public RuntimeContext getRuntimeContext() {
+        return producer.getRuntimeContext();
+    }
+
+    @Override
+    public void invoke(RowData value, Context context) throws Exception {
+        wrappedContext.setContext(context);
+        addToBuffer(value, context.timestamp());
+    }
+
+    @Override
+    public void notifyCheckpointComplete(long checkpointId) throws Exception {
+        if (producer instanceof CheckpointListener) {
+            ((CheckpointListener) producer).notifyCheckpointComplete(checkpointId);
+        }
+    }
+
+    @Override
+    public void notifyCheckpointAborted(long checkpointId) throws Exception {
+        if (producer instanceof CheckpointListener) {
+            ((CheckpointListener) producer).notifyCheckpointAborted(checkpointId);
+        }
+    }
+
+    @Override
+    public synchronized void close() throws Exception {
+        if (!closed) {
+            closed = true;
+
+            if (this.scheduledFuture != null) {
+                scheduledFuture.cancel(false);
+                this.scheduler.shutdown();
+            }
+
+            if (batchCount > 0) {
+                try {
+                    flush();
+                } catch (Exception e) {
+                    LOG.warn("Writing records to kafka failed.", e);
+                    throw new RuntimeException("Writing records to kafka failed.", e);
+                }
+            }
+
+            producer.close();
+        }
+        super.close();
+        checkFlushException();
+    }
+
+    @Override
+    public void snapshotState(FunctionSnapshotContext context) throws Exception {
+        flush();
+        if (producer instanceof CheckpointedFunction) {
+            ((CheckpointedFunction) producer).snapshotState(context);
+        }
+    }
+
+    @Override
+    public void initializeState(FunctionInitializationContext context) throws Exception {
+        if (producer instanceof CheckpointedFunction) {
+            ((CheckpointedFunction) producer).initializeState(context);
+        }
+    }
+
+    // --------------------------------------------------------------------------------------------
+
+    private synchronized void addToBuffer(RowData row, Long timestamp) throws Exception {
+        checkFlushException();
+
+        RowData key = keyExtractor.apply(row);
+        RowData value = valueCopier.apply(row);
+        reduceBuffer.put(key, new Tuple2<>(changeFlag(value), timestamp));
+        batchCount++;
+
+        if (batchCount >= batchMaxRowNums) {
+            flush();
+        }
+    }
+
+    private synchronized void flush() throws Exception {
+        checkFlushException();
+        for (Tuple2<RowData, Long> value : reduceBuffer.values()) {
+            wrappedContext.setTimestamp(value.f1);
+            producer.invoke(value.f0, wrappedContext);
+        }
+        reduceBuffer.clear();
+        batchCount = 0;
+    }
+
+    private RowData changeFlag(RowData value) {
+        switch (value.getRowKind()) {
+            case INSERT:
+            case UPDATE_AFTER:
+                value.setRowKind(UPDATE_AFTER);
+                break;
+            case UPDATE_BEFORE:
+            case DELETE:
+                value.setRowKind(DELETE);
+        }
+        return value;
+    }
+
+    private void checkFlushException() {
+        if (flushException != null) {
+            throw new RuntimeException("Writing records to JDBC failed.", flushException);
+        }
+    }
+
+    // --------------------------------------------------------------------------------------------
+
+    /**
+     * Wrapper of {@link Context}.
+     *
+     * <p>When records arrives, the {@link BufferedUpsertSinkFunction} updates the current {@link
+     * Context} and memorize the timestamp with the records. When flushing, the {@link
+     * BufferedUpsertSinkFunction} will emit the records in the buffer with memorized timestamp.
+     */
+    private static class WrappedContext implements Context {
+
+        private Long timestamp;
+        private Context context;
+
+        void setTimestamp(Long timestamp) {
+            this.timestamp = timestamp;
+        }
+
+        void setContext(Context context) {
+            this.context = context;
+        }
+
+        @Override
+        public long currentProcessingTime() {
+            return context.currentProcessingTime();
+        }
+
+        @Override
+        public long currentWatermark() {
+            return context.currentWatermark();
+        }
+
+        @Override
+        public Long timestamp() {
+            return timestamp;
+        }
+    }
+}
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaSerializationSchema.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaSerializationSchema.java
index c4b6776..b2a0b21 100644
--- a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaSerializationSchema.java
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/DynamicKafkaSerializationSchema.java
@@ -187,7 +187,7 @@ class DynamicKafkaSerializationSchema
         return null;
     }
 
-    private static RowData createProjectedRow(
+    static RowData createProjectedRow(
             RowData consumedRow, RowKind kind, RowData.FieldGetter[] fieldGetters) {
         final int arity = fieldGetters.length;
         final GenericRowData genericRowData = new GenericRowData(kind, arity);
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicSink.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicSink.java
index 29db659..f0d0a48 100644
--- a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicSink.java
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicSink.java
@@ -35,7 +35,6 @@ import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.types.DataType;
 import org.apache.flink.table.types.logical.LogicalType;
 import org.apache.flink.table.types.utils.DataTypeUtils;
-import org.apache.flink.util.Preconditions;
 
 import org.apache.kafka.common.header.Header;
 
@@ -51,6 +50,8 @@ import java.util.Objects;
 import java.util.Properties;
 import java.util.stream.Stream;
 
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
 /** A version-agnostic Kafka {@link DynamicTableSink}. */
 @Internal
 public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetadata {
@@ -66,6 +67,9 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
     // Format attributes
     // --------------------------------------------------------------------------------------------
 
+    /** Data type of consumed data type. */
+    protected DataType consumedDataType;
+
     /** Data type to configure the formats. */
     protected final DataType physicalDataType;
 
@@ -106,10 +110,14 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
      */
     protected final boolean upsertMode;
 
+    /** Sink buffer flush config which only supported in upsert mode now. */
+    protected final SinkBufferFlushMode flushMode;
+
     /** Parallelism of the physical Kafka producer. * */
     protected final @Nullable Integer parallelism;
 
     public KafkaDynamicSink(
+            DataType consumedDataType,
             DataType physicalDataType,
             @Nullable EncodingFormat<SerializationSchema<RowData>> keyEncodingFormat,
             EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat,
@@ -121,28 +129,32 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
             @Nullable FlinkKafkaPartitioner<RowData> partitioner,
             KafkaSinkSemantic semantic,
             boolean upsertMode,
+            SinkBufferFlushMode flushMode,
             @Nullable Integer parallelism) {
         // Format attributes
+        this.consumedDataType =
+                checkNotNull(consumedDataType, "Consumed data type must not be null.");
         this.physicalDataType =
-                Preconditions.checkNotNull(
-                        physicalDataType, "Physical data type must not be null.");
+                checkNotNull(physicalDataType, "Physical data type must not be null.");
         this.keyEncodingFormat = keyEncodingFormat;
         this.valueEncodingFormat =
-                Preconditions.checkNotNull(
-                        valueEncodingFormat, "Value encoding format must not be null.");
-        this.keyProjection =
-                Preconditions.checkNotNull(keyProjection, "Key projection must not be null.");
-        this.valueProjection =
-                Preconditions.checkNotNull(valueProjection, "Value projection must not be null.");
+                checkNotNull(valueEncodingFormat, "Value encoding format must not be null.");
+        this.keyProjection = checkNotNull(keyProjection, "Key projection must not be null.");
+        this.valueProjection = checkNotNull(valueProjection, "Value projection must not be null.");
         this.keyPrefix = keyPrefix;
         // Mutable attributes
         this.metadataKeys = Collections.emptyList();
         // Kafka-specific attributes
-        this.topic = Preconditions.checkNotNull(topic, "Topic must not be null.");
-        this.properties = Preconditions.checkNotNull(properties, "Properties must not be null.");
+        this.topic = checkNotNull(topic, "Topic must not be null.");
+        this.properties = checkNotNull(properties, "Properties must not be null.");
         this.partitioner = partitioner;
-        this.semantic = Preconditions.checkNotNull(semantic, "Semantic must not be null.");
+        this.semantic = checkNotNull(semantic, "Semantic must not be null.");
         this.upsertMode = upsertMode;
+        this.flushMode = checkNotNull(flushMode);
+        if (flushMode.isEnabled() && !upsertMode) {
+            throw new IllegalArgumentException(
+                    "Sink buffer flush is only supported in upsert-kafka.");
+        }
         this.parallelism = parallelism;
     }
 
@@ -162,7 +174,18 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
         final FlinkKafkaProducer<RowData> kafkaProducer =
                 createKafkaProducer(keySerialization, valueSerialization);
 
-        return SinkFunctionProvider.of(kafkaProducer, parallelism);
+        if (flushMode.isEnabled() && upsertMode) {
+            BufferedUpsertSinkFunction buffedSinkFunction =
+                    new BufferedUpsertSinkFunction(
+                            kafkaProducer,
+                            physicalDataType,
+                            keyProjection,
+                            context.createTypeInformation(consumedDataType),
+                            flushMode);
+            return SinkFunctionProvider.of(buffedSinkFunction, parallelism);
+        } else {
+            return SinkFunctionProvider.of(kafkaProducer, parallelism);
+        }
     }
 
     @Override
@@ -176,12 +199,14 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
     @Override
     public void applyWritableMetadata(List<String> metadataKeys, DataType consumedDataType) {
         this.metadataKeys = metadataKeys;
+        this.consumedDataType = consumedDataType;
     }
 
     @Override
     public DynamicTableSink copy() {
         final KafkaDynamicSink copy =
                 new KafkaDynamicSink(
+                        consumedDataType,
                         physicalDataType,
                         keyEncodingFormat,
                         valueEncodingFormat,
@@ -193,6 +218,7 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
                         partitioner,
                         semantic,
                         upsertMode,
+                        flushMode,
                         parallelism);
         copy.metadataKeys = metadataKeys;
         return copy;
@@ -213,6 +239,7 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
         }
         final KafkaDynamicSink that = (KafkaDynamicSink) o;
         return Objects.equals(metadataKeys, that.metadataKeys)
+                && Objects.equals(consumedDataType, that.consumedDataType)
                 && Objects.equals(physicalDataType, that.physicalDataType)
                 && Objects.equals(keyEncodingFormat, that.keyEncodingFormat)
                 && Objects.equals(valueEncodingFormat, that.valueEncodingFormat)
@@ -224,6 +251,7 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
                 && Objects.equals(partitioner, that.partitioner)
                 && Objects.equals(semantic, that.semantic)
                 && Objects.equals(upsertMode, that.upsertMode)
+                && Objects.equals(flushMode, that.flushMode)
                 && Objects.equals(parallelism, that.parallelism);
     }
 
@@ -231,6 +259,7 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
     public int hashCode() {
         return Objects.hash(
                 metadataKeys,
+                consumedDataType,
                 physicalDataType,
                 keyEncodingFormat,
                 valueEncodingFormat,
@@ -242,6 +271,7 @@ public class KafkaDynamicSink implements DynamicTableSink, SupportsWritingMetada
                 partitioner,
                 semantic,
                 upsertMode,
+                flushMode,
                 parallelism);
     }
 
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactory.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactory.java
index 9c6f98b..cbd05cc 100644
--- a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactory.java
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactory.java
@@ -350,6 +350,7 @@ public class KafkaDynamicTableFactory
             Integer parallelism) {
         return new KafkaDynamicSink(
                 physicalDataType,
+                physicalDataType,
                 keyEncodingFormat,
                 valueEncodingFormat,
                 keyProjection,
@@ -360,6 +361,7 @@ public class KafkaDynamicTableFactory
                 partitioner,
                 semantic,
                 false,
+                SinkBufferFlushMode.DISABLED,
                 parallelism);
     }
 }
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaOptions.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaOptions.java
index de1ffe7..993af27 100644
--- a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaOptions.java
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/KafkaOptions.java
@@ -214,6 +214,32 @@ public class KafkaOptions {
                     .withDescription(
                             "Optional semantic when commit. Valid enumerationns are [\"at-least-once\", \"exactly-once\", \"none\"]");
 
+    // Disable this feature by default
+    public static final ConfigOption<Integer> SINK_BUFFER_FLUSH_MAX_ROWS =
+            ConfigOptions.key("sink.buffer-flush.max-rows")
+                    .intType()
+                    .defaultValue(0)
+                    .withDescription(
+                            "The max size of buffered records before flush. "
+                                    + "When the sink receives many updates on the same key, the buffer will retain the last record of the same key. "
+                                    + "This can help to reduce data shuffling and avoid possible tombstone messages to Kafka topic."
+                                    + "Can be set to '0' to disable it."
+                                    + "Note both 'sink.buffer-flush.max-rows' and 'sink.buffer-flush.interval' "
+                                    + "must be set to be greater than zero to enable sink buffer flushing.");
+
+    // Disable this feature by default
+    public static final ConfigOption<Duration> SINK_BUFFER_FLUSH_INTERVAL =
+            ConfigOptions.key("sink.buffer-flush.interval")
+                    .durationType()
+                    .defaultValue(Duration.ofSeconds(0))
+                    .withDescription(
+                            "The flush interval mills, over this time, asynchronous threads will flush data. "
+                                    + "When the sink receives many updates on the same key, the buffer will retain the last record of the same key. "
+                                    + "This can help to reduce data shuffling and avoid possible tombstone messages to Kafka topic."
+                                    + "Can be set to '0' to disable it. "
+                                    + "Note both 'sink.buffer-flush.max-rows' and 'sink.buffer-flush.interval' "
+                                    + "must be set to be greater than zero to enable sink buffer flushing.");
+
     private static final ConfigOption<String> SCHEMA_REGISTRY_SUBJECT =
             ConfigOptions.key("schema-registry.subject").stringType().noDefaultValue();
 
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/SinkBufferFlushMode.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/SinkBufferFlushMode.java
new file mode 100644
index 0000000..d5c0c2d
--- /dev/null
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/SinkBufferFlushMode.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.streaming.connectors.kafka.table;
+
+import java.util.Objects;
+
+/** Sink buffer flush configuration. */
+public class SinkBufferFlushMode {
+
+    private static final int DISABLED_BATCH_SIZE = 0;
+    private static final long DISABLED_BATCH_INTERVAL = 0L;
+
+    public static final SinkBufferFlushMode DISABLED =
+            new SinkBufferFlushMode(DISABLED_BATCH_SIZE, DISABLED_BATCH_INTERVAL);
+
+    private final int batchSize;
+    private final long batchIntervalMs;
+
+    public SinkBufferFlushMode(int batchSize, long batchIntervalMs) {
+        this.batchSize = batchSize;
+        this.batchIntervalMs = batchIntervalMs;
+
+        // validation
+        if (isEnabled()
+                && !(batchSize > DISABLED_BATCH_SIZE
+                        && batchIntervalMs > DISABLED_BATCH_INTERVAL)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "batchSize and batchInterval must greater than zero if buffer flush is enabled,"
+                                    + " but got batchSize=%s and batchIntervalMs=%s",
+                            batchSize, batchIntervalMs));
+        }
+    }
+
+    public int getBatchSize() {
+        return batchSize;
+    }
+
+    public long getBatchIntervalMs() {
+        return batchIntervalMs;
+    }
+
+    public boolean isEnabled() {
+        return !(batchSize == DISABLED_BATCH_SIZE && batchIntervalMs == DISABLED_BATCH_INTERVAL);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        SinkBufferFlushMode that = (SinkBufferFlushMode) o;
+        return batchSize == that.batchSize && batchIntervalMs == that.batchIntervalMs;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(batchSize, batchIntervalMs);
+    }
+}
diff --git a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactory.java b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactory.java
index fb92586..1d2860b 100644
--- a/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactory.java
+++ b/flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactory.java
@@ -43,6 +43,7 @@ import org.apache.flink.table.factories.SerializationFormatFactory;
 import org.apache.flink.table.types.DataType;
 import org.apache.flink.types.RowKind;
 
+import java.time.Duration;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -54,6 +55,8 @@ import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.KEY
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.KEY_FIELDS_PREFIX;
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.KEY_FORMAT;
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.PROPS_BOOTSTRAP_SERVERS;
+import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.SINK_BUFFER_FLUSH_INTERVAL;
+import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.SINK_BUFFER_FLUSH_MAX_ROWS;
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.TOPIC;
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.VALUE_FIELDS_INCLUDE;
 import static org.apache.flink.streaming.connectors.kafka.table.KafkaOptions.VALUE_FORMAT;
@@ -89,6 +92,8 @@ public class UpsertKafkaDynamicTableFactory
         options.add(KEY_FIELDS_PREFIX);
         options.add(VALUE_FIELDS_INCLUDE);
         options.add(FactoryUtil.SINK_PARALLELISM);
+        options.add(SINK_BUFFER_FLUSH_INTERVAL);
+        options.add(SINK_BUFFER_FLUSH_MAX_ROWS);
         return options;
     }
 
@@ -105,7 +110,7 @@ public class UpsertKafkaDynamicTableFactory
         // Validate the option data type.
         helper.validateExcept(KafkaOptions.PROPERTIES_PREFIX);
         TableSchema schema = context.getCatalogTable().getSchema();
-        validateTableOptions(tableOptions, keyDecodingFormat, valueDecodingFormat, schema);
+        validateSource(tableOptions, keyDecodingFormat, valueDecodingFormat, schema);
 
         Tuple2<int[], int[]> keyValueProjections =
                 createKeyValueProjections(context.getCatalogTable());
@@ -146,7 +151,7 @@ public class UpsertKafkaDynamicTableFactory
         // Validate the option data type.
         helper.validateExcept(KafkaOptions.PROPERTIES_PREFIX);
         TableSchema schema = context.getCatalogTable().getSchema();
-        validateTableOptions(tableOptions, keyEncodingFormat, valueEncodingFormat, schema);
+        validateSink(tableOptions, keyEncodingFormat, valueEncodingFormat, schema);
 
         Tuple2<int[], int[]> keyValueProjections =
                 createKeyValueProjections(context.getCatalogTable());
@@ -155,10 +160,16 @@ public class UpsertKafkaDynamicTableFactory
 
         Integer parallelism = tableOptions.get(FactoryUtil.SINK_PARALLELISM);
 
+        int batchSize = tableOptions.get(SINK_BUFFER_FLUSH_MAX_ROWS);
+        Duration batchInterval = tableOptions.get(SINK_BUFFER_FLUSH_INTERVAL);
+        SinkBufferFlushMode flushMode =
+                new SinkBufferFlushMode(batchSize, batchInterval.toMillis());
+
         // use {@link org.apache.kafka.clients.producer.internals.DefaultPartitioner}.
         // it will use hash partition if key is set else in round-robin behaviour.
         return new KafkaDynamicSink(
                 schema.toPhysicalRowDataType(),
+                schema.toPhysicalRowDataType(),
                 keyEncodingFormat,
                 new EncodingFormatWrapper(valueEncodingFormat),
                 keyValueProjections.f0,
@@ -169,6 +180,7 @@ public class UpsertKafkaDynamicTableFactory
                 null,
                 KafkaSinkSemantic.AT_LEAST_ONCE,
                 true,
+                flushMode,
                 parallelism);
     }
 
@@ -192,11 +204,19 @@ public class UpsertKafkaDynamicTableFactory
     // Validation
     // --------------------------------------------------------------------------------------------
 
-    private static void validateTableOptions(
+    private static void validateSource(
+            ReadableConfig tableOptions, Format keyFormat, Format valueFormat, TableSchema schema) {
+        validateTopic(tableOptions);
+        validateFormat(keyFormat, valueFormat, tableOptions);
+        validatePKConstraints(schema);
+    }
+
+    private static void validateSink(
             ReadableConfig tableOptions, Format keyFormat, Format valueFormat, TableSchema schema) {
         validateTopic(tableOptions);
         validateFormat(keyFormat, valueFormat, tableOptions);
         validatePKConstraints(schema);
+        validateSinkBufferFlush(tableOptions);
     }
 
     private static void validateTopic(ReadableConfig tableOptions) {
@@ -237,6 +257,24 @@ public class UpsertKafkaDynamicTableFactory
         }
     }
 
+    private static void validateSinkBufferFlush(ReadableConfig tableOptions) {
+        int flushMaxRows = tableOptions.get(SINK_BUFFER_FLUSH_MAX_ROWS);
+        long flushIntervalMs = tableOptions.get(SINK_BUFFER_FLUSH_INTERVAL).toMillis();
+        if (flushMaxRows > 0 && flushIntervalMs > 0) {
+            // flush is enabled
+            return;
+        }
+        if (flushMaxRows <= 0 && flushIntervalMs <= 0) {
+            // flush is disabled
+            return;
+        }
+        // one of them is set which is not allowed
+        throw new ValidationException(
+                String.format(
+                        "'%s' and '%s' must be set to be greater than zero together to enable sink buffer flushing.",
+                        SINK_BUFFER_FLUSH_MAX_ROWS.key(), SINK_BUFFER_FLUSH_INTERVAL.key()));
+    }
+
     // --------------------------------------------------------------------------------------------
     // Format wrapper
     // --------------------------------------------------------------------------------------------
diff --git a/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunctionTest.java b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunctionTest.java
new file mode 100644
index 0000000..e8d6a9a
--- /dev/null
+++ b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/BufferedUpsertSinkFunctionTest.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.streaming.connectors.kafka.table;
+
+import org.apache.flink.api.common.functions.RuntimeContext;
+import org.apache.flink.api.common.state.CheckpointListener;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
+import org.apache.flink.streaming.api.functions.sink.SinkContextUtil;
+import org.apache.flink.streaming.util.MockStreamingRuntimeContext;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.runtime.connector.sink.SinkRuntimeProviderContext;
+
+import org.junit.Test;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.flink.types.RowKind.DELETE;
+import static org.apache.flink.types.RowKind.INSERT;
+import static org.apache.flink.types.RowKind.UPDATE_AFTER;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/** Test for {@link BufferedUpsertSinkFunction}. */
+public class BufferedUpsertSinkFunctionTest {
+
+    private static final ResolvedSchema SCHEMA =
+            ResolvedSchema.of(
+                    Column.physical("id", DataTypes.INT().notNull()),
+                    Column.physical("title", DataTypes.STRING().notNull()),
+                    Column.physical("author", DataTypes.STRING()),
+                    Column.physical("price", DataTypes.DOUBLE()),
+                    Column.physical("qty", DataTypes.INT()),
+                    Column.physical("ts", DataTypes.TIMESTAMP_LTZ(3)));
+
+    private static final int keyIndices = 0;
+    private static final int TIMESTAMP_INDICES = 5;
+    private static final SinkBufferFlushMode BUFFER_FLUSH_MODE =
+            new SinkBufferFlushMode(4, Long.MAX_VALUE);
+
+    public static final RowData[] TEST_DATA = {
+        GenericRowData.ofKind(
+                INSERT,
+                1001,
+                StringData.fromString("Java public for dummies"),
+                StringData.fromString("Tan Ah Teck"),
+                11.11,
+                11,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T15:00:00Z"))),
+        GenericRowData.ofKind(
+                INSERT,
+                1002,
+                StringData.fromString("More Java for dummies"),
+                StringData.fromString("Tan Ah Teck"),
+                22.22,
+                22,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T16:00:00Z"))),
+        GenericRowData.ofKind(
+                INSERT,
+                1004,
+                StringData.fromString("A Cup of Java"),
+                StringData.fromString("Kumar"),
+                44.44,
+                44,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T17:00:00Z"))),
+        GenericRowData.ofKind(
+                UPDATE_AFTER,
+                1004,
+                StringData.fromString("A Teaspoon of Java"),
+                StringData.fromString("Kevin Jones"),
+                55.55,
+                55,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T18:00:00Z"))),
+        GenericRowData.ofKind(
+                UPDATE_AFTER,
+                1004,
+                StringData.fromString("A Teaspoon of Java 1.4"),
+                StringData.fromString("Kevin Jones"),
+                66.66,
+                66,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T19:00:00Z"))),
+        GenericRowData.ofKind(
+                UPDATE_AFTER,
+                1004,
+                StringData.fromString("A Teaspoon of Java 1.5"),
+                StringData.fromString("Kevin Jones"),
+                77.77,
+                77,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T20:00:00Z"))),
+        GenericRowData.ofKind(
+                DELETE,
+                1004,
+                StringData.fromString("A Teaspoon of Java 1.8"),
+                StringData.fromString("Kevin Jones"),
+                null,
+                1010,
+                TimestampData.fromInstant(Instant.parse("2021-03-30T21:00:00Z")))
+    };
+
+    @Test
+    public void testWriteData() throws Exception {
+        MockedSinkFunction sinkFunction = new MockedSinkFunction();
+        BufferedUpsertSinkFunction bufferedSink = createBufferedSink(sinkFunction);
+
+        // write 3 records which doesn't trigger batch size
+        writeData(bufferedSink, TEST_DATA, 0, 3);
+        assertTrue(sinkFunction.rowDataCollectors.isEmpty());
+
+        // write one more record, and should flush the buffer
+        writeData(bufferedSink, TEST_DATA, 3, 1);
+
+        HashMap<Integer, List<RowData>> expected = new HashMap<>();
+        expected.put(
+                1001,
+                Collections.singletonList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1001,
+                                StringData.fromString("Java public for dummies"),
+                                StringData.fromString("Tan Ah Teck"),
+                                11.11,
+                                11,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T15:00:00Z")))));
+        expected.put(
+                1002,
+                Collections.singletonList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1002,
+                                StringData.fromString("More Java for dummies"),
+                                StringData.fromString("Tan Ah Teck"),
+                                22.22,
+                                22,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T16:00:00Z")))));
+        expected.put(
+                1004,
+                Collections.singletonList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1004,
+                                StringData.fromString("A Teaspoon of Java"),
+                                StringData.fromString("Kevin Jones"),
+                                55.55,
+                                55,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T18:00:00Z")))));
+
+        compareCompactedResult(expected, sinkFunction.rowDataCollectors);
+
+        sinkFunction.rowDataCollectors.clear();
+        // write remaining data, and they are still buffered
+        writeData(bufferedSink, TEST_DATA, 4, 3);
+        assertTrue(sinkFunction.rowDataCollectors.isEmpty());
+    }
+
+    @Test
+    public void testFlushDataWhenCheckpointing() throws Exception {
+        MockedSinkFunction sinkFunction = new MockedSinkFunction();
+        BufferedUpsertSinkFunction bufferedFunction = createBufferedSink(sinkFunction);
+        // write all data, there should be 3 records are still buffered
+        writeData(bufferedFunction, TEST_DATA, 0, TEST_DATA.length);
+        // snapshot should flush the buffer
+        bufferedFunction.snapshotState(null);
+
+        HashMap<Integer, List<RowData>> expected = new HashMap<>();
+        expected.put(
+                1001,
+                Collections.singletonList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1001,
+                                StringData.fromString("Java public for dummies"),
+                                StringData.fromString("Tan Ah Teck"),
+                                11.11,
+                                11,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T15:00:00Z")))));
+        expected.put(
+                1002,
+                Collections.singletonList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1002,
+                                StringData.fromString("More Java for dummies"),
+                                StringData.fromString("Tan Ah Teck"),
+                                22.22,
+                                22,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T16:00:00Z")))));
+        expected.put(
+                1004,
+                Arrays.asList(
+                        GenericRowData.ofKind(
+                                UPDATE_AFTER,
+                                1004,
+                                StringData.fromString("A Teaspoon of Java"),
+                                StringData.fromString("Kevin Jones"),
+                                55.55,
+                                55,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T18:00:00Z"))),
+                        GenericRowData.ofKind(
+                                DELETE,
+                                1004,
+                                StringData.fromString("A Teaspoon of Java 1.8"),
+                                StringData.fromString("Kevin Jones"),
+                                null,
+                                1010,
+                                TimestampData.fromInstant(Instant.parse("2021-03-30T21:00:00Z")))));
+
+        compareCompactedResult(expected, sinkFunction.rowDataCollectors);
+    }
+
+    // --------------------------------------------------------------------------------------------
+
+    @SuppressWarnings("unchecked")
+    private BufferedUpsertSinkFunction createBufferedSink(MockedSinkFunction sinkFunction)
+            throws Exception {
+        TypeInformation<RowData> typeInformation =
+                (TypeInformation<RowData>)
+                        new SinkRuntimeProviderContext(false)
+                                .createTypeInformation(SCHEMA.toPhysicalRowDataType());
+
+        BufferedUpsertSinkFunction bufferedSinkFunction =
+                new BufferedUpsertSinkFunction(
+                        sinkFunction,
+                        SCHEMA.toPhysicalRowDataType(),
+                        new int[] {keyIndices},
+                        typeInformation,
+                        BUFFER_FLUSH_MODE);
+        bufferedSinkFunction.open(new Configuration());
+
+        return bufferedSinkFunction;
+    }
+
+    private void writeData(BufferedUpsertSinkFunction sink, RowData[] data, int startPos, int size)
+            throws Exception {
+        for (int i = startPos; i < startPos + size; i++) {
+            RowData row = data[i];
+            long rowtime = row.getTimestamp(TIMESTAMP_INDICES, 3).getMillisecond();
+            sink.invoke(row, SinkContextUtil.forTimestamp(rowtime));
+        }
+    }
+
+    private void compareCompactedResult(
+            Map<Integer, List<RowData>> expected, List<RowData> actual) {
+        Map<Integer, List<RowData>> actualMap = new HashMap<>();
+
+        for (RowData rowData : actual) {
+            Integer id = rowData.getInt(keyIndices);
+            actualMap.computeIfAbsent(id, key -> new ArrayList<>()).add(rowData);
+        }
+
+        assertEquals(expected.size(), actualMap.size());
+        for (Integer id : expected.keySet()) {
+            assertEquals(expected.get(id), actualMap.get(id));
+        }
+    }
+
+    // --------------------------------------------------------------------------------------------
+
+    private static class MockedSinkFunction extends RichSinkFunction<RowData>
+            implements CheckpointedFunction, CheckpointListener {
+
+        private static final long serialVersionUID = 1L;
+        transient List<RowData> rowDataCollectors;
+
+        @Override
+        public RuntimeContext getRuntimeContext() {
+            return new MockStreamingRuntimeContext(true, 1, 1);
+        }
+
+        @Override
+        public void open(Configuration parameters) throws Exception {
+            super.open(parameters);
+            rowDataCollectors = new ArrayList<>();
+        }
+
+        @Override
+        public void notifyCheckpointComplete(long checkpointId) {
+            // do nothing
+        }
+
+        @Override
+        public void snapshotState(FunctionSnapshotContext context) {
+            // do nothing
+        }
+
+        @Override
+        public void initializeState(FunctionInitializationContext context) {
+            // do nothing
+        }
+
+        @Override
+        public void invoke(RowData value, Context context) {
+            assertEquals(
+                    value.getTimestamp(TIMESTAMP_INDICES, 3).toInstant(),
+                    Instant.ofEpochMilli(context.timestamp()));
+            rowDataCollectors.add(value);
+        }
+    }
+}
diff --git a/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactoryTest.java b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactoryTest.java
index 027391d..15e2913 100644
--- a/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactoryTest.java
+++ b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactoryTest.java
@@ -886,6 +886,7 @@ public class KafkaDynamicTableFactoryTest extends TestLogger {
             @Nullable Integer parallelism) {
         return new KafkaDynamicSink(
                 physicalDataType,
+                physicalDataType,
                 keyEncodingFormat,
                 valueEncodingFormat,
                 keyProjection,
@@ -896,6 +897,7 @@ public class KafkaDynamicTableFactoryTest extends TestLogger {
                 partitioner,
                 semantic,
                 false,
+                SinkBufferFlushMode.DISABLED,
                 parallelism);
     }
 
diff --git a/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactoryTest.java b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactoryTest.java
index d05845e..07e155d 100644
--- a/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactoryTest.java
+++ b/flink-connectors/flink-connector-kafka/src/test/java/org/apache/flink/streaming/connectors/kafka/table/UpsertKafkaDynamicTableFactoryTest.java
@@ -119,20 +119,23 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
         UPSERT_KAFKA_SINK_PROPERTIES.setProperty("bootstrap.servers", "dummy");
     }
 
+    static EncodingFormat<SerializationSchema<RowData>> keyEncodingFormat =
+            new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
+    static EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat =
+            new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
+
+    static DecodingFormat<DeserializationSchema<RowData>> keyDecodingFormat =
+            new TestFormatFactory.DecodingFormatMock(
+                    ",", true, ChangelogMode.insertOnly(), Collections.emptyMap());
+    static DecodingFormat<DeserializationSchema<RowData>> valueDecodingFormat =
+            new TestFormatFactory.DecodingFormatMock(
+                    ",", true, ChangelogMode.insertOnly(), Collections.emptyMap());
+
     @Rule public ExpectedException thrown = ExpectedException.none();
 
     @Test
     public void testTableSource() {
         final DataType producedDataType = SOURCE_SCHEMA.toPhysicalRowDataType();
-
-        DecodingFormat<DeserializationSchema<RowData>> keyDecodingFormat =
-                new TestFormatFactory.DecodingFormatMock(
-                        ",", true, ChangelogMode.insertOnly(), Collections.emptyMap());
-
-        DecodingFormat<DeserializationSchema<RowData>> valueDecodingFormat =
-                new TestFormatFactory.DecodingFormatMock(
-                        ",", true, ChangelogMode.insertOnly(), Collections.emptyMap());
-
         // Construct table source using options and table source factory
         final DynamicTableSource actualSource =
                 createTableSource(SOURCE_SCHEMA, getFullSourceOptions());
@@ -161,11 +164,6 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
 
     @Test
     public void testTableSink() {
-        EncodingFormat<SerializationSchema<RowData>> keyEncodingFormat =
-                new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
-        EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat =
-                new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
-
         // Construct table sink using options and table sink factory.
         final DynamicTableSink actualSink = createTableSink(SINK_SCHEMA, getFullSinkOptions());
 
@@ -179,6 +177,7 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
                         null,
                         SINK_TOPIC,
                         UPSERT_KAFKA_SINK_PROPERTIES,
+                        SinkBufferFlushMode.DISABLED,
                         null);
 
         // Test sink format.
@@ -195,17 +194,51 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
     }
 
     @Test
+    public void testBufferedTableSink() {
+        // Construct table sink using options and table sink factory.
+        final DynamicTableSink actualSink =
+                createTableSink(
+                        SINK_SCHEMA,
+                        getModifiedOptions(
+                                getFullSinkOptions(),
+                                options -> {
+                                    options.put("sink.buffer-flush.max-rows", "100");
+                                    options.put("sink.buffer-flush.interval", "1s");
+                                }));
+
+        final DynamicTableSink expectedSink =
+                createExpectedSink(
+                        SINK_SCHEMA.toPhysicalRowDataType(),
+                        keyEncodingFormat,
+                        valueEncodingFormat,
+                        SINK_KEY_FIELDS,
+                        SINK_VALUE_FIELDS,
+                        null,
+                        SINK_TOPIC,
+                        UPSERT_KAFKA_SINK_PROPERTIES,
+                        new SinkBufferFlushMode(100, 1000L),
+                        null);
+
+        // Test sink format.
+        final KafkaDynamicSink actualUpsertKafkaSink = (KafkaDynamicSink) actualSink;
+        assertEquals(expectedSink, actualSink);
+
+        // Test kafka producer.
+        DynamicTableSink.SinkRuntimeProvider provider =
+                actualUpsertKafkaSink.getSinkRuntimeProvider(new SinkRuntimeProviderContext(false));
+        assertThat(provider, instanceOf(SinkFunctionProvider.class));
+        final SinkFunctionProvider sinkFunctionProvider = (SinkFunctionProvider) provider;
+        final SinkFunction<RowData> sinkFunction = sinkFunctionProvider.createSinkFunction();
+        assertThat(sinkFunction, instanceOf(BufferedUpsertSinkFunction.class));
+    }
+
+    @Test
     public void testTableSinkWithParallelism() {
         final Map<String, String> modifiedOptions =
                 getModifiedOptions(
                         getFullSinkOptions(), options -> options.put("sink.parallelism", "100"));
         final DynamicTableSink actualSink = createTableSink(SINK_SCHEMA, modifiedOptions);
 
-        EncodingFormat<SerializationSchema<RowData>> keyEncodingFormat =
-                new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
-        EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat =
-                new TestFormatFactory.EncodingFormatMock(",", ChangelogMode.insertOnly());
-
         final DynamicTableSink expectedSink =
                 createExpectedSink(
                         SINK_SCHEMA.toPhysicalRowDataType(),
@@ -216,6 +249,7 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
                         null,
                         SINK_TOPIC,
                         UPSERT_KAFKA_SINK_PROPERTIES,
+                        SinkBufferFlushMode.DISABLED,
                         100);
         assertEquals(expectedSink, actualSink);
 
@@ -422,6 +456,25 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
                                         "I;UA;UB;D")));
     }
 
+    @Test
+    public void testInvalidSinkBufferFlush() {
+        thrown.expect(ValidationException.class);
+        thrown.expect(
+                containsCause(
+                        new ValidationException(
+                                "'sink.buffer-flush.max-rows' and 'sink.buffer-flush.interval' "
+                                        + "must be set to be greater than zero together to enable"
+                                        + " sink buffer flushing.")));
+        createTableSink(
+                SINK_SCHEMA,
+                getModifiedOptions(
+                        getFullSinkOptions(),
+                        options -> {
+                            options.put("sink.buffer-flush.max-rows", "0");
+                            options.put("sink.buffer-flush.interval", "1s");
+                        }));
+    }
+
     // --------------------------------------------------------------------------------------------
     // Utilities
     // --------------------------------------------------------------------------------------------
@@ -546,9 +599,11 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
             String keyPrefix,
             String topic,
             Properties properties,
+            SinkBufferFlushMode flushMode,
             Integer parallelism) {
         return new KafkaDynamicSink(
                 consumedDataType,
+                consumedDataType,
                 keyEncodingFormat,
                 new UpsertKafkaDynamicTableFactory.EncodingFormatWrapper(valueEncodingFormat),
                 keyProjection,
@@ -559,6 +614,7 @@ public class UpsertKafkaDynamicTableFactoryTest extends TestLogger {
                 null,
                 KafkaSinkSemantic.AT_LEAST_ONCE,
                 true,
+                flushMode,
                 parallelism);
     }
 }