后端的轮子(三)--- 缓存

前言

前面花了一篇文章说数据库这个轮子,其实说得还很浅很浅的,真正的数据库比这复杂很多,今天咱们继续轮子系列,今天说说缓存系统吧。node

缓存是后端使用得最多的东西了,由于性能是后端开发一个重要的特征,因此缓存就应运而生了,并且如今缓存已经到了泛滥的程度了,我几乎没见过没有缓存的后端,一遇到性能问题,首先想到的不是看代码,而是加缓存,我也是醉了,好了,不扯这些,这些和今天的文章无关,今天咱们来专门讲讲缓存吧。golang

缓存和KVDB数据库

缓存和KVDB两个东西常常一块儿出现,二者在使用上没有明显的界限,当一个KVDB速度够快,性可以强劲,那么就能够当缓存来用了,咱们使用Redis来作缓存,实际上就是把一个KVDB来当缓存用。但通常状况下,KVDB能提供更多的数据结构,因此象Redis这样的KVDB中有不少实用的数据结构,好比List啊,hashtable啊之类的,并且KVDB通常都提供持久化的存储,而像memcached这样的纯缓存通常不提供持久化存储功能,并且数据结构也比较简单,仅仅提供key和value都是字符串的形式。编程

如今KVDB的表明Redis性能已经愈来愈强劲了,虽然它是个单线程的服务,但目前基本上能用memcached的均可以用Redis代替,并且Redis由于支持更多的数据结构,因此扩展性更好。如今不少状况下所说的缓存,实际上都是指的是Redis缓存。后端

缓存的类型数组

咱们这里抛开Redis,来单独说说缓存,所谓缓存,其实是为了给数据提供一个更快的访问方式,这个更快通常是相对于最终数据而言的,最终数据能够是KVDB中的数据,也能够是文件数据,还能够是其余机器上的数据库数据,只要比这些个数据访问的快的,均可以叫缓存,那么通常缓存分红一下几种。缓存

  • 数据库型缓存,好比最终数据是其余机器上的MySql数据库中,那么咱们作一个KVDB的数据库,查询的时候按照key查询,总比数据库要快点吧,那这个KVDB就是个缓存。
  • 文件型的缓存,进一步说,远程的KVDB仍是有网络延迟,慢了点,这时候我在本地作一个文件缓存,这个文件的访问速度比远程数据库要快吧,那这个文件也是个缓存。
  • 内存型的缓存,再进一步,本地文件仍是嫌慢了,那么咱们在作一个内存缓存,把最热的数据存到内容中,那这个内存的访问速度必定比文件要快,那这个内存块也是个缓存。

因此说,只要比最终数据访问得快的数据结构,就是一个缓存系统。服务器

为了通常性,咱们这里所说的缓存轮子,将会说一个内存型的缓存,只提供简单字符串类型的key和value的操做。网络

如何设计一个缓存数据结构

设计一个独立的内存型的缓存系统,首先先要肯定缓存最关心的东西,那就是性能,全部的须要考虑的东西都是围绕性能两个字来进行设计的,因此最重要的部分,设计一个缓存须要考虑如下三个方面,底层数据结构,内存管理,网络模型。

底层数据结构

又看到数据结构这个词了,咱们所说的全部轮子,都跑不掉数据结构这个东西,对于缓存来讲,通常都是KV形式的数据结构,因此底层通常会使用树或者哈希表来保存数据,而缓存对性能的要求更高,因此通常使用哈希表来保存数据,因此底层的数据结构就是哈希表了。

哈希表

哈希函数和类型

选择哈希表,是由于他的O(1)的查询复杂度,这是一个很重要的性能指标,但若是哈希函数没有选择好,产生了大量的哈希碰撞,那性能就会急剧下降,因此对于哈希函数的选择也是一个须要考虑的问题,比较流行的哈希函数有不少,特别的,若是是自用型的缓存,哈希函数能够根据业务场景再来调整,保证哈希的均匀,从而让查询复杂度更加接近O(1)。 哈希表的实现方式有不少中,最最基础的就是数组+链表的形式了,也叫开链哈希,数组长度就是哈希的桶的长度,链表用来解决冲突,插入数据的时候若是哈希碰撞了,把具体节点挂在该节点后面的链表上,查询数据时候有冲突,就继续线性查询这个节点下的链表。

 

还有一种叫闭链哈希,闭链哈希实际是一个循环数组,数组长度就是桶的长度,插入数据的时候有冲突的话,移动到该节点的下一个,直到没有冲突为止,若是移动到了末尾的话,转到数组的头部,查找数据的时候相似。

