Redis 是一个开源的高性能的 Key-Value 服务器。本篇主要介绍一下缓存的设计与优化。
缓存收益与成本
- | 说明 |
---|---|
缓存的受益 | 1、加速读写,通过缓存加速读写速度,例如 CPU L1/L2/L3 Cache、Linux page Cache 加速硬盘读写、浏览器缓存、Ehcache 缓存数据库结果; 2、降低后端负载,后端服务器通过前端缓存降低负载,业务端使用 Redis 降低后端 MySQL 负载等。 |
缓存的成本 | 1、数据不一致,缓存和数据层有时间窗口不一致,和更新策略有关; 2、代码维护成本增加,多了一层缓存逻辑; 3、运维成本增加。 |
单线程架构
单线程架构要注意什么?
- 一次只运行一条命令;单条命令都具有原子性
- 拒绝长(慢)命令,例如 keys、flushall、flushdb、slow lua scrip、mutil/exec、operate big value(collection);
缓存一致性
缓存更新策略:
1.先更新数据库,再删除缓存(推荐)
可能存在的问题
- 缓存更新失败
如何解决?
引入消息队列,删除缓存失败不断重试,缺点是,对业务代码大量的侵入。
具体流程:
- 更新数据库数据
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
理论上存在并发问题脏数据问题(一个请求A做查询操作,一个请求B做更新操作),但很难出现。
出现并发问题脏数据问题的场景:
- 缓存刚好失效
- 请求A查询数据库,得一个旧值
- 请求B将新值写入数据库
- 请求B删除缓存
- 请求A将查到的旧值写入缓存
发生条件:步骤3的写数据库操作比步骤2的读数据库操作耗时更短(很难出现)
2.先删缓存,在更新数据库
可能存在的问题?
- 数据不一致(同时有一个请求A进行更新操作,另一个请求B进行查询操作)
如何解决?
延时双删策略:
- 先删除缓存
- 再写数据库
- 休眠1秒,再次删除缓存(具体根据业务确实时间)
理论上依然存在极短时间窗口的数据不一致。且同时存在和方案1一样的缓存更新失败问题
缓存穿透优化
缓存穿透:说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层
发生缓存穿透的常见原因:
- 业务代码自身问题;
- 恶意攻击、爬虫等等。
如何发现问题?
- 业务的响应时间;
- 业务本身问题;
- 相关监控指标:总调用数、缓存层命中数、存储层命中数;
解决方案:
方案一:缓存空对象。示例代码:
public String getPassThrough(String key) {
String cacheValue = cache.get(key);
if (StringUtils.isBlank(cacheValue)) {
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置过期时间
if (StringUtils.isBlank(storageValue)) {
cache.expire(key, 300); // 300秒
}
return storageValue;
} else {
return cacheValue;
}
}
方案二:布隆过滤器拦截。通过很小的内存来实现对数据的过滤。
布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。
加入布隆过滤器之后的缓存处理流程图如下。
但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
为什么会出现误判的情况呢? 我们还要从布隆过滤器的原理来说!
我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为 1。
我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率)
缓存雪崩优化
缓存雪崩:由于 cache 服务承载大量请求,当 cache 服务异常/脱机后,流量直接压向后端组件(例如 DB),造成级联故障。
缓存雪崩优化方案:
- 保证缓存高可用性,例如 Redis Cluster、Redis Sentinel、VIP;
- 依赖隔离组件为后端限流;
- 提前演练,例如压力测试。
热点key优化
优化方案:
- 避免 bigkey。命名上做规范, 比如key通常是要区分服务的 很多人的做法就是把服务名自己做为了前缀,通过公共枚举去定义key中可能存在的服务名 业务名等等。
- 热键不要用 hash_tag,因为 hash_tag 会落到一个节点上。
- 如果真有热点 key 而且业务对一致性要求不高时,可以用本地缓存 + MQ 解决。
热点key重建优化
问题:热点 key + 较长的重建时间。
获取缓存 -> 查询数据源 -> 重建缓存 -> 输出,这个步骤在高并发的情况下,由于查询数据源需要时间,所以会有很多请求会进入到 查询数据源 -> 重建缓存 这个过程。对数据源会造成很大压力,响应时间也会变慢。
优化目标:
- 减少重建缓存的次数;
- 数据尽可能一致;
两个优化方案:
- 互斥锁(mutex key),查询数据源 -> 重建缓存 这个过程加互斥锁;
- 永不过期,缓存层面不设置过期时间(没有用 expire),功能层面为每个 value 添加逻辑过期时间,但发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
两个优化方案的对比:
策略 | 优点 | 缺点 |
---|---|---|
互斥锁 | 思路简单,保证一致性 | 代码复杂度增加,存在死锁的风险 |
永不过期 | 基本杜绝热点 key 重建问题 | 不保证一致性,逻辑过期时间增加维护成本和内存成本 |
常见问题
1、本地缓存与缓存服务的选型?
本地缓存(HashMap/ConcurrentHashMap、Ehcache、Guava Cache等),缓存服务(Redis/Tair/Memcache等)。
- 如果数据量小,并且不会频繁地增长又清空(这会导致频繁地垃圾回收),那么可以选择本地缓存。具体的话,如果需要一些策略的支持(比如缓存满的逐出策略),可以考虑Ehcache;如不需要,可以考虑HashMap;如需要考虑多线程并发的场景,可以考虑ConcurentHashMap。
- 其他情况,可以考虑缓存服务。目前从资源的投入度、可运维性、是否能动态扩容以及配套设施来考虑,我们优先考虑Tair。除非目前Tair还不能支持的场合(比如分布式锁、Hash类型的value),我们考虑用Redis。