为什么我调大了sort_buffer_size,并发量一大,查询排序慢成狗?

1,819 阅读3分钟

导读

在《啊?排序字段的大小也会影响排序性能???》我遗留了一个思考题:

SELECT * FROM user WHERE user_name LIKE "%am%" AND age >= 18 AND age <= 24 AND sex = 0 ORDER BY age, user_name LIMIT 0, 50

这条SQL使用了快速排序对age,username排序,有没有更好的办法,提升排序的性能?

你可能已经发现,这条SQL其实只需要取前50个排好序的用户,但是,上面的执行过程确对表中的1000条记录都进行了排序,如果我只对50记录进行排序,保证这50条记录就是排好序的前50条,从排序算法来看,是不是代价就小很多了呢?那么,我们怎么做到只对50记录进行排序,同时,这50条记录又刚好是排好序的前50条呢?

学过排序算法的同学可能已经猜到了,堆排序!是的,MySQL5.6开始引入了一个新的排序算法,叫做优先级队列排序,该队列使用的就是堆排序,保证只对有限数量n条的记录排序,同时,排序后的结果就是前n条记录。

关于堆排序,计算机相关专业的同学应该比较熟悉,在此,我就不详细讲述了。

PTMalloc

这里,我主要结合《单表数据规模达到多大时进行分表最佳?》这一章中《内存分配》这部分,来详细看一下上面排序过程中,MySQL申请内存的过程:MySQL默认使用ptmalloc内存分配器给进程分配内存。

数据结构

因此,我们先来看一下ptmalloc管理内存块所使用的数据结构:

image-20210311214925636.png

分配区

ptmalloc使用一个主分配区和多个动态分配区来管理内存,主分配区与非 主分配区用环形链表进行管理。如上图中的Main Arena表示一个主分配区,Dynamic Arena表示3个动态分配区,它们之间收尾相连,形成环形状。ptmalloc 根据系统对分配区 的争用情况动态增加非主分配区的数量,分配区的数量一旦增加,就不会再减少了。

在《单表数据规模达到多大时进行分表最佳?》中《内存分配》部分,我讲过内存分配器在自身没有可分配内存时,会向Linux系统申请内存,可以申请堆内存,也可以申请文件映射区的内存,所以,对ptmalloc来说,主分配区可以申请堆和文件映射的内存,而动态分配区只能申请文件映射区的内存。

当某一线程需要调用malloc()分配内存空间时,该线程先查看Linux内存中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存, 如果失败,该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁, 然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。

chunk

image-20210311215006241.png

ptmalloc会统一管理分配区空闲的内存块,当用户进行下一次分配请求时,ptmalloc 会首先试图在空闲的内存块中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。这里所说的内存块就是上图纵向用双向箭头连接的方块,我们叫它chunk,它们互相连接,组成一个双向链表每个链表的头部组成了bins数组,即图中水平方块组成的部分。bins被划分为4种类型:

  1. small bins:如上图,数组中从 2 开始编号的前 64 个 bin 称为 small bins,同 一个small bin中的chunk具有相同的大小。两个相邻的small bin中的chunk大小相差8bytes。 small bins 中的 chunk 按照最近使用顺序进行排列,最后释放的 chunk 被链接到链表的头部, 而申请 chunk 是从链表尾部开始,这样,每一个 chunk 都有相同的机会被 ptmalloc 选中。
  2. large bins:如上图,small bins 后面的bin被称作 large bins。large bins 中的每一个bin分别包含了一个给定范围内的 chunk,其中的 chunk 按大小顺序排列。相同大小的 chunk 同样按照最近使用顺序排列。

当chunk被释放并链接到 bin 中的时候,ptmalloc 会把表示该 chunk 是否处于使用中的 标志 P 设为 0(注意,这个标志实际上处在下一个 chunk 中),同时 ptmalloc 还会检查它前后的 chunk 是否也是空闲的,如果是的话,ptmalloc 会首先把它们合并为一个大的 chunk, 然后将合并后的 chunk 放到 unstored bin 中。要注意的是,并不是所有的 chunk 被释放后就立即被放到 bin 中。于是,这就引出了第三中类型的bin:unsorted bin。

  1. unsorted bins:unsorted bins使用 bins 数组的第一个,如上图头部。

上面提到了chunk合并,那么,我来看下这么一个场景:当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需 要从大的空闲内存中切分出一块,这样做是不是很低效?

因此,ptmalloc在分配过程中 引入了fast bins来解决这个问题。于是,出现了第4中bins:

  1. fast bins:当不大于 max_fast(默认值为 64B)的 chunk 被释放后,首先会被放到 fast bins 中,fast bins 中的 chunk 并不改变它的使用标志 P。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,ptmalloc 首先会在 fast bins 中查找相应的空闲块, 然后才会去查找bins中的空闲chunk。这样一来,用户进程申请小内存分配时,就可以直接从fast bins中获取,不用再切分大内存块了。

