缓存

只是一篇简单的读书笔记

主要介绍缓存使用技巧和设计方案

  • 缓存的收益和成本分析
  • 缓存更新策略的选择和使用场景
  • 缓存粒度控制方法
  • 穿透问题优化
  • 无底洞问题优化
  • 雪崩问题优化
  • 热点key重建优化

作为一个并发量较大的应用,使用缓存有三个目标

  • 加快用户访问速度,提高用户体验
  • 降低后端负载,减少潜在的风险,保证系统平稳
  • 保证数据“尽可能”及时更新

缓存的收益和成本

  • 收益
    • 加速读写
    • 降低后端负载(较少 SQL 复杂计算)
  • 成本
    • 缓存和存储数据不一致
    • 代码维护成本
    • 运维成本,集群增加运维成本
  • 场景
    • 开销大的复杂计算: 例如 SQL 查询
    • 加速请求相应

缓存更新策略

缓存数据需要在指定时间后删除或者更新,保证缓存空间在一个可控的范围。

比较推荐策略是结合剔除、超时、主动更新三种方案共同完成

LRU/LFU/FIFO 算法剔除

用于缓存使用超过预设的最大值时候

Redis 使用 maxmemory-policy 配置作为内存最大值后对于数据的剔除策略

  • 一致性: 具体清除哪些数据由算法决定,对于开发者透明,一致性最差
  • 维护成本: 只需要配置 maxmemory 和对应的策略即可、开发者只需要知道每种算法的含义即可

超时剔除

通过给缓存数据设置过期时间,过期后自动删除。

如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。

  • 一致性: 一定过期时间内存在一致性问题,缓存数据和真实数据源的数据不一致
  • 维护成本: 只需要设置过期时间即可,维护成本底

主动更新

对于一直性要求比较高,在真实的数据更新后立即更新缓存数据

  • 一致性: 一致性最高,但是如果主动更新失败,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好
  • 维护成本: 开发者主动完成缓存更新,维护成本高

实践

  • 底一致性的业务建议配置最大内存和淘汰策略的方式使用
  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据

缓存粒度控制

以 Redis 和 MySQL 为例,缓存全部属性还是只缓存部分重要属性

  • 通用性: 缓存全部数据比部分数据更加通用,但很长实践内应用只需要几个重要的属性
  • 空间占用: 全部数据占用更多的空间,传输时比较耗时,序列化和反序列化 CPU 比较耗时
  • 代码维护: 如果增加新的字段则需要修改程序

穿透优化

缓存穿透: 查询一个根本不存在的数据,缓存层和存储层都不会命中

缓存穿透导致不存在的数据每次请求都要到存储层查询,失去了缓存保护后端存储的意义

使用缓存空对象布隆过滤器来解决

  • 当存储层没有命中,仍然将空对象保留在缓存层中,之后再访问这个数据就会从缓存中获取,保护了存储层
    • 空值缓存,浪费内存空间(如果是攻击更严重),所以需要设置一个较短的过期时间
    • 缓存层和存储层不一致,某个 value 为空值并且设为 5 分钟,而此时存储层增加了这个数据,则会出现不一致,需要主动清除缓存层空对象
  • 使用布隆过滤器拦截,在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。
    • 例如: 一个推荐系统有 4 亿个用户 id,每个小时算法工程师会根据每个用户的历史行为计算出推荐数据放在存储层中,但是最新的用户没有历史行为,所以会发生缓存穿透,可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户 id 不存在,那么就不会访问存储层,从而保护了存储层
    • 有关布隆过滤器的相关知识,可以参考: https://en.wikipedia.org/wiki/Bloom_filter 可以利用Redis的Bitmaps实现布隆过滤器,GitHub上已经开源了类似的方案,可以进行参考: https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter
    • 这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
解决缓存穿透使用场景维护成本
缓存空对象数据命中不高,数据频繁变化实时性高代码维护简单,需要过多的缓存空间,数据不一致
布隆过滤器数据命中不高,数据固定实时性底代码维护复杂,缓存空间占用少

无底洞优化

无底洞现象: 添加了大量的新的节点,性能不但没有好转反而下降

键值数据库通常采用哈希函数将 key 映射到各个节点,造成了 key 的分布与业务无关,增加大量节点水平扩容,导致键值分布到更多的节点上,相比于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大
  • 网络连接数变多,对节点的性能也有一定影响

更多的节点不代表更高的性能

常用优化

  • 优化命令,例如优化 SQL 语句
  • 减少网络通信次数
  • 降低接入成本,长连接、连接池等

假设命令和客户端已经是最优,讨论减少网络操作次数

// todo

雪崩优化

如果缓存层由于某些原因不能提供服务,所有请求都会到达存储层,存储层调用量会暴增。

缓存雪崩的英文原意是stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。

  • 保证缓存层服务高可用性
  • 依赖隔离组件为后端限流并降级
  • 提前演练

热点 key 重建优化

如果某个 key 是一个热点 key,并发量非常高,重建缓存不能在短时间内完成(是一个复杂的 SQL 等原因),在缓存失效的瞬间,有大量的线程来重建缓存,造成后端负载加大,甚至让应用崩溃

  • 互斥锁: 只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可
    • 优点: 有效降低后端存储负载,并在一致性上做的比较好
    • 缺点: 如果构建缓存过程出现问题或者时间比较长,可能会存在死锁或阻塞
  • 永不过期: 在缓存层,确实没有设置过期时间,在功能层,为每个 value 设置一个逻辑过期时间,当发现超过逻辑时间后,会使用单独的线程去构建缓存
    • 优点: 由于没有设置真正的过期时间,实际不存在热点 key 产生的一系列危害
    • 缺点: 存在数据不一致的情况,同时代码复杂度会增加

代码示例

如果同时多个线程访问存储层也有可能造成缓存雪崩,所以解决缓存雪崩和热点 key 都可以使用互斥锁方案,当多个线程同时访问同一资源时只用其中一个线程去访问,其他线程从缓存层读取。

在 golang 的 groupcache 源码中的 singleflifht 是一个非常完美的互斥锁实现,同时 golang.org/x/sync/singleflight 是一个比较完善的版本

https://github.com/golang/groupcache 源码不到 100 行就完美解决了并发问题

// Package singleflight provides a duplicate function call suppression
// mechanism.
package singleflight

import "sync"

// call is an in-flight or completed Do call
type call struct {
	wg  sync.WaitGroup
	val interface{}
	err error
}

// Group represents a class of work and forms a namespace in which
// units of work can be executed with duplicate suppression.
type Group struct {
	mu sync.Mutex       // protects m
	m  map[string]*call // lazily initialized
}

// Do executes and returns the results of the given function, making
// sure that only one execution is in-flight for a given key at a
// time. If a duplicate comes in, the duplicate caller waits for the
// original to complete and receives the same results.
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call)
	}
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()

		// 等待 “真正” 在运行的协程完成
		c.wg.Wait()

		// 直接返回 “真正” 运行的协程的返回值
		return c.val, c.err
	}
	c := new(call)
	c.wg.Add(1)

	// 添加到 map 中,其他协程通过 key 来判断是否有相同 key 的协程正在运行
	g.m[key] = c
	g.mu.Unlock()

	// 执行方法,保存运行结果
	c.val, c.err = fn()
	c.wg.Done()

	g.mu.Lock()

	// 删除 map,保证数据一致性
	// 只有在 协程 运行过程中进入到的协程才能共享到 “真正” 运行协程的返回值
	delete(g.m, key)
	g.mu.Unlock()

	return c.val, c.err
}

使用示例:

参考链接