4. 数据格式校验与转换:日期格式统一、数字格式校验、字符串清洗

数据格式问题,说白了就是脏数据的「重灾区」。我做了这么多年流处理,见过最离谱的一次——上游系统把日期字段传成了「2023/02/29」,你想想看,2月哪来的29号?还有数字字段里混进字母「O」冒充数字「0」的。这一节,我们就专门来收拾这些格式上的妖魔鬼怪。

核心原则:数据格式校验必须在源头做,越早发现,损失越小。等数据流到下游计算节点再报错,恢复成本至少翻10倍。

4.1 日期格式统一:从「百家争鸣」到「大一统」

日期格式不统一,是实时数仓里最常见的坑。有的系统用 yyyy-MM-dd HH:mm:ss,有的用 yyyy/MM/dd,还有的直接甩一个时间戳过来。我建议,所有日期字段进入 Flink 后,第一时间统一成 yyyy-MM-dd HH:mm:ss 格式,或者直接转成 Timestamp 类型。

下面是我常用的日期清洗 MapFunction:

public class DateCleanMapFunction implements MapFunction<String, String> {
    // 定义多种可能的日期格式
    private static final DateTimeFormatter[] FORMATTERS = {
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
        DateTimeFormatter.ofFormatter("yyyy/MM/dd HH:mm:ss"),
        DateTimeFormatter.ofPattern("yyyyMMdd"),
        DateTimeFormatter.ISO_LOCAL_DATE_TIME
    };

    @Override
    public String map(String value) throws Exception {
        String[] fields = value.split(",");
        String rawDate = fields[2]; // 假设日期在第3个字段

        // 尝试解析
        for (DateTimeFormatter fmt : FORMATTERS) {
            try {
                LocalDateTime parsed = LocalDateTime.parse(rawDate, fmt);
                // 统一输出格式
                fields[2] = parsed.format(
                    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
                );
                return String.join(",", fields);
            } catch (Exception ignored) {
                // 继续尝试下一种格式
            }
        }

        // 所有格式都解析失败,标记为脏数据
        throw new RuntimeException("无法解析的日期格式: " + rawDate);
    }
}

我的经验:不要只写一种格式就完事。我在项目中遇到过上游系统突然改了日期格式,结果整个作业全挂。多写几个格式兜底,能省去半夜爬起来修作业的痛苦。

4.2 数字格式校验:别让「字母」混进数字里

数字字段的脏数据,常见的有三种:

  • 空字符串——字段是空的,但又不是 null
  • 非数字字符——比如金额字段里出现了「$」或「,」
  • 精度溢出——比如数据库字段是 Decimal(10,2),结果传了个 999999999.999

我一般会写一个通用的数字校验工具:

public class NumberValidator {
    public static Double validateAndParse(String raw, String fieldName) {
        if (raw == null || raw.trim().isEmpty()) {
            throw new IllegalArgumentException(
                fieldName + " 字段为空,无法解析为数字"
            );
        }

        // 先清洗常见干扰字符
        String cleaned = raw.trim()
            .replace(",", "")   // 去掉千分位逗号
            .replace("$", "")   // 去掉货币符号
            .replace(" ", "");  // 去掉空格

        try {
            Double value = Double.parseDouble(cleaned);
            // 检查精度:保留两位小数
            BigDecimal bd = BigDecimal.valueOf(value);
            if (bd.scale() > 2) {
                throw new IllegalArgumentException(
                    fieldName + " 精度超出限制: " + raw
                );
            }
            return value;
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                fieldName + " 包含非数字字符: " + raw
            );
        }
    }
}

避坑指南:我曾经遇到过一个线上事故——某个金额字段传了「1,234.56」,直接 Double.parseDouble 就报错了。后来我才发现,上游系统用了英文逗号做千分位分隔。所以,数字清洗一定要先做 replace(",", "")。

4.3 字符串清洗:trim、replace 与正则

字符串字段的脏数据,说白了就是「不该有的东西太多」。常见的包括:

  • 前后空格、制表符、换行符
  • 不可见字符(比如 ASCII 码 0-31 的控制字符)
  • 全角/半角混用
  • 敏感词或特殊符号

