做了这么多年PHP开发,我越来越觉得“增删改查”这四个字被严重低估了。刚入行的时候我也觉得,不就是拼几个SQL字符串,调几个函数把数据塞进数据库再取出来吗?后来被线上事故教育过几次才明白,恰恰是这些最基础的操作,最能看出一个开发者对数据安全、异常处理、业务边界有没有敬畏心。网上能找到的PHP操作MySQL教程,要么是十几年前用mysql_*函数的古董代码,要么就是只贴代码不讲为什么,复制下来跑通就完事。今天这篇东西,我不打算从头到尾念一遍手册,而是把实际开发中真正值得注意的细节、踩过的坑、以及我认为正确的写法一次性讲清楚。不管你是刚学会基础语法准备写第一个数据库程序的新手,还是写了两三年业务代码但没系统梳理过数据库操作的老手,这篇应该都能给你一些参考。
1. 连接数据库这件“小事”,值得认真对待
1.1 选扩展:MySQLi还是PDO
很多人第一步就卡住了——PHP里操作MySQL到底用哪个扩展?mysqli还是PDO?我见过不少教程直接说“用PDO,功能强大”,但没说清楚为什么。
简单对比一下:
| 对比项 | MySQLi | PDO |
|---|---|---|
| 面向对象 | 支持 | 支持 |
| 命名空间 | 默认存在 | 默认存在 |
| 预处理语句 | 支持 | 支持 |
| 事务 | 支持 | 支持 |
| 多数据库兼容 | 仅MySQL | 支持MySQL、PostgreSQL、SQLite等12种 |
| 命名参数 | 不支持 | 支持 |
| 获取结果方式 | 较丰富 | 较丰富 |
我的建议是:新项目直接用PDO,没有悬念。理由不只是“多数据库兼容”这种听起来很虚的话,而是PDO的API设计思路更统一、更干净,特别是预处理语句的命名参数绑定,可读性比MySQLi的?占位符强很多,后面维护代码的时候你会感激这个选择。
PHP 7以上,
mysql_*系列函数已经彻底移除,任何教程里如果还在教mysql_connect(),直接把页面关掉就行。这是第一道排查代码好坏的线。
1.2 连接参数与字符集,一个都不能少
连接MySQL的第一步看着简单,但坑恰恰藏在最不起眼的细节里。下面是我现在写连接代码的标准姿势:
php复制<?php
$config = [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'blog',
'username' => 'root',
'password' => 'your_password',
'charset' => 'utf8mb4',
];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['dbname'],
$config['charset']
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $config['username'], $config['password'], $options);
} catch (PDOException $e) {
// 生产环境不能把异常信息直接抛给用户
error_log('Database connection failed: ' . $e->getMessage());
exit('系统暂时无法访问,请稍后再试。');
}
这里每一个参数我都是在实际项目中踩过坑之后才注意到它们的价值,简单拆解一下:
charset=utf8mb4:绝大多数中文乱码问题都是字符集没统一。utf8mb4和utf8的区别在于前者支持emoji和生僻字,一个utf8的库如果遇到特殊字符就会报错。连接串上写了还不够,还要确认数据库和表本身的字符集也是utf8mb4。PDO::ATTR_ERRMODE设为ERRMODE_EXCEPTION:让PDO出错时抛出异常而不是静默返回false,这直接决定了后续错误处理是优雅还是灾难。PDO::ATTR_EMULATE_PREPARES设为false:使用MySQL原生预处理而不是PDO模拟,SQL注入防护更可靠,后面第四节详细说。PDO::ATTR_DEFAULT_FETCH_MODE设为FETCH_ASSOC:默认返回关联数组,比混着数字索引的FETCH_BOTH(默认值)干净得多。
1.3 连接失败时的优雅降级
很多初学代码里,连不上数据库就是页面白屏一片,或者直接显示一堆英文报错。这在本地开发时无所谓,但一旦上线,你就是把服务器状态和技术栈暴露给了用户。上面示例里exit()之前用error_log把真实错误写进服务端日志,对外只给一句友好提示,这是最基本的操作。
我见过一个生产事故,Pdo连接抛异常后,异常信息直接打在页面上,里面包含了数据库用户名和密码。从那以后我对“连接失败的异常信息必须写日志而不是输出”这件事就特别固执。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新增、删除、修改、查询:四大操作的套路与翻车现场
2.1 新增数据:插入后拿到主键ID是刚需
最常见的插入场景,是插入一篇文章、一条订单记录,然后立刻要用它的自增主键做后续处理(比如跳转到详情页、生成关联子记录)。用PDO的写法:
php复制<?php
$sql = "INSERT INTO articles (title, content, category_id, status) VALUES (:title, :content, :category_id, :status)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':title' => $title,
':content' => $content,
':category_id' => $categoryId,
':status' => 1,
]);
$newId = (int)$pdo->lastInsertId();
这里有几个细节值得注意。第一,execute()里传关联数组的方式,键名要和SQL里的命名参数一一对应,多传、少传都会报错。第二,lastInsertId()拿到的ID是本连接上一次插入操作生成的自增ID,在并发请求下也是准确的,不用担心。第三,如果表里的主键不是自增,而是业务主键(比如订单号),那这一步可以跳过。
2.2 删除数据:物理删除还是逻辑删除
这可能是增删改查里最需要“先想清楚再动手”的操作。物理删除就是DELETE FROM,把记录从表里抹掉;逻辑删除是加一个deleted_at或者status字段,查询时统一过滤。
我的经验是:能逻辑删除就不要物理删除。原因很简单——数据一旦物理删除,后悔药是没有的。用户跟你说“我不小心删错了,能恢复吗”,你如果做了逻辑删除,一条UPDATE就能搞定;如果物理删了且没有备份,那就只能让用户承受损失。
逻辑删除的表设计一般长这样:
sql复制ALTER TABLE articles ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL;
逻辑删除的实际操作是UPDATE:
php复制<?php
$sql = "UPDATE articles SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => $articleId]);
之后所有查询里,统一加一个条件AND deleted_at IS NULL。这相当于给表加了一层“回收站”的概念。
如果确实需要物理删除(比如清空临时表、清理日志表),务必先把条件写好,再执行:
php复制<?php
$sql = "DELETE FROM access_logs WHERE created_at < :date";
$stmt = $pdo->prepare($sql);
$stmt->execute([':date' => date('Y-m-d 00:00:00', strtotime('-30 days'))]);
2.3 修改数据:忘写WHERE条件是最恐怖的事故
不用笑,很多工作了好几年的人也在这种低级错误上翻过车。我有个同事,执行UPDATE的时候少写了个WHERE id = ?,结果整张表的用户昵称全部变成了同一个值。还好当时是测试环境,生产环境出了这种问题,能不能善了就看公司文化了。
所以修改操作有两条铁律:
- 写UPDATE先写WHERE,再写SET。我知道这个顺序反直觉,但你可以先在草稿纸上把条件圈出来,再动笔写要改的字段。
- 执行后立刻检查影响行数。
rowCount()返回的就是被更新到的行数,如果该为1却返回了10或者返回false,说明条件写得有问题。
代码示范:
php复制<?php
$sql = "UPDATE users SET nickname = :nickname, updated_at = NOW() WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nickname' => $newNickname,
':id' => $userId,
]);
if ($stmt->rowCount() === 0) {
// 影响行数为0,可能是条件不匹配,也可能新值和旧值一样
// 这是业务判断要注意的细节,后面单独说
}
还有一点要啰嗦:更新操作里如果涉及主键ID,要先确认这个ID确实属于当前操作用户,否则就会出现越权修改。比如用户A登录后,把请求里的ID改成用户B的ID,直接就把B的资料改了。这个问题的防护在业务层,通常是加一个user_id条件:
php复制<?php
$sql = "UPDATE users SET nickname = :nickname WHERE id = :id AND user_id = :operator_id";
2.4 查询数据:fetch模式与分页
查询是增删改查里业务变化最多的环节,也是最容易写出性能烂代码的地方。先讲最基本的:
php复制<?php
// 查询多条
$sql = "SELECT id, title, created_at FROM articles WHERE category_id = :category_id AND deleted_at IS NULL ORDER BY id DESC LIMIT 20";
$stmt = $pdo->prepare($sql);
$stmt->execute([':category_id' => 3]);
$articles = $stmt->fetchAll();
// 查询单条
$sql = "SELECT id, title, content FROM articles WHERE id = :id AND deleted_at IS NULL LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => 123]);
$article = $stmt->fetch();
// 如果没查到,fetch()返回false
if (!$article) {
// 处理“文章不存在”的情况
}
fetchAll()默认按前面ATTR_DEFAULT_FETCH_MODE设置的FETCH_ASSOC返回二维关联数组,每一行是一个关联数组。查询列表时这基本够用了。
分页是必考必用场景,正确做法是:先查总数算出总页数,再查当前页数据。查询语句用LIMIT offset, count:
php复制<?php
$page = max(1, (int)($_GET['page'] ?? 1));
$pageSize = 20;
$offset = ($page - 1) * $pageSize;
$countSql = "SELECT COUNT(*) FROM articles WHERE category_id = :category_id AND deleted_at IS NULL";
$stmt = $pdo->prepare($countSql);
$stmt->execute([':category_id' => 3]);
$total = (int)$stmt->fetchColumn(); // fetchColumn()直接拿第一列的值
$totalPages = (int)ceil($total / $pageSize);
if ($page > $totalPages) {
$page = $totalPages;
}
$listSql = "SELECT id, title, created_at FROM articles WHERE category_id = :category_id AND deleted_at IS NULL ORDER BY id DESC LIMIT :offset, :limit";
$stmt = $pdo->prepare($listSql);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->execute();
$list = $stmt->fetchAll();
注意这段代码里我用了bindValue而不是execute([...])来传LIMIT参数,原因是PDO默认会把绑定值当字符串处理,MySQL在LIMIT处接收字符串可能报语法错误。指定PDO::PARAM_INT就解决了。
3. 预处理不只是把字符串换成问号,防的还是血淋淋的注入事故
3.1 拼接SQL为什么危险
很多人知道“拼接SQL会被SQL注入”,但没完全理解原理。我举个极端的例子:
php复制<?php
// 危险写法
$sql = "SELECT * FROM users WHERE username = '{$_POST['username']}' AND password = '{$_POST['password']}'";
如果用户在用户名框里输入:
code复制admin' --
那么拼出来的SQL变成了:
sql复制SELECT * FROM users WHERE username = 'admin' -- ' AND password = ''
--在MySQL里是注释符,后面的条件全被注释掉了,整个查询退化成了“查用户名为admin的所有记录”。如果这是登录逻辑,攻击者等于绕过了密码验证。
如果你觉得自己只会写“正规的”拼接,不会这么天真,那我告诉你还有一道隐藏坑:你以为把POST参数转义一下就行,但转义函数mysqli_real_escape_string很依赖连接字符集的正确设置,一旦字符集不一致,转义就可能失效,攻击者利用多字节编码特性照样逃逸出来。所以最稳的解法不是“消毒后拼接”,而是“压根不拼接”。
3.2 参数化查询的原理与写法
预处理语句(Prepared Statement)解决的就是这个问题。它的执行流程分成两步:先把SQL模板发给MySQL,由MySQL完成解析、编译,再绑定参数执行。也就是说,用户输入的任何内容都只会被当作“值”处理,永远不会被当作“SQL代码”执行。后面传进来的admin' --,在数据库眼里就是一个普通字符串。
PDO推荐写法,我之前增删改查的例子里用的都是这个方式:
php复制<?php
$sql = "SELECT * FROM users WHERE username = :username AND password = :password";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':username' => $username,
':password' => $password,
]);
$user = $stmt->fetch();
也可以不传关联数组,用bindValue一个个绑定:
php复制<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->bindValue(':id', $inputId, PDO::PARAM_INT);
$stmt->execute();
3.3 预处理是万能药吗
得泼盆冷水:预处理能防SQL注入,但防不了所有输入问题。比如你查询的字段用在ORDER BY后面,或者动态拼表名、列名,这些场景参数化是帮不上忙的。因为ORDER BY后面跟的必须是列名或表达式,数据库的“值绑定”机制在这里失效。
碰到这种情况,常规解法是白名单校验:把允许排序的字段用一个数组列出来,用户传什么都要先对比数组,不在数组里就用默认值:
php复制<?php
$allowedOrderBy = ['id', 'created_at', 'view_count'];
$orderBy = $_GET['order_by'] ?? 'id';
if (!in_array($orderBy, $allowedOrderBy, true)) {
$orderBy = 'id';
}
$sql = "SELECT * FROM articles ORDER BY {$orderBy} DESC LIMIT 20";
这种情况下,因为$orderBy已经被限定在安全集合里,可以直接拼接进SQL,不用担心注入。
4. 事务、异常与日志:给增删改查装上保险丝
4.1 什么时候必须用事务
单条增删改查用不上事务。但一旦一次业务请求要同时操作多张表,或者同一条记录先改状态再插入关联记录,就必须用事务把这些操作捆成一个整体——要么全部成功,要么全部回滚。
举一个最经典的转账场景:从A账户扣1000元,给B账户加1000元。如果扣款这条UPDATE执行成功,加款那条UPDATE执行失败,没有事务的话钱就凭空消失了。用事务包一下:
php复制<?php
try {
$pdo->beginTransaction();
$sql1 = "UPDATE accounts SET balance = balance - :amount WHERE user_id = :uid AND balance >= :amount";
$stmt1 = $pdo->prepare($sql1);
$stmt1->execute([':amount' => 1000, ':uid' => $userIdA]);
if ($stmt1->rowCount() === 0) {
// 账户不存在或余额不足,主动抛异常触发回滚
throw new RuntimeException('账户余额不足');
}
$sql2 = "UPDATE accounts SET balance = balance + :amount WHERE user_id = :uid";
$stmt2 = $pdo->prepare($sql2);
$stmt2->execute([':amount' => 1000, ':uid' => $userIdB]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
error_log('Transfer failed: ' . $e->getMessage());
// 对外返回统一错误提示
}
注意这里我用beginTransaction()之前,PDO的ERRMODE_EXCEPTION已经开启了,任何一个SQL语句出错都会抛异常,catch里rollBack()立即回滚。另外我在扣款SQL里加了balance >= :amount这个条件,这是防止超扣的关键——先判断再扣款,如果条件不满足,rowCount()返回0,我们主动抛异常。
4.2 异常处理的黄金组合
日常增删改查中,异常处理的目标是:开发环境看到详细错误,生产环境隐藏细节只记日志。display_errors这个配置需要在php.ini里区分环境设置:
ini复制; 开发环境
display_errors = On
error_reporting = E_ALL
; 生产环境
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
代码层面,我习惯把数据库操作包在try/catch里,异常对象里其实带着很多排查线索:getCode()是MySQL错误码,getMessage()是错误信息。但真正有用的排查信息往往需要更完整的上下文,所以日志里最好记录:
php复制<?php
catch (PDOException $e) {
$context = [
'sql' => $sql ?? 'unknown',
'params' => $params ?? [],
'error' => $e->getMessage(),
'code' => $e->getCode(),
'trace' => $e->getTraceAsString(),
];
error_log(json_encode($context, JSON_UNESCAPED_UNICODE));
// 对外提示
}
把SQL语句和执行参数都写进日志,排查问题会轻松很多。
4.3 影响行数为0不代表失败
这个坑我在前面提过,但值得单独说。rowCount()返回0的情况至少有三种:
- WHERE条件没匹配到任何记录;
- 匹配到了,但新值跟旧值完全一样,MySQL认为没有变化,影响行数为0;
- 操作的表恰好有触发器或某些特殊状态。
所以如果你的业务逻辑是“更新行数必须为1,否则报错”,就要先确认自己是否真的需要把“值没变化”当成错误。很多时候,用户提交了和原来一样的资料,更新没必要报“修改失败”。合理做法是:把“条件没匹配到”和“值没变化”区分开,前者才是真正要警惕的情况。
5. 常见报错与排查思路:增删改查写不出问题才奇怪
5.1 连接类错误
| 错误信息(截取) | 原因 | 排查方向 |
|---|---|---|
| SQLSTATE[HY000] [2002] Connection refused | 端口连不上,或其他原因 | 确认MySQL是否启动、端口是否3306、防火墙是否放行 |
| SQLSTATE[HY000] [2002] No such file or directory | 用Unix Socket连接,但socket文件路径不对 | 检查php.ini里pdo_mysql.default_socket或DSN里host设为localhost时的socket路径 |
| SQLSTATE[HY000] [1045] Access denied for user | 用户名或密码错误 | 确认账号密码、确认该账号是否有权限从当前IP连接 |
| SQLSTATE[HY000] [1049] Unknown database | 数据库名不存在 | 确认dbname拼写、大小写 |
连接类错误里最坑的就是Connection refused和No such file or directory的区别。简单说,DSN里host填127.0.0.1走TCP协议,连不上就报refused;填localhost在很多环境中会走Unix Socket,路径不对就报No such file。本地开发时这两个报错来回切换,很能考验排查功力。
5.2 SQL语法与字段相关错误
| 错误信息(截取) | 原因 |
|---|---|
| SQLSTATE[42000] Syntax error | SQL语法错误,去数逗号、括号、引号 |
| SQLSTATE[42S22] Column not found | 查询的列名不存在,检查字段拼写 |
| SQLSTATE[42S02] Base table or view not found | 表名不存在,确认前缀、数据库名 |
| SQLSTATE[22003] Out of range value | 数值超出字段范围,比如INT存了超过21亿的数字 |
| SQLSTATE[22001] Data too long for column | 字符串超过字段长度,VARCHAR(20)塞了30个字符 |
这类错误里,最常被忽略的是字段长度和数据类型的匹配。我在开发中发现,很多“莫名其妙”的报错都来自数据库字段设计时没想清楚:
INT存手机号,一超过21亿就崩,手机号请用VARCHAR(11)或BIGINT;VARCHAR(10)存中文,一个汉字占3个字节,10个字符能存几个汉字要注意;DATETIME和TIMESTAMP的范围不同,业务上规划时间范围时要提前选好。
5.3 中文乱码的排查链路
中文乱码是增删改查里非常高频的问题。我通常按下面这条链路排查:
- 查看数据库表字符集:
SHOW CREATE TABLE articles;,确认是utf8mb4。 - 查连接字符集:在传入数据前执行
SET NAMES utf8mb4,或者像我第一节那样在DSN里带上charset=utf8mb4。 - 查PHP文件编码:文件本身要是UTF-8(无BOM头)。BOM头会导致输出内容在最前面多一个看不见的字符,接口返回JSON时尤其致命。
- 查HTTP响应头:
Content-Type: text/html; charset=utf-8或JSON场景的application/json; charset=utf-8。
这四个环节只要有一个不是UTF-8,中文就会变成问号或乱码。逐个确认,基本能定位。
还有一个容易被忽视的:如果数据插入时在PHP侧已经是乱码,那存进数据库也是乱码,这属于“源头污染”。所以排查时先确认程序里变量打印出来是否正常,再查数据库和连接。
5.4 排查SQL问题的标准姿势
写一个快速验证SQL的办法:先把预处理语句里的参数替换成实际值,拿到数据库客户端(比如MySQL命令行、Navicat、Workbench等)里执行一遍。如果语法、数据结果都正常,说明问题在PHP侧的绑定参数;如果SQL本身报错,那数据库客户端会给更直接的提示。
实际操作时,我习惯给PDO开一个SQL日志记录器:
php复制<?php
$pdo->exec("SET GLOBAL general_log = 'ON'");
// 或用MySQL的slow_query_log、general_log记录所有语句
开发环境开启general_log能记录下所有实际发给MySQL的SQL,和代码对比就能看出是不是参数没传对。
6. 从增删改查走向项目实战的几条个人建议
写到这里,基础的数据库操作都覆盖得差不多了。但我想再说几句实在的心里话。
第一,增删改查代码不要散落在业务逻辑里。 我见过很多烂代码,一个简单的用户查询散落在十几个控制器里,没人知道哪份是对的。现在的主流做法是用一个Repository类或者Model类把SQL收敛起来,业务层只调用方法,不直接操作PDO。你要是一个人写项目,也建议先立这个规矩——不用框架也可以自己封装一个简单的BaseRepository,把findById、insert、update、delete这些通用方法统一放进去,后面换库、加缓存、加日志都会轻松很多。
第二,SQL语句里只返回你需要的列。 看到有人写SELECT *我总想上去念叨两句。多查出来的列不仅浪费带宽,还可能把不该暴露的字段(比如密码哈希、内部状态码)带进业务层甚至接口返回里。
第三,索引是查询性能的命根子。 增删改查写熟了之后,性能瓶颈多半会出现在慢查询上。如果一张表的数据量上了百万,而你的WHERE条件列没有索引,全表扫描的效率会让你怀疑人生。平时写查询时多留意:WHERE、ORDER BY、JOIN ON后面的字段都值得加索引。当然,索引不要滥用,写多读少的表加太多索引只会拖慢写入速度。
第四,备份习惯比技术本身更能救你命。 无论你代码写得多完美,数据库总有出意外的时候,定期备份、模拟恢复演练这些基本功,关键时刻能让你从“完蛋”变成“虚惊一场”。这个不是增删改查代码的范畴,但我真心建议每个和数据库打交道的人提前把备份方案做好。
回到标题本身——PHP操作MySQL的增删改查,看似基础,但它的边界其实很宽:连接方式的选择、SQL注入的防御、事务的使用、异常的分级处理、字符集的多环节把控,每一环都在检验一个开发者的经验厚度。我写这篇东西的时候反复在回想自己入行以来在这些环节上踩过的坑,如果其中某一段能让你避开我走过的弯路,那这篇就没白写。
