写点什么

深入浅出 redis 缓存应用

作者:六月的
  • 2022-10-19
    上海
  • 本文字数:3708 字

    阅读完需:约 1 分钟

0.1、索引

https://blog.waterflow.link/articles/1663169309611

1、只读缓存

只读缓存的流程是这样的:


当查询请求过来时,先从 redis 中查询数据,如果有的话就直接返回。如果没有的话,就从数据库查询,并写入到缓存中。


当删改请求过来时,会直接从数据库中删除修改数据,并把 redis 中保存的数据删除。


这样做的好处是,所有最新的数据都在数据库中,而数据库是有数据可靠性保障的。

2、读写缓存

读写缓存的流程是这样的:


  • 当查询请求过来时,先从 redis 中查询数据,如果有的话就直接返回。如果没有的话,就从数据库查询,并写入到缓存中。

  • 当增删改请求过来时,得益于 Redis 的高性能访问特性,数据的增删改操作可以在缓存中快速完成,处理结果也会快速返回给业务应用,这就可以提升业务应用的响应速度。

  • 但是和只读缓存不同的是,最新的数据都是在 redis 中,一旦出现掉电宕机,由于 redis 的持久化机制,最新的数据有可能会丢失,就会给业务带来风险。


所以,根据业务应用对数据可靠性和缓存性能的不同要求,我们会有同步直写和异步写回两种策略。其中,同步直写策略优先保证数据可


靠性,而异步写回策略优先提供快速响应。

2.1、同步直写

当增删改请求过来时,请求到 redis 的同时,也会请求 mysql,等到 redis 和 mysql 都写完数据才会返回数据。


这样,即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。


但是也会降低缓存的使用性能,因为写缓存很快,但是写数据库就要慢很多,整个的响应时间就会增加。

2.2、异步写回

异步写回优先考虑了响应速度,写到缓存会立即响应客户端。等到数据要从 redis 中淘汰时,再同步到 mysql。


但是如果发生掉电,数据还是没有写到 mysql,还是有丢失的风险。

3、如何选择

  • 如果需要对写请求进行加速,我们选择读写缓存;

  • 如果写请求很少,或者是只需要提升读请求的响应速度的话,我们选择只读缓存。

4、关于一致性

  • 对于读写缓存的异步写回,由于是只写 redis,淘汰时才会写入 mysql,如果发生宕机不能保证一致性

  • 对于读写缓存的同步写回,由于 redis 和 mysql 是同时写,需要加入事物机制,要么都执行要么都不执行,可以保证一致性。(问题:如何保证原子性?当有并发写过来时即使都执行了也可能会不一致,这是就要引入锁保证互斥性)

  • 对于只读缓存,如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。由于 redis 和 mysql 是同时操作,需要加入事物机制,要么都执行要么都不执行,可以保证一致性。(问题:如何保证原子性?)

4.1、对于只读缓存的一致性问题

先删除缓存,再更新数据库


  • 如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数库,但是数据库中的值为旧值,应用就访问到旧值了。

  • 如果线程 A 都成功了,但是同时另一个线程 B 在线程 A 的这俩个请求中间过来。这个时候缓存已经删除,但是数据库还是旧值,线程 B 发现没有缓存,就从数据库读读取了旧值更新到 redis 中,然后线程 A 把新值更新到数据库。此时 redis 中是旧值,mysql 中是新值。


