Go缓存系列之: 缓存的设计

缓存

缓存是我们开发过程中必不可少的一项提供接口性能的方式,但是,对项目引入缓存也会带来问题,比如缓存穿透,HotKey,缓存雪崩,缓存击穿,缓存一致性的问腿。所以,我们可能在缓存库中加入一些解决方案。

设计的目标

我们的目标是设计一个通用的缓存库。设计的目标如下

基本操作

提供基础操作,创建和删除缓存。

// Cache ...
type Cache interface {
	Set(ctx context.Context, key string, value interface{}, expiration time.Duration) (err error)
	Get(ctx context.Context, key string, fetch fetchFunc) (result []byte,err error)
	Del(ctx context.Context, key string) (err error)
	AddPlugin(p Plugin)
}

多级缓存

多级缓存指的是本地缓存+分布式缓存。

通常来说,Redis用来存储热点数据,Redis中没有的数据则直接去数据库访问。那么为什么还需要多级缓存呢?总的来说有两点(参考:如何优雅的设计和使用缓存?)

  • Redis如果挂了或者使用老版本的Redis,其会进行全量同步,此时Redis是不可用的,这个时候我们只能访问数据库,很容易造成雪崩。
  • 访问Redis会有一定的网络I/O以及序列化反序列化,虽然性能很高但是其终究没有本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。这个思路并不是我们做互联网架构独有的,在计算机系统中使用L1,L2,L3多级缓存,用来减少对内存的直接访问,从而加快访问速度。
type RedisCacheClient struct {
	client         *redis.Client
	prefix         string
	plugins        []Plugin
	status         *cacheStat
	unstableExpiry mathx.Unstable
	loadGroup      flightGroup 
	DefaultExpire  time.Duration
	localCache  *freecache.Cache // 本地缓存,实际用的是freecache.Cache
}

并发控制

并发控制指的是在并发的情况下请求同一个key保证只会有一个请求落到数据库中,这样做可以减少对数据库的压力

// 并发控制,定义了一个接口,可以自己实现这个接口做并发的控制
type flightGroup interface {
	// Done is called when Do is done.
	Do(key string, fn func() (interface{}, error)) (interface{}, error)
}

数据统计

数据统计指的是缓存库中会统计当前缓存的Hit和Miss率,用于观测缓存的使用情况

type cacheStat struct {
	hit          uint64 // local cahe + remote cahe
	miss         uint64 // local cahe + remote cahe
	localCacheHit uint64
	localCacheMiss uint64
}

插件机制

提供接口,对缓存获取的一些日志解析记录或者信息上报到监控系统,实现这个接口就做一些自定义在请求开始和结束时的工作。在

type Plugin interface {
	OnSetRequestEnd(ctx context.Context, cmd string, elapsed int64, fullKey string, err error)
	OnGetRequestEnd(ctx context.Context, cmd string, elapsed int64, fullKey string, err error)
}

需要解决的问题

HotKey

HotKey 的问题在这里并没有解决,但是我看了下一些解决方案。在这里可以参考一些

  • 热点key问题的发现与解决
  • 有赞透明多级缓存解决方案(TMC)
  • 在单机内存中使用 hashmap 统计每个 key 的访问频次,这里可以使用滑动窗口统计,即每个窗口中,维护一个 hashmap,之后统计所有未过去的 bucket,汇总所有 key 的数据。之后使用小堆计算 TopK 的数据,自动进行热点识别。
缓存穿透

缓存穿透存在的原因是请求不存在的数据。这里有两个解决方案:Bloomfilter 和 设置空值

Bloomfilter
  • Bloom Filter概念和原理
  • 海量数据处理算法—Bloom Filter
设置空值

在原始数据源中查询不到数据或者查询返回错误时。设置一个约定的空值到缓存中,应用程在发现是这个约定的空值的时候,再做对应的处理。

	NoneValue = []byte("NoneValue")	
// not found key
	if err == redis.Nil {
		r.status.IncrementMiss()
		if fetch!=nil {

			var b []byte
			_, err = r.loadGroup.Do(fullKey, func() (interface{}, error) {
				var fetchResult interface{}
				if val, err := r.localCache.Get(fullKeyByte); err == nil {
					return val, nil
				}
				v, e := fetch()
                // if fetch data return err, will set NoneValue to cache
				if e != nil {
					logger.Error("get redis key: %v, from fetch error: %v", fullKey, e)
					// set none value
					expiration := r.unstableExpiry.AroundDuration(r.DefaultExpire)
					_ = r.localCache.Set(fullKeyByte, NoneValue, int(expiration.Seconds()))
					return NoneValue, nil 
				}
				expiration := r.unstableExpiry.AroundDuration(r.DefaultExpire)
				b, _ = json.Marshal(fetchResult)

				_ = r.localCache.Set(fullKeyByte, b, int(expiration.Seconds()))
				return v, nil
			})
			if err != nil {
				return nil, err
			}
			return b, nil
		}
	}
缓存击穿

缓存击穿的原因是热点数据的过期,因为是热点数据,所以一旦过期可能就会有大量对该热点数据的请求同时过来,这时如果所有请求在缓存里都找不到数据,如果同时落到DB去的话,那么DB就会瞬间承受巨大的压力,甚至直接卡死。

防止缓存击穿可以使用 singleflight.go, 这个库可以保证对同一个Key的请求只会有一个到达数据源

缓存雪崩

缓存雪崩的原因是大量同时加载的缓存有相同的过期时间,在过期时间到达的时候出现短时间内大量缓存过期,这样就会让很多请求同时落到DB去,从而使DB压力激增,甚至卡死。用了go-zero 中的 unstable 会返回一个与设置的时间有偏差的过期时间。避免大量Key在同一时间过期。

  • 随机过期
  • 在过期时间上加上5%的标准偏差,5%是假设检验里P值的经验值

参考

  • 缓存系统稳定性 - 架构师峰会演讲实录
  • 进程内缓存助你提高并发能力!
  • 懂得取舍才是缓存设计的真谛
  • 如何优雅的设计和使用缓存
  • github cache 代码

你可能感兴趣的:(Golang,缓存,数据库,golang)