Redis 开发与运维缓存设计
Posted 木兮同学
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis 开发与运维缓存设计相关的知识,希望对你有一定的参考价值。
文章目录
一、缓存的收益与成本
收益
加速读写
降低后端负载
,例如使用 redis 缓存降低 mysql 负载等。
成本
数据不一致
:缓存层和存储层的数据存在着一定时间窗口的不一致性,和更新策略有关。代码维护成本
:加入缓存后,需要同时处理缓存层和存储层的逻辑。运维成本
:例如 Redis Cluster,加入后无形中增加了运维成本。
使用场景
降低后端负载
,对高消耗的 SQL 比如 JOIN 结果集、分组统计结果缓存。加速请求响应
,利用 Redis/Memcache 优化 IO 响应时间。大量写合并为批量写
,如计数器先在 Redis 累加再批量写 DB。
二、缓存更新策略
Redis 过期键的删除策略?
- 定时删除:超时时间到达时,删除
- 惰性删除:再次访问过期数据时,删除
- 定期删除:每隔一定周期,删除
- 对于定时删除:由于数据库可能同时接受成千上万个用户的访问,那么可能有大量的 key 需要删除,如果我们为每一个 key 的超时时间都设置一个定时器,每次超时就进行删除操作,那么会导致系统性能非常低。
- 对于惰性删除:如果一个过期 key 长期没有被访问,那么该 key-value 对将会一直存储在数据库中,会一直占有内存。而 redis 又是一个基于内存的数据库,这样很容易导致内存被耗尽。
- 对于定期删除 :redis 难以确定执行删除操作的时长和频率
- 因此 redis 采用
惰性删除和定期删除
相结合的方式,来删除系统中的过期键
Redis 内存淘汰机制?
- Redis v4.0 前提供 6 种数据淘汰策略:
- volatile-lru:利用 LRU 算法移除设置过期时间的 key(LRU:最近最少使用,Least Recently Used )
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- allkeys-random:当内存不足以容纳新写入数据时,从数据集中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
- Redis v4.0 后增加以下 2 种:
- volatile-lfu:从已设置过期时间的数据集中挑选最不经常使用的数据淘汰(LFU,Least Frequently Used)算法,也就是最频繁被访问的数据将来最有可能被访问到。
- allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。
三、缓存粒度控制
粒度问题
- 比如要缓存一个 mysql 中的用户信息:
select * from user where id = id
。 - 设置用户缓存:
set user:id '(上面sql语句的结果)'
- 缓存粒度-全部属性:即缓存
select *
的结果 - 缓存粒度-部分重要属性:比如缓存
select user_name, phone
部分字段结果
三个角度分析
- 通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性
- 空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
- 全部数据会造成内存浪费
- 每次传输产生的网络流量会比较大,耗时相对较大,极端情况下会阻塞网络
- 全部数据的序列化和反序列化的 CPU 开销更大
- 代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据
四、缓存穿透问题
问题描述
- 缓存穿透
- 缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从缓存层查不到数据则不写入缓存层。
- 缓存穿透将
导致不存在的数据每次请求都要到存储层去查询
,失去了缓存保护后端存储的意义。
- 原因
自身业务代码
或数据出现问题。恶意攻击或者爬虫等等造成大量空命中
,比如根据一堆不存在的 id 进行查询,每次都是穿透缓存直接查询数据库。
- 发现
- 根据业务的响应时间,
审查业务本身代码
问题 - 可以在程序中分别
统计总调用数、缓存层命中数、存储层命中数
,如果发现大量空命中,可能就是出现了缓存穿透。
- 根据业务的响应时间,
解决方案
-
缓存空对象,当存储层不命中后,仍然将空对象保留到缓存层。可能存在的问题
空值做了缓存,缓存层就需要存更多的键
,比如说恶意攻击,需要存一堆无用的 id 键,值为 null。这种情况可以设置一个较短的过期时间,让其自动剔除。缓存层和存储层数据“短期”不一致
。比如查询 mysql,这时候 mysql 出现问题了,导致返回的是 null,然后又把这个空数据缓存到了redis,假如有 5 分钟过期时间,那这 5 分钟内都是错误数据,这个时候就是数据不一致了。此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
-
布隆过滤器拦截(后面文章详细介绍)
- 在访问缓存层和存储层之前,
将存在的 key 用布隆过滤器提前保存起来
,做第一层拦截。 - 这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但缓存空间占用少。
- 在访问缓存层和存储层之前,
五、无底洞优化
问题描述
- 场景描述
- 2010年,Facebook 已经有了 3000 个 Memcache 个节点,为了满足需求需要添加大量新 Memcache 节点,发现
加机器性能并没有提升,反而下降
。
- 2010年,Facebook 已经有了 3000 个 Memcache 个节点,为了满足需求需要添加大量新 Memcache 节点,发现
- 问题分析
- 客户端一次批量操作会涉及多次网络操作,也就意味着
批量操作会随着节点的增多,耗时会不断增大
。 - 网络连接数变多,对节点的性能也有一定影响。
- 客户端一次批量操作会涉及多次网络操作,也就意味着
- 问题关键点
更多的节点不代表更高的性能
,所谓“无底洞”就是说投入越多不一定产出越多。- 一般出现在
批量
接口的需求,比如 mget、mset 等。
分布式条件下优化批量操作
- 常见的 IO 优化思路
- 命令本身优化,例如优化 SQL 语句等
- 减少网络通信的次数
- 降低接入成本,例如客户端长连/连接池、NIO 等
- 四种批量优化的方法(具体代码实现,请参考书籍)
串行命令
:由于 n 个 key 是比较均匀地分布在 Redis Cluster 的各个节点上,因此无法使用 mget 命令一次性获取
,所以通常来讲要获取 n 个 key 的值,最简单的方法就是逐次执行 n 个 get 命令
,操作时间 =n 次网络时间 + n 次命令时间
。串行 IO
:Redis Cluster 使用 CRC16 算法计算出散列值,再取对 16383 的余数就可以算出 slot 值,同时 Smart 客户端会保存 slot 和节点的对应关系,有了这两个数据就可以将属于同一个节点的 key 进行归档得到每个节点的 key 子列表,之后对每个节点执行 mget 或者 Pipeline 操作,操作时间 =node 次网络时间 + n 次命令时间
。并行 IO
:此方案是上面方案最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程,网络时间变为 O(1) ,这种方案会增加编程的复杂度,它的操作时间 =max_slow(node 网络时间) + n 次命令时间
。hash_tag 实现
:Redis Cluster 的 hash_tag 功能,它可以将多个 key 强制分配到一个节点上
,操作时间 =1 次网络时间 + n 次命令时间
。
六、缓存雪崩优化
问题描述
- 由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有请求都会到达存储层,存储层的调用量会暴增,
造成存储层也会级联宕机的情况
。
优化方案
- 保证缓存高可用性。个别节点、个别机器或者个别机房出现问题时能保证高可用,可以使用 Redis Sentinel 和 Redis Cluster。
- 依赖隔离组件为后端限流并降级。例如线程池、信号量隔离组件,或一些比如阿里的 Sentinel 限流组件。
- 提前演练。例如压力测试。
七、热点 key 重建优化
问题描述
- 开发人员使用“缓存 + 过期时间”的策略即可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
- 当前 key 是一个
热点 key,并发量特别大
。 重建缓存不能在短时间完成
,可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等。
- 当前 key 是一个
- 在
缓存失效的瞬间
,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。 - 可以指定如下三个目标去解决这个问题,又不给系统带来更多麻烦:
- 减少重建缓存的次数
- 数据尽可能一致
- 减少潜在的风险
解决思路
- 互斥锁,当检测到缓存需要重建的时候加上一把锁,缓存成功了就把锁释放,在这个期间如果有其他线程访问缓存时会一直等待。
- 代码示例
String get(String key) throws InterruptedException String value = redis.get(key); // 如果 value 为空,开始重构缓存 if (value == null) String mutexKey = "mutex:key:" + key; // 加锁:如果 key 不存在,则 set ,且设置 180 秒的过期时间,如果能设置成功返回 true if (redis.set(mutexKey, "1", "ex 180", "nx")) value = db.get(key); redis.set(key, value); redis.delete(mutexKey); // 其他线程休息 50 毫秒后重试 else Thread.sleep(50); get(key); return value;
- 永远不过期
- 从缓存层面来看,确实没有设置过期时间,不会出现热点 key 过期后产生的问题
- 从功能层面来看,为每个 value 设置逻辑过期时间,当发现超过逻辑过期时间后,使用单独的线程去构建缓存
- 代码示例:
String get(final String key) throws InterruptedException V v = redis.get(key); String value = v.getValue(); // 逻辑过期时间 Long logicTimeout = v.getLogicTimeout(); // 如果逻辑过期时间小于当前时间,开始后台构建 if (logicTimeout <= System.currentTimeMillis()) String mutexKey = "mutex:key:" + key; // 加锁:如果key不存在,则set,且设置180秒的过期时间,如果能设置成功返回true if (redis.set(mutexKey, "1", "ex 180", "nx")) // 重构缓存 threadPool.execute(new Runnable() @Override public void run() String dbValue = db.get(key); redis.set(key, (dbValue, newLogicTimeout)); redis.delete(mutexKey); ); else // 其他线程休息50毫秒后重试 Thread.sleep(50); get(key); return value;
两种方案对比
方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 思路简单、保证一致性 | 代码复杂度增加、存在死锁风险 |
永远不过期 | 基本杜绝热点 key 重建问题 | 不保证一致性、逻辑过期时间增加维护成本和内存成本 |
八、缓存降级
问题描述
- 当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,
仍然需要保证核心服务还是可用的
,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。 降级的最终目的是保证核心服务可用,即使是有损的
。而且有些服务是无法降级的(如加入购物车、结算)。
处理方案
- 在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅,从而
梳理出哪些必须誓死保护,哪些可降级
,比如可以参考日志级别设置预案:- 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级。
- 警告:有些服务在一段时间内成功率有波动(如在 95~100% 之间),可以自动降级或人工降级,并发送告警。
- 错误:比如可用率低于 90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级。
- 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
来源:《Redis 开发与运维》第 11 章 缓存设计
以上是关于Redis 开发与运维缓存设计的主要内容,如果未能解决你的问题,请参考以下文章