模板下载性能优化记录

模板下载性能优化记录

场景:GET /xxxx/downloadTemplate接口下载模版文件耗时长

结论速览:端到端 ~6.5s → 目标 ~1.5-2s(模板渲染优化 7s 稳态 → 2.3-2.8s 离线实测 + DB优化 1s → ~0.15s);

产物逐格比对 0 差异。

1. 背景

xxx.downloadTemplate 是"某业务"模块的 Excel 导入模板下载接口:把 留学单位字典(5,754-6,336 条) 和 学科字典(512 条) 写进一份预置模板(static/模板.xlsx)产出给用户填数据。模板含:

Sheet 内容 规模
填报sheet 14 列填报格 + 列宽 + 公式区(VLOOKUP)+ 5 张水印 + 56 条名称引用/数据验证下拉 与字典无关
国别留学单位对应关系数据 表头 + 5 列主数据(单位码/中名/英名/国别码/国别名)+ 4 组三列辅助键键对(下拉字典) ~1.3 万行,1.4MB
二级学科代码对应关系数据 名称/代码两列键对 ~1,024 行
其他sheet 静态 41 行
填写说明 静态 文本

用户反馈下载模板明显卡顿,期望:先测耗时 → 定位瓶颈 → 优化。

约束:不要成品缓存;倾向按"导出数据那种流式方式"。

测试环境数据:6,336 单位(辅助键 2 行/条 → 字典页 1.2 万+行)。

2. 耗时点分析

2.1 测量方法(可复现)

  1. 线上分段日志 :xxxx.downloadTemplate 加 DB 计时与端到端 INFO 日志;XXX.write() 内加各阶段毫秒统计。运行中的服务由 IDEA 启动.
  2. 离线基准 :从运行中进程反查完整 classpath(ps -p <pid> -ww -o command= 提取,存 /tmp/proccmd.txt),用生产同款依赖在 /tmp/bench-classes 里写基准程序(TplBench/RealFull/StreamCmp),规避 IDEA 冷启动/JIT 噪声。
  3. JFR + jstack:确认热点线程栈与分配大户。
  4. JDBC 只读探针 :/tmp/sqlbench/SqlProbe 直连测试库(xxxx:1521/ORCL)对同一条 SQL 变体计时,绕开 MyBatis/连接池噪声。

2.2 第一轮数据(旧实现 + 日志埋点后)

线上真实请求(含 DB):

  • 总耗时 ~6-6.5s,剔除 DB 后渲染 ~5.4s。

离线稳态(模板渲染部分):

阶段 耗时
模板 POI 解析 0.7-1.0s
fillX1Sheet 写单位字典 2.6-4.8s
fillX2Sheet 0.02-0.07s
refreshFillSheet 公式刷新 0.7-1.0s
水印 ~0.01s
保护 + 序列化 2.0-3.0s
合计 ~7.0s

线上 DB 部分(当时):

scss 复制代码
downloadTemplate[DB耗时] 外国单位字典(5754条)=951ms 学科字典(512条)=76ms 合计=1027ms

2.3 热点归因(JFR 及代码走查结论)

耗时点 根因
fillX1Sheet 2.6-4.8s 57,540 格 逐格 setCellValue:XSSF 生 CTRst(富文本) 节点 + 加入 stylesTable,纯 XMLBeans 开销;与富文本是否实际用无关
序列化 2.0-3.0s 字典页 1.2 万行的 XSSF DOM 也要走一遍 XMLUtil.load → serialize,产出 6-7MB XML 字符串
样式 ~0.5s 每格 inheritStyle 要靠 sheet.getRow(r-1).getCell(c) 链式取上一行样式(触发 row/cell 缓存重建)
公式刷新 0.45-1.0s 14 列 × N 公式区 setCellFormula 重建命名区域引用
模板解析 0.7-1.0s 字典页 12.6k 行 DOM 一次性载入
DB ~1s Oracle JDBC 默认 fetchSize=10 → 5,754 行 = 576 次网络往返

3. 方案对比

