SQL Server 大量寫入(bcp / Bulk Copy)與 TDS 協定

為什麼大量寫入 SQL Server 時 bcp 比逐筆 INSERT 快:bulk copy 路徑、底層 TDS Bulk Load 機制、最小記錄與上線策略


目錄


為什麼大量寫入時 bcp 特別快

把大量資料寫進 SQL Server 時,逐筆 INSERT 的速度會隨資料量線性惡化;改用 bcp 或 bulk copy 介面,往往能快上一兩個數量級。差距不是來自「指令比較好」,而是來自一條完全不同的寫入路徑:bulk load 在底層走的是 TDS(Tabular Data Stream)協定中專用的批次資料串流,繞過了逐筆 INSERT 在網路、解析、交易記錄上的固定開銷。

理解這件事需要三層拼起來:

工具層     bcp / SqlBulkCopy / BULK INSERT
   ↓
協定層     TDS Bulk Load Request(COLMETADATA + ROW token 串流)
   ↓
儲存層     最小記錄(minimal logging)+ 批次配置 extent

這篇由上而下把這三層講清楚。


三條大量寫入路徑

把「大量寫入」拆開,其實有三條不同的路徑,前兩條在線路上送的是同一個 TDS Bulk Load Request:

路徑 是什麼 需要資料檔? 資料從哪來 線路上送什麼
bcp 工具 命令列程式,讀/寫資料檔;本身是底層 bulk copy API 的 CLI 外殼(現代版建在 ODBC 之上) 要 檔案 Bulk Load Request
程式內 Bulk Copy API 在程式裡呼叫的類別/函式:.NET SqlBulkCopy、JDBC SQLServerBulkCopy、ODBC bcp_*、OLE DB IRowsetFastLoad 不用 程式記憶體 同一個 Bulk Load Request
BULK INSERT / OPENROWSET(BULK) T-SQL 指令,由 伺服器端讀檔 要(伺服器看得到的路徑) 伺服器本機/共享路徑 一般 SQL Batch(資料在伺服器端讀入)

關鍵區別

  • bcp 工具 vs 程式內 API:兩者最終都在 TDS 上送出 Bulk Load Request,速度上限相同。差別在前置成本——bcp 需要先把資料落地成檔案、再啟動一個外部程序;程式內 API 直接從記憶體串流,省掉檔案那一步。資料已經在程式裡時,用 SqlBulkCopy 這類 API 可避免「寫檔 → 讀檔」的多餘 I/O;bcp 的優勢則是免寫程式、跨語言、適合臨時或排程匯入。

  • 客戶端 bulk load vs BULK INSERT:前兩條是「客戶端把資料串流給伺服器」;BULK INSERT 是「伺服器自己去讀一個檔案」。後者的檔案必須放在 SQL Server 程序看得到的位置(本機磁碟或它有權限的共享),資料不經由客戶端的 TDS 連線傳輸。


為什麼 bulk copy 快(核心)

把逐筆 INSERT 的成本拆開,可以看到 bulk load 是逐項把它們消掉的。

逐筆 INSERT 的四大成本

對每一筆 INSERT INTO t VALUES (...):

  1. 網路往返(round-trip):每筆是一次 TDS 請求/回應。1,000,000 筆 = 1,000,000 次往返,延遲累加。
  2. 解析與編譯(parse + compile):每個語句字串要在伺服器端剖析、產生執行計畫(未參數化時尤其貴)。
  3. 交易記錄(transaction log):每筆變更逐列寫入 log,且預設每筆是一個自動提交交易,log flush 頻繁。
  4. 逐筆協定開銷:每筆都帶 token、欄位描述等固定 overhead。

bulk load 怎麼繞過

逐筆成本 bulk load 的做法
網路往返 一個資料串流連續送多筆,往返次數從 N 降到接近 1
解析/編譯 只送一次 INSERT BULK 設定欄位,之後是純資料 token,不再逐筆剖析 SQL
交易記錄 符合條件時走最小記錄,log 只記錄配置的 extent 而非每一列
協定開銷 欄位描述(COLMETADATA)只送一次,後面每列是緊湊的二進位

所以「100 萬筆逐筆 INSERT」和「100 萬筆 bulk load」的差距,本質是 O(N) 次往返 + 編譯 + 逐列 log 變成 接近 O(1) 次往返 + 一次設定 + 批次配置 log。資料量越大,差距越明顯。


TDS 是什麼

TDS(Tabular Data Stream) 是 SQL Server(與早期 Sybase)用來在客戶端與資料庫伺服器之間溝通的應用層協定。所有與 SQL Server 的互動——登入、送 SQL、收結果、bulk load——都封裝在 TDS 之上。微軟以開放規格 [MS-TDS] 公開了它。

