Microsoft PHP 驅動程式效能調校 for 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 handle 會保留在 ODBC 驅動管理員的池中,並被下一個請求相同的 連接字串 重複使用。

Windows:連線池預設開啟。 要確認,請在你的 DSN 裡不要有這個 ConnectionPooling 選項。 要停用除錯池,請設定 ConnectionPooling=0

Linux 和 macOS:這些平台上沒有 DSN 選項。 在驅動程式管理器中,透過在 的odbcinst.ini區段設定[ODBC]Pooling=Yes,並在驅動節下設定正值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 token 刷新保持誠信)。

在請求中重複使用該連線

即使有池化,開啟新的 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 回應)。
  • 當你只關心單一標量(a COUNT、 或 SUMMAX時使用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。 將資料寫入分隔或原生格式檔案,然後執行 bcp 或 BULK INSERT 從排程工作、ETL 步驟或管理腳本執行。

注意事項

如果你用 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 可以用兩種方式準備陳述。 原生 prepare 會 將 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) 數,並且始終將實際值作為參數通過 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() 詳情請參見 「以串流方式傳送資料」。

設定適當的暫停時間

逾時同時也是效能設定和可靠性設定。 長掛查詢會阻擋池連線,並使其他請求陷入飢餓。

陳述式逾時

設定每個陳述式的時間限制,避免逃逸查詢無限期保留池連線。 給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 秒的數值對大多數雲端工作負載來說都很有效。 關於大小 LoginTimeout 大小及 ConnectRetryCount * ConnectRetryInterval 失效模式的詳細資訊,請參見 連線逾時。 關於選項參考,請參見 連接選項

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

對於 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 Query Performance Insight

對於 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_completed and sql_batch_completed 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 查詢存放區 已啟用並定期審查 查詢存放區