摘要:本文深入分析 Apache Flink Doris Connector 的 Sink 端实现原理,涵盖三种写入模式(STREAM_LOAD、STREAM_LOAD_BATCH、COPY)的架构设计、核心源码解读、故障恢复机制以及性能优化实践。通过对源码的逐层剖析,帮助读者理解 Connector 的内部工作机制,从而在实际生产中做出更优的技术选型和参数调优。

一、概述

1.1 Flink Doris Connector 简介

Flink Doris Connector 是 Apache Doris 官方提供的 Flink 集成组件,支持通过 Flink 实时写入数据到 Doris。其核心优势包括:

  • 流批一体:同时支持流式写入和批量导入
  • Exactly-Once 语义:通过两阶段提交(2PC)保证数据精确一次
  • 高性能:多种写入模式适配不同场景
  • 易用性:支持 Flink SQL 和 DataStream API

项目地址:https://github.com/apache/doris-flink-connector

1.2 三种写入模式概览

Flink Doris Connector 提供三种写入模式,适用于不同的业务场景:

模式

配置方式

语义保证

适用场景

STREAM_LOAD

sink.enable.batch-mode=false

(默认)

Exactly-Once

金融交易、订单系统等要求精确一次的场景

STREAM_LOAD_BATCH

sink.enable.batch-mode=true

At-Least-Once

日志采集、用户行为分析等高吞吐场景

COPY

sink.write-mode=copy

At-Least-Once

数仓同步、历史数据迁移等超大批量场景

1.3 数据流转路径对比

1.4 语义保证深度解析

在深入源码之前,需要先厘清两个核心概念:

At-Least-Once = 不丢数据 + 可能重复
Exactly-Once  = 不丢数据 + 不重复

三种模式的语义保证机制对比

模式

不丢数据的保证

不重复的保证

最终语义

STREAM_LOAD

Checkpoint + 2PC PreCommit

Label 幂等 + 2PC Commit

Exactly-Once

STREAM_LOAD_BATCH

Checkpoint 前 flush 完成

❌ 无(Label 每次不同)

At-Least-Once

COPY

Checkpoint 前上传完成

❌ 无

At-Least-Once

二、整体架构设计

2.1 核心类结构

classDiagram
    class DorisSink {
        +getWriter()
        +getCommitter()
        +getWriterStateSerializer()
    }
    
    class DorisAbstractWriter {
        <<interface>>
        +write(IN in)
        +prepareCommit()
        +snapshotState()
    }
    
    class DorisWriter {
        -DorisStreamLoad dorisStreamLoad
        -LabelGenerator labelGenerator
        +write(IN in)
        +prepareCommit()
    }
    
    class DorisBatchWriter {
        -DorisBatchStreamLoad batchStreamLoad
        +write(IN in)
        +prepareCommit()
    }
    
    class DorisCopyWriter {
        -BatchStageLoad batchStageLoad
        +write(IN in)
        +prepareCommit()
    }
    
    class DorisStreamLoad {
        -RecordBuffer recordBuffer
        +startLoad()
        +writeRecord()
        +stopLoad()
    }
    
    class DorisBatchStreamLoad {
        -Map bufferMap
        -BlockingQueue flushQueue
        +writeRecord()
        +flush()
    }
    
    class BatchStageLoad {
        -Map bufferMap
        -Map fileListMap
        +writeRecord()
        +uploadToStorage()
    }
    
    class DorisCommitter {
        +commit()
        -commitTransaction()
    }
    
    class DorisCopyCommitter {
        +commit()
        -executeCopySQL()
    }
    
    DorisSink --> DorisAbstractWriter
    DorisAbstractWriter <|.. DorisWriter
    DorisAbstractWriter <|.. DorisBatchWriter
    DorisAbstractWriter <|.. DorisCopyWriter
    
    DorisWriter --> DorisStreamLoad
    DorisBatchWriter --> DorisBatchStreamLoad
    DorisCopyWriter --> BatchStageLoad
    
    DorisSink --> DorisCommitter
    DorisSink --> DorisCopyCommitter

2.2 模式选择逻辑

DorisSink 根据配置参数选择对应的 Writer 实现:

// DorisSink.java
public DorisAbstractWriter getDorisAbstractWriter(InitContext initContext,
                                                   DorisWriterState state) {
    if (WriteMode.STREAM_LOAD.equals(executionOptions.getWriteMode())) {
        // 默认模式:流式写入 + 2PC
        return new DorisWriter<>(initContext, state, serializer, ...);
    } else if (WriteMode.STREAM_LOAD_BATCH.equals(executionOptions.getWriteMode())) {
        // 攒批模式:内存攒批 + 异步发送
        return new DorisBatchWriter<>(initContext, state, serializer, ...);
    } else if (WriteMode.COPY.equals(executionOptions.getWriteMode())) {
        // COPY模式:Stage导入
        return new DorisCopyWriter(initContext, state, labelGenerator, ...);
    }
    throw new IllegalArgumentException("Unsupported write mode");
}

三、STREAM_LOAD 模式详解(默认模式)

STREAM_LOAD 是 Flink Doris Connector 的默认写入模式,也是唯一支持 Exactly-Once 语义的模式。其核心通过 Doris 的两阶段提交(2PC)机制与 Flink Checkpoint 协同,保证数据不丢不重。

