本文介绍了如何在SQL Server、Azure SQL 数据库、Azure SQL 托管实例、Azure Synapse Analytics和Microsoft Fabric中的SQL数据库下编写快速PHP代码。 本指南同时适用于 SQLSRV 和 PDO_SQLSRV,两者用于包装相同的底层 Microsoft ODBC Driver for SQL Server。
从影响最大的变更开始
如果你只能做三项更改,请做以下更改:
- 启用连接池。 建立新的 SQL Server TLS 连接需要几十到几百毫秒,具体取决于网络路径和 TLS 协商。 重用连接池中的连接可以免除每个请求的这部分开销。 参见 “高效管理连接”。
- 只提取所需的列和行。
SELECT *无界查询是导致端点缓慢的最常见原因。 请参阅仅查询所需内容。 - 批量插入时使用表值参数。 对于数百行或更多行,表值参数(TVP)通常比逐行
INSERT语句快得多,并且随行数线性增长。 参见 “高效插入数据”。
高效管理连接
连接建立是驱动程序执行的最昂贵操作。 几乎所有 PHP 性能调查都会以连接管理修复告终。
启用连接池
池化在 PHP 请求中重用 ODBC 连接,而不是在请求端拆除它们。 脚本结束时,连接对象会被释放,但底层的 ODBC 句柄仍会保留在 ODBC 驱动程序管理器的池中,并在下一个请求使用相同连接字符串时被复用。
Windows:连接池默认开启。 为了确认,把这个 ConnectionPooling 选项从你的DSN里剔除。 要禁止将池用于调试目的,请设置 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秒对大多数网页工作负载来说效果不错。
详情请参见 连接池。
理解首次查询成本
多重活动结果集 (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响应)。 - 当你只关心单个标量(a
fetchColumn()、 、COUNT或SUM)时使用MAX。 - 使用
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倍。
少于约 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。 将数据写入带分隔符的文件或原生格式文件,然后通过计划作业、ETL 步骤或管理脚本运行 bcp 或 BULK INSERT。
Caution
如果你在 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 设置为使驱动程序使用原生预处理。 原生预备程序允许 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 列表大小,要么从验证过的整数计数中构建占位符字符串,要么将列表作为表值参数传递。
Caution
切勿将原始用户输入直接插入到 SQL 文本中(包括占位符数量)。 在构建占位符字符串之前,使用 (int) 对计数进行类型转换,并始终通过 execute() 将实际值作为参数传递。
管理光标和内存
默认游标类型为 PDO::CURSOR_FWDONLY,这是一种仅向前的流式游标。 它一次只将行流到PHP且不缓冲,因此大型结果集受限于行缓冲内存,而非总行数。 这通常是你想要的。
只有在需要后退或数行时才使用缓冲光标
PDO::SQLSRV_CURSOR_BUFFERED(客户端缓冲静态光标)会预先将整个结果集提取到 PHP 内存中。 这种方法允许你调用 rowCount()、反向寻找,并重复使用该语句。 默认情况下,通过 PDO::SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE 将缓冲区上限设为 10,240 KB(10 MB);如果查询的结果集超过该上限,则会返回 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 数据库 或 Azure SQL 托管实例 时,设置一个明确的值,这样冷启动和故障转移组故障转移不会让客户端无限期挂机。 30秒到90秒的数值对大多数云工作负载来说效果不错。 有关将 LoginTimeout 相对于 ConnectRetryCount * ConnectRetryInterval 进行大小配置的详细信息,以及由此导致的故障模式,请参见 连接超时。 关于选项参考,请参见 连接选项。
将只读工作负载路由到副本
对于针对 Always On 可用性组中的数据库、Azure SQL 托管实例或具有读取扩展或异地复制功能的 Azure SQL 数据库进行的只读查询,请在连接字符串添加 ApplicationIntent=ReadOnly:
<?php
$dsn = "sqlsrv:Server=<listener>;Database=<database>;" .
"Encrypt=true;ApplicationIntent=ReadOnly";
只读路由将连接路由到已同步的次要副本,从而减轻主要副本的工作负载。 与 MultiSubnetFailover=true 结合使用,以实现与多子网可用性组侦听器的最快连接。
观察服务器的性能
客户端时序只告诉你查询从头到尾花了多长时间。 要找出速度慢的原因,可以使用 SQL Server 内置的诊断功能。
查询存储
查询存储 为数据库中每个查询捕获执行计划、运行时统计和等待统计。 它默认在 Azure SQL 数据库、Azure SQL 托管实例 和 Fabric 中的 SQL 数据库中启用。 在 SQL Server 上,按数据库启用:
ALTER DATABASE <database_name> SET QUERY_STORE = ON;
然后使用 SQL Server Management Studio 的 查询存储 报表,查找你最慢且执行最频繁的查询。 参见使用 查询存储 监控性能。
Azure SQL 查询性能见解
对于 Azure SQL 数据库,Azure 门户的查询性能洞察会自动显示最耗资源的查询,无需任何配置。 有关详细信息,请参阅 Azure SQL 数据库的 Query Performance Insight。
SET STATISTICS 用于一次性调查
对于你想要进行分析的单一查询,请在 SQL Server Management Studio 中运行,并开启统计数据:
SET STATISTICS TIME ON;
SET STATISTICS IO ON;
-- your query here
高逻辑读取几乎总是意味着缺少或无法使用的索引。 高 CPU 时间和低逻辑读取通常意味着计划不佳(参数嗅探、隐式转换阻止索引使用,或标量函数阻碍并行处理)。
用于驱动程序级跟踪的扩展事件
若要准确查看驱动程序向 SQL Server 发送的内容(包括其插入的实际参数值),请使用 rpc_completed 和 sql_batch_completed 事件捕获 扩展事件 会话。
性能清单
请将此清单作为任何连接SQL Server的PHP应用的预部署回顾:
| 区域 | 检查 | Reference |
|---|---|---|
| Connection | 已为该平台启用并配置连接池 | 高效管理连接 |
| Connection | 应用程序在请求中重复使用连接,且不会每次查询都打开连接 | 请求中重用该连接 |
| Connection |
LoginTimeout 涵盖 Azure SQL 的冷启动和故障转移 |
登录超时 |
| 查询 | 查询仅选择所需的列,不使用 SELECT * |
仅选择要使用的列 |
| 查询 | 过滤是在 SQL 中完成的,而不是用 PHParray_filter |
仅提取所需的行 |
| 查询 | 大型结果集的分页为 OFFSET ... FETCH |
对大型结果集进行分页 |
| 查询 | 存储过程集合 SET NOCOUNT ON |
首选 SET NOCOUNT ON |
| 插入数 | 批量插入使用表值参数,而非逐行循环 | 高效插入数据 |
| Statements | 将 PDO::ATTR_EMULATE_PREPARES 设置为 false |
优先使用原生预定义语句 |
| Statements | 该应用程序在执行过程中重复使用预设的语句 | 重用预处理语句 |
| Cursors | 应用程序默认使用仅前进光标,除非需要缓冲 | 管理光标和内存 |
| Memory | 大型二进制和字符值是流式传输的,而非实体化 | 流式传输大型二进制值和字符值 |
| 超时 | 所有面向用户的查询都设置了语句超时时间 | 语句超时 |
| 路线规划 | 在有副本的情况下,只读工作负载会设置为 ApplicationIntent=ReadOnly |
路由只读工作负载 |
| Observability | 查询存储 已启用并定期审核 | 查询存储 |