前言
“为什么MySQL不将表总行数存起来,而要我们自己使用count(*)来获取表的总行数?”
这是我前两天跟同事讨论的问题,每次实现分页功能要获得表的总数时,我总是需要使用SQL中的count(id)函数来获得某表的总行数。
这是为什么?MySQL统计一下表的总行数不是很简单吗?
深究之下,才发现并不简单,本篇文章聊一下InnoDB引擎中count(*)函数的门道。
事务隔离
MySQL本身支持多引擎,而目前使用最广泛的是InnoDB引擎,该引擎并没有记录表的总行数,而MyISAM引擎却把表的总行数存在的磁盘中,当用户使用count(*)时可以直接将值返回,效率很高。
为什么使用更加广泛的InnoDB引擎反而不这样做?
思考一下MyISAM引擎与InnoDB引擎两者的区别。因为InnoDB支持事务,所以InnoDB无法将表的总行数存起来,而只能通过count(*)方法实时去取。
事务具有隔离性,InnoDB通过MVCC(多版本并发控制)实现不同事务隔离级别时的不同效果。
当MySQL中有多个事务在同时执行时,就可能会出现脏读(dirty read)、不可重复读(nonrepeatable read)、幻读等问题(phantom read),为了解决这个问题,才引入隔离级别的概念,而且隔离越级别越高(隔离的越严格),数据库效率就越低。
SQL标准的事务隔离级别有4种,分别是读未提交(read uncommitted)、读提交(read commited)、可重复读(repeatable read)和串行化(serializable),具体解释如下:
- 读未提交:当一个事务没有提交,那么该事务内做的变更其他事务是可以「看到」的。
- 读提及:当一个事务没有提及,那么该事务内做的变更其他事务是「看不到」的,只有当事务提及后,该事务的改变才能被其他事务看到。
- 可重复读:一个事务在执行的过程中,它看到的数据始终是该事务启动时所看到的数据,也就是说,在该事务执行的过程中,无论其他事务是否提及,变动了数据库,可重复读隔离级别下,都无法看到其变更。
- 串行化:串行的使用数据表中的数据,在该隔离级别下,写数据会加写锁,读数据会加读锁,当有多个事务时,若发生了锁冲突,那么后访问的事务就必须等当前持有锁的事务执行完成,才能继续。
4种隔离级别与数据准确性的关系:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提及 | 可能 | 可能 | 可能 |
读提及 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
串行化 | 不可能 | 不可能 | 不可能 |
InnoDB引擎默认使用可重复读这一隔离级别(RR级别),可以通过show VARIABLES like 'transaction_isolation';
命令查看引擎默认隔离级别。
假设现在有A、B、C三个事务需要获取表t的总行数,因为有隔离级别的存在,3个事务可能得到不同的结果,所以InnoDB引擎只能通过count(*)方法实时的计算出表的总行数。
有些朋友会通过show table status
命令获取表的总行数,该命令返回结果中的TABLE_ROWS确实表示某个表的总行数,而且该命令执行速度很快,但TABLE_ROWS字段无法代替count(*),因为TABLE_ROWS并不是实时准确的数据,根据官方文档说法,误差可能到达40%~50%。
但在准确要求不高的情景,确实是一种方法...
count(*)的优化
因为InnoDB引擎只能通过count(*)函数实时获取表的总行数,所以InnoDB对其做了相应的优化。
任何数据库,优化的大方向都是在逻辑正确的前提下,尽量减少磁盘读写次数以及减少扫描的数据量。
在InnoDB中,count()是聚合函数,它的作用就是对返回的结果集一行行的进行判断统计,如果count函数中的参数不为空,累计值就加1,最后返回最终的累计值。
目前,count函数的用法主要分为count(id)、count(某字段)、count(1)、count(*)这4种,下面分别看一下。
- 使用count(id)时,InnoDB会遍历整张表并将每一行的id值取出来返回给数据库的server层,server层拿到id后,判断id不可能为空,就按行累加。
- 使用count(某字段)与使用count(id)类似,不同之处在于server层拿到某字段后需要多做一步判断,判断该字段的值是否为空,不为空,才进行累加操作。
- 使用count(1),InnoDB遍历整张表,但不取值,server层对返回的每一行都放数据1进去,因为数字1不可能为空,所以直接按行累加。
从这3种用法可以看出,count(1)快于count(id),count(id)快于count(某字段),这是因为count(某字段)时比count(id)多了判断操作,而count(id)比count(1)多了server层解析返回的行数据以及拷贝字段值的操作。
最后说一下count(*),使用count(*)时,InnoDB同样遍历全表,但同样不会取值,因为优化器针对count(*)操作进行过优化,它的执行速度约等于count(1),最终。
count(某字段) < count(id) < count(1) ≈ count(*)
如何加快count(*)?
虽然InnoDB对count(*)操作进行过相应的优化,但随着数据表的快速的增大,count(*)因为要遍历全表,所以必然会越来越慢,对于一些需要实时知道个数的系统,比如交易系统,需要实时知道交易操作的总数,count(*)变慢是不可接受的。
如何解决这个问题?那只能自己统计。
当我们每执行一次插入操作时,可以进行计数,通常的做法是将这部分逻辑加到ORM框架中或数据库相关的中间件中,计数保存的位置通常为Redis或MySQL数据表中。
这里建议单独使用数据库,在数据库中单独创建一个表来存储表的总行数。
为什么不推荐Redis?并不是因为数据存到Redis中容易丢失,而是因为使用Redis计数可能导致统计的数值不准确。
先考虑一下Redis中数据丢失的问题,如果用户只需了insert操作,此时Redis中累加表总数并在一段时间后同步到磁盘中,但如果在之前Redis异常重启了,那么当Redis从磁盘中恢复数据后,此时表的总行数已经不准确了。
但这个问题还是可以解决的,在Redis异常重启后,执行一次count(*)方法将最新的表行总数存入Redis中,毕竟Redis不会经常异常中断,偶尔使用count(*)进行全表扫描统计行数还是可以接受的。
问题是Redis与MySQL是两个独立的数据库,就会出数值不准确的问题,如果执意要用,只能通过分布式锁,从应用层面将会话A与会话B弄成串行执行的形式。
比如现在有会话A与会话B两个会话,会话A在T1时刻向MySQL中插入了数据Q,在T2时刻,会话B查询了Redis中表的总行数,在T3时刻,会话A才在Redis中对表的总行数加1,此时会话B获得的总行数就不准确。
而在MySQL中创建单独的表来存放总行数就可以利用事务的特性将这个问题解决掉。
依旧是会话A与会话B两个会话,会话A在T1时刻开启事务并向MySQL中插入了数据Q,在T2时刻,会话B开启事务查询了行数记录表中某个表的总行数然后提交事务,T3时刻,会话A向行数记录表中对某表的总行数加一,然后再提交事务。
MySQL默认是可重复读,所以两个会话获得的数据是准确的。
结尾
最近对数据库的兴趣空前的高,购买了极客时间上MySQL与Redis相关的课程以及一些数据库相关的数据,包括自制数据库的????,本文的内容就参考了极客时间MySQL专栏中的内容,再次感受到「付费就是捡便宜」,如果让我自己探索count函数,要搞清楚,不知道要花多少时间。