sort_buffer排序使用了ptmalloc内存分配器向Linux内核申请内存,由于ptmalloc分配内存时会对分配区加锁,高并发下锁争用更激烈,ptmalloc不得不为进程创建更多的分配区,由于分配区长时间不释放内存,导致 ptmalloc缓存的 chunk 数量增长更快,从而出现内存暴增,甚至溢出。对于MySQL这种存储大规模数据的数据库而言是不能接受的,因为如果在高并发场景下,MySQL不断创建越来越多的分配区来缓存更多数据,那么,势必很快用尽内存资源,内存耗尽后,不得不将更多数据写入磁盘,此时,数据查询的效率将出现大幅度的下降。那么,问题来了,有什么办法解决这个问题呢?

还记得我在《单表数据规模达到多大时进行分表最佳?》这一章节中提到过tcmalloc内存分配器,或许你已经猜到了,是的!用tcmalloc替换ptmalloc,下面,我们就从tcmalloc使用的数据结构开始,讲解一下其是如何解决锁竞争的问题的?

TCMalloc

数据结构

下图是tcmalloc使用的数据结构:

v2-af5ba79341db113855678eb11507dee5_1440w.jpeg

tcmalloc主要包含下面几个部分:

size class:对于256KB以内的小对象分配,TCMalloc按大小划分了85个类别(官方介绍中说是88个左右,但我个人实际测试是85个,不包括0字节大小),称为Size Class,每个size class都对应一个大小,比如8字节,16字节,32字节。应用程序申请内存时,TCMalloc会首先将所申请的内存大小向上取整到size class的大小,比如1~8字节之间的内存申请都会分配8字节,9~16字节之间都会分配16字节,以此类推。如上图的size class部分。通过取整,我们发现如果申请内存小于size class,取整后就会产生内部碎片。比如:我要申请4字节内存,取整后,给我分配了8字节,如果此后申请的内存大小都超过4字节,那么,剩下的4字节就成为了碎片。TCMalloc将这里的内部碎片控制在12.5%以内

Thread Cache:对于每个线程,TCMalloc都为其保存了一份单独的缓存,称之为ThreadCache。每个ThreadCache中对于每个size class都有一个单独的FreeList,缓存了n个还未被应用程序使用的空闲对象。

如上图,有n个线程,每个线程有一份Thread Cache,Thread Cache0 ~ Thread Cache n。每个cache中包含n个size class的Freelist链表:size class0 ~ size class n

由于每线程一个ThreadCache,因此从ThreadCache中取用或回收内存是不需要加锁的,速度很快。这样,就解决了上面ptmalloc多线程并发导致的分配区加锁争用加剧的问题。

CentralCache:一个所有线程公用的缓存。CentralCache中对于每个size class也都有一个单独的链表来缓存空闲对象,称之为CentralFreeList,如上图CentralCache部分,它供各线程的ThreadCache从中取用空闲对象。也就是说CentralCache为ThreadCache提供内存资源。

PageHeap:TCMalloc对可动态分配的内存的抽象。当CentralCache中的空闲对象不够用时,CentralCache会向PageHeap申请一块内存(可能来自PageHeap的缓存,也可能向系统申请新的内存),并将其拆分成一系列空闲对象,添加到对应size class的CentralFreeList中。其内部的内存块的取用是以span单位。如上图中的PageHeap部分。

image-20210419201731295.png

PageHeap内部根据内存块(span)的大小采取了两种不同的缓存策略。128个page以内的span,每个大小都用一个链表来缓存,超过128个page的span,存储于一个有序set(std::set)。如上图,链表中的方块以及set中的方块表示一个span。假设一个page是4字节,那么,上图中1 page中链表中所有span的大小总和就等于4字节。以此类推,2 pages的span总大小等于8字节,3 pages为12字节等等。

上面说的都是内存分配,内存回收的情况是怎样的?

用户进程调用free()或delete一个小对象时,将其插入到ThreadCache中其size class对应的FreeList中,不需要加锁,因此速度是非常快的。

只有当满足一定的条件时,ThreadCache中的空闲对象才会重新放回CentralCache中,以供其他线程取用。同样的,当满足一定条件时,CentralCache中的空闲对象也会还给PageHeap,PageHeap再还给系统。

现在我们再来看一下tcmalloc分配和释放内存的过程。

内存分配

tcmalloc将内存分配大小分为3类:

小对象分配

tcmalloc将大于0,小于等于256K的对象分配称为小对象分配。具体过程如下:

  • 将要分配的内存大小映射到对应的size class。

  • 查看ThreadCache中该size class对应的FreeList。

  • 如果FreeList非空,则移除FreeList的第一个空闲对象并将其返回,分配结束。

  • 如果FreeList是空的:

  • 从CentralCache中size class对应的CentralFreeList获取一堆空闲对象。

    • 如果CentralFreeList也是空的,则:
    • 向PageHeap申请一个span。
    • 拆分成size class对应大小的空闲对象,放入CentralFreeList中。
  • 将这堆对象放置到ThreadCache中size class对应的FreeList中(第一个对象除外)。

  • 返回从CentralCache获取的第一个对象,分配结束。