3.1 DorisWriter:写入器核心逻辑

DorisWriter 负责管理数据写入的生命周期,包括 StreamLoad 的初始化、数据写入、预提交等。

// DorisWriter.java 核心结构
public class DorisWriter<IN>
        implements DorisAbstractWriter<IN, DorisWriterState, DorisCommittable> {

    private DorisStreamLoad dorisStreamLoad;
    private final DorisWriteMetrics dorisWriteMetrics;
    private volatile boolean loading = false;
    private long lastCheckpointId;

    // 初始化 StreamLoad
    public void initializeLoad() throws IOException {
        this.dorisStreamLoad = new DorisStreamLoad(...);
        // 处理残留事务(故障恢复)
        if (executionOptions.enabled2PC()) {
            dorisStreamLoad.abortLingeringTransactions(
                labelGenerator.getLabelPrefix(), ...);
        }
    }

    // 写入单条记录
    @Override
    public void write(IN in, Context context) throws IOException, InterruptedException {
        checkLoadException();
        // 序列化
        DorisRecord record = serializer.serialize(in);
        if (record == null || record.getRow() == null) {
            return;
        }
        // 首次写入时启动 HTTP 连接
        if (!loading) {
            dorisStreamLoad.startLoad(
                labelGenerator.generateTableLabel(...), ...);
            loading = true;
        }
        // 写入数据
        dorisStreamLoad.writeRecord(record.getRow());
    }

    // Checkpoint 时预提交
    @Override
    public Collection<DorisCommittable> prepareCommit() 
            throws IOException, InterruptedException {
        if (!loading) {
            return Collections.emptyList();
        }
        // 结束当前 StreamLoad,获取事务ID
        RespContent response = dorisStreamLoad.stopLoad();
        loading = false;

        if (!executionOptions.enabled2PC()) {
            return Collections.emptyList();
        }
        // 返回待提交的事务信息
        return Collections.singletonList(
            new DorisCommittable(dorisStreamLoad.getHostPort(),
                                 dorisStreamLoad.getDb(),
                                 response.getTxnId()));
    }
}

3.2 DorisStreamLoad:HTTP 流式传输执行器

DorisStreamLoad 是实际执行 HTTP Stream Load 的核心类,通过持续的 HTTP 连接流式传输数据到 Doris BE。

// DorisStreamLoad.java 核心结构
public class DorisStreamLoad implements Serializable {

    private static final String STREAM_LOAD_URL = 
        "http://%s/api/%s/%s/_stream_load";
    private RecordBuffer recordBuffer;
    private Future<CloseableHttpResponse> pendingLoadFuture;
    private final ExecutorService loadExecutorService;

    // 启动 StreamLoad
    public void startLoad(String label, boolean isResume) throws IOException {
        loadBatchFirstRecord = true;
        // 生成 HTTP 请求
        HttpPutBuilder putBuilder = new HttpPutBuilder();
        String url = String.format(STREAM_LOAD_URL, currentHost, db, table);
        putBuilder.setUrl(url)
                  .baseAuth(username, password)
                  .addCommonHeader()
                  .addLabel(label)
                  .setEntity(recordBuffer);  // RecordBuffer 作为 HTTP Entity

        // 2PC 模式下启用两阶段提交
        if (enable2PC) {
            putBuilder.enable2PC();  // Header: two_phase_commit=true
        }

        // 异步发送 HTTP 请求(请求会一直保持,直到 stopLoad)
        this.pendingLoadFuture = loadExecutorService.submit(() -> {
            return httpClient.execute(putBuilder.build());
        });
    }

    // 写入记录到缓冲区
    public void writeRecord(byte[] record) throws InterruptedException {
        recordBuffer.write(record);
    }

    // 停止 StreamLoad,获取响应
    public RespContent stopLoad() throws IOException {
        recordBuffer.stopBufferData();  // 标记数据结束
        CloseableHttpResponse response = pendingLoadFuture.get();
        return handleResponse(response);
    }
}

3.3 RecordBuffer:生产者-消费者缓冲区

RecordBuffer 是连接数据写入和 HTTP 传输的核心组件,采用生产者-消费者模式解耦数据写入与网络传输。

3.3.1 核心数据结构
// RecordBuffer.java 核心结构
public class RecordBuffer extends InputStream {

    // 1. 空闲缓冲区队列(存放多个 ByteBuffer)
    private final BlockingQueue<ByteBuffer> writeQueue;
    
    // 2. 待发送缓冲区队列(存放多个 ByteBuffer)
    private final BlockingDeque<ByteBuffer> readQueue;
    
    // 3. 当前正在写入的缓冲区(单个 ByteBuffer)
    private ByteBuffer currentWriteBuffer;
    
    // 4. 当前正在读取的缓冲区(单个 ByteBuffer)
    private ByteBuffer currentReadBuffer;

    public RecordBuffer(int bufferSize, int queueSize) {
        this.writeQueue = new ArrayBlockingQueue<>(queueSize);
        this.readQueue = new LinkedBlockingDeque<>();
        // 预分配缓冲区(这就是"多个缓冲区"的含义)
        for (int i = 0; i < queueSize; i++) {
            writeQueue.add(ByteBuffer.allocate(bufferSize));
        }
    }
}

配置参数说明

  • sink.buffer-size单个 ByteBuffer 的大小(默认 1MB)
  • sink.buffer-count队列中 ByteBuffer 的数量(默认 3)

