缓存设计问题
缓存及三个问题
1. 缓存是用空间换时间,来解决性能问题的一种架构设计模式; 2. 磁盘存储往往是原始数据,而缓存保存可以是面向呈现的数据; 3. 缓存不仅加快了IO,还减少原始数据的计算工作; 4. 缓存系统一般设计简单、功能相对单一,如Redis缓存系统整体吞吐量可达到关系型数据库的几倍甚至几十倍,因此缓存特别适用于互联网应用高并发场景; 5. 使用Redis做缓存需要注意缓存的同步、雪崩、并发、穿透等问题。 //三个问题 1. 处理好缓存的回源逻辑,显示设置Redis的最大内存使用和数据淘汰策略避免OOM; 2. 考虑大量请求绕过缓存直击数据库造成数据库瘫痪的情况。 -- 对于缓存瞬时大面积失效的缓存雪崩问题,可以通过差异化缓存过期时间解决; -- 对于高并发的缓存Key回源问题,可以使用锁来限制回源并发数; -- 对于不存在的数据穿透缓存的问题,通过布隆过滤器进行数据存在性预判,或在缓存中设置一个值来解决; 3. 当数据库中数据更新时,保证缓存中数据一致性; -- 使用“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”的策略最为稳妥; -- 同时设置合适的缓存过期时间,即使发生不一致也可以在缓存过期后数据得到及时同步。 //缓存系统评估 1. 监控缓存系统的内存使用量、命中率、对象平均过期时间等重要指标 2. 通过指标评估系统的有效性并及时发现问题
别把Redis当作数据库使用
原因
1. 通常,我们会使用Redis等分布式缓存数据库来缓存数据,但别把Redis当作数据库使用; 2. 许多案例,因为Redis中数据消失导致业务逻辑错误,并且没有保留原始数据,业务无法恢复; 3. Redis的确有数据持久化功能,可以实现服务重启后数据不丢失。这一点很容易误认为Redis可以作为高性能的KV数据库; 4. 从本质上看,Redis(免费版)是一个内存数据库,所有数据保存在内存中,并直接从内存读写数据响应操作,只不过具有数据持久化能力。 5. 因此,Redis特点是,处理请求很快,但无法保存超过内存大小数据; 6. VM模式虽可以保存超过内存大小数据,但因性能原因从2.6开始废弃; 7. 另外,Redis企业版提供了Redis on Flash可以实现Key+字典+热数据保存在内存中,冷数据保存在SSD中。
用Redis作缓存,需要注意两点
//1. 从客户端角度来说 -- 缓存数据的特点一定是有原始数据来源,且允许丢失,即使设置的缓存时间是1分钟,在30秒时数据因为某种原因消失了,我们也能接受。 -- 当数据丢失后,我们需要从原始数据重新加载数据,不能认为缓存系统是绝对可靠的,更不能认为缓存系统不会删除没有过期的数据。 //2. 从Redis服务端的角度来说 -- 缓存系统可以保存的数据量一定是小于原始数据的。 -- 首先,我们应该限制Redis对内存的使用量,也就是设置maxmemery参数; -- 其次,我们应该根据数据特点,明确Redis应该以怎样的算法来驱逐数据。
从Redis的文档可以看到,常用数据缓存策略有:
//这些算法是Key范围+Key选择算法的搭配组合,其中范围有allkeys和volatile两种,算法有LRU、TTL和LFU三种。 1. allkeys-lru,针对所有 Key,优先删除最近最少使用的 Key; 2. volatile-lru,针对带有过期时间的 Key,优先删除最近最少使用的 Key; 3. volatile-ttl,针对带有过期时间的 Key,优先删除即将过期的 Key(根据 TTL 的值); 4. allkeys-lfu(Redis 4.0 以上),针对所有 Key,优先删除最少使用的 Key; 5. volatile-lfu(Redis 4.0 以上),针对带有过期时间的 Key,优先删除最少使用的 Key。
从Key范围和算法角度,如何选择合适的驱逐算法
//从算法角度来说 -- Redis4.0以后推出的LFU比LRU更“实用”。 -- 试想一下,如果一个Key访问频率是1天一次,但正好在1秒前刚访问过 -- 那么LRU可能不会选择优先淘汰这个key,反而可能会淘汰一个5秒访问一次但最近2秒没有访问过的Key,而LFU算法不会有这个问题。 -- 而TTL会笔记“头脑简单”一点,优先删除即将过期的Key,但有可能这个Key正在被大量访问。 //从Key范围角度来说 -- allkeys可以确保即使Key没有TTL也能回收 -- 如果使用的时候客户端总是"忘记"设置缓存的过期时间,那么可以考虑使用这个系列算法。 -- 而volatile会更稳妥一些,万一客户端把Redis当作了长久缓存使用,只是启动时候初始化一次缓存, -- 那么一旦删除了此类没有TTL的数据,可能就会导致客户端出错。
因此,使用者应该考虑以缓存的姿势来使用Redis,管理者应该以Redis设置内存限制和合适的驱逐策略避免出现OOM
缓存雪崩问题
缓存雪崩是什么
//缓存失效,也叫缓存雪崩 -- 由于缓存系统的IOPS比数据库高很多,因此要特别小心短时间内大量缓存失效的情况; -- 这种情况一旦发生,可能会在瞬间有大量数据需要回源数据库查询,对数据库造成极大压力,极限情况导致后端数据库之间崩溃。
产生缓存雪崩的原因
第一种,缓存系统本身不可用,导致大量数据直接回源到数据库; 第二种,应用设计层面大量的Key在同一时间过期,导致大量数据回溯; //其中第一种原因主要涉及缓存系统本身高可用的配置不属于缓存设计层面问题
如何确保大量Key不在同一时间被动过期
案例:
//程序初始化的时候放入 1000 条城市数据到 Redis 缓存中,过期时间是 30 秒; //数据过期后从数据库获取数据然后写入缓存,每次从数据库获取数据后计数器 +1; //在程序启动的同时,启动一个定时任务线程每隔一秒输出计数器的值,并把计数器归零。 //压测一个随机查询某城市信息的接口,观察一下数据库的 QPS: @Autowired private StringRedisTemplate stringRedisTemplate; private AtomicInteger atomicInteger = new AtomicInteger(); @PostConstruct public void wrongInit() { //初始化1000个城市数据到Redis,所有缓存数据有效期30秒 IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS)); log.info("Cache init finished"); //每秒一次,输出数据库访问的QPS Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { log.info("DB QPS : {}", atomicInteger.getAndSet(0)); }, 0, 1, TimeUnit.SECONDS); } @GetMapping("city") public String city() { //随机查询一个城市 int id = ThreadLocalRandom.current().nextInt(1000) + 1; String key = "city" + id; String data = stringRedisTemplate.opsForValue().get(key); if (data == null) { //回源到数据库查询 data = getCityFromDb(id); if (!StringUtils.isEmpty(data)) //缓存30秒过期 stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS); } return data; } private String getCityFromDb(int cityId) { //模拟查询数据库,查一次增加计数器加一 atomicInteger.incrementAndGet(); return "citydata" + System.currentTimeMillis(); }
使用wrk工具,设置10线程10连接压测city接口:
wrk -c10 -t10 -d 100s http://localhost:45678/cacheinvalid/city
启动程序30秒后缓存过期,回源的数据库OPS最高达到700多:
DB QPS:0 DB QPS:789 DB QPS:213 DB QPS:0
解决缓存Key同时大规模失效需要回源,导致数据库压力激增的方式有两种。
方案一,差异化缓存过期时间,不要让大量的Key在同一时间过期
//在初始化缓存时候,设置缓存的过期时间是30秒+10秒以内的随机延迟(扰动值)。 //这样这些Key不会集中在30秒这个时刻过期,而是会分散在30~40秒之间过期: @PostConstruct public void rightInit1() { //这次缓存的过期时间是30秒+10秒内的随机延迟 IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS)); log.info("Cache init finished"); //同样1秒一次输出数据库QPS: Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { log.info("DB QPS : {}", atomicInteger.getAndSet(0)); }, 0, 1, TimeUnit.SECONDS); }
修改后,缓存过期时的回源不会集中在同一秒,数据库的QPS从700多降到最高100左右
DB QPS:0 DB QPS:83 DB QPS:89 DB QPS:114 DB QPS:100 DB QPS:115 DB QPS:90 DB QPS:98 DB QPS:91 DB QPS:106 DB QPS:110 DB QPS:11 DB QPS:0 DB QPS:0
方案二,让缓存不主动过期
//初始化缓存数据的时候设置缓存永不过期, //然后启动一个后台线程 30 秒一次定时把所有数据更新到缓存, //而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力: @PostConstruct public void rightInit2() throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(1); //每隔30秒全量更新一次缓存 Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { IntStream.rangeClosed(1, 1000).forEach(i -> { String data = getCityFromDb(i); //模拟更新缓存需要一定的时间 try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { } if (!StringUtils.isEmpty(data)) { //缓存永不过期,被动更新 stringRedisTemplate.opsForValue().set("city" + i, data); } }); log.info("Cache update finished"); //启动程序的时候需要等待首次更新缓存完成 countDownLatch.countDown(); }, 0, 30, TimeUnit.SECONDS); Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { log.info("DB QPS : {}", atomicInteger.getAndSet(0)); }, 0, 1, TimeUnit.SECONDS); countDownLatch.await(); }
这样修改后,虽然缓存整体更新耗时21秒左右,但数据库压力会比较稳定
DB QPS:0 DB QPS:43 DB QPS:45 DB QPS:46 DB QPS:47 DB QPS:42 DB QPS:44 DB QPS:44 DB QPS:45 DB QPS:46 Cache update finished DB QPS:14 DB QPS:0
两种方案注意事项
1. 方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,只能使用方案一; 2. 即使使用方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。我们无法确保缓存系统种数据永不丢失; 3. 不管方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。
如遇到的重大事故,某缓存系统种对基础数据进行长达半年的缓存,在某个时间点DBA把数据库种的原始数据进行了归档(可以认为是删除)操作,因为缓存种的数据一直在,一开始没问题,但半年后的一天缓存种数据过期了,就从数据库种查询了空数据加入缓存,爆发了大面积事故。
这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存前一定要校验,如果发现有明显异常要及时报警。
缓存穿透问题
什么是缓存穿透
缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。 //之前的例子中,缓存回源的逻辑都是当缓存中查不到需要的数据时,回源到数据库查询 //缓存中没有数据不一定代表数据没有缓存,还有一种可能是原始数据压根就不存在。
例子
//数据库中只保存有 ID 介于 0(不含)和 10000(包含)之间的用户, //如果从数据库查询 ID 不在这个区间的用户,会得到空字符串,所以缓存中缓存的也是空字符串。 //如果使用 ID=0 去压接口的话,从缓存中查出了空字符串,认为是缓存中没有数据回源查询,其实相当于每次都回源: @GetMapping("wrong") public String wrong(@RequestParam("id") int id) { String key = "user" + id; String data = stringRedisTemplate.opsForValue().get(key); //无法区分是无效用户还是缓存失效 if (StringUtils.isEmpty(data)) { data = getCityFromDb(id); stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS); } return data; } private String getCityFromDb(int id) { atomicInteger.incrementAndGet(); //注意,只有ID介于0(不含)和10000(包含)之间的用户才是有效用户,可以查询到用户信息 if (id > 0 && id <= 10000) return "userdata"; //否则返回空字符串 return ""; }
压测后,QPS达到了几千:如果这种漏洞被恶意利用,会对数据库造成很大性能压力,这就是缓存穿透
DB QPS:4106 DB QPS:5268 DB QPS:4908 DB QPS:6198
缓存穿透和缓存击穿的区别:
缓存穿透是指,缓存没有起到压力缓冲的作用;
而缓存击穿是指,缓存失效时瞬时的并发打到数据库。
方案一,对于不存在的数据,同样设置一个特殊的Value到缓存中
//如当数据库中查出的用户信息为空的时候,设置 NODATA 这样具有特殊含义的字符串到缓存中。 //这样下次请求缓存的时候还是可以命中缓存,即直接从缓存返回结果,不查询数据库: @GetMapping("right") public String right(@RequestParam("id") int id) { String key = "user" + id; String data = stringRedisTemplate.opsForValue().get(key); if (StringUtils.isEmpty(data)) { data = getCityFromDb(id); //校验从数据库返回的数据是否有效 if (!StringUtils.isEmpty(data)) { stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS); } else { //如果无效,直接在缓存中设置一个NODATA,这样下次查询时即使是无效用户还是可以命中缓存 stringRedisTemplate.opsForValue().set(key, "NODATA", 30, TimeUnit.SECONDS); } } return data; } //这种方式可能会把大量无效的数据加入缓存中,如果担心大量无效数据沾满缓存的话,使用布隆过滤器做前置过滤方案二
方案二,把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次
1. 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
2. 对于极小概率的误判请求,才会最终让非法 Key 的请求走到缓存或数据库。
使用布隆过滤器,用Google 的 Guava 工具包提供的 BloomFilter 类改造一下程序:
//启动时,初始化一个具有所有有效用户 ID 的、10000 个元素的 BloomFilter, //在从缓存查询数据之前调用其 mightContain 方法,来检测用户 ID 是否可能存在; //如果布隆过滤器说值不存在,那么一定是不存在的,直接返回: private BloomFilterbloomFilter; @PostConstruct public void init() { //创建布隆过滤器,元素数量10000,期望误判率1% bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01); //填充布隆过滤器 IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put); } @GetMapping("right2") public String right2(@RequestParam("id") int id) { String data = ""; //通过布隆过滤器先判断 if (bloomFilter.mightContain(id)) { String key = "user" + id; //走缓存查询 data = stringRedisTemplate.opsForValue().get(key); if (StringUtils.isEmpty(data)) { //走数据库查询 data = getCityFromDb(id); stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS); } } return data; }
对于方案二,我们需要同步所有可能存在的值并加入布隆过滤器,这是比较麻烦的地方。如果业务规则明确,可以考虑直接根据业务规则判断值是否存在。
其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据库。
什么是布隆过滤器
1. 布隆过滤器是一种概率型数据库结构,由一个很长的二进制向量和一系列随机映射函数组成。 2. 它的原理是,当一个元素被加入集合时,通过 k 个散列函数将这个元素映射成一个 m 位 bit 数组中的 k 个点,并置为 1。 3. 检索时,我们只要看看这些点是不是都是 1 就(大概)知道集合中有没有它了。 4. 如果这些点有任何一个 0,则被检元素一定不在;如果都是 1,则被检元素很可能在。 5. 布隆过滤器不保存原始值,空间效率很高,平均每一个元素占用 2.4 字节就可以达到万分之一的误判率。 6. 这里的误判率是指,过滤器判断值存在而实际并不存在的概率。我们可以设置布隆过滤器使用更大的存储空间,来得到更小的误判率。
原理图
注意缓存数据同步策略
前面提到的都属于缓存数据过期后的被动删除,在实际情况下,修改了原始数据后,考虑到缓存数据更新的及时性,会采用主动更新缓存的策略:
主动更新缓存策略
1. 先更新缓存,再更新数据库; 2. 先更新数据库,再更新缓存; 3. 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存; 4. 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存。
应该选择哪种更新策略
1策略不可行。数据库设计复杂,压力集中,数据库因为超时等原因更新操作失败的可能性较大,此外还会涉及事务,很可能因为数据库更新失败,导致缓存和数据库的数据不一致;
2策略不可行。一方面如果线程A和B先后完成数据库更新,但更新缓存却是B和A的顺序,那很可能会把旧数据更新到缓存中引起数据不一致;另一方面,我们不确定缓存中的数据是否会被访问,不一定要把所有数据都更新到缓存中去;
3策略不可行。在并发情况下,很可能删除缓存后还没来得及更新数据库,就有另一个线程先读取了旧值到缓存中,如果并发量很大的话这个概率也会很大;
4策略最好。
//虽然在极端情况下,“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”这种策略也可能出现数据不一致问题,但概率非常低,级别可以忽略。 //举“极端情况”例子,比如更新数据的时间节点恰好是缓存失效的瞬间,这是A先读取到了旧值,随后在B操作数据库完成更新并且删除了缓存之后,A再把旧值加入缓存。 //需要注意的是,更新数据库后删除缓存的操作可能失败; //如果失败则考虑把任务加入延迟对了进行延迟重试,确保数据可以删除,缓存可以及时更新。 //因为删除操作是冥等的,所以及时重复删除问题也不太大,这又是删除比更新好的一个原因。
因此,针对缓存更新最推荐的方式是:缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存中的数据即可。
热点 Key 回源会对数据库产生的压力问题
如果 Key 特别热的话,可能缓存系统也无法承受,毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用 Redis 来做缓存,那可以把一个热点 Key 的缓存查询压力,分散到多个 Redis 节点上吗?
使用–hotkeys 配合 redis-cli 命令行工具来探查热点 Key
1. Redis 4.0 以上如果开启了 LFU 算法作为 maxmemory-policy,那么可以使用–hotkeys 配合 redis-cli 命令行工具来探查热点 Key。 2. 此外,我们还可以通过 MONITOR 命令来收集 Redis 执行的所有命令,然后配合redis-faina 工具来分析热点 Key、热点前缀等信息。 3. 对于如何分散热点 Key 对于 Redis 单节点的压力的问题,我们可以考虑为 Key 加上一定范围的随机数作为后缀,让一个 Key 变为多个 Key,相当于对热点 Key 进行分区操作。 4. 当然,除了分散 Redis 压力之外,我们也可以考虑再做一层短时间的本地缓存,结合 Redis 的 Keyspace 通知功能,来处理本地缓存的数据同步。
加随机后缀
分型一个场景: 假如在一个非常热点的数据,数据更新不是很频繁,但是查询非常的频繁,要保证基本保证100%的缓存命中率,该怎么处理? 我们的做法是,空间换效率,同一个key保留2份,1个不带后缀,1个带后缀, 不带的后缀的有ttl,带后缀的没有,先查询不带后缀的,查询不到,做两件事情: 1. 后台程序查询DB更新缓存; 2. 查询带后缀返回给调用方。这样可以尽可能的避免缓存击穿而引起的数据库挂了。
单个key存储的value很大导致性能问题
Redis 的大 Key 可能会导致集群内存分布不均问题,并且大 Key 的操作可能也会产生阻塞。
关于查询Redis中的大Key
1. 使用 redis-cli --bigkeys 命令来实时探查大 Key。 2. 此外,我们还可以使用 redis-rdb-tools 工具来分析 Redis 的 RDB 快照,得到包含 Key 的字节数、元素个数、最大元素长度等信息的 CSV 文件。 3. 然后,我们可以把这个 CSV 文件导入 MySQL 中,写 SQL 去分析。
针对大Key两方面优化
1. 是否有必要在 Redis 保存这么多数据。一般情况下,我们在缓存系统中保存面向呈现的数据,而不是原始数据;对于原始数据的计算,我们可以考虑其它文档型或搜索型的 NoSQL 数据库。
2. 考虑把具有二级结构的 Key(比如 List、Set、Hash)拆分成多个小 Key,来独立获取(或是用 MGET 获取)。
针对大Key的删除操作引起较大性能问题
1. 从 Redis 4.0 开始,我们可以使用 UNLINK 命令而不是 DEL 命令在后台删除大 Key;
2. 而对于 4.0 之前的版本,我们可以考虑使用游标删除大 Key 中的数据,而不是直接使用 DEL 命令,比如对于 Hash 使用 HSCAN+HDEL 结合管道功能来删除。
其他处理方法
//key分为2两种类型 1. 该key需要每次都整存整取 -- 可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响; 2. 该对象每次只需要存取部分数据 -- 可以像第一种做法一样,分拆成几个key-value, 也可以将这个存储在一个hash中,每个field代表一个具体的属性,使用hget,hmget来获取部分的value,使用hset,hmset来更新部分属性。
一个集群存储了上亿的key导致占用过多内存空间
第一种 key本身的占用(每个key都会有一个Category前缀)
第二种 集群模式中,服务端需要建立一些slot2key的映射关系,这其中的指针占用的key多的情况下也是浪费巨大空间
因此,这两个方面在key个数上亿的时候消耗内存十分明显(Redis 3.2及以下版本均存在这个问题,4.0有优化);
解决:减少key的个数可以减少内存消耗
方案是参考转Hash结构存储,即原先是直接使用Redis String 的结构存储,现在将多个key存储在一个Hash结构中,具体场景参考如下:
1. key 本身就有很强的相关性 -- 比如多个key 代表一个对象,每个key是对象的一个属性,这种可直接按照特定对象的特征来设置一个新Key——Hash结构, 原先的key则作为这个新Hash 的field。 2. key 本身没有相关性,预估一下总量,预分一个固定的桶数量, -- 比如现在预估key 的总数为 2亿,按照一个hash存储 100个field来算, -- 需要 2亿 / 100 = 200W 个桶 (200W 个key占用的空间很少,2亿可能有将近 20G ) -- 现在按照200W 固定桶分就是先计算出桶的序号 hash(123456789) % 200W , 这里最好保证这个 hash算法的值是个正数,否则需要调整下模除的规则; -- 这样算出三个key 的桶分别是 1 , 2, 2。 所以存储的时候调用API hset(key, field, value),读取的时候使用 hget (key, field) -- 注意两个地方: 1. hash 取模对负数的处理; 2. 预分桶的时候, 一个hash 中存储的值最好不要超过 512 ,100 左右较为合适
原文链接:https://time.geekbang.org/column/article/231501