记一次连接器改造经历:为什么我把 BigQuery Sink 从待处理流重构为缓冲流?

作者 | Doyeon Kim

在为 Apache SeaTunnel 开发 BigQuery Sink 连接器的过程中,我最初认为最大的挑战会很直接:确保数据能够正确写入 BigQuery。

但随着实现不断深入,我逐渐意识到,真正困难的问题并不只是调用 BigQuery API,而是如何将 BigQuery 的外部写入可见性模型SeaTunnel 的 checkpoint 和恢复生命周期对齐。

本文将总结我在使用 BigQuery 存储写入 API 过程中遇到的设计问题,包括为什么最初基于 Pending Stream(待处理数据流)的设计无法很好地支持 checkpoint 恢复,以及为什么最终重新设计批量写入流程,改用 Buffered Stream(缓冲流)。

初始设计:使用 Pending Stream 进行批量写入

BigQuery Storage Write API 提供了多种写入流类型。最开始,我认为 Pending Stream 是批量写入场景的自然选择。

整体流程如下:

Create PENDING stream
AppendRows
FinalizeWriteStream
BatchCommitWriteStreams

写入 Pending Stream 的数据,在调用 BatchCommitWriteStreams 之前对读取端不可见。由于 BigQuery 支持对多个 Pending Stream 进行原子提交,这种机制看起来非常适合基于 checkpoint 的 Sink 提交协议。

最初的实现大致如下:

@Override
public Optional<BigQueryCommitInfo> prepareCommit() {
    flush();
    streamWriter.finalizeStream();
    return Optional.of(new BigQueryCommitInfo(streamWriter.getStreamName()));
}

@Override
public List<Void> snapshotState(long checkpointId) {
    this.streamWriter.close();
    this.streamWriter = BigQueryBatchWriter.of(client, config);
    return Collections.emptyList();
}

乍看之下,这与两阶段提交协议(two-phase commit protocol)非常类似:

prepareCommit()  -> finalize stream
commit()         -> BatchCommitWriteStreams

然而,在代码 review 过程中,一个细微但关键的问题暴露出来。

问题:失败 checkpoint 后的所有权缺口

在 SeaTunnel 的 Sink 生命周期中,prepareCommit() 会在 checkpoint 完全完成之前被调用。

问题在于,prepareCommit() 已经通过调用 FinalizeWriteStream 对外部系统产生了副作用。

考虑以下场景:

1. Checkpoint N 开始。
2. prepareCommit() finalize stream A。
3. 创建 commitInfo(stream A)。
4. 在 checkpoint N 完成之前任务失败。
5. 作业从最近一次成功完成的 checkpoint N-1 恢复。

恢复之后,restoreWriter(states) 接收到的只有最近一次成功 checkpoint 保存的状态。失败 checkpoint 的状态会被丢弃。

这意味着,在失败 checkpoint N 中被 finalize 的 stream A,并不会出现在恢复状态中。

这正是问题的核心:

FinalizeWriteStream 已经被调用,
但 checkpoint 并没有持久化记录这个 finalized stream
之后应该被提交、放弃还是清理。

最开始,我认为由于 Pending Stream 中的数据在 BatchCommitWriteStreams 调用之前不可见,因此直接放弃该 stream 应该是可以接受的。

但这个解释并不足以形成清晰的 checkpoint 恢复语义。

如果希望 Connector 具备明确的恢复逻辑,就必须对外部副作用建立更加清晰的管理机制。

为什么 restoreWriter() 不应该提交失败 checkpoint 中的 Stream

一个看似简单的解决方案,是在恢复过程中发现并提交已经确认/完成的 Pending Stream。

但这种方式并不安全。

原因在于,恢复操作始终从最近一次成功完成的 checkpoint N-1 开始。因此,在 checkpoint N 期间处理的数据,在恢复之后可能会被重新消费。

如果 restoreWriter() 发现 stream A 并提交,可能出现以下情况:

stream A 中的数据变为 BigQuery 可见
+
任务从 checkpoint N-1 恢复后重新处理相同数据
+
重复写入这些数据,并在之后再次提交

最终可能导致重复数据。