所以"3个缓冲区"指的是:队列中有 3 个 ByteBuffer 对象在循环复用

3.3.2 缓冲区循环复用机制

数据流转过程

  1. 写入线程从 writeQueue 取出空闲 Buffer
  2. 写满后 flip() 放入 readQueue
  3. HTTP 发送线程从 readQueue 取出待发送 Buffer
  4. 发送完成后 clear() 归还到 writeQueue
3.3.3 写入方法实现
// 写入数据(生产者调用)
public void write(byte[] record) throws InterruptedException {
    if (currentWriteBuffer == null ||
        currentWriteBuffer.remaining() < record.length + lineDelimiter.length) {
        // 将当前缓冲区放入待发送队列
        if (currentWriteBuffer != null) {
            currentWriteBuffer.flip();  // 切换为读模式
            readQueue.put(currentWriteBuffer);
        }
        // 从空闲队列获取新缓冲区(可能阻塞)
        currentWriteBuffer = writeQueue.take();
        currentWriteBuffer.clear();
    }
    // 写入数据
    currentWriteBuffer.put(record);
    currentWriteBuffer.put(lineDelimiter);
}
3.3.4 读取方法实现(关键!)
// 读取数据(HTTP线程通过InputStream接口调用)
@Override
public int read(byte[] buf, int off, int len) throws IOException {
    if (currentReadBuffer == null || !currentReadBuffer.hasRemaining()) {
        // 回收已读完的缓冲区
        if (currentReadBuffer != null) {
            currentReadBuffer.clear();
            writeQueue.offer(currentReadBuffer);  // 归还到空闲队列
        }
        // 从待发送队列获取新缓冲区(带超时)
        currentReadBuffer = readQueue.poll(timeout, TimeUnit.MILLISECONDS);
        if (currentReadBuffer == null) {
            // 队列空了,但数据还没结束
            return endOfStream ? -1 : 0;  // 返回 0,HTTP 线程继续等待
        }
    }
    // 有数据就立即返回(不等 Buffer 满)
    int readLen = Math.min(len, currentReadBuffer.remaining());
    currentReadBuffer.get(buf, off, readLen);
    return readLen;
}

关键点:HTTP 线程只要有数据就立即读取并发送,不会等待 Buffer 填满。这就是"边写边传"的实现原理。

3.4 STREAM_LOAD 的"边写边传"特性

3.4.1 为什么调大 Buffer 参数效果有限?

核心原因:RecordBuffer 实现了 InputStream 接口,HTTP 线程通过 read() 方法读取数据。只要有数据可读,就会立即返回并发送,而不是等 Buffer 填满。

调大参数的实际效果

参数

调大后的效果

对吞吐量的影响

buffer-size

单个 ByteBuffer 更大

❌ 几乎无影响,因为不等填满就发

buffer-count

队列中 Buffer 更多

⚠️ 略有帮助,减少写入阻塞

Checkpoint 间隔

单次 Stream Load 时间更长

✅ 减少 2PC 提交开销

真正影响 STREAM_LOAD 吞吐的因素

实际吞吐 ≈ min(数据产生速率, 网络带宽, BE处理能力)

Buffer 参数主要是解耦生产和消费,防止相互阻塞,而不是用来"攒批提高效率"。

3.4.2 为什么要设计成"边写边传"?

原因一:支持 2PC 的需要

如果像 BATCH 一样攒批,一个 Checkpoint 周期内可能触发多次 Stream Load,每次都是独立事务,无法实现整个 Checkpoint 周期的原子性

原因二:内存可控

STREAM_LOAD 内存占用 = buffer-size × buffer-count
                    = 1MB × 3 = 3MB (固定)

BATCH 内存占用 = 实际数据量 (动态增长,可能很大)

STREAM_LOAD 通过流式传输,数据不在 Flink 端堆积

原因三:延迟更低

数据到达后立即发送,不等待攒批,端到端延迟更低。

3.5 DorisCommitter:两阶段提交执行器

DorisCommitter 负责在 Flink Checkpoint 完成后,向 Doris 发送事务提交请求。

// DorisCommitter.java 核心结构
public class DorisCommitter implements Committer<DorisCommittable>, Closeable {

    private static final String COMMIT_URL = "http://%s/api/%s/_stream_load_2pc";

    @Override
    public void commit(Collection<CommitRequest<DorisCommittable>> committables)
            throws IOException, InterruptedException {
        for (CommitRequest<DorisCommittable> request : committables) {
            commitTransaction(request.getCommittable());
        }
    }

    private void commitTransaction(DorisCommittable committable)
            throws IOException, InterruptedException {
        // PUT /api/{db}/_stream_load_2pc?txn_operation=commit&txn_id={txnId}
        HttpPutBuilder builder = new HttpPutBuilder()
                .setUrl(String.format(COMMIT_URL, 
                    committable.getHostPort(), committable.getDb()))
                .baseAuth(username, password)
                .addTxnId(committable.getTxnId())
                .setEmptyEntity()
                .addCommonHeader()
                .commit();  // txn_operation=commit

        int retry = 0;
        while (retry++ < maxRetries) {
            try (CloseableHttpResponse response = 
                    httpClient.execute(builder.build())) {
                if (handleCommitResponse(response)) {
                    return;  // 提交成功
                }
            } catch (Exception e) {
                LOG.warn("Commit failed, retry {}/{}", retry, maxRetries, e);
            }
            Thread.sleep(1000 * retry);  // 退避重试
        }
        throw new DorisException("Commit failed after " + maxRetries + " retries");
    }
}

