引入缓存是一种非常常见的策略,可以提高上层的查询性能,同时减轻下层存储的压力。但同时也引入了更多的问题,主要围绕命中率和一致性两个方面,如果命中率过低,那么缓存就形同虚设,如果不能保证缓存与真实数据一致,那么就会造成错误,不如不用缓存。下面主要总结下使用redis作为缓存时常见的问题和解决办法
缓存击穿
针对数据库中存在,但缓存中没有的数据(一般是热点数据缓存时间到期),如果此时请求并发很高,由于缓存没有读到,会同时去查询数据库,造成数据库压力陡增,影响系统稳定性;
解决办法:
处理并发请求时,只有第一个请求线程能拿到锁并执行数据库查询操作,其他线程阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| private RedissonClient redissonClient;
public List<String> getData(String key) throws InterruptedException { for (int retry = 0; retry < 10; retry++) { List<String> result = getDataFromCache(key); if (result != null) { return result; }
RLock lock = redissonClient.getLock("prefix:lock:" + key); if (lock.tryLock(0, 10, TimeUnit.SECONDS)) { try { result = getDataFromCache(key); if (result == null) { result = getDataFromDB(key); if (result == null) { result = new ArrayList<>(); } setDataToCache(key, result); } return result; } finally { lock.unlock(); } }
Thread.sleep(100); }
return getDataFromDB(key); }
|
缓存雪崩
可以理解成升级版的缓存击穿,大量热点key同时失效(可能是缓存重启或者大量key设置了相同的过期时间),此时数据库压力暴增,可能会崩溃;
解决办法:
缓存穿透
缓存穿透的危害最大,因为它的目标是根本不存在的数据,此时缓存形同虚设,如果恶意攻击,制造大量非法请求,很容易将数据库压垮;
解决办法:
布隆过滤器思路:通过多个hash函数,对存在的key刷一遍,将hash值放到一个bitmap中,这样对于一个请求key,用同样的hash函数对它进行计算,结果放入bitmap进行比较,只要有1位不存在,那么这个key就一定不存在;如果所有位都存在,则可能是误判存在。但它的好处是存储小,且对不存在的判断准确;缺点则是对存在的判断可能不准,而且删除困难;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| @Component public class BloomFilter<T> { private final BitSet bitSet; private final int bitSetSize; private final int expectedNumberOfItems; private final int numberOfHashFunctions; private final HashFunction[] hashFunctions; public BloomFilter(int expectedNumberOfItems, double falsePositiveProbability) { this.expectedNumberOfItems = expectedNumberOfItems; double ln2 = Math.log(2); double ln2Squared = ln2 * ln2; this.bitSetSize = (int) Math.ceil(-expectedNumberOfItems * Math.log(falsePositiveProbability) / ln2Squared); this.bitSet = new BitSet(bitSetSize); this.numberOfHashFunctions = (int) Math.ceil(ln2 * bitSetSize / expectedNumberOfItems);
this.hashFunctions = new HashFunction[numberOfHashFunctions]; for (int i = 0; i < numberOfHashFunctions; i++) { hashFunctions[i] = new SimpleHash(bitSetSize, i + 1); } } public void add(T element) { if (element == null) { return; }
int hash1 = element.hashCode(); int hash2 = hash1 >>> 16; for (int i = 0; i < numberOfHashFunctions; i++) { int combinedHash = hash1 + i * hash2; int position = Math.abs(combinedHash) % bitSetSize; bitSet.set(position); } } public boolean contains(T element) { if (element == null) { return false; }
int hash1 = element.hashCode(); int hash2 = hash1 >>> 16; for (int i = 0; i < numberOfHashFunctions; i++) { int combinedHash = hash1 + i * hash2; int position = Math.abs(combinedHash) % bitSetSize; if (!bitSet.get(position)) { return false; } } return true; } }
|
对于分布式环境,可以使用Redis的布隆过滤器模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Component public class RedisBloomFilter { @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String BLOOM_FILTER_KEY = "bloom_filter:user_ids"; public boolean contains(Long id) { try { return (Boolean) redisTemplate.execute( (RedisCallback<Boolean>) con -> con.executeCommand( new CommandObject<>(RedisCommands.BF_EXISTS, BLOOM_FILTER_KEY, String.valueOf(id)))); } catch (Exception e) { return true; } } public void add(Long id) { try { redisTemplate.execute( (RedisCallback<Void>) con -> con.executeCommand( new CommandObject<>(RedisCommands.BF_ADD, BLOOM_FILTER_KEY, String.valueOf(id)))); } catch (Exception e) { } } }
|
- 缓存空值,如果请求的key通过了校验和过滤,可以考虑缓存空值来拦住请求,具体办法与上面缓存击穿的处理一样;
缓存一致性问题
旁路缓存 Cache Aside
- 读操作:缓存命中则返回,如果未命中则读取数据库,并回填缓存;
- 写操作:先更新数据库,然后删除缓存;
这是最常用和方便的模式,基本可以满足大部分场景。通过惰性加载保证高性能,并通过删除而非更新缓存来避免并发写的不一致,实现最终一致性。
但是需要防范缓存击穿、雪崩等问题,至于出现数据不一致的概率基本可以忽略。只有在一个线程A读完数据库写入Redis的期间,另一个线程B完成了数据库更新和删除Redis,才会发生。而且,也可以通过延迟双删进一步降低这种概率,就是在删除之后等待一小段时间后再删一次。
有的业务场景可能容忍不了惰性加载缓存,进行写操作时,需要同时更新数据库和缓存。这里问题就是先写数据库还是先写缓存,推荐是先写数据库(毕竟数据库才是真正的数据持久化,而且写缓存失败还可以回滚前面的数据库操作),但不管是先写哪个,都存在数据不一致的可能,毕竟写库和写缓存是两件独立的操作。或者你可以通过互斥锁来将两个操作变成一个原子操作,但这样明显会影响性能(在强一致性与并发性能之间无法做到既要又要)。
读/写穿透 Read/Write Through
- 读穿透:应用只找缓存要数据,如果缓存里没有,缓存组件自己去数据库加载数据;
- 写穿透:应用只负责将数据写入缓存,然后由缓存组件自己去更新数据库;
这里应用只需面对缓存进行操作即可,因为缓存组件承担了更多的工作,所以重点就是对缓存组件的操作封装。主要就是载入和刷库操作,具体操作方式可以再分为同步和异步。
比较稳一点选择是:同步写库,然后异步刷新缓存。这样虽然读操作的即时性稍微会差一点,但可以避免写操作时丢数据(毕竟难以容忍)。
写回 Write Behind
- 读操作,读取缓存,如果没有则读取数据库并回填;
- 写操作,只更新缓存,然后直接返回,数据库的更新被推迟到缓存数据过期或被逐出时触发;
这里缓存组件需要做更多的事情,要监听缓存中的key,在其过期时将最新数据刷到数据库。这种方式也很常见,比如mysql对Buffer pool中脏页的处理。但代价是可能丢数据,比如缓存突然宕机,那么所有未刷到数据库的脏数据将全部丢失。(其实这种方式反而不存在数据不一致的问题,因为对应用来说只面对缓存一个存储,但前提是能解决数据丢失问题)
注意,这里读操作回填缓存,或写操作载入缓存时不能直接SET覆盖,需要使用SETNX(Set if Not Exists),因为缓存的数据版本永远高于数据库中的版本,可能有别的线程已经更新了缓存数据,如果直接覆盖可能造成修改丢失的问题。
预刷新 Refresh Ahead
- 读操作,读取缓存,如果没有则读取数据库并回填;
- 写操作,只更新数据库,然后直接返回,后面通过监听数据库Binlog方式异步刷新缓存;
这里同样面临数据不一致的问题,比如在数据写入数据库之后,Binlog被消费并刷新到缓存之前,读取的缓存数据将是旧版本数据。另外,这种方式中的数据是以数据库为准的,也就是来自Binlog的数据更新一定是可信的,所以读操作在回填缓存是也要使用SETNX。(比如读线程A读取了一个数据旧值,但是在它回填缓存之前,写线程B完成了更新,并将Binlog的变化刷到了缓存,此时线程A拿到的值就不应该再写入缓存了)
参考: