本地缓存:用本地缓存做服务会遇到哪些坑?

在探讨数据服务相关内容时,有一个方面不得不提及,那就是缓存。

要知道,就当下的情况而言,唯有缓存能够承担起大流量的数据服务需求。而我们常见的缓存架构呢,基本上都是采用集中式缓存的方式来面向外部提供服务的。

然而,这种集中式缓存在面对读多写多的场景时,是存在一定上限的。一旦流量增长到了特定的程度,集中式缓存以及与之相关的无状态服务所产生的大量网络损耗问题就会变得越发严重。如此一来,在高并发的读写场景之下,就会出现缓存成本居高不下并且还不稳定的状况。

那为了改变这种情况,达到降低成本、节省资源的目的,我们会采取一种措施,即在业务服务层额外再增添一层缓存。在这个过程中,我们会选择放弃强一致性的要求,转而保持最终一致性。通过这样的方式,便能够有效地降低核心缓存层所承受的读写压力了。

虚拟内存和缺页中断

想做好业务层缓存,我们需要先了解一下操作系统底层是如何管理内存的。对照后面这段 C++ 代码,你可以暂停思考一下,这个程序如果在环境不变的条件下启动多次,变量内存地址输出是什么样的?

int testvar = 0; 
int main(int argc, char const *argv[]) 
{ 
  testvar += 1; 
  sleep(10); 
printf("address: %x, value: %dn", &testvar, testvar ); 
return 0; 
}

当你进行相关试验时,可能会得到一个出乎你意料的答案哦。你会发现变量内存地址的输出居然一直是固定的,而这一现象恰恰证明了程序所接触到的内存是具有独立性的。

要是我们的服务所访问的是物理内存的话,那可就不会出现这样的情况啦。那为什么最终呈现出来的结果会是这样的呢?这就不得不提到 Linux 的内存管理方式啦。Linux 采用的是虚拟内存的方式来对内存进行管理的哦,正因为如此,每一个正在运行的进程都拥有其自身专属的虚拟内存空间呢。

回过头来再看一下,当我们对外提供缓存数据服务的时候呀,如果想要提供更加高效的并发读写服务,那就需要把相关的数据存放在本地内存当中啦。通常情况下呢,这会被实现为在一个进程内部的多个线程来共同共享这些缓存数据哦。

不过呢,在这个实现的过程当中呀,我们还会碰到一个问题,那就是缺页问题哦。接下来呀,咱们就一起来仔细看看这个缺页问题到底是怎么一回事吧。

如上图所示,我们的服务在 Linux 中申请的内存并不会立刻就从物理内存中划分出来哦。只有当系统数据进行修改的时候,才会察觉到物理内存尚未分配呢。这个时候,CPU 就会产生缺页中断,然后操作系统才会以 page 为单位把物理内存分配给程序哦。

系统之所以这样设计,主要是出于两个方面的考虑呢。一方面是为了降低系统的内存碎片,另一方面则是为了减少内存的浪费情况哦。不过呀,系统分配的页是很小的哦,一般来说只有 4KB 呢。假如我们有一次需要把 1G 的数据插入到内存中,那么在往这块内存写入数据的时候就会频繁地触发缺页中断啦,这就会导致程序响应变得缓慢,服务状态也会不稳定哦。

所以呢,当我们确定需要进行高并发的读写内存操作时,通常都会先申请一大块内存并且将其填为 0,然后再去使用它哦。这样做的话就可以减少数据插入时所产生的大量缺页中断啦。这里我要额外补充一个注意事项哦,这种申请大内存并填 0 的操作是比较慢的呢,所以尽量要在服务启动的时候去进行操作哦。

前面所说的这种操作虽然效果是立竿见影的,但是在资源紧张的时候还是会存在问题哦。在现实中呀,很多服务刚启动的时候就会申请几 G 的内存呢,然而在实际的运行过程中,活跃使用的内存可能还不到 10% 哦。Linux 会根据统计情况将我们长时间不访问的数据从内存里挪走,从而留出空间给其他活跃的内存使用哦,这个操作叫做 Swap Out 哦。

