模板下载性能优化记录
场景:
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 测量方法(可复现)
- 线上分段日志 :
xxxx.downloadTemplate加 DB 计时与端到端 INFO 日志;XXX.write()内加各阶段毫秒统计。运行中的服务由 IDEA 启动. - 离线基准 :从运行中进程反查完整 classpath(
ps -p <pid> -ww -o command=提取,存/tmp/proccmd.txt),用生产同款依赖在/tmp/bench-classes里写基准程序(TplBench/RealFull/StreamCmp),规避 IDEA 冷启动/JIT 噪声。 - JFR + jstack:确认热点线程栈与分配大户。
- 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. 总结与经验
- 别把"Excel 写入慢"全算到 POI 头上 :POI/XSSF 慢的根因是 XMLBeans + RichText 字符串逐格建节点的税,而非真正的格式化工作。重数据量的 sheet 与"要精细 DOM 编辑"的 sheet 分离------前者走 zip 级流式字段拼装,后者保留 POI 曲线救国,是这次压到 3 倍提升的核心思路。
- DB 慢先量 fetchSize 再看 SQL :5,754 行 + MyBatis 默认走 ojdbc fetchSize=10,谁都会"莫名"掉进这个网络税坑;一行
@Options(fetchSize=...)收益大过任何 SQL 改写。 - 链式样式语义是最容易翻车的一处 :
grubby级逐格 A/B 对比(值/公式/样式/存在性四个维度)在交付前是唯一刚需。 - 体积换时间是可接受的取舍:1MB 已在 Attach 大小可接受区间内,成品体积不是本次瓶颈。
- 可复现的数据链路:线上分段日志 + 离线同依赖基准 + JDBC 只读探针三件套,让"慢在哪"从猜测变成可验证。