溯源数据清洗:一次由“可控”到“失控”的复盘

业务查询 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的奇葩结构。

当时我面对的具体场景是:

  1. 时间约束:产品要求三天后上线新版本,清洗逻辑必须提前跑通
  2. 资源约束:只有一台8核16G的测试服务器,生产环境配置未定
  3. 数据约束:单日数据量约2000万条,峰值TPS 1K
  4. 质量约束:下游Doris数据库对数据规范性要求极高,乱数据会直接导致导入失败

我坚信的前提很简单:只要控制好消费速度,单机服务完全能处理这个量级。过去几年做支付渠道接入时,我用同样的思路处理过更复杂的对账流水,从没出过问题。

决策:用最熟悉的方式开局

我画了张流程图,决定采用经典的三层架构:
清洗服务内部逻辑
流控层

手动控制poll速率
解析层

正则/规则引擎
转换层

统一格式
写入层

批量提交
Kafka原始数据
Doris实时表
错误数据降级存储
监控告警
人工干预接口

这个设计的核心控制点在于流控层。我放弃了使用Kafka原生限流或Spark Streaming的方案,原因很现实:

  1. 时间不够:引入新框架至少要两天学习调试
  2. 历史包袱:团队里只有几个人熟悉Java生态,其他人主要写Python、C++
  3. 自信判断:我认为手动控制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做流处理。放弃原因:

  1. 团队没人会运维Flink集群
  2. 测试环境资源不够(至少需要3节点)
  3. 我认为清洗逻辑简单,用Spring Boot足够,过度设计会增加维护成本

执行:在测试环境"一切正常"

部署到测试环境后,我用JMeter模拟了生产流量。8核CPU占用稳定在40%,内存使用6G,延迟控制在200ms内。监控指标一切正常,我甚至有点得意------看,简单方案也能搞定。

但当时我只知道:

  1. 测试数据是均匀生成的,没有突增
  2. 测试环境的Kafka集群没有其他消费者竞争
  3. Doris测试实例的数据量只有生产环境的1/100
  4. 最关键的一点未知:我没有模拟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不会导致雪崩,因为支付流量相对平稳。

现实情况完全不同:

  1. 级联放大的积压 :清洗服务GC暂停2秒期间,Kafka Consumer的max.poll.records配置为500,恢复后一次性拉取了大量数据
  2. 写入放大的压力:批量写入逻辑在数据涌入时,瞬间构造了超大的INSERT语句(最多一次尝试写入2000条)
  3. Doris的内存墙:单次批量写入数据量过大,Doris BE节点分配内存时直接OOM
  4. 连接池的死锁:写入失败导致连接不释放,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人时

修正:从失控中恢复

回滚后,我花了整整两天分析问题。必须重构,但重构点必须来自前面的失败:

  1. 针对GC暂停导致积压的问题:放弃手动流控,改用Kafka Consumer原生限流
  2. 针对批量写入过大的问题:增加写入前检查,限制单批次大小
  3. 针对连接池耗尽的问题:引入熔断器和快速失败
  4. 针对数据丢失的问题:所有失败数据必须持久化

重写的核心逻辑:

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小时手动补录因死信堆积而丢失的数据(没人手解决暂时搁置修改程序)

相关推荐
仅此,2 小时前
Java请求进入Python FastAPI 后,请求体为空,参数不合法
java·spring boot·python·组合模式·fastapi
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于springboot的健身房信息管理为例,包含答辩的问题和答案
java·spring boot·后端
L Jiawen2 小时前
【Web】RESTful风格
前端·后端·restful
爱编码的傅同学2 小时前
【单例模式】深入理解懒汉与饿汉模式
java·javascript·单例模式
better_liang2 小时前
每日Java面试场景题知识点之-ThreadLocal在Web项目中的实战应用
java· threadlocal· web开发· 多线程· 企业级开发
用户6802659051192 小时前
2026年企业级网络监控选型指南
javascript·后端·面试
Rysxt_2 小时前
Spring Boot 4.0 新特性深度解析与实战教程
java·spring boot·后端
程序员飞哥2 小时前
2025 年的寒冬,我这个大龄程序员失业了
后端·程序员
qq_256247052 小时前
Google Labs 新品实测:Mixboard、Flow 和 Learn Your Way 上手体验
后端