ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP连接SQL Server实战:驱动选型、版本适配与避坑指南

PHP连接SQL Server实战:驱动选型、版本适配与避坑指南

PHP 项目要接 SQL Server,这件事在 Windows 环境下看着简单,真动手的时候坑一个接一个。我自己第一次给一个老系统做数据对接,本地环境是 PHP 5.6,对方数据库是 SQL Server 2012,光是让sqlsrv_connect()返回一个不是false的资源,就折腾了大半天。后来项目升级到 PHP 7.x,同一套代码又得换驱动版本重来一遍。所以这篇东西我打算把 PHP 5.6 和 7.x 两个阶段连接 SQL Server 的完整路径都捋清楚,包括驱动怎么选、扩展怎么装、连接参数怎么写、出错怎么查。适合手里有遗留系统要维护的开发者,也适合刚接手一个 PHP 对接 SQL Server 项目、还没搞清楚sqlsrv和pdo_sqlsrv区别的朋友。下面全部是我实际踩过或者帮别人排查过的内容,能直接照着做。

1. 先搞清楚驱动选型这件事

1.1 为什么 PHP 连 SQL Server 不能像连 MySQL 那样开箱即用

很多人第一次接触这个需求会有一个疑问:PHP 不是自带一堆数据库扩展吗,为什么连 SQL Server 还要单独装东西?原因在于 PHP 官方内核里从来没有内置过 SQL Server 的原生驱动。MySQL 有mysqli和pdo_mysql跟着 PHP 源码一起发布,而 SQL Server 的通信协议是微软自己的 TDS(Tabular Data Stream),PHP 核心团队不维护这部分实现,所以连接能力一直是以外部扩展的形式存在的。

历史上出现过好几条技术路线。最早是mssql扩展,基于 FreeTDS 库,PHP 5.3 之后就被标记为废弃,PHP 7.0 直接移除。然后是odbc路线,通过 ODBC 驱动中转,配置繁琐但通用性强。再往后就是微软官方出手维护的sqlsrv和pdo_sqlsrv两个扩展,这也是目前唯一还在持续更新、并且同时覆盖 PHP 5.6 和 7.x 的方案。你在网上搜到的绝大多数教程,只要提到sqlsrv_connect,说的就是这条路。

这里有个关键认知:sqlsrv是面向过程的函数式 API,pdo_sqlsrv是 PDO 驱动。两者底层共用同一套通信库,但上层接口完全不同。选哪个不是技术优劣问题,而是看你项目现有代码风格。老项目大量用mssql_query那种写法的,迁移到sqlsrv_*函数改动最小;新项目或者已经在用 PDO 抽象层的,直接上pdo_sqlsrv更统一。

1.2 sqlsrv 与 pdo_sqlsrv 的取舍对照

我把两者的差异整理成一张表,选型的时候对着看就行:

对比维度sqlsrvpdo_sqlsrv
接口风格面向过程函数面向对象 PDO
学习成本低,函数名直观需要了解 PDO 基础
参数化查询支持,sqlsrv_query传数组支持,标准 PDO 预处理
多数据库切换不支持,绑定 SQL Server支持,同一套代码换 DSN
事务控制sqlsrv_begin_transaction等标准 PDO 事务方法
结果集遍历sqlsrv_fetch_arrayfetch/fetchAll
适用场景遗留系统改造、快速对接新项目、已有 PDO 体系

我个人的判断标准很简单:如果这个 PHP 项目里已经有 PDO 连接其他数据库的代码,那毫不犹豫用pdo_sqlsrv,保持抽象层一致,后面换库或者加库都省事。如果是一个纯粹的老系统,满屏mysql_query风格的代码,那用sqlsrv函数迁移起来心理负担小,改动也更局部。

注意:不要在同一台机器上同时加载sqlsrv和pdo_sqlsrv的多个版本。这两个扩展依赖同一套底层 DLL,版本错配会导致 Apache 或 PHP-FPM 启动时直接崩溃,而且报错信息往往指向别处,非常难查。

1.3 版本对应关系是最容易翻车的地方

PHP 5.6 和 PHP 7.x 用的驱动版本完全不是一回事,这一点必须先钉死。微软的驱动发布是按 PHP 大版本分支的,PHP 5.6 对应的是 3.x 系列的驱动,PHP 7.0 到 7.4 对应的是 5.x 系列。你如果拿 PHP 7 的驱动往 PHP 5.6 上装,扩展加载阶段就会报“不是有效的 Win32 应用程序”或者干脆静默不加载。

