广东水文项目接口改造实战:字段扩展与性能优化

本文记录了广东水文项目中两个典型接口改造的技术实现过程,详细描述了从需求分析、方案设计到最终落地的完整技术路径。第一个改造是测站关联列表接口添加水位流量字段,涉及多数据源切换、大表查询优化等挑战;第二个改造是设备分类接口扩展测站基础信息字段,展示了跨数据源关联查询的典型应用场景。通过这两个案例,分享我在实际项目中积累的数据库设计优化经验。

前言

广东水文项目是一个涉及多数据源、多表关联的水利信息化系统,承载着广东省境内河流、水库、湖泊等水文测站的实时监测数据管理。系统需要整合来自不同数据库的业务数据,为前端应用提供统一的 API 接口服务。

在实际开发过程中,我遇到了两个典型的接口改造需求:

  1. 测站关联列表接口需要添加最新水位和流量字段,用于列表页面直接展示水情数据
  2. 设备分类接口需要附加测站的流域、河流、管理单位、行政区等基础信息

这两个需求看似简单,但在实现过程中涉及到多数据源路由大表查询性能跨数据库关联等关键技术点。本文将详细记录整个技术实现过程,包括遇到的坑以及最终的解决方案。

项目技术背景

系统架构概述

广东水文项目采用典型的 Spring Boot + MyBatis 技术栈,后端服务通过 MyBatis-Plus 提供的 @DataSource 注解实现多数据源动态切换。系统连接两个核心数据库:

数据源 用途 代表性数据表
WATER 测站基础信息 ST_STBPRP_B(测站基本信息表)
hydrology 水文实时数据 ST_RIVER_R_MIN(河道水情表)、ST_RSVL_R_MIN(水库水情表)

数据库设计特点

测站基础信息(如流域、河流、管理单位等)存储在 WATER 数据源的 ST_STBPRP_B 表中,这是一张相对稳定的配置表,数据量适中。而水文实时数据(如水位、流量等)存储在 hydrology 数据源的分钟级汇总表中,数据量庞大,亿级记录规模。

这种分库设计的好处是:

  • 冷热分离:基础配置信息与实时监测数据物理隔离
  • 读写分离:大表查询不影响基础信息查询
  • 职责单一:各数据源关注各自的业务领域

但同时也带来了跨库关联查询的复杂性。

需求一:测站关联列表添加水位流量字段

1.1 需求背景

前端团队需要在测站关联列表页面直接展示每个测站的最新水情数据,包括水位(waterLevel)流量(flow)。原来的接口只返回测站的基本关联信息,不包含实时水情数据。

如果每次展示水情都需要单独调用一次接口,会造成:

  • 前端请求次数增加:列表 100 条数据就要发 100 次请求
  • 用户体验下降:额外的数据加载等待时间
  • 服务器压力增大:不必要的网络开销

因此,前端团队提出了在列表接口中直接返回水位和流量字段的合理需求。

1.2 数据来源分析

不同类型的测站对应不同的数据表和字段:

测站类型 类型代码 数据表 水位字段 流量字段
水文站 ZQ ST_RIVER_R_MIN Z Q
水位站 ZZ ST_RIVER_R_MIN Z Q
潮位站 TT ST_RIVER_R_MIN Z Q
水库站 RR ST_RSVR_R_MIN RZ INQ(入库流量)
其他类型 PP等 - null null

可以看到:

  • 河道类测站(ZQ/ZZ/TT)使用 ST_RIVER_R_MIN 表,水位字段为 Z,流量字段为 Q
  • 水库类测站(RR)使用 ST_RSVR_R_MIN 表,水位字段为 RZ,流量字段为 INQ
  • 其他类型测站不返回水情数据

1.3 方案演进:从失败到成功

方案一:直接查询原始表(失败)

最早的方案是直接查询原始的分钟级数据表,核心代码逻辑如下:

1
2
3
4
5
6
7
8
9
// 在 StationAssociationBiz 中直接查询实时数据
@DataSource(WATER) // 尝试切换数据源
public Map<String, Map<String, BigDecimal>> fetchLatestWaterData(List<String> riverStcds, List<String> rsvrStcds) {
// 查询河道站水情
List<RiverData> riverData = stRiverRMinMapper.selectLatestByStcds(riverStcds);
// 查询水库站水情
List<RsvrData> rsvrData = stRsvrRMinMapper.selectLatestByStcds(rsvrStcds);
// 合并结果...
}