定位

應用程式 / driver(ODBC、JDBC、ADO.NET…)
        ↓  產生 TDS 訊息
      TDS(應用層)
        ↓  封裝在
   TCP(預設埠 1433;也支援具名管道 / 共享記憶體)

ODBC、JDBC、ADO.NET 這些 driver 並不是「另一種協定」,它們是把你的 API 呼叫翻譯成 TDS 訊息的程式庫。(ODBC 架構詳見 ODBC 完全指南。)

封包與訊息類型

TDS 把資料切成封包(packet),每個封包有 8 位元組標頭(類型、狀態、長度、SPID 等)加上資料。常見的訊息類型:

類型 用途
Pre-Login 連線前協商(版本、加密選項)
Login7 登入認證
SQL Batch 送一段 T-SQL 文字
RPC 呼叫預存程序 / 參數化呼叫
Bulk Load 大量資料的二進位串流(本篇重點)

Token 串流

TDS 的回應(以及 bulk load 的請求)不是純文字,而是一連串 token。例如查詢結果是 COLMETADATA(欄位描述)後面接多個 ROW,最後 DONE。bulk load 正是反過來用同一套 token 把資料送進伺服器。

加密與認證

加密在 Pre-Login 階段協商:客戶端與伺服器交換加密選項,必要時進行 TLS 交握,之後的 TDS 流量在 TLS 之上。連線字串的 Encrypt / TrustServerCertificate 控制這個行為。注意:開啟加密會增加 CPU 與一些延遲,大量 bulk load 的吞吐可能因此下降,是調效時要納入的變因。


TDS 的 Bulk Load Request

這是「為什麼快」在協定層的答案,也是 bcp 與程式內 API 共同走的路徑。

流程

1. 客戶端送一個 SQL Batch:INSERT BULK <table> (<欄位定義>)
        ↓
2. 伺服器準備接收,回應
        ↓
3. 客戶端送 Bulk Load 資料串流:
        COLMETADATA   ← 描述接下來資料的欄位(型別、長度…),只送一次
        ROW           ← 第 1 列(緊湊二進位)
        ROW           ← 第 2 列
        ...           ← 連續多列,不再逐筆送 SQL
        DONE          ← 結束

重點

  • 資料串流的格式,和從伺服器 SELECT 出資料時一樣——只是方向相反。COLMETADATA + ROW... + DONE 這套 token 同時用於「讀出」與「灌入」。
  • 因為送的是二進位 token 而非 SQL 文字,bulk load 的實際資料值不會出現在一般的查詢追蹤(trace)裡——追蹤只看得到 INSERT BULK 那個設定語句,看不到每一列的值。除錯時要注意這點。
  • 資料是「依 COLMETADATA 描述的傳輸格式」而非儲存頁面格式,由伺服器收下後寫入頁面。

名詞釐清:嚴格說沒有官方叫「TDS API」的東西。TDS 是線路協定;你在程式裡呼叫的是各 driver 的 bulk copy API(如 SqlBulkCopy),由它產生上面的 TDS Bulk Load Request。


bcp 實戰

bcp(Bulk Copy Program)是命令列工具。在 Windows 上是 bcp.exe;在 macOS / Linux 上由 mssql-tools(或新版 go-sqlcmd)提供,指令名是 bcp(無 .exe)。

基本語法

bcp {資料表 | 查詢} {in | out | queryout | format} 資料檔 [選項]
方向 作用
in 從資料檔匯入資料表(大量寫入用這個)
out 從資料表匯出到資料檔
queryout 從查詢結果匯出
format 產生格式檔(format file)

匯入範例

# 從 data.csv 匯入 SalesDB 的 dbo.Orders(SQL 驗證、字元格式、批次 1 萬筆、表鎖)
bcp SalesDB.dbo.Orders in data.txt \
  -S myserver.database.windows.net -U sa -P '***' \
  -c -t ',' -b 10000 -h "TABLOCK"

常用選項

選項 說明
-c 字元格式(純文字,欄位以 tab 分隔)
-w Unicode(寬字元)格式
-n 原生(native)二進位格式,最快、但只能 SQL Server 之間互通
-t 欄位分隔符,搭配 -c(如 -t ',')
-b 每批筆數(如 -b 10000,影響交易粒度與記憶體)
-f 指定格式檔(如 -f orders.fmt)
-h 載入提示(如 -h "TABLOCK"):TABLOCK、ORDER(...)、ROWS_PER_BATCH=n、CHECK_CONSTRAINTS、FIRE_TRIGGERS、KEEPNULLS
-E 保留來源的 IDENTITY 值(預設由伺服器重新編號)
-S 伺服器位址
-d 資料庫名稱
-U SQL 驗證帳號
-P SQL 驗證密碼
-T 信任連線(Windows 整合驗證,僅 Windows)

