☰
MySQL ROW_COUNT与FOUND_ROWS语义差异及分页弃用排查
2026/11/3 21:06:18 网站建设 项目流程

上周有位同行在群里甩了张截图过来,一条UPDATE语句明明把三行数据改成了想要的样子,可 Java 代码里拿到的返回值是 0,后面那段“更新成功后清缓存”的逻辑死活不触发。他把 SQL 打印出来看了八遍,条件没错,字段没错,行也确实变了。我回了他一句“去看下你连接串里有没有开 useAffectedRows”,三分钟后他回了个“服了”。这事儿说白了就是ROW_COUNT()和FOUND_ROWS()这两个东西的口径问题——MySQL 里跟“数行数”沾边的函数看着都不复杂,真正动手用起来,坑全藏在语义边界上。这篇我把这两个函数掰开讲清楚:它们各自数的是什么、值什么时候会被重置、不同驱动默认走哪套口径、分页场景里那套老写法为什么被官方标记成弃用,以及我自己在排查这类问题时固定会走的几条路径。不管你是刚写完第一个 CRUD 接口的新手,还是已经维护了几年线上库的老手,读完至少能少加两次班。

1. 先把语义边界钉死:这两个函数数的根本不是一回事

1.1 一个数“改了没”,一个数“有多少”

ROW_COUNT()和FOUND_ROWS()经常被放在一起提,但它们的职责其实完全不重叠。ROW_COUNT()回答的问题非常朴素:上一条语句让多少行发生了“影响”。这里的“影响”是个动词,覆盖 INSERT、UPDATE、DELETE、REPLACE、LOAD DATA 这些写操作。而FOUND_ROWS()回答的是另一个问题:上一条 SELECT 在不加 LIMIT 的情况下总共能匹配到多少行,它天生是为分页服务的,并且必须配合SQL_CALC_FOUND_ROWS这个修饰符才有完整意义。

拿生活里的场景类比一下。ROW_COUNT()像超市收银台的扫码枪,它只报告这一次结算扫了几件商品;FOUND_ROWS()像仓库管理员告诉你“店里这个 SKU 总共有多少库存”,跟你这次到底买了几件毫无关系。两个人站在同一个货架前,报出来的数字完全不同,因为统计的目标压根不是同一件事。

很多人第一次踩坑,就是把ROW_COUNT()当成“查询结果集行数”来用,写了SELECT * FROM t; SELECT ROW_COUNT();,然后发现拿到 -1,一脸茫然。MySQL 官方文档写得很清楚,对 SELECT 语句,ROW_COUNT()返回 -1。这个 -1 不是错误码,也不是没查到数据的意思,它就是一个占位符,表示“这条语句不在我的统计范围内”。结果集有多少行,那是结果集自己的属性,MySQL 走的是协议层的 row count 字段,跟服务端的ROW_COUNT()函数是两条线。

1.2 ROW_COUNT() 是会话级变量,不是全局状态

这是个容易被忽略但极其关键的点:ROW_COUNT()的值是当前连接(会话)级别的,而且每次执行新语句都会被覆盖。它不是“上一条写语句”的影响行数,而是“上一条语句”的影响行数,中间夹一条 SELECT、一条 SET、甚至一条SELECT 1心跳,前面攒下来的值就没了。

做过一个实验,在同一个 mysql 会话里跑:

