適用於 PHP 的 Microsoft SQL Server 驅動程式效能調校

下載 PHP 驅動程式

本文將介紹如何在 SQL Server、Azure SQL Database、Azure SQL 受控執行個體、Azure Synapse Analytics 以及 Microsoft Fabric 中的 SQL 資料庫中撰寫快速的 PHP 程式碼。 此指引適用於 SQLSRV 與 PDO_SQLSRV,兩者皆封裝相同的底層 Microsoft ODBC 驅動程式 for SQL Server。

從影響最大的變更開始

如果你只能做三項修改,請做以下這些變更:

  • 啟用連線集區。 建立新的 SQL Server TLS 連線需要數十到數百毫秒,視網路路徑和 TLS 協商而定。 重複使用合併連線可以消除每次請求的成本。 請參見 「有效管理連結」。
  • 只取得你需要的欄位和列。 SELECT * 而無界查詢是導致端點緩慢的最常見原因。 請參閱 僅查詢所需內容
  • 批量插入時使用表值參數。 對於數百列或更多資料,表值參數(TVP)通常比逐列 INSERT 語句快得多,且隨列數線性成長。 詳見 「有效插入資料」。

有效管理連線

連接建立是驅動程式執行的最昂貴操作。 幾乎所有 PHP 效能調查都會以連線管理修正作結。

啟用連線池

連線池化會在不同的 PHP 請求之間重複使用 ODBC 連線,而不是在請求結束時將其關閉。 當你的指令碼結束時,連線物件會被丟棄,但底層的 ODBC 控制代碼仍會保留在 ODBC 驅動程式管理員的集區中,並由下一個要求使用相同連線字串的請求重複使用。

Windows:連線池預設為啟用。 要確認,請在你的 DSN 裡不要有這個 ConnectionPooling 選項。 若要為了偵錯而停用集區功能,請設定 ConnectionPooling=0

Linux 和 macOS:在這些平台上,連線集區不是 DSN 選項。 在驅動程式管理器中,於odbcinst.ini[ODBC]區段中設定Pooling=Yes來啟用它,並在該驅動程式的 stanza 下將CPTimeout設為正值。 例如:

[ODBC]
Pooling=Yes