更细的一点是线程安全(TS)和非线程安全(NTS)的区分。这取决于你 PHP 的编译方式,用phpinfo()看“Thread Safety”那一行,enabled 就是 TS,disabled 就是 NTS。IIS 下跑 FastCGI 通常是 NTS,Apache 的 mod_php 模式通常是 TS。驱动包下载页面上会明确标注 ts 和 nts,选错了同样加载失败。

还有一个位数问题:PHP 是 32 位还是 64 位,驱动必须对应。现在新环境基本都是 64 位,但一些老服务器上还跑着 32 位 PHP,这个要提前确认,用php -v或者phpinfo()都能看到架构信息。

2. PHP 5.6 环境下的完整落地步骤

2.1 驱动下载与文件放置

PHP 5.6 这个版本比较特殊,它处在驱动支持周期的尾端,能用的最新驱动是 3.2 版本。下载下来是一个自解压包或者压缩包,解开后里面按 PHP 版本和线程安全类型分了目录,你要找的是类似php_sqlsrv_56_ts.dll和php_pdo_sqlsrv_56_ts.dll这样的文件。

放置位置是 PHP 的扩展目录,也就是php.ini里extension_dir指向的那个路径。Windows 下常见的是C:\php\ext或者C:\php56\ext。把两个 DLL 复制进去就行,不需要放系统目录。

这里有个细节值得说:驱动包解压后往往还带一个msodbcsql.msi或者类似的 ODBC 组件安装程序。PHP 5.6 时代的sqlsrv扩展对系统 ODBC 驱动的依赖没有后来那么强,但如果你要用到一些高级特性或者遇到连接层面的怪问题,把这个 ODBC 组件也装上会更稳。装的时候注意版本,别装了个太新的把老环境搞乱。

2.2 php.ini 配置的正确写法

打开php.ini,找到扩展加载区域,加上两行:

extension=php_sqlsrv_56_ts.dll extension=php_pdo_sqlsrv_56_ts.dll

注意文件名要和你实际放进去的文件名完全一致,包括前缀php_和后缀.dll。有些教程写的是extension=sqlsrv,这种简写在某些 PHP 版本上能识别,但 5.6 上我建议写全名,避免歧义。

改完保存,重启 Web 服务。然后访问一个phpinfo()页面,搜索sqlsrv,应该能看到两个独立的配置区块,分别对应sqlsrv和pdo_sqlsrv,里面会列出支持的版本号和编译信息。如果搜不到,说明扩展没加载成功,往下看排查部分。

提示:修改php.ini之前先备份一份。老环境上经常出现改错一个字符导致整个 PHP 起不来的情况,有备份能快速回滚。

2.3 第一个连接测试脚本

扩展加载成功后,写一个最小化的测试脚本验证连通性:

<?php $serverName = "192.168.1.100,1433"; $connectionOptions = array( "Database" => "TestDB", "Uid" => "sa", "PWD" => "your_password", "CharacterSet" => "UTF-8" ); $conn = sqlsrv_connect($serverName, $connectionOptions); if ($conn === false) { die(print_r(sqlsrv_errors(), true)); } echo "连接成功"; sqlsrv_close($conn); ?>

几个参数说明一下。$serverName里逗号后面是端口,SQL Server 默认 1433,如果对方改过端口就写实际端口。CharacterSet设成 UTF-8 是为了避免中文乱码,这个参数在 PHP 5.6 的驱动里是支持的,但要注意它影响的是客户端和服务器之间的字符转换,数据库本身的排序规则是另一回事。

如果这个脚本返回“连接成功”,说明整条链路通了。如果返回false,sqlsrv_errors()会给出具体的错误码和消息,这是排查的起点。

2.4 用 PDO 方式连接的写法

如果决定走pdo_sqlsrv,连接代码是这样的:

<?php $dsn = "sqlsrv:Server=192.168.1.100,1433;Database=TestDB"; $username = "sa"; $password = "your_password"; try { $pdo = new PDO($dsn, $username, $password, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::SQLSRV_ATTR_ENCODING => PDO::SQLSRV_ENCODING_UTF8 )); echo "连接成功"; } catch (PDOException $e) { echo "连接失败: " . $e->getMessage(); } ?>

