广东水文项目问题排查实录:2026年3月19日技术挑战

2026年3月19日,对广东水文项目来说是一个充满挑战的工作日。在这一天的日常开发和维护中,我们接连遇到了三个不同层面的技术问题:数据采集层的数据缺失、业务层的数据表归属判断、以及数据展示层的时区和数据质量问题。本文将详细记录这三个问题的排查过程、根因分析以及修复方案,希望能为从事类似水文信息化项目的开发者提供一些参考和借鉴。

前言

广东水文项目是一个综合性较强的水利信息化系统,涵盖了水位监测、雨量监测、河道水情、水库水情等多种水文数据的采集、存储、查询和展示功能。系统采用典型的微服务架构,后端基于 Spring Boot 框架构建,数据存储使用 MySQL 数据库,业务数据按照不同的测站类型和数据特征分表存储。

项目中有几个核心的数据实体需要理解:

  • 测站(Station):水文监测的基本单元,每个测站有一个唯一的测站编码(STCD),并且有一个测站类型(STTP)来标识其功能类型,如 ZZ 表示闸站、ZQ 表示河道站、RR 表示水库站、WD 表示水位站、YD 表示雨量站等。
  • 实时数据(Realtime Data):最新的监测数据,通常以 5 分钟为采集间隔,包含水位、流量等关键指标。
  • 历史数据(Historical Data):历史极值和历史记录,用于一站一档等分析展示功能。

在3月19日这一天,我们主要处理了与”一站一档”和”雨情实时信息”这两个业务模块相关的问题,下面逐一展开说明。

问题一:茜坑测站雨情实时数据缺失

问题现象

3月19日上午,收到用户反馈:茜坑测站(测站编码 81103420)在”雨情实时信息”页面无法查询到数据。然而奇怪的是,同一个测站在”一站一档”模块中能够正常搜索并显示数据。

用户描述的现象是:

  • 进入”雨情实时信息”页面
  • 搜索茜坑测站或直接输入测站编码 81103420
  • 系统提示”没有数据”

但是在”一站一档”模块中,该测站能够正常显示其基本信息和历史数据。

排查过程

我首先检查了该测站的基础信息。通过查询 st_stbprp_b 测站基础表,确认茜坑测站存在且状态正常:

1
2
3
SELECT STCD, STNM, STTP, USFL
FROM st_stbprp_b
WHERE STCD = '81103420'

返回结果显示该测站类型为 YD(雨量站),使用标志为 1(启用)。

接下来,我分别查询了该测站在不同雨量数据表中的记录数量:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 查询5分钟实时雨量表
SELECT STCD, COUNT(*) as cnt
FROM ST_PPTN_R_MIN
WHERE STCD = '81103420'
AND TM BETWEEN '2026-03-16 11:41:00' AND '2026-03-19 11:41:00'
GROUP BY STCD

-- 查询小时雨量表
SELECT STCD, COUNT(*) as cnt
FROM ST_PPTN_R
WHERE STCD = '81103420'
AND TM BETWEEN '2026-03-16 11:41:00' AND '2026-03-19 11:41:00'
GROUP BY STCD

查询结果如下:

数据表 数据类型 记录数
ST_PPTN_R_MIN 5分钟实时雨量 0条
ST_PPTN_R 小时雨量 860条

根因分析

经过排查,问题的根因已经非常清晰:

茜坑测站(81103420)只有小时雨量数据(ST_PPTN_R),没有 5 分钟实时数据(ST_PPTN_R_MIN)。

从技术角度来看,这是数据采集层面的问题,而不是代码 Bug。测站硬件或上报配置决定了它只上报小时级别的雨量数据,而不是 5 分钟级别的实时数据。

系统架构设计中,雨情实时信息模块默认查询的是 5 分钟实时数据表(ST_PPTN_R_MIN),而茜坑测站在该表中没有任何数据,因此返回空结果。这解释了为什么”雨情实时信息”页面无法显示数据。

而”一站一档”模块能够正常显示,是因为它查询的是小时雨量表(ST_PPTN_R),该表中存在茜坑测站的 860 条数据。

问题定性

经过分析,我们认为这是一个数据可用性问题,而非代码缺陷。系统按照设计正常运作,只是茜坑测站本身不具备 5 分钟实时雨量数据的采集条件。

