有些业务经常访问数据库表数据,但是访问数据库表是有IO消耗的,特别是成百上千万的访问量时,系统更加受不住,会造成一部分用户获取不到响应,交互体验差。
这时候就需要提升系统性能,以便改善响应速率,最高效方便的就是缓存,现在就使用redis实现高性能缓存,将我们业务中最常用的数据缓存到redis中,则我们不需要访问数据库,直接获取内存中的缓存,效率高很多。
就是访问save和update方法时,将数据放到redis里缓存起来,访问delete方法时删掉redis中的数据,访问get方法的时候直接从redis中获取数据,没有的时候才从数据库中获取数据。
来看一下一个最简版的缓存实现
public class NewsServiceImpl implements NewsService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private Redisson redisson;
@Autowired
private NewsDao newsDao;
private final static String CACHE_NEWS_PRE = "cache:newes";
@Override
public NewsEntity get(long id) {
String news = (String)redisTemplate.opsForValue().get(CACHE_NEWS_PRE + id);
if (StringUtils.isNotBlank(news)){
NewsEntity entity = JSON.parseObject(news, NewsEntity.class);
return entity;
}
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity);
return dbEntity;
}
@Override
public NewsEntity insert(NewsEntity entity) {
NewsEntity news = newsDao.save(entity);
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + news.getId(), JSONObject.toJSONString(news));
return news;
}
@Override
public NewsEntity update(NewsEntity entity) {
NewsEntity news = newsDao.save(entity);
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + news.getId(), JSONObject.toJSONString(news));
return news;
}
@Override
public void delete(long id) {
newsDao.deleteById(id);
redisTemplate.delete(CACHE_NEWS_PRE + id);
}
}
这种代码只能应付偶尔使用缓存的情况,高并发高访问下就不中用了,那就改进一下。
明眼的一看就知道有线程安全问题,缓存读写不一致。缓存也没有做管理,一直占用内存,如果新浪微博的数据,缓存一直不做清理,上亿的数据,redis不得炸了,先来处理下这个问题,当我们读写这个数据时,给它设置超时时间,那么一直被访问的数据就会被保留在redis中,不被访问的慢慢的就会过期了。
// 所有设置缓存的地方都加了超时时间,我假设定了1天超时
@Override
public NewsEntity get(long id) {
String news = (String)redisTemplate.opsForValue().get(CACHE_NEWS_PRE + id);
if (StringUtils.isNotBlank(news)){
NewsEntity entity = JSON.parseObject(news, NewsEntity.class);
redisTemplate.expire(CACHE_NEWS_PRE + id, CACHE_NEWS_EXPIRE, TimeUnit.SECONDS);
return entity;
}
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity, CACHE_NEWS_EXPIRE, TimeUnit.SECONDS);
return dbEntity;
}
很经典的一个问题,顾名思义,就击穿了缓存,在缓存中差不多数据,当有大量的请求打过来时就打了数据库中,会导致数据库压力变大甚至崩溃。来看下场景就容易知道怎么解决了。
情景1:国人心血来潮,都来访问新浪,这时新浪会建立大量的缓存在redis中,过了段时间,缓存超时时间到了,大家又来访问,但是这时缓存都已经失效了,这样大家的访问就都落在了数据库里,这时就要阻止缓存的同时失效,才不至于击穿缓存。
// 随机生成3小时内差异的超时时间
public int getCacheNewsExpire() {
return CACHE_NEWS_EXPIRE + new Random().nextInt(3*60*60);
}
@Override
public NewsEntity get(long id) {
String news = (String)redisTemplate.opsForValue().get(CACHE_NEWS_PRE + id);
if (StringUtils.isNotBlank(news)){
NewsEntity entity = JSON.parseObject(news, NewsEntity.class);
redisTemplate.expire(CACHE_NEWS_PRE + id, getCacheNewsExpire(), TimeUnit.SECONDS);
return entity;
}
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity, getCacheNewsExpire(), TimeUnit.SECONDS);
return dbEntity;
}
和缓存击穿类似的另一个问题就是缓存穿透,和缓存击穿有一点不一样,缓存穿透是把redis缓存和数据库都穿透了,即两边都查不到数据,那这个会造成什么问题呢?看下情景:
情景2:实不相瞒,我是个黑客,我现在想要攻击实现了上面缓存方法的网站,我生成不合规id(保证不在数据库中)来访问网站,用压测工具瞬时生成上百万的请求达到网站中,数据库都得被打崩。
解决:给空数据建立缓存,过期时间就几分钟就好了,免得空缓存一直留在redis中占用内存。
private final static int CACHE_NEWS_EXPIRE = 60*60*24;
private final static String CACHE_NEWS_NULL = "{}";
@Override
public NewsEntity get(long id) {
String news = (String)redisTemplate.opsForValue().get(CACHE_NEWS_PRE + id);
if (StringUtils.isNotBlank(news)){
if (CACHE_NEWS_NULL.equals(news)){
// 如果查到的是空缓存,则延迟空缓存时间
redisTemplate.expire(CACHE_NEWS_PRE + id, getNullCacheNewsExpire(), TimeUnit.SECONDS);
return null;
}
NewsEntity entity = JSON.parseObject(news, NewsEntity.class);
//对查到的缓存做延时失效
redisTemplate.expire(CACHE_NEWS_PRE + id, getCacheNewsExpire(), TimeUnit.SECONDS);
return entity;
}
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) {
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity, getCacheNewsExpire(), TimeUnit.SECONDS);
}else {
// 数据库里没有的也要做缓存,防止缓存穿透问题
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, CACHE_NEWS_NULL, getNullCacheNewsExpire(), TimeUnit.SECONDS);
}
return dbEntity;
}
public int getNullCacheNewsExpire() {
return 120 + new Random().nextInt(60);
}
当遇到高并发时,还是有严重的问题,如下图,所有访问都打到查数据库的地方,都没有缓存可返回,会造成数据库的压力。
所以要防止这种情况发生,我们要解决一下下面的问题:
@Override
public NewsEntity get(long id) {
NewsEntity redisCache = getRedisCache(CACHE_NEWS_PRE + id);
if (redisCache != null){
return redisCache;
}
RLock lock = redisson.getLock(CACHE_NEWS_PRE + id);
lock.lock();
try{
// 双重检测机制,通过锁同步,当第一个请求完成时redis就会有缓存,到时候直接走缓存就能获取数据了,不用走db
NewsEntity redisCacheDoubleCheck = getRedisCache(CACHE_NEWS_PRE + id);
if (redisCacheDoubleCheck != null){
return redisCacheDoubleCheck;
}
// 查询数据库
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) {
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity, getCacheNewsExpire(), TimeUnit.SECONDS);
}else {
// 数据库里没有的也要做缓存,防止缓存穿透问题
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, CACHE_NEWS_NULL, getNullCacheNewsExpire(), TimeUnit.SECONDS);
}
return dbEntity;
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
return null;
}
public NewsEntity getRedisCache(String key){
String news = (String)redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(news)){
if (CACHE_NEWS_NULL.equals(news)){
// 如果查到的是空缓存,则延迟空缓存时间
redisTemplate.expire(key, getNullCacheNewsExpire(), TimeUnit.SECONDS);
return new NewsEntity();
}
NewsEntity entity = JSON.parseObject(news, NewsEntity.class);
//对查到的缓存做延时失效
redisTemplate.expire(key, getCacheNewsExpire(), TimeUnit.SECONDS);
return entity;
}
return null;
}
这段代码还是解决不了双写不一致的问题,什么是双写不一致呢?就是数据库和缓存的值不一样。
这时有两个线程,,每个线程都有两个步骤,写数据库,更改缓存,如下:
线程A:
A.1 : 写数据进数据库
A.2 : 更新缓存
线程B:
B.1 : 写数据进数据库
B.2 : 更新缓存
但是当执行顺序按照 A.1 -> B.1 -> B.2 -> A.2 时,数据库保存的就是线程B的值,缓存保存的就是线程A的值 ,这就出现了不一致的情况。
这时要给写加锁
@Override
public NewsEntity get(long id) {
NewsEntity redisCache = getRedisCache(CACHE_NEWS_PRE + id);
if (redisCache != null){
return redisCache;
}
// 分布式锁
// RLock lock = redisson.getLock(CACHE_NEWS_PRE + id);
// lock.lock();
// 把锁改成读写锁,读读不互斥
RReadWriteLock readWriteLock = redisson.getReadWriteLock(CACHE_NEWS_PRE + id);
// 上读锁
RLock readLock = readWriteLock.readLock();
try{
// 双重检测机制,通过锁同步,当第一个请求完成时redis就会有缓存,到时候直接走缓存就能获取数据了,不用走db
NewsEntity redisCacheDoubleCheck = getRedisCache(CACHE_NEWS_PRE + id);
if (redisCacheDoubleCheck != null){
return redisCacheDoubleCheck;
}
// 查询数据库
NewsEntity dbEntity = newsDao.getOne(id);
if (dbEntity != null) {
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, dbEntity, getCacheNewsExpire(), TimeUnit.SECONDS);
}else {
// 数据库里没有的也要做缓存,防止缓存穿透问题
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + id, CACHE_NEWS_NULL, getNullCacheNewsExpire(), TimeUnit.SECONDS);
}
return dbEntity;
}catch (Exception e){
e.printStackTrace();
}finally {
readLock.unlock();
}
return null;
}
@Override
public NewsEntity update(NewsEntity entity) {
RReadWriteLock readWriteLock = redisson.getReadWriteLock(CACHE_NEWS_PRE + entity.getId());
// 上写锁
RLock writeLock = readWriteLock.writeLock();
NewsEntity news = null;
try{
news = newsDao.save(entity);
redisTemplate.opsForValue().set(CACHE_NEWS_PRE + news.getId(), JSONObject.toJSONString(news), getCacheNewsExpire(), TimeUnit.SECONDS);
}finally {
writeLock.unlock();
}
return news;
}
给代码加上读写锁,读写互斥,读读不互斥,这样就能处理了双写不一致情况。
我们redis单节点能够抗住的并发量撑死也就10万,但是如果每秒内有百万级别的并发时,我们的redis就会抗不住而挂掉,请求打到数据库中,redis都抗不住,数据库更不用说,也挂了,导致整个应用都挂了,这就是缓存雪崩效应了。
这时候的解决方案就很是见仁见智了,架构这东西只有最适合的没有最好的。
缓存的方式有很多种,上面列举的只是个人使用的,也有些公司使用的是布隆过滤器,何为布隆过滤器?
类似Map的存储结构,通过bit位的0和1来表示是否有缓存,比如一个byte就能存储8位的bit,01010110这样,设置一个位数组来存储这些数据,然后通过均匀的hash算法将数据均匀的分布到位数组中,有数据则将对应位置设置为1,hash算法要确保数据尽可能的均匀分布,这样的布隆过滤器才更加准确。
请求落在布隆过滤器上的值是0的话则表示一定没有数据,直接不让请求过,如果值为1的话就可能有数据。
实现布隆过滤器前提是预先把数据加载到布隆过滤器上,redisson也提供了布隆过滤器的实现:
public RBloomFilter initBloomFilterCache(){
RBloomFilter<Object> idListBloom = redisson.getBloomFilter("idList");
// 初始化布隆过滤器,参数1:预估数据量,参数2:允许的误差量
idListBloom.tryInit(10000000L, 0.05);
// TODO 将数据库中的数据填充到布隆过滤器中
// while () idListBloom.add(id);
return idListBloom;
}
布隆过滤器也是有缺点的,它不能删除数据,位数组下标的值为1代表的可能是一个或者多个值的hash缓存位置,没有像map一样用链表维护,所以删了会导致其他缓存的问题,这也就表示如果频繁修改数据,就会导致布隆过滤器上有大量的过期数据,会越来越不准确。
说白了,任何缓存都是需要根据自己的业务做调整,做最适合自己系统的处理,引进缓存是为了让系统更加高性能的,如果因为引进缓存而导致系统卡顿,那就没意义了。不多说了,撸马去了。