格式檔(format file)

當資料檔欄位順序、分隔符與資料表不完全對應時,用格式檔描述對應關係:

# 產生一份非 XML 格式檔(互動式按 Enter 用預設)
bcp SalesDB.dbo.Orders format nul -f orders.fmt -c -S myserver -U sa -P '***'

# 之後用格式檔匯入
bcp SalesDB.dbo.Orders in data.txt -f orders.fmt -S myserver -U sa -P '***'

macOS 安裝

# Homebrew(Microsoft 套件庫)
brew install microsoft/mssql-release/mssql-tools18
# 安裝後指令是 bcp / sqlcmd(注意 18 版預設要求加密,連舊伺服器可能要 -C 之類選項)

程式內 Bulk Copy 實戰

資料已經在程式記憶體裡時,直接用 bulk copy API 省掉檔案落地。各語言的 driver 各有實作。

.NET:SqlBulkCopy

using Microsoft.Data.SqlClient;

using var bulk = new SqlBulkCopy(connStr, SqlBulkCopyOptions.TableLock)
{
    DestinationTableName = "dbo.Orders",
    BatchSize = 10000,       // 對應 bcp 的 -b
    BulkCopyTimeout = 600,   // 秒
};
bulk.ColumnMappings.Add("OrderId", "OrderId");
bulk.ColumnMappings.Add("Amount", "Amount");

// 來源可以是 DataTable、DataRow[],或最省記憶體的 IDataReader(串流)
bulk.WriteToServer(reader);

SqlBulkCopyOptions 旗標對應前面那些載入行為:TableLock、KeepIdentity、FireTriggers、CheckConstraints、KeepNulls。

JDBC(Microsoft JDBC Driver)

兩種用法:

# 1. 連線屬性:讓批次 INSERT 自動改走 bulk copy(driver 8.4+)
jdbc:sqlserver://host;databaseName=SalesDB;useBulkCopyForBatchInsert=true
// 2. 直接用 SQLServerBulkCopy 類別
try (SQLServerBulkCopy bulk = new SQLServerBulkCopy(connection)) {
    bulk.setDestinationTableName("dbo.Orders");
    SQLServerBulkCopyOptions opts = new SQLServerBulkCopyOptions();
    opts.setBulkCopyTimeout(600);
    opts.setBatchSize(10000);
    opts.setTableLock(true);
    bulk.setBulkCopyOptions(opts);
    bulk.writeToServer(sourceResultSet);   // 來源是 ResultSet 或自訂 ISQLServerBulkData
}

ODBC:bulk copy 函式

底層 C API,也是 bcp 工具內部所用:bcp_init → bcp_bind(綁定欄位)→ bcp_sendrow(逐列送,但走 bulk 串流)→ bcp_batch(提交一批)→ bcp_done。

何時免去 bcp 直接用 API

  • 資料已在程式記憶體 / 來自另一個查詢 → 用 API 串流,免寫檔。
  • 需要與既有交易、連線池整合 → 用 API。
  • 臨時匯入、排程腳本、跨語言、不想寫程式 → 用 bcp 工具。

影響速度的關鍵條件:最小記錄

bulk load「快」的儲存層關鍵是最小記錄(minimal logging):交易記錄只記錄「配置了哪些 extent」,而不是每一列的內容,大幅降低 log I/O。但它不會自動發生,要同時滿足條件。

前提條件

  1. 復原模式為 SIMPLE 或 BULK_LOGGED(FULL 模式下無法最小記錄)。 (復原模式與 log 的關係見 交易與 ACID 完全指南。)
  2. 取得表級鎖:加 TABLOCK 提示,或用 sp_tableoption 設定 table lock on bulk load。
  3. 目標資料表的索引狀態符合下表。

依目標結構的記錄行為

目標結構 條件 記錄行為
堆積(heap,無非叢集索引) 有 TABLOCK 最小記錄
堆積 無 TABLOCK 完整記錄
空的叢集索引表 TABLOCK(必要時加 ORDER 提示) 資料頁與索引頁皆最小記錄
非空的叢集索引表 — 資料頁與索引頁皆完整記錄(不論復原模式)
有非叢集索引 — 索引維護通常使最小記錄複雜化甚至失效

版本差異(fast load context)

