聊聊缓存
缓存是一种用空间换时间来解决性能问题的架构设计模式,缓存设计简单,功能相对单一,通常我们会拿它来保存一些常被用到且不常修改的数据,以便减少对数据库(磁盘)的压力。
Redis是互联网开发中最为常用的分部署缓存数据库,它相对于我们常用的MySQL关系型数据库,在整体吞吐量上,可以是MySQL几倍甚至几十倍。因此它特别适用于互联网应用这种容易高并发的场景。
但是使用Redis做缓存,在使用和设计上并不是看上去那么简单,用得好可以锦上添花,用错了,也会落井下石。
1.不要把Redis当作数据库去使用
Redis虽然具有数据持久化的功能,可以实现服务重启后数据不丢失。这一点,很容易让人误以为Redis可以作为高性能的KV数据库。实际上,免费版的Redis是个内存数据库,所有数据都是保存在内存里的,而且它通常存的不是原始数据,更多的是面向呈现的数据,可以减少了对磁盘的压力,也减少了原始数据的计算工作。但是又因为可以直接从内存读取数据响应操作,所以加了使用缓存机制的功能,在处理请求上很快。
它的特点是处理操作快,但是它有内存限制,无法存储超过内存大小的数据。(VM模式虽然可以,但是性能低下,早在版本功能迭代的时候就废弃了)
所以使用Redis做缓存,需要注意两点:
- 不能认为缓存系统绝对可靠,更不能认为它不会删除过期数据。
- 设置必要参数maxmemory来达到限制缓存对内存的使用与结合业务场景选择适合我们自己的数据淘汰策略。
2.Redis淘汰策略
策略 | 简单说明 |
noeviction | 达到内存限制时不再保存新值 |
allkeys-lru | 针对所有 Key,优先删除最近最少使用的 Key |
allkeys-lfu | 针对所有常用的键,优先删除最不常用的LRU键 |
volatile-lru | 针对带有过期时间的 Key,优先删除最近最少使用的 Key |
volatile-lfu | 针对expire字段设置为true,优先删除最不常用的键 |
allkeys-random | 随机删除键,为添加的新数据腾出空间 |
volatile-random | 随机移除过期字段设置为true的键 |
volatile-ttl | 针对带有过期时间的 Key,优先删除即将过期的 Key |
具体可参考官方文档 https://redis.io/docs/manual/eviction/
在开发时,我会尽可能的选择有TTL的算法,这样即便我对于业务了解的再不怎么深刻,选择错了,在TTL的算法下,只要设置的时间不要太长,那么它所带来的损失是最小的。但是这个只是万不得已的情况下,如果有时间还是要去好好了解一下这些策略,然后再去结合业务场景的使用,去选择一个适合业务数据的缓存策略。
其次就说allkeys的算法,从key的范围角度来看,allkeys可以确保即使key没有TTL也能够被回收。毕竟总会有开发者“忘了”设置缓存的过期时间。
3.关于使用缓存的那些跑不掉的问题
说到缓存,就难免不会被提及并发,雪崩,穿透,同步等问题,面试的时候,这些问题更是常见于交谈中。
一、为什么会导致雪崩?
前面说了,缓存是为了减少请求一些不常被修改的数据对数据库的压力,因此我们要注意避免短时间内大量的缓存失效的情况,一旦发送了,就有可能瞬间会有大量的数据需要回源到数据库去查询,一瞬间去数据库带来极大的压力,极限情况下导致后端数据库直接崩溃,使得缓存失效,这就是缓存雪崩。
所以我们要做的,其实就是保证不让大量的缓存同时失效。
//我们通常都是这样缓存数据的,以string为key,缓存data数据30秒 redisTemplate.opsForValue().set("string",data, 30, TimeUnit.SECONDS); //为了保证不让大量缓存同时失效,我们可以控制这个30秒的时间,随机为这30秒加上多少秒,保证他们都不会在同一个时间点失效。 redisTemplate.opsForValue().set("string",data, 30 + ThreadLocalRandom.current().nextInt(20), TimeUnit.SECONDS)
这样设置的key就不会在30秒后同时失效,而会分散在30-50秒内失效。
也可以不为缓存设置TTL时间,不让它主动失效,但是需要后台使用定时任务每30秒去更新缓存数据,这也是一种方法。
但是无论是哪个方法,我们都需要注意一个问题,那就是data值判空问题,如果不注意这个问题,那么缓存对于你的系统来说就是落井下石的作用了。曾经我写过一次IOT的上层应用的数据展示,当时的数据获取优先度是先拿缓存,缓存没有再拿平台层。就是因为存入了null值,导致了明明平台层一直拥有数据,但是我上层应用就是拿不到,只返回了null。说到null,就不得不说一下跟null有关的缓存穿透了。
二、为什么会缓存穿透?
当我们缓存没有的时候,我们就会去数据库请求数据,然后把它当前只存在缓存里,为它设置TTL过期时间,以减少数据库压力。但是如果说,它在数据库里一直没有数值呢?或者这个数据永远都不会存在,只是被恶意请求了呢?
如果没有做任何防范措施,那么它将会每次都穿透缓存,直接请求到数据库身上,给数据库造成极大的性能压力。
对于穿透,我一般要么使用布隆过滤器来判断是否要请求数据库,要么给没有值的缓存个字符来减少对数据库的性能压力。
布隆过滤器的原理是:当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。
BloomFilter<CharSequence> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), int); //查到数据的时候,通过这个方法把数据放进去 bf.put("" + data); //通过这个方法去查询是否有这个值 bf.mightContain("" + data)
布隆过滤器是概率型数据结构,所以它不一定完全对,有一定的误判,但是10e数据下,也不过1/1000。哪怕1/1000误判了,也不过去请求一下数据库罢了,比起10e,已经减少了很多性能压力,可以忽略不计了。
如果有人对这个数据的求证有兴趣,可以去下面这个地址试试
三、为什么同步也是问题
因为在高并发的情况下,同一时间A线程与B线程,在系统执行同步的时候,获取的值极有可能都不一样,获取值后缓存的前后顺序不一样,都会影响后续的所有使用。
比如我们设置的逻辑是:
先更新缓存,再更新数据库呢?那么在数据库压力大时,若数据库更新失败了,就会导致两者数据不一致问题。
先更新数据库,再更新缓存呢?拿物联网行业举例,因为ping不通设备,刚上报离线状态,下一毫秒就被通网的设备发送了在线状态,那么到了平台层必定是两个线程A和B去执行,如果线程 A 和 B 先后完成数据库更新,但更新缓存时却是 B 和 A 的顺序,那很可能会把旧数据更新到缓存中引起数据不一致。
所以在这方面没有绝对的方案,只有根据自身业务的内容去定制同步更新机制,尽可能的减少误差。我这边使用时是先更新数据库,然后删除缓存,被访问时按照需要再去缓存数据。虽然在个别极端的情况,可能也会出现数据不一致的问题,但是概率很低,也很快会被新数据给修复覆盖掉,所以忽略不记。
4.Redis的各种操作
简单举例最常用的几种缓存操作
一、String操作
@Autowired private StringRedisTemplate redisTemplate; //设置无过期时间的缓存 redisTemplate.opsForValue().set(key, "Java面试"); //设置过期时间为30秒的缓存 redisTemplate.opsForValue().set(key, "Java面试", 30, TimeUnit.SECONDS); //获取缓存值 redisTemplate.opsForValue().get(key)
二、Set操作
@Autowired private StringRedisTemplate redisTemplate; //为key缓存三次 redisTemplate.opsForSet().add(key,"语文"); redisTemplate.opsForSet().add(key,"数学"); redisTemplate.opsForSet().add(key,"英语"); //为key1缓存两次 redisTemplate.opsForSet().add(key1,"语文"); redisTemplate.opsForSet().add(key1,"英语"); //取出key和key1的交集Set集合值 Set<String> intersect = redisTemplate.opsForSet().intersect(key, key1); //最后肯定是打印出这样的结构 ["语文","英语"]
三、Hash操作
@Autowired private StringRedisTemplate redisTemplate; //key是缓存key,"身份证","民族"为hashkey,最后才是value值 redisTemplate.opsForHash().put(key,"身份证","4415211XXXXXXXXX"); redisTemplate.opsForHash().put(key,"民族","汉族"); List<Object> values = redisTemplate.opsForHash().values(key);
四、List操作
@Autowired private StringRedisTemplate redisTemplate; String key = "opsForList"; redisTemplate.opsForList().leftPush(key,"张三"); redisTemplate.opsForList().leftPush(key,"李四"); redisTemplate.opsForList().leftPush(key,"王五"); //rightPop:删除并返回列表中以键存储的最后一个元素、打印张三 System.out.println(redisTemplate.opsForList().rightPop(key)); //leftPop:删除并返回列表中以键存储的第一个元素、打印王五 System.out.println(redisTemplate.opsForList().leftPop(key));
5.最后结语
对于redis的使用,本文仅是做简单介绍入门,如果只是入门项目使用,上面的四种操作足够应付一般场景。操作的使用需要依旧业务场景,每个人都不一样,但是对于缓存的使用设计的思考却是大家都需要关注的,毕竟方法用途是不变的,不断变化的只有业务需求而已。不必太过拘于数据该用哪种缓存方法,能为我们更好的解决业务问题,才是缓存被设计出来的最初目的。