中对象分配

tcmalloc将大于256K,小于等于1M的对象分配称为中对象分配。具体过程如下:

  • 将应用程序所要申请的内存大小向上取整到整数个page,假设是k个page
  • 从k个page的span链表开始,到128个page的span链表,按顺序找到第一个非空链表。
  • 取出这个非空链表中的一个span,将这个span拆分成两个span:
  • 一个span大小为k个page,作为分配结果返回。
  • 另一个span大小为n - k个page,重新插入到n - k个page的span链表中。
  • 如果找不到非空链表,则将这次分配看做是大对象分配
大对象分配

tcmalloc将大于1M的对象分配称为大对象分配。具体过程如下:

  • 将应用程序所要申请的内存大小向上取整到整数个page,假设是k个page
  • 搜索PageHeap中的set,找到不小于k个page的最小的span
  • 将这个span拆分为两个span:
  • 一个span大小为k个page,作为结果返回。
  • 另一个span大小为n - k个page,如果n - k > 128,则将其插入到大span的set中,否则,将其插入到对应的小span链表中。
  • 如果找不到合适的span,则使用sbrk或mmap向系统申请新的内存以生成新的span,并重新执行中对象或大对象的分配算法。

现在我以MySQL排序字段申请sort_buffer为例,结合上面tcmalloc内存分配策略,详细讲解一下内存分配过程。

假设MySQL现在要为排序字段申请sort_buffer_size=128M的内存,由于申请大小超过1M,所以,使用大对象分配策略,为了简化计算,假设一个page大小4M:

  1. 将MySQL所要申请的内存大小128M向上取整到整数个page为128/4=32个page
  2. 由于32小于128,所以,进行中对象分配:
    • 从32个page的span链表开始,到128个page的span链表,按顺序找到第一个非空链表。
    • 假设这个非空链表对应的64 page,取出64 page非空链表中的一个span,将这个span拆分成两个span
    • 一个span大小为k个page,作为分配结果返回。
    • 另一个span大小为64 - 32 = 32个page,重新插入到这32个page的span链表中。

通过使用tmalloc对MySQL排序字段内存申请过程以及tmalloc内存管理的数据结构的讲解,tmalloc通过一个线程一个cache的方式,在小对象分配时,避免了多线程并发对cache加锁引起的性能下降的问题,但是,对于中对象和大对象分配,还是会有加锁的问题存在。但是,毕竟tcmalloc还是解决了小对象分配的性能问题。

那么,现在,我们来看看如何安装tcmalloc到MySQL中,让MySQL可以使用该内存分配器?

64位操作系统请先安装libunwind库

yum -y install gcc make gcc-c++ libunwind

然后下载并安装google-perftools

#下载源码包
wget https://github.com/gperftools/gperftools/releases/download/gperftools-2.5/gperftools-2.5.tar.gz
#解压源码包
tar xf gperftools-2.5.tar.gz
#开始编译安装
./confighre
make
make install
#搜索一下,找到安装好的相关C库文件
find / -name libtcmalloc.so
/usr/local/lib/libtcmalloc.so
/opt/gperftools-2.5/.libs/libtcmalloc.so
#创建lib库软连接,这样就不需要用ldconfig了
ln -sf /usr/local/lib/libtcmalloc.so /usr/lib/
ln -sf /usr/local/lib/libtcmalloc.so /usr/lib64/
#完成
ll /usr/lib/libtcmalloc.so
lrwxrwxrwx. 1 root root 29 Jan 23 12:05 /usr/lib/libtcmalloc.so -> /usr/local/lib/libtcmalloc.so
#打开mysql配置文件,添加下面两行
vim /usr/local/mysql/my.cnf
    .
    .
    .
[mysqld_safe]
malloc-lib=tcmalloc
    .
    .
    .
#重启mysql
/etc/init.d/mysql restart

输入如下命令验证:

lsof -n | grep tcmalloc
mysqld     3665         mysql  mem       REG              253,0    1050408  100663894 /usr/local/lib/libtcmalloc_minimal.so.4.3.0
mysqld     3665  3666   mysql  mem       REG              253,0    1050408  100663894 /usr/local/lib/libtcmalloc_minimal.so.4.3.0
mysqld     3665  3669   mysql  mem       REG              253,0    1050408  100663894 /usr/local/lib/libtcmalloc_minimal.so.4.3.0

这样就成功安装tcmalloc到MySQL中了。

总结

通过本章内容的讲解,我们知道了如果排序字段值的大小不超过sort_buffer_size,同时,SQL中包含limit,MySQL会使用优先级队列对字段进行排序,避免了无效记录的排序,提升了排序的效率。

同时,我可以使用tcmalloc内存分配器使MySQL在小对象内存分配时避免锁竞争,从而充分利用内存资源,让更多的数据存于内存,提升查询的效率。