盘活卫星数据资产!卫星平台 2.0 数据治理实战分享

每个数据平台都会经历三个阶段:"就这点数据?" → "怎么有点慢?" → "救命。"

我们决定不让第三阶段到来。

这是卫星平台 2.0 工程化笔记的第三篇。前两篇修了地基(数据库基线 + Flyway)和门锁(WebSocket 鉴权 + 三层 RBAC);这一篇处理的,是"房子住久了才会浮现的问题":一张只会长胖的遥测表、一堆无人认领的旧数据、一次可能噎死内存的导出、一本从来没人记过的账。

这周三件事:给遥测表分抽屉、给写操作记账本、给数据导出装水龙头——外加一次理直气壮的"构建失败"。每件事都有能搬回你自己系统的经验,建议先收藏。

遥测监控.png

一、装水龙头:导出十万行,内存只端一只小碗

遥测页面新增了导出功能:选个时间范围,导出 CSV 或 JSON。

功能不难,难的是把它做"稳"。摆在后端面前最直觉的写法是:查出全部数据 → 拼成响应 → 一次性返回。行数少的时候岁月静好;行数一多,等于把一锅饭一次性塞进嘴里——几十万行对象同时躺在 JVM 堆内存里,OOM 不是"会不会",而是"哪一次"。

我们的做法是把它改成"水龙头":细水长流,内存里永远只有一小碗。

三道闸门,按顺序排队:

  1. 先称重,再开箱。导出前先用 countInRange 数一遍:超过 10 万行,直接拒绝并附一句人话提示"请缩小时间范围"。把风险挡在业务开始之前,而不是写到一半才崩。
  2. 小碗分餐。真正取数走 forEachInRange,每批 1000 行。内存峰值只由批大小决定,和"总共要导多少行"脱钩。
  3. 边拉边写。CSV 抓着 OutputStream 逐行写;JSON 用 JsonGenerator 流式吐数组。全程不构建"完整结果集"这个中间物。

几个值得抄的细节:

  • CSV 开头写 UTF-8 BOM。不加这个三字节,中文 Excel 打开就是经典"锟斤拷"名场面;逗号、引号、换行统一转义。
  • 响应头带 X-Total-Count,让前端知道"这锅饭有多少粒";文件名带时间戳,连点两次导出不会互相覆盖。
  • 导出 14 列与页面表格严格一一对应,抽样比对验证过——导出文件不是"另一份数据",就是页面那份。

还有一个前端的坑:项目里 axios 统一封装了业务响应拦截器(R<T> 那层),blob 下载不能走它——否则"下载文件"会被当作"业务响应"解析,喜提一个损坏文件。方案是独立 axios 实例 + 解析 Content-Disposition 文件名 + 失败时把 blob 解码回业务错误,把"为什么失败"还给用户。

遥测导出.png

二、记账本:给每次写操作配一台"行车记录仪"

卫星平台这类系统,"谁改过轨道参数"是必须答得上来的问题。改造之前,答案是:答不上来。

方案本身很轻:一个 @AuditLog 注解 + 一个 AOP 切面。不熟 AOP 也没关系,一句话解释——不用改任何业务代码,在方法的前后自动"夹带"一段记录逻辑。给写操作方法贴个注解,剩下的交给切面。

每笔"账"记录八个要素:谁、何时、对什么资源(含资源 ID)、做了什么、结果如何、失败原因、来自哪个 IP 和 User-Agent、耗时多少。落到 audit_log 表(Flyway V2 迁移,4 个索引,IF NOT EXISTS 保证幂等)。

三个设计决策,每个都配一条"为什么":

  1. 审计是旁路,不是主链路。落库整段包 try-catch——审计写失败,业务照样成功。记账的不能耽误开车的。
  2. 匿名请求直接跳过。登录接口那一刻还没有身份,不产生"查无此人"的废记录。
  3. IP 三级回退X-Forwarded-ForX-Real-IPremoteAddr。请求过了 Nginx 之后,remoteAddr 拿到的是代理的地址,得像查快递一样逐级回溯真实来源。

还有一条"克制"原则值得单独说:我们只接了 8 个 Controller、约 30 处写操作与导出;仿真里调速、暂停这类纯展示交互一律不接。审计不是越多越好——每天几万条流水里找一条异常,等于没有审计。

查询侧配套交付:GET /audit-logs(ADMIN 限定)+ 前端审计页(按操作人 / 动作 / 资源 / 结果 / 时间范围筛选)。身份传递用了最轻的方式:userId 挂到 authentication.details 上,不动 principal 类型,存量鉴权代码零感知。