CREATE TABLE rc_demo (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; INSERT INTO rc_demo VALUES (1, 10), (2, 20), (3, 30); SELECT ROW_COUNT(); -- 返回 3,插入三行 SET @x = 1; SELECT ROW_COUNT(); -- 返回 0,被 SET 覆盖了 UPDATE rc_demo SET v = 100 WHERE id <= 2; SELECT ROW_COUNT(); -- 返回 2 SELECT 1; SELECT ROW_COUNT(); -- 返回 -1,被 SELECT 覆盖

这个特性在存储过程里尤其要命。存储过程内部每条语句执行完都会刷新ROW_COUNT(),如果你在 UPDATE 之后还夹了一句写日志表、或者一段SELECT ... INTO取个参数,再回头取ROW_COUNT(),拿到的就是日志表或者那次 SELECT 的影响数,逻辑上完全错位。正确姿势是紧贴着目标语句取,取完立刻存进局部变量,不要在中间插任何别的 SQL。

1.3 客户端库的 mysql_affected_rows 是不是同一个东西

在 C API 里有个mysql_affected_rows(),Java 的executeUpdate()、PHP 的PDOStatement::rowCount()、Go 的Result.RowsAffected(),这些驱动层 API 的返回值,本质上都是服务端ROW_COUNT()的映射。但它们中间隔了一层协议,而这层协议里藏着一个能力标志CLIENT_FOUND_ROWS,它会把统计口径从“真正被改动的行”切换成“被 WHERE 条件匹配到的行”。这一个标志,就是开头那位同行遇到问题的根源。

同一个含义,在不同技术栈里有不同的默认值,这在我见过的线上事故里占了不小比例。写一行代码之前先搞清楚你手上的驱动默认走哪边,比事后对着 SQL 反复怀疑人生有价值得多。

2. ROW_COUNT() 的三种口径,以及那个能把你骗到的 CLIENT_FOUND_ROWS

2.1 INSERT、DELETE、REPLACE 的返回值规则

先把最没有歧义的部分说完。INSERT 的返回值就是实际插入的行数,多值插入写了几行就返回几;INSERT IGNORE里被主键冲突忽略掉的行不计入,返回的是成功落盘的行数。DELETE 返回的是实际删除的行数,DELETE没命中任何行就是 0。TRUNCATE 走的是 DDL 路线,返回 0。

REPLACE 稍微特殊一点,它的返回值等于“删除的行数 + 插入的行数”。如果一条 REPLACE 命中了已有主键并完成替换,返回 2;如果没有命中,直接插入,返回 1。批量 REPLACE 时,这个数字会在两者之间浮动,很多人拿它当“插入了多少条”来用,结果统计报表和实际条数永远对不上。

INSERT ... ON DUPLICATE KEY UPDATE是另一个高频误区聚集地。它的规则是:全新插入返回 1,命中冲突并实际更新返回 2,命中冲突但新值和旧值完全一样、什么也没改则返回 0。如果你在代码里写if (affected == 1) insertCount++ else updateCount++,那当用户重复提交一条完全相同的数据时,两个计数器都不会动,统计就漏了一笔。要么显式用两个不同字段去判定,要么干脆接受“0 表示无变化”这个语义并在业务层单独处理。

2.2 UPDATE 的 matched 与 changed:所有坑的总源头

真正的重灾区在 UPDATE。假设表里有一行id=1, v=10,跑这条语句:

UPDATE rc_demo SET v = 10 WHERE id = 1;

WHERE 条件匹配到了 1 行,但因为新值等于旧值,InnoDB 判断这一行无需修改,直接跳过写入。这时候:

  • 如果连接没有协商CLIENT_FOUND_ROWS标志,ROW_COUNT()返回0(changed rows 口径,只数真正产生变更的行);
  • 如果连接协商了CLIENT_FOUND_ROWS,ROW_COUNT()返回1(matched rows 口径,数被条件命中的行)。

同一句 SQL、同一个数据库、同一份数据,两个客户端拿到的结果不一样。这就是为什么“我本地测是 1,上生产是 0”这种玄学现象会反复出现——本地用的是 CLI 或者某个默认不开标志的客户端,生产用的是开了标志的驱动。

提示:想快速确认当前连接走的是哪套口径,跑一条必然无变化的 UPDATE 再看ROW_COUNT()就行。返回 1 是 matched 口径,返回 0 是 changed 口径。这个判断方法比翻文档快得多。

值得注意的是,MySQL 命令行客户端默认不开启这个标志,所以你在终端里做实验,看到的永远是 changed 口径的结果。这也导致大量“文档上明明说要返回 1”的困惑。驱动层面的默认值才是你代码实际使用的口径,具体数值我列在下面这张表里:

技术栈获取方式默认口径切换到另一套口径的方式
MySQL CLISELECT ROW_COUNT()changed通过--found-rows类客户端选项协商
C APImysql_affected_rows()changedmysql_real_connect时传CLIENT_FOUND_ROWS
JDBC (Connector/J)executeUpdate()返回值matched连接串加useAffectedRows=true
PDO_MySQL$stmt->rowCount()matched构造时设PDO::MYSQL_ATTR_FOUND_ROWS => false
Go database/sqlResult.RowsAffected()取决于驱动 DSNDSN 里加clientFoundRows=true
Python PyMySQLcursor.rowcountchanged连接参数client_flag=CLIENT.FOUND_ROWS

这张表建议直接存进笔记里。跨语言团队协作时,同一套接口在 Node 侧调、在 Java 侧调,拿到的行数不一样,排查成本极高。

2.3 其他语句类型的返回值,顺便一次记全

把边角情况一次补齐,省得日后一个个试:

  • SELECT:返回 -1。结果集行数请用客户端 API 的num_rows()、len(rows)这类属性去拿。
  • SET / DO / USE / BEGIN / COMMIT:返回 0。
  • DDL(CREATE / ALTER / DROP / TRUNCATE):返回 0。别指望用ROW_COUNT()判断 DDL 影响了多少数据。
  • LOAD DATA:返回成功导入的行数,被忽略的重复行不计入。
  • CALL 存储过程:返回存储过程最后一条语句的影响行数。如果过程里最后一步是个 SELECT,你拿到 -1。
  • 触发器内部:触发器里执行的语句同样会刷新ROW_COUNT(),这意味着在外层语句结束后取ROW_COUNT(),拿到的可能是触发器里最后一条语句的值而不是外层语句的。这个坑很隐蔽,涉及触发器的场景,别太依赖ROW_COUNT()。

把上面这些行为串起来看,你会发现ROW_COUNT()的设计哲学非常一致:它只关心“这条语句对数据造成了多少实际变动”,是一个纯粹的写操作反馈机制,任何试图让它兼差做查询统计的用法,本质上都是在跟设计意图对抗。

3. FOUND_ROWS() 与 SQL_CALC_FOUND_ROWS:一个已经被官方判死缓的分页老套路

3.1 它当年是为了解决什么问题

回到十几年前那个分页还要写两条 SQL 的年代。一个典型的分页接口需要:第一,返回当前页的数据;第二,返回满足条件的总条数,让前端渲染页码。最直觉的写法是两条独立查询,一条SELECT * ... LIMIT 20 OFFSET 40,一条SELECT COUNT(*) FROM ... WHERE ...。两条 SQL 意味着两次解析、两次优化、两次执行,如果 WHERE 条件复杂、表还不小,这个开销是实打实的。

SQL_CALC_FOUND_ROWS就是为了省掉第二次执行而设计的:

SELECT SQL_CALC_FOUND_ROWS id, title FROM article WHERE status = 1 ORDER BY id DESC LIMIT 20 OFFSET 40; SELECT FOUND_ROWS();

第一条语句在返回 20 行数据的同时,顺手把“去掉 LIMIT 之后总共多少行”这个数字记在了会话里;第二条语句把它读出来。一条 SQL 干两件事,当年看起来相当优雅,几乎所有 PHP 的分页类、Java 的分页插件都实现过这套逻辑。

3.2 这个“优雅”在优化器面前有多贵

优雅只是表象。SQL_CALC_FOUND_ROWS强制服务器在执行时保留一份完整的、未经 LIMIT 裁剪的结果集信息,这意味着优化器失去了大量可以偷懒的空间。最典型的一点:当 LIMIT 很小、排序字段上又有索引时,优化器本来可以走“边扫索引边输出,凑够 20 行就停”的短路策略;一旦加上SQL_CALC_FOUND_ROWS,它必须把所有匹配行都算出来,短路策略直接失效。

我做过一组对比,一张约 180 万行的订单表,status上有普通索引,查询条件是status=1,命中约 42 万行,按id倒序分页取第 1 页 20 条:

写法平均耗时说明
SELECT SQL_CALC_FOUND_ROWS ... LIMIT 20约 380 ms必须扫完所有匹配行
SELECT ... LIMIT 20单独执行约 3 ms走排序索引 + 提前终止
SELECT COUNT(*) ... WHERE status=1约 120 ms覆盖索引全扫
两次分别执行合计约 123 ms数据查询几乎可以忽略

结论非常直接:拆成两条独立语句,总耗时只有合并写法的大约三分之一。而且这还是数据全部命中同一个二级索引、缓存预热过的理想情况。要是 WHERE 条件更复杂、需要回表,差距会进一步拉大。

3.3 一致性上它也站不住脚

比性能更麻烦的是正确性。SQL_CALC_FOUND_ROWS和SELECT FOUND_ROWS()是两条独立的语句,它们各自有各自的一致性读视图。在可重复读隔离级别下,同一条连接同一个事务里的两次读确实能看到同一个快照,但绝大多数分页查询根本不在显式事务里,两条语句之间隔着一个网络往返,中间完全可能有新数据插入或删除。

结果就是:列表页显示“共 1000 条”,翻到第 50 页发现是空的;或者总数是 1000,用户翻到第 51 页还能看到数据。这种偶发问题极难复现,用户投诉过来,你连日志都抓不到。

也正是基于这两点,从 MySQL 8.0.17 开始,SQL_CALC_FOUND_ROWS和一个相关的FOUND_ROWS()语义被正式标记为弃用(deprecated),官方给出的替代建议就是老老实实写COUNT(*),或者用别的方式优化排序分页。8.0 后续版本里,FOUND_ROWS()的返回值在缺少SQL_CALC_FOUND_ROWS时的行为也被明确为不可依赖。新项目里别再用了,老项目迁移时顺手改掉。

3.4 现在分页该怎么写:三种方案按场景选

第一种,也是最稳妥的:两条独立语句。

SELECT COUNT(*) FROM article WHERE status = 1; SELECT id, title FROM article WHERE status = 1 ORDER BY id DESC LIMIT 20 OFFSET 40;

数据量在百万级以内、条件字段上有合适索引时,COUNT(*)走覆盖索引扫描并不慢,两条加起来通常也就在几十毫秒量级。再配合一层短 TTL 的缓存(比如总数缓存 30 秒),实际压力可以压得很低。别被“COUNT 慢”这句话吓住,慢的是没索引的全表扫描,不是 COUNT 本身。

第二种,窗口函数一次搞定(MySQL 8.0 起):

SELECT id, title, COUNT(*) OVER () AS total FROM article WHERE status = 1 ORDER BY id DESC LIMIT 20 OFFSET 40;

好处是只发一次请求、天然一致。代价是OVER()里的聚合在 LIMIT 之前就完成了,等于又回到了“必须算完所有匹配行”的路子上,性能接近SQL_CALC_FOUND_ROWS。适合数据量不大、但一致性要求高的后台管理页面。

第三种,游标分页(keyset pagination),这是深分页的唯一正解:

SELECT id, title FROM article WHERE status = 1 AND id < 100086 ORDER BY id DESC LIMIT 20;

把 OFFSET 换成“上一页最后一行的 id”,每次查询都从索引的一个确定位置开始扫 20 行就停。翻到第 10000 页,耗时和第 1 页几乎一样。代价是没有随机跳页能力,只支持“下一页/上一页”。内容流、消息列表、订单流水这类场景完全够用,后台那种要直接跳到第 87 页的管理界面才需要 OFFSET。

选型上没有银弹,我给自己的判断标准是:能接受“没有跳页按钮”就一律用游标分页,接受不了就用 COUNT + LIMIT,窗口函数只在数据量明确可控时使用。

4. 驱动与框架里的对应关系:同一个数字,三种语义

4.1 Java 侧:executeUpdate 到底返回什么

Connector/J 的默认行为经常让从其他数据库转过来的同学感到困惑。在标准 JDBC 语义里,executeUpdate()返回“被更新的行数”,但 MySQL 驱动默认协商了CLIENT_FOUND_ROWS,所以它返回的是被 WHERE 匹配的行数,即使这些行的值根本没变。

// 默认行为:返回 matched rows String url = "jdbc:mysql://localhost:3306/db?useAffectedRows=false"; int r1 = stmt.executeUpdate("UPDATE rc_demo SET v = 10 WHERE id = 1"); // 值没变化时 r1 = 1 // 切换口径:返回 changed rows String url2 = "jdbc:mysql://localhost:3306/db?useAffectedRows=true"; int r2 = stmt.executeUpdate("UPDATE rc_demo SET v = 10 WHERE id = 1"); // 值没变化时 r2 = 0

这直接影响乐观锁的实现。很多团队是这么写更新逻辑的:

UPDATE orders SET status = 2, version = version + 1 WHERE id = 1001 AND version = 7;

然后判断返回值是否大于 0 来决定“更新成功”还是“版本冲突”。如果用默认的 matched 口径,这条语句只要 id 和 version 都匹配上,哪怕 status 本来就是 2、version + 1 之后确实变了,返回也是 1——这里没问题。但如果有人手滑把 version 自增漏掉,语句变成SET status = 2 WHERE id = 1001 AND version = 7,用 matched 口径就会返回 1,你会误以为一切正常,实际上什么也没变。乐观锁的更新语句必须实际修改至少一个字段的值(通常就是 version 自增),这样两套口径的返回值才一致。

4.2 PHP 侧:PDO 默认开了 FOUND_ROWS

PDO_MySQL 构造时会默认设置MYSQL_ATTR_FOUND_ROWS = true,所以rowCount()返回的是 matched 行数。想改成真正改动行数:

$pdo = new PDO($dsn, $user, $pass, [ PDO::MYSQL_ATTR_FOUND_ROWS => false, ]); $stmt = $pdo->prepare('UPDATE rc_demo SET v = 10 WHERE id = 1'); $stmt->execute(); echo $stmt->rowCount(); // 0

这就解释了大量 PHP 项目中同类代码在测试环境表现正常、在某个服务上突然“更新失败”的现象——不同环境的 DSN 或者框架配置不一致。上线前把这一项写进配置基线,比后期排查省事得多。

4.3 连接池和中间件:最常见的 ROW_COUNT 被覆盖原因

还有一个场景,不管什么语言都躲不过:连接池在你还连接和业务 SQL 之间插了别的语句。

典型链路是这样的:业务代码从连接池拿连接,框架先跑一次SET NAMES utf8mb4或者SELECT @@session.tx_read_only做状态校验,然后再执行你的 UPDATE。你的 UPDATE 执行完之后,如果框架在rowCount()之前又补了一次健康检查查询,那这个值就彻底报废了。

更隐蔽的是某些 ORM 的行为:执行完UPDATE后自动追加一条SELECT去回填对象属性,或者触发审计日志写库。这些操作全部会刷新会话级的ROW_COUNT()。

注意:如果怀疑是连接池或框架插入语句导致的,把 MySQL 的通用日志(general log)临时打开,抓一段完整会话,按顺序看每条 SQL,能立刻定位是谁偷偷插了队。这个手段在排查这类问题时几乎百发百中,代价是日志量大,记得排查完关掉。

4.4 事务里的一处细节

ROW_COUNT()是会话变量,不受事务提交或回滚影响。你执行一条 UPDATE 拿到 2,然后ROLLBACK,再执行SELECT ROW_COUNT(),得到的还是 2(前提是中间没插别的语句)。它记录的是“这条语句干了多少事”,不是“最终落盘了多少”。

这个特性在批量处理里可以拿来做进度判断——比如分批更新 5000 条,每批结束取一次ROW_COUNT(),累加得到总处理量。但要注意中间别插SELECT,也别用会触发触发器的表,不然累加出来的数字会漂。

5. 排查手册:这十来个问题我基本都踩过

5.1 高频问题速查表

现象大概率原因处理办法
UPDATE 明明改了数据,返回值是 0新值等于旧值,走 changed 口径确认驱动口径,或让 UPDATE 一定修改某个字段
本地返回 1,线上返回 0两边驱动/连接串配置不同统一连接串参数,写进配置基线
SELECT ROW_COUNT()返回 -1上一条是 SELECT,不在统计范围用结果集行数,不要用 ROW_COUNT
存储过程里取到的值不对中间插了别的语句,值被覆盖紧贴目标语句取,存进局部变量
FOUND_ROWS()返回 0 或不准前面没有SQL_CALC_FOUND_ROWS,或版本已弃用改用COUNT(*)
批量 ON DUPLICATE KEY 统计总和偏小值完全相同时返回 0单独处理 0 的情况,或加 time 字段保证有变化
事务回滚后 ROW_COUNT 还是原值它是会话变量,不受事务影响不要用它判断提交结果
REPLACE 返回 2删除 + 插入各计一次用 ON DUPLICATE KEY UPDATE 代替
JDBC 拿到的受影响行数比实际改的多默认 matched 口径连接串加useAffectedRows=true
分页总数和实际条数对不上SQL_CALC_FOUND_ROWS 两次读的时间差改两条语句或游标分页

5.2 我固定会走的排查路径

碰到“行数不对”的反馈,我的顺序是固定的,一般三步之内能定位。

第一步,先确认口径。在同一个环境用同一个客户端执行一条“必然无变化”的 UPDATE,看ROW_COUNT()是 0 还是 1,立刻就知道当前连接走的是 changed 还是 matched。这一步把问题范围砍掉一半。

第二步,抓完整会话。临时开 general log,把业务操作完整跑一遍,把日志里从取连接到取 rowCount 之间的所有语句按顺序列出来。十次里有七八次能直接看到那条偷偷插进来的SELECT或者状态检查语句。

第三步,查驱动配置。前两步都没问题,基本就是连接串参数或者框架层的默认值不统一。这时候别猜,直接把开发、测试、预发、生产四套配置的 DSN 拉出来逐项对比,差异往往一眼可见。

三步走完还定位不到的,我一般会怀疑是不是表上有触发器,或者在 MyISAM 表上做了操作。触发器的问题前面提过了,MyISAM 又不一样:它不支持事务,UPDATE的 affected rows 计算方式在部分场景下跟 InnoDB 有差异,特别是涉及重复键或者并发写的时候。线上库现在应该没人用 MyISAM 了,但老系统迁移中撞上这种情况完全有可能。

5.3 几条能直接抄的习惯

我把这些年攒下来的做法归了几条,团队里新人入门基本都按这个来:

永远不要在 SQL 层做“改没改”的判断,判断放在应用层。需要区分“匹配到了但值没变”和“压根没匹配到”这两种情况时,最可靠的做法是先SELECT一把把当前状态取出来,在应用层比对,再决定是发 UPDATE 还是走别的分支。多一次查询,换来的逻辑确定性,比省下的那 1 毫秒值钱得多。

UPDATE 一定要带一个“必然产生变化”的字段。比如updated_at = NOW(),或者秒级时间戳、自增版本号。这样无论驱动走哪套口径,返回值都能稳定反映“这条语句确实匹配并且执行了”。这个小习惯在我这里属于强制规范,因为它顺手把口径问题也给屏蔽掉了。

别把ROW_COUNT()当业务统计接口。它是给应用判断语句执行效果的,不是给报表用的。报表要统计“今天新增多少、更新多少”,老老实实写审计表或者读 binlog,靠ROW_COUNT()累加迟早出岔子。

新代码里禁用SQL_CALC_FOUND_ROWS。这条写进 code review 检查项。老代码迁移时看到就顺手改,改成 COUNT + LIMIT,改动量通常也就是几行。

连接串参数纳入配置管理。useAffectedRows、MYSQL_ATTR_FOUND_ROWS、clientFoundRows这些参数不能散落在各处,统一放在一套配置模板里,环境之间只切值不改结构。这样“本地是 1 线上是 0”这类问题从源头上就消失了。

顺带提一个我个人的小偏好:调试这类问题时,比起翻文档,我更愿意直接建一张三行数据的小表做对照实验。ROW_COUNT()的所有行为都能用那张表验证出来,包括 DDL 返回 0、SELECT 返回 -1、REPLACE 返回 2、ON DUPLICATE KEY UPDATE 返回 1/2/0 这些细节。花五分钟把实验跑一遍,比在文档里翻半小时更牢靠,而且实验结论会牢牢钉在脑子里——下次再遇到,第一反应就是那三条数据的查询结果,而不是去搜索引擎。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询