我接手过的系统里,凡是运营后台带“批量”两个字的功能,十有八九最后都要落到数据库的批量 UPDATE 上。比如刚才还在群里有人问:勾选了几百个商品要改价格,一条条 UPDATE 太慢了,有没有办法一条 SQL 全改完?这是个特别典型的场景——每条数据要更新的值还不一样,有的是改价,有的是改状态,有的只是改个排序权重。
处理这种需求,绕不开两条主流路线:一条是应用层循环着逐条 UPDATE,另一条是把多条不同值的更新拼成一条带 CASE WHEN 的大 SQL 一次性执行。两条路我都走过,也在线上踩过不少坑,数据量从几十条到几万条都试过,性能和稳定性差别非常明显。这篇就把两种方式的实现细节、性能底细、适用边界,以及我踩过的那些问题一次性讲清楚,最后再把临时表 JOIN 和 INSERT ON DUPLICATE KEY UPDATE 这两个变种也拉出来对比一下,帮你按场景做选择。
1. 先说清楚:批量UPDATE到底卡在哪
1.1 “批量更新”分两种,别混为一谈
很多人口中的“批量更新”,实际上指的是两种完全不同的事。第一种很简单——把所有满足条件的行改成同一个值,比如把状态为 0 的订单全部改成已取消,一条UPDATE orders SET status = 2 WHERE status = 0就完了。这种需求根本不存在性能焦虑,也没人专门讨论。
真正让后端头疼的是第二种:一批记录,每条都有自己独立的新值。同样勾选了 2000 个商品,商品 A 要改成 19.9 元,商品 B 要改成 29.9 元,商品 C 要改成 39.9 元……值各不相同。SQL 语法本身对“一次给多行分别赋不同值”的支持非常弱,你没法写一条标准的 UPDATE 说“第 1 行改成这样,第 2 行改成那样”。所以只能靠应用层去组织这些更新指令,组织方式就演化出了两种主流方案。
1.2 逐行不同值更新时的两个性能瓶颈
先看最直观的做法:既然是每条数据值不一样,那就一条一条来,循环里每行执行一次 UPDATE。
for row in rows: cursor.execute( "UPDATE t_product SET price = %s WHERE id = %s", (row["price"], row["id"]) )这写法在数据量小的时候没人觉得有问题,可一旦数据量上来,瓶颈就藏不住了。第一个瓶颈是网络交互次数,N 条记录就是 N 次完整的 SQL 发送、解析、执行、返回,每一条都要一个网络往返。本地数据库可能还好,一次往返零点几毫秒,跨机房的话一次可能是几毫秒甚至几十毫秒,2000 条乘下来,光网络耗时就在几秒到几十秒之间。第二个瓶颈是锁和事务的持有时间,如果把这 2000 条 UPDATE 包在一个事务里,循环期间这些行锁一直握在手里不释放,其他会话想更新同一行就只能等。
所以批量差异更新的本质问题,是一个取舍问题:是减少应用层与数据库的交互次数,把复杂度集中到单条 SQL 里?还是保持 SQL 简单,用交互次数换可维护性?这两种取舍,正好对应了标题里的两种方式。
2. 方式一:逐条UPDATE循环执行
2.1 最朴素的实现长什么样
逐条方式的代码很好理解,任何语言里都是“遍历 + 执行 UPDATE”。我见过的大多数第一版实现都是这种。
// Java 示例:常规 for 循环逐条更新 for (Product p : productList) { jdbcTemplate.update( "UPDATE t_product SET price = ?, status = ? WHERE id = ?", p.getPrice(), p.getStatus(), p.getId() ); }这个写法的优点一眼就能看出来:逻辑直白,每条更新的值、条件都清清楚楚,哪条出错了立刻能定位到具体数据;每条 UPDATE 都是独立的小操作,影响行数也可以分别拿到,方便做校验。对于只有十几二十条数据的低频后台操作,这完全足够,不会有人因此说你写得差。
提示:逐条 UPDATE 最大的问题不在于 SQL 本身,而在于循环里的网络往返。本地测试时感觉不到,一旦放到线上环境、数据库和应用服务器之间隔着网络,延迟立刻被放大。
2.2 性能画像:2000 条数据要跑多久
我特意做过一次对比测试。同一台测试库,2000 条商品记录,每条价格不同,分别用逐条循环和后面要讲的 CASE WHEN 方式执行。结果是这样的:逐条循环因为走的是连接池,每条大概 0.8 毫秒到 1.5 毫秒,2000 条合计约 2 到 3 秒;而 CASE WHEN 方式一条 SQL 执行完,总耗时 40 毫秒左右。差距大概 50 到 70 倍。
这个差距几乎全部来自 SQL 交互次数。数据库执行 2000 条简单 UPDATE 本身并不慢,慢的是 2000 次网络往返,以及 2000 次 SQL 解析和 2000 次事务提交的资源开销。如果连接还开了自动提交,那压力更大,等于做了 2000 次独立的提交。如果手动开启事务并在循环结束后统一提交,网络往返仍然躲不掉,锁的持有时间还会被拉长。
2.3 哪些场景下逐条仍是更合适的选择
我不是来劝大家彻底抛弃逐条方式的——它是“笨”,但有些场景它就是更稳。比如更新数量非常少,只有几条,怎么写都无所谓,逐条反而更清晰;再比如每行更新的条件根本无法归纳成同一个模式,必须依赖程序的复杂判断才能确定更新哪些字段,用 CASE WHEN 去拼一个通用 SQL 反而会累死;还有一种情况是业务上必须要逐条检查影响行数,比如更新失败的要单独记录下来重试,逐条方式处理这种逻辑非常自然。
逐条方式只要加上一个事务壳,并把提交时机控制好,在几百条数据量级下完全够用。真正要避免的是“逐条 + 自动提交”这种最差组合。
# 更稳的写法:手动控制事务,统一提交 conn.begin() try: for row in rows: cursor.execute( "UPDATE t_product SET price = %s WHERE id = %s", (row["price"], row["id"]) ) conn.commit() except Exception: conn.rollback() raise2.4 逐条更新的隐藏成本清单
除了时间和锁,逐条更新还有一个常被忽略的成本——日志量。每条 UPDATE 都会产生对应的 binlog 记录,2000 条循环产生 2000 条独立事务记录;而批量方案如果放在一个事务里,产生的 binlog 内容总量可能相差不大,但事务记录数量天差地别。主从复制时,从库要处理的事务条目越少,延迟往往越低。另外,逐条更新中途一旦某条失败,如果没有事务包裹,前面已经更新成功的数据就无法自动回滚,数据会处于一半新一半旧的状态,手工恢复非常费劲。
3. 方式二:CASE WHEN拼接一条SQL搞定批量更新
3.1 核心语法:一个CASE搞定多行多列
逐条方式的问题在于交互次数多,那自然想得到:能不能把 N 条 UPDATE 合并成一条?MySQL 里确实有办法,就是借助 CASE 表达式,在一条 UPDATE 语句里按条件对不同行赋不同的值。
UPDATE t_product SET price = CASE id WHEN 1 THEN 19.9 WHEN 2 THEN 29.9 WHEN 3 THEN 39.9 END WHERE id IN (1, 2, 3);这条 SQL 的意思很直接:当 id 等于 1 时把 price 改成 19.9,等于 2 时改成 29.9,等于 3 时改成 39.9。一次网络往返,一条语句,全部改完。还可以在同一个 SET 里同时更新多个字段,用逗号分隔。
UPDATE t_product SET price = CASE id WHEN 1 THEN 19.9 WHEN 2 THEN 29.9 END, status = CASE id WHEN 1 THEN 2 WHEN 2 THEN 1 END WHERE id IN (1, 2);这里有个细节:同一个 id 在多个字段的 CASE 里要分别出现一次,这是因为 CASE 表达式是逐字段独立计算的。如果某些行的某个字段不需要更新,要在 CASE 里加上ELSE 原字段名,把旧值保留住,否则它会被置成 NULL。这个坑后面细说。
3.2 应用层怎么安全地拼出这条SQL
写起来最能体现“应用层拼 SQL”的功力。以 Python 为例,核心就是把 id 和新值拼成 CASE 的 WHEN-THEN 对。
ids = [] when_clauses = [] for row in rows: ids.append(str(row["id"])) # 注意:name 是字符串,需要转义或参数化 when_clauses.append( f"WHEN {row['id']} THEN '{escape_str(row['name'])}'" ) sql = ( "UPDATE t_user SET name = CASE id " + " ".join(when_clauses) + f" END WHERE id IN ({','.join(ids)})" )拼接字符串听上去简单,但非常容易踩 SQL 注入和类型转换的坑。id 应该强转成整数再拼,name 这类字符串必须做转义。如果团队的规范不允许手动拼 SQL,可以用支持动态 SQL 到 ORM 框架,从语法层面约束传入参数。实际上很多 ORM 框架能自己生成这种批量更新语句,但仍需先在测试环境检查它生成的 SQL 是否执行了全表扫描。
3.3 为什么它快:一次解析、一次往返、一次提交
CASE WHEN 方案快的核心,是它把“N 次交互”压缩成了“1 次交互”。数据库只需要解析一次 UPDATE,规划一次执行计划,然后按主键索引逐行定位、逐行更新。网络耗时从 N 个 RTT 变成 1 个 RTT,SQL 解析开销也从 N 次变成 1 次,整体耗时自然掉了一个数量级。InnoDB 引擎内部仍然是一行一行更新的,但那是引擎内部的事,不需要应用层参与,省掉的时间非常可观。
拿我测试的标准,1000 条数据,逐条方式在本地网络里也要 1 秒以上,CASE WHEN 方式基本在 30 到 60 毫秒。2000 条也就是 100 毫秒以内级别的表现。对这个量级,接口的响应时间已经不再是问题。
3.4 必须处理好的三个风险点
CASE WHEN 不是万能的,它有三道坎。
第一道坎是 SQL 长度。每增加一条数据,SQL 文本就会变长。1000 条可能 50KB,10000 条可能 500KB。MySQL 的max_allowed_packet参数决定了客户端和服务器之间能传输的最大包体积,默认通常是 64MB,但生产环境为了安全可能会调小。一旦 SQL 超过限制,直接报packet too large。所以数据量大的时候不能一把梭,要分批拼接。
第二道坎是锁范围。一条 UPDATE 语句会更新 WHERE 条件命中的所有行,执行期间这些行都要加行锁。语句本身很快的话,锁持有时间很短,但如果数据量特别大,执行了几秒甚至十几秒,其他并发更新就会阻塞。所以量越大,越要拆批。
第三道坎是数据一致性中的 NULL 陷阱。前面提到的ELSE必须给足,否则未匹配的行会把字段更新成 NULL。比如你只想改 3 条记录的 price,但业务代码拼出来只写了这 3 条记录的 CASE,目标是执行UPDATE t_product SET price = ... WHERE id IN (1,2,3),如果是逐条方式,其他行的 price 完全不受影响。但 CASE WHEN 方式如果写成只对 id=1,2,3 赋值而缺少 ELSE,其他行并不会被更新,因为 WHERE 已经限定只处理这 3 行了。真正的风险在于,WHERE 条件万一没加,或者 IN 范围比预期大,未列出的行就会变成 NULL,这是生产事故级别的问题。
小心:CASE WHEN 拼完之后,一定要检查生成的 SQL 里有没有 WHERE 条件。宁可在代码里写断言检查
'WHERE' not in sql就抛异常,也不要拿脸去试。
4. 两个常用变种:临时表JOIN与INSERT ON DUPLICATE KEY UPDATE
4.1 临时表JOIN:大数据量下更稳的升级版
CASE WHEN 在几千条以内很舒服,但当数据量到了一万、两万甚至更多,单条 SQL 体积过于庞大,解析和网络传输开始变得沉重。这时可以考虑临时表 JOIN 的方式,思路是换个角度:把要更新的新值先批量 INSERT 进一张临时表,然后用一次 UPDATE JOIN 把临时表的新值回写到目标表。
-- 1. 建临时表 CREATE TEMPORARY TABLE tmp_product_price ( id INT PRIMARY KEY, price DECIMAL(10,2) ) ENGINE=InnoDB; -- 2. 批量插入新值,可以分多批执行 INSERT INTO tmp_product_price (id, price) VALUES (1, 19.9), (2, 29.9), (3, 39.9); -- 3. 用 JOIN 完成批量更新 UPDATE t_product p JOIN tmp_product_price t ON p.id = t.id SET p.price = t.price;临时表是会话级的,连接断开后自动删除,用完手写 DROP 也行。这种方式的好处是每一批 INSERT 都不长,UPDATE JOIN 语句本身短小精悍,而且临时表里通常只有当前批次的数据,JOIN 用主键索引很快。数据量大时,这是我最推荐的方式,因为它的每一步都在可控范围内。
4.2 INSERT ON DUPLICATE KEY UPDATE:主键/唯一键下的简洁选择
如果待更新的目标表本身有主键或者唯一键,而且你需要更新的字段不算太复杂,还有另一个思路:把新数据直接 INSERT 进目标表,利用唯一键冲突触发 UPDATE。
INSERT INTO t_product (id, price, status) VALUES (1, 19.9, 2), (2, 29.9, 3), (3, 39.9, 1) AS new ON DUPLICATE KEY UPDATE price = new.price, status = new.status;注意,VALUES()函数在 MySQL 8.0.20 版本开始被标记为废弃,推荐用上面这种AS new别名方式引用新值。如果还在用老写法,升级数据库后日志会刷一堆 deprecation 警告。这个方式有个前提:目标表的其他字段要么有默认值,要么允许 NULL,因为你 INSERT 时没写的列会被填默认值,冲突后 UPDATE 也只会更新 ON DUPLICATE KEY UPDATE 里指定的列,其他列不受影响。如果只想更新部分字段,其他列希望保持原值,需要把原值也写进 INSERT 语句或用更复杂的写法,容易出错,需要谨慎。
4.3 四种方式横向对比
| 方式 | 网络往返 | 单条SQL体积 | 可读性 | 适用量级 | 主要风险 |
|---|---|---|---|---|---|
| 逐条UPDATE | N次 | 小 | 最好 | 百条以内 | 网络耗时高,锁持有时间长 |
| CASE WHEN | 1次 | 大 | 中 | 千条以内,分批 | SQL过长,容易漏WHERE |
| 临时表JOIN | 3~N次 | 小 | 较好 | 万条级 | 步骤多,临时表管理需注意 |
| INSERT ON DUPLICATE KEY | 1次 | 较大 | 较好 | 依赖主键/唯一键 | 未写列会受影响,版本差异需留意 |
选择逻辑我一般这么判断:百条以内逐条加事务就行,别折腾;千条以内 CASE WHEN 最省事;上万条用临时表 JOIN,稳字当头;如果恰好是更新全字段且表有主键,INSERT ON DUPLICATE KEY UPDATE 可以顺手用。
5. 实操复盘与问题排查
5.1 一次线上批量改价的重构过程
有一回做某电商后台的改价功能,运营一次性最多选 500 个商品改价,第一版实现就是最简单的 for 循环逐条 UPDATE。单机测试的时候没有任何异常,但一上预发环境,接口平均响应时间接近 3 秒,运营频频反馈“转圈好久”。
我当时的处理步骤先是在测试环境模拟了 500 条数据的两种方式对比,耗时差距确认后,把代码改造成拼接 CASE WHEN 的写法。为了保证安全,加了三道保险:第一,先用SELECT COUNT(*) FROM t_product WHERE id IN (...)核对本次影响行数;第二,拼完 SQL 后检查 WHERE 条件存在;第三,把 500 条数据拆成 5 批,每批 100 条执行,批间稍微停顿几十毫秒,避免单条 SQL 过长。改造后接口耗时降到了 200 毫秒以内,操作体验明显改善。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| UPDATE 后部分行没变化 | id 类型不一致,比如字符串和整数比较 | SELECT * FROM ... WHERE id = '1'检查 | 拼接时统一转成整型 |
| 提示 packet too large | 单条 SQL 超过 max_allowed_packet | SHOW VARIABLES LIKE 'max_allowed_packet' | 分批执行或调大参数 |
| 锁等待超时 | 事务大、执行时间长 | 查询performance_schema.data_lock_waits | 拆批、缩短事务 |
| 更新后字段变成 NULL | CASE 里缺 ELSE 或 WHEN 没覆盖全 | 对比更新前后数据快照 | 补 ELSE 保留原值或精确 WHERE |
| 8.0 日志警告 VALUES() deprecated | 版本升级后旧写法被弃用 | 查看错误日志 | 改为AS new别名写法 |
| 主从复制延迟变大 | 单事务更新量太大,binlog 集中 | 看从库Seconds_Behind_Master | 分批执行降低单事务体积 |
5.3 生产环境执行的几条建议
无论选哪种方式,生产环境执行批量 UPDATE 前我都建议按下面这个检查清单过一遍:先在测试库把 SQL 的 EXPLAIN 执行计划看一眼,确认用到的是索引而不是全表扫描;然后统计 WHERE 条件的命中行数,和业务预期对比;再确认当前是业务低峰期,避开大促和定时任务高峰;最后加上事务,失败要能回滚。分批更新时,批间可以加一个小的不可见的延迟,比如 100 毫秒,这条对主从延迟特别有效,尤其当数据的单行值很大、binlog 体积不小的时候。
我个人在实际操作中最大的体会是,批量更新没有银弹,核心就是控制三个东西:交互次数、SQL 体积、锁的持有时间。逐条和 CASE WHEN 两种方式代表了两个极端,而临时表 JOIN 是它们之间的平衡点。你只要把这几条原则记在心里,遇到批量更新的需求,基本都能在几分钟内判断出该用哪一种。最后再分享一个小技巧:上线前先在测试库开general_log把应用实际执行的批量 SQL 捞出来人工看一遍,多花五分钟,能省掉半夜被叫起来处理事故的整个晚上。