MySQL「06」COUNT() 实现方式及区别

328 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第06天,点击查看活动详情

SELECT COUNT(*) FROM T_NAME 是我们开发过程中常用的一种统计表中记录行数的语句。 它的工作原理是怎样的呢? 为什么当表记录数持续增长的时候,它变得越来越慢了呢? 今天我们就来一起学习下 COUNT(*)COUNT(1)COUNT(COLUMN) 的实现方式及它们之间的区别。

01-COUNT(*) 的实现方式

首先,需要明确的是 COUNT() 方法的语义是: 它是一个聚合函数,对于返回的结果集,一行行的判断,如果函数入参不是 NULL,累计值就加1,否则不加,最后返回累计值。

其次,不同的存储引擎实现 COUNT() 方式是不同的。 例如,MyISAM 就是将表中记录总行数存储在磁盘上,当遇到 COUNT() 方法时,直接将此值返回。 InnoDB 中由于对事务的支持及 MVCC,每个事务看到的 COUNT() 值可能是不同的,所以不能采用与 MyISAM 一样的方式。

01.1-InnoDB 中 COUNT() 的实现方式

由于对事务的支持及 MVCC 机制存在,不同会话中的事务执行 COUNT() 统计行数时,可能返回不一样的结果。 下面我们将举例说明。

假设我们有一个 person_info 表,它的 DDL 如下:

CREATE TABLE `person_info` (
  `id` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `name` varchar(100) NOT NULL,
  `sex` char(1) NOT NULL,
  `phone_number` char(11) NOT NULL,
  `country` varchar(100) NOT NULL
) ENGINE=InnoDB;

初始情况下,person_info 表中有 10000 条数据。

此时我们开启三个会话,session_a\session_b\session_c,它们的内容及启动时机如下:

-- session_a
BEGIN ; -- 01
SELECT count(*) FROM person_info; -- 02   
-- 10000

SELECT count(*) FROM person_info; -- 07
-- 10000
-- session_b
BEGIN ; -- 04
INSERT INTO `person_info` values(4, 'zhangsan', now(), '13162110002', 'Japan');  -- 05
SELECT count(*) FROM person_info; -- 06
-- 10002
-- session_c
INSERT INTO `person_info` values('lisi', now(), '13162110001', 'China');  -- 03

SELECT count(*) FROM person_info; -- 08
-- 10001
  1. 首先 session_a 先开启一个事务。 由于 MySQL 中默认的事务隔离级别为可重复读,所以不管其他事务怎么修改数量,它读取到的记录数量永远是10000(02、06)。
  2. 在 session_a 执行过一次 count(*) 查询后,session_c 插入了一条数据(03)。 其没有显式地开启事务,且默认情况下 autocommit = 1,所以一条语句就是一个事务。
  3. 之后,session_b 开启一个事务,此时03中事务已提交,所以在 session_b 开启的一致性读视图中,数据条数有10001条。 然后,它插入一条数据(05),再查询数据(06)得到结果10002,事务尚未提交。
  4. session_a 和 session_c 中进行查询使用得是各自的一致性读视图,所以结果不相同。

01.2-COUNT(*)、COUNT(1)、COUNT(COLUMN) 性能比较

先说结论:COUNT(*) =~= COUNT(1) > COUNT(COLUMN)

COUNT(*) 在查询时被替换成了 COUNT(0),所以与 COUNT(1) 近似的性能。 可以通过以下命令验证:

explain select count(*) from person_info;
show warnings;
+-------+------+------------------------------------------------------------------------+
| Level | Code | Message                                                                |
+-------+------+------------------------------------------------------------------------+
| Note  | 1003 | /* select#1 */ select count(0) AS `count(*)` from `mydb`.`person_info` |
+-------+------+------------------------------------------------------------------------+

COUNT(0)COUNT(1) 的实现原理是:

  • 在所有的二级索引中,找空间最小的索引,遍历叶子节点,但不会取值。Server 层对返回的每一行都将累计值加1。
  • 找空间最小的索引的原因是,空间越小,每页存储的索引就越多,加载页到内存的操作次数就越小,遍历效率就越高。

COUNT(COLUMN) 的实现原理是:

  • 在二级索引中(如何可以),或聚簇索引中,遍历所有的记录,取对应的 column 值返回给 Server 层。 Server 层判断值是否为 NULL,不是则累计值加1,否则不加。
  • 如果 column 为主键的化,可以选择一个最小的索引数去遍历,所以 COUNT(KEY_COLUMN) > COUNT(NON_KEY_COLUMN)

02-保存表记录总数的设计方案

使用 InnoDB 情况下,既然使用 COUNT(*) 获得记录行数随着记录数的逐渐增多,遍历代价会越来越大。 一个头痛医头的解决方案就是,将表记录的总数量记录在缓存中,每次插入、删除记录时,同步更新行数。 接下来我们将分析下这个方案。

02.1-方案一:MySQL + Redis

使用 Redis 缓存记录总行数,首先面临的第一个问题是缓存丢失问题。 不过这个问题可以通过持久化解决,或者在 Redis 奔溃重启后,再次执行 COUNT(*) 去统计下记录数并缓存在 Redis 中。

这里仍然存在着数据不一致的风险。 MySQL 和 Redis 是两个独立的组件,要保证数据一致性面临分布式事务问题,即更新数据库、更新 Redis 这两个操作如何保证原子性。 为解决这个问题,可以将行数存储在 MySQL 中额外的表中。 接下来,我们将介绍这个方案。

02.2-方案二:MySQL + 额外的表

将表记录中行数存储在 MySQL 表中,可以解决存储在 Redis 中遇到的分布式事务问题。 将插入数据、更新行数放在一个 MySQL 事务中,可以解决数据不一致问题。

如果不需要特别精确的记录行数,可以使用近似值。 例如,通过百度搜索时,结果数就是个近似值。

img_mysql-approximate-value.png

获取近似值的方式:

  1. explain,结果中的 rows 就是一个近似值。
-- explain select * from person_info;
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table       | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------+
|  1 | SIMPLE      | person_info | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    4 |   100.00 | NULL  |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------+
  1. show table status like 'person_info'; 结果中的 Rows 就是一个近似值。
-- show table status like 'person%';
+-------------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+--------------------+----------+----------------+---------+
| Name        | Engine | Version | Row_format | Rows | Avg_row_length | Data_length | Max_data_length | Index_length | Data_free | Auto_increment | Create_time         | Update_time         | Check_time | Collation          | Checksum | Create_options | Comment |
+-------------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+--------------------+----------+----------------+---------+
| person_info | InnoDB |      10 | Dynamic    |    4 |           4096 |       16384 |               0 |        16384 |         0 |              5 | 2022-12-01 10:54:42 | 2022-12-01 10:54:42 | NULL       | utf8mb4_0900_ai_ci |     NULL |                |         |
+-------------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+--------------------+----------+----------------+---------+