缓存使用的一些经验

在一个大的项目中, 使用了全缓存模型, 即, 所有数据都会经过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为例, 批量操作

  1. 代码for循环, 一个个获取
  2. 可以考虑使用mget/mhget之类的多个key
  3. 使用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以获取各个环节的上下文信息, 快速调试