可能的解决方案

针对这类问题,有几种潜在的解决方案可供考虑:

方案一:前端降级处理

当实时数据为空时,前端自动降级显示小时数据。这需要在 UI 层面增加逻辑判断,当检测到 5 分钟数据为空时,自动切换到小时数据视图,并给用户一个提示说明当前显示的是小时数据而非实时数据。

方案二:后端数据聚合

在后端处理层面,当查询 5 分钟数据为空时,自动从小时数据表中聚合数据返回。例如,可以将最近一小时的小时数据求和,作为实时数据的近似值返回。这种方案的优点是对前端透明,缺点是数据语义不够精确。

方案三:业务确认与配置调整

与水文部门确认茜坑测站的实际数据采集配置。如果该测站确实只需要上报小时数据,那么应该在系统配置中将其标记为”小时数据站”,前端据此显示不同的数据视图。

最终,我们与水文部门进行了沟通,确认茜坑测站由于硬件限制确实只支持小时数据采集,暂时采用方案一的前端降级处理作为短期解决方案,长期来看需要与设备部门协调升级采集设备。

问题二:江口水闸实时水情查询失败

问题现象

下午时段,接到另一个问题反馈:江口水闸(测站编码 81210300)在实时水情查询中无法找到数据。同样的,该测站在”一站一档”模块中能够正常显示。

具体表现:

  • 在”实时水情”模块搜索江口水闸
  • 系统提示没有数据
  • 但在”一站一档”中能够看到该测站的基本信息和部分数据

系统排查

首先检查测站基础信息和数据分布情况。通过 SQL 查询发现:

1
2
3
4
-- 检查测站基础信息
SELECT STCD, STNM, STTP, USFL
FROM st_stbprp_b
WHERE STCD = '81210300'

江口水闸的基础信息正常,测站类型为 ZZ(闸站),状态启用。

然后分别查询该测站在不同实时数据表中的情况:

1
2
3
4
5
6
7
-- 检查河道实时表
SELECT COUNT(*) FROM st_river_r_min
WHERE STCD = '81210300'

-- 检查水库实时表
SELECT COUNT(*) FROM st_rsvr_r_min
WHERE STCD = '81210300'

查询结果:

数据表 数据类型 记录数
st_river_r_min 河道实时数据 0条
st_rsvr_r_min 水库实时数据 92条

这个结果非常有意思:江口水闸是一个 ZZ 类型(闸站)的测站,但其数据存储在水灾实时表中,而不是河道实时表中。

根因分析

经过深入分析,我们发现了问题的根本原因:

代码按测站类型区分查询数据表,但江口水闸的数据实际存储在与类型不匹配的表中。

系统的实时水情查询逻辑是:

1
2
3
4
5
6
7
8
9
10
11
// 代码中的查询逻辑(修复前)
if ("RR".equals(base.getSttp())) {
// 水库类型 -> 查询 st_rsvr_r_min
reservoirStations.add(stcd);
} else if ("ZQ".equals(base.getSttp())) {
// 河道类型 -> 查询 st_river_r_min
riverStations.add(stcd);
} else if ("ZZ".equals(base.getSttp())) {
// 闸站类型 -> 查询 st_river_r_min
riverStations.add(stcd);
}

江口水闸的测站类型是 ZZ,按照代码逻辑会查询河道实时表(st_river_r_min),但实际上该测站的数据存储在水库存实时表中(st_rsvr_r_min),所以查询结果为空。

这是一种数据录入或数据迁移过程中产生的不一致问题:测站的类型属性与其实际数据存储位置不匹配。

修复方案

为了解决这个问题,我们在 WaterRealtimeSummaryServiceImpl 中增加了 fallback 机制:当 ZZ 类型测站在河道表中没有数据时,自动尝试查询水库存实时表。

修改文件:WaterRealtimeSummaryServiceImpl(第 104-157 行附近)

核心修改思路

  1. 新增 zzStations 列表记录所有 ZZ 类型测站
  2. ZZ 类型优先查询河道表
  3. 新增 fallback 逻辑:如果河道表没有数据,则 fallback 查询水库存表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 2. 分离河道站和水库站