这个方案在开发环境中测试时没有问题,但一上线就出现了严重问题

问题 1:@DataSource 注解不生效

Spring AOP 的拦截机制决定了 @DataSource 注解只能拦截 Service 层方法(标注了 @Service 的类),而 StationAssociationBiz 使用的是 @Component 注解。

1
2
3
4
5
6
7
@Component  // Biz 层是 Component,不会被 Service 层的 AOP 拦截
public class StationAssociationBiz {
@DataSource(WATER) // 这个注解不会生效!
public List<StationAssociation> listAssociations(String groupId) {
// ...
}
}

根因分析:Spring AOP 的代理机制遵循”代理穿透”原则——外层代理可以拦截内层调用,但同层调用不走代理。在 Biz 层方法上标注 @DataSource,由于 Biz 是被 Controller 直接调用的(而不是通过 Service 代理),AOP 拦截根本不会触发。

问题 2:原始表查询超时

即使解决了数据源切换问题,还有一个更严重的问题——ST_RIVER_R_MIN 表是亿级数据量的大表。

1
2
3
4
5
6
7
8
9
10
<!-- 最初的 SQL 设计:查询每种测站的最新数据 -->
<select id="selectLatestByStcds" resultType="map">
SELECT STCD, Z, Q, MAX(TM) as TM
FROM ST_RIVER_R_MIN
WHERE STCD IN
<foreach collection="stcds" item="stcd" open="(" separator="," close=")">
#{stcd}
</foreach>
GROUP BY STCD
</select>

这个 SQL 存在严重的性能问题:

  • GROUP BY + MAX(TM) 需要全表扫描
  • ST_RIVER_R_MIN 表数据量太大,查询超过 60 秒超时
  • 没有合适的索引支持这种查询模式

方案二:使用汇总表(成功)

经过分析,我们决定不查询原始大表,而是改用实时数据汇总表 water_realtime_summary