日志审计.png

三、自曝:我们主动让构建变红了

这一节讲一次"反直觉"的交付。

覆盖率门禁用的是 JaCoCo:行覆盖 ≥ 70%,servicedataaccess 包 ≥ 80%,绑定在 mvn verify 上。门禁绑定的那天,基线长这样:

指标基线值门槛
总行覆盖率10.7%(294/2749)70%
service.impl12.5%80%
dataaccess / controller0%80%

摆在我们面前有两条路:A,把阈值降到"现在就能过";B,让构建红着,把缺口精确列出来。

我们选了 B。

理由很简单:门禁的意义不是展示绿灯,而是让欠账一直看得见。绑定后 mvn verify 会精确报出 4 项违规——这就是一份写给未来的还债清单,而不是一个"大家都假装没看见"的数字。补测冲刺已经排进 M2 第一天;现在让构建红着,是为了让它更快地真正变绿。

同一批还顺手做了缓存精细化(原计划里的弹性项):

  • TTL 分层:默认 10 分钟、仪表盘 1 分钟、实体详情 30 分钟——冷热数据分开对待;
  • @CacheEvict 精准失效:改、删按 ID 踢对应的 key;新增不需要踢;只有批量删除才整片清。

期间挖出一个静默的坑,值得单独讲讲:自定义 RedisCacheConfiguration Bean 会全盘接管 yml 里 spring.cache.redis.* 的所有配置。你在 yml 里改 TTL、配 key-prefix,全部静默失效、还不报错。修复之后我们补了测试把 TTL 契约钉死——缓存配置这种东西,"悄悄退化"比"当场报错"可怕得多。

覆盖率门禁.png

四、分抽屉:遥测表按天归档,过期整屉清走

终于说到这周的主角。

遥测数据是典型的"只进不出":每一秒都在写入,且永远不嫌多。用传统思路清理旧数据,两条路都不好走:

  • 不删:查询的扫描量跟着时间一起涨,一年后查一天的数据,数据库要翻遍整个"仓库";
  • DELETE:注意,DELETE 只是"标记删除"——空间不还、索引持续膨胀、VACUUM 追在后面跑。

分区表给的是第三条路:按天分抽屉。查询只开对应日期的抽屉;过期数据不一本本撕,直接把整个抽屉端走(DROP PARTITION 是元数据操作,秒级回收)。

这次迁移里的四个硬核细节

1)一个反直觉的硬规则:主键必须包含分区键。

PostgreSQL 声明式分区下,唯一约束必须涵盖分区列,否则建表直接被拒。我们的主键从 (id) 改成了 (id, "timestamp")——第一次写分区迁移脚本的人,八成会在这里撞墙。

sql
复制代码
-- V3__telemetry_partition.sql(节选示意) CREATE TABLE telemetry_data (..., PRIMARY KEY (id, "timestamp") -- 分区键必须进主键 ) PARTITION BY RANGE ("timestamp");

2)迁移"全有或全无"。

整个 V3 迁移跑在单事务里 + 幂等守卫:旧表让名 → 建分区父表 → 按「历史数据日期 ∪ 昨天~未来 7 天」建按日分区 → 搬数据 → 行数强校验 → 校验不过 RAISE EXCEPTION 整体回滚 → 删旧表、重建 4 个索引。宁可重来,不留半成品——数据迁移最怕的不是失败,是"成功了一半"。

3)搬存量用"编号"找下一批,不用 OFFSET

5000 行一批、以 id 为游标往前推。为什么不用看起来更顺手的 OFFSET?因为在大表上,深翻页的 OFFSET 每次都要重新"数过前面所有的行",越翻越慢;而且大批量操作拖得越久,锁表风险越高。搬家公司的正确姿势,是记住"最后一箱的编号",而不是每次都从第一箱数起。

4)光分区没用,查询得"带着抽屉号来"。

如果 SQL 不带分区键条件,优化器照样给你全表扫——"分了但没用"。所以 page / countInRange / forEachInRange 的时间窗做了缺省补全(不传就用 [近 91 天, 明天]),显式传的边界原样保留。保证每一条查询都携带分区键,裁剪才真正发生。

自动打理与开机自愈

