本文將介紹如何在 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、 或SUM)MAX時使用fetchColumn()。 - 使用
PDO::FETCH_KEY_PAIR或PDO::FETCH_UNIQUE建立查找字典,無需第二次修改。
數值取取模式PDO::FETCH_NUM()比結合取取模式稍快,因為它們跳過建立欄位名稱映射。 偏好清晰;只有當分析器標記取取開銷為重要時才切換。
在儲存程序與批次中偏好SET NOCOUNT ON
每個 INSERT、 UPDATE和 DELETE 陳述式都會回傳 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
對於真正的大規模作業(資料倉儲載入、初期遷移),請使用 BCP 或 BULK 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_STATIC, PDO::SQLSRV_CURSOR_DYNAMIC, PDO::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" => 30 入 sqlsrv_query 或 sqlsrv_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 | 查詢存放區 已啟用並定期審查 | 查詢存放區 |