List<String> riverStations = new ArrayList<>();
List<String> reservoirStations = new ArrayList<>();
List<String> zzStations = new ArrayList<>(); // 记录ZZ类型测站,用于fallback
for (String stcd : batch) {
StationBaseInfoDTO base = baseMap.get(stcd);
if (base != null) {
if ("RR".equals(base.getSttp())) {
reservoirStations.add(stcd);
} else if ("ZQ".equals(base.getSttp())) {
riverStations.add(stcd);
} else if ("ZZ".equals(base.getSttp())) {
riverStations.add(stcd); // ZZ优先查河道表
zzStations.add(stcd); // 同时记录,用于fallback
}
}
}

// 3. 查询河道站实时数据(ST_RIVER_R_MIN)
final Map<String, StRiverRMin> riverDataMap;
if (!riverStations.isEmpty()) {
riverDataMap = stRiverRMinMapper.selectByTimeAndStcds(targetTime, riverStations)
.stream().collect(Collectors.toMap(StRiverRMin::getSTCD, v -> v, (a, b) -> a));
} else {
riverDataMap = new HashMap<>();
}
riverRealtimeMap = riverDataMap;

// 4. 查询水库站实时数据(ST_RSVR_R_MIN)
final Map<String, StRsvrRMin> reservoirDataMap;
if (!reservoirStations.isEmpty()) {
reservoirDataMap = stRsvrRMinMapper.selectByTimeAndStcds(targetTime, reservoirStations)
.stream().collect(Collectors.toMap(StRsvrRMin::getSTCD, v -> v, (a, b) -> a));
} else {
reservoirDataMap = new HashMap<>();
}
reservoirRealtimeMap = reservoirDataMap;

// 4.1 ZZ类型fallback - 如果河道表没数据,尝试查水库表
// 解决部分ZZ类型测站(如闸站)数据实际在水库表的问题
if (!zzStations.isEmpty()) {
List<String> zzStationsWithoutData = zzStations.stream()
.filter(stcd -> !riverDataMap.containsKey(stcd))
.collect(Collectors.toList());

if (!zzStationsWithoutData.isEmpty()) {
Map<String, StRsvrRMin> zzFallbackData = stRsvrRMinMapper
.selectByTimeAndStcds(targetTime, zzStationsWithoutData)
.stream()
.collect(Collectors.toMap(StRsvrRMin::getSTCD, v -> v, (a, b) -> a));

reservoirRealtimeMap.putAll(zzFallbackData);
}
}

修复验证

修复后,我们通过手动指定时间同步接口进行了验证:

1
POST /api/water/summary/sync?stcds=81210300&targetTime=2026-03-19 15:00:00

验证结果成功返回数据:

1
2
3
4
5
6
7
{
"stnm": "江口水闸",
"stcd": "81210300",
"z": 92.170,
"tm": "2026-03-19 15:00:00",
"sttp": "ZZ"
}

待修复问题:同步时间匹配

虽然上述修复解决了查询问题,但我们发现了一个关联的同步问题:

现状:同步任务使用精确时间匹配(TM = targetTime),例如查询 15:55 的数据。

问题:江口水闸数据的时间戳是 15:00、14:15 等非整 5 分钟时刻,这导致自动同步时使用精确匹配无法找到数据。

根本原因分析

  1. 同步任务计算出”上一个 5 分钟”的时间点(如 15:55)
  2. SQL 查询使用 TM = #{tm} 精确匹配
  3. 但江口水闸上报的数据时间戳是 15:00、14:15 等非整 5 分钟时刻
  4. 精确匹配查不到数据,所以汇总表始终没有这条数据

初步建议的解决方案

方案一是修改 SQL 为范围查询,查询 targetTime 之前的最近一条数据:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 查询最近的数据 -->
<select id="selectLatestByTimeAndStcds" resultMap="StRsvrRMinResult">
SELECT STCD, TM, RZ, INQ, OTQ, W, RWPTN
FROM st_rsvr_r_min
WHERE TM <= #{tm}
AND STCD IN
<foreach collection="stcds" item="stcd" open="(" separator="," close=")">
#{stcd}
</foreach>
AND RZ IS NOT NULL
ORDER BY TM DESC
</select>

方案二是在 Service 层处理,多次尝试向前查找直到找到数据或超过阈值。