分区维护不需要人管:

  • 每天凌晨 02:30:预建"昨天 ~ 未来 7 天"的分区(防止跨天凌晨"抽屉还没到货"),顺手清掉 90 天前的分区;
  • 应用启动时自愈一次:出差三天没开服务?重启那一刻,它自己把欠的分区补上、过期的清掉,失败只告警、不阻塞启动;
  • 双重防呆护栏:自动 DROP 前,分区名必须匹配 telemetry_data_pyyyyMMdd 的命名,且边界能被 pg_get_expr 解析出来——只扔自家抽屉,隔壁系统的表碰都不碰。自动化想获得"自主行动"的资格,先证明自己知道边界在哪。
mermaid
复制代码
flowchart LR A["遥测写入<br/>自动进当天抽屉"] --> B["查询带时间窗<br/>只开相关抽屉"] B --> C["每天 02:30<br/>预建未来 7 天"] C --> D["满 90 天<br/>整屉 DROP"]

实测数据

验证走的是"两阶段"路线,很值得借鉴:先用 Flyway 的 target 参数把库停在中途版本(V2),预置 5 行数据(3 条近期 + 2 条远期),再全量升级:

  • 迁移日志:"搬移完成:5 行(批大小 5000)",行数校验通过;
  • 启动自愈:超期分区被自动识别并清理;
  • 写入冒烟:新数据正常落进当天分区,序列继续递增;
  • EXPLAIN 实测:单日窗口只碰 1 个分区;7 天窗口只碰 4 个相关分区——没有全表扫,也没有"假装分区"式的全分区扫。

配套新增 14 条单测(清理服务 9 条 + 查询窗口 5 条),后端全量 46/46 全绿。

遥测分区.png

分区裁剪.png

五、一周数字快照

验收项方法结果
后端单元测试mvn test46/46 全绿(分区相关新增 14 条)
分区裁剪EXPLAIN 实测单日窗口仅扫 1 个分区;7 天窗口仅扫 4 个
存量搬移临时库两阶段验证行数强校验通过,日志留痕
过期清理启动自愈实跑超期分区自动 DROP
导出上限守护单测超 10 万行拒绝并给出提示
审计(切面 + 查询)单测 8 条成功 / 失败 / 落库失败 / 匿名 / IP 回退全过
前端门禁npm run lint / vue-tsc0 error
E2E 回归npm run test:e2e7 passed + 3 skipped
覆盖率门禁mvn verify"诚实的红":4 项违规精确列出(见第三章)

六、这周最值的 7 个坑(直接抄作业)

  1. PostgreSQL 分区表的主键必须涵盖分区键列。想写 (id) 的请收手,建表会直接失败。
  2. 大表搬移:游标分批 + 行数强校验,校验不过就整体回滚。宁可重来,不留半成品。
  3. 清理旧数据用 DROP 分区,不用 DELETE。一个把抽屉整个端走,一个只是在报纸上划了道线。
  4. 流式导出三件套:先 count 称重 → 分批拉取 → 边写边刷;CSV 记得 UTF-8 BOM,不然中文 Excel 满屏"锟斤拷"。
  5. 审计切面落库必须 try-catch。审计是旁路,永远不能拖垮主业务。
  6. 自定义 RedisCacheConfiguration Bean 会静默接管 yml 缓存配置。TTL 改了没反应时,先查有没有这个 Bean;再用测试把契约锁死。
  7. 自动清理必须带防呆护栏:命名格式 + 边界解析双重校验,才配得上"自动"两个字。

七、接下来:M2 主战场

  • 告警规则引擎 + 通知中心:阈值 / 区间 / 持续时间三类规则,越限 → 站内信 + WebSocket 推送 → 顶栏铃铛亮起,不再靠人盯屏;
  • 认证加固三件套:图形验证码、失败锁定(5 次锁 15 分钟)、密码强度策略;
  • 可观测性:Prometheus + Grafana 看板 + Loki 日志检索,关键指标配置告警;
  • 还有一件要还的账:补测冲刺,让 mvn verify 从"诚实的红"变回"健康的绿"。

写在最后

这三件事没有一件是"卫星专属"——任何系统只要活得够久,都会遇到同样的三堵墙:表在长胖、账没人记、导出随时崩。对应的解药也就三句话:

数据有出口(流式导出)、操作有账本(审计留痕)、旧数据有去处(分区清理)。

如果这篇帮你绕开了哪怕一个坑,欢迎点赞、在看、转发给可能用得上的同事。留言区聊聊:你们的过期数据,是 DELETE 掉的,还是 DROP 分区掉的?


本文关键词:遥测分区 | 流式导出 | 审计日志 | AOP | PostgreSQL | JaCoCo 覆盖率门禁 | Redis 缓存 | 卫星平台 2.0

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
czheng
下载 APP