简介:面向 MySQL 初学者的批量更新实战笔记,解决已用 INSERT 导入 name 字段后仍需按对应关系更新 package 字段的问题,适用于批量导入后补字段、配置表初始化等场景。文档从实际工作场景出发,给出 UPDATE table_name SET field_name = CASE other_field WHEN ... THEN ... END WHERE id IN (...) 的完整写法,重点说明如何根据其他字段条件为不同记录写入不同值,从而避免逐条执行 UPDATE 的低效做法;这种方法比逐条编写多条 UPDATE 语句更省时,也更便于维护。进一步提供 PHP 示例:循环读取 package_name.txt,动态拼接 SQL,将 id 集合放入 IN 子句后一次提交,将文本内容按行映射到对应 id;同时提醒 mysql_* 系列函数已废弃,应选用 mysqli 或 PDO,并注意大文件分批处理、SQL 注入防护与事务管理。资源为 PDF 格式,共 1 个文件,压缩包仅 40KB,轻量易查阅;已有 5746 人学习下载,适合开发人员作为 MySQL 批量更新速查与排错参考。实践中可直接参考其中的 SQL 拼接与安全建议。
1. 一次更新多条记录:为什么先谈 UPDATE 而不是再 INSERT
这几天在处理一张 7 字段的表时,遇到一个不算刁钻但很典型的场景:id、name、package 等字段都建好了,先用 INSERT 把 name 文本文件里的数据全部导了进去,然后发现 package 字段是空的,想按同样方式补数据却插不进去——记录已经躺在表里,主键冲突让 INSERT 整批放弃。这时候正确思路其实是 MySQL 的 UPDATE,而且是「一条 UPDATE 更新多条记录」的 CASE 写法:把要更新的值和记录 id 一一对应,一次 SQL 全刷完。这个思路适合所有「表结构已存在、按已有主键回填字段」的补录场景,比如从文本文件、接口 JSON 或 Excel 导入后做二次回填,也适合运营后台批量改分类、改状态、改套餐标识。做这件事的你大概率是运维、后端开发或者常年跟数据打交道的实施,不用懂太深的 MySQL 内核,只需要把一个 CASE 表达式拼对、把批处理边界考虑清楚。
2. 批量更新的原理:CASE 表达式和逐条 UPDATE 的差别
2.1 为什么不用 N 条 UPDATE 循环
提到「更新多条记录」,大多数人第一反应是写一个 for 循环,逐条执行 UPDATE。每次回填一个值:
UPDATE pydot_g SET package_name = 'app1.apk' WHERE id = 1; UPDATE pydot_g SET package_name = 'app2.apk' WHERE id = 2; UPDATE pydot_g SET package_name = 'app3.apk' WHERE id = 3;循环执行 2000 次,MySQL 解析 2000 次 SQL、做 2000 次网络往返。如果 PHP 脚本和数据库在同一台机器上,可能还只是慢一点;如果数据库在远程服务器,那这 2000 次往返的耗时占比就很可观了。批量更新的核心价值是省掉网络开销和 SQL 解析开销,把 N 次小更新合并成 1 次大更新。要注意脚本里用完 mysql_connect 后还有 mysql_select_db 选库、mysql_query 执行并等待返回的过程,循环里每跑一条都要等数据库响应,这个「等待-返回-再发下一条」的串行节奏,才是数据量大时最拖后腿的地方。
2.2 CASE 表达式的语法结构拆开看
CASE 有两种写法:简单 CASE 和搜索 CASE。批量更新用到的通常是简单 CASE,把某个判断字段(一般是主键 id)和一个常量比较:
UPDATE pydot_g SET package_name = CASE id WHEN 1 THEN 'app1.apk' WHEN 2 THEN 'app2.apk' WHEN 3 THEN 'app3.apk' END WHERE id IN (1, 2, 3);执行过程是:MySQL 逐行读取 pydot_g 表中满足 WHERE id IN 条件的记录,对每一行取出 id 值,拿它去和 WHEN 后面的常量 1、2、3 匹配。匹配上了就返回对应的 THEN 值,赋给 package_name;如果一行都匹配不上,就走 ELSE 分支,没写 ELSE 就返回 NULL。也就是说,id 是 1 就把 package_name 更新成 app1.apk,id 是 2 就把 package_name 更新成 app2.apk,像一张「映射关系表」一样逐行回填。
这里有两个容易被忽略的点。第一,WHERE id IN 的范围一定要和 CASE WHEN 里出现的 id 保持一致,否则会出现「WHERE 放进来但 CASE 没有匹配项」的行,那这些行会被静默更新成 NULL,非常危险。第二,CASE 匹配到的值是按顺序扫描的,如果 id 是主键、没有重复值,那顺序无所谓;如果判断字段不是主键、有重复值,CASE 只会取第一个匹配到的 THEN 分支,后面的分支不会生效,所以做这种批量更新时,判断字段务必用唯一键或主键。
2.3 一条 UPDATE 的原子性和锁开销
单条 UPDATE 在 InnoDB 里就是一个隐式事务,要么全部提交、要么全部回滚。这句话反过来理解就是:如果你采用逐条循环的方式,第 1 条成功、第 50 条失败,脚本会停在中间状态——前 49 条已改、后面的还是旧值,你甚至不知道从第几条开始断的。而拼成一条超大 SQL 后,如果有任何一行语法错误、类型不匹配,整条 SQL 失败回滚,表保持原样,这对补录场景来说反而是好事:失败是整批失败,不会留一半改一半的烂摊子。
锁的开销也要单独提一下:一条 UPDATE 锁定的是满足 WHERE 条件的索引记录间隙,执行完就释放。N 条循环 UPDATE 意味着执行 N 次加锁/释放,在并发较高的库上会放大锁等待概率;一条大 UPDATE 虽然单次锁范围可能更大,但总的锁持有时间是单次 SQL 的执行时间,通常比 N 次循环短。数据量特别大时,一条 SQL 锁太多行会拉长事务时间,这也是第 4 章要讲分批的一个核心原因,但为了「一次更新多条记录」这个目标,CASE 法仍然是最优解。
3. 把文本文件拼进 UPDATE:PHP 脚本与两个可选写法
3.1 原始示例为什么能跑但只能算半成品
正文示例里通过读 package_name.txt 逐行拼接 WHEN THEN,最后把 id 列表拼进 WHERE IN,方向是对的。但代码里有个很刺眼的细节:文件名变量叫$fname_package_name,后面却用$handle直接 fopen,实际根本没定义$handle。原代码是:
$path = "txt"; $fname_package_name = "package_name.txt"; //$handle= @fopen($path."/".$fname_package_name, "r");$handle永远为 null,fgets 读不到内容,循环直接跳过,最终拼出来的 SQL 只有一个不完整的 WHERE 条件。这是我见过最典型的「贴出来能看懂但复制去跑必翻车」的代码,后面我给你一份拿到就能用的。
另外注意原始拼接逻辑有两个边界坑:当读到最后一行时,fgets()返回文件末尾的换行符\n,如果不 trim 掉,SQL 里就会出现带换行的字符串值,值末尾多一个看不见的换行,跟文本文件里实际的 package 名不一致。还有$ids .= sprintf("%d,", $i);把每个 id 后面加了一个逗号,循环完了又做$ids .= $i;,实际上拼出来的 id 列表末尾多了一个没配逗号的数字,例如1,2,3,4变成了1,2,3,44,应写成:
$ids = trim($ids, ",");再用到 WHERE 里,避免多逗号、多数字的问题。
3.2 mysqli 写法:能直接落地的版本
现在 PHP 的 mysql_* 函数早在 PHP 7 就被移除了,想跑通示例必须先切换到 mysqli。用 mysqli 改写并修正上述问题,生产可用的代码如下:
<?php $server = 'localhost'; $user = 'root'; $passwd = 'root'; $dbname = 'catx'; $table = 'pydot_g'; $mysqli = new mysqli($server, $user, $passwd, $dbname); if ($mysqli->connect_errno) { die('Connect Error: ' . $mysqli->connect_error); } $handle = fopen(__DIR__ . '/txt/package_name.txt', 'r'); if (!$handle) { die('open txt failed: ' . $error); } $sql = "UPDATE {$table} SET package_name = CASE id "; $ids = ''; $i = 1; while (($line = fgets($handle, 512)) !== false) { $package = trim($line); // 去掉行尾换行 $package = $mysqli->real_escape_string($package); // 转义单引号等特殊字符 $sql .= sprintf("WHEN %d THEN '%s' ", $i, $package); $ids .= sprintf("%d,", $i); $i++; } fclose($handle); if ($i === 1) { die('文件为空,没有可更新的数据'); } $ids = rtrim($ids, ','); // 去掉最后一个多余的逗号 $sql .= "END WHERE id IN ({$ids})"; $mysqli->begin_transaction(); if ($mysqli->query($sql)) { $mysqli->commit(); } else { $mysqli->rollback(); die('UPDATE failed: ' . $mysqli->error); } printf("affected rows: %d\n", $mysqli->affected_rows); $mysqli->close();这段代码做了三件关键的事:一是fgets读到的内容先trim(),去掉每行末尾的\n;二是real_escape_string转义文本里的单引号,避免 value 含引号时破坏 SQL;三是用begin_transaction包住整条 UPDATE,失败时整体回滚。判断条件用$i === 1,是因为循环从 1 开始自增,一行都没读到就执行不了任何 WHEN 分支,此时的 SQL 缺失真正的赋值动作,必须提前终止。还有一点:文件路径用__DIR__拼接,比原来写死的相对路径更稳,脚本被 cron 调用时工作目录变化也不会找不到文件。
3.3 PDO 写法:更推荐在框架里用这种方式
现代 PHP 框架里最通用的还是 PDO。PDO 没法直接把一张动态生成的映射表用 prepared statement 的?占位符表达完整,因为 WHEN 分支数量是运行期才知道的,所以常见做法是手动拼接 SQL 字符串、但只对「值」做参数绑定:
<?php $pdo = new PDO( 'mysql:host=localhost;port=3306;dbname=catx;charset=utf8mb4', 'root', 'root', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $handle = fopen(__DIR__ . '/txt/package_name.txt', 'r'); $sql = "UPDATE pydot_g SET package_name = CASE id "; $ids = ''; $i = 1; while (($line = fgets($handle, 512)) !== false) { $package = trim($line); $named = ':pkg' . $i; // 生成 :pkg1, :pkg2 ... $sql .= sprintf("WHEN %d THEN %s ", $i, $named); $ids .= sprintf("%d,", $i); $params[$named] = $package; // 收集绑定参数 $i++; } fclose($handle); $ids = rtrim($ids, ','); $sql .= "END WHERE id IN ({$ids})"; $stmt = $pdo->prepare($sql); $stmt->execute($params); echo "affected rows: " . $stmt->rowCount() . "\n";这里有一个值得展开的细节:WHEN 后面的 id 序列是按行号生成的(1、2、3…),它必须和文件里第 N 行对应表中的第 N 条记录。实际生产里文件里的顺序通常和表里 id 顺序不一致,所以更严谨的做法是文件里第一列写 id、第二列写 package,循环里同时把 id 和 package 读出来。文件格式是两列的话,改一行fgetcsv就能同时取到两个字段,这也是把这个脚本从「只能按顺序回填」升级为「按 id 精确回填」的关键一步。示例代码里用行号代替 id 只是演示,文档型数据补录场景一般不会恰好按 id 排序,下文避坑还会再说一次。
提示:PDO 拼接
CASE id WHEN :pkg1时,:pkg1是占位符,不能加引号,加了引号会被当成普通字符串,这是新手最常见的翻车点。
4. 避坑与排查:批量 UPDATE 四个高频事故现场
4.1 文件文本里的单引号把 SQL 打断
现象:执行时 MySQL 报语法错误,错误信息类似You have an error in your SQL syntax near 's' at line 1,或者插进去的值被截断、错位。原因:package_name.txt 里恰好有带英文单引号的内容,比如O'Brien.apk,拼接 SQL 时直接变成THEN 'O'Brien.apk',MySQL 认为字符串在O'处结束了,后面的Brien.apk变成裸 SQL 片段。解决:mysqli 版本用real_escape_string,PDO 版本用参数绑定。这两招能挡掉绝大多数「值里有引号」的问题,即使你觉得自己的数据源「不可能有引号」,补录脚本也该顺手做掉,成本几乎为零。
4.2 WHERE 条件写不全导致整表刷成同一个值
现象:UPDATE 执行成功了,但没有报错,结果全表的 package_name 都变成了空或同一个值,id 对不上原先的映射关系。原因:拼 SQL 时 WHERE id IN 部分没有拼上,或者 ids 变量为空字符串,最终执行的是没有 WHERE 条件的UPDATE pydot_g SET package_name = CASE id ... END——注意,没有 WHERE 约束时,MySQL 会把所有行都当成目标行,而那些 id 没出现在 WHEN 列表里的行会得到一个 NULL。解决:执行前先把拼好的 SQL 用echo $sql打出来人工看一眼,确认结尾有WHERE id IN (...)且括号内 id 非空;更保险的做法是在代码里加一道检查,if (trim($ids) === '')直接拒绝执行。平时我写完这种脚本会先用SELECT COUNT(*)看一下文件行数和表数据量是否对应,数量对不上就先别执行。
4.3 max_allowed_packet 超过上限导致整条 SQL 失败
现象:数据量几千到几万行时,mysqli 报Lost connection to MySQL server during query或MySQL server has gone away,本地测小文件没问题。原因:拼出来的单条 UPDATE 太大,超过 MySQL 的max_allowed_packet限制,默认值常见为 4M 或 16M,取决于安装配置;服务端直接拒绝接收这么大的包。解决:等文件特别大时分批处理,比如每 500 行拼一条 UPDATE、执行一次,再拼下一条。分批数量要看数据库配置,可以用SHOW VARIABLES LIKE 'max_allowed_packet';先查上限,一般控制在 1M 以内比较稳妥,500 行一刷通常不会有问题。分批执行的另一个好处是单条事务持有锁的时间缩短,不会把整张表钉死太长时间。
4.4 文件行数与表 id 不对齐,更新串行
现象:脚本执行完,发现某些记录的 package_name 和预期对不上,检查逻辑发现是把「第 N 行」对应成了「id = N」。原因:这个坑是最容易信号衰减的。原因:原示例用循环计数器$i的值直接当作表里的 id,假设文本文件第 1 行对应表中 id=1 的记录,但如果表里 id 有间隔、有删除,或者文件行顺序不是按 id 升序,整个映射就错位了。解决:文本文件每行改成id,package_name两列,用fgetcsv读成数组,把实际 id 和实际值一起拼进 CASE。这样无论文件行序怎么变,映射关系始终由显式 id 决定,不再依赖「第几行」的隐式约定。这也顺便解决了「id 中间有断号」时误更新的问题。
5. 批量更新后如何验证:一条 SQL 确认数据没歪
在跑完这段 UPDATE 并看到affected rows之后,别急着关终端,我会再做三件事来确认没有埋雷。第一件事是抽查,用一条 SQL 对比文件源和表:
SELECT id, package_name FROM pydot_g WHERE id IN (1, 2, 3, 500, 1000) ORDER BY id;把输出和 package_name.txt 里对应行人工对一眼,重点看头和尾——拼接类脚本最容易在首行、末行出问题。第二件事是检查有没有意外多更新的行数,可以先记录更新前的表总行数,再执行SELECT COUNT(*) FROM pydot_g WHERE package_name IS NULL OR package_name = '';,正常补录完这个计数应该是 0 或者一个已知的极少值,如果数量很大,说明 WHERE 或 CASE 有遗漏,得立刻回滚备份。第三件事是统计 CASE 映射的覆盖度,用一条 GROUP BY 看历史遗留数据是否已被全量覆盖:
SELECT COUNT(*) AS total, SUM(package_name IS NOT NULL) AS filled FROM pydot_g;看 total 和 filled 是否一致,不一致就说明还有行没吃上这次的映射值。这类检查配合第 4.2 条会让整个补录过程像「先看伤口再下药」,不用蒙着头反复执行。
从那以后,我每次写批量 UPDATE 脚本,都强制自己先查三样东西:文件里有没有空行和引号、WHERE 条件拼完有没有打印过完整 SQL、表里目标字段当前的空值分布是怎么样。这三样查完再执行,翻车概率能降一大半。希望这份 CASE 拼接笔记帮到你,下次遇到数据表补录,先别急着 INSERT,试试一条 UPDATE 刷完它。
本文还有配套的精品资源,点击获取