先更新数据库,再删除缓存中的值


  • 如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。

  • 如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。(可以理解为最终一致性,读到旧数据只是暂时的,最终都会读到新数据


所以一般项目中使用只读缓存,先更新数据库,再删除缓存。这样的代价是最小的,而且尽量保证了一致性。

5、缓存异常

5.1、缓存雪崩

缓存雪崩是指,大量的请求无法在 redis 中处理(redis 没拦住),直接打到了 mysql,导致数据库压力激增,甚至服务崩溃。


redis 无法处理的原因有两种:


缓存中大量数据同时过期


解决方案:


  • 给过期时间增加一个较小的随机数,过期的数据通过时间去分摊

  • 服务降级,直接返回错误信息


Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层


解决方案:


  • 服务熔断或者请求限流,redis 客户端直接返回,不会请求到 redis 服务,但是影响范围比较大

  • 构建 redis 集群,提高可用性

5.2、缓存击穿

缓存击穿是指,访问某个热点数据,无法在缓存中处理,大量请求打到 mysql,导致数据库压力激增,甚至服务崩溃。


解决方案:


  • 对于频繁访问的热点数据不设置过期时间

5.3、缓存穿透

缓存穿透是指,要访问的数据既不在 redis 中,也不在 mysql 中。请求 redis 发现数据不存在,继续访问 mysql 发现数据还是不存在,然后也无法写回缓存,下次继续请求的时候还是会打到 mysql。


解决方案:


  • 缓存空值或者缺省值

  • 使用布隆过滤器


布隆过滤器


布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在(准确说是判断不存在,如果布隆过滤器不存在数据库中一定不存在,如果布隆过滤器判断存在,数据库不一定存在,这是布隆过滤器的机制决定的)。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:


  • 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。

  • 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。

  • 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。


如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。


所以当我们写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。

6、应用场景

我们看下 go-zero 中是如何使用缓存的,go-zero 中使用的只读缓存,当数据有更新删除操作的时候,redis 中的对应 Primary 记录和查询条件记录会同步删除。go-zero 中对某行的缓存,会缓存主键到行记录的缓存,和查询条件(唯一索引)到主键的缓存


我们看下查询的逻辑(针对的是单行的记录):


  1. 通过查询条件查询某条记录时,如果没有查询条件到主键的缓存

  2. 通过查询条件到 mysql 查询行记录,然后把主键到行记录的缓存,和查询条件(唯一索引)到主键的缓存更新到 redis(前者的过期时间会多余后者几秒时间)

  3. 继续回到 1,如果有查询条件到主键的缓存,如果没有主键到记录的缓存,通过主键到 mysql 查询并写入 redis


下面看下 go-zero 源码:


// v - 需要读取的数据对象// key - 缓存key// query - 用来从DB读取完整数据的方法// cacheVal - 用来写缓存的方法func (c cacheNode) doTake(v interface{}, key string, query func(v interface{}) error,  cacheVal func(v interface{}) error) error {  // singleflight一批请求过来,只允许一个去真正访问数据,防止缓存击穿  val, fresh, err := c.barrier.DoEx(key, func() (interface{}, error) {    // 从cache里读取数据    if err := c.doGetCache(key, v); err != nil {      // 如果是预先放进来的placeholder(用来防止缓存穿透)的,那么就返回预设的errNotFound      // 如果是未知错误,那么就直接返回,因为我们不能放弃缓存出错而直接把所有请求去请求DB,      // 这样在高并发的场景下会把DB打挂掉的      if err == errPlaceholder {        return nil, c.errNotFound      } else if err != c.errNotFound {        // why we just return the error instead of query from db,        // because we don't allow the disaster pass to the DBs.        // fail fast, in case we bring down the dbs.        return nil, err      }
// 请求DB // 如果返回的error是errNotFound,那么我们就需要在缓存里设置placeholder,防止缓存穿透 if err = query(v); err == c.errNotFound { if err = c.setCacheWithNotFound(key); err != nil { logx.Error(err) }
return nil, c.errNotFound } else if err != nil { // 统计DB失败 c.stat.IncrementDbFails() return nil, err }
// 把数据写入缓存 if err = cacheVal(v); err != nil { logx.Error(err) } } // 返回json序列化的数据 return jsonx.Marshal(v) }) if err != nil { return err } if fresh { return nil }
// got the result from previous ongoing query c.stat.IncrementTotal() c.stat.IncrementHit()
// 把数据写入到传入的v对象里 return jsonx.Unmarshal(val.([]byte), v)}
复制代码


从上面代码我们可以看到:


  1. 使用 sigleflight 防止缓存击穿

  2. 缓存穿透,使用了占位符,即在 redis 中保存一个空值

用户头像

六月的

关注

还未添加个人签名 2019-07-23 加入

还未添加个人简介

评论

发布
暂无评论
深入浅出redis缓存应用_redis_六月的_InfoQ写作社区