使用缓存
为什么使用缓存
缓存就是数据交换的缓冲区(称作Cache),目的就是提高我们的接口性能,特别是那些需要大量CPU计算和I/O获取的数据。
使用缓存带来的问题
缓存虽然能够提高应用程序的性能,但也会带来一些问题。比如:缓存失效,缓存击穿,缓存雪崩,数据一致性问题
缓存雪崩
缓存失效为什么会带来问题呢?试想一下,单个的缓存失效其实并不会引发多大的问题,问题在于当大量的Key同时失效时,在高并发的情况下,大量的请求同时到数据库层,会给数据库层带来压力,从而引发其他的问题。
解决方案
优化过期时间
既然是同时失效,那么我们只需要在Key的失效时间上再加上一个随机时间就好了,也就是失效时间 + 随机时间。go-zero 上已经有相关的代码,我简单摘抄出来看下
go
// A Unstable is used to generate random value around the mean value base on given deviation.
type Unstable struct {
deviation float64
r *rand.Rand
lock *sync.Mutex
}
// AroundDuration returns a random duration with given base and deviation.
func (u Unstable) AroundDuration(base time.Duration) time.Duration {
u.lock.Lock()
val := time.Duration((1 + u.deviation - 2*u.deviation*u.r.Float64()) * float64(base))
u.lock.Unlock()
return val
}
优化缓存
采用多级缓存,不同级别缓存设置的超时时间不同,及时某个级别缓存都过期,也有其他级别缓存兜底。代码如下,完整代码见:cache_redis.go
go
func (r *RedisCacheClient) Get(ctx context.Context, key string, fetch fetchFunc) (result []byte, err error) {
var byteValue []byte
fullKey := getFullKey(r.prefix, key)
fullKeyByte, _ := json.Marshal(fullKey)
if val, err := r.localCache.Get(fullKeyByte); err == nil {
r.status.IncrementLocalCacheHit()
return val, nil
}
r.status.IncrementLocalCacheMiss()
startTime := time.Now()
byteValue, err = r.client.Get(fullKey).Bytes()
elapsed := time.Since(startTime).Milliseconds()
for _, p := range r.plugins {
p.OnGetRequestEnd(ctx, cmdGet, elapsed, fullKey, err)
}
// 数据源拉取原始数据
........
}
缓存击穿
对于某些key设置了过期时间,但是其是热点数据,如果某个key失效,可能大量的请求打过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。
解决方案
多级缓存+singleflight
我们可以设置多级缓存,每一级缓存失效时间不一样,某个级别缓存过期,也有其他级别缓存兜底。而且再加上singleflight 限制,就可以做每一个服务实例只有一个请求最终到数据库源上,大大减轻了数据源压力
缓存穿透
缓存穿透是指查询的数据在数据库是没有的,那么在缓存中自然也没有,所以,在缓存中查不到就会去数据库取查询,这样的请求一多,那么我们的数据库的压力自然会增大。
解决方案
设置Null值
- 约定:对于返回为Null的依然缓存,对于抛出异常的返回不进行缓存,注意不要把抛异常的也给缓存了。采用这种手段的会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。
数据过滤
- 小数据用BitMap,大数据可以用布隆过滤器
数据一致性问题
我们通常说的数据一致性指的是在程序运行过程中本地缓存、分布式缓存、mysql数据库三者之间的数据一致性
本地缓存与DB保持一致
解决方案
MQ 方案
- 应用实例1收到请求,更新 db,同时更新应用自己的本地缓存.
- 应用实例1 发送更新 mq 广播消息.
- 应用 实例2 和应用实例3 收到消息,查询 db,更新本地缓存.
- 这个时候应用实例1,2,3与 DB 数据就保持一致
Redis与DB保持一致
基于 binlog 方案
- 更新 db 数据
- 监听 mysql binlog, 并写入到MQ
- 启动一个数据处理应用,消费 MQ 数据并进行数据加工
- 将加工后的数据写入 redis
- 查询 redis 数据返回
延迟双删方案
先进行缓存清除,再执行 update sql,最后(延迟 N 秒)再执行缓存清除。
上述中(延迟 N 秒)的时间要大于一次写操作的时间,一般为 3-5 秒。
基于定时任务方案
1.更新 db 数据,同时写入数据到 redis
2.启动一个定时任务定时将 db 数据同步到 redis
热key和大key问题
热key
热key是服务端的常见问题,指一段时间内某个key的访问量远远超过其他的key,导致大量访问流量落在某一个redis实例中;或者是带宽使用率集中在特定的key
以被请求频率来定义是否是热key,没有固定经验值。某个key被高频访问导致系统稳定性变差,都可以定义为热key。
可能造成的问题
- 占用大量的CPU资源,影响其他请求并导致整体性能降低。
- 集群架构下,产生访问倾斜,即某个数据分片被大量访问,而其他数据分片处于空闲状态,可能引起该数据分片的连接数被耗尽,新的连接建立请求被拒绝等问题。
- 在抢购或秒杀场景下,可能因商品对应库存Key的请求量过大,超出Redis处理能力造成超卖。
- 热Key的请求压力数量超出Redis的承受能力易造成缓存击穿,即大量请求将被直接指向后端的存储层,导致存储访问量激增甚至宕机,从而影响其他业务。
发现方法
开发独立的热 key 检测系统
提供单独的热 key 检测的接入 sdk,应用系统引入该 sdk 后,热 key 检测系统自动计
算是否热 key 并推送相关结果给应用系统,应用系统根据业务实际情况进行相应处理。
改写 redis 客户端收集上报数据
改写 Redis SDK,记录每个请求,定时把收集到的数据上报,然后由一个统一的服务进行聚合计算。
解决方案
利用本地缓存
在你发现热 key 以后,把热 key 加载到系统的内存中。针对这种热 key 请求,会直接从内存中取,而不会走到 redis 层。
- 优点:内存访问和 redis 访问的速度不在一个量级,基于本地缓存,接口性能非常好, 可以
大大增加单实例的 QPS。 - 缺点:受应用内存限制,容量有限,数据量非常大的时候,占用太多内存,不太适合。部分热点数据,需要提前预知。热点数据自动检测有一定的延迟,系统短时间内承受的风险比较大。
大key
大key是指当redis的字符串类型占用内存过大或非字符串类型元素数量过多
生产环境中,综合衡量运维和环境的情况,给大key定义参考值如下:
- string类型的key超过10KB
- hash/set/zset/list等数据结构中元素个数大于5k/整体占用内存大于10MB
可能造成的问题
- 客户端执行命令的时长变慢。
- Redis内存达到maxmemory参数定义的上限引发操作阻塞或重要的Key被逐出,甚至引发内存溢出(Out Of Memory)。
- 集群架构下,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。
- 对大Key执行读请求,会使Redis实例的带宽使用率被占满,导致自身服务变慢,同时易波及相关的服务。
- 对大Key执行删除操作,易造成主库较长时间的阻塞,进而可能引发同步中断或主从切换。
发现方法
实时统计
我们可以通过在Redis 客户端上实时统计出大Key,直接计算出Key对应的Value值大小就可以,例如
go
// b 为序列化之后的数据
b, err := utils.Serialize(value, c.getSerializer())
if err != nil {
return err
}
// var b []byte
// 长度
reqSize = len(b)
// 10KB
bigKey := 1024 * 10
if reqSize > bigKey {
}
- 优点:对性能几乎无影响。
- 缺点:返回的Key序列化长度并不等同于它在内存空间中的真实长度,因此不够准确,仅可作为参考。
离线全量Key分析
-
对Redis的RDB备份文件进行定制化的分析,帮助您发现实例中的大Key,掌握Key在内存中的占用和分布
-
Redis提供了bigkeys参数能够使redis-cli以遍历的方式分析Redis实例中的所有Key,并返回Key的整体统计信息与每个数据类型中Top1的大Key,bigkeys仅能分析并输入六种数据类型(STRING、LIST、HASH、SET、ZSET、STREAM),命令示例为
redis-cli -h 127.0.0.1 -p 6379 --bigkeys
-
优点:可对历史备份数据进行分析,对线上服务无影响。
-
缺点:时效性差,RDB文件较大时耗时较长。
解决方案
- 业务拆分,将key的含义更细粒度化,避免大key出现。
- 数据结构上拆分。如果大key是个大json,可以通过mset的方式,将这个key的内容打散到各个实例中,减小大key对数据量倾斜的影响;如果是大list,可以拆成list_1,list_2,list_N;其他数据结构同理。(可以考虑增加单独key存储大key被拆分的个数或元数据信息)
- 对于长文本,更建议使用文档型数据库例如MongoDB等。
- 对一致性要求不高的场景,尝试使用客户端缓存。(只解决了redis的阻塞问题,但机器或局域网的带宽问题没有改善)
- 对大key的压缩。相当于用cpu资源来降低网络io,其中google提出的snappy算法较常用。
- 对于hash等数据结构,需要注意业务是否可以引入定期清理无效field的机制。
- Hash 结构不建议使用,没有办法对具体的Key做过期时间设置,只能再额外开发功能去做,增加开发成本