NOTE

2.9 如何设计缓存系统

1. 什么是缓存 CPU和磁盘的速度差异巨大,一般会根据局部性原理把常用的数据加载到内存中,以提高访问速度 2. 为什么需要缓存 - 提高性能 - 降低下游负载 3. 缓存的缺点 3.1. 缓存一致性 - CAP原则下延迟导致的一致性是没办法解决的,只能保证最终一致性 - 如果是read-throu

系统设计创建于 更新于 historical

这是历史学习笔记,可能存在过时或不完整的理解。

1. 什么是缓存

CPU和磁盘的速度差异巨大,一般会根据局部性原理把常用的数据加载到内存中,以提高访问速度

2. 为什么需要缓存

  • 提高性能
  • 降低下游负载

3. 缓存的缺点

3.1. 缓存一致性

3.2. 额外的存储空间

典型的空间换时间

4. 缓存层次

4.1. 客户端缓存

  • HTTP缓存.md(关联笔记尚未公开)

4.2. CDN缓存

  • CDN.md(关联笔记尚未公开)

4.3. Web服务器缓存

  • Nginx.md(关联笔记尚未公开)

4.4. 业务服务器缓存

  • 本地内存

4.5. 缓存中间件缓存

4.6. 数据库缓存

  • MySQL内置的缓存

5. 缓存的使用

5.1. 缓存大小

缓存的大小可以设置为平均活跃用户数,这样就不会被淘汰出缓存

5.2. 缓存时长

缓存的时长可以设置为平均活跃时间,这样就不会被淘汰出缓存

5.3. 缓存命中率

缓存的好坏要看命中率。缓存的命中率高说明缓存有效,一般来说命中率到 80% 以上就算很高了 对于非热点数据,可以用一致性Hash把请求路由到同一个节点,缓存命中率就会相当高,但是依然会有数据热点问题,原因在于如果请求量上来了,即使扩容了,那么一致性Hash的特定会导致原有的请求打到原来的节点,扩容根本没用。虽然可以用虚拟节点,可是虚拟节点需要迁移数据,导致缓存不命中,不过还好

5.4. 缓存预热

统计访问次数多的数据,启动服务器的时候把数据加载到缓存中

5.5. 缓存淘汰策略

缓存替换策略.md(关联笔记尚未公开)

5.6. 缓存架构模式

5.6.1. cache-as-sor

  • 业务代码和缓存分开。一般配合read-through+write-back或者read-through+write-through或者refresh-ahead
  • 优点:耦合度低
  • 缺点:灵活度低

5.6.2. cache-aside

  • 业务代码和缓存放在一起。一般配合read-through+write-invalid
  • 优点:灵活度高
  • 缺点:耦合度高

5.7. 缓存更新

5.7.1. read-through+write-invalid

  • 读的时候, 缓存中有,直接返回;没有则取数据库取,然后放入缓存,再返回
  • 写的时候,先更新数据库,再删除缓存

PlantUML 图表

5.7.1.1. 为什么写的时候是删除缓存而不是更新缓存

这是lazy计算的思想,即用到的时候再做计算。 更新缓存涉及一系列复杂操作比较耗时,且有可能之后不会用到 本质上就是重写轻读还是重读轻写

5.7.1.2. 缓存一致性问题
5.7.1.2.1. 并发读写问题

无论先更新数据库还是先删除缓存都会出现缓存不一致的问题

  1. 先删除缓存再更新数据库,在并发读写一个key的时候会出现缓存一致性问题

    • 用户1写操作:删除Redis缓存
    • 用户2读操作:从MySQL读取数据
    • 用户2读操作:把数据写入Redis
    • 用户1写操作:写入数据库
  2. 先更新数据库再删除缓存,并发读写一个key的时候会出现缓存一致性问题。

    • 用户1读操作:从MySQL读取数据
    • 用户2写操作:更新MySQL数据
    • 用户2写操作:删除Redis缓存
    • 用户1读操作:把数据写入Redis 这种是读操作开始在写操作提交之前,并且放入缓存又晚于写操作之后的删除缓存。相比于上面的删除缓存再更新数据库,这种脏数据的概率低得多
  3. 如何解决

    • 最终一致性:加上缓存过期时间
    • 强一致性:加锁
5.7.1.2.2. 网络错误问题
  1. 更新数据库成功但是删除缓存失败,针对网络错误只能重试
    • 更新数据库后,把删除缓存的操作丢入消息队列,重试直至成功
    • 或者更新完数据库后,使用canal订阅bin-log,把删除缓存的操作丢入消息队列,重试直至成功

5.7.2. read-through+write-through

  • 读的时候, 缓存中有,直接返回;没有则取数据库取,然后放入缓存,再返回
  • 写的时候,先更新缓存,然后更新数据库。即双写

PlantUML 图表

5.7.2.1. 缓存一致性问题

无论先更新数据库还是先更新缓存都会出现缓存不一致的问题

5.7.2.1.1. 并发写问题
  1. 先更新数据库再更新缓存, 并发写的时候会出现缓存一致性问题

    • 用户1写操作:更新数据库
    • 用户2写操作:更新数据库
    • 用户2写操作:更新缓存
    • 用户1写操作:更新缓存,此时缓存中的是旧的数据 但实际上并发写的情况比较少,并且写同一条记录数据库有锁,所以概率很低。
  2. 先更新缓存再更新数据库,并发写的时候会出现缓存一致性问题

    • 用户1写操作:更新缓存
    • 用户2写操作:更新缓存
    • 用户2写操作:更新数据库
    • 用户1写操作:更新数据库,此时数据库中的是旧的数据
  3. 如何解决

    • 最终一致性:加上缓存过期时间
    • 强一致性:加锁