PDO::SQLSRV_ATTR_ENCODING这个常量是pdo_sqlsrv特有的,设成 UTF8 的效果和上面CharacterSet参数类似。把ERRMODE设成异常模式,后面所有数据库操作出错都会抛异常,比手动检查返回值清爽很多。

3. PHP 7.x 的迁移与差异处理

3.1 驱动版本切换的注意事项

从 PHP 5.6 升到 7.x,驱动必须换成 5.x 系列。文件名规则变成php_sqlsrv_7x_ts.dll这种形式,其中7x对应具体的 PHP 小版本,比如 7.2 就是php_sqlsrv_72_ts.dll。这里要特别注意,驱动包对 PHP 小版本是有区分的,7.0、7.1、7.2、7.3、7.4 各有各的文件,不能混用。

升级操作上,先把旧的 5.6 驱动 DLL 从ext目录删掉或者移走,再放新的进去,然后改php.ini里的文件名。如果旧文件不清理,有时候 PHP 会加载到错误的那个,表现出的症状是扩展明明在phpinfo里显示了,但函数调用报“未定义”。

PHP 7.x 时代微软还推出了一个统一驱动包,安装方式变成了跑一个 exe 安装程序,它会自动检测系统里的 PHP 版本并放置对应文件。这种方式省事,但有个副作用:它可能同时给多个 PHP 版本装驱动,如果你机器上有多个 PHP 环境,要确认它装到了你实际在用的那个上面。

3.2 PHP 7 下 API 层面的变化

好消息是sqlsrv_*函数和 PDO 的用法在 7.x 下基本没变,之前写的连接代码可以直接跑。但有几个行为差异需要留意。

第一是错误处理更严格了。PHP 7 对类型检查更严,sqlsrv_query传参时如果类型不对,5.6 下可能默默转换,7.x 下会直接报错。比如把字符串传给期望整数的参数,或者传了个null给必填参数,行为会不一样。

第二是sqlsrv_fetch_array的默认返回模式。5.6 下默认是SQLSRV_FETCH_BOTH,7.x 下这个默认值没变,但如果你代码里依赖了某些边界行为,比如字段名大小写,建议显式指定SQLSRV_FETCH_ASSOC,避免踩到差异。

第三是资源类型的变化。PHP 7 里很多资源变成了对象,sqlsrv的连接资源在 7.x 下仍然是资源类型,但如果你用is_resource()去判断,某些场景下结果可能和 5.6 不同。稳妥的做法是用$conn === false来判断失败,而不是用is_resource判断成功。

3.3 一个兼容两个版本的封装思路

如果项目需要同时兼容 5.6 和 7.x,最省心的做法是写一层薄封装,把连接和查询的差异挡在外面。我的做法是定义一个连接工厂,根据 PHP 版本或者配置决定用哪个驱动,但对外暴露统一的接口。

<?php class DbFactory { public static function connect($config) { if (extension_loaded('pdo_sqlsrv')) { $dsn = sprintf( "sqlsrv:Server=%s,%s;Database=%s", $config['host'], $config['port'], $config['db'] ); return new PDO($dsn, $config['user'], $config['pass'], array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION )); } if (function_exists('sqlsrv_connect')) { $conn = sqlsrv_connect( $config['host'] . ',' . $config['port'], array( "Database" => $config['db'], "Uid" => $config['user'], "PWD" => $config['pass'], "CharacterSet" => "UTF-8" ) ); if ($conn === false) { throw new RuntimeException(print_r(sqlsrv_errors(), true)); } return $conn; } throw new RuntimeException('没有可用的 SQL Server 驱动'); } } ?>

这个封装的好处是业务代码不用关心底层是哪个扩展,迁移的时候只改这一处。当然,如果两个扩展都装了,优先用 PDO 那套,因为接口更现代,后续维护成本低。

4. 连接参数与字符集处理的深水区

4.1 连接字符串里每个参数的实际作用

很多人连接串是抄来的,参数含义并不清楚,出问题就抓瞎。我把常用参数逐个拆开讲。

Server后面跟主机和端口,格式是host,port。如果实例不是默认实例,还要加实例名,写法是host\instance,port。这里有个坑:实例名和端口同时写的时候,某些驱动版本会优先用端口,实例名被忽略,导致连到错误的实例上。稳妥做法是只写端口,或者只写实例名,别混着来。

Database指定初始数据库。不写的话连到登录账号的默认库,如果那个库不存在或者没权限,连接会失败但报错信息可能很含糊。

