1. 先说清楚:你每天写的 COUNT 到底在数什么
打开任何一个后台管理系统,几乎都有"列表总数""本月订单量""注册用户数"这类数字,往前翻一般都能找到一个 SQL:SELECT COUNT(*) FROM xxx。这个函数太常用了,以至于很多人把它当成"理所应当一秒返回"的东西,直到某天表从几十万涨到几千万,一条统计接口直接把数据库 CPU 拉满,才发现自己连 COUNT 的基础行为都没真正吃透。
我这个主题的起因是一篇关于 counting-rows 与 function_count 的英文技术文章,翻译加实测之后,踩了几个坑,也重新捋了一遍 COUNT 在 MySQL 里的实现逻辑。这篇文章就把我自己的读译心得、压测过程、以及实际生产环境里的优化方案完整写一遍,给同样被 COUNT 慢查询折磨过的人一个参考。不管你是写业务 SQL 的研发,还是要做数据库优化的 DBA,这篇都值得留个书签。
2. 拆解 function_count:三种用法,三种语义
2.1 COUNT(*) 和 COUNT(字段) 不是一回事
很多人写 COUNT 完全是肌肉记忆,用错了也不知道。MySQL 里 COUNT 有几个基本形式,对应的语义有明确差别:
| 写法 | 统计内容 | 是否计算 NULL |
|---|---|---|
COUNT(*) | 统计结果集中的全部行数 | 计算,不管整行是否为 NULL |
COUNT(expr) | 统计 expr 表达式非 NULL 的行数 | 不计算 NULL |
COUNT(DISTINCT expr) | 统计 expr 去重后非 NULL 的数量 | 不计算 NULL |
看一个简单例子。有一张用户表,nickname字段允许 NULL:
SELECT COUNT(*), COUNT(nickname) FROM users;如果表里有 100 行,其中 20 行 nickname 是 NULL,那么COUNT(*)返回 100,COUNT(nickname)返回 80。这不是性能问题,而是逻辑正确性问题。我见过不止一次,统计口径直接用COUNT(字段)结果比实际行数少一截,排查半天发现是字段可空导致漏数。
还有一个容易忽略的点:COUNT(1)到底和COUNT(*)有什么区别?从语义上讲没有区别,1是常量表达式,恒不为 NULL,所以它统计的就是所有行数。早期版本里两者在优化器中的处理略有不同,但在现代 MySQL 8.0 版本中,优化器已经会把COUNT(1)直接改写为COUNT(*)的执行计划,性能差异可以忽略不计。
2.2 COUNT(DISTINCT) 的临时表代价
COUNT(DISTINCT expr)这个写法看着优雅,实际是 COUNT 家族里最"重"的操作。它的执行逻辑是:先对所有非 NULL 的值做去重,再去计数。这个去重过程通常需要创建临时表,数据量大时甚至会落到磁盘临时表,代价非常可观。
举个例子:
SELECT COUNT(DISTINCT user_id) FROM order_items;这行 SQL 的执行流程大致是:扫描order_items表,把user_id非 NULL 的值全部捞出来,写入临时表做去重,最后统计临时表里的行数。如果user_id上正好有索引,优化器可以用索引来辅助去重,情况会好一些;如果没有索引,那你就是在让数据库现场做一遍全量分组。
所以我的习惯是:COUNT(DISTINCT)只用于数据量可控的场景,一旦预估要去重的基数达到百万以上,就要掂量掂量。更合理的方式是单独维护一张维度表,或者用近似基数估算方案(比如 HyperLogLog)在应用层解决。
2.3 一个容易误导新手的误区:COUNT(*) 是数主键索引吗
翻译那篇文章时有一个观点很值得琢磨:InnoDB 里COUNT(*)并不一定扫描主键索引,优化器会更愿意选择"最小的可用二级索引"来扫描。为什么?因为二级索引叶子节点只存索引列和主键值,通常比主键索引的叶子节点小很多,相同数据量下需要读取的数据页更少,IO 也更少。
我之前用一条 500 万行的订单表做过验证:表上有主键和几个二级索引,执行EXPLAIN SELECT COUNT(*) FROM orders,结果key字段指向的是一个二级索引而不是主键。这个行为大家不用惊讶,InnoDB 的数据页加载是成本大头,扫描越小的索引越快。所以在设计索引时,如果你预感到这个表会被频繁做无过滤条件的 COUNT,可以考虑特意建一个只包含小字段的二级索引作为"计数索引"。
3. 为什么 MyISAM 秒回,InnoDB 却要慢慢扫
这个问题我早年刚接触 MySQL 时困惑了很久。同样的SELECT COUNT(*) FROM t,在 MyISAM 引擎上是瞬间返回的,切换到 InnoDB 就变成几秒钟的慢查询。这两者的差异,本质上源于引擎架构的设计取舍。
MyISAM 把每张表的精确行数直接存储在表的元数据里。不带 WHERE 条件的COUNT(*),等于直接读一个数字,不需要碰数据文件,所以 O(1) 时间返回。
InnoDB 不一样,它支持事务和 MVCC,同一个时刻不同事务看到的数据版本可能不同。一个事务对表做了插入,另一个事务还没提交,那第二个事务在COUNT(*)时要不要数这一行?答案是取决于隔离级别和该事务的一致性视图。正因为每一行都需要做可见性判断,InnoDB 无法在元数据里维护一个"对所有事务都正确"的精确行数,所以只能实时扫描。
这个设计差异带来一个结论:在 InnoDB 下,凡是准确计数,都意味着一次全表(或全索引)扫描,扫描成本随数据量线性增长。这是在 MySQL 里做行数统计时必须时刻记住的核心约束。
下面是我压测的一组简单数据,供参考:
| 数据量 | 查询方式 | 引擎 | 耗时 |
|---|---|---|---|
| 100 万 | COUNT(*)无过滤 | InnoDB | 约 180ms |
| 1000 万 | COUNT(*)无过滤 | InnoDB | 约 1.8s |
| 5000 万 | COUNT(*)无过滤 | InnoDB | 约 9s |
| 100 万 | COUNT(*)无过滤 | MyISAM | 约 1ms |
同样是 100 万行,InnoDB 和 MyISAM 的差距是数量级的。但别因为这点就想把表切回 MyISAM,MyISAM 不支持事务、崩溃恢复能力弱,生产环境核心数据基本不会用它。理解这个原理的意义在于:你需要为 InnoDB 的高成本计数设计应对方案,而不是试图绕回 MyISAM。
4. 实战记录:一个千万级订单表的 COUNT 优化全过程
4.1 故障现场
某个模拟项目里有一张订单流水表,叫order_flow,当时数据量已经超过 2000 万行。业务端有个管理后台页面,每次进入都要展示总订单数,对应的 SQL 是:
SELECT COUNT(*) FROM order_flow WHERE status <> 9;这条 SQL 在数据量小的时候毫无存在感,到了千万级之后,查询耗时从几十毫秒飙升到三秒多,而且因为页面轮询调用,直接把一个从库的 CPU 打到接近 100%。现象就是后台页面越来越卡,数据库慢查询日志里全是这条 SQL。
我先做了问题定位:
EXPLAIN显示走了全表扫描,扫描行数就是全表行数,因为status <> 9这个条件本身没有选择性,索引帮不上忙。- 这张表还在持续高频写入,每次 COUNT 都要扫描大量刚产生的数据页,缓存命中率还不高。
- 业务对"总量"的实时性要求其实没那么高,误差几分钟完全可以接受。
第一个优化点很快就明确了:从"实时准确"降级为"准实时",精度换性能。
4.2 建立计数表的完整步骤
我采用的方案是独立计数表 + 事务内同步更新。核心思路:把"数行数"这样昂贵的操作,转化为"维护一个数字"这样极低成本的操作。
计数表结构很简单:
CREATE TABLE order_flow_count ( id TINYINT PRIMARY KEY, total BIGINT NOT NULL DEFAULT 0 ) ENGINE=InnoDB;业务写入order_flow时,在同一事务里更新计数表:
START TRANSACTION; INSERT INTO order_flow (order_no, user_id, status, amount) VALUES ('xxx', 1001, 0, 99.00); UPDATE order_flow_count SET total = total + 1 WHERE id = 1; COMMIT;删除和状态变更也要同步更新。比如业务上把订单状态改为 9(逻辑删除),就执行total = total - 1。这里最关键的是一致性:INSERT和UPDATE必须在同一事务内完成,否则并发下计数就会漂移。
优化后的计数查询变成:
SELECT total FROM order_flow_count WHERE id = 1;这是一条走主键的单行查询,耗时稳定在 1ms 以内,接口响应时间从秒级降到了毫秒级,问题直接解决。
4.3 这个方案背后的三个坑
方案简单,但直接照搬很容易踩坑,我逐个说:
坑一:事务没提交,计数先改了。如果你把UPDATE order_flow_count放在事务外面执行,那么一旦事务回滚,计数表已经 +1 了,而业务数据没有写入,账就对不上。必须把两个操作放进同一个事务。
坑二:别忘了状态变更的联动。业务里凡是会改变"被计数条件"的 UPDATE,都必须同步调整计数。这个逻辑分散在代码里,容易漏。我后来用了一个统一的服务层方法封装这些写入操作,避免业务方绕过。
坑三:并发 UPDATE 同一行计数表的锁竞争。所有事务都在更新同一行,这一行会成为性能热点。高并发写入场景下,这一行的行锁竞争可能比 COUNT 全表扫描更严重。如果写入量非常大,可以考虑把计数拆成多个桶,比如 100 个分桶,每次随机更新一个桶,查询时用SUM(total)聚合,分摊单行锁压力。
4.4 缓存计数的经验补充
除了计数表,Redis 缓存也是常见做法。我实践后的意见是:如果是金融、订单这类强一致场景,以数据库计数表为准,Redis 只做读缓存;如果只是内容平台的帖子数、阅读数这类允许丢失一些计数的场景,用 Redis 自增更轻量。
但用 Redis 有一个隐患:计数器在 Redis 里,数据源却在 MySQL,两边的数据一致性很难保证。我见过一个方案是在 MySQL 里保存一个基线值,Redis 里保存增量值,查询时两个相加。这个方案可以显著降低写压力,但实现复杂度也上来了。
推荐顺序:先计数表,后 Redis,最后才考虑复杂的一致性方案。计数表实现最简单,一致性由数据库事务保证,是性价比最高的起点。
5. 大表不想精确 COUNT 时,这几条路都行
不是所有业务都需要精确行数。列表页的"共 xx 条"、管理后台的概览数字,很多场景下允许有一定误差。这种时候仍然傻乎乎地全表 COUNT,就是给数据库上刑。
5.1 估算行数:用统计信息救急
MySQL 的系统表information_schema.tables里有一个table_rows字段,反映的是优化器估算的行数:
SELECT table_rows FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'order_flow';这个数字来自 InnoDB 的统计信息,是采样估算值,不是精确值。数据量越大,估算偏差可能越明显,但作为趋势参考够用。我在做监控大盘时经常用它来观察表增长趋势,替代高频 COUNT 查询。
注意:这个查询本身走的是元数据,速度极快,不会触碰数据页。但它不适合作为业务侧核心数据的精确展示,只适合做内部估算。
5.2 用 EXPLAIN 看 COUNT 的执行计划
遇到 COUNT 慢查询,第一件事就是看执行计划。我常用的一个方法:
EXPLAIN FORMAT=JSON SELECT COUNT(*) FROM order_flow WHERE status = 0;重点看几个字段:
rows:优化器估算的扫描行数,能看出是否扫全表filtered:过滤后剩余行的比例key:用到的索引Extra:是否出现Using where、Using temporary、Using filesort
如果是全表扫描,rows会和全表行数接近。这时候要么补索引,要么考虑改写语句。
有一个值得提的细节:COUNT(*)在 InnoDB 下即使加了 WHERE,也未必能通过索引覆盖快速完成。比如WHERE status = 0,如果status上有二级索引,MySQL 会扫描该索引并逐行判断,这时扫描量等于满足条件行数对应的索引条目数,比全表好,但也谈不上极快。要追求极致性能,还是得回到计数表或估算思路。
5.3 information_schema 与实际值误差能接受吗
实际生产中,我用information_schema估算过一个亿级表,table_rows显示 9800 万,而实际精确值是 1.05 亿,误差大约 7%。对于监控、容量规划、趋势展示完全够用。但如果产品经理要求精确到个位数的"实时总量",那就别指望这个字段了。
在团队内部我定过一个规矩:业务面向用户展示的计数,必须来自精确来源;内部报表、监控、运维侧允许使用估算值。这样既保住体验,又不会把数据库拖垮。
6. 常见问题与排查技巧:COUNT 翻车实录
6.1 COUNT 长时间不返回,卡在哪儿了
场景:执行一条全表COUNT(*),等了十几秒没反应。多半原因有几种:
- 表数据量巨大,扫描本身就需要几分钟
- 被其他事务的锁阻塞了(
SHOW PROCESSLIST查看State字段) - 磁盘 IO 能力不足,数据页读不出来
排查顺序:先EXPLAIN看扫描量,再SHOW PROCESSLIST看是否在等待锁,最后用iostat或监控系统看磁盘 IO。我在生产环境遇到过一个案例,问题不是扫描量大,而是另一个长事务持有大量行锁,COUNT 扫描到那些行时被迫等待锁释放,最后连带阻塞了一连串查询。
6.2 COUNT 和 LIMIT 搭配的经典分页坑
分页查询最常见的写法是:
SELECT COUNT(*) FROM table WHERE 条件; SELECT * FROM table WHERE 条件 LIMIT 10, 20;这两条 SQL 的执行路径完全不同:第一条要全量扫描符合条件的行,第二条只需要扫描到第 30 行就可以停止。所以"列表页第 1 页很快,但总数接口很慢"是常态。这也意味着分页性能瓶颈往往在 COUNT,不在 LIMIT。
优化方向有几个:
- 如果总数不需要精确,用估算值替代
- 改用"上一页/下一页"的键集分页,彻底去掉 COUNT
- 把 COUNT 放缓存或计数表
键集分页是我比较推荐的做法:不计算总页数,只根据上一页最后一条数据的排序字段往下取。比如WHERE id > 上一页最大id ORDER BY id LIMIT 20。这种方式的查询可以走索引快速定位,数据量大时性能稳定得多。
6.3 COUNT(字段) 导致的全表扫描
有经验的 DBA 一眼就能判断:COUNT(字段)比COUNT(*)慢,原因有两个。第一个是要逐行判断字段是否为 NULL,多一步判断;第二个是如果字段没有索引,必须回表取字段值,也就是全表扫描加随机 IO。
我处理过一个慢查询:
SELECT COUNT(memo) FROM orders WHERE create_time > '2024-01-01';memo字段没有索引,且大量行的 memo 是 NULL。这条查询扫描了所有符合条件的行,还要逐一去数据页读 memo 的值。优化方案是改成COUNT(*),因为业务关心的就是这段时间的订单数,不关心 memo 是否为 NULL。一条语句的改动,性能直线上升。
6.4 COUNT 结果在不同时间点不一致
同一个表,连续执行两次COUNT(*),结果不一样。这不一定是有 Bug,而是事务隔离级别在起作用。默认的REPEATABLE READ隔离级别下,同一个事务内的多个查询看到的是同一个一致性快照,不会出现差异。但如果两次查询在不同事务里执行,期间有其他事务提交了新数据,第二次当然会多一些。
如果是在监控或报表里发现"数字对不上",先确认是不是跨事务查询,再看有没有并发写入,最后检查代码是否隐式提交了事务。很多时候不是 SQL 错了,而是执行环境变了。
7. 我把外文结论翻译回实践后,最大的体会是什么
那篇关于 counting-rows 和 function_count 的外文资料,最核心的一个观点其实只有一句话:精确计数是有成本的,而成本随数据量增长,你必须为它设计专门的方案。翻译完之后我反过来审视自己维护的系统,发现很多 COUNT 慢查询都可以归为同一个问题:想用最简单的方式获得最准确的结果,却又希望它有最好的性能,这在 InnoDB 的架构下是做不到的。
我最后再分享一个个人习惯:对于任何可能需要高频 COUNT 的表,建表之初就要想清楚"这个数字将来怎么算"。不要等到数据量涨上去了再补救。方案无非三种——精确计数走计数表、允许误差走估算、大分页走键集分页。提前设计好,后面就不会被慢查询追着跑了。