11.1 端到端 Exactly-Once

eventd v3 的端到端 Exactly-Once 保证:Kafka Source 的可重放 offset、Flink Checkpoint 的一致快照与 Sink 侧 2PC 的区别、Redis Sink 的确定性 key 幂等写入,以及这套组合成立的前提与失效边界。

端到端一致性的三层模型

eventd v3 的数据流涉及三个外部系统:

Kafka (eventd-raw) → Kafka Streams → Kafka (eventd-cleaned)
                           │
                           ↓
                   Flink DataStream → Redis

端到端一致性涉及三层:

层系统一致性机制
SourceKafkaoffset 随 Checkpoint 提交(可重放)
InternalFlinkCheckpoint 一致快照(barrier 对齐)
SinkRedis幂等写入(确定性 key)

三层的机制不是同一回事:Source 靠「可重放的位点」,Flink 内部靠「一致快照回滚」,Sink 靠「重复写不出错」。端到端 EOS 是由这三件事拼出来的,而不是某处配一个开关。

Kafka Source 的 Exactly-Once

KafkaSource 没有 EXACTLY_ONCE 这样的模式参数。它的语义由两件事决定:offset 只在 Checkpoint 完成时提交;消费位置能从 Checkpoint 恢复并重读。

KafkaSource<Event> source = KafkaSource.<Event>builder()
    .setBootstrapServers("kafka:9092")
    .setTopics("eventd-cleaned")
    .setGroupId("eventd-analytics")
    .setStartingOffsets(OffsetsInitializer.earliest())
    // 不设反序列化器时 build() 会直接抛异常;定义见 7.1
    .setValueOnlyDeserializer(new EventDeserializationSchema())
    // 默认 true:offset 随 Checkpoint 提交;设成 false 就只能靠 group 自动提交,
    // 那样 offset 与状态快照对不上,恢复会丢或重
    .setProperty("commit.offsets.on.checkpoint", "true")
    // 上游是 Kafka Streams EOS 写出的事务消息时才需要;普通 topic 加不加都一样
    .setProperty("isolation.level", "read_committed")
    .build();

Kafka Streams 的 EOS 配置(eventd-cleansing):

props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);

这意味着:

  1. Kafka Streams 处理完一批数据后,原子性提交 offset 和生产输出
  2. 消费者只读取已提交的事务消息
  3. 故障时重放:从最后提交的 offset 开始,确保不丢不重

Checkpoint:内部一致快照

Flink 内部的 EOS 来自 Chandy-Lamport 式的 barrier 对齐,不是 2PC:

JobManager 周期性插入 barrier
    Source: 快照 offset,barrier 顺流下发
    Map/Window: 收到 barrier 时快照本地状态,向下转发
    Sink: 收到 barrier 时快照自身状态

全部子任务确认 → Checkpoint 完成 → Source 提交这批 offset
任一步骤失败 → 本次 Checkpoint 丢弃 → 作业回滚到最后一次完成的快照

注意 Sink 在这里只快照自己的状态,并不保证对外部系统写入的原子性。外部系统的语义由 Sink 自己决定,见下一节。

只有在 Sink 实现了 TwoPhaseCommitSinkFunction(或 Sink V2 的预提交协议,如 KafkaSink 的 DeliveryGuarantee.EXACTLY_ONCE)时,才会出现真正的事务两阶段提交:barrier 到达时开启事务并缓存写入,Checkpoint 完成时提交事务,作业回滚时丢弃(abort)未提交事务。Redis 没有这种 Sink,所以 eventd v3 的 Redis 侧只能走幂等。

配置:

env.enableCheckpointing(30000);
env.getCheckpointConfig().setCheckpointingConsistencyMode(
    org.apache.flink.core.execution.CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(10000);

Redis Sink 的幂等写入

Redis 没有事务型 Sink,eventd v3 用确定性 key + 覆盖写让重复写无害:

public class IdempotentRedisSink
        extends RichSinkFunction<AnalyticsJob.MetricRecord> {
    // 字段名与 9.3 的 MetricRecord(metric, dimension, dimensionValue,
    // windowStart, windowEnd, payload) 保持一致
    private transient RedisClient client;
    private transient StatefulRedisConnection<String, String> connection;
    private transient RedisCommands<String, String> sync;

    @Override
    public void open(OpenContext openContext) {
        client = RedisClient.create("redis://redis:6379");
        connection = client.connect();
        sync = connection.sync();
    }

    @Override
    public void invoke(AnalyticsJob.MetricRecord record, Context context) {
        // key 由 (指标, 窗口起点, 维度值) 唯一确定:同一窗口重放 → 同一个 key
        String key = String.format("eventd:metrics:%s:%d:%s",
            record.metric(), record.windowStart(), record.dimensionValue());
        sync.setex(key, 300, record.payload());
    }

    @Override
    public void close() {
        if (connection != null) {
            connection.close();
        }
        if (client != null) {
            client.shutdown();
        }
    }
}

为什么 SET 是幂等的?

因为 key 与 value 都由窗口内容唯一确定:同一个窗口重放时算出同一个 key、同一个值,覆盖写不会留下第二条记录。这依赖两个前提,缺一不可:

  1. 窗口结果是确定性的。用了 now()、随机数、当前汇率等运行时输入,重算结果就会不同。
  2. 输出只按窗口键落盘,不追加到列表。

「latest 指针」不满足这两条,所以不要天真地覆盖:

// 反例:重放旧窗口时会把 latest 指针写回过去
sync.set("eventd:metrics:" + record.metric() + ":latest", record.payload());

要让 latest 也幂等,需要在写之前比较窗口起点。payload 里只有聚合值(如 {"count":42}),没有窗口起点,所以把窗口起点单独存一个键,再用 Lua 把「比较 + 写」做成原子操作:

-- KEYS[1] = latest payload,KEYS[2] = latest 的 windowStart
-- ARGV[1] = 本次 windowStart,ARGV[2] = 本次 payload
local cur = redis.call('GET', KEYS[2])
if cur == false or tonumber(cur) <= tonumber(ARGV[1]) then
  redis.call('SET', KEYS[1], ARGV[2])
  redis.call('SET', KEYS[2], ARGV[1])
  return 1
end
return 0
sync.eval(
    "local cur = redis.call('GET', KEYS[2]) " +
    "if cur == false or tonumber(cur) <= tonumber(ARGV[1]) then " +
    "  redis.call('SET', KEYS[1], ARGV[2]) " +
    "  redis.call('SET', KEYS[2], ARGV[1]) return 1 end return 0",
    ScriptOutputType.INTEGER,
    new String[]{
        "eventd:metrics:" + record.metric() + ":latest",
        "eventd:metrics:" + record.metric() + ":latest:windowStart"},
    String.valueOf(record.windowStart()),
    record.payload());

两个键在同一个 Lua 脚本里读写,Redis 单线程执行脚本,所以不会出现「payload 更新了但 windowStart 没更新」的中间态。

读到与判定的原子性由单条 Lua 保证;跨多个 key 的更新仍然不是事务,Redis 没有跨 key 的回滚。

幂等性设计原则

eventd v3 各组件的幂等策略:

组件操作幂等策略
Kafka Streams 去重put(eventId, now)key = eventId(唯一);value 是首次见到的时间,供 TTL 清理
Flink 窗口聚合增量累加状态在 Checkpoint 里,恢复后从快照继续
Redis SinkSETEX keykey 由窗口内容确定,重复覆盖无害

故障恢复能测到什么

在本地 createLocalEnvironment 里跑「抛异常 → 期待自动重启」是测不出 EOS 的:

  • 本地执行器的异常会一路抛到 execute(),作业直接失败退出;
  • 即便用 MiniCluster,重启也要靠 restart-strategy 生效并重新从 Checkpoint 拉取 Source,而集合源(fromCollection)没有 offset 状态,重放行为与 Kafka 完全不同;
  • 最关键的是,测试里必须有一个能被重复写入并检查副作用的外部系统,否则即使真的重放了,你也看不到「重复」。

所以 eventd v3 的恢复测试分两层做:

层怎么测能证明什么
幂等 key 单元测试对同一 MetricRecord 调两次 keyOf(record),断言 key 相等key 设计确定,覆盖写是无害的
端到端重放测试Testcontainers 起 Kafka + Redis,作业跑完 Checkpoint 后 kill TaskManager,重启后统计 Redis 里的键数重放之后没有多余键

第一层是纯函数测试,跑得飞快:

// 与 IdempotentRedisSink.invoke 里的格式化规则保持一致;
// 生产代码可以把这段抽成 MetricRecord 的静态方法,测试直接复用
private static String keyOf(MetricRecord record) {
    return String.format("eventd:metrics:%s:%d:%s",
        record.metric(), record.windowStart(), record.dimensionValue());
}

@Test
void sameWindowProducesSameKey() {
    // 两个内容相同但实例不同的记录,模拟「重放算出同一个窗口」
    MetricRecord first = new MetricRecord(
        "order-count", "symbol", "BTC", 1_700_000_000_000L,
        1_700_000_060_000L, "{\"count\":42}");
    MetricRecord replay = new MetricRecord(
        "order-count", "symbol", "BTC", 1_700_000_000_000L,
        1_700_000_060_000L, "{\"count\":42}");

    assertEquals(keyOf(first), keyOf(replay));
    assertEquals("eventd:metrics:order-count:1700000000000:BTC", keyOf(first));
}

第二层需要真实集群和真实外部系统,才能观察「重复写到底有没有副作用」。它的断言不是「作业没报错」,而是「Redis 里键的数量等于唯一窗口数」——重复写不会增加键数,这才是幂等生效的证据。这类测试放在 CI 的集成阶段,而不是每个 mvn test 都跑。

端到端一致性检查清单

□ Kafka Source: commit.offsets.on.checkpoint=true(默认),确有必要才加 read_committed
□ Kafka Streams: EXACTLY_ONCE_V2 配置
□ Flink Checkpoint: EXACTLY_ONCE 模式 + 合理间隔
□ Flink RestartStrategy: 自动从 Checkpoint 恢复
□ Redis Sink: 幂等 key 设计(确定性 key)
□ 窗口计算: 确定性(相同输入 → 相同输出)
□ 去重逻辑: 基于 eventId(唯一标识)

语义降级

某些场景下可以接受 AT_LEAST_ONCE:

场景推荐语义原因
实时面板指标AT_LEAST_ONCE偶尔重复不影响趋势判断
告警触发EXACTLY_ONCE重复告警会干扰
计费统计EXACTLY_ONCE不能重复计费
日志聚合AT_LEAST_ONCE重复日志可去重后处理

降级配置:

// AT_LEAST_ONCE:性能更好,但不保证不重复
env.getCheckpointConfig().setCheckpointingConsistencyMode(
    org.apache.flink.core.execution.CheckpointingMode.AT_LEAST_ONCE);

小结

组件一致性机制
Kafka Source可重放offset 随 Checkpoint 提交 + read_committed
Flink 内部EXACTLY_ONCEbarrier 对齐的一致快照
Redis Sink幂等确定性 key + 覆盖写
整体端到端 EOS(有前提)三层叠加,且窗口结果必须确定

最后一行的「有前提」不能省:Redis 侧没有任何回滚机制,所谓端到端 EOS 实际是「重放不会改变最终结果」。一旦窗口结果依赖运行时不变量以外的东西,或者用了 latest 这类非确定性键,语义就退化成了至少一次。

下一节 11.2 多流 Join 与关联分析 :Interval Join、CoProcessFunction。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南