14.3 从 v2 到 v3 的演进

eventd v2 到 v3 的平滑演进:事件格式的向后兼容与字段改名处理、Kafka Topic 双线并行与灰度比例控制、指标比对验证、历史数据保留与回滚步骤,并给出按周推进的迁移时间线、常见兼容性陷阱,以及 v2 只读期的历史查询兜底与下线顺序。

为什么需要演进

eventd v2 是响应式 HTTP 服务,v3 是流处理管道。演进需求:

  • v2 已有业务逻辑,不能停机迁移
  • v3 需要消费 v2 产生的事件
  • 可能需要保留 v2 的某些接口

演进策略

阶段 1:并行运行(灰度)

v2 事件生产
    ├── Topic "eventd-raw" → v3 清洗 → v3 面板
    └── v2 数据库 → v2 API → v2 面板(保留)

用户:
    10% → v3 面板
    90% → v2 面板

阶段 2:数据验证

并行期持续 N 天(如 7 天):
    → 每天对比 v2 和 v3 的指标数据
    → 误差 < 1% → 继续
    → 误差 > 1% → 排查修正

阶段 3:完全切换

100% 流量 → v3 面板
v2 面板下线(或保留只读)
v2 API 保留(用于历史数据查询)

事件格式兼容

v2 事件 → v3 事件:新增字段,并把 timestamp 改名为 eventTime。新增字段可以给默认值,改名必须由下游显式兼容,不能算「向后兼容」。

v2 格式:

{
  "eventId": "uuid",
  "type": "ORDER",
  "userId": "alice",
  "amount": 100.0,
  "timestamp": 1700000000000
}

v3 格式(新增字段 + 一个字段改名):

{
  "eventId": "uuid",
  "type": "ORDER",
  "userId": "alice",
  "amount": 100.0,
  "eventTime": 1700000000000,
  "version": 1,
  "source": "eventd-v2",
  "symbol": "BTC",
  "status": 0,
  "ipAddress": null,
  "device": null,
  "metadata": {}
}
变化字段兼容处理
改名timestamp → eventTime下游必须显式兼容,不能算「向后兼容」
新增version / source缺省 1 / eventd-v2
新增symbol / status / ipAddress / devicesymbol 缺省 UNKNOWN,status 缺省 0,ipAddress 和 device 缺省 null
新增metadata缺省空 Map,端到端延迟等观测字段可放这里

清洗管道处理兼容性:

public class EventDeserializer {
    public Event deserialize(String json) {
        // 兼容 v2 格式(缺少 version/source/symbol)
        JsonNode node = JsonMapper.parse(json);

        return Event.builder()
            .eventId(node.get("eventId").asText())
            .type(node.get("type").asText())
            .userId(node.get("userId").asText())
            .amount(node.has("amount") ? node.get("amount").asDouble() : 0)
            .eventTime(node.has("eventTime") ? node.get("eventTime").asLong()
                        : node.get("timestamp").asLong())  // 兼容旧字段名
            .version(node.has("version") ? node.get("version").asInt() : 1)
            .source(node.has("source") ? node.get("source").asText() : "eventd-v2")
            .symbol(node.has("symbol") ? node.get("symbol").asText() : "UNKNOWN")
            .status(node.has("status") ? node.get("status").asInt() : 0)
            .build();
    }
}

双线并行切换

Kafka Topic 配置:

// v3 的 Kafka Source 可以消费多个 Topic
KafkaSource<Event> source = KafkaSource.<Event>builder()
    .setBootstrapServers("kafka:9092")
    .setTopics("eventd-raw-v2", "eventd-raw-v3")  // 两个 Topic
    .setGroupId("eventd-analytics")
    .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.EARLIEST))
    // 必须在同一个 schema 里兼容两种格式(timestamp/eventTime 回退),
    // 见上一节的 EventDeserializer;7.1 的 schema 只认单一种字段名
    .setValueOnlyDeserializer(new V2V3DeserializationSchema())
    .build();

或者使用两个独立的 Consumer Group(更灵活):

Group "eventd-cleansing-v2" → Topic "eventd-raw-v2"
Group "eventd-cleansing-v3" → Topic "eventd-raw-v3"

灰度比例控制

eventd v2 发送事件时控制比例:

public class EventPublisher {
    public Mono<Void> publish(Event event) {
        String json = JsonMapper.toJson(event);
        // Spring Kafka 3.x 的 KafkaTemplate.send 返回 CompletableFuture,
        // 没有 then();要先转成 Mono 再接入响应式链
        Mono<Void> toV2 = Mono.fromFuture(
            kafkaTemplate.send("eventd-raw-v2", event.userId(), json)).then();

        // 按 userId 做粘性灰度:同一个用户只落一侧。
        // 按自增计数器取模会让同一个用户的事件随机进两条线,面板数据对不上
        if (Math.floorMod(event.userId().hashCode(), 10) != 0) {
            return toV2;
        }

        // v2 的 Event 没有 source 字段,但 9.1 的构造器默认就是 "eventd-v2",
        // 这里不需要再补;清洗后 9.2 的 normalize 会把它改成 "eventd-v3"。
        // 如果确实要标记「经灰度通道进入」,用 metadata 加业务字段,
        // 不要复用 source——它表示的是来源系统,不是链路
        return Mono.fromFuture(
            kafkaTemplate.send("eventd-raw-v3", event.userId(), json)).then();
    }
}

这段代码是真正的分流:90% 用户只写 eventd-raw-v2,10% 用户只写 eventd-raw-v3。不要写成「先写 v2、再给 10% 额外写 v3」——那样 v2 仍处理 100% 流量,v3 只是影子流量,按用户切面板时 90% 和 10% 的边界并不存在。

代价是 v2 面板在这 10% 用户上会缺数据,面板侧必须按 userId 路由到对应版本,或者直接以 v3 面板为准。灰度比例按 userId 哈希固定还带来一个好处:回滚时把哈希为 0 的用户切回 v2 即可,受影响用户的范围是确定的,不用重新洗牌。

数据迁移

v2 的历史数据不需要迁移到 v3:

v2 数据库:保留历史数据,提供查询 API
v3 Redis:只存储实时数据(TTL 5 分钟)
v3 面板:只展示实时数据

如果用户需要历史趋势:
    → v3 面板调用 v2 API 获取历史
    → 或者 v3 的 Flink 作业同时写入长期存储(如 HDFS)

回滚策略

如果 v3 出现问题:

Step 1: 停止向 "eventd-raw-v3" 发送事件
    → v2 恢复 100% 流量

Step 2: v3 Flink 作业保持运行(为了排查)
    或 Cancel 从 Savepoint

Step 3: 面板切回 v2
    → 用户无感知(因为 v2 一直在运行)

Step 4: 修复 v3,重新灰度

演进时间线

Week 1: v3 开发完成,内部测试
Week 2: 5% 灰度,对比数据
Week 3: 20% 灰度,监控告警
Week 4: 50% 灰度,问题修复
Week 5: 100% 切换,v2 只读
Week 6: v2 下线(可选)

小结

阶段策略
格式兼容新增字段给默认值;timestamp → eventTime 改名需下游显式兼容
并行运行双线 Topic,灰度比例控制
数据验证对比 v2/v3 指标,误差 < 1%
回滚切回 v2 流量,v3 保留用于排查

第 14 章结束。进入附录:附录 A Flink DataStream API 速查 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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