MySQL ROW_COUNT与FOUND_ROWS语义差异及分页弃用排查
上周有位同行在群里甩了张截图过来一条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) ENGINEInnoDB; 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_ROWS2.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。假设表里有一行id1, v10跑这条语句UPDATE rc_demo SET v 10 WHERE id 1;WHERE 条件匹配到了 1 行但因为新值等于旧值InnoDB 判断这一行无需修改直接跳过写入。这时候如果连接没有协商CLIENT_FOUND_ROWS标志ROW_COUNT()返回0changed rows 口径只数真正产生变更的行如果连接协商了CLIENT_FOUND_ROWSROW_COUNT()返回1matched 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_ROWSJDBC (Connector/J)executeUpdate()返回值matched连接串加useAffectedRowstruePDO_MySQL$stmt-rowCount()matched构造时设PDO::MYSQL_ATTR_FOUND_ROWS falseGo database/sqlResult.RowsAffected()取决于驱动 DSNDSN 里加clientFoundRowstruePython PyMySQLcursor.rowcountchanged连接参数client_flagCLIENT.FOUND_ROWS这张表建议直接存进笔记里。跨语言团队协作时同一套接口在 Node 侧调、在 Java 侧调拿到的行数不一样排查成本极高。2.3 其他语句类型的返回值顺便一次记全把边角情况一次补齐省得日后一个个试SELECT返回 -1。结果集行数请用客户端 API 的num_rows()、len(rows)这类属性去拿。SET / DO / USE / BEGIN / COMMIT返回 0。DDLCREATE / 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上有普通索引查询条件是status1命中约 42 万行按id倒序分页取第 1 页 20 条写法平均耗时说明SELECT SQL_CALC_FOUND_ROWS ... LIMIT 20约 380 ms必须扫完所有匹配行SELECT ... LIMIT 20单独执行约 3 ms走排序索引 提前终止SELECT COUNT(*) ... WHERE status1约 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?useAffectedRowsfalse; int r1 stmt.executeUpdate(UPDATE rc_demo SET v 10 WHERE id 1); // 值没变化时 r1 1 // 切换口径返回 changed rows String url2 jdbc:mysql://localhost:3306/db?useAffectedRowstrue; 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_ROWSPDO_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 口径连接串加useAffectedRowstrue分页总数和实际条数对不上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 这些细节。花五分钟把实验跑一遍比在文档里翻半小时更牢靠而且实验结论会牢牢钉在脑子里——下次再遇到第一反应就是那三条数据的查询结果而不是去搜索引擎。