缓存使用的一些经验

语言: CN / TW / HK

在一个大的项目中, 使用了全缓存模型, 即, 所有数据都会经过cache.

简单分层: 应用->内存缓存->redis缓存->数据库

是一个典型的 多读写少 的场景, 并且数据量, 请求量非常大.

总结了一些使用经验, 供参考

1. 更新缓存的Design Pattern: 使用Cache aside

简洁优雅

关于缓存更新, 可以阅读这篇文章: CoolShell: 缓存更新的套路

为什么选择 Cache Aside Pattern , 因为这个模式足够简单, 出现不一致的概率非常低, 对于大多数项目来说够用了.

而其他几种模式, 复杂度会高很多.

2. 并发很高时, 需要防缓存击穿

当并发很高的时候, 一个热点 key 失效, 会触发回数据库重查的逻辑, 此时会有大量请求落到数据库

需要做防缓存击穿的处理.

一般各种语言的库, 都有考虑到这一点, 例如 go-redis/cache

如果是golang并且自定义了cache, 可以使用 singleflight , 其他语言也可以找类似机制的库.

这个库很轻量

# define
type Cache struct {
	name              string
	keyPrefix         string
	codec             *cache.Cache
	cli               *redis.Client
	defaultExpiration time.Duration
	G                 singleflight.Group
}

# usage
// if missing, call retrieveFunc
data, err, _ := c.G.Do(key.Key(), func() (interface{}, error) {
		return retrieveFunc(key)
	})

3. 缓存空值, 需要防缓存穿透

如果一个key不存在, 在缓存中查不到, 在数据库中也查不到, 那么这个key的请求每次都会穿透到数据库

此时, 可以引入 bloomfilter 或者 cuckoofilter ;

但是, 更简单的做法是, 缓存空值; 当成一个普通的key处理(缓存失效/数据一致性处理等)

4. 总是设置过期时间, 并且带随机数避免缓存雪崩

大部分场景下, 给每一个缓存 key 设置 TTL 是一个很好的习惯. 可以避免无用数据占用资源, 及时淘汰掉使用较少的数据.

但是, 设置 TTL 的时候, 建议加上一个范围内容的随机数, 避免缓存在同一时间失效, 造成缓存雪崩.

TTL = 900s + randint(0,10)

5. key 中使用namespace+version前缀

key = {namespace}:{version}:{type}:{uniqueKey}

在实际应用部署中, 由于可能跟其他应用共用一套缓存, 所以建议缓存的 key 加入前缀, 防止冲突(如果冲突, 非常难以debug)

另外, 需要加入一个 version , 在版本发布必要时变更, 以弃用缓存中已有的数据

  1. 由于不断迭代开发, 同一个key对应的value可能会变更, 例如value对应的数据结构新增了一个字段, 那么此时缓存中存量的缓存数据是没有这个字段的, 可能会造成一些bug.
  2. 还有另外一个需要特别小心的是, 升级缓存第三方库的时候, 某些版本可能是breaking change, 例如改变了压缩算法, 此时存量数据将无法正确被获取. 一个例子: Can’t upgrade from v7 to v8 directly?

6. 缓存结构体, 使用msgpack替代json

MessagePack: It’s like JSON.but fast and small

优点:

缺点:

  • 在redis等服务端debug获取时不是明文, 不是很利于调试

所以, 缓存数据量比较大, 并且对性能有要求的, 可以使用msgpack

7. value比较大, 可以考虑启用压缩

如果 value 比较大, 那么在放入缓存前, 可以进行一次压缩, 获取后再解压

当然, 这个会产生额外的资源消耗(CPU), 以及会多一些耗时.

但是, 这个有利于减少网络传输中的包大小. 如果 读取 是非常高频的话, 那么代价还是值得的.

可以参考 go-redis/cache , 当值超过一定大小时使用 s2 compression 进行压缩

8. 批量操作, 使用pipeline

以redis为例, 批量操作

mget/mhget
pipeline

可以根据 key-value 特征, 批量 key 的数量等, 简单压测下性能, 决定使用哪种方式. 正常情况下, key 数量较大的时候, pipeline 性能最好.

甚至, 代码实现可以根据 key 的数量, 自行决定使用 mget 还是 pipeline

9. 内存缓存 vs Redis

大部分情况, 项目中会混用两种缓存.

如果对数据一致性要求比较高, 可以全部使用 Redis.

但是, 其实每一次 Redis 操作代价大于内存操作

某些数据, 例如模型, 主键之类的, 一旦确定, 是不会变更的.

此时, 可以考虑使用 内存缓存 替代.

如果是golang, 推荐使用 go-cache . 没有其他实现那么强大, 但是胜在不需要序列化/反序列化.

10. 多级缓存 and client-side-cache

如果使用的 Redis6, 并且程序的driver支持, 那么可以直接利用 client-side-caching 特性获取最大的性能. 这个对程序透明, 无需在额外的逻辑处理.

但是, 当前(2022)有很多时候, 部署基建还是老版本Redis, 很多语言的driver也还没有支持, 可能复用不了

那么, 此时如果使用了 内存->redis 两级缓存, 如何确保数据一致性.

可以做的额外操作:

  1. 实现类似redis6 client-side-cache机制, 通过发布订阅等方式实现
  2. 可以使用一个 sorted-set 存储 5 分钟内变更的 key , 内存缓存TTL设置 5 分钟; 每次先获取变更 key 列表, 本地缓存进行时间戳对比(这个方案对于批量key操作性能提升很大, 相当于把 N 次redis操作, 变成 本地缓存+ 1 次 changedkeylist获取+M次redis操作)

11. 配置建议: 同时支持standalone和sentinel配置

让运维根据实际应用场景, 自行切换使用.

成本不高的话, 也可以支持下 redis-cluster 配置

注意, 开启 pool 以获取更好的性能

另外, 也需要关注下如何开启prometheus/otel相关的配置, 以便某些情况下, 监测相关的指标

12. 设置开关, 支持withCache/withoutCache调试

引入缓存后, 在进行问题调试的时候非常不变.

建议加入相关的调试标志, 例如 ?force=true

  • 加上, 全链路数据获取走数据库
  • 没加, 全链路走缓存

此时, 可以通过对比两次请求的差异, 确定是否是缓存问题

甚至, 可以加入 ?debug=true 以获取各个环节的上下文信息, 快速调试