写入分配砍掉 63%:Acl.Excel 绕过 XmlWriter 字节直写
写入分配砍掉 63%:Acl.Excel 绕过 XmlWriter 字节直写
本文力求客观:既呈现 Acl.Excel 的实测数据,也如实标注测试口径、版本差异与已知局限,供读者自行判断。
口径说明:10K/100K × 7 基准、分配降 63%/Gen0 降 60%/-68.5% 来自 EngineAb 实测;局限:小规模延迟基本持平。
Excel系列文章 第7篇 / 共11篇
系列导航:← 上一篇:零装箱设计 · 下一篇:DEFLATE 引擎 →
本系列第四篇解析 Acl.Excel 的写入管线,从 Utf8RawWriter 字节直写到三种压缩策略,从多 Sheet 并行写入到增量持久化与原子替换,逐层剖析写入路径的性能优化。
导出 100 万行 Excel 时,你有没有遇到过这样的情形:CPU 占用并不高,内存却一路飙升、GC 频繁、程序越跑越慢?问题往往不在你的数据量,而在写入库中间那层层封装带来的额外编码转换与缓冲分配。
本文逐层拆开 Acl.Excel 的写入引擎,你会看到它如何砍掉 XmlWriter/StreamWriter 两层封装、用字节直写把分配压低 63%,以及并行写入、增量保存这一整套组合拳,是怎么做到百万行级恒定内存的。
核心结论
- 写入管线砍掉XmlWriter/StreamWriter两层,把固定 XML 结构预编译为byte[]常量,运行期零编码开销。
-Utf8RawWriter字节直写让分配降 63%、Gen0 降 60%;规模越大,XML 生成降幅越明显(最高 -68.5%)。
- 三种压缩策略(Throughput / Balanced / Compression)在吞吐与压缩比之间取舍,性价比首选Balanced。
- 三种共享字符串模式(Inline / Shared / Auto)按重复率自动去重,SST 上限防止内存暴涨。
- 多 Sheet 并行写入 + 增量持久化 + 原子替换,让大规模写入既快又安全。
写入 Excel 文件时,大多数库的路径是:模型对象 → 序列化为字符串 → XmlWriter → StreamWriter → DeflateStream → 文件。这条路径中的每一层都有额外的编码转换和缓冲分配。Acl.Excel 的写入管线砍掉中间两层(XmlWriter 和 StreamWriter),直接使用 Utf8RawWriter 写入 UTF-8 字节,同时将所有固定 XML 结构预编译为 byte[] 常量,在运行期零编码开销。
一、写入管线:五层模型一览
先把整条写入路径摊开来看,它可以被拆成五个层次:
TModel 集合
│
▼
[映射层] CompiledRowMapper
模型属性 → CellValue[] 写入缓冲区
│
▼
[XML 写入层] Utf8RawWriter
字节级直写 Worksheet XML(绕过 XmlWriter/StreamWriter)
│
▼
[压缩层] IZipEntrySink
按 CompressionStrategy 分发:
├─ Throughput → DotNetZipSink (.NET 内置 deflate)
├─ Balanced → LibDeflateZipSink (libdeflate level 1 + Optimal)
└─ Compression → LibDeflateZipSink (libdeflate level 12 + SmallestSize)
│
▼
[持久化层] XlsxWriter
多 Sheet 并行写入 → ZIP 组装 → 原子替换
│
▼
.xlsx 文件
为什么要把它拆成五层来说?因为后面的每一项优化,都精确落在其中的某一层上:字节直写发生在 XML 写入层,压缩取舍发生在压缩层,并行与原子替换发生在持久化层。
二、Utf8RawWriter:绕过 XmlWriter 的字节直写
StreamWriter + XmlWriter 组合在写入时需要处理字符编码转换、XML 转义、元素嵌套等,每次 WriteStartElement / WriteString 调用都会产生中间字符串分配。Utf8RawWriter 的思路是:预编译所有固定 XML 结构为 byte[],运行期直接写入字节流,只在数据部分动态编码。
这意味着那些永远不变的 XML 骨架(行标签、单元格标签、值标签)不再需要每次重新编码,真正需要动态处理的,只有行号、单元格引用和单元格值。
// 预编译的常量片段(构造期一次性 UTF-8 编码)
private static readonly byte[] _rowOpen = "<row r=\""u8; // 编译时内联
private static readonly byte[] _rowClose = "</row>"u8;
private static readonly byte[] _cOpen = "<c r=\""u8;
private static readonly byte[] _cClose = "</c>"u8;
private static readonly byte[] _vOpen = "<v>"u8;
private static readonly byte[] _vClose = "</v>"u8;
运行期写入一行数据的流程:
// 伪代码
writer.Write(_rowOpen);
writer.Write(rowNumber); // 动态编码行号
writer.Write(_rowClosePrefix);
for each cell:
writer.Write(_cOpen);
writer.Write(cellReference); // 动态编码单元格引用
writer.Write(_cClosePrefix);
writer.Write(_vOpen);
writer.Write(cellValue); // 动态编码单元格值
writer.Write(_vClose);
writer.Write(_cClose);
writer.Write(_rowClose);
每个 Write(byte[]) 调用都是一次直接的 Stream.Write,不经过任何编码转换或字符缓冲。换句话说,写一行单元格的 XML,就是若干次"把预编译好的字节块拼起来 + 给数据部分单独编码",没有中间字符串、没有重复编码。
2.1 实测收益:规模越大,优势越明显
在 EngineAb 基准测试中(10K 行 x 7 列,worksheet → DeflateStream):
| 指标 | StreamWriter + XmlWriter | Utf8RawWriter | 变化 |
|---|---|---|---|
| Mean | 39.23 ms | 38.66 ms | 基本持平 |
| Allocated | 4.60 MB | 1.69 MB | 降 63% |
| Gen0 | 533 | 214 | 降 60% |
核心价值在内存/GC 压力而非延迟。在 100K 行 x 7 列的更大规模测试中,延迟差异开始显现:
| 数据集 | 端到端 | Utf8RawWriter | 旧 XmlWriter | XML 降幅 |
|---|---|---|---|---|
| 数值为主 | 202ms | 110ms | 173ms | -36.4% |
| 文本为主 | 197ms | 123ms | 254ms | -51.7% |
| 混合模型 | 117ms | 57ms | 180ms | -68.5% |
数据量越大,Utf8RawWriter 的分配优势越明显。文本为主的场景降幅最大,因为 XmlWriter 在文本场景下字符串分配开销最高。
三、三种压缩策略:吞吐与压缩比如何取舍
写入侧 CompressionStrategy 控制 ZIP 条目的压缩实现:
| 策略 | 实现 | 压缩级别 | 策略参数 | 适用场景 |
|---|---|---|---|---|
Throughput |
DotNetZipSink |
.NET 内置 | — | 吞吐优先,文件大小次要 |
Balanced |
LibDeflateZipSink |
Level 1 | Optimal | 吞吐/压缩比均衡 |
Compression |
LibDeflateZipSink |
Level 12 | SmallestSize | 最高压缩比 |
Balanced 模式使用 libdeflate level 1 + Optimal 策略,写入速度约 .NET 内置的 2 倍,文件大小仅增加约 14%。Compression 模式使用 level 12 + SmallestSize 策略,压缩比最高,但吞吐较低。
选择建议:
- 数据管道 / 实时导出:Throughput(库默认)或 Balanced
- 归档存储 / 文件分发:Compression
- 推荐场景:Balanced(性价比最优)
四、三种共享字符串模式:去重还是内联
写入侧的 SharedStringsMode 控制字符串在 XLSX 中的存储方式:
| 模式 | 行为 | 分配 | 文件大小 |
|---|---|---|---|
Inline(默认) |
字符串直接写入单元格 | 最低 | 较高(无去重) |
Shared |
强制共享字符串表 | 较高(SST 构建) | 较低(高重复文本) |
Auto |
自适应:重复率 ≥30% 启用 | 按需 | 自适应 |
Auto 模式在首遍扫描时统计字符串重复率,超过 30% 阈值时自动切换到共享字符串表。SST 条目数有上限控制(默认 1,000,000),防止极端重复导致内存暴涨。
五、并行写入:多 Sheet 场景如何提速
当写入的 Sheet 数 ≥3 时,XlsxWriter 自动启用 Parallel.For 并行生成各 Sheet 的 XML:
串行阶段(主线程) 并行阶段(ThreadPool) 串行阶段(主线程)
┌──────────────────┐ ┌─────────────────────────┐ ┌──────────────────┐
│ 准备 ZIP 结构 │ │ Sheet 1: 生成 XML + 压缩 │ │ 组装最终 ZIP │
│ 写入固定 XML 条目 │ → │ Sheet 2: 生成 XML + 压缩 │ → │ 原子替换原文件 │
│ Content_Types │ │ Sheet 3: 生成 XML + 压缩 │ │ │
└──────────────────┘ └─────────────────────────┘ └──────────────────┘
关键优化:并行阶段各 Sheet 写入独立的 MemoryStream,串行阶段使用零拷贝路径:GetBuffer() + Length 直接写入 ZIP 输出流,不经过额外拷贝。
六、增量持久化与原子替换:崩溃也不丢文件
Workbook 支持编辑模式:打开已有文件,修改部分 Sheet,保存时只重写变更的 Sheet,未变更的 Sheet 从原 ZIP 字节直接拷贝。
原文件: [Sheet1] [Sheet2] [Sheet3] [SharedStrings] [Styles]
↓ 修改 Sheet1
保存流程:
1. 拷贝 Sheet2、Sheet3 的原始 ZIP 条目(零解压/零重压缩)
2. 重写 Sheet1 的新 XML + 压缩
3. 更新 SharedStrings(如有变更)
4. 写入 .tmp 文件
5. 成功 → 原子替换原文件(File.Move .tmp → .xlsx)
原子替换确保写入过程中崩溃不会损坏原文件。先写 .tmp,成功后 File.Move 替换,这是一次文件系统元数据操作,在 NTFS 上具有原子性保证。
七、关键收获
Utf8RawWriter用字节直写绕过XmlWriter,核心收益是降低分配与 GC 压力,而非单纯提速(10K 行场景延迟基本持平,但分配降 63%)。- 数据规模越大,字节直写的分配优势越明显;文本为主场景降幅最大(-68.5%)。
- 压缩策略按场景选:实时导出用
Throughput/Balanced,归档分发用Compression,性价比首选Balanced。 - 共享字符串
Auto模式按重复率(≥30%)自动去重,SST 上限 1,000,000 防止内存暴涨。 - 并行写入 + 增量保存 + 原子替换,让大规模写入既快又安全,中途崩溃也不丢原文件。
免责声明:本文基于 Acl.Excel 4.0.0(2026-08-05 前后)实测数据撰写,测试环境与口径见正文;数据可能随版本演进变化,重要决策请自行复测核验。
客观性说明:本文的结论基于以下事实约束,而非自诩无偏:① 分配降 63%、Gen0 降 60%、XML 生成降幅最高 -68.5% 均来自 EngineAb 基准(10K/100K 行 × 7 列)实测;② 小规模(10K 行)场景下延迟与旧实现基本持平,核心收益集中于内存/GC 压力;③ 压缩级别与跨篇档位存在待作者确认项(见文末 ⚠ 注释),已按各自原文保留。以上局限已在正文相应位置如实标注,供读者结合完整信息自行判断。
系列导航
Acl.Excel 系列文章(共 11 篇)
序号 文章 重点 ① 从 libdeflate 到纯 C#:Acl.Excel DEFLATE 解压/压缩器的移植与优化之旅 纯 C# 移植 libdeflate ② 从 libdeflate 到纯 C#(续):优化纯 C# DEFLATE 压缩器——从 1.6x 到 4.9x 的提速之路 14 轮优化提速 ③ Acl.Excel vs MiniExcel 1.45.0:性能对比与选型分析(.NET 8 基准) 百万行对等实测 ④ 不依赖 NPOI/ClosedXML,纯 C# Excel 库如何做到 30 倍性能? 概述与设计哲学 ⑤ 百万行Excel如何恒定内存?Acl.Excel流式拆解 读取引擎流式管线 ⑥ 24B消灭上亿次装箱:Acl.Excel零装箱设计 24B CellValue 零装箱 ⑦ 写入分配砍掉 63%:Acl.Excel 绕过 XmlWriter 字节直写 字节直写与并行写入(本文) ⑧ 零依赖纯 C# DEFLATE 引擎:2916 行移植 libdeflate DEFLATE 引擎移植 ⑨ 一个 42KB 的 Excel 如何撑爆 4.5PB?Acl.Excel 8 层防御 安全纵深防御 ⑩ Acl.Excel 跨库基准:读取最高快 32.8 倍 跨库性能基准 ⑪ 从实战到生产:Acl.Excel 八大场景与避坑指南(终篇) 实战与避坑指南 建议按 ①→⑪ 顺序阅读,从底层算法到实战落地形成完整认知。(当前本篇为第 7 篇,已以 粗体 标记。)
下一篇预告
下一篇将深入 Acl.Excel 最底层的技术基石:纯 C# 实现的 DEFLATE 压缩/解压引擎,从 hash-chain 匹配查找到 7-Zip 风格 Huffman 编码,完整讲述从 C 语言 libdeflate 1.25 移植到纯 C# 的工程实践。
标签(建议):Acl.Excel, .NET, 性能优化, Excel 处理, 写入引擎

浙公网安备 33010602011771号