业务查询 Doris 实时表 清洗服务 Kafka 原始队列 上游数据源 业务查询 Doris 实时表 清洗服务 Kafka 原始队列 上游数据源 数据流入正常 状态1:理想流控 状态2:流控失效开始 Kafka Consumer自动追赶 导致消息积压瞬间涌入 状态3:级联失败 Doris内存超限 BE节点OOM loop [失控写入] 状态4:服务崩溃 状态5:数据积压 状态6:业务影响 写入原始数据 (1K TPS) 均匀拉取 (C控制速率) 清洗后批量写入 偶发GC暂停2-3秒 生成大批量INSERT 写入失败,连接断开 数据库连接池耗尽 线程阻塞 服务宕机 数据持续堆积(>100万条) 查询超时/无结果
溯源数据清洗:一次由"可控"到"失控"的复盘
动机:为什么必须介入清洗
凌晨两点,我盯着监控大盘,那个代表数据延迟的红色曲线像心电图一样剧烈跳动。我们的溯源系统要求数据在10分钟内可查,但现在延迟已经超过两小时。上游是七个不同的业务系统,通过Kafka推送原始数据,格式混乱:同一个字段,有的用product_id,有的用productId;日期格式从yyyy-MM-dd到时间戳都有;甚至还有JSON里套XML的奇葩结构。
当时我面对的具体场景是:
- 时间约束:产品要求三天后上线新版本,清洗逻辑必须提前跑通
- 资源约束:只有一台8核16G的测试服务器,生产环境配置未定
- 数据约束:单日数据量约2000万条,峰值TPS 1K
- 质量约束:下游Doris数据库对数据规范性要求极高,乱数据会直接导致导入失败
我坚信的前提很简单:只要控制好消费速度,单机服务完全能处理这个量级。过去几年做支付渠道接入时,我用同样的思路处理过更复杂的对账流水,从没出过问题。
决策:用最熟悉的方式开局
我画了张流程图,决定采用经典的三层架构:
清洗服务内部逻辑
流控层
手动控制poll速率
解析层
正则/规则引擎
转换层
统一格式
写入层
批量提交
Kafka原始数据
Doris实时表
错误数据降级存储
监控告警
人工干预接口
这个设计的核心控制点在于流控层。我放弃了使用Kafka原生限流或Spark Streaming的方案,原因很现实:
- 时间不够:引入新框架至少要两天学习调试
- 历史包袱:团队里只有几个人熟悉Java生态,其他人主要写Python、C++
- 自信判断:我认为手动控制poll速率+批量写入足够稳定,这在之前的消息推送系统里验证过
当时的代码实现是这样的:
java
// 第一版清洗核心逻辑 - 手动控制消费
@Component
public class TraceDataCleaner {
@Autowired
private DorisWriter dorisWriter;
// 当时我认为的关键控制点:手动控制拉取间隔
private volatile long lastPollTime = 0;
private final long POLL_INTERVAL_MS = 100; // 100ms拉取一次
@KafkaListener(topics = "${kafka.topic.raw}")
public void cleanAndWrite(ConsumerRecord<String, String> record) {
// 控制消费速率
long now = System.currentTimeMillis();
if (now - lastPollTime < POLL_INTERVAL_MS) {
try {
Thread.sleep(POLL_INTERVAL_MS - (now - lastPollTime));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
lastPollTime = System.currentTimeMillis();
try {
// 解析数据
TraceData data = parseData(record.value());
// 清洗逻辑
if (!validateRequiredFields(data)) {
sendToDeadLetterQueue(record);
return;
}
// 格式标准化
standardizeFields(data);
// 批量写入(攒够100条或超时1秒)
dorisWriter.addToBatch(data);
} catch (Exception e) {
log.error("清洗失败,记录丢弃: {}", record.value(), e);
metrics.recordError();
}
}
// 批量写入器
@Component
public class DorisWriter {
private List<TraceData> batch = new ArrayList<>(100);
private long lastFlushTime = System.currentTimeMillis();
private final Object lock = new Object();
public void addToBatch(TraceData data) {
synchronized (lock) {
batch.add(data);
// 满足条件时批量写入
if (batch.size() >= 100 ||
System.currentTimeMillis() - lastFlushTime > 1000) {
flushBatch();
}
}
}
private void flushBatch() {
if (batch.isEmpty()) return;
// 构建批量INSERT SQL
String sql = buildBatchInsertSQL(batch);
try {
// 使用JDBC批量提交
int affected = jdbcTemplate.update(sql);
log.debug("批量写入{}条,影响行数{}", batch.size(), affected);
batch.clear();
lastFlushTime = System.currentTimeMillis();
} catch (DataAccessException e) {
log.error("批量写入失败,尝试单条重试", e);
// 降级为单条插入
fallbackToSingleInsert(batch);
}
}
}
}
我放弃的更优方案是使用Flink做流处理。放弃原因:
- 团队没人会运维Flink集群
- 测试环境资源不够(至少需要3节点)
- 我认为清洗逻辑简单,用Spring Boot足够,过度设计会增加维护成本
执行:在测试环境"一切正常"
部署到测试环境后,我用JMeter模拟了生产流量。8核CPU占用稳定在40%,内存使用6G,延迟控制在200ms内。监控指标一切正常,我甚至有点得意------看,简单方案也能搞定。
但当时我只知道:
- 测试数据是均匀生成的,没有突增
- 测试环境的Kafka集群没有其他消费者竞争
- Doris测试实例的数据量只有生产环境的1/100
- 最关键的一点未知:我没有模拟GC暂停的场景
测试持续了6小时,处理了200万条数据,成功率99.98%。基于这个结果,我判断方案可行,准备第二天上线。
失败:生产环境的"完美风暴"
上线第一天晚上8点,业务高峰来临。以下是我当时在监控上看到的时间线:
业务查询 Doris 实时表 清洗服务 Kafka 原始队列 上游数据源 业务查询 Doris 实时表 清洗服务 Kafka 原始队列 上游数据源 数据流入正常 状态1:理想流控 状态2:流控失效开始 Kafka Consumer自动追赶 导致消息积压瞬间涌入 状态3:级联失败 Doris内存超限 BE节点OOM loop [失控写入] 状态4:服务崩溃 状态5:数据积压 状态6:业务影响 写入原始数据 (1K TPS) 均匀拉取 (C控制速率) 清洗后批量写入 偶发GC暂停2-3秒 生成大批量INSERT 写入失败,连接断开 数据库连接池耗尽 线程阻塞 服务宕机 数据持续堆积(>100万条) 查询超时/无结果
当时我坚信的错误前提是:"GC暂停只是短暂影响,系统能自动恢复"。这个判断来自过去的经验------在支付系统中,偶尔的GC不会导致雪崩,因为支付流量相对平稳。
现实情况完全不同:
- 级联放大的积压 :清洗服务GC暂停2秒期间,Kafka Consumer的
max.poll.records配置为500,恢复后一次性拉取了大量数据 - 写入放大的压力:批量写入逻辑在数据涌入时,瞬间构造了超大的INSERT语句(最多一次尝试写入2000条)
- Doris的内存墙:单次批量写入数据量过大,Doris BE节点分配内存时直接OOM
- 连接池的死锁:写入失败导致连接不释放,30秒后连接池耗尽,后续请求全部阻塞
最致命的是,错误处理逻辑加剧了问题:
java
// 这是当时实际的降级逻辑 - 现在看是灾难性的
private void fallbackToSingleInsert(List<TraceData> batch) {
for (TraceData data : batch) {
try {
// 单条插入,失败重试3次
for (int i = 0; i < 3; i++) {
if (singleInsert(data)) break;
Thread.sleep(50); // 等待50ms重试
}
} catch (Exception e) {
log.error("单条插入最终失败: {}", data.getId());
// 数据丢失了!没有持久化到死信队列
}
}
}
代价:
- 业务中断45分钟:溯源查询全部失败
- 数据丢失约3万条:错误处理逻辑缺陷导致
- 凌晨紧急回滚,团队3人加班到早上6点
- 后续数据补录花了8人时
修正:从失控中恢复
回滚后,我花了整整两天分析问题。必须重构,但重构点必须来自前面的失败:
- 针对GC暂停导致积压的问题:放弃手动流控,改用Kafka Consumer原生限流
- 针对批量写入过大的问题:增加写入前检查,限制单批次大小
- 针对连接池耗尽的问题:引入熔断器和快速失败
- 针对数据丢失的问题:所有失败数据必须持久化
重写的核心逻辑:
java
// 第二版:修复关键问题点
@Component
public class TraceDataCleanerV2 {
// 修复点1:使用Kafka原生限流,放弃手动控制
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
// 关键配置:每次poll最多100条,每秒最多poll 10次
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 100);
// 开启自动提交偏移量,但控制提交间隔
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
return new DefaultKafkaConsumerFactory<>(props);
}
// 修复点2:批量写入增加大小限制
@Component
public class DorisWriterV2 {
private static final int MAX_BATCH_SIZE = 50; // 从100降到50
public void addToBatch(TraceData data) {
synchronized (lock) {
batch.add(data);
// 增加大小检查
if (batch.size() >= MAX_BATCH_SIZE ||
System.currentTimeMillis() - lastFlushTime > 500) { // 超时缩短到500ms
flushBatch();
}
}
}
private void flushBatch() {
if (batch.isEmpty()) return;
// 修复点3:写入前检查批次大小,超限拆分
if (batch.size() > MAX_BATCH_SIZE) {
List<List<TraceData>> partitions = Lists.partition(batch, MAX_BATCH_SIZE);
for (List<TraceData> part : partitions) {
doFlush(part);
}
} else {
doFlush(batch);
}
}
private void doFlush(List<TraceData> toFlush) {
try {
// 修复点4:增加熔断检查
if (circuitBreaker.isOpen()) {
sendToFallbackStorage(toFlush); // 降级存储
return;
}
String sql = buildBatchInsertSQL(toFlush);
int affected = jdbcTemplate.update(sql);
// 修复点5:记录成功,用于熔断器判断
circuitBreaker.recordSuccess();
} catch (DataAccessException e) {
// 修复点6:所有失败数据必须持久化
circuitBreaker.recordFailure();
persistentFallback(toFlush, e); // 持久化到MySQL失败表
}
}
}
// 修复点7:简化错误处理,确保数据不丢失
private void handleParseError(String rawData, Exception e) {
// 必须持久化到死信队列
deadLetterQueueService.save(rawData, e.getMessage());
metrics.recordDeadLetter();
}
}
重构耗时:开发2天 + 测试1天 + 数据补录0.5天,总计3.5人天。
未解决的问题
当上游数据格式发生不兼容变更时,清洗服务会批量失败。虽然数据不会丢失(都进了死信队列),但需要人工介入解析新格式,这个窗口期可能导致业务查询不到最新数据。
该系统持续运行了一年多,直至我离开那家公司,期间有一些注意点:每天需花费约15~20分钟监控死信队列 ;每次上游数据格式变更,都要投入1-2小时修改清洗规则并重新部署 ;此外每月还需耗费2-4小时手动补录因死信堆积而丢失的数据(没人手解决暂时搁置修改程序)。