14 高并发之缓存
1️⃣缓存简介
在现在互联网的环境下,内容越来越复杂用户越来越多而服务的资源是有限的,数据库一定时间内能处理的请求也是有上限的,怎么才能处理日益增长的用户请求成了当务之急这里我们就新介绍一种的方式-缓存;如上图所示,缓存可以应用到上述流程图的各个环节中利用缓存我们直接从缓存中返回目标数据,让服务器的有限资源服务更多的人;
2️⃣缓存的特征
1 命中率 : 命中数 / (命中数 + 未命中数)
命中我们可以简单的理解就是直接从缓存中获取目标数据,每成功获取一次就是命中一次,反之就是未命中;缓存的命中率越高表示我们使用缓存的收益越高,应用的性能越好;
2 最大元素(空间)
表示缓存中可以存放的最大元素的数量,一旦缓存中元素数量超过这个值或者缓存数据所占的空间超过了最大支持的空间将会触发缓存的清空策略,根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率从而更有效的使用缓存;
3 清空策略 : FIFO LFU LRU 过期时间 随机等
FIFO : 先进先出策略,最先进入的数据在缓存空间不够的情况下或者超出最大元素空间限制的时候会优先被清除掉以腾出新的空间来接受新的数据;这个策略主要是比较缓存的创建时间,在数据实时性要求的场景下可以选择该策略优先保证最新数据可用;
LFU : 最少使用策略,无论是否过期根据元素的使用次数来做决策,清除使用次数最少的元素来释放空间,这个策略主要是比较元素的命中次数,在保证高频数据有效性的场景下可以使用该策略;
LRU : 最近最少使用策略,无论是否过期根据元素最后一次使用的时间戳,清除最远使用时间戳的元素释放空间,这个策略主要比较最后一次元素被get使用时间,在热点数据场景下选用此策略优先保证热点数据的有效性;
过期时间 : 根据设置的过期时间来进行清空操作;清理过期时间最长的元素;
随机 : 完全随机策略;
3️⃣缓存命中率影响因素
1 业务场景和业务需求
缓存适合读多写少的业务场景,如果不是这样的场景使用缓存的意义并不是很大,业务需求也决定了对实时性的要求会直接影响到缓存的过期时间和影响策略,实时性要求越低就越适合缓存,在相同key和请求数的情况下缓存存在的时间越长命中率就越高;
2 缓存的设计(粒度和策略)
通常情况下缓存的粒度越小命中率越高,
3 缓存的容量与基础设施
缓存的容量有限容易引起缓存失效和被淘汰,目前多数的缓存中间件都采用了LRU的算法,同时缓存的技术选型也是至关重要的,比如采用应用内置的本地缓存就比较容易出现单机瓶颈而采用分布式缓存就比较容易扩展,所以需要做好系统容量规划并考虑好扩展;此外不同的缓存中间件的效率也是存在一定的差异的;
4️⃣缓存的分类和应用场景
1 本地缓存 : 编程实现(成员变量 局部变量 静态变量) GuavaCache
本地缓存指的是应用中的缓存组件;
优点 : 应用和Cache是在同一个应用的内部,请求缓存非常的快速没有过多的网络开销在单机应用中不需要集群的支持或者在集群环境下各节点不需要相互通知的情况下使用本地缓存比较合适;
缺点 : 缓存与应用程序耦合,多个应用程序无法直接共享缓存,各应用或集群的各个节点都需要维护自己的缓存对内存有较大的浪费;2 分布式缓存 : Memcache Redis
指的是应用分离的缓存组件或者服务;
优点 : 自身就是一个独立的应用且与本地应用是隔离的,多个应用之间可以共享缓存;
5️⃣Guava Cache
1 Guava Cache图示
2 Guava Cache简介
Guava Cache是谷歌开源的Java工具库中的一个缓存工具,Guava Cache继承了ConcurrentHashMap的思路,使用多个Segment的细粒度锁在保证线程安全的同时支持高并发场景的需求;这里的Cache类似一个Map它是存储键值对的集合不同的是它还需要处理缓存过期动态加载等算法的逻辑,需要一些额外的信息来实现这些操作,同时根据面向对象的思想它还需要做一些方法关联性的封装;
6️⃣Memcache
1 Memcache图示
2 Memcache简介
Memcache是一个应用比较广的开源的分布式产品之一,它本身并不提供分布式的解决方案,在服务端Memcache的集群环境实际上就是一个个Memcache服务器的堆积,Memcache的分布式主要是在客户端实现的,通过客户端的路由来达到分布式的目的;它的原理比较简单应用服务器在每次存取某个key的value的时候通过某种算法把key映射到某台服务器上,因此这个key的所有操作都会在这个服务器上;Memcache的客户端采用的时候一致性Hash算法作为路由策略,相比较其他的算法此算法会在计算key的hash值的同时还会计算每一个Server对应的hash值,然后将这些hash值映射到有限的值域上;Memcache是一个搞笑的内存cache如下图所示Memcache将内存空间分成了若干个slab每一个slab下边有若干个page每一个page默认为1M,如果一个slab占用100M内存的话那么这个slab下边将会有100个page,每一个page里边包含一组chunk,chunk是真正存放数据的地方,同一个slab下边的chunk大小是固定的,有相同大小的chunk和slab被组织在一起被称为slab_class;
7️⃣Redis
1 Redis图示
2 Redis简介
Redis是一个远程的内存非关系型数据库,性能强劲,具有复制特性且拥有已解决问题而生的数据模型,它可以存储键值对一致五种不同类型的值的映射,可以将存储的数据持久化到硬盘,可以使用复制特性来扩展读性能还可以使用客户端分片来扩展写性能;Redis的内部使用的是一个redisObject的对象来标识所有的key和value数据,redisObject最主要的信息如上图展示;
3 Redis的特点
① 支持持久化 : 可以将内存中的数据保存到磁盘中;
② 支持多种数据结构的存储
③ 支持数据的备份(主从模式)
④ 性能极高
⑤ 原子性 : Redis的所有操作都是原子性的,同时还支持对几个操作合并后的原子性执行;
4 Redis适用的场景
① 取最新的N个数据
② 排行榜类似的应用
③ 精准设定过期时间的应用
④ 计数器场景的应用
⑤ 唯一性检查的场景
.....
8️⃣ 缓存的使用
1 Guava Cache的使用
// CacheLoader实现
@Slf4j
public class GuavaCacheExample1 {
public static void main(String[] args) {
LoadingCache<String, Integer> cache = CacheBuilder.newBuilder()
.maximumSize(10) // 最多存放10个数据
.expireAfterWrite(10, TimeUnit.SECONDS) // 缓存10秒
.recordStats() // 开启记录状态数据功能
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) throws Exception {
return -1;
}
});
log.info("{}", cache.getIfPresent("key1")); // null
cache.put("key1", 1);
log.info("{}", cache.getIfPresent("key1")); // 1
cache.invalidate("key1");
log.info("{}", cache.getIfPresent("key1")); // null
try {
log.info("{}", cache.get("key2")); // -1
cache.put("key2", 2);
log.info("{}", cache.get("key2")); // 2
log.info("{}", cache.size()); // 1
for (int i = 3; i < 13; i++) {
cache.put("key" + i, i);
}
log.info("{}", cache.size()); // 10
log.info("{}", cache.getIfPresent("key2")); // null
Thread.sleep(11000);
log.info("{}", cache.get("key5")); // -1
log.info("{},{}", cache.stats().hitCount(), cache.stats().missCount());
log.info("{},{}", cache.stats().hitRate(), cache.stats().missRate());
} catch (Exception e) {
log.error("cache exception", e);
}
}
}
// Callable实现
@Slf4j
public class GuavaCacheExample2 {
public static void main(String[] args) {
Cache<String, Integer> cache = CacheBuilder.newBuilder()
.maximumSize(10) // 最多存放10个数据
.expireAfterWrite(10, TimeUnit.SECONDS) // 缓存10秒
.recordStats() // 开启记录状态数据功能
.build();
log.info("{}", cache.getIfPresent("key1")); // null
cache.put("key1", 1);
log.info("{}", cache.getIfPresent("key1")); // 1
cache.invalidate("key1");
log.info("{}", cache.getIfPresent("key1")); // null
try {
log.info("{}", cache.get("key2", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // -1
cache.put("key2", 2);
log.info("{}", cache.get("key2", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // 2
log.info("{}", cache.size()); // 1
for (int i = 3; i < 13; i++) {
cache.put("key" + i, i);
}
log.info("{}", cache.size()); // 10
log.info("{}", cache.getIfPresent("key2")); // null
Thread.sleep(11000);
log.info("{}", cache.get("key5", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // -1
log.info("{},{}", cache.stats().hitCount(), cache.stats().missCount());
log.info("{},{}", cache.stats().hitRate(), cache.stats().missRate());
} catch (Exception e) {
log.error("cache exception", e);
}
}
}
2 Redis的使用
@Component
public class RedisClient {
@Resource(name = "redisPool")
private JedisPool jedisPool;
public void set(String key, String value) throws Exception {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.set(key, value);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
public String get(String key) throws Exception {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
return jedis.get(key);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
@Controller
@RequestMapping("/cache")
public class CacheController {
@Autowired
private RedisClient redisClient;
@RequestMapping("/set")
@ResponseBody
public String set(@RequestParam("k") String k, @RequestParam("v") String v)
throws Exception {
redisClient.set(k, v);
return "SUCCESS";
}
@RequestMapping("/get")
@ResponseBody
public String get(@RequestParam("k") String k) throws Exception {
return redisClient.get(k);
}
}
9️⃣高并发场景下缓存的常见问题
1 缓存一致性
当数据时效要求很高的时候,我们就需要保证缓存数据的一致性,这就比较依赖缓存的过期时间以及更新策略了,一般会在数据发生更改的时候主动更新缓存中的数据或者移除对应的缓存这个时候就可能会出现缓存一致性的问题如下图所示2 缓存的并发问题
缓存过期后将尝试从后端的数据尝试获取数据,但是在高并发的场景下有可能会出现多个线程并发的去数据库获取数据对数据库造成极大的压力甚至有可能会导致雪崩的现象;此外当某一个缓存的key在被更新时同时也有可能在被大量请求获取这也会导致一致性的问题;如果想要避免这样的问题发生我们可以尝试使用锁来解决;在缓存更新或者过期的情况下先尝试获取到锁,当获取完成后在释放锁这个时候其他的线程只需要牺牲一定的等待时间即可从缓存中继续获取数据;
3 缓存穿透
很多人对缓存穿透的理解是由于缓存故障导致大量的请求穿透到数据库从而对数据库产生较大的压力,但是这样的理解是不对的;在高并发场景下如果某一个key被高并发的访问且没有被命中处于对容错性的考虑会尝试去后端数据库中获取,从而导致大量的请求到达数据库,而当该key对应的数据为空的情况下这就导致数据库中并发的执行了很多不必要的查询操作从而导致了具体的冲击和压力,这样的问题可以通过以下几种方式来避免:
① 缓存空对象 : 对查询结果为空的对象也进行缓存,如果是集合的话可以缓存一个空的集合;如果缓存的是单个对象可以通过字段标识来区分这样就可以避免请求穿透到后端数据库,同时也需要保证缓存数据的时效性;这种方式实现起来成本较低比较适合命中不高但可能会被频繁更新的数据;
② 单独过滤处理 : 对所有可能对应数据为空的key进行统一的存放,可以在请求前做拦截,这样就可以避免请求穿透到后端数据库;这种方式实现起来性对复杂一些;比较适合命中不高更新不太频繁的数据;4 缓存的颠簸问题
缓存颠簸也成为缓存抖动,它可以看做比雪崩稍微轻微的故障但是也会在一段时间内对系统造成冲击和性能的影响;它一般是由于缓存节点故障导致,目前推荐的做法是通过hash一致性算法来解决;
5 缓存的雪崩现象
缓存雪崩其实就是由于缓存的原因导致大量的请求到达数据库从而导致数据库崩溃从而又导致整个系统崩溃发生灾难;导致整个问题的原因有很多种比如缓存并发 缓存穿透 缓存抖动都有可能导致雪崩,另一外一个原因是在某个时间点内系统预加载的缓存周期性的集中失效了也有可能会导致雪崩,针对这种情况可以设置不同的过期时间来避免;从应用架构层面我们可以通过限流 降级 熔断等手段来降低影响也可以通过多级缓存来避免这种灾难;同时从研发体系上应该做加强压力测试尽量模拟真实场景,尽早的暴露问题从而进行防范;
🔟实际场景下高并发的使用
1 先使用Guava Cache缓存最近几分钟以内所有股票的分时数据,key是数据的时间单位到分钟(就是根据小时和分钟来缓存每只股票的数据),一分钟以内有多次推送时会使用最后的进行覆盖这样的话每只股票每分钟最多只会缓存一条数据,然后启动一个定时任务每分钟都将最近几分钟的数据写入到Redis里边保证Redis中的数据一直都是最新的;
2 Redis : 使用特殊的数据结构-Hash结构它的可以也是分时数据的小时和分钟,这个是有特别的好处的,用户如果要访问一支股票的分时线的时候只需要从Redis中取出当前这只股票缓存的分时数据并做成map,这一步的map是客户端提供的方法,直接取出来就能变成map,然后计算好需要展示的时间点,比如这里从开市到闭市总共有400个点,如果计算好这些点以后按照计算好的点从map中取数据就可以了;需要注意的是之前存到Redis中的数据是不能直接使用的,还需要处理一次主要是因为某只股票每分钟因为没有变化从而会导致没有数据或者是第三方出了问题导致数据没有推送过来,缺少的点需要使用当前这个点的前一个点来进行补充,然后计算好具体的分时数据返回给前端即可;
3 算完一次分时线以后可以继续缓存我们计算好的分时数据到下一分钟的开始,这样其他用户在访问这只股票的分时线时直接取出缓存中已经计算好的数据就可以了,开市期间缓存的计算好的数据不能太长,一般不会超过一分钟,而闭市后这些数据基本就不会变化了可以一直缓存到下个开市的开始前;