fast_xlsx:用 Rust 寫的 Ruby xlsx 產生器,比 caxlsx 快 9 倍

Rails 專案幾乎都逃不掉「匯出 Excel」這個需求。最常見的選擇是 caxlsx,功能齊全,但資料一多就慢,記憶體也跟著暴衝。要快的話有 fast_excel,底層是 C 的 libxlsxwriter,速度確實好,只是安裝要編 C、走 FFI,日期不給格式就會顯示成一串數字,而且最後一次更新停在 2025 年 4 月。
fast_xlsx 是 fast_excel 的精神續作:一樣主打「快速逐列寫入、吃很少記憶體」,底層換成 Rust 的 rust_xlsxwriter,再用 magnus 包成 Ruby native extension。20,000 列的匯出只要 75 ms,比 caxlsx 快將近 9 倍。
跑分
Apple Silicon、Ruby 4.0.5,每個 gem 都用它自己最慣用的逐列寫入 API。20,000 列 × 5 欄(整數、字串、整數、Time、浮點數),建好並輸出成 String,取 7 次的中位數:
| Library | 時間 | 倍數 | 配置的 Ruby 物件 |
|---|---|---|---|
fast_xlsx(memory: :constant) | 75 ms | 1.0x | 7 |
| fast_xlsx(預設) | 85 ms | 1.1x | 10 |
| xlsxtream 3.1 | 173 ms | 2.3x | 561,740 |
| fast_excel 0.5(constant_memory) | 204 ms | 2.7x | 20,079 |
| fast_excel 0.5 | 238 ms | 3.2x | 320,076 |
| write_xlsx 1.15 | 592 ms | 7.9x | 1,483,899 |
| caxlsx 4.5 | 671 ms | 8.9x | 745,122 |
| rubyXL 3.4 | 2571 ms | 34.1x | 8,700,448 |
時間之外,最右邊那欄更值得看:整份檔案只配置了 7 個 Ruby 物件。每一列的值在 Rust 那邊就直接轉成儲存格,不會在 Ruby 這端產生一堆中間物件,GC 幾乎沒事做。
記憶體則是 200,000 列存成檔案,量峰值 RSS 比只建資料的 process 多了多少:
| Library | 每列字串都不同 | 字串大量重複 |
|---|---|---|
fast_xlsx(memory: :constant) | +1 MB | +1 MB |
fast_xlsx(memory: :low) | +61 MB | +1 MB |
| fast_xlsx(預設) | +271 MB | +217 MB |
| fast_excel 0.5(constant_memory) | +10 MB | +9 MB |
| fast_excel 0.5 | +182 MB | +151 MB |
預設模式反而比 fast_excel 吃記憶體,原因是 rust_xlsxwriter 存檔時會在記憶體裡把每張工作表的 XML 組好(這樣多張工作表可以平行處理),不是從暫存檔串流出去。所以大量匯出請直接用 :constant 或 :low。
三種記憶體模式
FastXlsx::Workbook.new # :standard,全部放記憶體,可以任意順序寫入
FastXlsx::Workbook.new(memory: :constant) # 寫完的列丟到暫存檔,字串直接存在儲存格裡
FastXlsx::Workbook.new(memory: :low) # 寫完的列丟到暫存檔,字串放 Excel 的 shared string table
怎麼選:
:standard:一般報表、需要回頭改前面的列,或要用autofit自動算欄寬。:constant:大量匯出、逐列往下寫。記憶體不管資料多大都是平的,也是最快的模式。缺點是 inline string 不是每個讀取工具都支援(例如 xsv)。:low:大量匯出、而且檔案要給其他程式讀。地區、狀態這種重複字串多的資料,記憶體一樣很低,輸出也是標準格式。
兩種存到磁碟的模式都只能由上往下寫,回頭寫已經存到磁碟的列會直接丟 FastXlsx::Error,不會默默吞掉。
基本用法
require "fast_xlsx"
wb = FastXlsx::Workbook.new
ws = wb.add_worksheet("Sales")
header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin)
money = FastXlsx::Format.new(num_format: "#,##0.00")
ws.append(["Region", "Product", "Units", "Price", "Sold on"], format: header)
sales.each do |s|
ws.append([s.region, s.product, s.units, s.price, s.sold_on],
format: [nil, nil, nil, money, nil])
end
ws.autofit
ws.freeze_panes("A2")
ws.autofilter("A1:E#{sales.size + 1}")
wb.save("sales.xlsx") # 或 wb.to_xlsx 拿到 binary String
格式也可以直接傳 Hash,相同內容的 Hash 會共用同一個格式:
ws.append(["Total", 1_234], format: { bold: true })
title = header.merge(font_size: 16)
Rails controller 裡大概長這樣:
def export
wb = FastXlsx::Workbook.new(memory: :constant)
ws = wb.add_worksheet("Orders")
ws.append(["Order", "Customer", "Total", "Placed at"], format: { bold: true })
Order.includes(:customer).find_each { |o| ws << [o.number, o.customer.name, o.total, o.created_at] }
send_data wb.to_xlsx, filename: "orders.xlsx",
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
end
除了寫資料,條件式格式、資料驗證(下拉選單)、表格、圖表、圖片、註解、工作表保護、列印設定、大綱群組也都有對應的 Ruby 方法,完整清單看 README。
幾個刻意的設計
日期不用設格式就是日期
用 fast_excel 時最常踩的坑:created_at 沒給 date format,打開 Excel 看到的是 46298.5 這種序號。fast_xlsx 對 Date 預設套 yyyy-mm-dd,Time 套 yyyy-mm-dd hh:mm:ss。如果你給的格式只有粗體沒有 num_format,也會自動補上日期格式,不會因為加了粗體就變回數字。
Rails 的 ActiveSupport::TimeWithZone 不是真正的 Time,0.8.1 之前會被當成字串寫出去,現在已經修好,created_at 直接丟進去就對了。
存檔不卡 GVL
匯出時大部分時間其實花在存檔:組 XML、壓縮。to_xlsx 和 save 做這段時會釋放 Ruby 的 global lock,所以在 Puma 或 Sidekiq 這種多執行緒環境下,一個大匯出不會把同一個 process 裡的其他 request 卡住。代價是 Ctrl-C、Timeout 要等存檔結束才會生效。
錯誤一律早點丟
選項打錯字、顏色格式不對、超過 Excel 上限(單格 32,767 字、1,048,576 列、16,384 欄)都會在寫入前就丟例外,不會寫到一半才壞,也不會默默忽略。錯誤類別也照規則分:型別錯丟 TypeError、超出工作表範圍丟 RangeError、值不合法丟 ArgumentError,工作簿本身做不到的事才丟 FastXlsx::Error。
預先編好的 gem
Linux(x86_64、aarch64,glibc 和 musl)、macOS(arm64、x86_64)、Windows x64 都有預編譯的 gem,bundle add fast_xlsx 就好,不用裝 Rust toolchain 也不用 C compiler。需要 CRuby 3.3 以上。
從 fast_excel 搬過來
大部分只是改名:
# fast_excel
workbook = FastExcel.open(constant_memory: true)
worksheet = workbook.add_worksheet("Orders")
worksheet.append_row(["Order", "Total", "Placed at"], workbook.bold_format)
send_data workbook.read_string, filename: "orders.xlsx"
# fast_xlsx
workbook = FastXlsx::Workbook.new(memory: :constant)
worksheet = workbook.add_worksheet("Orders")
worksheet.append(["Order", "Total", "Placed at"], format: { bold: true })
send_data workbook.to_xlsx, filename: "orders.xlsx"
完整的對照表和行為差異整理在 Migrating from fast_excel。
測試:連 Rust 端都跑 mutation test
Ruby 測試只能從外面打 API,Rust extension 裡的邏輯到底有沒有被測到不太好確認。所以除了用 cargo-llvm-cov 量 Ruby 測試跑過 Rust 程式碼的覆蓋率,還加上 cargo-mutants:它一次改 Rust 原始碼的一小處(把 < 換成 <=、把回傳值換掉之類),再跑整套 Ruby 測試,確認每個改動都會讓某個測試失敗。沒抓到的 mutant 就代表有行為沒人測,每週在 CI 上自動跑一次。
收尾
目前版本是 0.8.1,API 從 0.8 起凍結,0.8.x 只修 bug,實際用一陣子沒需要改動就會發 1.0。如果你的 Rails 專案還在用 caxlsx 匯出大報表,或卡在 fast_excel 的日期和安裝問題,可以換換看。