写这篇东西的起因挺简单:有一次帮一个做仿抖音短视频项目的老同事排查线上问题,用户上传视频后列表接口偶发报错,他排查了整整一天,最后定位到是mysqli预处理语句里的参数绑定类型写错了——数字ID用了字符串类型,MySQL优化器选错索引,导致慢查询拖垮接口。这种问题说难不难,但如果你对mysqli的理解只停留在“能连上、能执行SQL”这个层面,遇到它就会很痛苦。
这篇文章想做的,就是把PHP的mysqli扩展从基础连接到高级实战完整梳理一遍。我会把我在真实项目里踩过的坑、调优过的参数、以及面试中反复被问到的细节都放进来,让你不光会用,还能知道为什么这么做。适合刚入行想系统学数据库操作的PHP新人,也适合写了两三年代码但一直在用框架封装好的DB类、没怎么直接接触过mysqli的开发者。
1. 面向过程还是面向对象:写法不同,底层同源
很多新手一开始学mysqli,最纠结的问题就是:到底用面向对象写法还是函数写法?网上教程两种都有,代码风格混着记容易乱。其实这两种方式没有本质区别,它们调用的都是同一个底层扩展,只是暴露出来的API风格不同。
1.1 两种写法的核心差异
mysqli扩展提供了一组方法,同时也提供了一组对应的过程式函数。比如创建连接,面向对象用 new mysqli(),函数式用 mysqli_connect();执行查询,面向对象用 $mysqli->query(),函数式用 mysqli_query($mysqli, $sql)。
从功能上讲,两种写法能做完全一样的事情。从性能上讲,差异微乎其微,不至于成为选型理由。真正影响选择的因素是代码可读性和团队习惯。我个人的建议是:新项目一律用面向对象写法。理由有三个:
- 方法调用链清晰,IDE自动补全友好。
- 和PDO的写法更接近,以后换数据库抽象层成本低。
- 代码更整洁,尤其在处理预处理和事务这种多步骤操作时,
$stmt->execute()这种链式语义比mysqli_stmt_execute($stmt)更直观。
下面这个表格是常用的API对照,方便你在看老项目代码时快速切换思维:
| 功能 | 面向对象 | 面向过程 |
|---|---|---|
| 创建连接 | new mysqli(...) |
mysqli_connect(...) |
| 设置字符集 | $mysqli->set_charset('utf8mb4') |
mysqli_set_charset($mysqli, 'utf8mb4') |
| 执行SQL | $mysqli->query($sql) |
mysqli_query($mysqli, $sql) |
| 预处理 | $mysqli->prepare($sql) |
mysqli_prepare($mysqli, $sql) |
| 获取错误码 | $mysqli->errno |
mysqli_errno($mysqli) |
| 获取错误信息 | $mysqli->error |
mysqli_error($mysqli) |
| 事务提交 | $mysqli->commit() |
mysqli_commit($mysqli) |
| 事务回滚 | $mysqli->rollback() |
mysqli_rollback($mysqli) |
注意一点,PHP 7.0以后老掉牙的 mysql_* 函数族已经被彻底移除了,现在还在用 mysql_query 的代码直接就是致命错误。如果你维护的老项目里有类似代码,迁移到mysqli时不要只把函数名替换了,要重新审视连接管理和错误处理这两块,因为它们的模型差别挺大。
1.2 底层驱动:mysqlnd和libmysqlclient决定了什么
php.ini里和MySQL相关的扩展配置,常见的有 mysqli、pdo_mysql,它们底层可以由两种驱动实现:mysqlnd(MySQL Native Driver)和 libmysqlclient(MySQL官方的C客户端库)。
mysqlnd是PHP官方维护的驱动,PHP 5.4之后默认内置,也是绝大多数发行版默认启用的方案。libmysqlclient是MySQL官方提供的客户端库,需要单独安装。对普通开发者来说,你不需要深入理解两者的实现差异,但有几个行为差异会影响你的代码:
- mysqlnd自带了
get_result()方法,可以直接拿到结果集对象,用起来非常方便。如果用libmysqlclient,get_result()不可用,只能用bind_result()逐列绑定变量。 - mysqlnd对内存的管理和PHP的zend内存管理器集成更好,长时间运行的脚本更不容易内存泄漏。
- 某些统计函数(如
mysqli_get_connection_stats())只有mysqlnd才有。
在PHP官网下载的Windows发行版里,默认启用的就是mysqlnd。Linux下如果用包管理器安装php-mysql,通常也是mysqlnd。你可以通过 php -i | grep mysqli.default_socket 查看当前环境用的哪个驱动,输出里如果有 Client API library version => mysqlnd 字样,说明就是mysqlnd。
我建议你在开发环境直接使用mysqlnd,可以少踩很多坑。特别是下面第三节要讲的结果集获取方式,mysqlnd支持 get_result(),写起来比 bind_result() 舒服太多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接这一步的坑,比你想的多:字符集、超时与持久连接
连接数据库听起来简单,但我在生产环境排查过不少跟连接阶段相关的诡异问题,比如页面偶尔报连接超时、中文乱码、以及数据库连接数被打满。这些问题往往都不是SQL写错了,而是连接阶段的配置和细节处理没到位。
2.1 一个标准的连接写法
首先给一个生产环境可用的连接样例:
php复制<?php
$config = [
'host' => '127.0.0.1',
'port' => 3306,
'username' => 'app_user',
'password' => 'your_password',
'database' => 'app_db',
'charset' => 'utf8mb4',
'timeout' => 5,
];
$mysqli = new mysqli();
$mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, $config['timeout']);
$mysqli->real_connect(
$config['host'],
$config['username'],
$config['password'],
$config['database'],
$config['port']
);
if ($mysqli->connect_errno) {
throw new RuntimeException(
'数据库连接失败: ' . $mysqli->connect_error,
$mysqli->connect_errno
);
}
$mysqli->set_charset($config['charset']);
这里我用了 new mysqli() 不传参、再单独调 options() 和 real_connect() 的方式,而不是直接 new mysqli($host, $user, $pass, $db, $port)。这样做的目的只有一个:能在连接前设置超时时间。如果你直接用构造函数传参,一旦数据库不可达,默认可能要等几十秒甚至更久才报错,这在Web请求里是完全不可接受的。
关于 connect_errno 的判断,如果你用的PHP版本是8.1以上,情况有点变化:mysqli在8.1之后默认开启了 MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT,这意味着连接失败会直接抛出 mysqli_sql_exception 异常,根本走不到 connect_errno 那行代码。我的建议是统一开启异常模式并配合try-catch处理,代码更简洁,也避免漏判。
2.2 字符集乱码的真正成因
服务端数据库字符集、连接字符集、PHP文件本身编码、HTML页面声明的字符集,这四者如果不一致,就会出现经典的中文乱码问题。其中最容易忽略的是连接字符集。
MySQL的连接字符集是独立的,它不一定会跟着数据库表的字符集走。比如你的表是 utf8mb4,但连接字符集默认可能是 latin1,那么写入时会发生一次字符集转换,取出时又一次转换,来回折腾中文字符就容易变成问号。
正确做法就是在建立连接后立刻执行 set_charset('utf8mb4')。这里注意要使用 utf8mb4 而不是 utf8,因为MySQL里的 utf8 是 utf8mb3 的别名,最多只支持3字节编码,存不了emoji和一些生僻字。现在的项目一律用 utf8mb4 是行业共识。
还有一个隐蔽的坑:如果PHP文件本身是GBK编码保存的,那不管连接字符集设置得对不对,硬编码在代码里的中文SQL都可能出问题。尽量统一为UTF-8编码的PHP文件,别混用。
2.3 连接超时、读取超时与探活机制
连接超时用 MYSQLI_OPT_CONNECT_TIMEOUT 设置,单位是秒,一般设3到5秒比较合理。比连接超时更容易被忽略的是读取超时,也就是SQL已经发出去了,但MySQL迟迟不返回结果。这种情况通常由慢查询或者锁等待引起,PHP默认会一直等下去,直到触发PHP自身的 max_execution_time。所以有必要给mysqli设置读取超时:
php复制$mysqli->options(MYSQLI_OPT_READ_TIMEOUT, 10);
读取超时如果设得太短,比如1秒,遇到正常但稍微慢一点的查询也会中断,反而引发误报。我一般设10到30秒,具体看业务容忍度。
还有一个常见的线上问题:程序代码有bug,导致每次请求都新建连接但没正常关闭。传统PHP-FPM模式下,请求结束连接会自动释放,问题不大;但如果你用Swoole或Workerman这类常驻内存模式,连接不会随请求结束自动关闭,必须显式复用或关闭连接,否则连接数会被快速耗尽,报 Too many connections 错误。
2.4 长连接到底该不该用
mysqli支持长连接,写法是在host前面加 p: 前缀,比如 p:127.0.0.1。长连接能避免每次请求都做TCP握手和MySQL认证,理论上对性能有帮助,但实际在PHP-FPM模式下我反而不推荐轻易开启。
原因有两个:
- PHP-FPM的worker进程是常驻的,长连接会跟着worker存活。如果一个worker处理完请求后MySQL发生了重启,这个连接就会变成“半死状态”,下次使用时报
MySQL server has gone away。 - 长连接容易把事务状态串到下一个请求。比如当前请求开了事务忘了提交,下一个请求复用同一个连接,会带着上一个请求未提交的锁或者隔离级别,出现难以排查的数据错乱。
如果你确实需要连接复用,我建议用连接池方案成熟的框架或者中间件,而不是依赖mysqli的 p: 前缀。
3. 预处理语句是mysqli的生命线:读懂占位符,才算入门
如果只让我选一个mysqli最核心的功能,我会选预处理语句(Prepared Statement)。它不仅是防SQL注入的标准手段,也是批量操作时的性能优化利器。这一节我们把它讲透。
3.1 为什么预处理能防住SQL注入
先看一个危险的写法:
php复制$sql = "SELECT * FROM users WHERE username = '{$_POST['username']}' AND password = '{$_POST['password']}'";
如果用户在用户名输入框里填:
text复制' OR '1'='1
拼出来的SQL就变成了:
sql复制SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ''
这个条件恒为真,攻击者不需要知道密码就能查询到用户数据,甚至结合 UNION 注入拖走整个表的数据。各种代码审计、CTF题目里的SQL注入,绝大多数都源于这种“字符串拼接”的习惯。
预处理语句解决这个问题的核心思路是:把SQL的“结构”和“参数”分开传输。执行 prepare() 时,SQL结构已经发送给MySQL服务端完成解析和编译,参数位是问号占位符。后续执行 execute() 时,参数通过独立的二进制协议通道传给服务端,服务端只把它当成纯粹的数据值,不会参与SQL语法解析。所以在正确使用预处理的前提下,无论参数里带什么特殊字符,都只是数据,不可能改变SQL语义。
这里要多说一句:预处理不是银弹。如果你在写SQL时把参数用字符串拼接嵌进SQL,再丢给 prepare(),那预处理也救不了你。有些新手以为用了 prepare() 就绝对安全,结果仍然把变量拼进SQL字符串,这属于用法错误,不是预处理的问题。
3.2 bind_param的参数类型到底怎么选
预处理语句的完整流程是:prepare → bind_param → execute → 获取结果。bind_param() 的格式是:
php复制$stmt->bind_param('is', $id, $name);
第一个参数是一个字符串,每个字符代表一个占位符的类型,常用的有四种:
| 类型字符 | 对应MySQL数据类型 | 说明 |
|---|---|---|
i |
int | 整数 |
d |
double/float | 浮点数 |
s |
string | 字符串、日期等 |
b |
blob | 二进制大对象 |
这个类型字符串很容易被忽略,但它直接影响MySQL对参数的解析方式。回到文章开头那个案例:ID字段在表里是 BIGINT 类型,用 s 绑定数字,MySQL在比较时就不能直接用索引上的数值进行比较,可能发生隐式类型转换,导致索引失效,全表扫描,接口就慢了。
原理是:MySQL的索引树是按照字段的原始类型存储的。查询参数如果是字符串,MySQL会尝试把字段值转成字符串再比较,这个转换会让索引匹配逻辑变复杂,优化器就可能放弃走索引。所以“一个字符写错”造成的性能问题,现象可能只是慢,但根因在在这里。
正确做法:数字字段就用 i,字符串字段用 s,浮点位精确的金额字段用 d 或者干脆全部用字符串避免精度损失。宁可多花两秒钟想清楚类型,也不要无脑全写 s。
3.3 动态拼接查询条件时的参数绑定技巧
实际业务中,查询条件经常是动态的,比如一个商品筛选接口,可能按分类、关键词、价格区间、上架状态任意组合过滤。参数数量不固定,怎么绑定?
我用的方案是:先把SQL片段和参数数组同步收集起来,最后用 call_user_func_array 展开绑定。示例代码:
php复制<?php
$conditions = [];
$params = [];
$types = '';
if (!empty($categoryId)) {
$conditions[] = 'category_id = ?';
$params[] = $categoryId;
$types .= 'i';
}
if (!empty($keyword)) {
$conditions[] = 'name LIKE ?';
$params[] = '%' . $keyword . '%';
$types .= 's';
}
if (!empty($minPrice)) {
$conditions[] = 'price >= ?';
$params[] = $minPrice;
$types .= 'd';
}
$sql = 'SELECT * FROM products';
if ($conditions) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= ' ORDER BY id DESC LIMIT 20';
$stmt = $mysqli->prepare($sql);
if ($types !== '') {
// bind_param的参数是引用传递,call_user_func_array可以展开数组并处理引用关系
$bindParams = [$types];
foreach ($params as $key => $param) {
$bindParams[] = &$params[$key];
}
call_user_func_array([$stmt, 'bind_param'], $bindParams);
}
$stmt->execute();
$result = $stmt->get_result();
$list = $result->fetch_all(MYSQLI_ASSOC);
这里有一个关键细节:bind_param() 的第二个及以后的参数是引用传递。在PHP 5.3之前,你需要给数组元素加 & 前缀;现在虽然不再强制要求,但为了兼容性和避免奇怪的告警,我仍然建议用引用方式构建参数数组。
另外,LIKE 查询的 % 通配符是拼在参数里的,不会破坏预处理的安全性,这是正确写法。千万不要把 % 拼在SQL模板里,比如 WHERE name LIKE %?%,这是语法错误。
3.4 获取结果的两种姿势:get_result和bind_result
预处理执行后,获取结果集的两条路线分别是 get_result() 和 bind_result()。
get_result() 返回一个 mysqli_result 对象,可以继续用 fetch_assoc()、fetch_row()、fetch_all() 等方法取数据,和 query() 的用法一致,非常灵活。缺点是这个方法依赖mysqlnd驱动,如果用libmysqlclient则不可用。
bind_result() 则是在执行前就声明好要把查询的每一列绑定到哪些PHP变量上,执行后调用 fetch() 拉取一行。它的优点是不依赖mysqlnd,缺点是PHP变量的绑定顺序必须和SELECT列顺序完全一致,一旦改SQL就很容易忘记同步调整,维护起来比较痛苦。
我的建议很简单:能用 get_result() 就用它,除非你被限制在一个只能用libmysqlclient的环境里。
还有一类常见需求是拿到查询结果的同时统计行数。注意 num_rows 只在 store_result() 之后才准确。使用 get_result() 的情况下,mysqli会自动帮你缓冲结果集,所以可以直接读取 $result->num_rows。但如果你用的是 $stmt->execute() 后直接 fetch(),此时 $stmt->num_rows 可能返回0,因为结果还没完全从服务器拉下来。这个坑排查起来很烦,建议有空就复习一下 store_result() 和 use_result() 的区别。
3.5 PHP 8.1之后的新写法:execute_query
PHP 8.1为mysqli引入了一个非常方便的语法糖,叫 execute_query()。它的签名是:
php复制$mysqli->execute_query(string $sql, array $params = []): mysqli_result|bool
以前要写五行代码的预处理流程,现在两行搞定:
php复制$result = $mysqli->execute_query(
'SELECT username, email FROM users WHERE id = ?',
[$id]
);
$user = $result->fetch_assoc();
参数数组被称为“位置参数”,不用再显式声明类型。底层会根据参数在数组中的位置和值自动推断并绑定。这个API把“类型声明”这个步骤简化为“自动推断”,写法上非常接近PDO的预处理了。
不过要注意:execute_query() 适合查询、更新、删除这类操作。如果涉及需要反复执行的批量循环,建议还是走 prepare() + execute() 的方式,避免每次都重新解析SQL结构。
4. 事务处理:begin、commit、rollback之间藏着多少细节
事务是保证数据一致性的基础能力,尤其在做订单、支付、库存这类业务时必不可少。mysqli的事务接口很简单,但真实环境里的问题往往出在“你以为你开了事务,实际没生效”或者“事务没正常回滚”。
4.1 一个完整的事务示例
我写一个经典的转账场景:
php复制<?php
$mysqli->begin_transaction(MYSQLI_TRANS_START_READ_WRITE, MYSQLI_TRANS_ISOLATION_REPEATABLE_READ);
try {
$stmt = $mysqli->prepare('UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100');
$stmt->execute();
if ($stmt->affected_rows !== 1) {
throw new RuntimeException('扣款失败或余额不足');
}
$stmt = $mysqli->prepare('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
$stmt->execute();
$mysqli->commit();
} catch (Throwable $e) {
$mysqli->rollback();
error_log('转账失败: ' . $e->getMessage());
// 重新抛出给上层或返回错误响应
throw $e;
}
begin_transaction() 可以接收两个可选参数:第一个指定事务是只读还是读写(MYSQLI_TRANS_START_READ_ONLY / MYSQLI_TRANS_START_READ_WRITE),第二个指定隔离级别(MYSQLI_TRANS_ISOLATION_READ_COMMITTED 等)。大多数场景下用默认参数就够了,但对一致性要求高的业务,显式声明读写权限能让数据库优化器提前做一些优化。
这里的核心是:commit() 和 rollback() 必须在同一个try/catch结构里。数据库操作属于IO调用,可能失败的原因太多了——死锁、超时、约束冲突,只要前面任何一步抛了异常,就必须回滚,否则数据就处于半更新状态。
一个易错点是:如果 UPDATE 虽然执行成功,但影响的函数不是预期值(比如扣款时发现余额不足,条件 balance >= 100 不满足,affected_rows 为0),这时候也应该主动抛出异常触发回滚。不能想当然地认为“SQL执行没报错,事务就成功了”。
4.2 事务不生效的常见原因
第一,表引擎必须是 InnoDB。MyISAM引擎不支持事务,begin_transaction() 调用不会报错,但commit和rollback全部无效,数据该改还是改。在建表时就要确认 ENGINE=InnoDB。可以用以下SQL查询表引擎:
sql复制SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_database';
第二,锁等待超时和死锁。线上并发高时,死锁错误信息常见的是:
text复制Deadlock found when trying to get lock; try restarting transaction
MySQL检测到死锁后会自动回滚其中一个事务,并抛出这个错误。开发者的应对策略通常有两种:
- 把事务体做得尽可能小,减少锁的持有时间。不要在事务里面调用外部HTTP接口、循环发送通知等耗时操作。
- 对偶发的死锁错误,在代码里做重试机制,比如捕获到
1213错误码后重试整个事务。
我的个人经验是:事务里的SQL顺序尽量固定。比如涉及多行更新的场景,如果所有请求都按同一个顺序更新记录,死锁概率会小很多。死锁的本质是两个事务以不同顺序去抢占同一组资源,形成环路等待。
第三,开启事务后,不能再执行会导致隐式提交的语句。比如 ALTER TABLE、CREATE INDEX、TRUNCATE 等DDL语句会触发隐式提交,把当前事务悄悄提交掉。如果在一个大事务里中途执行了这些操作,后面的回滚就无效了。排查这类问题时的表现是:明明调用了rollback,但数据还是被改了。这时候第一反应是查查事务里有没有DDL语句。
4.3 嵌套事务和保存点
mysqli本身没有嵌套事务的概念。你调用 begin_transaction() 两次,第二次调用实际上会提交第一次的事务,然后在新的位置重新开始一个事务。如果你需要在事务中做部分回滚,应该使用保存点(SAVEPOINT):
sql复制SAVEPOINT sp1;
UPDATE ... ;
ROLLBACK TO sp1;
在mysqli里可以用 query() 直接执行这些保存点语句。保存点适合比较复杂的批处理逻辑,比如一个导入任务,前几条数据处理成功、后几条失败,你只想回滚失败的部分而保留前面的进度。不过日常业务中保存点用得不多,大部分需求都可以通过拆小事务来解决。
4.4 事务配合批量写入的性能实践
把多个写操作放在一个事务里提交,能显著减少磁盘刷盘次数,这一点在做批量插入时尤其明显。事务的默认行为是每条语句自动提交,如果是逐条执行,就要等待磁盘确认1000次;合并为一个事务后,磁盘确认只需要一次(当然,实际InnoDB还有redo log等机制,这里不展开)。
我本机测试过:向一个表里插入5000条记录,逐条 INSERT 执行耗时大概在2到3秒;改成预处理 + 一个事务批量提交后,耗时能降到0.2到0.4秒,提升接近一个数量级。这还没算减少网络往返次数带来的收益。框架里的类似场景,比如订单里的多件商品明细写入,非常适合这个模式。
事务开启后要注意别把其他无关查询也塞进去,长时间持有锁会影响并发。建议事务里只放需要保持一致性的写操作以及必要的查询,把只读的汇总查询放到事务外。
5. 错误处理:三种报告模式与一套可落地的日志方案
很多PHP项目遇到数据库报错时,页面就是一片空白,或者只显示一行“Internal Server Error”,查问题得靠猜。这通常是因为没搞明白mysqli的报告机制。mysqli提供了一套报告模式,用好了能让运行时错误处理变得清晰。
5.1 三种报告模式的区别
mysqli_report() 函数可以设置全局的报告模式,接收以下常量:
| 模式常量 | 行为说明 |
|---|---|
MYSQLI_REPORT_OFF |
关闭额外报告,出错时只设置 errno 和 error 属性,程序自行检查 |
MYSQLI_REPORT_ERROR |
出错时报PHP警告,但程序继续执行 |
MYSQLI_REPORT_STRICT |
出错时抛出 mysqli_sql_exception 异常 |
MYSQLI_REPORT_ALL |
等于 ERROR | STRICT,同时也报告其他警告信息 |
在PHP 8.1之前,默认是 MYSQLI_REPORT_OFF,所以老代码里经常能看到到处判断 if (!$result) { echo $mysqli->error; } 的写法。PHP 8.1之后,默认变成了 MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT,也就是说所有mysqli方法出错都会抛异常。
这个变化对老项目是破坏性的,不少系统升级PHP版本后一夜之间接口全挂,就是因为没有捕获异常。但对新项目来说,异常模式是更好的选择——错误被显式抛出,处理逻辑更统一。
5.2 生产环境推荐的做法
我推荐的组合是:
- 开发环境:开启
MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT,让所有数据库错误以异常形式暴露,及时发现问题。 - 生产环境:同样开启严格异常,但在全局异常处理器里把错误记录到日志并返回友好的提示,不要向用户暴露具体SQL和错误信息。
全局异常处理器的样例:
php复制<?php
set_exception_handler(function (Throwable $e) {
error_log(sprintf(
'[%s] %s in %s:%d%s',
date('Y-m-d H:i:s'),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
PHP_EOL . $e->getTraceAsString()
));
if ($e instanceof mysqli_sql_exception) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 500, 'message' => '系统繁忙,请稍后重试']);
return;
}
throw $e;
});
这里的日志格式不算华丽,但足够排查。error_log 默认写到PHP的错误日志文件,你也可以改成写入专门的数据库日志文件,方便按系统模块检索。日志信息里务必要带上异常发生的文件位置和行号,否则线上定位问题只能靠猜。我见过有些团队记录日志时只写 $e->getMessage(),没有文件也没有堆栈,排查问题的效率至少低一半。
5.3 从慢日志和EXPLAIN下手排查性能异常
数据库性能问题的最常见信号是“接口突然变慢”或“数据库CPU升高”。这时第一步不是看代码,而是看MySQL慢查询日志。
开启慢查询日志可以在MySQL配置文件中设置:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
long_query_time 设为1秒,超过1秒的查询都会记录到日志里,包含SQL文本和执行时间。拿到慢SQL后,用 EXPLAIN 分析执行计划:
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 20;
重点看 type 列。如果出现 ALL(全表扫描),说明索引没命中;ref 或 const 属于比较理想的索引访问类型。另外留意 rows 列,预估扫描行数越少越好。我之前遇到过一个订单查询,user_id 字段上明明有索引,但SQL里写成了 WHERE user_id = '12345'(字符串),导致MySQL放弃索引,全表扫了上百万行。把参数类型改为int后,查询从800毫秒直接降到5毫秒。这是字符串参数导致索引失效的经典案例。
5.4 严格模式引发的异常:并不是PHP的bug
MySQL 5.7及以上的版本默认开启了严格SQL模式(STRICT_TRANS_TABLES等)。在这种模式下,向 INT 字段插入超出范围的值、向 DATE 字段插入非法日期、执行 UPDATE 时数据长度超限,都会直接报错,而不是像老版本那样发出警告并截断数据。
这套行为对程序来说是“友好”的,因为异常能被捕获,问题能被暴露。但许多老项目升级MySQL版本后,突然出现大量SQL错误,排查原因就是代码里还在依赖不严格模式的宽松行为。
比如有一个用户表,nickname 字段长度是 VARCHAR(50),接口层没做长度校验,直接存用户输入的用户名。严格模式下,用户名80个字符时就会出现 Data too long for column 'nickname' 的异常。这本质上是业务代码的校验缺失,但如果你没用异常处理,线上就只会看到一条更新失败。
我的建议是:所有从外部传入的、要写进数据库的字段,都要在PHP层先做长度、类型、范围校验。不要指望数据库宽容。数据库的严格模式是保护你的,不是给你添麻烦的。
6. 从“能用”到“好用”:批量写入、JSON字段与PDO的选型思考
最后一节,聊一些能直接提升开发体验和生产稳定性的进阶点,包括批量操作、JSON字段处理,以及我对于“mysqli和PDO到底选谁”这个经典问题的看法。
6.1 批量插入:一次prepare,循环execute
批量插入想高效又有安全性,尽量用“一次prepare + 循环execute”的方式。这样SQL解析只发生一次,参数通过二进制协议高效传输,内部还在同一个事务里提交,整体性能远好于每条记录一次 query()。
php复制<?php
$data = [
['user_id' => 1, 'product_id' => 100, 'quantity' => 2],
['user_id' => 1, 'product_id' => 101, 'quantity' => 1],
// ...
];
$mysqli->begin_transaction();
try {
$stmt = $mysqli->prepare('INSERT INTO order_items (user_id, product_id, quantity) VALUES (?, ?, ?)');
foreach ($data as $item) {
$stmt->bind_param('iii', $item['user_id'], $item['product_id'], $item['quantity']);
$stmt->execute();
}
$mysqli->commit();
} catch (Throwable $e) {
$mysqli->rollback();
throw $e;
}
如果你的数据量特别大(比如上万条),一条多值INSERT的写法也有价值,因为SQL体积小、往返次数少。但要注意SQL文本长度有限制,超长时需要分段。分段时每段建议500到1000条,实测在绝大多数情况下性能和可控性平衡得最好。
php复制$chunks = array_chunk($data, 500);
foreach ($chunks as $chunk) {
$values = [];
$types = '';
$params = [];
foreach ($chunk as $item) {
$values[] = '(?, ?, ?)';
$types .= 'iii';
array_push($params, $item['user_id'], $item['product_id'], $item['quantity']);
}
$sql = 'INSERT INTO order_items (user_id, product_id, quantity) VALUES ' . implode(',', $values);
$stmt = $mysqli->prepare($sql);
$bindParams = [$types];
foreach ($params as $key => $param) {
$bindParams[] = &$params[$key];
}
call_user_func_array([$stmt, 'bind_param'], $bindParams);
$stmt->execute();
}
这里还要注意一点:INSERT 的SQL要显式列出字段名,尤其是项目里后续有加字段的规划时,显式字段列表能避免因为表结构变化导致整条插入语句失效,也让别人看代码时更清楚要写哪些数据。
6.2 JSON字段的读写姿势
从MySQL 5.7开始,官方提供了JSON类型字段。和普通TEXT/VARCHAR存JSON字符串相比,JSON类型的优势是:插入时会自动校验JSON合法性,查询时可以用 JSON_EXTRACT 或 -> 操作符提取字段内部的某个值,还能对JSON内部字段建虚拟索引。
mysqli读写JSON字段没有特殊的API,就是按字符串处理。写入时用 json_encode,读取时用 json_decode:
php复制<?php
// 写入
$preferences = ['theme' => 'dark', 'notifications' => ['email' => true, 'sms' => false]];
$stmt = $mysqli->prepare('UPDATE users SET preferences = ? WHERE id = ?');
$json = json_encode($preferences, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$stmt->bind_param('si', $json, $userId);
$stmt->execute();
// 读取
$result = $mysqli->execute_query('SELECT preferences FROM users WHERE id = ?', [$userId]);
$row = $result->fetch_assoc();
$preferences = json_decode($row['preferences'], true);
json_encode 时的两个选项值得记住:JSON_UNESCAPED_UNICODE 保证中文不会被转成 \uXXXX,节省存储空间且可读性更高;JSON_UNESCAPED_SLASHES 避免URL里的 / 被转义成 \/。这两个选项不是必需,但在业务中保持JSON字符串更接近人眼可读的状态,排查问题时体验更好。
经常有人问,JSON字段能走索引吗?MySQL的JSON类型本身不能直接建索引,但可以对内部字段生成虚拟列,再给虚拟列建索引。虚拟列语法如下:
sql复制ALTER TABLE users ADD COLUMN theme VARCHAR(20) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(preferences, '$.theme'))) STORED,
ADD INDEX idx_theme (theme);
这个用法适合需要根据JSON内部字段频繁查询的场景。如果只是存储和读取,不需要内部字段的条件查询,那就不用折腾索引,直接用TEXT类型也可以,区别不大。
6.3 mysqoi还是PDO:我的一点选择建议
PDO(PHP Data Objects)是PHP官方的数据库抽象层,支持MySQL、PostgreSQL、SQLite等多种数据库。mysqli则是MySQL专属扩展。这两个东西经常被拿来对比,我的观点是:
- 如果项目确定长期使用MySQL,并且希望直接用MySQL特有的功能(比如JSON字段、空间索引),mysqli是更顺手的工具。
- 如果项目需要支持多种数据库,或者团队未来有可能从MySQL迁移到PostgreSQL,或者你在写通用底层库/框架,PDO几乎必然是你的选择。
- 如果你在写基于Swoole等常驻内存框架的MySQL组件,两个都可以,但建议优先用PDO,因为它和连接池、协程等机制配合的例子更多。
从预处理能力看,两者几乎等价。PDO支持 prepare() + execute([$params]),也支持 bindValue() / bindParam(),和mysqli在功能上没有代差。
从错误处理看,PDO默认是静默模式,需要手动开启异常模式:
php复制$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
mysqli从PHP 8.1开始默认就抛异常了,少一行配置代码,但也要注意兼容旧版本PHP。
综合来说,如果是给老项目做维护或重构,顺着项目现状来;如果是新开项目、团队也没人硬性要求PDO,那mysqli完全可以胜任,尤其是在你需要写MySQL专属SQL玩得比较花的时候。核心逻辑都在数据库层,抽象层那点差异影响有限。
6.4 最后一个小习惯:统一封装数据库入口
无论最终选mysqli还是PDO,我都强烈建议不要在业务代码里到处 new mysqli()。至少封装一个简单的数据库单例或工厂,把连接配置、字符集、异常处理、日志都在入口统一搞定。这样一来,换环境只需要改一个地方,出问题也只需要排查一个地方。我曾见过一个项目有八种不同的数据库连接写法,从 mysql_connect 残骸到 mysqli 再到 PDO 混着用,维护起来真的想哭。
以mysqli为例,最简封装可以长这样:
php复制<?php
class Database
{
private static ?mysqli $instance = null;
public static function connection(): mysqli
{
if (self::$instance === null) {
$config = require __DIR__ . '/config/database.php';
$mysqli = new mysqli();
$mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, $config['timeout'] ?? 5);
$mysqli->real_connect(
$config['host'],
$config['username'],
$config['password'],
$config['database'],
$config['port'] ?? 3306
);
$mysqli->set_charset($config['charset'] ?? 'utf8mb4');
self::$instance = $mysqli;
}
return self::$instance;
}
}
// 使用
$user = Database::connection()->execute_query(
'SELECT * FROM users WHERE id = ?',
[$id]
)->fetch_assoc();
在我自己经手的项目里,这种封装配合统一异常处理,再在SQL入口加上慢查询日志埋点,基本就能覆盖90%以上的数据库排查需求。剩下的10%,比如主从延迟、连接池耗尽、死锁优化,那都是更深一层的故事,以后有机会再单独聊。
