ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL count(*) 慢查询根因剖析与五种优化方案

MySQL count(*) 慢查询根因剖析与五种优化方案 “SELECT COUNT(*) FROM xxx”这句话我估计每个写过后端的人都被它坑过。数据量小的时候毫秒级返回谁都懒得管等表里攒了几百万、上千万行这条 SQL 就像卡了壳的录音机动不动给你转圈转出十几秒。更气人的是明明建了索引、明明只是数个数凭什么这么慢这篇文章就把 MySQL 里 count() 慢这件事从头到尾拆一遍引擎层为什么慢、各种写法有什么差别、除了慢还能藏哪些“内鬼”锁、宽字段、估算值以及真正能落地的五种优化方案。不管你是刚接触 MySQL 的新人还是正在被慢查询日志折磨的老后端这套思路都能直接用。1. 先把原理说透InnoDB为什么不能像MyISAM那样直接返回行数1.1 MyISAM的行数计数器和它的局限很久以前 MySQL 的默认引擎还是 MyISAM 的时候count() 无脑快因为 MyISAM 把每个表的行数当成一个元数据存起来了。表结构里有一块专门的位置记录“当前总行数”插入一行加一删除一行减一。所以你执行 count() 时它根本不用扫数据直接把存好的数字拿出来给你毫秒级返回。那个年代大家写 SQL 很爽SELECT COUNT(*) FROM t随便用一点心理负担都没有。但这个计数器有个硬伤它只管整张表的行数完全不支持 WHERE 条件。你都写 WHERE 了它就不可能还给你一个存好的数字只能老老实实扫数据判断每一行是否满足条件。更重要的是MyISAM 本身不支持事务它是表级锁写操作会把整张表锁住读和写互相阻塞这就是为什么后来 MySQL 默认引擎换成了 InnoDB然后大家就开始被 count(*) 折磨了。所以不是说“InnoDB 做不到 MyISAM 那样快”而是 InnoDB 为了事务、为了并发主动放弃了“缓存整表行数”这条路。1.2 MVCC才是真正的“内鬼”每个事务看到的行数不一样InnoDB 到底为什么不能缓存一个行数关键在于 MVCC多版本并发控制。每个事务在查询的时候看到的数据版本是自己快照时刻的版本。假设事务 A 在 10:00 开始事务 B 在 10:01 提交了两行数据事务 A 在执行 count(*) 时这两行对 A 是不可见的但对 10:02 开始的事务 C 是可见的。也就是说同一个表同一时刻不同事务看到的行数可能都不一样。这就把“缓存一个总行数”这条路彻底堵死了。如果你在 InnoDB 里存一个“当前行数 500 万”事务 A 说不对我看到的只有 499 万事务 B 说我看的是 501 万。引擎根本没法用一个数字同时满足所有事务的快照语义。所以 InnoDB 只能选择在每次执行 count(*) 时按当前事务的可见性规则逐行判断这一行“算不算数”然后把符合的行数加起来。这也是为什么 count() 在 InnoDB 里天生就是慢操作慢不是 bug是 MVCC 的必然代价。MySQL 官方也明确说过在没有 WHERE 的情况下InnoDB 的 count() 必须扫描索引来统计行数就是这么设计的。你可以把 MyISAM 想象成门口发牌数人数进一个加一个而 InnoDB 就像每个人眼里看到的羊群数量都不同你要统计每个人眼中的数量只能挨个问一遍。2. 不同count写法的性能差异实测数据说话2.1 四种count写法执行逻辑完全不同大家在面试或者刷帖时经常看到这样的说法count(*) 最快count(1) 同样快count(id) 慢count(字段) 最慢。这个结论大体方向对但对的原因很多人说不清。这里我把四种写法的执行逻辑摊开讲。count() 的语义是“统计表中有多少行”它不关心任何列的值也不需要判断 NULL。MySQL 在执行时会把 * 展开成“不用取任何字段”只数行记录。InnoDB 对 count() 做了一个优化优先选择一棵最小的二级索引来扫描因为二级索引只包含索引列和主键比聚簇索引小同样的数据量能少读很多页。如果没有二级索引就只能扫聚簇索引也就是主键索引。count(1) 同样是“每有一行就数一次”1 只是个常量表达式和 count() 在优化器眼里基本等价。在 MySQL 8.0 里 EXPLAIN 这两种写法出来的执行计划几乎一模一样选用的索引也是同一个。所以网上“count(1) 比 count() 快”的说法在 8.0 版本里已经不成立了。count(id) 就有点微妙。如果 id 是主键那它实际上会去扫描聚簇索引。聚簇索引的叶子节点存的是整行数据一个数据页能容纳的行数比小索引少得多同样扫 1000 万行需要读的页数量级完全不同所以 count(主键) 往往明显慢于 count(*)。count(字段) 的语义是“统计这个字段值不为 NULL 的行数”。它必须读这个字段的实际值来判断是不是 NULL。如果字段可空且没有索引它走聚簇索引也要把每条记录翻出来判断如果字段是大字段比如 TEXT/BLOB 或者很长的 VARCHAR开销就更大因为它还得顺着记录指针去读溢出页/大对象数据这种情况下 count(字段) 能比 count(*) 慢好几倍。2.2 一张200万行的表实测各写法的耗时为了让这些差异不是纸上谈兵我造了一张 200 万行的测试表结构大概是主键 iduser_id 上有普通索引amount 是数值remark 是可空 TEXT 字段。在同一台机器上分别执行四种写法结果如下写法执行计划实际耗时约关键说明count(*)走二级索引全扫0.83s引擎优先选了 user_id 那棵最小的索引count(1)同样走二级索引全扫0.85s和 count(*) 几乎无差别count(id)走主键聚簇索引全扫2.1s主键索引页更大IO 量更大count(remark)走主键聚簇索引 读 TEXT6.8s要逐行判断 NULL且 TEXT 存在溢出页这个结果基本符合预期。前两个写法最省count(id) 多读了一遍聚簇索引的大页count(remark) 简直灾难慢了一个量级。需要说明的是耗时会受机器配置、MySQL 版本、缓存命中率影响绝对数字不用纠结重点是相对关系count(字段) 尽可能别选可空大字段count(id) 如果没有对应的小索引也别单独用它来替代 count(*)。2.3 容易踩的坑count(可空字段)为什么更慢实际业务里很多人习惯写 count(user_id)觉得“我只要统计有 user_id 的行数”。如果 user_id 恰好有二级索引其实它走索引扫描也能接受。但如果 user_id 是可以为 NULL 的那 InnoDB 在扫描索引页时还要额外确认索引条目对应的记录里 user_id 是否为 NULL判断路径会变得更复杂。更坑的是如果这个字段根本没有索引count(字段) 就强制走聚簇索引聚簇索引叶子页是大块头可能一个大字段的数据还要额外读取“溢出页”每行多两次随机 IO200 万行就是多几百万次 IO不慢才怪。所以我给自己和团队定的规矩是没有特殊需求一律写 count(*) 或者 count(1)。想表达“数用户数”时优先考虑 count(DISTINCT user_id)但那是另一个复杂度话题而且前提是 user_id 必须有索引不然 DISTINCT 会触发临时排序/哈希开销更爆炸。3. 别忽略的隐藏元凶锁、估算值与索引陷阱3.1 长事务和元数据锁让count直接卡死很多人遇到 count(*) 慢第一反应是“表太大了”然后一头扎进索引优化的大坑。但还有一种非常隐蔽的情况表才几十万行count 却卡了十几秒甚至直接超时。这种时候大概率不是扫数据慢而是被锁堵住了。最常见的是元数据锁。比如有个长事务一直没提交里面曾经执行过 ALTER TABLE、DROP TABLE 或者 CREATE INDEX 之类的 DDL或者某个连接开着事务不关把表的元数据锁拽在手里后续所有对该表的 DDL 都会排队。count(*) 虽然是普通的 SELECT但如果表上有未提交的 DDL 在排队它也可能被元数据锁的队列挡住表现为“卡住不动”。另外还有行锁等待。如果 count 带 WHERE而 WHERE 又命中了一些正在被更新且未提交的记录行InnoDB 会尝试读取这些行的最新可见版本。正常情况下一行冲突也就等一个事务但如果写事务本身很慢、或者更新的行非常多count 就可能被拖住。排查办法很简单执行SHOW FULL PROCESSLIST看看 count 这条 SQL 的状态是什么。如果它停在 waiting for table metadata lock那基本就是元数据锁的锅找到持有锁的连接 kill 掉如果是 waiting for row lock去查 information_schema.innodb_trx、sys.innodb_lock_waits 找到阻塞事务。白白优化索引是治不了锁问题的。3.2 information_schema里的行数只是估算值有个很常见的“歪招”既然 count(*) 慢那我查 information_schema.tables 里的 table_rows 不就行了吗这个字段确实返回得非常快但它是 InnoDB 根据索引采样估算出来的不是真实行数。尤其在频繁插入删除、表碎片较多的场景下误差可以大到离谱。我见过一个生产事故监控系统显示某张表 table_rows 是 820 万实际 count(*) 出来是 1400 万差了近一倍。后来排查发现这表里大量记录被 delete 后又被插入新数据索引页的采样统计早就漂移得不像样了。所以 information_schema 的行数只适合用来做容量规划、大致预估比如“这个表大概千万级”千万别拿来当业务里展示的总条数。如果硬要用它做分页总数用户会看到明显不对的数字产品侧迟早来投诉。真要用估算值也建议配合 EXPLAIN 的 rows 交叉验证至少做到量级准确。3.3 覆盖索引被“宽字段”拖垮的情况前面提到过count(*) 默认会挑最小的二级索引扫。但“最小”不代表“小”如果这张表唯一的二级索引是个 varchar(255) 的字段它的索引页能容纳的记录条数就很有限。同样 1000 万行窄索引可能 3 万页扫完宽索引可能要 8 万页IO 开销差得不是一点半点。还有一种情况是联合索引的第一列区分度极低比如 (status, created_at)而 status 只有几个固定值。优化器如果选了这棵索引会扫出大量的无效页效率也不理想。这块的实践结论是如果 count(*) 是高频操作可以考虑专门加一个只包含自增主键的窄索引或者用一个 id 类型的短字段索引把扫描页数压到最低。但索引不是白加的插入会变慢所以要权衡。一般来说给 count 专门造索引这种操作只适合确实需要高频精确计数的表否则性价比不高。4. 大表count慢五种真正有效的优化方案4.1 允许误差时用估算值代替精确count第一种方案其实是“妥协方案”但却是实际项目里覆盖场景最多的方案。很多业务对总数并不要求绝对精确。比如列表页底部的“共 10000 条”、管理后台的走势图、运营报表的大盘数字差个几百几千完全不影响决策。实现上有三个选择EXPLAIN SELECT COUNT(*) FROM t之后的 rows 字段、SHOW TABLE STATUS里的 Rows 字段、information_schema.tables.table_rows。这三个都是快速接口秒回。配合 Redis 做一层短缓存甚至不用每次查 information_schema把估算结果缓存 5 分钟就能扛住高并发。但务必记住这是估算值必须接受误差并且最好在业务文案上体现出来比如“约 820 万条”。如果业务方接受不了“约”那这个方案就不适用只能往下看。4.2 精确计数方案Redis计数器怎么维护才不出错如果业务要求“分页总数必须绝对准确”那就得用计数器方案。最常见的做法是在 Redis 里维护一个总数的 key业务每次 insert 就 INCR每次 delete 就 DECR逻辑不复杂。难点在于怎么保证 Redis 和 MySQL 的一致性以及怎么防止计数器在极端情况下漂移。我的经验是不要先更新 Redis 再更新 MySQL那样 MySQL 失败了 Redis 就错了也不要依赖应用层代码到处打 INCR/DECR容易漏一条就永久错下去。更稳的做法是用 binlog 监听把 MySQL 的写入事件同步出来异步更新 Redis。或者把计数更新包在 MySQL 同一事务里通过事务消息/本地消息表发出去保证最终一致。还有一个小技巧加一个兜底对账任务每天把 Redis 里的计数和 count(*)可以在凌晨低峰期执行一次真实 count对一次不对就补偿。这样就算偶尔漏了也能自愈。4.3 报表场景汇总计数表的建模思路Redis 方案适合“总行数”这种单一维度但如果业务需要各种维度组合的 count比如“某一天注册的用户数”“某状态下订单数”缓存方案就不好维护了。这时候比较推荐建一张汇总计数表。具体做法是单独建一张 summary_count 表字段大概长这样维度 key、统计日期、计数值。业务在同一个事务里更新业务表和汇总计数表插入数据时给对应的维度 key 加一删除时减一。因为两个更新在同一个事务里不会出现 MySQL 和缓存不一致的问题。这个方案的优点是完全准确、事务可控缺点是只适合维度和条件相对固定的场景。如果用户随时能组合出新的查询条件你不可能为每一种组合都维护一个计数器。所以它更适合报表系统比如每天跑一个定时任务把前一天的汇总结果写进去前端直接查汇总表毫秒级响应不再碰原表。4.4 海量数据兜底把count查询分流到异构存储当单表真的到了几千万、上亿行按天归档还是一路狂涨时MySQL 里做精确 count 的成本就太高了。这种时候更合理的思路是“不要把统计压力全压在 MySQL 身上”。很多团队会把 MySQL 的数据通过 Flink CDC 或者 Canal 同步到 ClickHouse、Elasticsearch、TiDB 这类适合海量分析/检索的存储上。运营后台的复杂统计、明细搜索、跨表聚合全部走 ClickHousecount 在那里是毫秒级返回MySQL 只负责在线交易类的读写。这个方案实施成本不低至少需要一套同步链路和数据一致性保障但是从长期看只要数据量大到一定程度这一步迟早要迈出去。如果还在犹豫可以先用归档方案把历史数据从 MySQL 挪到冷存储把热表控制在千万行以内普通 count 可能就够用了。4.5 被低估的覆盖索引让count走最小的那棵索引树最后说一个所有方案里最“便宜”的优化手段给高频 count 的查询条件建覆盖索引让查询只扫描索引页不碰聚簇索引。举个例子如果你的业务经常要执行SELECT COUNT(*) FROM order_info WHERE status 1 AND created_at 2024-01-01那可以建一个 (status, created_at) 的联合索引。这样 count 就能直接在这个二级索引上做范围扫描索引页比聚簇索引小很多速度提升通常非常明显。实测中一个 1200 万行的表没索引时全表扫描 10 秒建了 (login_time) 索引后范围扫描可能只需要 2 秒左右如果再把索引做得更窄比如只包含必要的过滤列还能更快。当然覆盖索引不是万能药。如果业务要 count 的字段在索引之外它还是得回表取数据那就达不到“覆盖”的目的。所以准确的说法是select 列表里需要的列全部来自索引列才能实现覆盖索引扫描。在 count 场景下count(*) 本身不需要取任何列只要 WHERE 能走索引它天然就是“覆盖”的。这个点很多人容易忽略值得好好说。5. 一次真实生产环境的count慢排查实录5.1 现场情况与慢SQL确认我之前接手过一个用户登录日志的项目表名 user_login_log主键 id登录来源 source备注字段 remark 是 TEXT没建登录时间索引。这张表经过一年半积累已经到了 1200 多万行。某次版本上线后运营后台加了一个“统计 2024 年以来登录的用户总量”的看板对应的 SQL 是SELECT COUNT(*) FROM user_login_log WHERE login_time 2024-01-01;上线当天没发现异常第二天这个接口的平均响应时间直接飙到 12 秒慢查询日志里全是它。我当时的第一反应不是改代码而是先把这个 SQL 单独拿出来 EXPLAIN 了一下。EXPLAIN 的输出显示 typeALLpossible_keys 为空rows 显示 1200 多万Extra 是 Using where。也就是说这是赤裸裸的全表扫描InnoDB 把聚簇索引从头到尾过了一遍每一行都拿 login_time 和 2024-01-01 比较一遍。索引都懒得用因为 login_time 连索引都没有。5.2 从执行计划到锁等待的排查路径找到直接原因后我做了三件事。第一给 login_time 建普通索引ALTER TABLE user_login_log ADD INDEX idx_login_time (login_time);。这一步在 1200 万行上执行会锁表一会儿我选了半夜的低峰期操作。索引建好后再次 EXPLAINtype 变成了 rangekey 是 idx_login_timerows 大概是 320 万Extra 从 Using where 变成了 Using index condition。执行时间从 12 秒降到 2.3 秒。看起来进步很大但运营那边还是觉得慢2 秒多对看板接口来说不可接受。第二我顺手检查了有没有锁等待问题。SHOW PROCESSLIST 里没有发现 metadata lock 或者长事务information_schema.innodb_trx 也正常。说明这个 SQL 就是实打实的扫描慢不是被堵了。第三我试着把 count(*) 换成 count(login_time)发现反而更慢因为这里其实已经走索引扫描了主要瓶颈在于范围内的 320 万行索引页依然要扫完加上同期服务还有其他查询在抢磁盘 IO2.3 秒并不算太夸张。5.3 方案落地与效果对比考虑到这个看板其实根本不需要实时精确值我最后选择了“缓存 定期重建”的组合方案。具体做法是每天凌晨用一个定时任务执行一次真实 count结果写入 Rediskey 带日期当天的所有看板请求直接读 Redis夜间任务失败时用前一天的缓存兜底并报警。上线后接口响应稳定在 4 毫秒以内服务器压力也没了。同时我把登录日志的“按天汇总表”提上日程后续运营想按天、按来源看登录用户数都从汇总表取数不再碰原表。这个案例想表达的核心是不要被“优化到 2 秒”迷惑先问需求方到底能不能接受误差能接受误差用缓存和估算就是最合适的解法不能接受再考虑汇总表或者异构存储。6. count慢问题速查与日常防坑建议6.1 高频问题速查表整理了我在排查过程中最常见的几种情况直接给结论症状最可能的原因怎么查怎么解全表 count 无 where 极慢表太大或者只有一个很宽的聚簇索引EXPLAIN 看 rows 和 key覆盖索引、缓存、估算值count 带 where 也极慢where 条件无索引或索引选择差EXPLAIN 看 type 是否 ALL、看 key给条件列建联合索引只有几十万行却卡住元数据锁 / 行锁等待SHOW PROCESSLIST、sys.innodb_lock_waits找到阻塞事务 kill优化长事务count(可空大字段) 特别慢逐行判 NULL 读大对象页/溢出页EXPLAIN 看是否 Using where改用 count(*)或给字段加索引count 结果反复横跳MVCC 不同事务快照可见行数不同确认事务隔离级别和并发写业务正常现象不必处理information_schema 行数与实际对不上采样估算误差手动 count 对比只用于量级预估别当精确值这张表基本覆盖了我踩过的绝大多数坑建议收藏。6.2 日常编码时养成的几个好习惯最后说几个我在团队里一直强调的习惯都是低成本高回报的做法。第一先跟产品对齐“要不要精确总数”。大部分列表页做成“加载更多”或者滚动加载根本不需要总条数就算要展示“约 XX 条”也完全够用。把产品需求摸清楚了很多 count 优化根本不用做。第二写 SQL 时默认用 count(*)不要随手写 count(某个字段)。前者语义清晰引擎会挑最优索引后者容易踩 NULL 判断和宽字段的坑。代码评审时看到 count(大字段) 我通常都会要求改掉。第三所有统计分析类接口线上必须做缓存兜底不管是 Redis 也好、进程内缓存也好。统计查询本来就是低频且可以容忍延迟的没必要每次都打到 MySQL 上。第四监控慢查询日志和锁等待把“count 类 SQL 执行时间超过 1 秒”专门做成告警早发现早处理别等用户投诉。我个人在实际项目里还有一个习惯每次看到 count(*) 慢先别急着改 SQL先打开慢查询日志把上下文看一遍。很多时候它只是在某个时间段被锁堵了几秒或者恰好赶上大批量写入把问题定位准确再决定是建索引、走缓存还是做汇总表才不会白忙活。说句扎心的大多数 count 慢其实是业务设计问题不是 MySQL 的问题——把精确计数的执念放下很多方案自然就清晰了。
返回列表