3.6 两阶段提交完整时序图

四、STREAM_LOAD_BATCH 模式详解(攒批模式)

STREAM_LOAD_BATCH 模式通过内存攒批减少 HTTP 请求次数,提升写入吞吐量,但不支持 2PC,只能保证 At-Least-Once 语义。

4.1 核心设计差异

对比项

STREAM_LOAD

STREAM_LOAD_BATCH

数据传输

边写边传(Chunked 实时流式)

攒够再发(内存攒批后批量发送)

HTTP 连接

持续保持直到 Checkpoint

按需建立,发送完即关闭

缓冲区

RecordBuffer(固定大小循环复用)

BatchRecordBuffer(动态扩展)

发送时机

有数据就发

达到阈值才发

2PC 支持

✅ 支持

❌ 不支持

Buffer 调大效果

❌ 效果有限

显著提升吞吐

4.2 At-Least-Once 语义详解

BATCH 模式可以做到不丢数据,但做不到不重复数据

4.2.1 不丢数据的保证
sequenceDiagram
    participant Flink
    participant DorisBatchWriter
    participant Doris

    Note over Flink,Doris: 正常流程
    Flink->>DorisBatchWriter: write(data)
    DorisBatchWriter->>DorisBatchWriter: 攒批到 Buffer
    Flink->>DorisBatchWriter: prepareCommit (Checkpoint触发)
    DorisBatchWriter->>DorisBatchWriter: flush(waitUtilDone=true)
    DorisBatchWriter->>Doris: Stream Load (无2PC)
    Doris-->>DorisBatchWriter: 成功
    Note over Doris: 数据已持久化
    Flink->>Flink: Checkpoint 完成

关键点:Checkpoint 时会 flush(waitUtilDone=true)等待所有数据都发送成功才完成 Checkpoint。所以 Checkpoint 成功 = 数据一定已经写入 Doris。

4.2.2 为什么会重复?

原因:Label 机制无法去重

BATCH 模式的 Label 格式:

// DorisBatchStreamLoad.java
public String generateBatchLabel(String table) {
    // 每次都是新的 UUID,无法利用 Label 去重!
    return String.format("%s_%s_%s", labelPrefix, table, UUID.randomUUID());
}

而 STREAM_LOAD 模式的 Label 格式:

// LabelGenerator.java
public String generateTableLabel(String table, long checkpointId) {
    // 相同 checkpointId 生成相同 Label,Doris 会拒绝重复 Label
    return String.format("%s_%s_%d_%d", labelPrefix, table, subtaskId, checkpointId);
}

结论:BATCH 模式每次 flush 都是新 Label,故障恢复时 Doris 无法识别重复数据。

4.3 BATCH 模式架构图

4.4 DorisBatchStreamLoad:攒批执行器

// DorisBatchStreamLoad.java 核心结构
public class DorisBatchStreamLoad implements Serializable {

    // 每个表一个缓冲区
    private final Map<String, BatchRecordBuffer> bufferMap = 
        new ConcurrentHashMap<>();
    // 待发送队列
    private final BlockingQueue<BatchRecordBuffer> flushQueue;
    // 异步发送线程
    private LoadAsyncExecutor loadAsyncExecutor;
    // 当前缓存数据量(流量控制)
    private volatile long currentCacheBytes = 0L;
    private final long maxCacheBytes;

    // 写入记录
    public synchronized void writeRecord(String database, String table, 
            byte[] record) throws InterruptedException {
        checkFlushException();
        String bufferKey = getTableIdentifier(database, table);

        // 获取或创建该表的缓冲区
        BatchRecordBuffer buffer = bufferMap.computeIfAbsent(bufferKey,
                k -> new BatchRecordBuffer(database, table, lineDelimiter,
                        executionOptions.getBufferFlushMaxBytes()));

        // 流量控制:如果缓存超限,等待发送完成
        while (currentCacheBytes > maxCacheBytes) {
            LOG.info("Cache full, waiting...");
            Thread.sleep(1000);
            checkFlushException();
        }

        int recordSize = record.length + lineDelimiter.length;
        buffer.insert(record);
        currentCacheBytes += recordSize;

        // 判断是否需要触发 Flush
        if (buffer.getBufferSizeBytes() >= executionOptions.getBufferFlushMaxBytes()
                || buffer.getNumOfRecords() >= executionOptions.getBufferFlushMaxRows()) {
            flushBuffer(bufferKey, false);
        }
    }

    // Checkpoint 时强制刷新所有缓冲区
    public synchronized void flush(boolean waitUtilDone) throws InterruptedException {
        for (String bufferKey : new ArrayList<>(bufferMap.keySet())) {
            flushBuffer(bufferKey, false);
        }
        if (waitUtilDone) {
            waitAsyncLoadFinish();  // 等待所有发送完成
        }
    }
}

4.5 三种 Flush 触发条件

4.6 STREAM_LOAD_BATCH 时序图

五、COPY 模式详解(Stage 导入模式)

COPY 模式是一种批量导入方式,数据先上传到 Doris 的 Internal Stage(对象存储),然后通过 COPY INTO SQL 批量导入到表中。