因此,关键问题在于:

restoreWriter() 只能接收到成功 checkpoint 保存的状态。
失败 checkpoint 中 finalize 的 stream 并不属于恢复状态。
如果在恢复过程中提交这样的外部 stream,
可能提前发布即将被引擎重新处理的数据。

因此,不应该盲目提交在失败 checkpoint 中完成/确认的 stream。

这里的问题并不是 Pending Stream 一定会导致重复数据,而是 Connector 对于“已经完成/确认但尚未提交、且来自失败 checkpoint 的 stream”缺少持久化决策。

重新设计:切换到 Buffered Stream

为了避免这一所有权缺口,我重新设计了批量写入流程,使用 Buffered Stream 替代 Pending Stream。

Buffered Stream 不通过 finalize + batch commit 的方式提交整个 stream,而是通过指定 stream offset(流偏移量)控制数据可见范围。

整体流程如下:

Create BUFFERED stream
AppendRows(offset=N)
FlushRows(offset=M)

写入 Buffered Stream 的数据不会立即可见,而是在调用 FlushRows 后,根据指定 offset 更新可见范围。

这种模型与基于 checkpoint 的恢复机制更加匹配。

Checkpoint 表示计算引擎当前处理位置,而 Buffered Stream offset 表示 BigQuery 中的外部写入位置。

新的设计如下:

writer state:
  streamName
  nextOffset
  checkpointId

prepareCommit(checkpointId):
  flush()
  return BigQueryCommitInfo(streamName, flushOffset = nextOffset - 1)

snapshotState(checkpointId):
  return BigQuerySinkState(streamName, nextOffset, checkpointId)

commit(commitInfo):
  FlushRows(streamName, flushOffset)

restoreWriter(states):
  select the latest completed checkpoint state
  restore writer with streamName + nextOffset

此时,外部写入位置用 streamName + nextOffset表示。

该位置会被保存到 checkpoint 状态中。恢复之后,Writer 可以从最近一次成功 checkpoint 对应的外部写入位置继续执行。

理解 State 与 CommitInfo 的区别

这次改造中,一个重要的认识是:Writer State(写入状态)和 CommitInfo(提交信息)相关,但它们承担的职责不同。

Writer State 表示恢复之后 Writer 应该从哪里继续追加数据。

例如:

BigQuerySinkState {
    String streamName;
    long nextOffset;
    long checkpointId;
}

CommitInfo 表示 checkpoint 完成后,哪些数据应该在 BigQuery 中变得可见。

例如:

BigQueryCommitInfo {
    String streamName;
    long flushOffset;
}

举例来说:

writer state:
  streamName = S
  nextOffset = 100

commit info:
  streamName = S
  flushOffset = 99

它表示:

Writer 已经追加数据到 offset 99。
如果 checkpoint 成功完成,
BigQuery 应该 Flush 到 offset 99。
如果任务从该 checkpoint 恢复,
Writer 应该从 offset 100 开始继续写入。

这种状态与提交信息的分离,让整个恢复模型更加清晰。

正确管理 Offset

Buffered Stream 设计中最重要的细节,就是 offset 管理。

Offset 不是记录 ID,也不是主键,更不是 checkpoint ID。

Offset 表示的是某个 BigQuery Write Stream 内部的数据追加位置。

如果存在多个并行 Writer,每个 Writer 应该拥有独立的 Stream,并分别维护自己的 offset。

例如:

writer-0:
  stream S0
  nextOffset = 100

writer-1:
  stream S1
  nextOffset = 250

即使当前 checkpoint ID 是 10,BigQuery append offset 也不应该是 10。

Checkpoint ID 只是用于标识 checkpoint 状态,而 BigQuery offset 必须代表对应 stream 中的数据追加位置。

因此:

checkpointId:
  用于识别和选择 checkpoint 状态

nextOffset:
  作为 BigQuery Stream 下一次追加位置

flushOffset:
  nextOffset - 1,用于 FlushRows

这一点在 Sink 存在并行执行时尤其重要。

Committer 使用 FlushRows 替代 BatchCommitWriteStreams