咱们这里说的哈希表都是第一种开链哈希表。

因为缓存不只仅有读,还有写操做,若是在多线程的场景下,势必会产生加锁的操做,若是设计这个锁也是须要考虑的,若是写操做不是不少的状况下,那么整个哈希表加读写锁就好了,但若是写操做也比较频繁,那么能够为一批哈希槽或者每个哈希槽加一把锁,这样的话,能够把锁等待的时间延迟给降下来,具体仍是要看场景,我实现的时候是给每一个槽加了一个读写锁,这样更耗费内存,可是性能好一些。这种类型的哈希表的数据结构长成下面这个图的样子。

 

每个槽配了一把读写锁,每次写的时候都对单个槽进行加锁操做,这样的坏处就是须要维护巨多无比的锁,容易形成浪费,在实际中咱们能够根据实测的结果,给一批槽加一把锁,这样也能够把锁资源空下来,而且也能达到比较好的并发效果。

关于锁设计,再多说一下,多线程(或者多协程)的状况下,加锁是一种常规处理方式,如今的X86架构,支持一种CAS的无锁操做模式,是在CPU层面实现的对变量的多线程同步技术,golang中有个atomic包,简单的封装了这个功能,可是这个操做在进行变量更新的时候通常要在一个循环中来实现,不停的尝试直到成功为止,虽说减小了锁的操做,但代码看起来没那么清晰,并且若是出了问题,调试起来也没有锁那么清晰,而且虽然是CPU级别的支持,可是仍是有问题的,就是线程切换的时候仍是会形成不可预知的错误,这里就不展开了,感兴趣的能够本身去搜索一下CAS无锁操做,而且在通常的缓存中仍是读多写少,经过把锁扩展到槽级别基本上性能不会出现很大的损耗,固然,若是你对性能有着极致的追求,能够考虑CAS方案,可是也要注意坑哦。

字符串和整数

最后,咱们看看对于单个的具体的哈希槽,在发生哈希碰撞的时候一个槽下面可能挂了不少节点。

当进行读操做的时候,若是哈希到这个槽下面来了,咱们须要比较每一个节点的key和查询串的值,只有相等的状况才是咱们须要的。比较每一个key的值是进行了一次字符串的比较,效率是比较低的,这里继续出现一个用空间换时间的方法,就是咱们在插入节点的时候给每一个节点的key生成两个哈希值,第一个哈希值用来进行槽的选择,第二个哈希值保存在节点内,查询的时候不进行key的字符串比较而比较第二个哈希值,因为哈希值是整数的,因此比较效率比直接比较字符串要快多了。用这样的方式,在写入数据和查询数据的时候须要进行两次哈希计算,而且还须要有个单独的空间来存储第二个哈希值,可是查询的时候能够节省字符串比较的时间。

对于两个字符串的比较,平均时间复杂度是O(n/2)吧,而对于两个整数的比较,一个异或操做就搞定了,谁快就不用说了吧,这个槽变成这样了。

 

重哈希(reHash)

对于不断增加的数据而言,重哈希是一个必不可少的过程,所谓重哈希,就是当你的桶使用到必定程度之后碰撞的几率就变很大了,这时候就须要把桶加大了。

把桶加大,必然须要进行一次从新哈希的过程,这个过程的处理办法也有一些技巧。

  • 直接从新哈希,这是最简单的,就是把全部的key从新哈希一遍放到新的桶中,简单粗暴,可是缺点也很明显,就是当key不少的时候很是耗时间和资源,在这段时间中,服务是不可用的。
  •  
  • 逐步迁移的重哈希,由于桶的变化,从新哈希是没法避免的,这时候咱们主要要考虑的是让服务尽量的保持可用,那么除了直接哈希,还有一种策略上的优化,简单的描述就是申请两个桶A和B,B的桶数量大于A初始化申请的时候能够并不实际申请内存空间,首先用第一个桶A进行数据存储,当第一个桶A使用到必定比例,好比80%的时候,开始进行哈希迁移。新来的写操做先哈希到桶B,而后在哈希到桶A上,把A桶上命中的节点上的全部数据从新哈希到B上,而后在A上打一个标记,表示这个节点失效了。新来的读操做,先哈希到A进行命中,若是A的这个节点标记为失效,再哈希到B上读取正确数据,若是A节点没有标记失效,那么把这个节点下的全部数据从新哈希到B上,并在A上打一个标记,表示这个节点失效了。直到全部的A的数据都迁移完成,把A和B交换一下,而且把A的桶数据增长,做为下一次迁移使用。

