工作中做的所有项目都用到了redis,对其设计思路和问题处理做个总结。
key设计:可读性高,定义简洁,不包含特殊字符,一般使用:分隔,比如user:info:1000001,表示id为1000001的缓存key
value设计:字符串不宜过长,字符串最大是512M,一般来说超过10k我们就认为他是bigkey,集合,有序集合,哈希,个数不宜太多,比如存储百万级别的数据。具体大小应该根据读写频率进行评估。bigkey太多容易造成redis阻塞和网络阻塞。过期删除时,如果没有redis4以上的异步删除机制,bigkey也容易造成阻塞。bigkey要注意拆解成多个key-value,如果不可避免,尽量不要用一次性全部取出的命令。key值要控制生命周期,避免存储过多无用的数据。
命令:禁用keys,fslushall,flushdb等危险命令,适量使用批量命令可以提高效率,O(n)复杂度的命令要关注n的个数。部分遍历的需求可以使用scan相关的命令渐进式遍历。
什么是缓存击穿?redis键值对集中失效,导致大量的请求到存储层,容易引起存储层负载过高导致故障。比如电商网站可能同时商家多个热卖商品,活动时间两小时,两小时之后就会集中失效。解决办法就是将key值基于某个基数随机设置过期时间,避免集中失效。类似下面的伪代码:
public String get(String key) { // 从缓存中取数据 String cacheData = cache.get(key); if (StringUtils.isNotBlank(cacheData)) { // 缓存非空,返回数据 return cacheData; } // 缓存为空,从存储层获取数据 String storageValue = storage.get(key); cache.set(key, storageValue); // 随机设置过期时间 int expireTime = new Random().nextInt(500) + 500; return storageValue; }
什么是缓存穿透?一个不存在的数据,缓存和数据库都查不到,导致每次都会到数据库查询,失去了缓存的意义。这种一般是代码逻辑出问题或者被恶意攻击导致的。
最直接的解决办法是缓存空数据且设置过期时间,这样还是可以在缓存层拦截下来,缺点时如果缓存了很多空对象,key-value会占用较多redis的空间。
还有一种思路,使用布隆过滤器来解决,这是一种去重的思想,并不属于redis,redisson客户端进行比较好的实现,可以拿来使用。可以将布隆过滤器理解为一个非常大的bit数组,初始都为0,将key进行多次hash散列之后,放到指定的位置标记为1。具体判定方法为:如果过滤器中存在,这个key实际并不一定存在。如果过滤器中不存在,这个key一定不存在。缺点是需要提前构建过滤器,会有一定的误差率,不能删除,新增key时要在布隆过滤器中设置。
什么是缓存雪崩?缓存层顶住了绝大多数请求,但是由于种种原因,比如宕机、缓存设计不够好、超大并发导致缓存扛不住等问题,大量的请求直接流向存储层,容易造成存储层故障。
1)缓存设计要合理,减少bigkey的出现,对业务和数据量提前进行预估。
2)应该保证缓存层的高可用,现在更多的使用集群模式。
3)限流、熔断、降级,并不是服务器能顶住一百万的请求,就随时让请求全部进来,限流处理还是很有必要的,要留有一定的空间。如果顶不住流量的压力对于非核心数据可以降级返回,对于核心数据仍然可以从缓存以及数据库中获取
缓存和数据库不一致?并发条件下,读写缓存和读写数据库的执行过程是不可预估的,多个线程执行的顺序是外部很难看清楚,可能会出现缓存数据库不一致问题。比如A线程写入数据库,然后B线程此时写入数据库且写入缓存,由于各种原因,比如网络等,即使A线程先执行业务,也有可能会后写入缓存,这就可能出现不一致。
如果问题并发量很小,就很难出现上述问题,或者缓存和数据库可以容忍出现不一致情况,我们给缓存设置过期时间就行,让缓存隔一段时间刷新一次数据。如果我们对于缓存和数据库的一致性有严格的要求,可以加读写锁来实现,读读相当于无锁,效率还是比较高的。引用外部的工具也可以处理,比如使用阿里开源的一款工具:canal,监听数据库binlog近实时更新缓存,且和我们的业务代码解耦,缺点就是系统引入了新的中间件,会增加系统复杂性。
对热点key重建时的优化:在某些场景下,一些缓存的访问量极大,比如微博热搜、头条热点此类,缓存失效时,会涌入大量的请求尝试将数据写入缓存,建缓存可能设计很多复杂的逻辑,比如多次查询数据库、多次IO等,不能短时间内完成。如果每个请求都做这些操作,会给服务端应用造成压力。对于这种情况,我们可以使用互斥锁,可以参考redis分布式锁,同一时间只有一个线程重建缓存,其他线程只需要等待重建完成从缓存查询数据即可。看下面的伪代码:
String get(String key) { // 从缓存中获取数据 String cacheValue = redis.get(key); // 如果cacheValue为空, 重构缓存 if (StringUtil.isBlank(cacheValue)) { // 推荐使用redisson的分布式锁 if (redisson.tryLock(lockKey)) { cacheValue = storage.get(key); // 写入缓存 redis.setex(key, timeout, cacheValue); // 释放锁 redisson.unlock(); }else { // 没有拿到所的线程,休眠一段时间直接再尝试从缓存中读取数据 Thread.sleep(50); get(key); } } return cacheValue; }
对于并发量很高的系统,而且对应用的稳定性有要求的系统,上面的很多情况可能都要思考在内。