缓存二三事

276 阅读6分钟

高并发的三大利器:缓存,限流和降级,本章主要介绍一下缓存相关的东西。

Background

为什么需要缓存:用缓存,主要有两个用途:高性能高并发

  • 一个请求过来,操作 mysql,查出一个结果可能需要200ms,放在缓存里只需要2ms,响应速度更快。
  • 大量的请求如果直接打到db会导致数据库宕机(mysql是来一个请求给一个线程处理,来太多资源消耗太大,cpu/IO瓶颈,一次只能处理一个线程),使用缓存可以减少mysql的请求数量。

what Is cache (1).png

常用的缓存手段

  • 本地缓存:在服务进程的内存空间中缓存数据,数据读写都在同一个进程内
    • 优点:访问速度快,本地缓存占用的是应用进程内的空间,相对于分布式缓存它不需要跨网络传输,性能会更好。

    • 缺点:由于进程空间大小的限制,注定了本地缓存不支持大数据量的数据存储。 同时数据会随应用进程重启而丢失,实例重启时,应用进程内存空间本地缓存的数据会丢失,可能存在数据不一致。同时本地缓存只支持被该应用进程访问,无法被其他应用进程访问。如果在应用进程的集群部署当中,当数据更新需要同步不同部署节点的本地缓存数据来保证数据一致性时,复杂度高且很容易出问题。

    • 一般的实现方式:

      • map/sync.map 1、本地缓存最简单的实现方式就map,但map不是线程安全的,使用需要加锁,同时map空间不够时会重新开辟地址空间,容易导致访问出问题

      2、sync.map线程安全,但使用其实也需要加锁,效率不高

      3、使用map作为本地缓存时容易触发go的垃圾回收,gc与程序交互时候的效率会影响到整个程序的运行效率

      • 提高map缓存性能的方法:

        1、 map 中尽量避免存储指针,减少 GC

        2、分段(Shards)存储,减少 lock的粒度

      • 本地缓存框架:Go有较多本地缓存框架,性能如下图所示

      image-20220702220623641-6770791.png 总的来说,通过上表的总结,本地缓存组件中,实现零 GC 的方案主要就两种:(GC只去扫描堆内存?)

      a.无 GC:分配堆外内存(Mmap)

      b.避免 GC:map 非指针优化(map[uint64]uint32)或者采用 slice 实现一套无指针的 map

      c.避免 GC:数据存入[]byte slice(可考虑底层采用环形队列封装循环使用空间)

      同时,通过分片机制,可以减小锁的粒度,提高性能(不用锁全部数据)

      • 以BigCache框架为例 基于map,map存索引map[uint64]uint32(key的hash值和数组的下标),结合[]byte存数据加锁,没有GC

      代码分析

一个bigcache对象有多个分片cacheshard
type cacheShard struct {
	hashmap     map[uint64]uint32	// 图中的 map
	entries     queue.BytesQueue	// 数据存储
	lock        sync.RWMutex
	entryBuffer []byte
	onRemove    onRemoveCallback

	isVerbose    bool
	statsEnabled bool
	logger       Logger
	clock        clock
	lifeWindow   uint64

	hashmapStats map[uint64]uint32
	stats        Stats
}

image-20220702225921290.png

一个bigcache对象由多个cacheshard组成,将要存储的数据通过一定规则映射到一个cacheShard中,每个Shard都有一个读写锁,一个hashmap和和一个字节队列。操作时只需要锁对应的shard不需要锁所有数据;map的value是数据在字节队列中的Index,真正的数据通过编码存储在字节队列中。整个操作不使用指针,避免了GC。

BigCache只能设置全局的过期时间,每隔一个规定的时间就会去扫描数据,删除过期数据,数据大小可配置。

image.png

  • 分布式缓存:独立于服务进程部署,需要通过网络来完成分布式缓存数据传输。常见的例子:MemCached 和 Redis。
    • 优点:支持多数据结构、支持持久化和主备同步。单线程,多种淘汰机制
    • 设置超时时间,通过lua脚本保证原子操作,还可以做分布式锁等其他事情
    • redis 主进程是单线程工作,因此,Redis 的所有操作都是原子性的,即操作要么成功执行,要么失败完全不执行。单个操作是原子性的,多个操作也支持事务,即原子性。

数据一致性问题

只要使用了缓存,数据需要存在多个地方,就存在数据一致性问题。如果不能接受数据有不一致的情况,最好还是不使用缓存。而为了尽可能的保证数据最终一致性,最经典的缓存模式就是

  • Cache-Aside Pattern模式

image.png

image.png 先更新数据库,在删除缓存,是一种懒加载的方式,只有有读请求的时候,才会去数据库读数据然后放到缓存上

(Ps: 能不能先删缓存再更新db--不能-->会出现数据不一致情况:缓存已删除db未更新时,其他请求访问了db更新了cache;先更新db再删除缓存也会不一致,概率很小:请求 1 从 DB 读数据 A->请求 2 写更新数据 A 到数据库并把删除 cache 中的 A 数据->请求 1 将数据 A 写入 cache。)

其他集中模式也是基于CAP模式的变种,可以参考juejin.cn/post/696453…

对于读写分离、主从数据库的一些架构,主库写了数据,从库可能还没更新,这时候有读请求来,会把缓存更新为脏数据,这种可以使用延时双删等方式更新;对于删除缓存失败的场景,也可以引入重试机制。可以参考:dbaplus.cn/news-160-33…

缓存带来的问题

使用缓存可能会带来几个问题:

缓存击穿:即缓存和数据库都没有的数据,被大量请求,请求会直接打到数据库上,导致数据库崩掉 -- 对这种场景,可以使用布隆过滤器,如果没有命中,证明没有该数据,直接拒绝。

缓存穿透:数据库原本有得数据,但是缓存中没有,一般是缓存突然失效了,这时候如果有大量用户请求该数据,缓存没有则会去数据库请求,会引发数据库压力增大,可能会瞬间打垮。--对这种场景,需要提前做缓存预热,然后可以设置一个互斥锁,数据为空时只有一个线程可以去数据库取数据。或者设置接口限流等操作,防止大量的请求

缓存雪崩:缓存中有大量的数据,在同一个时间点,或者较短的时间段内,全部过期了,这个时候请求过来,缓存没有数据,都会请求数据库,则数据库的压力就会突增,扛不住就会宕机。--对这种情况,可以设置部分缓存不过期,或者对过期时间设置一个波动值,不会同时大量过期。或者设置接口限流等操作,防止大量的请求

Reference

blog.csdn.net/u010013573/…

kaito-kidd.com/2021/09/08/…

segmentfault.com/a/119000003…