我先说一个很多做后端的朋友问过我多次的问题为什么同一张 MySQL 表数据量到了千万级以后一条SELECT COUNT(*) FROM table能慢到让接口直接超时尤其用了 PageHelper 这类分页插件后打印出来的日志里 count 查询经常比真实数据查询还要慢甚至慢上好几倍。count是 MySQL 里最基础的统计函数也是面试高频题。但大部分人只停留在“count(*) 和 count(1) 没区别”、“count(字段) 不计 NULL”这个层面一旦落到真实业务中面对大表、慢查询、分页超时往往就开始乱猜原因是不是索引失效了是不是被锁表了是不是数据量太大了要分库其实大部分 count 慢的问题都能说清楚、也能解决关键是你得先搞清楚 InnoDB 底层到底是怎么算行数的。这篇博文我会从 count 的语义差异、InnoDB 为什么“慢”、慢查询定位手段、分页场景优化、常见问题速查这几个维度展开。文章适合正在被 count 慢查询困扰的后端开发、DBA 新人也适合准备面试时想把“count 为什么慢”讲透的兄弟。我会尽量用实际案例说话不整虚的。1. count 的基础用法与语义差异1.1 count 的四种写法到底差在哪里日常开发里最常见的其实就四种count(*)、count(1)、count(主键)、count(普通字段)。看上去只是括号里写的内容不一样但在 MySQL 眼里它们的执行含义完全不同。先看结论表后面再逐个拆写法是否统计 NULL是否可能走索引典型使用场景count(*)统计所有行是优化器选最小索引统计总行数count(1)统计所有行是优化器选最小索引统计总行数count(主键)主键非空统计所有行是否扫描主键索引基本可用count(普通字段)不统计 NULL是但若全表为 NULL 可能全扫统计该字段非空数量先说说count(*)和count(1)。很多人以为count(*)会把整行字段都取出来再数一遍所以觉得慢但这其实是个经典误解。MySQL 对count(*)有独立优化路径它会从执行器中直接按“行记录”的数量来做累计并不会尝试解析字段内容。说得直白一点count(*)就是“数有多少行”它根本不关心行里面存了什么。那count(1)呢这里的 1 只是一个常量表达式MySQL 在执行时同样不需要读取任何字段的值处理逻辑也是按行数累加。所以count(*)和count(1)在绝大多数场景下执行计划几乎完全一样性能也基本没有差异。网上流传的“count(1) 比 count(*) 快”在 MySQL 8.0 里不是正确结论市面上很多老教程是拿 Oracle 或者其他数据库的经验来套 MySQL。再说count(id)。id 一般是主键主键索引是聚簇索引聚簇索引的叶子节点存了整行数据扫描主键索引的成本有时候会比扫描二级索引更高。但这并不是说count(id)一定更慢因为 MySQL 的优化器在很多时候会“自作主张”把count(id)改写为count(*)的逻辑来执行最终走的还是一个成本更低的索引扫描方案。所以如果你单纯为了统计总行数去硬写count(id)其实完全没有必要。count(普通字段)是最容易坑人的。比如你写count(status)MySQL 在扫描每一行时会先判断status字段是否为 NULL如果为 NULL 就不计数。这意味着如果这个字段有大量 NULL统计结果会比你预期的行数少一截。而且更麻烦的是当这个字段上没有可用索引时MySQL 只能做全表扫描每行都要读取一次字段值性能是最差的。1.2 为什么很多人坚持“count(1) 比 count(*) 快”这个认知误区非常顽固我在代码评审里见过不止一次。追根溯源早期 MySQL 版本中count(*)的实现确实要粗糙一些执行计划没那么聪明所以有人做过压测发现count(1)快一点。但从 MySQL 5.7 开始尤其是 8.0 版本以后优化器对两个写法的处理几乎已经合并成同一条执行路径。我自己实测过一张 2000 万行的表分别执行SELECT COUNT(*) FROM t和SELECT COUNT(1) FROM t多次跑下来耗时差距在毫秒级以内完全属于正常波动。所以你在生产和面试中都可以直接说count(*)是 MySQL 推荐的总行数统计方式语义清晰优化器的支持也最到位。那真正需要担心的是哪种是count(某字段)这种带有“非空过滤”意图的写法。如果你只是要总数一律用count(*)如果你要统计某字段有多少非空值也要确认这个字段是否需要建索引否则大数据量下全表遍历会非常痛苦。2. 为什么 MyISAM 快而 InnoDB 慢2.1 InnoDB 的隐藏行与事务可见性这个问题你肯定听过MyISAM 引擎的count(*)不带 WHERE 时是秒回的为什么换了 InnoDB 就变慢核心原因是两个引擎的存储结构完全不一样。MyISAM 会把每张表的精确行数直接记录在表的元数据里执行count(*)时直接把这个数字读出来返回就行根本不涉及扫描数据文件。但它的代价是不支持事务任何写入操作都是全表锁级别改一行也要锁住整张表。站在并发和数据一致性的角度这个“秒回”的代价相当大。InnoDB 则完全不同它引入了多版本并发控制MVCC。简单说同一张表在不同事务里看到的行数可能是不同的你的事务还没提交时我发起的查询不应该看到你插入的行你在删除数据时另一个并发查询也不应该立刻少一行。为了满足这种隔离性InnoDB 没有办法在表头上直接存一个“当前总行数”并让所有人都读它。它必须在自己这个查询的“快照”基础之上逐行判断哪些行对当前事务可见然后才做累加。所以 InnoDB 的count(*)本质上是一个“计数扫描”过程复杂度是 O(N)数据行数越多花的时间就越长。这也是为什么你在千万级表上跑count(*)会明显感觉到慢的根本原因。2.2 count 一直在“白跑”扫的是索引树既然 InnoDB 非要扫描不可那它到底扫的是什么呢这里有个关键点count(*)扫描的不一定是聚簇索引主键索引优化器通常会把count(*)改写为成本更低的执行计划优先选择扫描最小的一棵二级索引树。要理解这一点需要先知道 InnoDB 的索引结构。聚簇索引的叶子节点保存的是完整行数据一页能存的行数较少扫描相同行数时读取的叶子节点页更多而二级索引的叶子节点只保存索引字段的值外加主键值每页能容纳的数据条数更多扫描整棵树的 IO 次数更少。我举个实际例子你就明白了。假设有张订单表字段很多、行宽很大主键和 user_id 列上都有索引。执行count(*)时优化器会倾向于选择idx_user_id这个二级索引来扫而不是扫主键索引。你在EXPLAIN中会看到type: indexkey: idx_user_id表示它正在做一次完整的索引扫描index full scan。那为什么还是慢因为即便是二级索引只要行数达到几千万需要读取的叶子节点数量依然巨大。二级索引的大小虽然比主键索引小但不代表它“不用扫”。它只是更省 IO而不是 O(1)。很多初学者以为加了索引 count 就快了实际情况是索引能让查询少走弯路但count(*)的目标是统计所有行它必然要遍历一遍索引树才能得到正确结果不存在“跳过去直接拿到总数”这回事。2.3 MyISAM 为什么有计数值却不准这里再补一个边界场景MyISAM 只在没有任何 WHERE 条件时会直接返回元数据中的行数。如果你写了WHERE status 1MyISAM 同样需要做全表扫描来统计并不能继续“作弊”。所以 MyISAM 的快是限定在无条件统计这个场景下的快。另外还要注意MyISAM 的行数是存储在表级元信息里的它无法区分不同事务的视图。InnoDB 放弃这种设计就是因为它要在高并发和事务隔离下保持正确性。换句话说MyISAM 的“秒回 count”是用事务隔离性和并发能力换来的属于典型的“看起来很美用起来要想清楚”的选型。3. 慢 count 的原因与定位手段3.1 扫描行数、回表、锁定与并发现在我们把影响 count 耗时的因素拆开来看。第一个当然是扫描行数。表里 1 万行和 2000 万行count 天然不在一个量级。这个不用多说真正的难点在于你到底该把哪部分代价看成“正常”哪部分代价看成“可以优化”。第二个因素是回表。当一个count(字段)的查询里字段没有覆盖索引MySQL 需要拿着二级索引里的主键去聚簇索引查完整行记录再判断字段是否为 NULL。这段回表过程会产生大量随机 IO比单纯的覆盖索引扫描慢一大截。比如统计count(remark)这种大字段性能往往会差到让人怀疑人生。第三个因素是行锁与并发。虽然 InnoDB 的普通count(*)走的是快照读理论上不阻塞写入但在极端高并发下大量 count 查询本身也会争抢 CPU 和 IO 资源把实例的负载拉高。尤其是很多报表页面一打开同时发来好几个不同条件下的 count 请求数据库连接池直接被打满这是生产事故里非常典型的场景。第四个因素是长事务和 undo log。如果一个长事务迟迟不提交它需要的旧版本数据就不能清理导致 undo log 膨胀、历史版本链变长。此时即使是很简单的 count 查询在判断行可见性时也可能需要遍历版本链性能显著下降。这个问题不是 count 本身造成了但它会严重拖慢所有查询包括 count。3.2 定位方法EXPLAIN、慢查询、状态变量我一般排查 count 慢查询的步骤是先抓执行计划再确认慢日志时间最后看系统层面的 IO 指标。第一步给慢 SQL 的 SELECT 前面直接加上EXPLAIN看执行计划EXPLAIN SELECT COUNT(*) FROM user_orders WHERE status 1;重点关注type、key、rows三列若type ALL说明在做全表扫描通常意味着 WHERE 过滤列没有可用索引或者优化器认为索引选择性太差。若type index说明在做索引全扫描能走索引但在数据量很大时依然很慢例如count(*)扫二级索引就是这种。key列能看出实际选中的索引是不是你预期的那棵。rows列是估算扫描行数不是精确值但能帮你判断 SQL 是不是一开始就选了一条“远路”。第二步打开慢查询日志。MySQL 8.0 中可以直接通过long_query_time控制阈值SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log ON;把超过 1 秒的查询记录下来再结合日志中的Rows_sent、Rows_examined判断如果一个 count 查询扫描了 5000 万行但只返回 1 行那它的代价基本来自扫描本身用户等的就是这口“数行数”的气。第三步看两个状态变量Innodb_rows_read和Innodb_buffer_pool_read_requests。你可以执行SHOW GLOBAL STATUS LIKE Innodb_rows_read;在执行 count 前记录一次执行后再记录一次差值就是实际扫描的行数。如果这个数字远大于EXPLAIN里预估的 rows说明执行计划存在偏差要多怀疑统计信息是否过期或者是否走了错误的索引。3.3 一个典型的线上定位例子我之前遇到过一起慢查询报警一张流水表接近 4000 万行接口里用了 PageHelper 分页每次请求都会自动生成一个SELECT COUNT(0)统计总数。高峰期该 SQL 单次执行耗时 3~5 秒把数据库连接池拖到接近打满。当时的排查路径是先看执行计划type index走的是主键索引扫描行数接近全表基本等于把整张表的聚簇索引树从头到尾翻了一遍。为什么没走二级索引因为这表除了主键以外几乎没有其他索引优化器根本没有“更小的树”可选只能扫主键。接着看慢查询日志确认这条 count 占到了总体慢 SQL 数量的 70% 以上。最后再看Innodb_rows_read的值单次 count 就读取了 3800 多万行。这个案例里count 慢的首要原因是表结构设计上索引缺失次要原因是分页插件无差别地做了精确 count。优化方向也就非常明确了要么加合适的二级索引并改写 SQL 走覆盖索引要么放弃精确 count用“只查是否有下一页”的思路替代。这在后面分页优化小节里再展开。4. 常见优化方案与取舍4.1 计数器表让总数变成一行记录业务上一个很常见的需求是显示列表总记录数比如“共 12345 条”。如果数据量在千万级每次请求都实时 count 确实不划算。这时候可以引入一张额外的计数器表业务在插入、删除数据的事务里同时更新计数器表中的数值。CREATE TABLE table_count ( table_name VARCHAR(64) PRIMARY KEY, row_count BIGINT NOT NULL );插入数据时START TRANSACTION; INSERT INTO user_orders (...) VALUES (...); UPDATE table_count SET row_count row_count 1 WHERE table_name user_orders; COMMIT;删除数据时反向操作。这样查询总数时直接SELECT row_count FROM table_count WHERE table_name user_orders瞬间返回。但这个方法有几个问题要提醒你。第一它会让写路径变得更复杂任何批量导入、定时清理、逻辑删除都不能漏更新一旦漏了就很难对账。第二计数器表本身会变成热点行在超高并发写入下这一行的锁竞争可能成为新的瓶颈需要配合分片或合并思路做。第三如果业务里既有逻辑删除又有物理删除公式就变成了各种状态维度维护难度指数级上升。所以计数器表适合“读多写少、总数口径稳定”的业务比如文章总浏览数、商品总销量这类。对于订单表、流水表这种高频增删、还要配合复杂 WHERE 条件筛选的场景直接维护一个精确计数器往往不现实。4.2 走估值SHOW TABLE STATUS 或 information_schema如果你的需求只是“这个表大概有多少行”不需要精确值可以走 MySQL 内置的统计信息SHOW TABLE STATUS LIKE user_orders;或者SELECT table_rows FROM information_schema.tables WHERE table_schema your_db AND table_name user_orders;这两者底层数据都来自 InnoDB 的统计信息是一个近似值可能与实际行数相差很大。尤其在频繁增删、大事务回滚后table_rows可能非常不准。它适合的场景是数据质量报表、容量预估、后台管理页的概览面板这些场景对精确度不敏感。但如果用户在分页组件里要看到“总共 66666 条”这些方案就完全不能用了。4.3 用缓存Redis 值缓存与补偿把 count 结果放到 Redis 里设置合理过期时间通常能大幅缓解数据库压力。比如 5 分钟过期总行数允许有 5 分钟延迟对很多后台列表完全够用。String key count:user_orders; Long total redisTemplate.get(key); if (total null) { total userOrderMapper.countAll(); redisTemplate.set(key, total, Duration.ofMinutes(5)); }这个方案要注意缓存穿透和并发重建的问题热点 key 过期瞬间多个请求同时打到数据库可能导致数据库短时压力上升。常见的做法是加分布式锁只允许一个请求重建缓存或者采用逻辑过期、异步刷新的方式。另外一旦总行数可能需要按不同 where 条件区分缓存 key 的设计会变得很乱。比如按状态统计、按创建日期统计、按用户维度统计key 的组合数量会爆炸。所以缓存方案更适合“全表总行数”这种口径不太适合复杂筛选条件的 count。4.4 查询改写尽量走覆盖索引如果一定得实时 count那就想办法减少扫描代价。核心思路是让 count 的查询能够覆盖索引不产生回表。举一个例子。原 SQL 是SELECT COUNT(*) FROM user_orders WHERE status 1;如果status列没有索引MySQL 只能全表扫描。这时为status建一个索引ALTER TABLE user_orders ADD INDEX idx_status (status);下次执行同样查询时优化器倾向于扫描idx_status这棵二级索引树扫描代价比聚簇索引小很多。因为你只关心行数并不需要读取其他字段的值二级索引叶子已经足够。如果你的 where 条件不止一列比如status加上created_at可以考虑建立联合索引让查询能走覆盖索引ALTER TABLE user_orders ADD INDEX idx_status_created (status, created_at); SELECT COUNT(*) FROM user_orders WHERE status 1 AND created_at 2024-01-01;走联合索引后MySQL 可以只扫idx_status_created这棵树再配合索引条件下推过滤效率会高很多。注意索引不是越多越好每多一个索引都会拖慢写入速度增加磁盘占用所以要根据真实慢查询来加不要给一张表建十几个索引。5. 分页场景中的 count 优化PageHelper 等场景5.1 PageHelper 到底做了什么如果你用的是 Java 后端大概率用过 PageHelper。这个分页插件在拦截到你在执行SELECT ...后会自动生成一条 count 查询再拼上 limit 参数执行真实的分页查询。它生成的 count 语句常见形式为SELECT COUNT(0) FROM (SELECT ... 完整业务查询 ...) tmp_count也就是说如果你业务查询本身比较复杂比如关联了多张表、做了很多字段重命名和子查询PageHelper 会把整段 SQL 包成一个子查询再在外面套一层 count。这一下子就把 count 查询的复杂度拉到了和业务主查询差不多的水平甚至更慢因为数据库需要先把整个结果集算出来再去数。我之前甚至见过这样的例子原业务查询因为 JOIN 产生了大量中间结果PageHelper 生成的 count 子查询扫描了接近 1 亿行和主查询扫描行数差不多但主查询有 LIMIT10 条拿到就停了count 却必须把整个结果集数完。这也就是为什么很多接口会“一开分页就变慢”。5.2 优化分页 count 的实操手段第一种做法是尽量去掉不必要的 JOIN。比如一个主表关联了配置表、扩展表但列表展示只需要主表的几十个字段分页统计也应该只基于主表和真正参与过滤的表。PageHelper 默认生成的 count 子查询会原样包住你写的 SQL那你就应该主动把业务 SQL 改得精简一些实在不行就在分页查询里单独写一个简化版的 countSQL明确只统计过滤主键或主表行数。第二种做法是给过滤字段和排序字段设计合理的索引。分页真实 SQL 如果写的是WHERE status 1 ORDER BY created_at DESC LIMIT 10那(status, created_at)联合索引能让排序和过滤都高效。这时候即使 count 走(status)或(status, created_at)索引扫描行数也会小很多。第三种做法是不追求精确页数改用“下一页游标”方式。简单来说用户翻页时不需要知道“总共有多少页”只需要知道“下一页还有没有数据”。你可以把真实查询由LIMIT offset, size改成WHERE id 上一页最大id ORDER BY id LIMIT size 1多取一条用来判断是否有下一页。这样完全绕开了 count 查询翻页性能非常稳定不会随着页码增大越来越慢。代价是页面上无法直接展示总页数和“跳转到第 X 页”。第四种做法是引入独立的统计接口和缓存。列表接口不再每次返回总数而是前端先渲染数据再异步调用count接口获取总数。这个 count 接口可以做 30 秒乃至 1 分钟级别的缓存用户对此基本无感。这样数据库压力被平摊list 主查询和 count 查询互不拖累。5.3 什么时候必须保留精确 count也不是所有场景都能放弃精确 count。运营后台的报表筛选、财务对账、导出任务前的总条数确认往往就要求精确。这些场景通常不会那么频繁地被用户点击就算慢一点也可以接受但你不能让它把整个库拖垮所以更适合在专职的只读实例上执行或者把统计任务放到异步队列里查询结果落地到缓存表后再展示。我自己给团队定的原则是C 端高频列表接口默认不做精确 countB 端低频报表页面保留精确 count但必须配合索引优化和超时降级。6. 常见问题与排查技巧实录6.1 表数据量一样为什么 count 时快时慢通常有三个原因。第一是否命中 MySQL 的查询缓存。在 MySQL 8.0 之前如果表没有更新操作count 结果可能被缓存第二次执行明显快很多但这会给排查造成错觉。第二InnoDB buffer pool 里缓存页的命中率不同。如果表数据页常驻内存执行 count 时不需要太多磁盘 IO速度自然快如果缓存池被其他大查询冲掉count 就要重新从磁盘读页慢十几倍都很正常。第三系统当前负载高低、是否有其他大事务占用资源也会造成明显波动。所以我排查 count 慢查询时一般会连续执行两三遍看第一次和后续执行的差异以此判断是冷数据问题还是执行计划问题。如果冷热差距非常大重点优化方向就是让统计查询尽量走二级索引小页扫描、扩大 buffer pool或者减少一次性大查询对缓存池的冲击。6.2 count(字段) 为什么结果远小于 count(*)这个我在前面原理部分已经说了count(字段)只统计非 NULL 的行。如果你发现自己写count(order_id)比count(*)少了很多不要奇怪先去查该字段在业务里是否大量为 NULL。如果确实需要统计“所有行里该字段有值”的数量用count(字段)是合理的如果只是想要总行数请老老实实写count(*)。6.3 COUNT(DISTINCT field) 慢到离谱怎么办COUNT(DISTINCT field)是 count 函数家族里最“贵”的操作之一。它不仅要扫描字段值还要做去重统计内部往往会使用临时表或者排序扫描行数非常高。优化思路通常是两种一是精确去重改成近似去重比如使用 HLLHyperLogLog风格的统计但 MySQL 自带的近似函数并不直接支持需要业务层配合二是减少统计的临时表压力比如先过滤再 distinct让参与去重的行数尽可能小。还有个实用土办法是建一个“字典表”把去重后的值单独维护起来统计时直接查这个表的行数但这也需要额外的写入逻辑适合低频更新、高频统计的字段。6.4 count 查询遇到锁等待和超时如果 count 查询报锁等待超时通常不是 count 本身的问题而是它参与的某个事务里有其他事务长时间持有的行锁或间隙锁没有被释放。排查手段是执行SHOW ENGINE INNODB STATUS;查看 LATEST DETECTED DEADLOCK 或者 TRANSACTIONS 部分的锁等待信息找到阻塞源事务。在确认事务没有异常后再通过指定会话的KILL或等待事务提交来恢复。注意普通count(*)默认是非锁定读理论上不会申请行锁加锁的往往是 count 查询所在事务内部的其他写语句。6.5 一个大表的 count 到底能优化到什么程度很多人期望把千万级表的count(*)优化到几百毫秒内这个目标要分情况讨论。如果你是全表无条件 count即使走了二级索引也要扫描整棵索引树瓶颈在磁盘和内存 IO在几个 T 的表上做到几百毫秒几乎不现实能做的就是通过二级索引把 IO 消耗降一个量级。如果你的 count 伴随了极强过滤条件比如按主键或唯一索引范围查询那么走索引定位后扫描行数可能很小就能做到毫秒级返回。所以与其纠结“大表 count 为什么不能再快一点”不如想清楚业务到底需要什么粒度的数据再决定是实时精确统计还是用估值、缓存、计数器表来替代。结尾一点个人体会做后端这么些年我踩过最深的坑不是 count 本身而是“永远不要假设 MySQL 会按你想的方式执行”。同一张表、同一条 count SQL在 MySQL 5.7 和 8.0 上执行计划可能不同在表数据量不同阶段执行计划也可能不同。所以我的习惯是每逢业务上线前必跑一次EXPLAIN观察一段时间慢查询日志再逐步加索引。如果你现在正被分页接口的 count 慢查询折磨我的建议是先别急着堆 Redis 缓存先用 EXPLAIN 看清楚它到底扫了什么索引再顺藤摸瓜去优化 SQL 和表结构。等结构层面的工作做完了再考虑缓存、计数器这类“外部手段”。很多事看着是性能问题其实往上追一层是设计问题。