
最近在处理气象预报数据的接入系列文章写到第四篇这次的主角是 Micaps 第4类数据也就是常说的 Diamond4。做气象数据开发的同学对 Micaps 应该不陌生它是一套在气象业务里用得非常多的人机交互系统定义了多种文本数据格式来交换实况和预报资料。Diamond4 属于站点离散数据常见的应用场景包括自动站实况要素、数值模式站点输出、站点降水预报等。这篇文章我会从文件格式讲起到 Java 解析实现再到批量入库 MySQL 的完整流程把关键代码和容易踩的坑都梳理一遍。如果你正准备写一个数据接入接口或者需要把文本格式的气象数据落库供查询分析使用这篇文章可以直接作为参考。基础要求是会用 Java 读写文件、用过 JDBC 或者 MyBatis对气象数据的站点、时次、要素这些概念有基本认识。后面涉及到的代码我都基于 Spring Boot 工程结构来写不过核心解析逻辑换成普通 Java 工程一样能跑。1. 认识 Micaps Diamond4这类数据文件到底装了什么1.1 为什么需要解析入库它能带来什么价值气象业务里预报员习惯了打开 Micaps 客户端直接看数据但到了开发侧很多上层应用没法直接读这种文本文件。无论是做 Web 端的实况页面、历史告警查询还是做数据统计分析都需要先把数据落到数据库里再用 SQL 去检索。我这次做的就是把每天定时推送过来的 Diamond4 文件自动解析、校验、入库让下游系统能够通过接口查询站点要素。这个需求听起来简单但牵扯的问题不少。数据源的文件命名不统一、部分文件是 GBK 编码、站点值偶发缺测、同一个时次文件可能被推送两遍……这些问题如果不提前在解析层解决后面每次任务告警都要耗费大量时间排查。所以我做的时候特意没有上来就写循环读文件而是先把格式、边界场景、异常处理都列了一遍。1.2 一个真实样例文件的字段拆解拿我手上一个真实文件举例出于脱敏考虑站点号与数值都做了处理但结构是原样的diamond 4 2024年05月20日08时 2米温度实况 5 58968 110.32 21.28 23.4 1 27.4 58944 110.35 21.28 21.0 1 25.8 59001 113.52 25.13 102.0 1 24.1 57957 116.45 27.44 68.5 1 26.9 58606 119.73 26.35 9.0 1 25.2逐行解释第一行固定是diamond 4这是文件类型的标识解析时先校验这个防止拿错文件。第二行是数据说明通常包含观测时次和要素名称这里的意思是2024年05月20日08时2米温度实况。时次要拆出来作为每条记录的时间字段。第三行是站点数量5 表示后面有 5 个站点记录。第四行开始每行是一个站点。前 4 个字段依次是区站号、经度、纬度、拔海高度第 5 个字段是要素值个数这里都是 1最后一个字段就是要素值。这个格式有个很容易踩的坑不同数据源给的 Diamond4 文件说明行写法五花八门。有的写成20日08时有的干脆把单位混在里面比如温度度实况。时间解析不能只写死一种格式需要多模式匹配。此外有些文件的站点行并不带要素值个数这一列而是站号 经度 纬度 高度 要素值。所以我会把解析器设计成可配置的两种布局都支持具体使用哪一种由配置文件决定。1.3 认清Diamond4和其他Micaps数据类型的区别Micaps 常见的数据类型里Diamond1 是地面实况填图数据Diamond2 是高空的探空数据Diamond4 是站点离散数据还有格点数据、T-lnp 数据等等。它们最直观的区别在字段结构和行数Diamond1 一行一个站、固定要素排列Diamond2 一个站会带多个层次每个层次又占一行Diamond4 则更灵活站行后面直接跟要素值要素数量也可以变化。理解这个区别是有用的。你复用解析逻辑的时候千万不要把 Diamond1 的解析器直接套到 Diamond4 上两者的字段顺序完全不同。我之前见过一个项目把 Diamond1 和 Diamond4 的文件混在一个目录里又没有做文件头校验结果解析出来的数据张冠李戴整个时次的数据作废。所以第一步先认清楚文件身份再谈解析。2. 解析方案设计与技术选型先想清楚再动手2.1 Java解析文本文件的几个可行路线Java 里读取解析文本文件的方式很多Scanner、BufferedReader、Apache Commons IO 的FileUtils、Hutool 的FileReader、Java 8 的Files.lines()等。我的结论是核心解析逻辑别用框架直接BufferedReader逐行读取自己做切分。原因有三点。第一Diamond4 格式不算复杂用框架并不会省太多代码。第二边界逻辑多比如空行要跳过、字段之间可能用多个空格或 Tab、文件头可能带 BOM 等这些用框架反而容易隐藏。第三自己控制解析过程才能在字段出错时精确定位到行号和内容日志打出来极具可读性。Hutool、Commons IO 这些工具类我更多用来做文件复制、归档、目录监听这些周边工作。实际读取时我用Files.newBufferedReader(file, charset)按行读取。不用Scanner是因为它在大文件上的表现不如BufferedReader而且按空白字符切分的逻辑还要自己写优势不明显。同样不要用正则一次匹配整行再挨个 group我试过如果一行字段非常多正则在处理几百上千个站点时性能会有明显损耗而且容易写错。按空白字符串split是更直接、更可控的做法。2.2 工程分层与核心类职责划分我把工程按解析、模型、存储、调度四层来组织对应到包结构大概是├── model │ └── StationObs.java ├── parser │ └── Diamond4Parser.java ├── dao │ └── StationObsDao.java ├── service │ └── Diamond4HandleService.java └── util └── TimeParsingUtil.java这么分的好处很明显将来如果增加 Diamond2 或者其他数据格式解析只需要新增对应的 ParserService 层做统一调度不会牵一发而动全身。我在做这个项目之前第一版代码把解析、入库、日志都写在同一个方法里后来要加文件归档功能改起来特别别扭所以重构了一次。现在这个结构维护起来舒服很多。Diamond4HandleService是入口负责编排整个流程找到文件、调用 Parser 解析、拿到对象列表、做幂等判断、批量入库、最后把文件移动到归档目录。单看任一环节都不复杂但组合起来能应对生产环境的各种意外。2.3 数据模型设计从文本字段到Java对象解析完一行站点数据需要转成一个 Java 对象。我定义了StationObs这个模型字段和数据库列一一对应避免后面 DAO 层来回 set。public class StationObs { /** 区站号 */ private String stationId; /** 经度单位度 */ private double longitude; /** 纬度单位度 */ private double latitude; /** 拔海高度单位米 */ private double altitude; /** 观测时次 */ private LocalDateTime obsTime; /** 要素编码如 temperature_2m */ private String elementType; /** 要素值 */ private BigDecimal dataValue; // getter/setter 省略 }这里有一个设计细节如果一行有多个要素值我建议直接把StationObs拆成多条也就是一个站点记录生成几个对象。这样入库时不需要在 DAO 里做循环嵌套批量插入更简单同时数据库里也能通过element_type区分不同要素。要素编码不要直接用中文中文在报表、接口传输、排序上都容易出问题。我这边维护了一张映射表把2米温度实况映射成temperature_2m这个映射关系放在配置里。3. 核心代码实现读取、解析、校验一条龙3.1 文件读取与字符集探测规避中文乱码气象数据文件不少是从老系统生成的那类系统很多跑在 Windows 环境下文件编码经常是 GBK 而不是 UTF-8。如果直接按 UTF-8 读第二行说明里的中文就会乱码时间解析直接失败。我的做法是做一个简单的字符集探测。private Charset detectCharset(Path file) throws IOException { byte[] head new byte[3]; try (InputStream in Files.newInputStream(file)) { in.read(head); } if (head[0] (byte) 0xEF head[1] (byte) 0xBB head[2] (byte) 0xBF) { return StandardCharsets.UTF_8; } // 先用单字节编码读取第二行避免提前损坏字节 String secondLine readSecondLineRaw(file); if (secondLine.contains(年) || secondLine.contains(月) || secondLine.contains(时)) { return StandardCharsets.UTF_8; } return Charset.forName(GBK); }这种方式虽然朴素但实测下来很稳。关键在于先用 ISO_8859_1 按单字节把第二行读出来再判断字符内容这样不会因为错误编码导致字节丢失。补充一点如果文件带 UTF-8 BOMFiles.newBufferedReader不一定会去掉解析前要手动跳过。我的做法是把第一行读出来 trim 一下再和diamond 4比较时用equalsIgnoreCase这样 BOM 导致的头几个字符问题也能被兼容。3.2 解析器实现逐行读取与字段切分解析器我设计成一个状态机。第一行做类型校验第二行解析时间和要素第三行解析站点总数之后进入站点数据循环。为了保证一个文件解析失败不影响其他文件解析器会收集所有异常行而不是遇到一行错误就立刻返回。public ParseResult parse(Path file) throws IOException { Charset charset detectCharset(file); ParseResult result new ParseResult(); try (BufferedReader reader Files.newBufferedReader(file, charset)) { String line reader.readLine(); if (line null || !diamond 4.equalsIgnoreCase(line.trim())) { throw new IllegalArgumentException(文件格式错误不是Diamond4); } String descLine reader.readLine(); ObsTimeInfo timeInfo TimeParsingUtil.parse(descLine); // 容错跳过空行找到第一个整数作为站点数 int expectCount 0; while ((line reader.readLine()) ! null) { if (!line.trim().isEmpty()) { expectCount Integer.parseInt(line.trim()); break; } } String dataLine; int actualCount 0; while ((dataLine reader.readLine()) ! null) { if (dataLine.trim().isEmpty()) { continue; } String[] parts dataLine.trim().split(\\s); if (parts.length 5) { result.addErrorLine(actualCount, 字段不足); continue; } try { StationObs obs buildObs(parts, timeInfo); if (obs ! null) { result.addObs(obs); } else { result.addErrorLine(actualCount, 缺测值); } } catch (NumberFormatException e) { result.addErrorLine(actualCount, 数值解析失败); } actualCount; } if (expectCount ! actualCount) { result.setWarning(声明站点数 expectCount 实际读取 actualCount); } } return result; }TimeParsingUtil里的时间解析我写了几种正则(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})时、(\d{4})(\d{2})(\d{2})(\d{2})、(\d{1,2})日(\d{1,2})时。后面两种模式需要结合当前日期补全年份月份。时间解析宁可多用几个 if 分支也不要让程序因为一种格式不符合就崩掉。buildObs方法里有一个容易被忽略的点经纬度解析后要做范围校验。经度在 0 到 180纬度在 0 到 90拔海高度在 -500 到 9000。超出这个范围说明这一行可能是错位或者无效数据。缺测值的问题也值得单独说气象数据里经常用-999、9999、999.9、//表示缺测解析时不要把正常数值和缺测混在一起private boolean isMissing(String value) { return //.equals(value) || -999.equals(value) || 9999.equals(value) || 999.9.equals(value); }命中缺测的字段直接跳过但要记日志这样可以知道数据质量情况。3.3 数据校验与异常处理把脏数据拦截在入库前我分三层做校验文件级、站点记录级、要素值级。文件级校验文件头、站点数声明站点记录级校验站号、经纬度、高度要素值级校验缺测以及数值范围。校验失败的数据我不会直接抛异常终止整个文件而是收集到ParseResult的错误列表里。一个文件几百个站点如果因为一个坏站就全盘失败暴露给下游的就是整个时次缺失影响太大。我选择让解析器把好数据返回坏数据记日志同时统计坏行数量超过阈值时触发告警。这里还有一个建议不要把解析逻辑和入库逻辑混在一起。很多新手解析的同时顺便往库里写这样一旦中间某个站点解析失败前面入库的数据就要回滚比较尴尬。我选择先全部解析成对象列表校验通过后再统一入库。大文件可能内存占用稍高但气象站点数据一个文件也就几千行内存完全不是瓶颈。4. 批量入库MySQL效率与幂等两手抓4.1 表结构设计窄表还是宽表入库表结构设计直接关系到后续 SQL 好不好写。我这里用了两张表station存站点基础信息diamond4_obs存观测要素值。CREATE TABLE station ( station_id varchar(20) NOT NULL COMMENT 区站号, station_name varchar(100) DEFAULT NULL COMMENT 站名, longitude decimal(7,3) NOT NULL COMMENT 经度(度), latitude decimal(7,3) NOT NULL COMMENT 纬度(度), altitude decimal(7,1) DEFAULT NULL COMMENT 拔海高度(米), PRIMARY KEY (station_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE diamond4_obs ( id bigint NOT NULL AUTO_INCREMENT, station_id varchar(20) NOT NULL, obs_time datetime NOT NULL COMMENT 观测时次, element_type varchar(50) NOT NULL COMMENT 要素类型, data_value decimal(10,2) NOT NULL COMMENT 要素值, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_station_time_element (station_id, obs_time, element_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个设计的出发点是窄表优先。一个时次一个文件要素值全部以行形式存储用element_type区分。查询某个站点的温度时间序列时SQL 写起来很自然SELECT obs_time, data_value FROM diamond4_obs WHERE station_id 58968 AND element_type temperature_2m ORDER BY obs_time;温湿度风速这些要素如果全做成宽表字段一列一要素查询确实快但新增要素就要改表结构在历史数据迁移上非常痛苦。窄表虽然行数多但对 OLTP 场景完全够用配合索引可以支撑千万级数据量。如果量再大可以按obs_time做分区查询时间范围时用分区裁剪速度同样有保障。4.2 JDBC批量插入与连接参数优化批量插入用 JDBC 的addBatch但有一个参数非常关键MySQL 连接串里必须加上rewriteBatchedStatementstrue。没有这个参数MySQL JDBC 驱动不会把多条 insert 重写成一条多值 insert加了才真正有性能提升。我实测过500 条数据的插入时间从 2 秒降到 200 毫秒上下效果非常明显。public void batchInsert(ListStationObs obsList, String elementType) throws SQLException { String sql INSERT INTO diamond4_obs (station_id, obs_time, element_type, data_value) VALUES (?, ?, ?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { int batchSize 500; int count 0; for (StationObs obs : obsList) { ps.setString(1, obs.getStationId()); ps.setObject(2, obs.getObsTime()); ps.setString(3, elementType); ps.setBigDecimal(4, obs.getDataValue()); ps.addBatch(); if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch(); } }连接池我用 HikariCP配置如下spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000批量提交的批次大小我推荐 500 到 1000。刚开始我图省事一次提交 2000 条结果在并发任务多的时候出现过内存压力。后来压测发现 500 条是性能和内存占用最平衡的档位再大收益不明显风险反而增加。4.3 幂等与去重防止重复文件造成脏数据重复入库这事气象数据接入里几乎人人都会遇到。我这边文件通过上游数据平台定时推送偶尔传了两份相同的文件如果不是幂等逻辑挡住库里就会有两份相同的数据。我在diamond4_obs表上建了唯一索引uk_station_time_elementstation_id, obs_time, element_type然后入库采用INSERT ... ON DUPLICATE KEY UPDATE。但对于文件重推这种场景我更倾向于直接跳过整个文件而不是覆盖更新。因为如果新推送的是旧文件覆盖更新会把新数据改成旧值逻辑上反而错了。所以我在 Service 层一开始就查一下该时次是否已入库如果存在就记录日志并 return。if (obsTimeArchiveService.exists(obsTime, elementType)) { log.info(该时次已入库跳过: {} {}, obsTime, elementType); return; }这个是否已入库的判断不用每次查数据库可以把最近处理过的时次放在 Redis 或者本地内存的缓存里。数据量不大一个 HashSet 就够了。另外处理完的文件一定要归档我按日期建目录处理成功的移动到archive/20240520/处理失败的留在error/目录里。这样第二天排查问题很方便也不需要去翻历史推送记录。5. 常见问题排查与优化实录5.1 乱码、缺测、字段错位三个高频问题的应对这几个问题我在上线第一个月都遇到过每一个都花了不少时间。整理成一张速查表问题现象解决思路中文乱码说明行变成乱码时间解析失败字符集探测优先 GBKBOM 要跳过缺测值数值列出现 //、-999缺测集合匹配命中跳过并记日志字段错位站点数解析成 0不固定第三行向下找第一个整数值乱码那个问题最隐蔽因为有时候文件有 BOM有时候没有。后来我把检测逻辑改成按 ISO_8859_1 读第二行如果解码后的字符串包含中文年月日关键词就按 GBK 解析否则按 UTF-8用到现在没有误判过。缺测值的坑在于不同数据源用的缺测标记不一样最稳妥的办法是问清楚上游有没有特殊标记然后把所有见过的缺测值都加进集合里。5.2 性能优化从单条insert到批量写入MySQL性能优化这部分我记录过一组实测数据环境是本机 4 核 8G、MySQL 8.0、单表数据量 100 万左右方案1000条耗时单条 INSERT 循环4.8 秒PreparedStatement addBatch1.2 秒addBatch rewriteBatchedStatementstrue180 毫秒LOAD DATA LOCAL INFILE60 毫秒可以看到参数优化带来的收益远大于换框架。如果你的项目允许临时文件落盘LOAD DATA确实更快但要注意字段转义和权限问题。我最终选择了 addBatch 方案因为简单可控性能也完全够用。如果用的是 MyBatis尽量别用默认的foreach嵌套循环拼接 insert那种方式生成的 SQL 很长解析起来很慢。要用就用 MyBatis 的ExecutorType.BATCH或者干脆在 DAO 层写 JDBC 批量逻辑。实测下来MyBatis 的 BATCH 模式和 JDBC 原生性能接近主要看有没有开启对应的连接参数。5.3 一次线上解析0站点的故障排查记录那天告警是Diamond4 解析入库站点数为 0日志里只看到声明站点数 5实际读取 0。第一次排查时我以为文件是空文件但用文本编辑器打开后发现文件内容都在只是第三行是空行真正站点数在第四行。上游生成脚本在第二行末尾多了一个换行符导致整个文件内容下移了一行解析器按固定行号读取自然读到了空行。我从那以后把解析器改成容错模式读到标识行之后跳过所有空行找到第一个能解析成整数的非空行作为站点数。这样即使遇到多一个空行或者说明行里带了多余换行也不会造成整批解析失败。这个改动很小但让解析稳定性提升了一大截。排查这类问题我还有一个习惯解析器每处理一个文件都会打印摘要日志包括文件名、字符集、声明站点数、实际站点数、正常数、错误数。这样出了告警先在日志里定位文件级别的问题再逐个看错误行效率高很多。日志格式大致是fileSURF_20240520_0800.dat, charsetGBK, expect5, actual5, ok5, err06. 扩展与实战建议让解析入库更健壮6.1 如何支持多要素文件和多种Diamond数据Diamond4 文件有时一行会带多个要素值。这时候我建议把一行拆成多个StationObs对象每个对象保存一个要素。比如一行是58968 110.32 21.28 23.4 2 27.4 65.0代表温度和湿度两个要素就生成两条记录一条temperature_2m一条humidity。这里有一个业务上的细节不同要素的类型映射和单位转换。比如温度字段是摄氏度湿度是百分比入库前最好统一单位否则下游做计算时会出问题。我这边用配置文件管理要素映射遇到新要素就加一行配置不需要重新改代码。至于多种 Diamond 数据可以抽象一个MicapsParser接口每种格式实现一个 Parser。Service 层根据文件头第一行的标识自动选择 Parser。代码里加一个简单的工厂模式就够了这也再次说明为什么开头要做文件头校验否则没法自动路由。工厂逻辑不复杂一个 HashMap 就能搞定public class ParserFactory { private static final MapString, MicapsParser PARSERS new HashMap(); static { PARSERS.put(diamond 4, new Diamond4Parser()); // 后续增加 Diamond1、Diamond2 等 } public static MicapsParser getParser(String headLine) { return PARSERS.get(headLine.trim().toLowerCase()); } }6.2 我最后想说的几条经验如果让我总结这次实现里最值得记住的三件事第一是格式必须吃透拿到文件先手工看几行不要上来就写代码。第二是异常处理一定要独立于主流程把坏数据挡在解析层不要让脏数据进入数据库。第三是入库前必须考虑幂等气象数据一旦丢了重灌对预报业务影响很大宁可多写一点判断也不要让重复数据钻空子。你们如果也要做类似的数据解析入库建议跟我一样把样例文件保存下来写几个单测用例把正常文件、缺测文件、乱码文件都覆盖到。这样后面修改解析逻辑时至少不会把已经能用的功能改坏。