盘活卫星数据资产!卫星平台 2.0 数据治理实战分享
每个数据平台都会经历三个阶段:"就这点数据?" → "怎么有点慢?" → "救命。"
我们决定不让第三阶段到来。
这是卫星平台 2.0 工程化笔记的第三篇。前两篇修了地基(数据库基线 + Flyway)和门锁(WebSocket 鉴权 + 三层 RBAC);这一篇处理的,是"房子住久了才会浮现的问题":一张只会长胖的遥测表、一堆无人认领的旧数据、一次可能噎死内存的导出、一本从来没人记过的账。
这周三件事:给遥测表分抽屉、给写操作记账本、给数据导出装水龙头——外加一次理直气壮的"构建失败"。每件事都有能搬回你自己系统的经验,建议先收藏。
一、装水龙头:导出十万行,内存只端一只小碗
遥测页面新增了导出功能:选个时间范围,导出 CSV 或 JSON。
功能不难,难的是把它做"稳"。摆在后端面前最直觉的写法是:查出全部数据 → 拼成响应 → 一次性返回。行数少的时候岁月静好;行数一多,等于把一锅饭一次性塞进嘴里——几十万行对象同时躺在 JVM 堆内存里,OOM 不是"会不会",而是"哪一次"。
我们的做法是把它改成"水龙头":细水长流,内存里永远只有一小碗。
三道闸门,按顺序排队:
- 先称重,再开箱。导出前先用
countInRange数一遍:超过 10 万行,直接拒绝并附一句人话提示"请缩小时间范围"。把风险挡在业务开始之前,而不是写到一半才崩。 - 小碗分餐。真正取数走
forEachInRange,每批 1000 行。内存峰值只由批大小决定,和"总共要导多少行"脱钩。 - 边拉边写。CSV 抓着
OutputStream逐行写;JSON 用JsonGenerator流式吐数组。全程不构建"完整结果集"这个中间物。
几个值得抄的细节:
- CSV 开头写 UTF-8 BOM。不加这个三字节,中文 Excel 打开就是经典"锟斤拷"名场面;逗号、引号、换行统一转义。
- 响应头带
X-Total-Count,让前端知道"这锅饭有多少粒";文件名带时间戳,连点两次导出不会互相覆盖。 - 导出 14 列与页面表格严格一一对应,抽样比对验证过——导出文件不是"另一份数据",就是页面那份。
还有一个前端的坑:项目里 axios 统一封装了业务响应拦截器(R<T> 那层),blob 下载不能走它——否则"下载文件"会被当作"业务响应"解析,喜提一个损坏文件。方案是独立 axios 实例 + 解析 Content-Disposition 文件名 + 失败时把 blob 解码回业务错误,把"为什么失败"还给用户。
二、记账本:给每次写操作配一台"行车记录仪"
卫星平台这类系统,"谁改过轨道参数"是必须答得上来的问题。改造之前,答案是:答不上来。
方案本身很轻:一个 @AuditLog 注解 + 一个 AOP 切面。不熟 AOP 也没关系,一句话解释——不用改任何业务代码,在方法的前后自动"夹带"一段记录逻辑。给写操作方法贴个注解,剩下的交给切面。
每笔"账"记录八个要素:谁、何时、对什么资源(含资源 ID)、做了什么、结果如何、失败原因、来自哪个 IP 和 User-Agent、耗时多少。落到 audit_log 表(Flyway V2 迁移,4 个索引,IF NOT EXISTS 保证幂等)。
三个设计决策,每个都配一条"为什么":
- 审计是旁路,不是主链路。落库整段包 try-catch——审计写失败,业务照样成功。记账的不能耽误开车的。
- 匿名请求直接跳过。登录接口那一刻还没有身份,不产生"查无此人"的废记录。
- IP 三级回退:
X-Forwarded-For→X-Real-IP→remoteAddr。请求过了 Nginx 之后,remoteAddr拿到的是代理的地址,得像查快递一样逐级回溯真实来源。
还有一条"克制"原则值得单独说:我们只接了 8 个 Controller、约 30 处写操作与导出;仿真里调速、暂停这类纯展示交互一律不接。审计不是越多越好——每天几万条流水里找一条异常,等于没有审计。
查询侧配套交付:GET /audit-logs(ADMIN 限定)+ 前端审计页(按操作人 / 动作 / 资源 / 结果 / 时间范围筛选)。身份传递用了最轻的方式:userId 挂到 authentication.details 上,不动 principal 类型,存量鉴权代码零感知。
三、自曝:我们主动让构建变红了
这一节讲一次"反直觉"的交付。
覆盖率门禁用的是 JaCoCo:行覆盖 ≥ 70%,service、dataaccess 包 ≥ 80%,绑定在 mvn verify 上。门禁绑定的那天,基线长这样:
| 指标 | 基线值 | 门槛 |
|---|---|---|
| 总行覆盖率 | 10.7%(294/2749) | 70% |
| service.impl | 12.5% | 80% |
| dataaccess / controller | 0% | 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 契约钉死——缓存配置这种东西,"悄悄退化"比"当场报错"可怕得多。
四、分抽屉:遥测表按天归档,过期整屉清走
终于说到这周的主角。
遥测数据是典型的"只进不出":每一秒都在写入,且永远不嫌多。用传统思路清理旧数据,两条路都不好走:
- 不删:查询的扫描量跟着时间一起涨,一年后查一天的数据,数据库要翻遍整个"仓库";
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 全绿。