Uid和PWD是账号密码。如果要用 Windows 集成认证,这两个不写,改成TrustServerCertificate和认证模式相关的参数,但集成认证在跨机器场景下配置复杂,一般项目里还是用 SQL 账号。

CharacterSet前面提过,设 UTF-8。这里要补充一点:这个参数只影响驱动层的字符转换,如果数据库排序规则是Chinese_PRC_CI_AS这类非 UTF-8 的,写入中文时仍然可能出问题。根治办法是把数据库或者字段的排序规则改成支持 Unicode 的类型,比如NVARCHAR。

LoginTimeout控制连接超时秒数,默认 15 秒左右。内网环境可以调小到 5 秒,快速失败比干等强。Encrypt参数控制是否加密传输,老版本 SQL Server 可能不支持强制加密,设成 true 会连不上,这个要看服务器配置。

4.2 中文乱码的三层排查法

中文乱码是这类项目最高频的问题,我总结了一个从外到内的排查顺序。

第一层看连接层。CharacterSet有没有设 UTF-8,PDO 那边有没有设PDO::SQLSRV_ATTR_ENCODING。这一层没设对,取出来的中文直接是问号或者乱码方块。

第二层看字段类型。如果字段是VARCHAR而不是NVARCHAR,那它存的是按排序规则编码的字节,不是 Unicode。这种情况下即使连接层设了 UTF-8,驱动做转换时也可能丢信息。解决办法是把字段改成NVARCHAR,或者在查询时用CONVERT函数转换。

第三层看 PHP 文件本身的编码和输出。PHP 源文件要存成 UTF-8 无 BOM,页面输出要设header('Content-Type: text/html; charset=utf-8')。这三层任何一层出问题都会表现为乱码,但根源完全不同,所以要按顺序排查,别一上来就改代码。

提示:排查乱码时,先写一个只查单个中文字段、直接var_dump输出的脚本,绕开所有框架和模板,看到原始字节再判断是哪一层的问题。这样能省掉大量猜测时间。

4.3 连接池与长连接的取舍

SQL Server 驱动支持连接池,在连接字符串里加ConnectionPooling=1可以开启。这个特性在 Web 场景下能减少反复建连的开销,但也有副作用:连接被复用后,会话状态(比如临时表、SET选项)可能残留,导致一些诡异的行为。

我的经验是,如果应用是短请求、无状态的那种,开连接池收益明显;如果应用里有大量依赖会话状态的操作,比如临时表、游标、事务跨请求,那还是关掉连接池,每次新建连接更可控。PHP 本身是每请求结束就释放资源的模型,所谓长连接在 PHP 里意义有限,别被其他语言的经验带偏。

5. 常见报错与排查实录

5.1 扩展加载失败的典型症状

最常见的症状是phpinfo()里根本看不到sqlsrv区块。这时候按这个顺序查:先确认 DLL 文件确实在extension_dir目录下,文件名拼写和php.ini里写的一致;再确认线程安全类型匹配,TS 的 PHP 配 TS 的 DLL;然后确认位数匹配,64 位 PHP 配 64 位 DLL;最后看 PHP 启动日志或者 Apache 错误日志,里面通常会有“无法加载动态库”的具体原因。

还有一种情况是phpinfo里能看到扩展,但函数调用报未定义。这通常是加载了错误的版本,或者php.ini里同一个扩展被加载了两次(一次用全名一次用简写),导致冲突。检查php.ini里有没有重复行。

5.2 连接阶段的错误码对照

连接失败时sqlsrv_errors()返回的数组里,错误码是最有价值的线索。我整理了几个高频的:

错误码含义常见原因处理方向
08001无法建立连接主机端口不通、实例名错检查网络和端口
28000登录失败账号密码错、账号被禁用核对凭据和账号状态
4060无法打开数据库数据库不存在或无权限确认库名和账号权限
18456登录失败(服务器侧)认证模式不对检查服务器是否混合认证
233连接被强制关闭协议不匹配、TLS 版本检查驱动和服务器版本兼容性

18456这个特别常见,服务器如果只开了 Windows 认证没开混合认证,SQL 账号根本登不进去,报错就是这个。这个要在服务器端改认证模式,客户端怎么调都没用。

233在 PHP 7.x 配老版本 SQL Server 时偶尔出现,根源是 TLS 版本协商失败。新版驱动默认要求 TLS 1.2,老服务器可能只支持 TLS 1.0。解决办法要么升级服务器,要么在驱动配置里调整安全协议设置,具体看驱动文档里的Encrypt和协议相关参数。

