爬虫网站开发,苏州网络公司工作室,地铁公司招聘信息网站,软件工程师证书报考网站本地缓存方案选型及使用缓存的坑 一、摘要二、本地缓存三、本地缓存实现方案3.1 自己编程实现一个缓存3.2 基于 Guava Cache 实现本地缓存3.3 基于 Caffeine 实现本地缓存3.4 基于 Encache 实现本地缓存3.5 小结 四、使用缓存的坑4.1 缓存穿透4.2 缓存击穿4.3 缓存雪崩4.4 数据… 本地缓存方案选型及使用缓存的坑 一、摘要二、本地缓存三、本地缓存实现方案3.1 自己编程实现一个缓存3.2 基于 Guava Cache 实现本地缓存3.3 基于 Caffeine 实现本地缓存3.4 基于 Encache 实现本地缓存3.5 小结 四、使用缓存的坑4.1 缓存穿透4.2 缓存击穿4.3 缓存雪崩4.4 数据不一致4.5 大key问题4.6 热key问题4.7 命中率问题 一、摘要
在互联网公司面试时说到缓存面试官基本上会绕不开的几个话题项目中哪些地方用到了缓存为什么要使用缓存怎么使用它的引入缓存后会带来哪些问题
引入缓存其实主要有两个用途高性能、高并发。
性能体现在引入缓存之前以商城网站为例频繁的从数据库里面获取商品数据也就需要频繁执行SQL等待结果若数据量很大同时请求频次逐渐增高响应就逐渐缓慢引入缓存之后将数据库里面查询出来的商品数据信息存入缓存需要时直接从缓存服务获取结果效率极大提升。
并发体现在引入缓存之前以 MySQL数据库为例单台机器一秒内的请求次数到达 2000 之后就会开始报警引入缓存之后比如以 Redis 缓存服务器为例单台机器一秒内的请求次数支持 110000 次两者支持的并发量完全不是一个数量级的。
缓存和数据库效率差距大的根本原因缓存数据存储在内存数据库数据存储在磁盘 而计算机中内存的数据读写性能远超磁盘的读写性能。但电脑重启后内存数据易丢失而磁盘数据不易丢失。
所以数据存储方案不同造就不同的实践用途。接下来就浅谈缓存主要是本地缓存的使用。
二、本地缓存
从缓存面向的对象不同缓存分为本地缓存、分布式缓存和多级缓存。 1本地缓存在单个计算机服务实例中直接把数据缓存到内存中进行使用。 2分布式缓存将一个计算机服务同时在多台计算机里部署所需数据无法共享比如session会话而引入一个独立部署的缓存服务来连接多台服务器的技术实践方案。 3多级缓存在实际的业务中本地缓存和分布式缓存会同时结合进行使用当收到访问某个数据的操作时会优先从本地缓存服务一级缓存查询如果没有再从分布式缓存服务二级缓存里面获取如果也没有最后再从数据库里面获取从数据库查询完成之后在依次更新分布式缓存服务、本地缓存服务的技术实践方案。
三、本地缓存实现方案
缓存关注点第一是内存持久化第二是支持缓存的数据自动过期清除。
3.1 自己编程实现一个缓存
对于简单的数据缓存完全可以自行编写一套缓存服务。实现思路很简单采用ConcurrentHashMap作为缓存数据存储服务然后开启一个定时调度每隔500毫秒检查一下过期的缓存数据然后清除。 首先创建一个缓存实体类
public class CacheEntity {/*** 缓存键*/private String key;/*** 缓存值*/private Object value;/*** 过期时间*/private Long expireTime;//...set、get
}接着创建一个缓存操作工具类CacheUtils
public class CacheUtils {/*** 缓存数据*/private final static MapString, CacheEntity CACHE_MAP new ConcurrentHashMap();/*** 定时器线程池用于清除过期缓存*/private static ScheduledExecutorService executor Executors.newSingleThreadScheduledExecutor();static {// 注册一个定时线程任务服务启动1秒之后每隔500毫秒执行一次executor.scheduleAtFixedRate(new Runnable() {Overridepublic void run() {// 清理过期缓存clearCache();}},1000,500,TimeUnit.MILLISECONDS);}/*** 添加缓存* param key 缓存键* param value 缓存值*/public static void put(String key, Object value){put(key, value, 0);}/*** 添加缓存* param key 缓存键* param value 缓存值* param expire 缓存时间单位秒*/public static void put(String key, Object value, long expire){CacheEntity cacheEntity new CacheEntity().setKey(key).setValue(value);if(expire 0){Long expireTime System.currentTimeMillis() Duration.ofSeconds(expire).toMillis();cacheEntity.setExpireTime(expireTime);}CACHE_MAP.put(key, cacheEntity);}/*** 获取缓存* param key* return*/public static Object get(String key){if(CACHE_MAP.containsKey(key)){return CACHE_MAP.get(key).getValue();}return null;}/*** 移除缓存* param key*/public static void remove(String key){if(CACHE_MAP.containsKey(key)){CACHE_MAP.remove(key);}}/*** 清理过期的缓存数据*/private static void clearCache(){if(CACHE_MAP.size() 0){return;}IteratorMap.EntryString, CacheEntity iterator CACHE_MAP.entrySet().iterator();while (iterator.hasNext()){Map.EntryString, CacheEntity entry iterator.next();if(entry.getValue().getExpireTime() ! null entry.getValue().getExpireTime().longValue() System.currentTimeMillis()){iterator.remove();}}}
}最后创建测试main方法
/ 写入缓存数据过期时间为3秒
CacheUtils.put(userName, 张三, 3);// 读取缓存数据
Object value1 CacheUtils.get(userName);
System.out.println(第一次查询结果 value1);// 停顿4秒
Thread.sleep(4000);// 读取缓存数据
Object value2 CacheUtils.get(userName);
System.out.println(第二次查询结果 value2);结果
第一次查询结果张三
第二次查询结果null3.2 基于 Guava Cache 实现本地缓存
Guava 是 Google 团队开源的一款 Java 核心增强库包含集合、并发原语、缓存、IO、反射等工具箱性能和稳定性上都有保障应用十分广泛。而Guava Cache 很强大支持很多特性如下
支持最大容量限制
支持两种过期删除策略插入时间和读取时间
支持简单的统计功能
基于 LRU 算法实现首先pom.xml引入guava依赖
!--guava--
dependencygroupIdcom.google.guava/groupIdartifactIdguava/artifactIdversion31.1-jre/version
/dependency使用
// 创建一个缓存实例
CacheString, String cache CacheBuilder.newBuilder()// 初始容量.initialCapacity(5)// 最大缓存数超出淘汰.maximumSize(10)// 过期时间.expireAfterWrite(3, TimeUnit.SECONDS).build();// 写入缓存数据
cache.put(userName, 张三);// 读取缓存数据
String value1 cache.get(userName, () - {// 如果key不存在会执行回调方法return key已过期;
});
System.out.println(第一次查询结果 value1);// 停顿4秒
Thread.sleep(4000);// 读取缓存数据
String value2 cache.get(userName, () - {// 如果key不存在会执行回调方法return key已过期;
});
System.out.println(第二次查询结果 value2);输出结果
第一次查询结果张三
第二次查询结果key已过期3.3 基于 Caffeine 实现本地缓存
Caffeine 是基于 java8 实现的新一代缓存工具缓存性能接近理论最优可以看作是 Guava Cache 的增强版功能上两者类似不同的是 Caffeine 采用了一种结合 LRU、LFU 优点的算法W-TinyLFU在性能上有明显的优越性。 首先pom.xml引入caffeine依赖
!--caffeine--
dependencygroupIdcom.github.ben-manes.caffeine/groupIdartifactIdcaffeine/artifactIdversion2.9.3/version
/dependency使用
// 创建一个缓存实例
CacheString, String cache Caffeine.newBuilder()// 初始容量.initialCapacity(5)// 最大缓存数超出淘汰.maximumSize(10)// 设置缓存写入间隔多久过期.expireAfterWrite(3, TimeUnit.SECONDS)// 设置缓存最后访问后间隔多久淘汰实际很少用到//.expireAfterAccess(3, TimeUnit.SECONDS).build();// 写入缓存数据
cache.put(userName, 张三);// 读取缓存数据
String value1 cache.get(userName, (key) - {// 如果key不存在会执行回调方法return key已过期;
});
System.out.println(第一次查询结果 value1);// 停顿4秒
Thread.sleep(4000);// 读取缓存数据
String value2 cache.get(userName, (key) - {// 如果key不存在会执行回调方法return key已过期;
});
System.out.println(第二次查询结果 value2);输出结果
第一次查询结果张三
第二次查询结果key已过期3.4 基于 Encache 实现本地缓存
Encache 是一个纯 Java 的进程内缓存框架具有快速、精干等特点是 Hibernate 中默认的 CacheProvider。
同 Caffeine 和 Guava Cache 相比Encache 的功能更加丰富扩展性更强特性如下
支持多种缓存淘汰算法包括 LRU、LFU 和 FIFO
缓存支持堆内存储、堆外存储、磁盘存储支持持久化三种
支持多种集群方案解决数据共享问题首先pom.xml引入ehcache依赖
!--ehcache--
dependencygroupIdorg.ehcache/groupIdartifactIdehcache/artifactIdversion3.9.7/version
/dependency使用
/*** 自定义过期策略实现*/
public class CustomExpiryPolicyK, V implements ExpiryPolicyK, V {private final MapK, Duration keyExpireMap new ConcurrentHashMap();public Duration setExpire(K key, Duration duration) {return keyExpireMap.put(key, duration);}public Duration getExpireByKey(K key) {return Optional.ofNullable(keyExpireMap.get(key)).orElse(null);}public Duration removeExpire(K key) {return keyExpireMap.remove(key);}Overridepublic Duration getExpiryForCreation(K key, V value) {return Optional.ofNullable(getExpireByKey(key)).orElse(Duration.ofNanos(Long.MAX_VALUE));}Overridepublic Duration getExpiryForAccess(K key, Supplier? extends V value) {return getExpireByKey(key);}Overridepublic Duration getExpiryForUpdate(K key, Supplier? extends V oldValue, V newValue) {return getExpireByKey(key);}
}public static void main(String[] args) throws InterruptedException {String userCache userCache;// 自定义过期策略CustomExpiryPolicyObject, Object customExpiryPolicy new CustomExpiryPolicy();// 声明一个容量为20的堆内缓存配置CacheConfigurationBuilder configurationBuilder CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, String.class, ResourcePoolsBuilder.heap(20)).withExpiry(customExpiryPolicy);// 初始化一个缓存管理器CacheManager cacheManager CacheManagerBuilder.newCacheManagerBuilder()// 创建cache实例.withCache(userCache, configurationBuilder).build(true);// 获取cache实例CacheString, String cache cacheManager.getCache(userCache, String.class, String.class);// 获取过期策略CustomExpiryPolicy expiryPolicy (CustomExpiryPolicy)cache.getRuntimeConfiguration().getExpiryPolicy();// 写入缓存数据cache.put(userName, 张三);// 设置3秒过期expiryPolicy.setExpire(userName, Duration.ofSeconds(3));// 读取缓存数据String value1 cache.get(userName);System.out.println(第一次查询结果 value1);// 停顿4秒Thread.sleep(4000);// 读取缓存数据String value2 cache.get(userName);System.out.println(第二次查询结果 value2);
}输出结果
第一次查询结果张三
第二次查询结果null3.5 小结 对于本地缓存的技术选型推荐采用 Caffeine性能上遥遥领先。功能与Guava 类似而Encache虽支持持久化和集群但不如分布式缓存中间件Redis。
四、使用缓存的坑
在项目中经常会使用缓存但用不好的话坑也挺多的
4.1 缓存穿透
当用户请求的id在缓存中不存在或恶意用户伪造不存在的id发起请求每次从缓存中都查不到数据而需要查询数据库同时数据库中也没有查到该数据也没法放入缓存。也就是每次这个用户请求过来的时候都要查询一次数据库。 很显然缓存根本没起作用好像被穿透一样每次都会去访问数据库而直接请求数据库数量非常多数据库可能因为扛不住压力而崩溃。 解决方案 缓存空值 当某个用户id在缓存中查不到在数据库中也查不到时也要将该用户id缓存起来只不过值是空的。这样后面的请求再拿相同的用户id发起请求时就能从缓存中获取空数据直接返回而无需再去查数据库。 比如redis:
redisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES);4.2 缓存击穿
在访问热点数据时该热点在缓存中过期失效导致这些大量请求短时间都直接怼到数据库可能会造成瞬间数据库压力过大而直接挂掉。 解决方案 1加锁。在访问数据库时加锁防止多个相同keyId的请求同时访问数据库。
try {String result jedis.set(keyId, requestId, NX, PX, expireTime);if (OK.equals(result)) {return queryInfoById(keyId);}
} finally{unlock(keyId,requestId);
}
return null;2自动续期 在key快要过期之前用job给指定key自动续期。比如redis使用lua脚本。 3永久有效 对于很多热门key其实是可以不用设置过期时间让其永久有效的。
4.3 缓存雪崩
而缓存雪崩是缓存击穿的升级版缓存击穿说的是某一个热门key失效了而缓存雪崩说的是有多个热门key同时失效。 缓存雪崩目前有两种
1有大量的热门缓存同时失效。会导致大量的请求访问数据库。而数据库很有可能因为扛不住压力而直接挂掉。
2缓存服务器down机可能是机器硬件问题或者机房网络问题。总之造成了整个缓存的不可用。解决方案 1 过期时间加随机数不要设置相同的过期时间可以在设置的过期时间基础上再加个1~60秒的随机数。
实际过期时间 过期时间 1~60秒的随机数2保证高可用 比如如果使用了redis可以使用哨兵模式或者集群模式避免出现单节点故障导致整个redis服务不可用的情况。
3服务降级 需要配置一些默认的兜底数据。程序中有个全局开关比如有10个请求在最近一分钟内从redis中获取数据失败则全局开关打开。后面的新请求就直接从配置中心中获取默认的数据。
4.4 数据不一致
数据库和缓存比如redis双写数据一致性问题是一个跟开发语言无关的公共问题。尤其高并发场景这个问题尤为严重。 解决方案 先写数据库再删缓存 先写数据库再删缓存 先写数据库再删缓存 除非同时满足
缓存刚好自动失效。
读请求从数据库查出旧值更新缓存的耗时比写请求写数据库并且删除缓存的还长。才会出现数据不一致但系统同时满足上述两个条件的概率非常小。
4.5 大key问题
在使用缓存的时候特别是Redis经常会遇到大key问题缓存中单个key的value值过大。 项目经历
在一个风控项目中曾开发过一个分类树查询接口系统刚上线时数据量少在Redis中定义的key比较小
我在做系统设计时也没考虑到这个问题。系统运行很长一段时间也没有问题。但随着时间的推移用户的数据越来越多
用户的购买行为分类树也越来越大慢慢形成大key问题。后来某一天之后发现线上查询客户画像接口耗时越来越长
追查原因发现单个用户分类数据涨到上万个导致该接口出现性能问题追查发现分类树json串已经接近16MB而引发大key问题导致的。解决方案 1缩减字段名 优化在Redis中存储数据的大小首先需要对数据进行瘦身。只保存需要用到的字段
AllArgsConstructor
Data
public class Category {private Long id;private String name;private Long parentId;private Date inDate;private Long inUserId;private String inUserName;private ListCategory children;
}这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。 然后修改自动名称
AllArgsConstructor
Data
public class Category {/*** 分类编号*/JsonProperty(i)private Long id;/*** 分类层级*/JsonProperty(l)private Integer level;/*** 分类名称*/JsonProperty(n)private String name;/*** 父分类编号*/JsonProperty(p)private Long parentId;/*** 子分类列表*/JsonProperty(c)private ListCategory children;
}由于在一万多条数据中每条数据的字段名称是固定的他们的重复率太高由此可以在json序列化时改成一个简短的名称以便于返回更少的数据大小。
2压缩数据 由于在Redis中保存的key/value其中的value我是存储json格式的字符串但是占用内存很大所以需要对存储的数据做压缩。
由于RedisTemplate支持value保存byte数组因此先将json字符串数据用GZip工具类压缩成byte数组然后保存到Redis中。
在获取数据时将byte数组转换成json字符串然后再转换成分类树。
这样优化之后保存到Redis中的分类树的数据大小减少10倍从而解决大key问题。
4.6 热key问题
二八原理描述80%的用户经常访问20%的热点数据。引发数据倾斜不能均匀分布尤其是高并发系统中问题比较大。
比如有个促销系统有几款商品性价比非常高这些商品数据在Redis中按分片保存的不同的数据保存在不同的服务器节点上。 如果用户疯狂抢购其中3款商品而这3款商品正好保存在同一台Redis服务端节点。 这样会出现大量的用户请求集中访问同一天Redis服务器节点该节点很有可能会因为扛不住这么大的压力而直接down机。
解决方案 1拆分key提前做好评估将热点数据分开存储在不同redis服务器来分摊压力。 2增加本地缓存对于热key数据可以增加一层本地缓存见前文能够提升性能的同时也能避免Redis访问量过大的问题。但可能会出现数据不一致问题。
4.7 命中率问题
前面的情况都影响缓存的命中率问题因为可能会出现缓存不存在或者缓存过期等问题导致缓存不能命中。 解决方案 1缓存预热 在API服务启动之前可以先用job将相关数据先保存到缓存中做预热。 这样后面的请求就能直接从缓存中获取数据而无需访问数据库。 2合理调整过期时间 3增加缓存内存