广东水文项目接口改造实战:字段扩展与性能优化
本文记录了广东水文项目中两个典型接口改造的技术实现过程,详细描述了从需求分析、方案设计到最终落地的完整技术路径。第一个改造是测站关联列表接口添加水位流量字段,涉及多数据源切换、大表查询优化等挑战;第二个改造是设备分类接口扩展测站基础信息字段,展示了跨数据源关联查询的典型应用场景。通过这两个案例,分享我在实际项目中积累的数据库设计优化经验。
前言
广东水文项目是一个涉及多数据源、多表关联的水利信息化系统,承载着广东省境内河流、水库、湖泊等水文测站的实时监测数据管理。系统需要整合来自不同数据库的业务数据,为前端应用提供统一的 API 接口服务。
在实际开发过程中,我遇到了两个典型的接口改造需求:
- 测站关联列表接口需要添加最新水位和流量字段,用于列表页面直接展示水情数据
- 设备分类接口需要附加测站的流域、河流、管理单位、行政区等基础信息
这两个需求看似简单,但在实现过程中涉及到多数据源路由、大表查询性能、跨数据库关联等关键技术点。本文将详细记录整个技术实现过程,包括遇到的坑以及最终的解决方案。
项目技术背景
系统架构概述
广东水文项目采用典型的 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 | // 在 StationAssociationBiz 中直接查询实时数据 |
这个方案在开发环境中测试时没有问题,但一上线就出现了严重问题:
问题 1:@DataSource 注解不生效
Spring AOP 的拦截机制决定了 @DataSource 注解只能拦截 Service 层方法(标注了 @Service 的类),而 StationAssociationBiz 使用的是 @Component 注解。
1 | // Biz 层是 Component,不会被 Service 层的 AOP 拦截 |
根因分析:Spring AOP 的代理机制遵循”代理穿透”原则——外层代理可以拦截内层调用,但同层调用不走代理。在 Biz 层方法上标注 @DataSource,由于 Biz 是被 Controller 直接调用的(而不是通过 Service 代理),AOP 拦截根本不会触发。
问题 2:原始表查询超时
即使解决了数据源切换问题,还有一个更严重的问题——ST_RIVER_R_MIN 表是亿级数据量的大表。
1 | <!-- 最初的 SQL 设计:查询每种测站的最新数据 --> |
这个 SQL 存在严重的性能问题:
GROUP BY+MAX(TM)需要全表扫描ST_RIVER_R_MIN表数据量太大,查询超过 60 秒超时- 没有合适的索引支持这种查询模式
方案二:使用汇总表(成功)
经过分析,我们决定不查询原始大表,而是改用实时数据汇总表 water_realtime_summary。
这个汇总表是数据团队预先计算好的,特点如下:
- 每条记录都是当前最新的水情数据(
IS_LATEST = 1) - 数据量小(每个测站只有一条记录)
- 已建立索引,查询速度毫秒级
架构对比:
1 | # 方案一(失败):直接查询原始表 |
1.4 关键技术实现
数据源切换的正确姿势
将数据源注解从 Biz 层移到 Service 层:
1 | // Service 接口 |
批量查询 SQL 设计
1 | <select id="selectLatestByStcds" resultMap="WaterRealtimeSummaryResult"> |
这个 SQL 的优势:
IS_LATEST = 1条件利用了汇总表的索引,快速定位最新数据IN批量查询避免了循环单查,减少数据库交互次数- 返回结果直接在应用层用
Map组织,O(1) 复杂度查找
DTO 字段扩展
在返回给前端的 DTO 中新增两个字段:
1 | public class StationAssociationGroupedDTO implements Serializable { |
1.5 验证结果
改造完成后,经过完整的测试验证:
| 验证项 | 结果 | 说明 |
|---|---|---|
| 编译通过 | ✅ | BUILD SUCCESS |
| 应用启动 | ✅ | 正常加载,无异常 |
| API 返回 | ✅ | data 包含 waterLevel 和 flow |
| 查询性能 | ✅ | 毫秒级响应(汇总表) |
| 边界情况 | ✅ | 非河道/水库站返回 null |
API 响应示例:
1 | { |
需求二:设备分类接口添加测站基础字段
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 |
|
步骤二:追加测站基础信息查询
在 getEquipmentClassified 方法的末尾(原步骤 5 构建 responseData 之后)追加新步骤:
1 | /** |
2.4 防御性设计
这个改造体现了良好的防御性编程理念:
设计原则一:查询失败不影响主流程
1 | try { |
为什么这样设计?
- 设备分类信息是核心功能,测站基础信息是附加信息
- 测站基础信息查询失败(如网络抖动、数据库短暂不可用)不应该导致整个接口返回错误
- 前端可以正常展示设备列表,只是缺少基础信息字段
设计原则二:空值检查
1 | if (station != null) { |
只有当查询结果不为空时才设置字段,避免 NPE(空指针异常)。
设计原则三:日志分级
warn级别:测站不存在,属于业务上可能的正常情况(如新接入测站)- 不使用
error级别:这不是系统错误,只是一个信息提示
2.5 调用链路分析
完整的调用链路如下:
1 | 前端请求 |
2.6 改造后返回示例
1 | { |
技术总结与经验
经验一:多数据源注解的正确使用
问题:@DataSource 注解在哪个层级使用才生效?
答案:Spring AOP 只能拦截 Service 层的代理方法。
| 类注解 | 层级 | @DataSource 是否生效 |
|---|---|---|
@Service |
Service 层 | ✅ 生效 |
@Component |
Biz 层 | ❌ 不生效 |
@Controller |
Controller 层 | ❌ 不生效 |
正确做法:
- 将需要切换数据源的逻辑封装在
Service层方法中 - Biz 层和 Controller 层调用 Service 层方法,由 Service 层的代理处理数据源切换
经验二:大表查询优化策略
问题:遇到亿级数据量的大表查询,应该如何优化?
答案:优先考虑汇总表/宽表方案。
优化路径:
- 判断查询场景:是统计分析还是实时查询?
- 评估数据规模:表数据量多少?查询频率如何?
- 选择优化策略:
| 场景 | 推荐方案 |
|---|---|
| 实时查询,查询条件简单 | 汇总表 + 索引 |
| 统计分析,需要聚合计算 | 预计算表 / 物化视图 |
| 历史数据查询 | 分区表 + 分页查询 |
| 高频小数据量查询 | Redis 缓存 |
本项目的优化:
- 不查询原始的
ST_RIVER_R_MIN(亿级数据) - 改用
water_realtime_summary汇总表(每测站一条) - 查询时间从 60+ 秒优化到 毫秒级
经验三:防御式编程实践
在接口改造中,我们始终坚持核心功能不受附加功能影响的原则:
1 | try { |
关键点:
- 异常捕获:使用
try-catch包裹可能失败的操作 - 空值检查:查询结果为 null 时不设置字段
- 日志分级:使用
warn而非error,区分业务警告和系统错误 - 优雅降级:附加信息查询失败时,接口仍返回核心数据
经验四:避免 N+1 查询问题
列表接口中最常见的性能问题是 N+1 查询:
1 | // ❌ 错误做法:循环中单独查询 |
性能对比:
- 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 的原子性
结语
本文通过广东水文项目的两个实际案例,展示了后端接口改造的完整技术路径。从需求分析、方案设计、问题排查到最终实现,每一个环节都需要技术判断和权衡。
核心收获:
- 多数据源路由:AOP 注解只在 Service 层生效,Biz 层需要调用 Service 方法
- 大表优化:优先考虑汇总表,避免直接查询亿级数据表
- 防御式编程:核心功能与附加功能解耦,附加功能的失败不能影响主流程
- 批量查询:列表接口避免 N+1 查询,先收集 ID 再批量操作
这些经验不仅适用于水文项目,在任何涉及多数据源、大规模数据处理的后端系统中都具有普遍的参考价值。希望本文能为遇到类似问题的开发者提供一些启发。