最慢?InnoDB下真正慢的是count(字段)与缺失索引)
“count() 性能最差所以要用 count(1) 替代”、“count(主键) 也比 count() 快” —— 类似的说法我实在见过太多次甚至不少公司的代码规范里还白纸黑字写着这条规则。可实际情况是这条路在 MySQL InnoDB 引擎下绝大多数时候都是错的。我前阵子正好对一个千万级数据量的业务表做了一次完整的 count 性能压测顺手把网上流传的各种“经验”都验证了一遍结果和很多人的直觉完全相反在 InnoDB 下count(*) 不是性能最差的那个恰恰相反它几乎总是最优的选择之一。真正性能最差的写法其实是大家以为很稳的 count(具体字段)。这篇文章我就从原理、实测、还有 PageHelper 这类分页框架在大数据量下 select count 变慢的真实原因讲起最后聊一聊当数据量真的大到没法救的时候我们应该怎么办。内容更适合被 count 慢查询困扰过的后端开发、DBA 和做性能优化的朋友新手看也不会太吃力每一步我都会拆开讲。1. 先纠正一个流传多年的说法count(字段) 才是最慢的写法事情得从源头说起。网上关于 count 性能的讨论很多都是十几年前 MyISAM 时代留下的经验那时候说 count(*) 慢放在 MyISAM 引擎里确实有点道理但现在的主流业务库基本都是 InnoDB情况完全变了。1.1 MyISAM 和 InnoDB 的底层差异决定了这件事的本质MyISAM 引擎在内部维护了一个“行数计数器”它把每张表的总行数直接存在表信息里。所以执行SELECT COUNT(*) FROM table的时候MyISAM 不需要真的去扫数据直接从元数据里把这个数字拿出来返回速度当然是极快的。InnoDB 不一样。InnoDB 要支持事务事务隔离级别里有一个叫 MVCC多版本并发控制的机制意味着同一时刻不同事务看到的同一张表行数可能是不同的因为别的事务可能还在插入、删除而且没提交。为了保证 count 的结果符合当前事务的可见性InnoDB 没法像 MyISAM 那样直接拿一个缓存数字出来它必须真的对当前可见的每一行做一次计数。正因为 InnoDB 的这个特性早期很多文章就得出结论InnoDB 下 count(*) 性能差不如 count(主键) 或者 count(1)。这个结论在当时有其历史背景后来却被无限放大甚至完全被误解了。1.2 三种写法的真实语义对比我在准备这次压测之前先把网上最常见的几种 count 写法做了个语义梳理你一看就明白问题出在哪写法实际语义是否统计 NULL是否统计重复值COUNT(*)统计所有行数无影响按行统计COUNT(1)统计所有行数每行传常量 1 给计数器无影响按行统计COUNT(主键)统计主键列非 NULL 的行数主键无 NULL等价于全部行按行统计COUNT(普通字段)统计该字段非 NULL 的行数字段为 NULL 则不计数按行统计COUNT(DISTINCT 字段)统计该字段去重后的非 NULL 行数字段为 NULL 则不计数去重后再统计从语义上就能看出来COUNT(普通字段)和COUNT(DISTINCT 字段)绝对不是无脑替代COUNT(*)的方案。前者会把 NULL 值的记录丢掉后者还要做去重操作。而COUNT(*)、COUNT(1)、COUNT(主键)在 MySQL 5.7 及更高版本里执行计划和耗时几乎没有任何区别优化器会大概率选择相同的执行路径。1.3 count(普通字段) 隐藏的性能陷阱真正容易踩坑的是COUNT(普通字段)。我之前接手过一个业务慢查询SELECT COUNT(nickname) FROM user_info WHERE ...执行时间要两秒多。当时第一反应很朴素nickname 不是主键但用户都需要昵称非空率很高用 COUNT(nickname) 应该没问题。后来 EXPLAIN 一看这列根本没走索引MySQL 只能对聚簇索引也就是整张表的主键索引树做全量扫描扫到每一行再回表去取 nickname 字段判断是否为空。一次全表扫描里加上了回表取列的额外开销能不慢吗结论很明确在 InnoDB 下如果只是统计表的行数count() 就是最优解之一count(普通字段) 反而是潜在的深坑除非你的业务真的需要统计非空数量否则别用它当 count() 的性能替代方案。2. InnoDB 的 count(*) 为什么快从执行计划看内部优化逻辑前面的结论可能还是让你有点不放心既然 InnoDB 没有行数缓存那 count(*) 到底是怎么把速度做上去的要搞明白这个必须去看执行计划看优化器到底选了什么索引来扫。2.1 优化器会挑一棵最小的索引树来扫描InnoDB 的表其实是一棵 B 树聚簇索引数据行就存在这棵树的叶子节点上整张表就是一棵以主键为顺序的索引树。如果你只对主键建立了索引那 count(*) 就一定要扫这棵最大的聚簇索引树因为它没有别的选择叶子节点里存放了整行的完整数据扫描起来 IO 开销自然最大。但业务表除了主键索引通常还会有若干个二级索引普通索引。二级索引的叶子节点只存索引键值 主键值不存整行数据。同样是一张表二级索引占用的磁盘空间和内存页数量往往比聚簇索引小很多。MySQL 优化器看穿了这一点你不是让我统计行数吗我随便找一棵索引数一遍叶子不就完了为什么要去扫最大的那棵聚簇索引树所以它会在所有可用的索引里选一棵最小的索引树来做全索引扫描。这个逻辑可以用一个生活场景类比。你要数一间大教室里坐了多少人最省事的办法是在门口发一张带编号的小纸条每进一个人发一张最后数一数发了多少张纸条而不是等所有人坐好之后挨个座位去拍每个人的肩膀数。二级索引就是那个小纸条体积小、扫描快、目的明确。2.2 实测观察换个索引cost 直接降一个量级我建了一张测试表模拟业务场景CREATE TABLE order_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, order_no varchar(64) DEFAULT NULL, amount decimal(10,2) DEFAULT NULL, status tinyint DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_create_time (create_time) ) ENGINEInnoDB;往里插入了约 1000 万行数据。然后执行EXPLAIN SELECT COUNT(*) FROM order_record;结果让我很意外吗并不意外。优化器选择的不是 PRIMARY而是idx_user_id-------------------------------------------------------------------------------------------------------- | id | select_type | table | type | key | key_len | rows | Extra | -------------------------------------------------------------------------------------------------------- | 1 | SIMPLE | order_record | index | idx_user_id | 8 | 9987654| Using index | --------------------------------------------------------------------------------------------------------key_len只有 8比idx_create_timedatetime 类型key_len 是 5 或 8 取决于版本这里其实也是 8更短比主键索引key_len 同样是 8但聚簇索引的叶子节点更大扫描代价更小。优化器选择了 user_id 这个二级索引并且Extra里显示Using index表示这个查询可以完全通过索引覆盖不需要回表。你可能会问如果只有主键索引没有二级索引呢那 InnoDB 只能扫全表聚簇索引这也是为什么网上很多性能对比文章给出的结果互不相同——他们的表结构不一样。有没有二级索引、二级索引字段多长、索引树大小直接决定了 count(*) 的速度。2.3 MySQL 8.0 的并行扫描改善MySQL 8.0 里还有一个官方优化叫parallel clustered index scan专门针对无 GROUP BY、无 WHERE 条件的简单 COUNT(*) 查询。简单说就是 MySQL 可以把扫描聚簇索引的任务拆成多个子任务通过多个线程并行扫描然后把结果合并。这解释了为什么很多人在 MySQL 8.0 上跑 count(*) 比 5.7 快不少。不过要注意这个并行扫描只适用于没有条件过滤的 count一旦加了 WHERE优化器往往就退回到串行执行了。3. 千万级数据表实测count(*) 真的不慢慢的是 WHERE为了让你有个直观的感受我把几种写法和几种场景混在一起压了一遍。测试环境是 MySQL 8.0.32InnoDB8 核 16G 内存SSD 磁盘1000 万行的表。3.1 五种写法的真实耗时对比场景SQL 写法执行耗时返回结果无条件全表SELECT COUNT(*) FROM order_record约 0.95s10,000,000无条件全表SELECT COUNT(1) FROM order_record约 0.94s10,000,000无条件全表SELECT COUNT(id) FROM order_record约 0.96s10,000,000无条件全表SELECT COUNT(order_no) FROM order_record约 1.8s9,987,654无条件全表SELECT COUNT(DISTINCT user_id) FROM order_record约 3.4s1,234,567有条件过滤SELECT COUNT(*) FROM order_record WHERE status 1约 4.7s3,210,111有条件过滤SELECT COUNT(*) FROM order_record WHERE create_time 2024-01-01约 1.3s6,543,210几个结论非常清晰第一COUNT(*)、COUNT(1)、COUNT(id)这三者在 1000 万行规模下耗时几乎一样差距不超过 2%。网上那种“数出几十毫秒差异”的测试多半是表的整体数据量还在内存 buffer pool 里扫描时间太短被随机噪声淹没了。数据量一大差异就会小到可忽略。第二COUNT(order_no)果然最慢因为它需要扫描完索引后对每一行判断 order_no 是否为 NULL。而且 order_no 没有独立的索引在聚簇索引上扫描还要在行记录里多取一个字段耗时几乎翻倍。第三真正的性能杀手其实是 WHERE 条件。WHERE status 1这条慢得离谱因为 status 字段没有索引MySQL 只能做全表扫描然后逐行过滤。这种情况下不管你是 count(*) 还是 count(1)慢的原因都跟 count 本身没关系是过滤条件的索引缺失导致的。3.2 一个反直觉的发现加了索引之后带 WHERE 的 count 反而没问题我后来给status字段加了一个索引ALTER TABLE order_record ADD INDEX idx_status (status);再次执行SELECT COUNT(*) FROM order_record WHERE status 1耗时从 4.7s 骤降到 0.2s 左右。为什么因为 MySQL 可以通过idx_status只扫描索引中值为 1 的那些记录索引里已经包含了足够的行信息扫描的数据页数量从全表级别的几万个页降到了部分匹配的几千个页。这个例子说明了一个重要道理遇到 count 慢查询第一反应不应该“换一种 count 写法”而是先用 EXPLAIN 看执行计划找到慢的根因。大部分情况下根因都是没有合适的索引导致扫描行数太大。3.3 为什么条件过滤会破坏“最小索引树”的优化这里补充一个知识只要 count 带上了 WHERE 条件优化器就无法单纯选一棵最小的索引树来数数了它必须根据 WHERE 条件的过滤方式来决定扫描路径。比如WHERE status 1且 status 上有索引MySQL 会选择idx_status扫描的只是部分索引范围。但如果 status 没有索引就只能放弃索引走全表扫描在聚簇索引的每一行里检查 status 是否等于 1。这种场景下count() 的执行代价就等于“全表扫描 逐行判断”和底层是 count() 还是 count(1) 毫无关系。所以别再折腾写法的差异了先看 WHERE后看索引最后才轮到讨论 count 的写法。4. 分页框架PageHelper里 count 慢的真相不是 count 本身慢检索这个主题时我看到一个高频场景词条“PageHelper如果数据太多了select count 就会很慢”。这个几乎是 Java 后端开发里最经典的问题之一。PageHelper 用起来确实爽但它有一个对很多人来说很隐蔽的行为每查一页数据它会自动执行一条 count 语句用来计算总页数和总记录数供前端分页组件展示用。4.1 PageHelper 的 count 查询是怎么拼出来的PageHelper 会在你的业务 SQL 之上包一层 count 查询。原理大致是把原 SQL 改写成SELECT COUNT(0) FROM ( 你写的原始 SQL ) AS tmp_count它把你的整个查询语句当作一个临时表然后对这个临时表做 count。这个策略本身在数据量小的时候没问题。但一旦原始 SQL 很复杂比如包含多张表 JOIN且 JOIN 条件字段有部分没索引子查询里做了 GROUP BY 分组并产生临时表ORDER BY 排序列没有索引还需要走 filesortWHERE 条件涉及大范围扫描。那 PageHelper 生成的 count 查询就会把上面所有开销重新执行一遍——排序、分组、产生临时表、join 全都要跑最后再数一遍行数。你原本分页查询只需要 10 条数据可能只需要几十毫秒count 却把整条 SQL 的全部结果跑了一遍耗时几秒钟。这不是 count 函数慢是外套了一层透明外壳的整条 SQL 慢只是最后落在了 count 上。4.2 PageHelper 的 count 慢排查链路我在实际项目中踩过这个坑给大家还原一下完整排查思路第一步去数据库执行EXPLAIN SELECT COUNT(0) FROM ( 原SQL ) tmp看这个外层查询的执行计划核心是看驱动的表、扫描行数和 Extra 里的内容。我当时遇到的情况是三条 JOIN 的 SQLPageHelper count 跑了 3.2 秒EXPLAIN 结果里有一个表的 rows 估算值到了 500 万并且 Extra 出现了Using temporary; Using filesort。这说明整个查询要对 500 万行做大表 join 和排序然后再做 count慢是必然的。第二步确认优化方向。如果你只是想去掉 JOIN没问题但 COUNT(0) 的语义要求返回跟原 SQL 相同的总行数如果原 SQL 因为 JOIN 产生了重复行直接删掉 JOIN 会导致计数不准。这个要分情况讨论原 SQL 的 JOIN 没有产生行数放大也就是多表是一对一关联去掉 JOIN 之后结果集行数不变那就可以安全地把 count 优化成SELECT COUNT(*) FROM 主表 WHERE 主表过滤条件PageHelper 支持手写 count 查询。原 SQL 的 JOIN 是一对多关联比如一个订单对应多个明细那行数可能被放大此时简化 count 要格外小心必须保证 count 的过滤条件能准确反映最终结果集行数。第三步确认索引覆盖。如果原 SQL 的 JOIN 和 WHERE 条件可以全部走索引覆盖查询那即使带着 JOIN 一起 count速度也会快不少。我那次优化就是给 JOIN 条件字段加了索引把Using temporary; Using filesort消掉了count 从 3.2 秒降到了 0.4 秒。4.3 手写 count 查询PageHelper 提供的优化入口PageHelper 官方提供了一个很重要的接口PageHelper.startPage(pageNum, pageSize, count)第三个参数如果传false就可以禁用自动 count。禁用之后就不能依赖分页插件帮你算 total 了你需要自己写条轻量级 count 语句单独执行。更常见的做法是在 Mapper 里手动写一个专用的 count 方法然后用pageInfo.setTotal()回填总记录数。// 手动执行轻量级 count Long total orderMapper.countForPage(userId, status); PageHelper.startPage(pageNum, pageSize); ListOrderDO list orderMapper.selectPage(userId, status); PageInfoOrderDO pageInfo new PageInfo(list); pageInfo.setTotal(total);注意这个手动 count 和分页查询 SQL 可能不在同一事务快照下执行极端情况下总数会有偏差但对于绝大多数业务场景是可接受的。如果对一致性要求极高再考虑把这些查询放到同一个事务里执行。4.4 超过千万级以后count 慢就不是 SQL 能解决的问题了如果你的表已经上亿行即使加了索引简单的 count 可能也要几十秒。这个阶段再讨论 count 的写法已经没有意义。你要思考的是业务上真的需要那么精确的 total 吗大部分分页场景用户根本不会翻到第 1000 页。列表页要展示的总记录数很多时候只是一个“大概值”就够了。这就要进入下一章的优化策略了。5. 当 count 真的优化不动了我的方案排序说句实在话我在生产环境见过太多因为 count 太慢导致接口超时的案例而解决问题的思路几乎都不是在数据库层面把 count 调到很快而是绕开它或者降低它的频率和精度要求。下面我按推荐程度从高到低列出几个方案。5.1 方案一缓存计数容忍轻微延迟这是最常见的做法。用一个 Redis 计数器维护总数每次业务插入一条记录时 INCR删除时 DECR。查询 total 时直接GET这个 key毫秒级返回。但有三个坑要避开如果业务数据有批量导入或者有定时任务修改数据很容易出现缓存计数与实际数据不一致。需要定期做一次对账例如每天凌晨跑一次SELECT COUNT(*)刷新缓存或者通过 binlog 明细做增量校准。缓存计数需要处理并发和事务问题。千万不要在事务提交前就 INCR万一事务回滚计数就多了。正确做法是监听事务提交成功后的消息再做缓存更新或者使用 binlog 同步中间件如 Canal解析 binlog 来更新 Redis。业务上线初期数据量小、无缓存计数时需要一个初始化动作先跑一次精确 count 把值塞进 Redis。5.2 方案二用信息近似值替代前提是业务能接受MySQL 的information_schema.tables表里有统计信息其中TABLE_ROWS字段就是行数的近似值它是 InnoDB 根据采样估算出来的通常误差可能在几万甚至几十万行但优点是查询速度极快直接查元数据不走业务表。SELECT TABLE_ROWS FROM information_schema.tables WHERE table_schema your_db AND table_name order_record;这个方案适合后台管理系统的首页大盘展示“累计订单量约 XXX 万单”这种场景不适合订单列表分页的精确 total。我踩过的一个教训是如果业务量波动剧烈这个估算值误差会非常大有次表里实际 50 万条估算值显示 200 万被运营同事找上门。所以用这个方案前一定要和产品确认展示文案是否可以用“约”。5.3 方案三内建计数表事务里同步维护如果业务要求精确、且写入频率不算离谱比如每秒几百次以下可以自建一张计数表在业务事务里同步更新。-- 统计表 CREATE TABLE table_counter ( table_name varchar(64) PRIMARY KEY, row_count bigint NOT NULL DEFAULT 0 ); -- 事务内插入主表数据并更新计数表 START TRANSACTION; INSERT INTO order_record (...) VALUES (...); UPDATE table_counter SET row_count row_count 1 WHERE table_name order_record; COMMIT;这个方案的优点是精确、不额外依赖 Redis 这类外部组件数据一致性由同一个数据库事务保证。缺点也同样明显写操作变多更新计数表的行锁可能成为并发瓶颈。适合低频写入的场景高频写入就别这么干了。5.4 方案四换存储引擎或上 OLAP 分析库如果业务已经到了亿级乃至十亿级以上那么把 count 这种聚合操作继续压在 OLTP 库上就不太明智了。此时常规做法是把统计分析类查询迁移到 ClickHouse、Doris 或者 TiDB 这类对海量数据聚合更友好的引擎上。它们的列式存储和向量化执行对 COUNT、SUM、GROUP BY 这类统计查询有非常明显的加速效果。比如 ClickHouse 里跑一个SELECT COUNT(*) FROM order_record对上亿行数据能轻松在几百毫秒内返回。而且它本身也有实时同步机制可以从上游数据源同步业务数据。代价是多维护一套系统、数据实时性有延迟通常是秒级到分钟级、架构复杂度上升。对于中小团队我建议先把前面几个方案用尽再考虑引入 OLAP 系统。5.5 顺带一提跨库计数的场景我注意到这次的搜索词里还提到“两张数据表tableA 源表、tableB 目标表存在于不同数据库”。如果两张表在不同库而你又要统计汇总值一定要明确跨库查询的代价。常规做法有几种在应用层分别对两个库做 count然后内存里相加如果是同实例下的不同库使用db1.tableA和db2.tableB的跨库查询直接 SQL 搞定如果跨库且跨实例就要考虑用同步工具把两个库的数据汇总到统一的数据仓库或 OLAP 引擎里再统计。这里面最不能踩的坑就是在业务 SQL 里使用数据库自带的跨库查询又不加任何限制条件把两个超大表的笛卡尔积或全量 join 拉出来这种写法不仅 count 会卡死整个实例的 CPU 和 IO 都可能被打满。跨库统计一定要从应用层或中间层做聚合不要在数据库层硬碰硬。6. 几个我踩过的坑提前帮你避掉前面讲的都是方法论这一节写点实际开发中容易踩的细节。这些坑几乎都是经验换来的常规文档里不太会写。6.1 count(*) 并不总是选最小索引别被“必须”骗了虽然优化器通常选最小的二级索引但也存在意外情况如果 WHERE 条件里的字段有多个索引可选优化器会根据基数估算和代价模型选择扫描行数最少的那个索引而它的 key_len 可能不是最小的。比如WHERE status 1 AND create_time 2024-01-01优化器可能选择idx_create_time而不是idx_status因为前者在这个条件组合下能过滤更多数据。所以不要拿网上“随便 count 都很快”的结论去套所有场景。遇到慢查询第一步永远是EXPLAIN看它实际选了哪个索引、扫描了多少行。6.2 别在 count 的 SQL 里写 ORDER BY分页查询的 SQL 往往带着 ORDER BYPageHelper 的 count 自动改写时会把外层包住如果原 SQL 的 ORDER BY 是非索引列就可能在临时表里多一次 filesort。MySQL 8.0 的优化器有时候能识别 ORDER BY 在 count 查询里是可以忽略的但并不能保证每次都识别。我自己的经验是如果分页 SQL 里的 ORDER BY 比较复杂或者已经检测到 count 慢就手动写一条去掉 ORDER BY 的 count 查询。语义上ORDER BY 不影响 count 的结果去掉它永远不会出错。6.3 小心 MVCC 导致的 count 结果波动在一个长事务里执行两次SELECT COUNT(*)可能得到两个不同的结果。这是因为 MVCC 机制下你的事务快照创建之后其他事务提交的数据并不会出现在你的快照里。有一个生产事故的回忆某个定时任务的事务开启了太久在事务中第一次 count 了一百万后续往事务里翻了半天日志再 count 一次变成了一百零五万业务方以为是统计数据错了实际上只是因为长事务读到了不同的快照版本。这个行为是 InnoDB 的固有特性不是 bug。做精确统计、对账这类需求时要么把事务严格限定在单条查询范围内要么使用SELECT COUNT(*) FROM table AS OF ...之类需要数据库支持的快照读取或者直接避免在长事务里面做 count。6.4 测试性能时注意 buffer pool 的干扰不少人测试 count 性能的时候同一台机器、同一张表第一次跑 3 秒第二次跑 0.5 秒然后兴高采烈地得出结论说“优化生效了”。其实大概率只是第一次把数据页从磁盘加载到了 InnoDB 的 buffer pool第二次直接内存命中自然快。要获得真实可靠的测试数据至少在每次测试前清掉 buffer pool或者使用一个不会复用缓存的方式-- 清空 buffer pool8.0 支持5.7 不一定有 SET GLOBAL innodb_buffer_pool_dump_at_shutdown OFF; -- 重启 MySQL 实例或者用以下命令观察当前缓存命中率 SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_requests;更简单的做法是取多次执行后的平均数并注意第一次和最后一次的差异。如果差异巨大先怀疑缓存别急着下结论。7. 建议现在就把 count 规范写进团队约定结合这次压测和排坑经历我最后给大家一套可以直接落地的团队规范能省掉以后绝大多数 count 性能问题的扯皮。统计全表行数统一用COUNT(*)不要设计成COUNT(1)或COUNT(主键)三者性能一致但统一写法能让审查代码的人少一次反应时间。如果你本来只是想要行数却写成了COUNT(普通字段)先复盘这个字段是否真的不会为 NULL。如果只是为了凑一种“性能更好”的写法立即改成COUNT(*)。分页总数如果来自 PageHelper 的自动 count上线前务必把那条自动生成的 count SQL 拿出来单独 EXPLAIN 一次确认扫描行数和 key 是否合理。大数据量分页且表超过千万级主动找产品经理确认 total 数值的精确性要求能接受“约”就用近似值或缓存不能接受就上计数表别硬扛。任何一次 count 慢查询的优化都以 EXPLAIN 为起点以“扫描行数下降 耗时下降”为终点不要靠拍脑袋换写法。回看这次压测的整个过程我也算是把以前对 count 的很多模糊认知彻底厘清了。真正决定性能的从来不是 count 后面跟的是星号还是数字而是你的索引设计、WHERE 条件的过滤能力、以及查询是否跑在了合适的存储引擎上。希望这篇内容能帮你省掉一些做无用功的时间下次再听见有人说“count(*) 最慢”你至少能笑着反驳一句你测过 1000 万行的表了吗