讓叢集索引也能最小記錄的「fast load context」:

  • SQL Server 2008~2014:需開啟 trace flag 610。
  • SQL Server 2016 起:預設開啟,可用 trace flag 692 關閉。

實務做法

最快的大量載入常見組合:空的堆積表 + TABLOCK + SIMPLE/BULK_LOGGED,載入完成後再建索引。原因見下一節與「載入後上線」。


bulk load 專屬陷阱

bulk load 為了快,預設跳過了好幾項一般 INSERT 會做的事。不知道的話會踩雷:

1. 觸發器預設不觸發

bulk load 預設不執行 INSERT 觸發器。需要的話加 FIRE_TRIGGERS(bcp -h "FIRE_TRIGGERS" / SqlBulkCopyOptions.FireTriggers),但會犧牲速度。

2. CHECK / 外鍵約束預設不檢查

預設略過 CHECK 與 FOREIGN KEY 驗證,載入後約束會被標記為「不受信任(not trusted)」,可能影響查詢最佳化。要驗證就加 CHECK_CONSTRAINTS,或事後執行:

ALTER TABLE dbo.Orders WITH CHECK CHECK CONSTRAINT ALL;

(外鍵與參照完整性見 外鍵與參照完整性完全指南。)

3. IDENTITY 欄位

預設由伺服器重新編號 IDENTITY。要保留來源值用 KEEPIDENTITY(bcp -E)。

4. 批次大小 = 交易與回滾粒度

-b / BatchSize 切出來的每一批是一個交易。中途失敗時,已提交的批次不會回滾,目標表會處於「載入了一部分」的狀態。需要全有全無時,要嘛整個包在一個外層交易,要嘛載入到暫存表確認無誤後再上線(見下一節)。

5. 預設值與 NULL

是否套用欄位的 DEFAULT、空白要當 NULL 還是空字串,由 KEEPNULLS 等選項決定,跨工具行為略有差異,匯入前確認對應。


載入後讓資料上線:SWITCH 與 MERGE

bulk load 是「只進、灌空表最快」的操作;真實管線通常先載入暫存表,再讓資料上線。依需求有兩種模式。

模式 A:staging + 分割切換(SWITCH)

適合大量附加到一張大型分割表、且要即時上線的情境(資料倉儲、時序資料)。核心:ALTER TABLE ... SWITCH 是 metadata-only 操作——不搬資料,只翻轉中繼資料指標,不論幾百萬列都近乎瞬間完成。

1. 建一張結構完全相同的 staging 表(同檔案群組、對齊索引)
2. 用 bcp / bulk copy 離線灌進 staging(盡情用 TABLOCK + 最小記錄、載入後再建索引;
   過程不碰正式表、不擋線上讀取)
3. ALTER TABLE staging SWITCH TO target PARTITION n;   ← 瞬間上線

對比直接灌正式表:昂貴的部分(寫資料 + 建索引)全在 staging 表離線完成,正式表只承受一次毫秒級的指標翻轉,不會因大量寫入而長時間鎖表或撐爆 log。

SWITCH 要成立有一組嚴格條件(同檔案群組、結構與索引對齊、staging 表要有界定範圍的 CHECK 約束、目標分割須為空等)。分割表的設計、索引對齊、SWITCH 完整條件與滑動視窗維護,見 SQL Server 資料表分割。

模式 B:staging + MERGE / UPSERT

需要更新或合併既有資料(去重、idempotent 同步)時:bulk load 進暫存表,再用一次集合式 MERGE 寫進正式表。

-- 概念示意:把 staging 合併進 target
MERGE dbo.Orders AS t
USING dbo.Orders_Staging AS s ON t.OrderId = s.OrderId
WHEN MATCHED THEN UPDATE SET t.Amount = s.Amount
WHEN NOT MATCHED THEN INSERT (OrderId, Amount) VALUES (s.OrderId, s.Amount);

這等於用「快但笨」的 bulk load + 「聰明但較慢」的 set-based MERGE 各取所長。MERGE / ON CONFLICT 等各 DB 的 upsert 語法與陷阱見 UPSERT 與衝突處理完全指南。


怎麼量測與疑難排解

公平地量測快多少

避免靠印象,用可重現的方式比較:

  • 吞吐量:以 rows/sec 或 MB/sec 為單位,固定資料集分別跑逐筆 INSERT 與 bulk load。
  • 計時:SET STATISTICS TIME ON;,或記錄起訖時間。
  • 找批次甜蜜點:掃幾個 -b / BatchSize(如 1k、1 萬、10 萬),太小往返多、太大記憶體與回滾成本高。
  • 控制變因:注意暖機(第一次有快取效應)、目標表是否為空、是否觸發最小記錄、是否開了加密。