这个问题需要进一步评估影响范围后再实施修复。

问题三:一站一档今日极值数据异常

问题描述

下午晚些时候,收到关于”一站一档”模块的数据异常反馈,涉及两个不同的字段问题:

问题 1:今日最低流量返回错误值 2.180,但实际数据库中存在更低的值 2.260

问题 2:历史最低水位返回 -9999(这是一个无效值占位符),实际应为 0.040

这两个问题发生在测站 81500150(水位站,类型 WD)上。

问题排查与分析

问题 1:今日最低流量错误

首先检查今日最低流量的计算逻辑。通过对比数据库中的实际数据,我们发现:

1
2
3
4
5
6
-- 查询该测站今天(北京时间)的所有流量数据
SELECT STCD, TM, Q FROM st_river_r
WHERE STCD = '81500150'
AND TM >= '2026-03-19 00:00:00'
ORDER BY Q ASC
LIMIT 10

实际查询结果显示最小的流量值确实是 2.260,但系统返回的今日最低流量却是 2.180。

经过仔细排查,我们发现问题的根因是时区不一致

  1. 数据库 ST_RIVER_R 表中存储的时间是 UTC 时间
  2. 代码使用 Calendar.getInstance() 构建今日时间范围:2026-03-19 00:00:00 ~ 23:59:59
  3. MySQL 查询时把这个字符串当作 UTC 时间处理
  4. 导致实际查询范围变成了 2026-03-19 08:00 ~ 2026-03-20 07:59(北京时间)
  5. 漏掉了北京时间 00:00~07:59 的数据,而真正的最低值 2.260 恰好在这个时间段内(UTC 19:00 = 北京时间 03:00)

问题 2:历史最低水位返回 -9999

历史最低水位的计算使用了 ST_RVEVS_R 表,通过 SQL 查询发现:

1
2
3
4
5
6
-- 原 SQL
SELECT STCD, LTZ, LTT FROM st_rvevs_r
WHERE STCD = #{stcd}
AND LTZ IS NOT NULL
ORDER BY LTZ ASC
LIMIT 1

问题在于 ST_RVEVS_R 表中存在 -9999 这种无效值占位符,原始 SQL 只过滤了 NULL,没有过滤负数,导致 -9999 被当作有效值返回。

修复方案

1. SQL 修改

修改 StRvevsRMapper.xml 中的 selectHistoricalMinLevel SQL,添加 AND LTZ > 0 过滤条件:

1
2
3
4
5
6
7
8
-- 修改前
WHERE STCD = #{stcd}
AND LTZ IS NOT NULL

-- 修改后
WHERE STCD = #{stcd}
AND LTZ IS NOT NULL
AND LTZ > 0

2. Java 修改:时区转换逻辑

修改 StationArchiveBiz.java 中的 fillTodayExtremes() 方法,使用 java.time 正确处理时区转换:

1
2
3
4
5
6
7
8
9
10
11
// 北京时区
ZoneId beijingZone = ZoneId.of("Asia/Shanghai");
ZoneId utcZone = ZoneId.of("UTC");

// 获取北京时间的今天开始和结束
ZonedDateTime beijingStart = ZonedDateTime.now(beijingZone).toLocalDate().atStartOfDay(beijingZone);
ZonedDateTime beijingEnd = beijingStart.plusDays(1).minusSeconds(1);

// 转换为 UTC 时间用于数据库查询
ZonedDateTime utcStart = beijingStart.withZoneSameInstant(utcZone);
ZonedDateTime utcEnd = beijingEnd.withZoneSameInstant(utcZone);

同样的时区转换逻辑也需要应用到水库站的今日极值计算方法 fillTodayReservoirExtremes() 中。

修复验证

修复后,两个问题都得到了正确的结果:

字段 修复前 修复后 状态
historicalMinLevel -9999.0 0.04 正常
historicalMinLevelTime 2013-02-21 2015-01-08 正常
todayMinFlow 2.180 2.260 正常
todayMinFlowTime 16:00(错误) 03:00(正确) 正常

正确的 UTC 查询范围应该是:

  • 北京时间 2026-03-19 = UTC 2026-03-18 16:00:00 ~ 2026-03-19 15:59:59
  • 该范围内最小流量为 2.260(UTC 19:00 = 北京时间 03:00)

