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"):TABLOCKORDER(...)ROWS_PER_BATCH=nCHECK_CONSTRAINTSFIRE_TRIGGERSKEEPNULLS
-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 旗標對應前面那些載入行為:TableLockKeepIdentityFireTriggersCheckConstraintsKeepNulls

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_initbcp_bind(綁定欄位)→ bcp_sendrow(逐列送,但走 bulk 串流)→ bcp_batch(提交一批)→ bcp_done

何時免去 bcp 直接用 API

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

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

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

前提條件

  1. 復原模式SIMPLEBULK_LOGGEDFULL 模式下無法最小記錄)。 (復原模式與 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 / 外鍵約束預設不檢查

預設略過 CHECKFOREIGN 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 ... SWITCHmetadata-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 INFILELOCAL 為客戶端版) 伺服器/客戶端讀檔
Oracle SQL*Loader / 外部表 / 直接路徑載入 繞過一般 SQL 引擎

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


常見問題

問題 1:bcpSqlBulkCopy 哪個快?

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

問題 2:BULK INSERTbcp 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 快且即時上線 大型分割表的大量附加(見分割筆記

最小記錄速查

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

建立日期:2026-06-30

🔗相關文章