这个汇总表是数据团队预先计算好的,特点如下:

  • 每条记录都是当前最新的水情数据(IS_LATEST = 1
  • 数据量小(每个测站只有一条记录)
  • 已建立索引,查询速度毫秒级

架构对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 方案一(失败):直接查询原始表
StationAssociationBiz

StRiverRMinMapper

[ST_RIVER_R_MIN] ── 亿级数据 ──→ 查询超时

# 方案二(成功):查询汇总表
StationAssociationBiz

RiverRealtimeChartService(Service层,@DataSource生效)

WaterRealtimeSummaryMapper

[water_realtime_summary] ── 每站一条 ──→ 毫秒级响应

1.4 关键技术实现

数据源切换的正确姿势

将数据源注解从 Biz 层移到 Service 层:

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
// Service 接口
public interface IRiverRealtimeChartService {
@DataSource(WATER) // 在 Service 层标注,AOP 可以拦截
Map<String, Map<String, BigDecimal>> getLatestWaterData(
List<String> riverStcds,
List<String> rsvrStcds
);
}

// Service 实现
@Service
public class RiverRealtimeChartServiceImpl implements IRiverRealtimeChartService {
@Autowired
private WaterRealtimeSummaryMapper waterRealtimeSummaryMapper;

@Override
@DataSource(WATER)
public Map<String, Map<String, BigDecimal>> getLatestWaterData(
List<String> riverStcds,
List<String> rsvrStcds
) {
// 合并河道站和水务站测站编码
List<String> allStcds = new ArrayList<>();
if (riverStcds != null) allStcds.addAll(riverStcds);
if (rsvrStcds != null) allStcds.addAll(rsvrStcds);

if (allStcds.isEmpty()) {
return Collections.emptyMap();
}

// 批量查询汇总表
List<WaterRealtimeSummary> summaries =
waterRealtimeSummaryMapper.selectLatestByStcds(allStcds);

// 转换为 Map 便于后续查找
Map<String, Map<String, BigDecimal>> result = new HashMap<>();
for (WaterRealtimeSummary summary : summaries) {
Map<String, BigDecimal> waterData = new HashMap<>();
// 根据测站类型确定水位和流量字段
if ("RR".equals(summary.getSTTP())) {
// 水库站:RZ 是水位,INQ 是入库流量
waterData.put("waterLevel", summary.getRZ());
waterData.put("flow", summary.getINQ());
} else {
// 河道站:Z 是水位,Q 是流量
waterData.put("waterLevel", summary.getZ());
waterData.put("flow", summary.getQ());
}
result.put(summary.getSTCD(), waterData);
}
return result;
}
}

批量查询 SQL 设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<select id="selectLatestByStcds" resultMap="WaterRealtimeSummaryResult">
SELECT
STCD, -- 测站编码
STNM, -- 测站名称
STTP, -- 测站类型
TM, -- 时间
Z, -- 河道水位
Q, -- 河道流量
RZ, -- 水库水位
INQ, -- 水库入库流量
OTQ, -- 出库流量
WPTN -- 水势
FROM water_realtime_summary
WHERE IS_LATEST = 1
AND STCD IN
<foreach collection="stcds" item="stcd" open="(" separator="," close=")">
#{stcd}
</foreach>
</select>

这个 SQL 的优势:

  • IS_LATEST = 1 条件利用了汇总表的索引,快速定位最新数据
  • IN 批量查询避免了循环单查,减少数据库交互次数
  • 返回结果直接在应用层用 Map 组织,O(1) 复杂度查找

DTO 字段扩展

在返回给前端的 DTO 中新增两个字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StationAssociationGroupedDTO implements Serializable {
private static final long serialVersionUID = 1L;

private String id; // 关联ID
private String name; // 测站名称
private String stcd; // 测站编码
private String sttp; // 测站类型
private String basin; // 所属流域
private String river; // 所属河流

// ========== 新增字段 ==========
private BigDecimal waterLevel; // 最新水位(米)
private BigDecimal flow; // 最新流量(立方米/秒)
// ==============================

// 省略 getter/setter
}

1.5 验证结果

改造完成后,经过完整的测试验证:

验证项 结果 说明
编译通过 BUILD SUCCESS
应用启动 正常加载,无异常
API 返回 data 包含 waterLevel 和 flow
查询性能 毫秒级响应(汇总表)
边界情况 非河道/水库站返回 null

API 响应示例

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
{
"code": 200,
"total": 100,
"rows": [
{
"id": "80115900",
"name": "封开江口",
"stcd": "80115900",
"sttp": "ZZ",
"basin": "珠江流域",
"river": "西江",
"waterLevel": 1.65,
"flow": null
},
{
"id": "80115800",
"name": "天堂山",
"stcd": "80115800",
"sttp": "RR",
"basin": "珠江流域",
"river": "增江",
"waterLevel": 25.38,
"flow": 15.6
}
]
}

需求二:设备分类接口添加测站基础字段

2.1 需求背景

/api/station/material/equipmentClassified 接口原本只返回设备分类数据,包括:

  • total:设备记录总数
  • stcd:测站编码
  • records:设备详情数组

前端团队希望在调用该接口时,无需额外请求,就能同时获取到测站的基础关联信息:

  • 所属流域(basin)
  • 所属河流(river)
  • 管理单位(adminUnit)
  • 所属行政区(adminDivision)

2.2 数据来源分析

测站基础信息存储在 WATER 数据源的 ST_STBPRP_B 表中,通过 StStbprpBMapper.selectStStbprpBBySTCD 方法查询。

字段映射关系如下:

前端需求字段 后端使用字段 数据库字段 字段说明
所属流域 bsnm BSNM Basin Name,流域名称
所属河流 rvnm RVNM River Name,河流名称
管理单位 admauth ADMAUTH Administrative Authority,管理单位
所属行政区 addvcdc ADDVCDC Administrative Division Code,行政区划代码

2.3 改造方案

改造文件

核心改造文件cnsci-admin/src/main/java/cn/cnsci/web/service/EquipmentDataService.java

步骤一:新增依赖注入

在 Service 类中注入测站基础信息 Mapper:

1
2
3
4
5
6
7
8
9
10
11
@Service
public class EquipmentDataService {

@Autowired
private StStbprpBMapper stStbprpBMapper; // 测站基础信息 Mapper

@Autowired
private EquipmentDataMapper equipmentDataMapper; // 原有设备数据 Mapper

// ... 其他内容
}

步骤二:追加测站基础信息查询

getEquipmentClassified 方法的末尾(原步骤 5 构建 responseData 之后)追加新步骤:

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
/**
* 获取设备分类列表(带测站基础信息)
* @param stcd 测站编码
* @return 设备分类数据
*/
public ResultData getEquipmentClassified(String stcd) {
// 1. 调用第三方 API 获取设备数据
// 2. 解析 records 数组
// 3. 批量查询字典映射
// 4. 为 records 每条附加 symbolName
// 5. 构建 responseData (stcd/total/records)

// ========== 新增步骤 6 ==========
// 6. 查询测站基本信息,附加流域、河流、管理单位、行政区字段
try {
StStbprpB station = stStbprpBMapper.selectStStbprpBBySTCD(stcd);
if (station != null) {
// 附加测站基础信息到返回数据
responseData.put("bsnm", station.getBSNM()); // 所属流域
responseData.put("rvnm", station.getRVNM()); // 所属河流
responseData.put("admauth", station.getADMAUTH()); // 管理单位
responseData.put("addvcdc", station.getADDVCDC()); // 所属行政区
} else {
log.warn("未查询到测站基本信息 - stcd: {}", stcd);
}
} catch (Exception e) {
// 测站基础信息查询失败不影响设备数据返回
log.warn("查询测站基本信息失败,不影响设备数据返回 - stcd: {}", stcd, e);
}
// =================================

return ResultData.success(responseData);
}

2.4 防御性设计

这个改造体现了良好的防御性编程理念:

设计原则一:查询失败不影响主流程

1
2
3
4
5
6
7
8
9
10
11
12
try {
StStbprpB station = stStbprpBMapper.selectStStbprpBBySTCD(stcd);
if (station != null) {
// 正常附加字段
} else {
// 测站不存在,打印警告日志但不阻断
log.warn("未查询到测站基本信息 - stcd: {}", stcd);
}
} catch (Exception e) {
// 发生异常也不阻断,只记录日志
log.warn("查询测站基本信息失败,不影响设备数据返回 - stcd: {}", stcd, e);
}

为什么这样设计?

  • 设备分类信息是核心功能,测站基础信息是附加信息
  • 测站基础信息查询失败(如网络抖动、数据库短暂不可用)不应该导致整个接口返回错误
  • 前端可以正常展示设备列表,只是缺少基础信息字段

设计原则二:空值检查

1
2
3
4
if (station != null) {
responseData.put("bsnm", station.getBSNM());
// ...
}

只有当查询结果不为空时才设置字段,避免 NPE(空指针异常)。

设计原则三:日志分级

  • warn 级别:测站不存在,属于业务上可能的正常情况(如新接入测站)
  • 不使用 error 级别:这不是系统错误,只是一个信息提示

2.5 调用链路分析

完整的调用链路如下:

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
前端请求


StStationMaterialController.equipmentClassified(stcd)


EquipmentDataService.getEquipmentClassified(stcd)

├─ 1. 调用第三方 API 获取设备数据

├─ 2. 解析 records 数组

├─ 3. 批量查询字典映射(dictionary Mapper)

├─ 4. 为 records 每条附加 symbolName(符号名称)

├─ 5. 构建 responseData(stcd/total/records)

└─ 6. 查询 StStbprpBMapper 附加基础信息(bsnm/rvnm/admauth/addvcdc)


StStbprpBMapper.selectStStbprpBBySTCD(stcd)


[ST_STBPRP_B] ── WATER 数据源

2.6 改造后返回示例

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
{
"msg": "操作成功",
"code": 200,
"data": {
"total": 3,
"stcd": "81005450",
"bsnm": "珠江",
"rvnm": "北江",
"admauth": "广东省水文局",
"addvcdc": "广东省肇庆市广宁县",
"records": [
{
"USFL": "1",
"STCD": "81005450",
"SYMBOL": "Q",
"ATCUNIT_ID": "102",
"EQID": "0081005450",
"EQTP": "HADCP-水平式ADCP",
"LGTD": 112.366464,
"DATASOURCE": "1",
"symbolName": "流量",
"EQNM": "祝州",
"LTTD": 23.742710,
"STLC": "肇庆市广宁县坑口镇祝州村"
}
]
}
}

技术总结与经验

经验一:多数据源注解的正确使用

问题@DataSource 注解在哪个层级使用才生效?

答案:Spring AOP 只能拦截 Service 层的代理方法。

类注解 层级 @DataSource 是否生效
@Service Service 层 ✅ 生效
@Component Biz 层 ❌ 不生效
@Controller Controller 层 ❌ 不生效

正确做法

  • 将需要切换数据源的逻辑封装在 Service 层方法中
  • Biz 层和 Controller 层调用 Service 层方法,由 Service 层的代理处理数据源切换

经验二:大表查询优化策略

问题:遇到亿级数据量的大表查询,应该如何优化?

答案:优先考虑汇总表/宽表方案。

优化路径:

  1. 判断查询场景:是统计分析还是实时查询?
  2. 评估数据规模:表数据量多少?查询频率如何?
  3. 选择优化策略
场景 推荐方案
实时查询,查询条件简单 汇总表 + 索引
统计分析,需要聚合计算 预计算表 / 物化视图
历史数据查询 分区表 + 分页查询
高频小数据量查询 Redis 缓存

本项目的优化

  • 不查询原始的 ST_RIVER_R_MIN(亿级数据)
  • 改用 water_realtime_summary 汇总表(每测站一条)
  • 查询时间从 60+ 秒优化到 毫秒级

经验三:防御式编程实践

在接口改造中,我们始终坚持核心功能不受附加功能影响的原则:

1
2
3
4
5
6
7
8
9
10
11
try {
// 附加信息查询
StStbprpB station = stStbprpBMapper.selectStStbprpBBySTCD(stcd);
if (station != null) {
responseData.put("bsnm", station.getBSNM());
// ... 其他字段
}
} catch (Exception e) {
// 只记录日志,不阻断主流程
log.warn("查询测站基本信息失败,不影响设备数据返回", e);
}

关键点

  • 异常捕获:使用 try-catch 包裹可能失败的操作
  • 空值检查:查询结果为 null 时不设置字段
  • 日志分级:使用 warn 而非 error,区分业务警告和系统错误
  • 优雅降级:附加信息查询失败时,接口仍返回核心数据

经验四:避免 N+1 查询问题

列表接口中最常见的性能问题是 N+1 查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 错误做法:循环中单独查询
for (Station station : stations) {
StStbprpB info = stStbprpBMapper.selectByStcd(station.getStcd()); // N 次查询
// 处理...
}

// ✅ 正确做法:先收集 ID,批量查询
List<String> stcds = stations.stream()
.map(Station::getStcd)
.collect(Collectors.toList());
List<StStbprpB> infos = stStbprpBMapper.selectByStcds(stcds); // 1 次查询
Map<String, StStbprpB> infoMap = infos.stream()
.collect(Collectors.toMap(StStbprpB::getSTCD, Function.identity()));
// 处理...

性能对比

  • N+1 查询:100 条数据 = 1 + 100 = 101 次数据库交互
  • 批量查询:100 条数据 = 1 + 1 = 2 次数据库交互

经验五:Git 提交规范

本次改造的 Git 提交记录清晰可追溯:

提交 说明
2e13568 feat: 为测站关联列表接口添加水位和流量字段(最终合并提交)
d033003 feat: 为 StationAssociationGroupedDTO 添加 waterLevel 和 flow 字段
a89a412 feat: StRiverRMinMapper 添加批量查询方法
7ea24aa feat: StRsvrRMinMapper 添加批量查询方法
8b6ca64 feat: StationAssociationBiz.listAssociations 添加水情查询

提交规范建议

  • 使用 feat: 前缀标识新功能
  • 每个小改动单独提交,便于追溯和回滚
  • 合并前确保各个 commit 的原子性

结语

本文通过广东水文项目的两个实际案例,展示了后端接口改造的完整技术路径。从需求分析、方案设计、问题排查到最终实现,每一个环节都需要技术判断和权衡。

核心收获

  1. 多数据源路由:AOP 注解只在 Service 层生效,Biz 层需要调用 Service 方法
  2. 大表优化:优先考虑汇总表,避免直接查询亿级数据表
  3. 防御式编程:核心功能与附加功能解耦,附加功能的失败不能影响主流程
  4. 批量查询:列表接口避免 N+1 查询,先收集 ID 再批量操作

这些经验不仅适用于水文项目,在任何涉及多数据源、大规模数据处理的后端系统中都具有普遍的参考价值。希望本文能为遇到类似问题的开发者提供一些启发。