最近在处理气象预报数据的接入,系列文章写到第四篇,这次的主角是 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 或者其他数据格式解析,只需要新增对应的 Parser,Service 层做统一调度,不会牵一发而动全身。我在做这个项目之前,第一版代码把解析、入库、日志都写在同一个方法里,后来要加文件归档功能,改起来特别别扭,所以重构了一次。现在这个结构维护起来舒服很多。
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 BOM,Files.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个设计的出发点是"窄表优先"。一个时次一个文件,要素值全部以行形式存储,用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 连接串里必须加上rewriteBatchedStatements=true。没有这个参数,MySQL JDBC 驱动不会把多条 insert 重写成一条多值 insert,加了才真正有性能提升。我实测过,500 条数据的插入时间从 2 秒降到 200 毫秒上下,效果非常明显。
public void batchInsert(List<StationObs> 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_element(station_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 乱码、缺测、字段错位:三个高频问题的应对
这几个问题我在上线第一个月都遇到过,每一个都花了不少时间。整理成一张速查表:
| 问题 | 现象 | 解决思路 |
|---|---|---|
| 中文乱码 | 说明行变成乱码,时间解析失败 | 字符集探测优先 GBK,BOM 要跳过 |
| 缺测值 | 数值列出现 //、-999 | 缺测集合匹配,命中跳过并记日志 |
| 字段错位 | 站点数解析成 0 | 不固定第三行,向下找第一个整数值 |
乱码那个问题最隐蔽,因为有时候文件有 BOM,有时候没有。后来我把检测逻辑改成"按 ISO_8859_1 读第二行,如果解码后的字符串包含中文年月日关键词,就按 GBK 解析,否则按 UTF-8",用到现在没有误判过。缺测值的坑在于不同数据源用的缺测标记不一样,最稳妥的办法是问清楚上游有没有特殊标记,然后把所有见过的缺测值都加进集合里。
5.2 性能优化:从单条insert到批量写入MySQL
性能优化这部分我记录过一组实测数据,环境是本机 4 核 8G、MySQL 8.0、单表数据量 100 万左右:
| 方案 | 1000条耗时 |
|---|---|
| 单条 INSERT 循环 | 4.8 秒 |
| PreparedStatement addBatch | 1.2 秒 |
| addBatch + rewriteBatchedStatements=true | 180 毫秒 |
| LOAD DATA LOCAL INFILE | 60 毫秒 |
可以看到,参数优化带来的收益远大于换框架。如果你的项目允许临时文件落盘,LOAD DATA确实更快,但要注意字段转义和权限问题。我最终选择了 addBatch 方案,因为简单可控,性能也完全够用。
如果用的是 MyBatis,尽量别用默认的foreach嵌套循环拼接 insert,那种方式生成的 SQL 很长,解析起来很慢。要用就用 MyBatis 的ExecutorType.BATCH,或者干脆在 DAO 层写 JDBC 批量逻辑。实测下来,MyBatis 的 BATCH 模式和 JDBC 原生性能接近,主要看有没有开启对应的连接参数。
5.3 一次线上解析0站点的故障排查记录
那天告警是"Diamond4 解析入库站点数为 0",日志里只看到"声明站点数 5,实际读取 0"。第一次排查时我以为文件是空文件,但用文本编辑器打开后发现文件内容都在,只是第三行是空行,真正站点数在第四行。上游生成脚本在第二行末尾多了一个换行符,导致整个文件内容下移了一行,解析器按固定行号读取自然读到了空行。
我从那以后把解析器改成容错模式:读到标识行之后,跳过所有空行,找到第一个能解析成整数的非空行作为站点数。这样即使遇到多一个空行或者说明行里带了多余换行,也不会造成整批解析失败。这个改动很小,但让解析稳定性提升了一大截。
排查这类问题我还有一个习惯:解析器每处理一个文件都会打印摘要日志,包括文件名、字符集、声明站点数、实际站点数、正常数、错误数。这样出了告警,先在日志里定位文件级别的问题,再逐个看错误行,效率高很多。日志格式大致是:
file=SURF_20240520_0800.dat, charset=GBK, expect=5, actual=5, ok=5, err=06. 扩展与实战建议:让解析入库更健壮
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 Map<String, 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 我最后想说的几条经验
如果让我总结这次实现里最值得记住的三件事,第一是格式必须吃透,拿到文件先手工看几行,不要上来就写代码。第二是异常处理一定要独立于主流程,把坏数据挡在解析层,不要让脏数据进入数据库。第三是入库前必须考虑幂等,气象数据一旦丢了重灌,对预报业务影响很大,宁可多写一点判断,也不要让重复数据钻空子。
你们如果也要做类似的数据解析入库,建议跟我一样把样例文件保存下来,写几个单测用例,把正常文件、缺测文件、乱码文件都覆盖到。这样后面修改解析逻辑时,至少不会把已经能用的功能改坏。