5.1 COPY 模式工作原理

5.2 BatchStageLoad:Stage 上传执行器

// BatchStageLoad.java 核心结构
public class BatchStageLoad implements Serializable {

    private static final String UPLOAD_URL_PATTERN = "http://%s/copy/upload";

    // 每个表一个 Buffer
    private Map<String, BatchRecordBuffer> bufferMap = new ConcurrentHashMap<>();
    // 已上传的文件列表
    private Map<String, List<String>> fileListMap = new ConcurrentHashMap<>();
    // 当前 Checkpoint ID
    private long currentCheckpointID;
    // 文件计数器
    private AtomicInteger fileNum;

    // 写入记录
    public synchronized void writeRecord(String database, String table, 
            byte[] record) throws InterruptedException {
        String bufferKey = getTableIdentifier(database, table);
        BatchRecordBuffer buffer = bufferMap.computeIfAbsent(bufferKey,
                k -> new BatchRecordBuffer(database, table, lineDelimiter,
                        executionOptions.getBufferFlushMaxBytes()));

        buffer.insert(record);

        // Buffer 达到 80% 容量时触发上传
        if (buffer.getBufferSizeBytes() >= 
                executionOptions.getBufferFlushMaxBytes() * 0.8) {
            flush(bufferKey, false);
        }
    }

    // 上传到 Internal Stage
    public void uploadToStorage(String fileName, BatchRecordBuffer buffer) 
            throws IOException {
        // 1. 获取上传地址(FE 返回 307 重定向到对象存储)
        String address = getUploadAddress(fileName);

        // 2. 上传文件到对象存储
        HttpPutBuilder putBuilder = new HttpPutBuilder()
                .setUrl(address)
                .addCommonHeader()
                .setEntity(new ByteArrayEntity(
                        buffer.getData().array(), 0, buffer.getData().limit()));

        try (CloseableHttpResponse response = 
                httpClient.execute(putBuilder.build())) {
            // 处理响应
        }
    }

    // 获取已上传的文件列表
    public Map<String, List<String>> getFileListMap() {
        return fileListMap;
    }
}

5.3 CopySQLBuilder:COPY SQL 构建器

// CopySQLBuilder.java
public class CopySQLBuilder {

    public String buildCopySQL() {
        StringBuilder sb = new StringBuilder();

        // COPY INTO db.table FROM @~('{file1,file2,file3}')
        sb.append("COPY INTO ")
                .append(tableIdentifier)
                .append(" FROM @~('{")
                .append(String.join(",", fileList))
                .append("}') ")
                .append("PROPERTIES (");

        // 必须同步执行
        properties.put("copy.async", "false");

        // 拼接属性
        StringJoiner props = new StringJoiner(",");
        for (Map.Entry<Object, Object> entry : properties.entrySet()) {
            props.add(String.format("'%s'='%s'", 
                entry.getKey(), entry.getValue()));
        }
        sb.append(props).append(")");

        return sb.toString();
    }
}

// 生成的 SQL 示例:
// COPY INTO `db`.`table` FROM @~('{prefix_tbl_1_0,prefix_tbl_1_1}')
// PROPERTIES ('copy.async'='false', 'file.type'='json')

5.4 COPY 模式时序图

5.5 COPY 模式适用场景分析

COPY 模式的性能优势体现在超大批量离线导入场景:

场景

是否推荐

原因

实时日志写入

两次 IO 增加延迟

每日数仓同步(50亿条)

分片并行,容错好

历史数据迁移(TB级)

充分利用并行度

订单实时入库

不支持 2PC

六、故障恢复机制

6.1 Label 幂等性原理

Doris 的 Stream Load 使用 Label 机制保证幂等性。同一个 Label 只能成功导入一次,重复提交会返回 LABEL_ALREADY_EXIST

Label 格式对比

模式

Label 格式

示例

能否去重

STREAM_LOAD

{prefix}_{table}_{subtaskId}_{checkpointId}

doris_sink_orders_0_1001

✅ 相同 checkpointId 生成相同 Label

STREAM_LOAD_BATCH

{prefix}_{table}_{uuid}

doris_sink_orders_a1b2c3d4

❌ 每次 UUID 不同

6.2 故障恢复流程

// DorisStreamLoad.java
public void abortLingeringTransactions(String labelPrefix, long checkpointId) {
    // 从当前 checkpointId 向前检查,最多检查 5 个
    for (long i = checkpointId; i >= Math.max(0, checkpointId - 5); i--) {
        String label = labelGenerator.generateTableLabel(table, i);

        // 发送空请求检查 Label 状态
        RespContent response = checkLabelState(label);

        if ("LABEL_ALREADY_EXIST".equals(response.getStatus())) {
            // Label 已存在
            if ("FINISHED".equals(response.getExistingJobStatus())) {
                // 数据已提交成功,说明恢复点不对
                throw new DorisException(
                    "Label " + label + " already committed, " +
                    "please restore from a newer savepoint");
            } else {
                // 数据未提交,abort 该事务
                abortTransaction(response.getTxnId());
            }
        } else {
            // Label 不存在,abort 返回的 txnId(如果有)
            if (response.getTxnId() > 0) {
                abortTransaction(response.getTxnId());
            }
            break;  // 退出检查
        }
    }
}

6.3 Writer State 设计