这样,当进行从新哈希的时候,多了几回哈希运算,性能损失了一些,可是服务始终是可用的。

内存管理

还有一个方面就是内存了,由于有写操做,那么就须要申请内存,若是写操做比较多的话,再加上有删除操做的话,那就会不停的申请释放内存。内存的申请和释放也是比较损耗性能的,因此通常会用本身的内存池来进行内存的分配。关于内存池这一块,有不少内存池的实现方式,这里就不详细说了。

其实我以为这种对性能有强要求的服务,不太适合使用带GC的语言进行编写,最直接的仍是用C这种系统语言来编写,特别是涉及到内存池这种比较靠底层的东西,有GC其实是很麻烦的事情,在Golang中,由于是自带GC的,若是须要进一步榨干系统的性能,那么这么底层的东西要用CGO来实现了,把GC丢一边,全部的内存都本身管。

网络模型

除了在底层数据结构层的性能损耗外,网络模型的选择也是很重要的,选择性能尽量高的网络模型也能极大的提高性能,好比memcached就用了libevent这个事件模型,总的来讲就是先经过一个主线程监听端口,接收到网络文件描述符之后,而后经过master-worker这种结构将网络的套接字分发到各个worker线程的独立队列中,各个线程利用libevent模型对队列中的套接字进行读写。

而Redis的网络模型更简明一点,但也是基于epoll的IO多路复用,感兴趣的朋友能够本身去看看Redis的源码,他至关因而一个稍微简化版本的libevent模型。

关于libevent和libev这两个模型(其实差不太多),咱们能够专门写一篇文章分析一下源码,他们的源码都很少,正好我也看过,能够写一篇文章,固然网上这类文章也不少。

对于网络模型,实际上现代的高级语言已经基本上封装到http这个层次了,好比golang这种现代语言,http的包均可以直接用,而且并发性能也挺好的,可是对于一个缓存系统,若是配合一个http的模型,就显得过重了,http底下的TCP模型就能够很好的解决问题,对于这一块,咱们能够用个简单的模型:

  • 根据CPU的核心数启动相应数量的协程,每一个协程配合一个channel
  • 启动一个协程负责接收tcp链接,accpet连接之后经过channel交给相应的协程处理

