ARTICLE DETAIL

资讯详情

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

MySQL count函数性能优化:从慢查询到索引设计实战

MySQL count函数性能优化:从慢查询到索引设计实战 聊一聊MySQL里的count函数。最近排查一个线上慢查询最后定位到是一句count写的太随意扫了上千万行才返回结果。这种事情并不少见很多同学平时写count都是“能用就行”执行计划不看、走了哪种扫描不关心等到表涨到一定量级才发现不对劲。这篇东西不打算跟教科书一样把count的语法列表搬一遍而是从一个真实排查场景出发把count的底层实现、各种写法的差异、索引怎么影响效率、以及我踩过的坑一起捋一遍希望能帮你在写SQL的时候多一点本能的警觉。内容适合MySQL的使用者和维护人员不管是后端开发、DBA还是刚入门数据分析的读者都能从中找到有价值的部分。大批量数据下count慢不慢很多时候并不取决于你“会不会写count”而是取决于你是否理解它是怎么数出来的。理解了这个很多性能问题都能提前避免。1. count到底是干什么的它在MySQL内部的执行逻辑远比你想象的复杂1.1 count函数的工作机制从“数行数”到“查存储引擎”先说最基础的语义count()是个聚合函数用来统计符合条件的行数。大部分人的理解就到这里但实际上MySQL里count的执行路径是有“两层”的一层在server层一层在存储引擎层。当你执行一段SELECT COUNT(*) FROM tMySQL并不是闭着眼睛从表的第一行开始数到末尾。如果这张表有辅助索引优化器很有可能会选择扫描一个“体积最小”的索引来完成计数而不是扫描主键索引也就是聚簇索引。为什么聚簇索引的叶子节点保存的是整行数据占用的空间大扫描起来磁盘IO和内存消耗都更大而二级索引的叶子节点只保存索引键值加主键值比整行数据小得多同样扫描完整个索引逻辑IO会少很多。MySQL优化器是按代价估算来选择执行计划的在InnoDB存储引擎下count的代价模型里有一条很关键的逻辑能走二级索引就别走聚簇索引因为后者的索引页数量通常更大。这里牵扯出一个重要的底层设计InnoDB和MyISAM在count上的表现是完全不同的。MyISAM会把整张表的行数存在表的元数据里所以不带where条件的COUNT(*)它可以直接从元数据里取出来速度极快。注意这里有一个关键前提MyISAM没有事务且不支持行锁所以它能直接存“表的总行数”。InnoDB支持事务有MVCC机制同一个时刻不同事务看到的数据版本不一样因此它不能维护一个全局的实时行数。事务隔离级别下事务A看到的行数可能跟事务B看到的行数不同。如果InnoDB也像MyISAM那样直接返回一个存好的数字那这个数字在哪个事务版本下有效没法说清楚。正因如此InnoDB只能老老实实去扫描索引、逐行统计这也是InnoDB下无where条件的count在百万级数据量时会明显变慢的根本原因。所以当你听到“MySQL的count很慢”这句话要分清说的是哪种存储引擎。绝大多数场景下我们用的是InnoDB就必须接受“count不能直接查元数据”这个设定。理解了这套机制你就能明白后面所有优化手段的来源。1.2 count算法的选择MyISAM的“计数器”和InnoDB的“实时扫描”为什么有这么大差距这里把MyISAM和InnoDB的count差异再展开讲一下因为我发现这个点很多面试题爱考实际工作中也容易踩。MyISAM的元数据行数只对不带where条件的count有效一旦加了where它依然需要扫描。很多人对“MyISAM查询快”有误解以为加where也快其实不是。InnoDB的实时扫描的具体过程是这样的拿到一个Read View之后按照可见性判断规则逐行检查当前行的最新版本是否对当前事务可见如果可见就累加。这个“可见性判断”是InnoDB在count时最大的性能开销之一。每行数据在聚簇索引里都有trx_id字段用来记录最后一次修改它的事务ID还有roll_pointer指向undo日志。计数时要比较行的trx_id和当前Read View的活跃事务列表判断能否看见这行数据。这个逻辑本身就是CPU密集型的操作。另外如果一张表的二级索引很多优化器到底选哪个索引来做count也是值得琢磨的。通常情况下优化器会选择一个“基数”不是最重要、但索引页数量最少的索引。你可以用EXPLAIN看看执行计划我在实际中见过明明有联合索引优化器却选了一个只有单个字段的普通索引来count因为那个索引的叶子节点更少扫描成本更低。这种选择逻辑跟业务查询的索引选择是两回事但常常被人忽略。从算法的角度总结一张表存储引擎无where的count(*)有where的count(*)数据来源MyISAM直接读元数据扫描统计元数据维护的总行数InnoDB扫描某个索引统计扫描索引并按条件过滤统计实时计算基于MVCC可见性从这个表能看出InnoDB下不管有没有where只要优化器不能直接从某个统计信息拿到结果它都得扫描。这也就解释了为什么数据量一上来SELECT COUNT(*)会把数据库CPU打高。2. count(1)、count(*)、count(字段)到底差多少以及索引在其中的决定性作用2.1 写法决定不了性能扫描方式才是关键在MySQL里执行SELECT COUNT(1) FROM t和SELECT COUNT(*) FROM t性能上到底有没有差别很多老博客说count(1)比count()快这个说法在我接触的MySQL 5.7、8.0版本里是不成立的。这两个写法在InnoDB中的执行计划几乎完全一致优化器会直接把count()翻译成count(0)或者count(1)来处理区别只是表达式不同扫描的行数、访问的索引、返回的结果没有任何本质区别。真正有本质区别的是COUNT(字段)。这里的字段是否允许NULL直接影响计数结果。count(字段)统计的是“该字段值非NULL的行数”。如果字段是允许NULL的那么有NULL的行不会计入总数。这是一个很隐蔽的坑因为很多人在意的是“字段叫user_id总有值吧”但实际数据里万一有NULL你得到的数字可能跟count(*)差了十万八千里。我曾经排查过一个数据对不上的问题最后发现就是因为一个报表SQL用了count(某个可空字段)而业务上认为这个字段必有值实际历史数据里有几千条NULL导致两个口径差了几天没找到原因。从性能角度来看count(字段)和count()、count(1)的真正差别不在写法而在于字段上有没有索引。如果字段没有索引MySQL只能扫描聚簇索引然后逐行判断字段是否为NULL这个开销比选用一个二级索引做覆盖扫描要差很多。反过来说只要能利用到某个覆盖索引的二级索引页不管写的是count()还是count(字段)性能都是OK的。有一个关键点需要说明当你在一个带有where条件的查询中用count(字段)优化器会先根据where条件过滤出符合条件的行集再在这个行集中统计字段非NULL的数量。如果where里用到的索引本身覆盖了count的字段那执行效率会好很多否则可能需要回表代价立刻升高。2.2 覆盖索引如何让count“秒回”以及在实际排查中怎么验证覆盖索引这个词通俗地解释就是查询需要的所有列都包含在同一个索引里InnoDB可以直接从头到尾扫这个索引的叶子节点不需要回表。举个例子表结构如下CREATE TABLE order_record ( id bigint NOT NULL AUTO_INCREMENT, user_id int NOT NULL, status tinyint NOT NULL DEFAULT 0, amount decimal(10,2) NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB;此时执行SELECT COUNT(*) FROM order_record WHERE user_id 123where条件命中的是idx_user_status这个联合索引联合索引的叶子节点包含user_id、status和主键id。Count只需要统计满足条件的索引条目数完全不需要再去聚簇索引里拿整行数据这就是覆盖扫描。此时count的代价主要在于扫描多少个索引页索引页里每一条记录都很短IO效率比扫聚簇索引高得多。我在排查慢查询时会用三步验证先EXPLAIN看type和key再看Extra里有没有Using index最后看扫描行数rows是否合理。如果看到Using index说明查询正在走覆盖索引这是count查询最理想的状态。如果EXPLAIN里显示Using where; Using index也问题不大说明过滤和统计都发生在索引页上。如果只显示Using where就要警惕回表了特别是where条件过滤出来的结果集很大时回表会带来大量随机IO。这里额外说一句EXPLAIN里的rows只是一个估值不是精确值但在对比不同索引方案时足够说明问题。就我的实际操作经验来说想优化count查询第一反应不该是改count(*)为count(1)而是确认有没有合适的索引能让扫描的页数更少。如果业务上有大量按user_id统计行数的需求给user_id建一个单独索引或者把它作为联合索引的最左列收益会比纠结count写法大得多。3. count在不同业务场景下的实战经验从单表计数到分页统计3.1 大表count的常见优化策略以及一个可用但需要权衡的方案既然InnoDB的count做的是实时扫描那面对几千万行、甚至上亿行的表怎么统计总行数才快这是很多团队会遇到的问题。我总结过几种常见方案各有取舍。第一种是走近似值。如果业务只关心“大概多少行”直接查SHOW TABLE STATUS里的Rows字段或者查information_schema.TABLES中的TABLE_ROWS速度极快。但这个值是估算值误差可能达到百分之几十官方文档也明确说这是估算值不适合精确统计场景。适合用在后台管理系统展示“数据总量大约xxx条”这种不需要精确的地方。第二种是缓存总行数。在Redis里维护一个计数器业务每次插入、删除数据时同步更新这个值。这个方案在并发较高时容易遇到一致性问题删数据的时候计数器更新失败怎么办插入事务回滚了计数器怎么补偿所以通常需要配合定时任务对账来修正。我见过不少团队这么干最后都因为对账逻辑太复杂而放弃。如果业务能接受最终一致并且数据量级确实大到实时count扛不住这算是一个权衡方案。第三种是汇总表。在业务侧维护一张统计表比如按天汇总每天新增多少行、删除多少行查询时把汇总结果累加。这种方案比Redis缓存更可靠因为它依托数据库事务可以在同一事务里写入业务数据并更新汇总表保证一致性。缺点是写入路径多一张表的更新写入性能会受影响。适合读多写少、且对总数精确性要求高的场景。这三种方案我都实际见过有人用没有绝对的好坏关键看业务对实时性、精确性、复杂度三个维度的取舍。用一个简单的表来对比会更直观方案精确性实时性实现复杂度适用场景直接count精确实时无中小表千百万级估算值不精确实时无展示“约xx条”统计报表概览Redis计数最终一致准实时中超大表可接受短暂误差汇总表精确实时较高对一致性要求高且有写入容忍度3.2 分页场景中的count为什么COUNT(*)会在有where时偶尔比预期慢分页接口里最常见的SQL模式是先COUNT(*)取总量再SELECT ... LIMIT取当前页数据。这个模式在小表上没有任何问题但大表上会有几个值得注意的性能隐患。第一个隐患是count的where条件和limit查询的where条件一致但优化器可能为两条SQL选择不同的索引。为什么呢因为count只需要统计行数不需要排序也不需要回表取列优化器可以选择一个“扫描页数最少”的索引而limit查询因为要返回具体字段可能需要回表优化器会更倾向于选择过滤效果最好的索引。这个差异本身没问题但如果你在count上看到扫描行数比预期高很多要意识到这是优化器认为的最小代价路径而不是SQL写错了。第二个隐患是分页页数越深count的时间并没有变化变的只是limit的部分。很多人错把分页变慢归因于count其实是因为LIMIT 1000000, 20这种深分页在MySQL里要扫描前100万行然后丢弃只返回最后20行。这是经典深分页问题跟count无关。如果整体变慢了要分开定位。第三个隐患是where条件中包含非索引字段时count会对全表或大范围索引做扫描过滤。比如WHERE status 0 AND create_time 2024-01-01如果status的区分度很低索引选择会非常尴尬。MySQL可能先按create_time索引过滤出一批数据再对这批数据做status过滤。count要统计最终满足条件的行数就不得不扫描所有符合条件的create_time区间。对于这种查询我通常建议给联合索引但也不是无脑加三个字段的索引得看哪个条件过滤性更强。关于分页接口还有一个实用经验如果总量本身对用户没那么重要可以考虑不每次都count改用“下一页是否有数据”的方式即查询LIMIT page_size 1如果多出来一条说明还有下一页。这个技巧能砍掉一场count查询对某些接口的响应时间提升立竿见影。当然这要看产品需求是否能接受“不显示总页数”的交互。4. 我踩过的count相关的坑以及一份问题排查速查表4.1 几个典型故障案例复盘先说一个让我印象很深的案例。有一次线上系统慢查询告警定位到一条SQL反复出现SELECT COUNT(*) FROM user_login_log WHERE user_id ?。当时这张表有三千多万行user_id上有索引单用户的数据量一般只有几十条。按理说这个查询很快可它慢到让数据库CPU飙高。EXPLAIN之后发现优化器没走user_id索引而选了一个叫idx_create_time的普通索引去做扫描。为什么因为在优化器看来user_id的等值条件可能过滤性不可靠某个热门用户的数据量极大而扫描create_time索引的页数成本更低。它为了“全局最小代价”选了扫描大量无用数据的路径。这个问题的解决办法是建立一个(user_id, create_time)的联合索引让count能通过最左前缀快速定位目标用户同时利用二级索引完成覆盖统计。建完索引之后这条SQL从几百毫秒降到几毫秒。这个案例说明在count慢查询的优化里索引设计比SQL改写要重要得多。第二个案例是关于count字段为NULL造成的业务口径问题。有一次数据部门反馈报表里新用户数比前一天少了几千查了半天发现当天上线的代码把COUNT(*)改成了COUNT(inviter_id)。业务上inviter_id只有部分用户有值也就是没有邀请人的用户这个字段是NULL。改动之前统计的是“所有新用户”改动之后统计的是“有邀请人的新用户”数字自然会少。这种错误非常隐蔽尤其是团队里如果有“能用就行”的习惯很容易把count用错。第三个案例是count一个非常大的表没有任何where条件结果跑了十几秒。当时我的第一反应是去看这张表上有哪些索引发现只有一个主键索引。也就是说count被迫扫描聚簇索引而聚簇索引的叶子节点包含了所有列的数据页数非常多。我给这张表加了一个业务字段的单列索引让count可以选择扫描这个二级索引性能一下子提升了将近10倍。注意这种“加索引只是为了加速count”的做法对写入会有额外负担索引也不是越多越好。如果这张表写操作频繁新增一个索引之前要评估写入延迟。我当时选择的是一个本身就有查询需求的字段相当于一石二鸟。4.2 count相关常见问题速查以及排查时真正值得关注的细节把常见问题整理成一张速查表方便你在出问题时快速对照现象可能原因排查思路无where的count也很慢只有聚簇索引可扫索引页过多建一个小型二级索引让优化器可选count(字段)结果比count(*)少字段存在NULL值确认业务定义明确是否需要统一口径count查询走了错误索引优化器代价估算偏向扫描最小索引建联合索引或使用FORCE INDEX临时验证分页接口整体慢但count不慢深分页造成的回表与排序改游标分页或延迟joincount和明细SQL结果不一致两次查询之间数据发生了变更确认是否为同一事务或同一条SQL连查EXPLAIN显示Using filesortcount场景下不应排序可能是distinct检查SQL是否存在distinct或group by排查count慢查询时我最推荐从EXPLAIN开始但不要只看type和key还要看rows和Extra。rows是优化器估算值不代表真实扫描行数Extra里的Using index是关键信号说明查询可能只扫描索引页Using index condition说明部分条件下推到了索引层但可能仍需要回表Using where说明server层要额外过滤这时候就要看过滤掉的行比例大不大。真正确认扫描行数的方法是开启SET optimizer_traceenabledon;然后用information_schema.OPTIMIZER_TRACE看详细过程但在生产环境不建议频繁使用它会产生额外的trace信息影响性能。还有一个很多人问过的问题count时要不要用SQL_CALC_FOUND_ROWS我的态度很明确不建议用。这个功能需要MySQL扫描完所有符合条件的行才能得到total跟执行一次count的代价差不多在高版本MySQL里也已经被标记为废弃。想要总数就老老实实count或者用limit1的方案省掉总数。4.3 最后分享两个日常能直接落地的小技巧第一个小技巧是如果某个页面反复用到同一种count汇总不要每次都实时count可以建一个定时任务把结果落到一张统计表里。比如一个内容平台每天要展示“今日新增文章数”完全可以在凌晨跑一次汇总把结果存入报表表。这样白天查询只查一行数据性能损耗几乎为零。这个方案适合对实时性要求不高的场景。第二个小技巧是监控慢日志时把count相关的SQL单独归类。我日常会开启慢查询日志并设置long_query_time 1然后定期扫描慢日志凡是SQL文本以COUNT(开头的都单独记一类。这样时间长了你就有一个属于自己的“count高风险SQL清单”哪些表的count开始变慢了一眼就看出来。这个方法我用了很久比临时排查高效得多。从我个人的实践来看count函数学起来不难真正考验人的是数据库底层的执行细节和业务语义的边界。每一条慢的countSQL背后都藏着一个可以优化的索引设计或者一个值得重新审视的业务口径。理解了这两点你在MySQL的使用上会少踩非常多坑。
返回列表