写入分配砍掉 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 文件时,大多数库的路径是:模型对象 → 序列化为字符串 → XmlWriterStreamWriterDeflateStream → 文件。这条路径中的每一层都有额外的编码转换和缓冲分配。Acl.Excel 的写入管线砍掉中间两层(XmlWriterStreamWriter),直接使用 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 处理, 写入引擎

posted @ 2026-08-25 10:41  风云  阅读(2)  评论(0)    收藏  举报