为了降低 Swap Out 的概率呢,就需要给内存缓存服务提供充足的内存空间和系统资源哦,让它能够在一个相对专用的系统空间里对外提供服务哦。但是我们都知道呀,内存空间毕竟是有限的呢,所以就需要精心地去规划内存中的数据量哦,要确认这些数据是会被频繁访问的哦。我们还需要控制缓存在系统中的占用量哦,因为当系统资源紧张的时候,OOM(Out of Memory)会优先杀掉资源占用多的服务呢。同时呀,为了防止内存浪费,我们需要通过 LRU(Least Recently Used)淘汰掉一些不频繁访问的数据哦,只有这样才能保证资源不被浪费哦。

即便我们做了这些措施,可能还是会存在漏洞哦,因为业务情况是很难预测的呢。所以呀,建议对内存做定期的扫描续热哦,以此来预防流量突增的时候触发大量缺页中断,从而导致服务卡顿、最终宕机的情况哦。

程序容器锁粒度

除了要确保内存中不存放冷数据之外,我们存放在内存中的公共数据也是需要加锁的哦。要是不设置互斥锁的话,就会出现多线程修改不一致的问题呢。当读写操作比较频繁的时候,我们通常会对相应的 struct 增加单条数据锁或者 map 锁。

不过呀,你得注意啦,锁的粒度如果太大的话,是会对我们的服务性能产生影响的哦。因为实际的情况往往和我们预计的会存在一些差异呢,所以建议你在具体使用的时候,在本地多进行一些压测测试哦。

就像我之前用 C++ 11 编写过一些内存服务的时候,就遇到过这样的情况哦。比如说读写锁的性能反而比不上自旋互斥锁,还有压缩传输的效率竟然不如不压缩的效率高呢。

那么我们再看一下业务缓存常见的加锁方式。

为了减少锁冲突,我常用的方式是将一个放大量数据的经常修改的 map 拆分成 256 份甚至更多的分片,每个分片会有一个互斥锁,以此方式减少锁冲突,提高并发读写能力。

除此之外还有一种方式,就是将我们的修改、读取等变动只通过一个线程去执行,这样能够减少锁冲突加强执行效率,我们常用的 Redis 就是使用类似的方式去实现的,如下图所示:

如果我们能够接受半小时或者一小时进行一次全量更新的话,那么我们可以制作 map,通过替换的方式来实现数据的更新哦。

具体的做法是这样的:使用两个指针,分别让它们指向两个 map。其中一个 map 用于对外提供服务。当我们获取到更新数据的离线包时,另一个指针所指向的 map 就会开始加载离线的全量数据。

等到加载完成之后呢,我们将这两个 map 的指针指向进行互换,通过这样的操作就能够实现数据的批量更新啦。

采用这样的方式实现的缓存,我们是可以不添加互斥锁的哦,如此一来,性能将会得到很大的提升呢。

GC 和数据使用类型

当我们在构建缓存的时候,是不是只要把数据 struct 直接放进像 map 这类的容器当中,就堪称完美了呢?实际上呀,我并不提倡这样去做哦。这个答案或许会在一定程度上颠覆您原有的认知,不过等您看完后面的分析内容,应该就能明白其中的缘由啦。

您想啊,当我们把十万条甚至更多的数据都放置到缓存里面的时候呢,编程语言所具备的 GC(垃圾回收机制)就会按照一定的周期去扫描这些对象哦,其目的就是要判断这些对象是否能够被回收掉呀。而正是由于这个机制的存在,就会出现这样一种情况:map 当中所包含的对象数量越多,那么服务于 GC 的速度可就会变得越慢啦。

所以呢,很多编程语言为了能够更加妥善地将业务缓存数据存放到内存当中,都做了不少特殊的优化措施哦。这也就是为什么在高级语言去开展缓存服务的时候,很少会把数据对象一股脑儿地都放到一个很大的 map 里面啦。

下面呢,我就以 Go 语言为例,带您具体来看看哦。为了能够减少需要扫描的对象个数呀,Go 语言针对 map 做了一个特殊的标记处理哦。要是 map 里面没有指针的话,那么 GC 在运行的时候就不会去遍历它所保存的那些对象啦。

为了能让您更方便地理解这一点,我来给您举个例子吧。我们不再使用 map 去保存那些具体的对象数据啦,而是仅仅运用一些简单的结构来充当查询索引哦。比如说,我们可以使用 map [int] int 这种形式,这里面的 key 呢,是把 string 通过 hash 算法转换而成的 int,而 value 所保存的内容则是数据所在的 offset(偏移量)和长度哦。

当我们对数据做完序列化处理之后呀,就会把它保存在一个长长的 byte 数组当中哦。通过这样的方式来实现对数据的缓存哦。不过呢,这种实现方式存在一个弊端,那就是很难对数据进行删除和修改操作哦。所以呢,一般情况下我们所删除的仅仅是 map 当中的索引记录而已啦。

这也导致了我们做缓存时,要根据缓存的数据特点分情况处理。如果我们的数据量少,且特点是读多写多(意味着会频繁更改),那么将它的 struct 放到 map 中对外服务更合理;如果我们的数据量大,且特点是读多写少,那么把数据放到一个连续内存中,通过 offset 和 length 访问会更合适。

内存对齐

前面曾经提到过,将数据放置到一块虚拟地址连续的大内存中,通过 offset(偏移量)和 length(长度)来访问时存在不能修改的问题,实际上,这个方式还是存在一些可以提升的空间的。

在讲述优化方案之前呢,我们需要先对内存对齐有所了解。在计算机领域中,很多语言都非常关注这一点。究其原因,是因为在内存对齐之后存在诸多好处。比如说,如果我们的数组内所有数据的长度是一致的,那么就能够实现对其快速定位。举个例子,如果我们想要快速找到数组中的第 6 个对象,可以采用如下方式来实现:sizeof (obj) * index => offset(对象大小乘以索引得到偏移量)。使用这种方式呢,要求我们的 struct(结构体)必须是定长的,并且长度要按照 2 的次方倍数进行对齐。

另外,对于那些变长的字段,我们可以采用将其用指针指向另外一个内存空间的方式。通过这样的方式,我们能够通过索引直接找到对象在内存中的位置,并且它的长度是固定的,此时无需记录 length,只需要依据 index(索引)就能够找到数据。如此设计还能够让我们在读取内存数据时,快速获取到数据所在的整块内存页,进而就能够从内存中快速查找到要读取索引的数据,而无需读取多个内存页。毕竟内存也属于外存的一种,访问次数少一些会更有效率。这种按页访问内存的方式,不但能够实现快速访问,而且还更容易被 CPU 的 L1、L2 缓存命中,从而进一步提升数据访问的效率。

SLAB 内存管理

除了以上的方式外,你可能好奇过,基础内存服务是怎么管理内存的。我们来看后面这个设计。

如上图所示,主流语言为了达成减少系统内存碎片以及提高内存分配效率的目的,基本上都实现了类似于 Memcache 的伙伴算法内存管理。甚至高级语言的一些内存管理库也是通过这种方式来实现的。

我来举个例子,在 Redis 里可以选择用 jmalloc 来减少内存碎片,下面我们一起来看看 jmalloc 的实现原理。jmalloc 会一次性申请一大块儿内存,然后将其拆分成多个组。为了能够适应我们的内存使用需求,它会把每组切分为相同的 chunk size(块大小),而且每组的大小会逐渐递增。比如第一组都是 32byte,第二组都是 64byte。

当需要存放数据的时候,jmalloc 会查找空闲块列表,然后分配给调用方。如果想要放入的数据没有找到相同大小的空闲数据块,它就会分配容量更大的块。虽然这样做在一定程度上会浪费一些内存,但是却可以大幅度地减少内存的碎片,进而提高内存的利用率。

很多高级语言也采用了这种实现方式。当本地内存不够用的时候,我们的程序会再次申请一大块儿内存用来继续提供服务。这就意味着,除非我们把服务重启,否则即便我们在业务代码里释放了临时申请的内存,编程语言也不会真正地将其释放。

所以,如果我们在使用过程中遇到临时的大内存申请情况,务必仔细思考这样做是否值得。

10