这段代码虽然使用了channel这个东西,但也算是一个比较标准的多线程编程方式了,在多线程的世界中也能够用循环链表来表示这个channel,用代码来体现大概就是这样子的。

 
func GetConnection() error { tcpAddr, _ := net.ResolveTCPAddr("tcp", ":26719") listener, _ := net.ListenTCP("tcp", tcpAddr) //启动处理协程 for i := 0; i < cpuCores; i++ { go Process(i) } i:=0 for { conn, _ := listener.AcceptTCP() select { case processChan[i] <- conn: //经过管道交给协程处理 i++ if i==cpuCores{ i = 0 } default: } } }复制代码

除了上面那种模型,还有一种比较奔放的模型,也是比较golang的写法了。

  • 启动一个协程负责接收tcp链接,accpet连接之后go出一个协程进行处理 代码实现就是下面这个样子
 
func GetConnection() error { tcpAddr, _ := net.ResolveTCPAddr("tcp", ":26719") listener, _ := net.ListenTCP("tcp", tcpAddr) for { conn, _ := listener.AcceptTCP() go Process(conn) //处理实际链接 } }复制代码

关于golang的协程分析

这种奔放的协程使用方式到底行不行?这两种到底哪一个比较好?这里咱们不讨论这两种模型哪一种好,咱们看看golang的协程吧。

golang的协程网上资料不少,关于协程的详细设计能够找到很多文章,总的来讲就是这是一个轻量级的线程,有多轻呢?轻到它其实就是一个代码段加上一个本身的栈,光这个还不够,线程也能够说是一个代码片断加上一个本身的栈,只是协程的栈比线程的小而已,除了这个觉得,协程主要的轻表如今它不经过操做系统调度,他是经过代码内部进行显示调度的。

什么叫代码内部进行显示调度呢?

咱们先看看目前流行的两个事件模型【同时也是处理网络链接的网络模型】,一种是nodejs为表明的IO模型,大堆回调函数,一种是传统的多线程模型。

先看回调模型,像下图同样,左边是IO队列,右边是代码片断,每一个IO事件对应一个回调函数,接收到IO事件之后进行相应的函数处理,这样的好处就是CPU利用率极高,不用切换资源,坏处就是代码被扯乱了,变成无序的了,对编程的要求比较高。

 

再看看线程模型,线程模型线程的切换其实是操做系统来进行的,线程模型以下图,右边是线程的代码,左边的舞台就是CPU了,当在CPU上跳舞的线程进行系统调用(好比读取文件)或者每隔一段时间(时钟中断,不了解的请自行看计算机体系结构),操做系统就会把当前在CPU上玩耍的线程换下来,换个新的上去,这样的调度方式是操做系统来进行的,不须要线程自己参与,线程何时运行彻底有操做系统说了算,线程切换也是操做系统说了算,好处就是编程的时候比较正常,缺点就是进行线程的切换是要耗费资源的,并且新开线程也须要资源。

 

有什么办法把这两种模型结合起来呢?我以为golang的协程就是干了这个事情,golang的协程模型以下,这个图是我本身画的,主要是为了说明协程把上面两个模型结合,实际的协程模型长得不是这样子的啊。咱们看到只剩下一个线程(或者进程)在CPU上跑了,上面的任务都跑到线程内部变成一个一个的代码片断了,执行哪一个片断由线程内部决定,当遇到系统调用的时候,不用切换进程,而是直接切换执行其余的代码片断,这样的话,和线程模型比少了线程切换的开销,而且还能和回调模型同样,当IO操做的时候运行其余代码片断,最重要的是没有回调函数了,在开发人员看来和线程同样了。

 

好了,关键问题来了,怎么调度的呢?上面的回调模型和线程模型调度的时候都是操做系统来完成的,这里就显示出代码内部的显示调用了,就是说这些代码片断运行的时候,运行一段时间后,经过调用一个函数,主动放弃CPU,这些个代码片断就像下面这样

 
func running(){ //计算一些东西 stop() //调用stop主动不跑了,请把我正在运行的这个地址和个人栈记录下来,下次运行的时候继续在这里跑 //剩下的代码 }复制代码

这就是代码内部的显示调用了,所谓协程嘛,就是须要运行的代码来协助进行任务的调度,怎么协助呢?就是显示的调用一个函数来进行现场保存和切换代码。

不少人说我在写golang的时候没看到这玩意啊,恩,为了让你编程容易,这东西隐藏到系统调用中去了,就是说你在协程中只要进行了系统调用(好比打印系统,读取文件,操做网络),那么在调用相似fmt.Println的时候就调用了这个调度函数来切换协程了,固然,你也能够在你的代码中主动放弃CPU使用权,只要调用runtime.Gosched()就好了。

关于这部分,你能够试试下面的代码,在单核CPU的状况下,第二个函数是永远执行不了的,若是是按照多线程的思想,第二个函数是能够执行的,这就是由于第一个函数没有系统调用没有IO操做,因此一直把持着CPU不放弃,这也是协程编程须要注意的地方。

 
func main() { runtime.GOMAXPROCS(1) var workResultLock sync.WaitGroup go func() { fmt.Println("我开始跑了哦。。。") i := 1 for { i++ } }() workResultLock.Add(1) time.Sleep(time.Second * 2) go func() { fmt.Println("我还有机会吗????") }() workResultLock.Add(1) workResultLock.Wait() }复制代码

好了,协程的原理说了一下,咱们再看看到底什么模型比较合适呢?咱们看到,golang的协程这么设计出来,首先创建协程的消耗不多,而且在多IO操做的时候比线程是要占优点的,由于在IO操做的时候,只是像回调同样换了一段代码来执行,没有线程的切换。这也是为何用golang来写服务器代码比较合适的缘由,由于服务端的代码基本上都少不了IO操做,网络读写是IO,数据库读写是IO,这样用golang既能够保持原来的多线程编程的连贯思惟,又能够尽量的使用事件模型的优点,减小线程切换。

恩,如今回到咱们的缓存,虽然咱们在对缓存读写的时候没有IO操做,可是网络读写仍是IO操做,并且对于缓存的操做自己理论上并不耗费多少时间(就是几个哈希操做),因此IO时间占比仍是比较大的,因此这种状况下我以为使用奔放的协程模式是能够的,但也别太奔放了,最好限制一下协程的数量。

但对于有些系统,好比搜索系统,广告系统这种服务,每次都有个在线排序的过程,这是个很是大计算量的任务,基本上一次请求80%的时间都耗费在排序这种计算上了,IO反而不是瓶颈,这种状况下,多线程模型和golang这种协程模型差异就不是很大了,这时候8核CPU只启动8个协程和启80个协程,效率的差异就不大了。后端的轮子(三)--- 缓存

相关文章
相关标签/搜索