[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver=/opt/microsoft/msodbcsql18/lib64/libmsodbcsql-18.<version>.so.1.1
CPTimeout=120

使用 odbcinst -q -d -n "ODBC Driver 18 for SQL Server"ls /opt/microsoft/msodbcsql18/lib64/ 找出實際的程式庫路徑。 檔名會嵌入已安裝的 ODBC 驅動版本,並隨著每次版本而改變。

CPTimeout (秒數內)控制閒置連接在池中停留的時間,直到關閉。 將其設得夠高,讓大多數請求都能找到連線池中的連線,但也要夠低,讓連到已容錯移轉伺服器的陳舊連線能在合理的時間內淘汰。 60 到 300 秒對大多數網頁工作負載來說都很有效。

如需詳細資訊,請參閱 連線集區

了解首次查詢成本

依預設會啟用 Multiple Active Result Set (MARS)。 當 MARS 和連線池都啟動時,驅動程式會在 第一次 查詢時重置池化連線,而這個重置會忽略你為第一個查詢設定的查詢逾時。 同一連線上的後續查詢會正常遵循逾時設定。 如果您在集區工作負載上將第一次查詢逾時設得很短,請將此行為納入考量;如果不需要 MARS,請使用 MultipleActiveResultSets=false 停用它。 請參閱 連線共用中的 MARS 和連線共用注意事項。

不支援持久的 PDO 連線

PDO_SQLSRV 拒絕 PDO::ATTR_PERSISTENT。 在建構函式上設定它會拋出:

SQLSTATE[IMSSP]: An unsupported attribute was designated on the PDO object.

使用 ODBC 連線池 來進行跨請求重用。 它是驅動程式原生機制,適用於 PDO_SQLSRV 和 SQLSRV,並且會在 CPTimeout 時淘汰閒置連線(這也能確保 Microsoft Entra 權杖重新整理確實正常運作)。

在請求中重複使用該連線

即使有池化,開啟新的 PDO 或 SQLSRV 連線仍需進行 ODBC 往返以取用並驗證池化的句柄。 每次請求只建立一次連線,然後將其傳遞給每個需要它的函式。

Tip

依賴注入容器或延遲存取器就夠了。 重點是避免 new PDO(...) 在請求處理器中途出現。

只查詢你需要的資訊

大多數 PHP 工作負載中,網路往返與結果集實體化主導了查詢延遲。 這些修正是適用於所有資料庫存取層的。

只選擇你使用的欄位

SELECT * 會拉取所有欄位,包括 varchar(max)varbinary(max) 欄位,這些欄位遠超過你實際使用的資料。 請為這些欄位命名:

<?php
// Slow: fetches all columns, including a 2 MB LOB column
$stmt = $conn->query("SELECT * FROM dbo.Products");

// Fast: fetches only the two columns the caller uses
$stmt = $conn->query("SELECT ProductID, Name FROM dbo.Products");

只擷取您需要的資料列

將篩選下推至 SQL Server。 千萬不要只是為了在 foreach 迴圈中篩選資料,就把整個資料表取回到 PHP 中。

<?php
// Slow: transfers every row to PHP, then filters
$rows = $conn->query("SELECT * FROM dbo.Orders")->fetchAll(PDO::FETCH_ASSOC);
$recent = array_filter($rows, fn($r) => $r["OrderDate"] > "2026-01-01");

// Fast: filters on the server
$stmt = $conn->prepare("SELECT OrderID, CustomerID, Total FROM dbo.Orders WHERE OrderDate > ?");
$stmt->execute(["2026-01-01"]);
$recent = $stmt->fetchAll(PDO::FETCH_ASSOC);

將大型結果集分頁

對於只從數百萬筆資料中顯示幾百列的清單檢視,不要回傳所有資料列,再交由用戶端自行處理。請使用搭配 OFFSET ... FETCH 的伺服器端分頁:

<?php
function fetchPage(PDO $conn, int $page, int $pageSize): array {
    $stmt = $conn->prepare(
        "SELECT OrderID, CustomerID, Total
         FROM dbo.Orders
         ORDER BY OrderID
         OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"
    );
    // With native prepares, execute([...]) binds values as strings.
    // OFFSET and FETCH NEXT require integer bindings; bind explicitly.
    $stmt->bindValue(1, ($page - 1) * $pageSize, PDO::PARAM_INT);
    $stmt->bindValue(2, $pageSize, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

選擇合適的擷取方法

  • 當你不需要同時在記憶體中處理所有列時,可以用 fetch(PDO::FETCH_ASSOC) 迴圈來串流迭代。
  • 當呼叫者真的需要整套資料時才會使用 fetchAll(PDO::FETCH_ASSOC) (例如,呈現完整的 JSON 回應)。
  • 當你只關心單一純量(即 COUNTSUMMAX)時,請使用 fetchColumn()
  • 使用 PDO::FETCH_KEY_PAIRPDO::FETCH_UNIQUE 建立查找字典,無需第二次修改。

數值取取模式PDO::FETCH_NUM()比結合取取模式稍快,因為它們跳過建立欄位名稱映射。 偏好清晰;只有當分析器標記取取開銷為重要時才切換。

在儲存程序與批次中偏好SET NOCOUNT ON

每個 INSERTUPDATEDELETE 陳述式都會回傳 DONE_IN_PROC 一個包含受影響列數的標記,PHP 通常會捨棄這些標記。 權杖不會增加一次往返,但每個權杖仍會占用線路上的位元組,並增加少量的驅動程式處理負擔。 在包含多個陳述式的程序中,或在每次呼叫會執行數百個陳述式的批次中,節省下來的效益累積後會相當可觀。 關閉它:

CREATE OR ALTER PROCEDURE dbo.ProcessOrder
    @OrderID INT
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE dbo.Inventory SET Stock = Stock - 1 WHERE ProductID IN (SELECT ProductID FROM dbo.OrderLines WHERE OrderID = @OrderID);
    UPDATE dbo.Orders SET Status = 'Processed' WHERE OrderID = @OrderID;
END;

有效插入資料

根據你要移動的行數選擇正確的插入方式。 錯誤的選擇可能會慢上百倍。

少於約 100 個資料列:在迴圈中使用預備陳述式

對於小批次,請在迴圈中執行單一預備陳述式:

<?php
$stmt = $conn->prepare("INSERT INTO dbo.Products (Name, Price) VALUES (?, ?)");
foreach ($products as $p) {
    $stmt->execute([$p["name"], $p["price"]]);
}

在交易中包裝迴圈,讓所有插入物作為一個單位提交,日誌不必在每列後都清除:

<?php
$conn->beginTransaction();
try {
    $stmt = $conn->prepare("INSERT INTO dbo.Products (Name, Price) VALUES (?, ?)");
    foreach ($products as $p) {
        $stmt->execute([$p["name"], $p["price"]]);
    }
    $conn->commit();
} catch (PDOException $e) {
    $conn->rollBack();
    throw $e;
}

數百到百萬列:表值參數

資料表值參數(TVP)會將整個批次一次傳送到 SQL Server,讓 SQL Server 以單一語句方式處理該集合。 對於數百列或更多列的批次,TVP 通常比預備陳述迴圈快得多,且隨列數線性擴展。

首先,在伺服器上建立一個資料表類型:

CREATE TYPE dbo.ProductTableType AS TABLE (
    Name  NVARCHAR(100),
    Price DECIMAL(10, 2)
);

PDO_SQLSRV 將 TVP 傳遞為關聯陣列,鍵為型別名稱,值為列集。 使用 PDO::PARAM_LOB 綁定:

<?php
$rows = [];
foreach ($products as $p) {
    $rows[] = [$p["name"], $p["price"]];
}
$tvpInput = ["ProductTableType" => $rows];

$stmt = $conn->prepare(
    "INSERT INTO dbo.Products (Name, Price) SELECT Name, Price FROM ?"
);
$stmt->bindParam(1, $tvpInput, PDO::PARAM_LOB);
$stmt->execute();

對於非預設結構,將該結構作為陣列的下一個元素 ["ProductTableType" => $rows, "Sales"]傳遞: 。 關於 SQLSRV 的程序語法與儲存程序範例,請參見 「使用表值參數」。

數百萬行:bcp 或 BULK INSERT

對於真正的大規模作業(資料倉儲載入、初期遷移),請使用 BCPBULK INSERT 取代 PHP。 將資料寫入分隔符號檔案或原生格式檔案,然後從排程工作、ETL 步驟或管理指令碼中執行 bcp 或 BULK INSERT

注意事項

如果你在 PHP 中使用 shell_exec()proc_open() 呼叫 bcp,絕對不要將不受信任的輸入插入命令列中。 在每個參數上使用 escapeshellarg() ,且偏好將負載以帶外方式執行,而非在網頁請求路徑中執行。

減少往返行程

每次 PHP 與 SQL Server 之間的網路往返都有固定成本。 當你一次性寄出五份帳單時,你只需支付一次費用,而不是五次。

對於會一併執行的相關作業,請將這些陳述式放入同一個批次中,並逐一處理所有結果集:

<?php
$sql = "
    SELECT * FROM dbo.Customers WHERE CustomerID = ?;
    SELECT * FROM dbo.Orders WHERE CustomerID = ?;
    SELECT * FROM dbo.Addresses WHERE CustomerID = ?;
";
$stmt = $conn->prepare($sql);
$stmt->execute([$id, $id, $id]);

$customer = $stmt->fetch(PDO::FETCH_ASSOC);

$stmt->nextRowset();
$orders = $stmt->fetchAll(PDO::FETCH_ASSOC);

$stmt->nextRowset();
$addresses = $stmt->fetchAll(PDO::FETCH_ASSOC);

對於 SQLSRV,請使用 sqlsrv_next_result 切換到下一個結果集。

需要時啟用多個主動結果集

多重主動結果集(MARS)允許單一連線擁有多個主動語句。 沒有 MARS,你無法對一個仍然有開放結果集的連線發出新的查詢。 兩個驅動程式預設都啟用了 MARS。 若要將其關閉,請在連線字串中設定 MultipleActiveResultSets=false。 請參見 「停用多個主動結果集(MARS)」。

MARS 很方便,但不是免費的。 每個主動結果集會消耗伺服器端資源。 最好先完整處理完一個結果集,再開始處理另一個。 使用 MARS 來解除真正巢狀的游標圖案。

調整預備陳述式

預備語句能讓驅動程式避免在伺服器上重新解析 SQL,並且能安全地將不受信任的輸入綁定為參數。

偏好本地的料理

PDO_SQLSRV 可以用兩種方式準備陳述。 原生預備語句會將 SQL 文字一次傳送到伺服器,並在每次執行時重複使用已解析的陳述式,每次 execute() 時只傳送參數值。 模擬預備語句 會將 SQL 文字保留在用戶端,並在每次執行時將參數插入其中,重新組成完整的 SQL 字串。

設定 PDO::ATTR_EMULATE_PREPARES => false,讓驅動程式使用原生預備陳述式。 原生 Prepares 允許 SQL Server 快取並重用查詢計畫,且避免每次執行都重新解析 SQL 文字。

<?php
$conn = new PDO($dsn, null, null, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

重用預先準備好的語句

準備一次,執行多次。 每次 prepare() 通話都需花費 ODBC 的句柄配置和伺服器端的解析。 在熱迴圈中,維持 $stmt 物件存活,並在迴圈內呼叫 execute()

<?php
// Fast: one prepare, many executes.
$stmt = $conn->prepare("UPDATE dbo.Inventory SET Stock = Stock - ? WHERE ProductID = ?");
foreach ($orderLines as $line) {
    $stmt->execute([$line["qty"], $line["productId"]]);
}

// Slow: re-prepares the same SQL on every iteration.
foreach ($orderLines as $line) {
    $stmt = $conn->prepare("UPDATE dbo.Inventory SET Stock = Stock - ? WHERE ProductID = ?");
    $stmt->execute([$line["qty"], $line["productId"]]);
}

留意 TOP (?)IN (?, ?, ...)

TOP需要在參數標記周圍加上括號,SELECT TOP (?) ...因此 SQL Server 可以將列數解析為參數。 IN (?, ?, ?, ?) 需要在準備時使用固定數量的佔位符。 對於動態 IN 清單大小,要麼從驗證過的整數計數建立佔位字串,要麼將清單作為表值參數傳遞。

注意事項

切勿將原始使用者輸入直接插入到 SQL 文字中(包括佔位符的數量)。 在建立預留位置字串之前,先使用 (int) 將 count 轉型,並且一律透過 execute() 以參數方式傳遞實際值。

管理游標與記憶體

預設游標類型為 PDO::CURSOR_FWDONLY,是一種只能向前移動的串流式游標。 它會一次將一列資料串流傳送至 PHP,且不會進行緩衝,因此大型結果集的記憶體使用量受限於單列緩衝所需的記憶體,而非總列數。 這通常是你想要的。

只有在需要往後移動或數列時才使用緩衝游標

PDO::SQLSRV_CURSOR_BUFFERED (客戶端緩衝靜態游標)會先將整個結果集匯入 PHP 記憶體。 這種方法可讓你呼叫 rowCount()、向後定位,並重複使用該陳述式。 預設情況下,緩衝區的容量上限為 10,240 KB(10 MB),而 PDO::SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE查詢若結果集超過上限,則可回傳 false 而非溢位 PHP 記憶體。 你可以將此上限調高到接近 PHP 的記憶體限制,但這麼做的代價是:當查詢超出新的上限時,原本的 false 回傳會變成真正的 Allowed memory size exhausted 致命錯誤。 要有意識地調音。 參見游標類型(PDO_SQLSRV)。

伺服器端可捲動游標(PDO::SQLSRV_CURSOR_STATICPDO::SQLSRV_CURSOR_DYNAMICPDO::SQLSRV_CURSOR_KEYSET)緩衝區設在伺服器端而非用戶端,因此不會佔用 PHP 記憶體。 然而,它們在游標存續期間會占用伺服器端資源,而且逐列處理速度比僅向前游標更慢。

串流讀取時,請使用預設的僅向前模式。 當您需要 rowCount() 或向後捲動時,請對小型結果集使用用戶端緩衝處理。 除非你在做特定事情,否則避免使用伺服器端可捲動游標。

<?php
// Fast, low memory: default forward-only, one row at a time
$stmt = $conn->prepare("SELECT OrderID, Total FROM dbo.Orders");
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    // ...
}

// Buffered: only when you need rowCount() or seeking
$stmt = $conn->prepare("SELECT * FROM dbo.SmallLookup", [
    PDO::ATTR_CURSOR                    => PDO::CURSOR_SCROLL,
    PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE => PDO::SQLSRV_CURSOR_BUFFERED,
]);
$stmt->execute();
$rowCount = $stmt->rowCount();

完整解析請參見游標類型(PDO_SQLSRV)游標類型(SQLSRV)。

串流大型二進位與字元值

對於 varbinary(max)、varchar(max)、nvarchar(max)、xml 及其他大型型別,請使用 PHP 串流,而非將整個值實體化到記憶體中:

<?php
$stmt = $conn->prepare("SELECT Name, PhotoBlob FROM dbo.Products WHERE ProductID = ?");
$stmt->execute([$id]);
$stmt->bindColumn("PhotoBlob", $photo, PDO::PARAM_LOB);
$stmt->fetch(PDO::FETCH_BOUND);

// $photo is a stream resource; write it directly to disk without loading it all
$outFile = fopen("/tmp/photo.bin", "wb");
stream_copy_to_stream($photo, $outFile);
fclose($outFile);

若要插入或更新大型值,請在 SQLSRV 中使用 SendStreamParamsAtExec=false,於 sqlsrv_execute() 之後分塊傳送串流資料。 詳情請參見 「以串流方式傳送資料」。

設定適當的暫停時間

逾時設定不只是可靠性設定,也同樣是效能設定。 長時間執行的查詢會占用連線池中的連線,導致其他請求無法取得所需資源。

陳述式逾時

設定每個 SQL 陳述式的時間限制,避免失控查詢無限期占用連線池中的連線。 對於 PDO_SQLSRV:

<?php
$stmt = $conn->prepare("SELECT ... FROM dbo.HugeTable ...");
$stmt->setAttribute(PDO::SQLSRV_ATTR_QUERY_TIMEOUT, 30); // seconds
$stmt->execute();

對於 SQLSRV,將選項陣列傳 "QueryTimeout" => 30sqlsrv_querysqlsrv_prepare

設定一個與你工作量相符的數值。 對於同步的網頁請求,通常需要 15 到 30 秒。 對於背景批次作業,幾分鐘可能比較合理。 在網頁請求中,千萬不要把逾時設為零(無限)。

登入逾時

LoginTimeout在 連接字串 中,控制駕駛等待建立連線的時間。 連接 Azure SQL Database 或 Azure SQL 受控執行個體 時,請設定明確的值,這樣冷啟動和故障轉移群組故障轉移時,客戶端不會無限期當機。 30 到 90 秒的數值對大多數雲端工作負載來說都很有效。 如需瞭解如何根據 ConnectRetryCount * ConnectRetryInterval 調整 LoginTimeout 的大小,以及由此產生的失效模式詳細資訊,請參閱 連線逾時。 關於選項參考,請參見 連接選項

將唯讀工作負載路由到副本

若要對 Always On 可用性群組中的資料庫、Azure SQL 受控執行個體,或已啟用讀取擴展或異地複本的 Azure SQL Database 執行唯讀查詢,請將 ApplicationIntent=ReadOnly 新增至連接字串中:

<?php
$dsn = "sqlsrv:Server=<listener>;Database=<database>;" .
       "Encrypt=true;ApplicationIntent=ReadOnly";

唯讀路由會將連線導向到已同步處理的次要複本,藉此減輕主要複本的工作負載。 結合 MultiSubnetFailover=true,以最快速度連線到多重子網路可用性群組接聽程式。

從伺服器觀察效能

客戶端的計時只告訴你查詢從頭到尾花了多久。 想找出速度緩慢的原因,可以使用 SQL Server 內建的診斷功能。

查詢存放區

查詢存放區 會擷取資料庫中每個查詢的執行計畫、執行時統計和等待統計。 它預設在 Azure SQL Database、Azure SQL 受控執行個體 以及 Fabric 中的 SQL 資料庫上啟用。 在 SQL Server 上,請針對每個資料庫分別啟用:

ALTER DATABASE <database_name> SET QUERY_STORE = ON;

接著使用 SQL Server Management Studio 的 查詢存放區 報告,找出你最慢且執行最頻繁的查詢。 請參見使用 查詢存放區 監控效能

Azure SQL 查詢效能深入解析

對於 Azure SQL Database,Azure 入口網站的查詢效能洞察會自動顯示最耗資源的查詢,無需設定。 如需詳細資訊,請參閱 Azure SQL Database 的查詢效能深入解析

SET STATISTICS 用於單次調查

若要分析單一查詢,請在 SQL Server Management Studio 中啟用統計資料後執行它:

SET STATISTICS TIME ON;
SET STATISTICS IO ON;

-- your query here

邏輯讀取次數過高幾乎總是表示索引缺失或無法使用。 高 CPU 時間與低邏輯讀取通常代表計畫不佳(參數嗅探、隱含轉換導致無法使用索引,或純量函式阻礙平行處理)。

用於驅動程式層級追蹤的延伸事件

若要精確查看驅動程式傳送到 SQL Server 的確切內容(包括其插入的實際參數值),請使用 rpc_completedsql_batch_completed 事件來擷取 Extended Events 工作階段。

效能檢查清單

請使用此清單作為任何連接 SQL Server 的 PHP 應用程式的部署前檢視:

區域 檢查 Reference
Connection 已為該平台啟用並設定連線集區 有效管理連線
Connection 應用程式在請求中重複使用連線,且不會在每次查詢中開啟連線 在請求中重複使用該連線
Connection LoginTimeout涵蓋 Azure SQL 的冷啟動與故障轉移 登入逾時
Query 查詢只選擇所需的欄位,不 SELECT * 只選擇你使用的欄位
Query 過濾是在 SQL 中完成,而不是在 PHP 中使用 array_filter 來進行 只擷取你需要的列
Query 大型結果集會以 OFFSET ... FETCH 分頁 分頁大型結果集
Query 預存程序集 SET NOCOUNT ON 偏好設定 SET NOCOUNT ON
Inserts 批量插入使用表值參數,而非每列迴圈 有效插入資料
Statements PDO::ATTR_EMULATE_PREPARES 設定為 false 偏好本地的料理
Statements 應用程式在執行中重複使用預備好的語句 重用預先準備好的語句
Cursors 除非需要緩衝,應用程式會使用預設的僅前進游標 管理游標與記憶體
Memory 大型二進位與字元值是串流式,而非實體化 串流大型二進位與字元值
逾時 所有使用者可見的查詢都已設定陳述式逾時 陳述句逾時
路由 唯讀工作負載 ApplicationIntent=ReadOnly 設定為在有複本的地方執行 路由唯讀工作負載
Observability 查詢存放區 已啟用並定期審查 查詢存放區