五、一周数字快照
| 验收项 | 方法 | 结果 |
|---|---|---|
| 后端单元测试 | mvn test | 46/46 全绿(分区相关新增 14 条) |
| 分区裁剪 | EXPLAIN 实测 | 单日窗口仅扫 1 个分区;7 天窗口仅扫 4 个 |
| 存量搬移 | 临时库两阶段验证 | 行数强校验通过,日志留痕 |
| 过期清理 | 启动自愈实跑 | 超期分区自动 DROP |
| 导出上限守护 | 单测 | 超 10 万行拒绝并给出提示 |
| 审计(切面 + 查询) | 单测 8 条 | 成功 / 失败 / 落库失败 / 匿名 / IP 回退全过 |
| 前端门禁 | npm run lint / vue-tsc | 0 error |
| E2E 回归 | npm run test:e2e | 7 passed + 3 skipped |
| 覆盖率门禁 | mvn verify | "诚实的红":4 项违规精确列出(见第三章) |
六、这周最值的 7 个坑(直接抄作业)
- PostgreSQL 分区表的主键必须涵盖分区键列。想写
(id)的请收手,建表会直接失败。 - 大表搬移:游标分批 + 行数强校验,校验不过就整体回滚。宁可重来,不留半成品。
- 清理旧数据用 DROP 分区,不用 DELETE。一个把抽屉整个端走,一个只是在报纸上划了道线。
- 流式导出三件套:先 count 称重 → 分批拉取 → 边写边刷;CSV 记得 UTF-8 BOM,不然中文 Excel 满屏"锟斤拷"。
- 审计切面落库必须 try-catch。审计是旁路,永远不能拖垮主业务。
- 自定义
RedisCacheConfigurationBean 会静默接管 yml 缓存配置。TTL 改了没反应时,先查有没有这个 Bean;再用测试把契约锁死。 - 自动清理必须带防呆护栏:命名格式 + 边界解析双重校验,才配得上"自动"两个字。
七、接下来:M2 主战场
- 告警规则引擎 + 通知中心:阈值 / 区间 / 持续时间三类规则,越限 → 站内信 + WebSocket 推送 → 顶栏铃铛亮起,不再靠人盯屏;
- 认证加固三件套:图形验证码、失败锁定(5 次锁 15 分钟)、密码强度策略;
- 可观测性:Prometheus + Grafana 看板 + Loki 日志检索,关键指标配置告警;
- 还有一件要还的账:补测冲刺,让
mvn verify从"诚实的红"变回"健康的绿"。
写在最后
这三件事没有一件是"卫星专属"——任何系统只要活得够久,都会遇到同样的三堵墙:表在长胖、账没人记、导出随时崩。对应的解药也就三句话:
数据有出口(流式导出)、操作有账本(审计留痕)、旧数据有去处(分区清理)。
如果这篇帮你绕开了哪怕一个坑,欢迎点赞、在看、转发给可能用得上的同事。留言区聊聊:你们的过期数据,是 DELETE 掉的,还是 DROP 分区掉的?
本文关键词:遥测分区 | 流式导出 | 审计日志 | AOP | PostgreSQL | JaCoCo 覆盖率门禁 | Redis 缓存 | 卫星平台 2.0