影响范围评估

此次修复的影响范围仅限于以下接口的今日极值计算:

  • /basic/station/archive/monitoring/{stcd}?sttp=WD&subType=realtime(河道站)
  • /basic/station/archive/reservoir/{stcd}(水库站)

不受影响的部分包括:

  • 用户传入的 startTime/endTime 参数(用户自行处理时区)
  • 历史极值(除 historicalMinLevel 外)
  • 其他业务接口

经验总结与技术思考

通过这一天的问题排查和修复工作,我们总结出以下几点经验教训,希望能对类似项目的开发者提供一些参考。

1. 时区问题是分布式系统中常见的数据陷阱

在广东水文项目中,数据库服务器、应用服务器可能处于不同的时区配置下,而水文数据采集又涉及多个地区的数据汇总,时区问题很容易被忽视。

本次修复的时区问题是一个非常典型的案例:数据库存储 UTC 时间,代码使用本地时间构建查询条件,两者的不一致导致了数据查询范围的偏差,最终造成了极值计算错误。

建议:在系统设计阶段就应该明确规定时间的存储格式和查询方式,推荐做法是:

  • 统一使用 UTC 时间存储
  • 在数据访问层统一进行时区转换
  • 在配置文件中显式声明时区设置
  • 在接口文档中明确标注时间格式

2. 无效值占位符需要在数据入口处统一处理

水文数据中常见使用 -9999、-99、NULL 等作为无效值占位符。这种设计在数据采集和存储层面是合理的,但在数据展示和分析层面必须进行过滤。

本次修复中,历史最低水位查询就是因为没有过滤 -9999 这个无效值,导致返回了明显错误的结果。

建议:建立统一的数据清洗和过滤规范,在以下位置进行处理:

  • 数据入库前的校验层
  • 数据查询的 Mapper 层
  • 数据展示的 Service 层

3. 测站类型与数据存储位置可能存在不一致

项目中经常会出现这样的情况:测站的类型属性(STTP)与实际数据存储位置不匹配。这可能是由于数据迁移、系统升级或人工录入错误导致的。

江口水闸的问题就是一个典型案例:测站类型为 ZZ(闸站),但数据存储在水库存实时表中而非河道实时表中。这种不一致性会导致按照类型判断数据表的代码逻辑失效。

建议

  • 在数据入口处增加校验,当测站类型与数据表不匹配时给出警告
  • 在查询逻辑中增加 fallback 机制,提高系统的容错能力
  • 定期进行数据一致性检查,发现并修复类型与数据不匹配的问题

4. 数据可用性问题需要业务层面配合解决

茜坑测站的问题本质上不是代码问题,而是数据采集层面的问题。测站硬件只支持小时数据采集,无法提供 5 分钟实时数据。对于这类问题,技术手段只能做到优雅降级或数据聚合,无法从根本上解决数据缺失的问题。

建议:建立测站能力档案,在系统中记录每个测站的数据采集能力和数据提供情况,让用户清楚地了解每个测站的数据范围和限制。

5. 代码审查和单元测试的重要性

这三个问题有一个共同点:它们都是由于代码逻辑在边界情况下处理不当导致的。如果有完善的单元测试覆盖这些边界场景,这些问题应该在开发阶段就被发现和修复。

建议

  • 加强对边界条件和异常情况的测试覆盖
  • 定期进行代码审查,特别是涉及数据表切换、时区处理、数值边界等逻辑的代码
  • 建立持续集成环境,在代码提交时自动运行相关测试

结语

2026年3月19日的这次问题排查经历,让我们深刻认识到水文信息化系统的复杂性和挑战性。从数据采集层的硬件限制,到数据存储层的数据表设计,再到数据展示层的时区和数据质量问题,每一个环节都可能成为问题的来源。

作为后端开发工程师,我们不仅要关注代码的功能实现,更要关注数据在各个环节的正确流转;不仅要处理正常的业务逻辑,更要考虑各种异常和边界情况;不仅要修复当前的问题,更要思考如何建立长效机制避免类似问题的再次发生。

水文信息化关乎国计民生,数据的准确性和系统的稳定性至关重要。希望本文的分享能为从事相关项目的开发者提供一些帮助,也欢迎大家交流探讨,共同进步。