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

2026-10-03
fast_xlsx:Blazing Fast Excel for Ruby

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 ms1.0x7
fast_xlsx(預設)85 ms1.1x10
xlsxtream 3.1173 ms2.3x561,740
fast_excel 0.5(constant_memory)204 ms2.7x20,079
fast_excel 0.5238 ms3.2x320,076
write_xlsx 1.15592 ms7.9x1,483,899
caxlsx 4.5671 ms8.9x745,122
rubyXL 3.42571 ms34.1x8,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 的日期和安裝問題,可以換換看。

https://blog.2ac.io/posts/feed.xml