我习惯在 MapFunction 里做一套「字符串三连」:

public class StringCleanMapFunction implements MapFunction<String, String> {
    @Override
    public String map(String value) throws Exception {
        // 第一步:trim 掉前后空白
        String cleaned = value.trim();

        // 第二步:替换不可见字符
        cleaned = cleaned.replaceAll("[\\x00-\\x1f]", "");

        // 第三步:全角转半角(中文场景常见)
        cleaned = fullWidthToHalfWidth(cleaned);

        // 第四步:去除重复空格
        cleaned = cleaned.replaceAll("\\s+", " ");

        return cleaned;
    }

    private String fullWidthToHalfWidth(String input) {
        StringBuilder sb = new StringBuilder(input);
        for (int i = 0; i < sb.length(); i++) {
            char c = sb.charAt(i);
            if (c >= '!' && c <= '~') {
                sb.setCharAt(i, (char) (c - 0xFEE0));
            }
        }
        return sb.toString();
    }
}

小技巧:字符串清洗的顺序很重要。先 trim,再替换不可见字符,最后做全角转半角。顺序搞反了,可能会把有效字符也误删掉。

4.4 使用 MapFunction 进行格式转换:实战整合

上面我们分别讲了日期、数字、字符串的清洗。但在实际项目中,这些操作往往是组合在一起的。我通常会写一个统一的 DataCleanMapFunction,把所有的格式校验和转换逻辑封装进去。

下面是一个完整的实战示例:

public class DataCleanMapFunction 
    implements MapFunction<String, CleanedRecord> {

    @Override
    public CleanedRecord map(String line) throws Exception {
        String[] fields = line.split(",", -1); // 保留空字段

        // 字段索引:0=用户ID, 1=姓名, 2=日期, 3=金额, 4=手机号

        // 1. 字符串清洗:姓名
        String name = fields[1].trim()
            .replaceAll("[\\x00-\\x1f]", "")
            .replaceAll("\\s+", " ");

        // 2. 日期统一
        String dateStr = fields[2].trim();
        LocalDateTime date = parseDate(dateStr);
        String formattedDate = date.format(
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
        );

        // 3. 数字校验:金额
        Double amount = NumberValidator.validateAndParse(fields[3], "金额");

        // 4. 手机号格式校验(简单示例)
        String phone = fields[4].trim();
        if (!phone.matches("^1[3-9]\\d{9}$")) {
            throw new RuntimeException("手机号格式错误: " + phone);
        }

        return new CleanedRecord(
            fields[0], name, formattedDate, amount, phone
        );
    }

    private LocalDateTime parseDate(String raw) {
        // 多种格式尝试解析(代码略,参考4.1节)
    }
}

关键点:MapFunction 是 Flink 中最轻量级的算子,适合做逐条数据的格式转换。但要注意——不要在 MapFunction 里做需要上下文状态的操作,比如计数、去重。那些事留给 RichMapFunction 或 KeyedProcessFunction。

4.5 本章知识体系总览

下面这张图,帮你把数据格式校验与转换的完整流程串起来:

数据格式校验与转换流程 原始数据流 日期格式统一 多格式解析 → 标准化输出 数字格式校验 去干扰 → 精度检查 → 类型转换 字符串清洗 trim → 去控制符 → 全半角转换 MapFunction 整合 清洗后的数据流 异常数据 → 侧输出流 避坑要点 1. 清洗顺序不能乱 2. 多格式兜底解析 3. 异常数据别丢弃

4.6 避坑总结:我踩过的三个坑

坑位 问题描述 解决方案
日期格式硬编码 只写了一种日期格式,上游一改就挂 用格式数组兜底,尝试多种解析
数字千分位逗号 没做 replace(",", ""),Double.parseDouble 报错 先清洗干扰字符,再解析数字
不可见字符漏处理 字符串里藏了控制字符,下游 join 不上 用正则 [\\x00-\\x1f] 全部干掉

最后提醒一句:数据格式校验不是「一次性」的工作。业务系统随时可能改格式、加字段、换编码。我建议把格式校验逻辑做成可配置的,用配置文件或参数表来管理,别写死在代码里。这样下次上游改格式,你改个配置就能搞定,不用重新上线作业。