常見問題排解

症狀 可能原因
比預期慢、log 暴漲 沒觸發最小記錄(FULL 模式、缺 TABLOCK、或目標是非空叢集索引表)
欄位對不上 / 截斷錯誤 資料檔欄位順序或型別與資料表不符 → 用格式檔
IDENTITY 值跑掉 未加 -E / KeepIdentity
觸發器沒跑、約束沒檢查 bulk load 預設略過,需 FIRE_TRIGGERS / CHECK_CONSTRAINTS
抓不到實際寫入的值 bulk load 是二進位 token 串流,不出現在一般 trace(可用 Wireshark 的 TDS 解析器看封包層)
權限不足 大量載入需要對目標表的 INSERT 權限;某些情境需要 ADMINISTER BULK OPERATIONS

其他資料庫的對應

bulk load 不是 SQL Server 獨有,主流 DB 都有「繞過逐筆 INSERT」的大量載入機制:

資料庫 大量載入機制 性質
SQL Server bcp / BULK INSERT / Bulk Copy API 客戶端串流 或 伺服器讀檔
PostgreSQL COPY(\copy 為 psql 客戶端版) 串流,最小化 per-row 開銷
MySQL LOAD DATA INFILE(LOCAL 為客戶端版) 伺服器/客戶端讀檔
Oracle SQL*Loader / 外部表 / 直接路徑載入 繞過一般 SQL 引擎

共通原理一致:一次設定欄位 + 連續串流資料 + 盡量減少逐列記錄。換到別的 DB 時,找它的「bulk / copy / load」機制即可。


常見問題

問題 1:bcp 和 SqlBulkCopy 哪個快?

速度上限相同,因為兩者在 TDS 上送同一個 Bulk Load Request。差別在前置成本:bcp 要先把資料寫成檔案、再開外部程序;SqlBulkCopy 直接從記憶體串流。資料已在程式裡就用後者,臨時/跨語言匯入就用前者。

問題 2:BULK INSERT 和 bcp in 差在哪?

BULK INSERT 是 T-SQL,由伺服器讀檔,檔案要在伺服器看得到的位置;bcp in 由客戶端讀檔並透過 TDS 串流給伺服器。前者省掉客戶端傳輸但需要伺服器端檔案存取權,後者適合檔案在客戶端的情境。

問題 3:為什麼我用了 bulk load 還是很慢、log 還是爆?

最常見原因是沒觸發最小記錄:復原模式是 FULL、沒加 TABLOCK、或目標是「非空的叢集索引表」。把目標改成空堆積表 + TABLOCK、復原模式設 SIMPLE/BULK_LOGGED,載入後再建索引。

問題 4:為什麼 trace 看不到 bulk load 的資料值?

因為 bulk load 送的是 TDS 的二進位 token 串流,不是 SQL 文字。追蹤只記得到 INSERT BULK 設定語句,看不到逐列的值。要看封包層得用網路擷取工具(Wireshark 內建 TDS 解析器)。


參考資源與總結

參考資源

資源 用途
[MS-TDS] 官方規格(Microsoft Learn) TDS 協定權威定義,含 Bulk Load / COLMETADATA token
Microsoft Learn:Prerequisites for Minimal Logging 最小記錄條件矩陣
FreeTDS 開源 TDS 實作,想真正看懂協定時的活教材

核心要點

快的本質 = 繞過 SQL 解析層 + 最小記錄

工具       bcp / SqlBulkCopy / BULK INSERT
  ↓        前兩者走同一個…
協定       TDS Bulk Load Request(INSERT BULK → COLMETADATA → ROW… → DONE)
  ↓        符合條件時觸發…
儲存       最小記錄(log 只記 extent 配置,不記每列)

方法 × 速度 × 適用情境

方法 速度 適用情境
逐筆 INSERT 慢 少量、即時、需逐筆觸發器/約束
批次多列 INSERT VALUES 中 中等量、不想引入 bulk 工具
bcp 工具 快 檔案匯入、臨時/排程、跨語言
程式內 Bulk Copy API 快 資料已在程式記憶體、需整合既有連線
BULK INSERT 快 檔案在伺服器端
staging + SWITCH 快且即時上線 大型分割表的大量附加(見分割筆記)

最小記錄速查

想要最小記錄 必須
復原模式 SIMPLE 或 BULK_LOGGED
鎖 TABLOCK(或 table lock 選項)
目標結構 空堆積,或空叢集索引表(+ ORDER)
叢集索引最小記錄 2016+ 預設可;2008–2014 需 TF 610

建立日期:2026-06-30

🔗相關文章