声明:本次设计仅考虑 Kafka,其他消息队列本人了解程度不高故不作考虑。
背景
部门维护的平台有一个核心模块,其本质是以 Kafka 为消息队列的实时流式系统,一旦链路中某个环节出现问题,都有可能产生不可挽回的后果。希望能有一个流量回放的机制,出现问题后能在一定程度上挽回回来,提高系统的健壮性和可用性。
目标
支持回放某一时间段的流量,使其重新进行归因或回传逻辑。
调研
Kafka
整个归因回传系统主要依赖 Kafka 来进行数据流转,所以流量回放的核心在于如何重新消费来自 Kafka 的消息。
因此调研了一下 Kafka 如何从指定时间开始消费消息,下面贴出Demo 代码:
ini
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "my-group-1");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "10000");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
String topicName = "my-topic";
// 消费指定分区
consumer.assign(Arrays.asList(new TopicPartition(topicName, 0)));
Map<TopicPartition, Long> map = new HashMap<>();
map.put(new TopicPartition(topicName, 0), 1669888189952L);
// 根据时间戳查找 offset
// 如果指定的时间戳早于分区的第一条消息,那么返回分区的第一条消息的 offset;如果指定的时间戳晚于分区的最后一条消息,那么返回 null。
Map<TopicPartition, OffsetAndTimestamp> topicPartitionOffsetAndTimestampMap = consumer.offsetsForTimes(map);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : topicPartitionOffsetAndTimestampMap.entrySet()) {
TopicPartition topicPartition = entry.getKey();
OffsetAndTimestamp offsetAndTimestamp = entry.getValue();
System.out.println(topicPartition + ": " + offsetAndTimestamp);
if (offsetAndTimestamp != null) {
// 设置分区的消费 offset
consumer.seek(topicPartition, offsetAndTimestamp.offset());
}
}
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("partition: %s, offset: %s, timestamp: %s, value: %s%n", record.partition(), record.offset(), record.timestamp(), record.value());
}
}
}
技术方案

蓝色部分是消息的正常处理流程,黄色部分是流量回放准备阶段,绿色部分是流量回放执行阶段
流量回放分为两个阶段,分别是准备阶段和执行阶段。
准备阶段
根据回放的时间段,转化为具体的 offset,进而读取到相应的范围的消息,消息经过一系列的 ETL 处理后,写入到专门用于流量回放的 Topic 里。
- ETL:在这里可以进行更细粒度、更精准化的筛选,比如只希望回放xx应用的流程。
- 专门用于流量回放的 Topic:与原 Topic 分隔开,不污染原 Topic,有助于使整个流量回放过程更安全、灵活。
执行阶段
流量回放数据准备完毕后,就进入到执行阶段,读取流量回放数据并最终交给原消息处理器处理。
-
流量回放调度器:用于监听流量回放任务,通知进度等,发现有新任务后,交给流量回放执行器来处理。
实现选型:Zookeeper or 轮询数据库?
-
流量回放执行器:用于维护整个流量回放任务的生命周期,根据配置读取回放消息,经过流量回放限流器后,最后交给消息处理器处理。
-
流量回放限流器:限流的原因有两点,一是减少流量回放对服务的压力,二是如果回传频率过快的话,媒体可能会认为作假。
限流器选型:本地 or Redis?因为在当前需求场景下不需要精准的全局限流,所以不需要依靠 Redis 等外部服务来做限流,使用本地限流即可。
其他
- 公司目前默认 Kafka Topic 消息最多保留7天,接入时要注意具体的 Kafka Topic 消息的有效期。
- 回放逻辑必须保证幂等。
代码设计
整体设计

整体设计分为3层,分别是流量回放 Server 层、流量回放 Client 层、业务层。
-
业务层:订阅来自 Kafka topic 的消息,处理业务逻辑,这一层是原来存在的,不在流量回放框架的范畴里。
-
流量回放 Server 层:接收流量回放请求,生成流量回放任务,管理整个流量回放任务的生命周期,并把状态变更通知给 Client 层。
- Server:流量回放框架对外的入口,生成流量回放任务后,把流量回放任务交给 Task Controller Manager 控制和管理。
- Task Controller Manager:维护单个流量回放任务的生命周期,把任务状态变更通知给外部。
- 流量回放任务状态包括:已创建、已初始化、进行中、已完成、已终止(代表任务未完成强制终止)。
-
流量回放 Client 层:流量回放任务初始化完成后,就进入到
进行中
的状态,此时 Client 层监听到就进入正式的流量回放工作中。其中 Client 层有两个流程,分别是读取原始 topic 消息到中转 topic 和读取中转 topic 消息用于业务逻辑处理,所以,把 Client 层分为两个不同的类型,负责不同的流程。-
Source Channel Client:监听需要从原始 topic 读取消息到 中转 topic 的任务,然后交给 Task Source Channel Handler 处理。
- Task Source Channel Handler:负责从原始 topic 读取消息,经过 Message Processor Chain 处理后,写入到中转 topic。
-
Transfer Channel Client:监听需要从中转 topic 读取消息到实际业务逻辑的任务,然后交给 Task Transfer Channel Handler 处理。
- Task Transfer Channel Handler:负责从中转 topic 读取消息,经过 Message Processor Chain 处理后,调用实际业务逻辑。
-
Message Processor Chain:消息处理链,用户可以根据具体情况进行一些数据加工或筛选处理。
-
类设计
TODO 待补充
优化点
- 增加一种回放模式------把中转通道去掉,直接原始通道-->业务逻辑。
- 流量回放任务增加暂停、恢复操作。
- 目前的限流实现是本地限流,基于令牌桶算法,可以提供多种限流方式,例如全局限流。
- 目前使用 MySQL + 轮询来做状态监听,使用 Zookeeper 会更丝滑。