在 Pending Stream 方案中,Committer 使用BatchCommitWriteStreams;而在 Buffered Stream 方案中,Committer 改为使用FlushRows。实现如下:

FlushRowsRequest request =
        FlushRowsRequest.newBuilder()
                .setWriteStream(info.getStreamName())
                .setOffset(Int64Value.of(info.getFlushOffset()))
                .build();

FlushRowsResponse response = client.flushRows(request);

该操作会使指定 offset 之前的数据对读取端可见。

因此,在 checkpoint 成功完成后,Committer 会根据 checkpoint 对应的 flushOffset 推进 BigQuery 数据可见范围。

保持 CDC 路径独立

此次改造只针对批量写入路径。

我没有将 CDC 路径改造成 Buffered Stream,因为 BigQuery CDC 数据导入具有自身的数据语义,包括 _CHANGE_TYPE_CHANGE_SEQUENCE_NUMBER 以及 primary key 处理逻辑。

CDC 不应该被强制纳入与批量写入相同的 checkpoint-offset 模型。

最终结构如下:

batch mode:
  Buffered Stream
  streamName + nextOffset 保存在 checkpoint state 中
  commit 时执行 FlushRows

cdc / streaming mode:
  保持现有 streaming path
  遵循 BigQuery CDC 语义

这样既保证了批量写入恢复模型的清晰性,也避免对 CDC 行为进行不必要调整。

测试挑战

这一改造依赖真实的 BigQuery Storage Write API 行为,尤其是 Buffered Stream、显式 offset 以及 FlushRows 的实际语义。

本地模拟环境无法可靠验证这些行为,因此我在真实 BigQuery 环境中测试了更新后的批量写入流程。

验证场景包括:

- 创建 Buffered Stream
- 使用显式 offset 写入数据
- 开启 checkpoint 执行批量写入
- 验证 FlushRows 后数据是否在 BigQuery 中可见

完整、确定性的失败恢复端到端测试更加困难。

它需要精确控制 checkpoint barrier 时间,在特定节点注入故障,恢复任务,并验证 BigQuery 数据可见性以及是否产生重复数据。

对于自动化测试,更实际的方式是覆盖可恢复元数据路径:

- BigQuerySinkState 序列化
- 根据 checkpointId 选择最新状态
- 仅在 append 成功后推进 nextOffset
- 创建 FlushRows commit info

这些测试无法替代完整失败恢复 E2E 测试,但能够验证最关键的恢复元数据逻辑。

总结:从这次改造中获得的经验

关于这次改造,我最大的收获是:

Connector 的 Exactly-Once 语义,并不仅仅取决于是否调用了一个 commit API。

一个 Connector 必须回答以下问题:

1. 数据什么时候在外部系统中可见?
2. checkpoint 失败后,外部副作用如何处理?
3. Writer 恢复时应该从哪个外部写入位置继续?
4. Writer State 表示什么?
5. CommitInfo 表示什么?
6. append 失败并重试时,offset 应该如何变化?

最初的 Pending Stream 设计,从 BigQuery 批量提交角度来看非常水到渠成。

但结合 SeaTunnel 的 checkpoint 和恢复生命周期后,它暴露出了失败 checkpoint 下 finalized stream 的所有权问题。

Buffered Stream 方案虽然实现更加复杂,但通过将外部写入位置保存为streamName + offset,提供了更加清晰的恢复模型。

结语

这项工作并不仅仅是增加一个 BigQuery Sink Connector。更重要的是,让外部系统的数据可见性模型,与流处理引擎的 checkpoint 生命周期保持一致。

最初的设计并不完美,但通过代码 review,我们发现了一个重要的故障场景。通过重新理解 BigQuery 不同 Stream 类型的语义,并围绕 Buffered Stream 重新设计批量写入流程,Connector 的恢复语义变得更加清晰。

开源代码 review 有时会带来压力,但它也促使我们跳出“正常流程能够运行”的思维,进一步思考系统边界、失败窗口以及可靠性保证。

对于我来说,这次 BigQuery Sink 的改造正是这样一次经历。

posted @ 2026-09-21 22:01  ApacheSeaTunnel  阅读(8)  评论(0)    收藏  举报