DorisWriterState 保存了恢复所需的最小信息:

public class DorisWriterState implements Serializable {
    private String labelPrefix;  // Label 前缀
    private String database;     // 数据库名
    private String table;        // 表名
    private int subtaskId;       // 子任务 ID
}

七、监控指标

7.1 DorisWriteMetrics 指标定义

Flink Doris Connector 通过 DorisWriteMetrics 类提供丰富的监控指标:

指标类型

指标名称

说明

Counter

totalFlushLoadBytes

已发送字节数

Counter

totalFlushLoadedRows

已发送行数

Counter

totalFlushSucceededTimes

发送成功次数

Counter

totalFlushFailedTimes

发送失败次数

Histogram

beginTxnTimeHistogramMs

开启事务耗时分布

Histogram

commitAndPublishTimeHistogramMs

提交事务耗时分布

Histogram

streamLoadPutTimeHistogramMs

Stream Load 耗时分布

7.2 DorisWriteMetrics 源码解析

// DorisWriteMetrics.java
public class DorisWriteMetrics implements Serializable {

    private static final String DORIS_METRICS_GROUP = "doris";

    // Counter 计数器
    private transient Counter totalFlushLoadBytes;
    private transient Counter totalFlushLoadedRows;
    private transient Counter totalFlushSucceededTimes;
    private transient Counter totalFlushFailedTimes;

    // Histogram 直方图(耗时分布)
    private transient Histogram beginTxnTimeHistogramMs;
    private transient Histogram commitAndPublishTimeHistogramMs;
    private transient Histogram streamLoadPutTimeHistogramMs;

    public DorisWriteMetrics(MetricGroup metricGroup) {
        registerMetrics(metricGroup);
    }

    private void registerMetrics(MetricGroup metricGroup) {
        MetricGroup dorisGroup = metricGroup.addGroup(DORIS_METRICS_GROUP);

        // 注册 Counter
        this.totalFlushLoadBytes = dorisGroup.counter("totalFlushLoadBytes");
        this.totalFlushLoadedRows = dorisGroup.counter("totalFlushLoadedRows");
        this.totalFlushSucceededTimes = dorisGroup.counter("totalFlushSucceededTimes");
        this.totalFlushFailedTimes = dorisGroup.counter("totalFlushFailedTimes");

        // 注册 Histogram(使用 DescriptiveStatisticsHistogram 实现)
        this.beginTxnTimeHistogramMs = dorisGroup.histogram(
            "beginTxnTimeHistogramMs",
            new DescriptiveStatisticsHistogram(1000));  // 保留最近 1000 个样本
        this.commitAndPublishTimeHistogramMs = dorisGroup.histogram(
            "commitAndPublishTimeHistogramMs",
            new DescriptiveStatisticsHistogram(1000));
        this.streamLoadPutTimeHistogramMs = dorisGroup.histogram(
            "streamLoadPutTimeHistogramMs",
            new DescriptiveStatisticsHistogram(1000));
    }

    // ============ 更新指标的方法 ============

    /** 更新发送成功指标 */
    public void updateFlushSucceeded(long bytes, long rows) {
        if (totalFlushLoadBytes != null) {
            totalFlushLoadBytes.inc(bytes);
        }
        if (totalFlushLoadedRows != null) {
            totalFlushLoadedRows.inc(rows);
        }
        if (totalFlushSucceededTimes != null) {
            totalFlushSucceededTimes.inc();  // 成功次数 +1
        }
    }

    /** 更新发送失败指标 */
    public void updateFlushFailed() {
        if (totalFlushFailedTimes != null) {
            totalFlushFailedTimes.inc();  // 失败次数 +1
        }
    }

    /** 更新 Stream Load 耗时 */
    public void updateStreamLoadPutTime(long timeMs) {
        if (streamLoadPutTimeHistogramMs != null) {
            streamLoadPutTimeHistogramMs.update(timeMs);
        }
    }

    /** 更新事务开启耗时 */
    public void updateBeginTxnTime(long timeMs) {
        if (beginTxnTimeHistogramMs != null) {
            beginTxnTimeHistogramMs.update(timeMs);
        }
    }

    /** 更新事务提交耗时 */
    public void updateCommitAndPublishTime(long timeMs) {
        if (commitAndPublishTimeHistogramMs != null) {
            commitAndPublishTimeHistogramMs.update(timeMs);
        }
    }

    // ============ Getter 方法(用于测试或外部访问) ============

    public Counter getTotalFlushSucceededTimes() {
        return totalFlushSucceededTimes;
    }

    public Counter getTotalFlushFailedTimes() {
        return totalFlushFailedTimes;
    }

    public Counter getTotalFlushLoadBytes() {
        return totalFlushLoadBytes;
    }

    public Counter getTotalFlushLoadedRows() {
        return totalFlushLoadedRows;
    }
}

7.3 指标更新调用链

指标在 Stream Load 响应处理时更新,以下是调用链:

sequenceDiagram
    participant Writer as DorisWriter/DorisBatchWriter
    participant StreamLoad as DorisStreamLoad
    participant Metrics as DorisWriteMetrics

    Writer->>StreamLoad: stopLoad() / flush()
    StreamLoad->>StreamLoad: HTTP 请求完成
    StreamLoad->>StreamLoad: 解析 RespContent
    
    alt 发送成功
        StreamLoad->>Metrics: updateFlushSucceeded(bytes, rows)
        Note over Metrics: totalFlushLoadBytes += bytes<br/>totalFlushLoadedRows += rows<br/>totalFlushSucceededTimes++
    else 发送失败
        StreamLoad->>Metrics: updateFlushFailed()
        Note over Metrics: totalFlushFailedTimes++
    end
    
    StreamLoad->>Metrics: updateStreamLoadPutTime(耗时)
    Note over Metrics: 记录到直方图

DorisBatchStreamLoad 中的指标更新示例

// DorisBatchStreamLoad.java - LoadAsyncExecutor 内部类
private class LoadAsyncExecutor implements Runnable {
    @Override
    public void run() {
        while (!loadThreadStop) {
            try {
                BatchRecordBuffer buffer = flushQueue.poll(2, TimeUnit.SECONDS);
                if (buffer == null) continue;

                long startTime = System.currentTimeMillis();
                
                // 执行 Stream Load
                RespContent response = load(buffer.getDatabase(), 
                                           buffer.getTable(),
                                           buffer.getLabelName(),
                                           buffer);

                long loadTimeMs = System.currentTimeMillis() - startTime;

                // 根据响应更新指标
                if (response != null && response.isSuccess()) {
                    // 发送成功:更新字节数、行数、成功次数
                    dorisWriteMetrics.updateFlushSucceeded(
                        response.getLoadBytes(),    // 已发送字节数
                        response.getLoadedRows()    // 已发送行数
                    );
                    LOG.info("Stream Load success, label={}, rows={}, bytes={}, cost={}ms",
                        buffer.getLabelName(),
                        response.getLoadedRows(),
                        response.getLoadBytes(),
                        loadTimeMs);
                } else {
                    // 发送失败:更新失败次数
                    dorisWriteMetrics.updateFlushFailed();
                    LOG.error("Stream Load failed, label={}, msg={}",
                        buffer.getLabelName(),
                        response != null ? response.getMessage() : "null response");
                }

                // 更新 Stream Load 耗时直方图
                dorisWriteMetrics.updateStreamLoadPutTime(loadTimeMs);

            } catch (Exception e) {
                dorisWriteMetrics.updateFlushFailed();
                LOG.error("LoadAsyncExecutor error", e);
            }
        }
    }
}

7.4 如何获取监控指标值

方式一:通过 Flink REST API 获取
# 获取 Job 的所有指标
curl http://flink-jobmanager:8081/jobs/<job-id>/vertices/<vertex-id>/subtasks/metrics

# 获取特定指标(发送成功次数)
curl "http://flink-jobmanager:8081/jobs/<job-id>/vertices/<vertex-id>/subtasks/metrics?get=doris.totalFlushSucceededTimes"

# 响应示例
[
  {
    "id": "doris.totalFlushSucceededTimes",
    "value": "12345"
  }
]
方式二:通过 Prometheus + Grafana

flink-conf.yaml 配置

# 启用 Prometheus Reporter
metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory
metrics.reporter.prom.port: 9249

# 或使用 PushGateway
metrics.reporter.promgateway.factory.class: org.apache.flink.metrics.prometheus.PrometheusPushGatewayReporterFactory
metrics.reporter.promgateway.host: prometheus-pushgateway
metrics.reporter.promgateway.port: 9091
metrics.reporter.promgateway.jobName: flink-doris-job
metrics.reporter.promgateway.randomJobNameSuffix: true
metrics.reporter.promgateway.deleteOnShutdown: false

Prometheus 抓取配置

# prometheus.yml
scrape_configs:
  - job_name: 'flink'
    static_configs:
      - targets: ['flink-taskmanager-1:9249', 'flink-taskmanager-2:9249']
    metrics_path: /

Grafana 面板 PromQL 示例

# 发送成功次数(每秒增量)
rate(flink_taskmanager_job_task_operator_doris_totalFlushSucceededTimes[1m])

# 发送失败次数
flink_taskmanager_job_task_operator_doris_totalFlushFailedTimes

# 写入吞吐量(字节/秒)
rate(flink_taskmanager_job_task_operator_doris_totalFlushLoadBytes[1m])

# 写入行数速率(行/秒)
rate(flink_taskmanager_job_task_operator_doris_totalFlushLoadedRows[1m])

# Stream Load P99 延迟
histogram_quantile(0.99, 
  rate(flink_taskmanager_job_task_operator_doris_streamLoadPutTimeHistogramMs_bucket[5m]))
方式三:在代码中直接访问(用于自定义告警)
// 自定义 ProcessFunction 中获取 Doris Sink 指标
public class DorisMetricsAlertFunction extends ProcessFunction<String, String> {

    private transient Counter flushSucceededCounter;
    private transient Counter flushFailedCounter;

    @Override
    public void open(Configuration parameters) {
        // 获取 Doris 指标组
        MetricGroup dorisGroup = getRuntimeContext()
            .getMetricGroup()
            .addGroup("doris");

        // 获取已注册的 Counter(如果 Sink 和此 Function 在同一个 Task 中)
        // 注意:通常需要通过 REST API 或 Reporter 获取其他算子的指标
        this.flushSucceededCounter = dorisGroup.counter("totalFlushSucceededTimes");
        this.flushFailedCounter = dorisGroup.counter("totalFlushFailedTimes");
    }

    @Override
    public void processElement(String value, Context ctx, Collector<String> out) {
        // 业务逻辑...

        // 获取当前计数值(Counter 本身只能 inc,获取值需要其他方式)
        // 实际生产中建议通过 Prometheus/REST API 获取
    }
}

7.5 告警配置示例

Prometheus AlertManager 规则

# alert_rules.yml
groups:
  - name: flink-doris-alerts
    rules:
      # 发送失败告警
      - alert: DorisFlushFailed
        expr: increase(flink_taskmanager_job_task_operator_doris_totalFlushFailedTimes[5m]) > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Doris Sink 发送失败"
          description: "Job {{ $labels.job_name }} 在过去 5 分钟内有 {{ $value }} 次发送失败"

      # 写入吞吐量下降告警
      - alert: DorisThroughputDrop
        expr: rate(flink_taskmanager_job_task_operator_doris_totalFlushLoadBytes[5m]) < 1000000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Doris 写入吞吐量下降"
          description: "当前吞吐量 {{ $value | humanize }}B/s,低于 1MB/s"

      # Stream Load 延迟过高告警
      - alert: DorisStreamLoadSlow
        expr: histogram_quantile(0.99, rate(flink_taskmanager_job_task_operator_doris_streamLoadPutTimeHistogramMs_bucket[5m])) > 30000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Doris Stream Load P99 延迟过高"
          description: "P99 延迟 {{ $value | humanize }}ms,超过 30 秒"

八、性能测试数据

8.1 官方整库同步测试(Flink Doris Connector 1.4.0)

数据来源:Apache Doris 官方博客

测试环境

  • 1000 张 MySQL 表
  • 单表 100 字段
  • 均为活跃表(持续写入,每张表单次写入上百条)
  • Checkpoint 间隔:10 秒

测试结果

指标

数值

同步表数量

1000 张

数据延迟

秒级

系统稳定性

稳定

此外,部分社区用户在生产环境中已实现万表级别的整库同步,查询性能和系统稳定性表现出色。

8.2 高并发写入测试(Doris 1.1 版本)

数据来源:SelectDB 官方博客

测试场景:通用 Flink 高并发场景

  • 上游 Kafka 数据:每秒 10 万条
  • 要求数据 5 秒内完成同步
  • Flink 并发度:20
  • Checkpoint 间隔:5 秒

测试结果

指标

优化后表现

Compaction 合并效率

提升 10+ 倍

CPU 资源消耗

下降 25%

Tablet 版本数

稳定在 50 以下

数据可见延迟

< 5 秒

极限压测场景

  • 单 BE 单 Tablet
  • 客户端 30 并发极限 Stream Load
  • 数据实时性 < 1 秒

8.3 性能测试建议

由于官方未发布三种模式的横向对比 Benchmark,建议根据实际业务场景进行测试:

测试框架参考

测试项

测试方法

吞吐量

Flink Datagen 模拟数据,统计 totalFlushLoadBytes/s

延迟

对比 Kafka 消息时间戳与 Doris 写入时间

CPU/内存

监控 Flink TaskManager 和 Doris BE 资源占用

稳定性

长时间运行,观察 Compaction Score 和版本数

十、总结

10.1 三种模式完整对比

对比维度

STREAM_LOAD

STREAM_LOAD_BATCH

COPY

配置方式

默认

sink.enable.batch-mode=true

sink.write-mode=copy

Writer 类

DorisWriter

DorisBatchWriter

DorisCopyWriter

语义保证

Exactly-Once

At-Least-Once

At-Least-Once

数据传输

边写边传 (Chunked)

攒够再发 (内存攒批)

Stage + COPY SQL

去重机制

Label 幂等 + 2PC

❌ 无

❌ 无

Buffer 作用

解耦生产消费

攒批提高吞吐

攒批后上传

调大 Buffer 效果

❌ 效果有限

显著提升

✅ 有效

吞吐量

中等

最高(大批量)

延迟

中等

内存占用

小(固定)

大(动态)

中等

适用场景

金融、订单

日志、埋点

数仓同步

依赖

对象存储

10.2 核心设计思想

mindmap
  root((Flink Doris<br/>Connector))
    灵活的写入模式
      STREAM_LOAD: 2PC精确一次
      BATCH: 攒批高吞吐
      COPY: Stage超大批量
    可靠的事务保证
      Doris 2PC
      Flink Checkpoint
      Label幂等性
    高效的数据传输
      边写边传 vs 攒够再发
      RecordBuffer双队列
      异步发送+流量控制
    完善的故障恢复
      Writer State
      残留事务处理
      断点续传
    丰富的监控指标
      Counter计数
      Histogram分布
      Prometheus集成

10.3 选型建议速查表

你的需求

推荐模式

关键配置

数据绝对不能重复

STREAM_LOAD

sink.enable-2pc=true

追求最高吞吐

STREAM_LOAD_BATCH

buffer-flush.max-bytes=100MB

TB 级数据迁移

COPY

sink.write-mode=copy

秒级数据可见

STREAM_LOAD

checkpointing.interval=5s

分钟级延迟可接受

STREAM_LOAD_BATCH

buffer-flush.interval=60s

10.4 参考资料


作者说明:本文基于 Flink Doris Connector 源码分析编写,如有错误欢迎指正。

版权声明:本文为原创内容,转载请注明出处。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