最近在客户现场讨论数据对账问题,大家对24小时内订单数量统计方法进行了深入的讨论,特别是有同学提到,这还不简单,我们只需要通过一条select count(*) from order就能获得订单表的计数,不需要那么麻烦的,比起来通过外部缓存redis来保存计数的值,并且实时刷新,需要的时候直接读这种方式简单多了。那么问题是,这两种方式是否存在差异,或者说那种更加适合(容易实现,风险低)?
为了搞清楚这个事情啊,我周末花费了一下时间,在本地进行了测试,这篇文章就是基于测试的结果,来聊聊count(*)语句到底是如何执行的,我们除了可以通过count(*)来统计,那么使用count(order_id)有什么不同,如果在项目中频繁的这种计数的业务需求,那么哪种方式会更好?
我之前提过MYSQL有两种存储引擎,在早期版本上,MYISAM为默认的存储引擎,最近的版本都是以InnoDB作为默认的存储引擎,也就是你不特别指定的话,大家现在创建表的时候,都是采用的InnoDB引擎,这里需要注意,因为我们是在创建表的时候,或者alter表的时候指定特殊的存储引擎需求,因此也就是说一个库下边,每个表可以有不同的存储引擎。
而MYSQL在执行count(*)这条语句的时候,还真的在不同的存储引擎上是不一样的: 1, MYISAM引擎会把一个表的总行数保存在磁盘上,这样当我们执行count的时候,直接从磁盘上读取;2,而InnoDB引擎就没有这种优化,而是按照我们“预期”的方式,从磁盘上读取每一条记录,然后进行计数统计。
说到这里可能会有同学吐槽了,为啥InnoDB引擎不实现这个优化,这不是显而易见的有点吗?我们是不是应该直接用MYISAM?其实MYSQL引入这个插件式的外部存储引擎比起自带的MYISAM引擎,提供了事务支持,并发能力控制,以及数据安全等高级特性,但是就是count这个操作,InnoDB的执行性能略差,并且随着表的数据量越来越大,如果按照上边这种执行方式,会越来越慢。
虽然说MYISAM引擎把数据表的总数单独进行了保存,但是你可靠考虑一下这个查询: select count(*) from order where createTime>'123456789'; 很不幸的是,这条语句在MYISAM上并不能直接返回保存的数量,因为我们使用了查询条线,因此这句SQL的执行,和InnoDB的执行就无二了,也是一条一条的扫描,然后通过where条件进行对比,符合条件,计数就+1,直到最后一条记录。
那么问题就来了,为什么InnoDB不优化这个计数的查询呢?或者说为啥InnoDB引擎不在数据库里记录比如说订单表的总数呢?其实这里的根本原因是因为InnoDB引入了对事务的支持,而MYSQL的InnoDB事务实现原理中,有一个叫MVCC的概念,也就是多版本并发控制,这个词看起来很复杂,我试图用几句话把这个概念说清楚。
工作年限长的同学应该有找工作的经历,特别是有过裸辞经验的同学,在找工作的时候,会和多个意向雇主在谈,而你提供给每个意向雇主的个人履历信息+现在找工作的状态就可以称作是不同的版本,举个例子:意向雇主A,意向雇主B,和小Q,小Q目前的进度是雇主A已经到了谈offer阶段,而雇主B正在发offer阶段,对于小Q来说(数据库的一条记录,找工作状态其实有两个版本),充分发挥自己的多版本控制能力,让两个事务雇主A和雇主B只能看到自己应该看到的版本,而小Q在这里施加的魔力,就是多版本并发控制。
正是因为有这个MVCC的存在,假设订单库有1000条记录,如果我们在订单数据库上执行两个事务,事务A比事务B先开始,并且启动后先查一次count,结果是1000;而事务B启动后,插入一条记录,然后执行一次count,结果是1001,符合预期,然后事务B提交,这个时候事务A在提交之前,再查询一次count,你觉这个数字应该是多少?其实如果你理解了刚才找工作的例子,很容易就能理解事务在第二次查询count的时候,结果是1000。
为什么?如果这个结果不符合你的预期,或者你蒙的,想知道背后的原理,我们继续来分析。搞数据库的同学,应该听过说事务的隔离级别,而MYSQL的默认隔离级别(InnoDB引擎)是可重复读,很多人对于四个隔离级别总是分布清楚,其实是你太复杂了,可重复读的意思不言而喻,就是可以重复读,那么换个可以重复读具体是从哪里到哪里可以重复读呢?
在可重复读的隔离级别下,事务在开始的时候,会生成一个一致性视图(consistent view),这个略技术性的概念还有另外一个名字,叫read view,其实read view这个名字不好,我觉得应该叫read-only view,也就是事务启动的时候给数据库拍个照,那么快照在整个事务期间就不会动,即便同时有多个事务在进行CUD操作,但是对于事务A来说,看到的是一个一致性的,或者只读的视图。
这个就厉害了,但是你可能回头一想,不对啊,我的数据库有2个T,你是如何能给它高效的拍个照,作为用户还感受不到?因为一个数据库上并发访问的量会非常可观,如果有100个这样的事务,岂不是要拍100次,不合理啊,无法理解。
在继续介绍如何拍照之前,我觉得事务这个概念有必要吐槽一下,事务在英文单词中叫Transaction,而这个词其实是和交易相关,而交易其实更容易体现出ACID的属性,因此我一直认为事务描述的ACID比较抽象,大家要从因为单词的角度来理解,会更加容易一些。
我们继续快照这个事情,当然MYSQL不能每次启动一个事务,都拷贝一次数据。其实啊,MYSQL在快照的时候,结合前边描述的MVCC的概念,只要大喊一声“我要开始执行了,我只能看到事务ID4之前所有移交提交事务,之后的版本(有更大的事务ID,因为事务ID是单调递增),以及之后提交的版本我都看不到”,其实这里的大喊一声,对应到数据库中,就是记录一下事务ID而已。
这也是为什么我一直建议给订单表增加版本号,也是为了让订单数据能够保持这种单调递增, 特别是在并发的情况下,当然通过更新时间也可以做到,但是时间本身在分布式系统中存在扭曲的可能性,因此对数据一致性要求比较高大规模分布式系统,一般不会用时间来保持单调递增,因为时间物理上是单调递增,但是多个单调递增的时间源,就可能出现时光逆转,造成数据一致性的问题。
总结一下,上边罗里吧嗦的一堆就是想说明,MYSQL不单独保存这个计数值是有原因的,并不是懒惰,而正是因为这个原因,count这个看起来貌似很天真无邪,童叟无欺的操作,给很多人造成了困扰。
我必须澄清一下,虽然说MYSQL的InnoDB引擎执行count看起来智商不在线,但是也尽了努力做了优化。在MYSQL中,数据既索引,索引既数据这10个大字要牢记啊,因此我们查询数据其实都是在索引这棵B+数上做搜索。
而一般的表都会有主键索引和非主键索引,主键索引也叫聚簇索引,这个索引上放的就是以主键为序的所有数据,举个例子,如果我们的订单表上有1000个订单数据,那么这些数据存在于主键索引的B+树的叶子节点上,并且从左到右保持有序存储。
除了主键索引,我们还有非主键索引,而非主键索引又叫做二级索引,二级索引其实也很容易理解,就是二级的,次级的,而这里“二”的主要意思是:不好存数据,只保存主键值;那么就可以看到二级索引上B+树的叶子节点上,除了索引构建的列的数据之外,还有主键值。
除了这两个概念,大家还听说过唯一索引,覆盖索引,联合索引概念。我特别说一下覆盖索引,覆盖索引是一个二级索引,纯粹是为了让查询的时候,尽量不用去回表(拿着主键ID去主键索引的B+树上找其他行的数据)而创建的,本质就是空间换时间的把戏,当然所有除了主键索引的二级索引,都是空间换时间的把戏。
回到count操作,其实MYSQL的优化就是,因为每个索引其实叶子节点是相等的(你可以仔细考虑一下,因为每一条记录都会对应一个索引节点,或者说任意B+树的叶子节点,因此你的数据库有1000条记录,那么任意一个索引都会有1000个叶子节点),因此MYSQL会选择高度更低的B+数来遍历,而B+数的高低,主要是由索引保存的数据量决定的,这么看起来的话,如果在订单表上,我们除了主键索引,还有基于用户年龄age的二级索引,那么这个count操作大概率会在age这个索引的B+树上执行,因为B+树越低,一个数据也的记录越多,一个页面的数据记录越多,这棵树总数据页越少,而总数据页越少,扫描的额外开销越低。
有点复杂了,总之大部分情况下,我们执行count操作其实问题不大,因此很不幸的是,我们在MYSQL的InnoDB引擎上,count操作就是这么执行的,很明显我们需要有一些优化的措施,要不然在高并发的情况下,每次都这么进行一次全表扫描,也受不了啊。
关于我们有哪些可选的方案来提升count操作的性能,下文继续介绍吧。其实也没有啥好办法,在MYSQL这种现状下,只能自己计数了。