方案 预期 风险 决定
A. 逐格级微优化(样式 memo、取消冗余) 省 ~0.5-1s 低 先落地验机制 ✔
B. 流式重写(本记录采用) :POI 只处理必须 DOM 的部分(公式/名称/水印/保护),字典页改为"从模板 zip 原样取 XML,直接拼 <sheetData> 并替换" 大头(写 6.3 万格 + 序列化)整体绕开 中 ✅
C. 全 zip 级公式改写(跳过 POI 模板解析 0.7s) 再省 0.7s 高(公式刷新正确性) ❌ 搁置
D. 成品级缓存(按字典内容哈希缓存 workbook,水印后注入) 再省 ~1.5-2s 高(缓存一致性 + 水印部件 zip 级注入) ❌ 用户明确不要缓存
E. DB fetchSize 省 ~0.9s 极低 ✅

4. 源码分析与问题定位

4.1 旧实现结构

Xxx.write(...) 流程:

scss 复制代码
load template (XSS) → fillX1Sheet (5 列主数据 + 4 组三列键对逐格写) → fillSubjectSheet
  → refreshFillSheet(重算命名公式区) → applyWaterMark ×5 → protectDictSheet ×2 → workbook.write(out)
  • inheritStyle:新建格从"上一行同列"取样式(链式语义:链上样式会一直滚到很深,不是只回看"模板静态行")。实测首版流式实现按"只回看模板静态行"来算样式,结果在第 3,079 行起与旧实现产生 1.6 万格样式差------这是本次最核心的一处语义还原(见 §5.2)。
  • clearBelow:模板静态区以外的残留行需清理(清除但不发送格)。
  • numberOrText:数字判定,纯数字格走数值格,其余走字符串格。

4.2 新实现结构(XxxImportTemplateStream.java, 新增类)

lua 复制代码
阶段一(POI 轻量 DOM):
  load 模板 → 布局计算 → 56 条名称引用 → 刷新填报页公式区,
  同时把两个字典页 DOM 整段替换为空 <sheetData/>(CTSheetData.Factory.newInstance()),
  反射清掉 XSSF 内部 _rows 緩存回写 null → workbook.write(...) 一次性序列化为 byte[]

阶段二(zip 级手术):
  打开模板原 .zip(ZipInputStream)逐个 entry 流式重写,
  命中字典 sheet 的 part(/xl/worksheets/sheetN.xml)时:
     取出原有 <sheetData> 前后骨架 → 按字典数据重新生成 <sheetData> 中段 → 替换;
  其他 part 原字节透传 → 最终 ZipOutputStream 产出 xlsx

关键工程点:

  • 位置回指 :模板的字典页对应 zip 中哪个 sheetN.xml,靠 workbook.xml 里的 rel 顺序解析(sheet1 = 回国人员数据填报 等),避免硬编码。
  • 样式滚动链 chainSource :不再查 DOM,改用 Map<col, style> 滚动上一行的实际落盘样式,完全复刻旧 inheritStyle 的链式语义(模板该行该列已有格→用其 s;否则新建格→沿旧实现取链上值,不存在则省略 s 属性)。
  • 数值/文本 :numberOrText 复刻。
  • 末行端点 :以"主数据尾 + 键对尾 + 列内容尾"的最大值为 sheet 长度,和旧 lastRow 语义一致。

4.3 A/B 验证口径

写了个比较器 StreamCmp:分别以同一 UI 传跑旧实现 + 新实现(6,336 单位/512 学科),对产物做逐格比对(值/公式/样式索引/格与行存在性)+ 名称引用逐条比对。

迭代过程:

  • v1:值/公式一致,但 sheet2/3 出现 1.6 万格 样式 '10' vs '0' 差 ------ 发现是 §4.1 所说的链式样式未复刻 → 引入 chainSource;
  • v2:全部 5 个 sheet diffs=0,名称引用 56/56 一致。

离线时长对比(阶段一致并发 JIT 预热):

变体 run0 run1 run2 输出体积
旧(稳态) --- --- ~7.0s 880KB
新(稳态) 3.9s 2.76s 2.32s 1.41MB

体积 +60% 原因:字典页字符串格用 inlineStr 内嵌,不占用共享字符串表去重(或共享字符串表同步逻辑过于棘手)。体积可接受,也可后续走"重新生成共享字符串表部件"路径回收。

4.4 DB 层(fetchSize)

线上直接证据:

scss 复制代码
外国单位字典(5754条)=951ms ...

ZzsDictMapper.temlate() 一条 SQL 5,754 行就带 ~1s。JDBC 探针实证:

变体 行数 耗时
现行 SQL + 默认 fetchSize(10) 5,754 0.95-1.9s
现行 SQL + fetchSize=2000 5,754 0.07-0.12s
SQL 改写(投影+EXISTS)+ fetch=2000 5,754 0.07-0.10s

结论:问题不在 SQL 语句本身,在 ojdbc 默认 fetchSize=10 (5,754 行 = 576 次串行 round-trip,网络税纯空转)。已在 temlate()、searchSubject 上加 @Options(fetchSize = 2000);同时为 temlate() 补 ORDER BY a.CODE_ID(旧行为为 searchForeignUnit + 内存过滤,尾部带 ORDER BY,整数链滚动行序依赖这个确定性)。listCountries/listCurrencies 行数较小未动。

4.5 其它(模板解析 0.6-0.9s)

这颗"税"来源是刷新填报页公式就必然 load 全簿(含 1.2 万行字典页 DOM)。要消掉它必须连着公式刷新一起改成"zip 级公式文本手术"(方案 C),正确性风险大于收益,本次搁置。


5. 落地结果

5.1 汇总

环节 旧 新(本次优化后)
模板解析 0.7-1.0s 0.6-0.9s
字典 6.3 万格写入 2.6-4.8s 0(直接拼 XML)
公式刷新 0.7-1.0s 0.45s
水印+保护 0.1-0.2s 0.18s
序列化 2.0-3.0s 0.2-0.25s
dictXml 生成 --- 0.6s
重打包 zip --- 0.26-0.39s
渲染合计 ~7s ~2.3-2.8s
DB ~1.0s ~0.15s
端到端 ~6-7s 目标 ~2s 级

5.2 关键语义还原清单(A/B 必须逐项过)

语义 旧实现出处 新实现对应
样式滚动链 inheritStyle(上一行同列实际样式,可深滚) chainSource
数字/文本判定 numberOrText emitNumericOrText
跳空格/清理残留 clearBelow 字典页行生成时的空行/空格逻辑
末行 endpoint lastRow 计算 主数据/键对/列尾最大值
命名引用 56 条 writeKeyRow 里的命名区间 阶段一 POI 直接写入
列宽/水印/保护 POI 操作 阶段一 POI 直接写入

5. 总结与经验

  1. 别把"Excel 写入慢"全算到 POI 头上 :POI/XSSF 慢的根因是 XMLBeans + RichText 字符串逐格建节点的税,而非真正的格式化工作。重数据量的 sheet 与"要精细 DOM 编辑"的 sheet 分离------前者走 zip 级流式字段拼装,后者保留 POI 曲线救国,是这次压到 3 倍提升的核心思路。
  2. DB 慢先量 fetchSize 再看 SQL :5,754 行 + MyBatis 默认走 ojdbc fetchSize=10,谁都会"莫名"掉进这个网络税坑;一行 @Options(fetchSize=...) 收益大过任何 SQL 改写。
  3. 链式样式语义是最容易翻车的一处 :grubby 级逐格 A/B 对比(值/公式/样式/存在性四个维度)在交付前是唯一刚需。
  4. 体积换时间是可接受的取舍:1MB 已在 Attach 大小可接受区间内,成品体积不是本次瓶颈。
  5. 可复现的数据链路:线上分段日志 + 离线同依赖基准 + JDBC 只读探针三件套,让"慢在哪"从猜测变成可验证。
相关推荐
兔子零102441 分钟前
架构师最危险的能力:用正确的技术,做出错误的架构决策
前端·javascript·后端
执团搓锐44 分钟前
个人知识库助手
后端
楼南1 小时前
数字化体验:从下单到收获的全过程
后端
武子康1 小时前
Coding Agent 的知识也会漂移:用固定快照检查 Vercel Plugin 升级
人工智能·后端·agent
货拉拉技术1 小时前
JDK 21 规模化落地实践
后端
isfox1 小时前
MapTask 工作机制:从读数据到写磁盘,一个 Map 任务的完整一生
后端
aibo8603081 小时前
基于 Spring Boot 的茶铺管理系统设计与实现
java·spring boot·后端
掘金酱2 小时前
带着作品来,坐下来聊|掘金 X-lab · Coffee Talk 邀请你来见一面
前端·javascript·后端
唐青枫3 小时前
企业知识库 RAG 实战:混合检索、权限过滤与答案溯源
后端