5.3 查询执行阶段的坑

连接通了不代表查询就顺。sqlsrv_query返回false时,同样用sqlsrv_errors()看原因。常见的有参数数量不匹配、参数类型不对、SQL 语法在目标版本上不支持。

参数化查询这里有个容易忽略的点:sqlsrv_query的第三个参数是参数数组,数组元素的顺序要和 SQL 里问号的顺序严格对应。如果 SQL 里用了命名参数(:name形式),那要配合sqlsrv_prepare使用,sqlsrv_query对命名参数的支持有限。

还有一个坑是sqlsrv_fetch_array遍历大结果集时的内存问题。默认情况下驱动会把结果缓冲在客户端,结果集特别大的时候内存会爆。可以在连接选项里设"Scrollable" => SQLSRV_CURSOR_FORWARD或者用sqlsrv_fetch逐行取,减少内存占用。

5.4 事务处理的注意事项

用sqlsrv_begin_transaction开事务后,所有操作必须用同一个连接资源。如果中间不小心用了另一个连接执行了语句,那个语句不在事务里,回滚的时候不会撤销,会造成数据不一致。

另外,PHP 脚本执行超时或者被中断时,未提交的事务会被服务器回滚,但连接可能还挂在池子里。如果开了连接池,下一个请求拿到这个连接时,事务状态可能不干净。所以事务代码一定要用try-catch包住,确保异常路径上也执行了rollback或者commit。

6. 性能与安全上的实操建议

6.1 参数化查询不是可选项

拼接 SQL 字符串在这个场景下风险尤其高,因为 SQL Server 的注入手法和 MySQL 有差异,一些在 MySQL 上被过滤的写法在 SQL Server 上照样能生效。sqlsrv_query和 PDO 预处理都支持参数绑定,没有理由不用。

参数绑定时注意类型。sqlsrv_query的第四个参数可以指定每个参数的类型和方向,比如array($value, SQLSRV_PARAM_IN, SQLSRV_PHPTYPE_STRING, SQLSRV_SQLTYPE_NVARCHAR(50))。不指定的话驱动会做推断,大多数情况没问题,但涉及日期、二进制、大文本时最好显式指定,避免隐式转换带来的性能损失或者精度问题。

6.2 查询超时与重试策略

sqlsrv_query的第五个参数是选项数组,可以设QueryTimeout。默认没有超时或者超时很长,一个慢查询能把 PHP 进程挂住很久。生产环境建议设一个合理的值,比如 30 秒,配合 PHP 的max_execution_time一起控制。

重试要谨慎。对于读操作,超时后重试一次通常安全;对于写操作,重试可能导致重复插入,除非有幂等设计。我的做法是只对明确的连接类错误做重试,查询超时不重试,直接报错让上层处理。

6.3 权限最小化原则

连接数据库的账号不要用sa。给应用单独建一个账号,只授予它需要的库和表的权限,需要执行存储过程就只给执行权限,不需要写就不给写权限。这个原则在哪都一样,但在 SQL Server 场景下尤其要注意,因为默认的public角色权限有时候比想象中大,建账号后要实际验证一下它能做什么不能做什么。

6.4 日志与可观测性

驱动本身不提供详细的日志,但你可以把sqlsrv_errors()的返回内容记到应用日志里,包括错误码、消息、SQLSTATE。排查线上问题时,这些信息比一句“数据库连接失败”有用得多。另外记录慢查询,把执行时间超过阈值的 SQL 和参数记下来,对优化很有帮助。

我在实际项目里吃过一个亏:早期没记错误详情,线上偶发连接失败,日志里只有一句笼统的提示,查了两天才定位到是网络设备对长连接的空闲回收导致的。后来把错误码和发生时间都记上,同类问题几分钟就能判断方向。

最后分享一个小技巧:如果你在本地开发环境反复遇到驱动加载问题,可以写一个check_env.php脚本,把phpversion()、php_sapi_name()、extension_loaded('sqlsrv')、extension_loaded('pdo_sqlsrv')、PHP_INT_SIZE这些信息一次性打出来,换机器或者换环境时先跑一遍,比逐个去phpinfo里翻快得多。这个脚本我到现在还留在几个老项目的根目录里,每次环境变动都靠它快速确认状态。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进