5.7.2.1.2. 网络错误问题
  1. 更新数据库成功但是更新缓存失败,针对网络错误只能重试
    • 更新数据库后,把更新缓存的操作丢入消息队列,重试直至成功
    • 或者更新完数据库后,使用canal订阅bin-log,把更新缓存的操作丢入消息队列,重试直至成功

5.7.3. read-through+write-back

  • 读的时候, 缓存中有,直接返回;没有则取数据库取,然后放入缓存,再返回
  • 写的时候,只更新缓存,不更新数据库。缓存会异步地批量更新数据库

PlantUML 图表

5.7.3.1. 为什么不直接更新数据库
  • 只操作内存,IO效率很高
  • 还可以合并对同一个数据的多次操作
5.7.3.2. 缓存一致性问题
5.7.3.2.1. 宕机
  • 数据在缓存中没刷入数据库前,如果宕机那么数据就丢失了

5.7.4. refresh-ahead

  • 读的时候,只从缓存中读
  • 写的时候,只写数据库。
  • 定时任务,拉取数据库中的全量数据,更新缓存

5.7.5. CDC

  • 变更数据捕获系统
  • 比如Linkedin的databus、阿里巴巴的canal(关联笔记尚未公开)

5.7.6. 方案对比

  • refresh-ahead:
    • 优点:读取数据的时候只走缓存,效率特别高
    • 缺点:
      • 需要定时全量拉取所有数据丢入缓存
      • 如果定时间隔太短并且数据量太大会超时,定时间隔太长则缓存和数据库不一致的时间也越长(相当于复制,复制延迟是定时拉取时间)
    • 适合小数据量的场景
  • read-through+write-back:
    • 优点:相对于refresh-ahead不用定时全量拉取;而且先写缓存后续批量写数据库相当快
    • 缺点:
      • 数据在缓存中没刷入数据库前,如果宕机那么数据就丢失了;
      • 数据不管用不用得到都刷入缓存命中率低
    • 适合数据量较大、可以丢失、写入性能要求特别高的场景
  • read-through+write-through:
    • 优点:相对于write-back数据不会丢失
    • 缺点:
      • 数据不管用不用得到都刷入缓存命中率低
      • 有读写并发问题
    • 适合数据量较大、不能丢失、写入性能要求没那么高的场景
  • read-through+write-invalid:
    • 优点:相对于write-through缓存命中率高,占用内存小
    • 缺点:
      • 相对于write-through需要get请求数据的时候才把数据丢入缓存,效率偏低
      • 有读写并发问题
    • 适合数据量特别大、不能丢失、写入性能要求没那么高的场景

5.8. 缓存回源

  • 如果在缓存中没读到数据,那么去数据库中读取叫做回源
  • 如果缓存需要回源,那么会有以下问题

5.8.1. 缓存穿透

5.8.1.1. 是什么
  • 查询的key不存在,那么数据库和缓存中都不会有,查询请求就会落到数据库中,如果这个请求量太大(比如黑客攻击),那么数据库就会被打挂
5.8.1.2. 如何解决
5.8.1.2.1. 缓存空值
  • 对返回为空的 Key 值依然进行缓存,如果插入了数据那么需要清除这个空缓存
  • 缺点
    • 第一次查询的仍然得从数据库中查
    • 缓存中大量的空数据
5.8.1.2.2. BloomFilter
  • 把数据库中存在的key都记录在BloomFilter中,这样子请求过来如果key在BloomFilter中存在那么直接从缓存中读取;如果在BloomFilter中不存在那么直接返回空
  • 缺点:
    • 有一定的误差率。由于hash冲突的原因BloomFilter可能会把不存在的key当作存在的,这样子就会去数据库中查一次发现没有,不过量不大所以可以接受
5.8.1.2.3. BloomFilter vs Cache Null
BloomFilter Cache Null
优点 占用空间少 不需要定时重建
缺点 需要定时重建 占用空间大

5.8.2. 缓存击穿

5.8.2.1. 是什么
  • 缓存中热点key失效,那么查询请求就会落到数据库中,如果这个请求量太大(比如大促场景),那么数据库就会被打挂
5.8.2.2. 如何解决
5.8.2.2.1. 不自动过期
  • 设置逻辑过期时间,超过了那么手动更新缓存
  • 定期更新缓存
5.8.2.2.2. 加上互斥锁排队处理
  • 只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完再去缓存中取
  • 比如Golang的sync.singleflight.md(关联笔记尚未公开)
5.8.2.2.3. 不自动过期 vs 互斥锁
不自动过期 互斥锁
优点 不存在死锁、阻塞风险 保证一致性
缺点 数据不一致 存在死锁、阻塞风险

5.8.3. 缓存雪崩

5.8.3.1. 是什么
  • 缓存中大规模的key失效(区别于缓存击穿的热点key失效),比如缓存宕机、重启或者key同时超时,那么查询请求就会落到数据库中,如果这个请求量太大(比如大促场景),那么数据库就会被打挂
5.8.3.2. 如何解决
5.8.3.2.1. 设置不同的失效时间
  • 在基础的时间上加减一个值
  • 缺点:
    • 无法解决单个key是热点的问题
    • 解决:加上互斥锁排队处理
5.8.3.2.2. 提高缓存系统可用性
  • 比如Redis开启集群模式,开启持久化模式
5.8.3.2.3. 启用透明多级缓存
  • 比如本地缓存

6. 如何设计缓存中间件

7. 参考