一、缓存技术原理
缓存技术的核心思想是:将一些经常需要从数据库中查询的数据保存在内存中,当再次想要获得这些数据时,就可以直接从内存中返回。
1 缓存的基本功能
1.1 读模式和写模式
缓存必须要实现对读模式和写模式的支持。 ● 读模式:当服务器收到一个查询请求时,会先去查缓存,如果缓存命中,就直接从缓存中返回查询结果;如果缓存未命中,再去查数据库,然后将查数据库的结果写入缓存,方便以后的查询。 ● 写模式:当服务器收到一个修改请求时,在修改数据库中的数据后,要对缓存中的数据做同步修改。
缓存穿透 当客户端首次请求一个数据库中不存在的数据时,由于缓存不命中,会去查数据库,而数据库中也查不到结果。如果不将查数据库得到的null结果写入缓存,之后每次对这个不存在的数据的请求都要到数据库中做查询,缓存就失去了意义。 解决方法:将查询数据库得到的null结果写入缓存。
1.2 缓存过期
Redis官方文档:https://redis.io/commands/expire/
通常,我们在内存中创建一条缓存记录时,会指明这条记录在内存中的超时时间。否则除非用户手动删除,这条记录将永远存在于内存中,很可能造成内存泄漏。 实现缓存过期的方式一般有两种:被动方式和主动方式。两种方式需要结合使用。 ● 被动方式:当客户端尝试访问一条缓存记录时,如果缓存记录已过期,就删掉这条记录并返回 null。 ● 主动方式:每隔一段时间,从关联了超时时间的缓存记录中随机选取一些进行测试,删除已过期的记录。
缓存雪崩 在高并发场景下,某一时刻,大量缓存记录同时失效,对这些缓存数据的请求全部被转发到数据库,可能会导致数据库瞬时压力过大而崩溃。 解决方法:设置缓存记录的超时时间时,在原来基础上增加一个随机值。
缓存击穿 对于一些"热点"数据,在某个时刻,可能会被许多客户端同时访问,如果此时缓存记录正好失效,大量的查询请求会被全部转发到数据库,可能导致数据库崩溃。 解决方法:加锁。同一时间只允许一个线程进行查询,其他线程等待。
1.3 缓存一致性问题
参考文档:http://kaito-kidd.com/2021/09/08/how-to-keep-cache-and-consistency-of-db/
缓存一致性问题指的是:数据库中的数据更新后,缓存中的数据要和数据库中保持一致。
为什么这会是一个问题? 数据库中的数据更新、缓存数据同步更新,两个步骤不能通过一个原子操作完成。 既然是两步操作,在高并发情况下就可能出现各种问题。
针对缓存一致性问题,业界给出的解决方案是:延迟双删。 第一次删除缓存数据==>更新数据库==>等待1~5s==>第二次删除缓存数据。 ● 为什么要删除缓存数据而不是直接更新? ? ?多个线程先后更新数据库后,它们不一定会按顺序更新缓存数据。 ● 更新数据库前为什么要删除缓存数据? ? ?防止数据库更新成功后,某些原因导致缓存数据删除失败。 ● 更新数据库后为什么要删除缓存数据? ? ?更新数据库前,可能有其他线程查询了数据库,并得到了旧数据,在更新数据库后,又将旧数据写入了缓存。 ● 为什么要等待1~5s? ? ?等待查到旧数据的线程向缓存写数据完成、等待数据库主从复制完成。
除了延迟双删策略,近几年还有一种比较流行的解决方案:订阅数据库变更日志。 拿MySQL举例,当一条数据被修改时,MySQL就会生成一条变更日志(binlog),我们可以订阅这个日志,拿到具体的操作数据,然后再根据这条数据去删除对应的缓存。 目前比较成熟的订阅数据库变更日志的开源中间件:阿里的Canal。
请注意,无论是延迟双删策略还是订阅数据库变更日志策略,都只能保证缓存数据的最终一致性,无法保证强一致性。
从设计的角度来讲,我们放入缓存的数据不应该是对实时性、一致性要求很高的数据。所以我们在工程中只要做到以下两点就可以满足大部分业务场景了: ● 给缓存记录加上超时时间。 ● 更新数据库后删除缓存。 而对于实时性、一致性要求高的数据,就应该查询数据库,或者加锁。
2 分布式缓存
在分布式架构中,实现缓存需要考虑的事情更多。
2.1 本地缓存的不足
写模式下,本地缓存暴露出问题: 当一台服务器对数据库中的数据进行修改后,无法对其他服务器上的本地缓存做同步更新。这样,在其他服务器处理读请求时,如果本地缓存中的数据还没有失效,就会返回修改前的旧数据。
分布式缓存的思想就是:在所有的业务服务器之外创建一块公共的缓存区域,为所有的业务服务器提供缓存服务。
2.2 分布式锁
参考文档:https://blog.csdn.net/weixin_35586546/article/details/111413184
分布式锁对分布式缓存来说是必需的吗? 不是。加锁是消耗性能的,只有对缓存一致性有要求,才需要加分布式锁。
加分布式锁可能会出现哪些问题?怎么解决? ● 执行业务逻辑异常,锁无法被释放。 ? ?加锁操作放在try块中,解锁操作放在finally块中。 ? ?即使解锁操作放在finally块中,也有可能因为服务器宕机而无法解锁,所以要给锁设置一个超时时间。这个超时时间一般设为业务平均执行时间的3~5倍。 ? ?加锁和给锁设置超时时间两个行为必须放在一个原子操作当中,防止加锁成功后给锁设置超时时间失败。 ● 执行业务逻辑期间,其他线程释放了锁。 ? ?在加锁的时候,给锁附上一段验证信息,一般采用服务器ID+线程ID或者UUID。解锁时,必须校验当前线程,验证成功才能释放锁。 ? ?校验和释放锁两个行为必须放在一个原子操作当中,防止校验通过后,锁正好到期,别的线程拿到锁,又被当前线程释放。 ● 锁的超时时间难以估计,到了设置的超时时间业务还没有执行完。 ? ?起一个守护线程,每隔一段时间就去检查一次业务逻辑有没有执行结束,如果没有,就重置锁的超时时间。这种行为被称为锁续命。 ? ?如何判断业务逻辑有没有执行结束?看占有分布式锁的线程有没有加锁,如果有,说明业务逻辑还没有执行结束。 ? ?注意,分布式锁本质上是一套控制流,而上文中的"加锁"是实实在在地把锁信息存在对象的markword当中。 ● 分布式锁应该是可重入的。 ? ?在加锁的时候需要维护当前锁的重入次数,初始值为1。解锁时,先把锁重入次数减1,当且仅当减1后的结果为0时,释放锁。 ? ?在加锁的时候还要维护锁的最大超时时间,当发生锁重入时,将本次重入设置的超时时间与最大超时时间做比较,产生新的最大超时时间,并将其设为锁的真实超时时间。这是为了防止锁重入导致超时时间缩水而提前过期。
二、缓存实现方案:Spring Cache+Redis
使用Spring Cache+Redis实现缓存服务。
1 名词解释
Spring Cache:Spring缓存。 Redis:Remote Dictionary Server,远程字典服务。 RedisTemplate:Spring Boot对Redis API的封装,性能要低于直接使用Redis客户端。 Redisson:本项目选择使用的Redis客户端。 Jedis:另一个Redis客户端。 Lettuce:另一个Redis客户端。 Redis Desktop Manager:一个Redis可视化工具。
2 实现缓存的基本功能
2.1 环境搭建
1. 使用Maven引入Spring Cache。 ? ? groupId: org.springframework.boot ? ? artifactId: spring-boot-starter-cache
2. 使用Maven引入Redis。 ? ? groupId: org.springframework.boot ? ? artifactId: spring-boot-starter-data-redis
3. 在配置文件中配置缓存实现方式为Redis。 ? ? spring.cache.type:redis ? ? spring.redis.host:192.168.56.10 ? ? spring.redis.port:6379
4. 我们一般要为缓存记录指定超时时间,在配置文件中配置: ? ? spring.cache.redis.time-to-live:3600000 ? ? 需要和下一步搭配起来使用。
5. 创建配置类:指定缓存数据的key的类型为String,value的类型为JSON;将查询得到的null结果写入缓存;将配置文件中的time-to-live值作为缓存记录的超时时间 ? ? @Configuration // 该文件是配置文件 ? ? @EnableCaching // 开启缓存功能 ? ? @EnableConfigurationProperties(CacheProperties.class) // 使CacheProperties类作为配置生效 ? ? public class MyCacheConfig { ? ? ? ? @Bean ? ? ? ? RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) { ? ? ? ? ? ? RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig(); ? ? ? ? ? ? // 指定key的类型为String,value的类型为JSON ? ? ? ? ? ? config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())); ? ? ? ? ? ? config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
? ? ? ? ? ? CacheProperties.Redis redisProperties = cacheProperties.getRedis(); ? ? ? ? ? ? // 将查询得到的null结果写入缓存 ? ? ? ? ? ? if (!redisProperties.isCacheNullValues()) { ? ? ? ? ? ? ? ? config = config.disableCachingNullValues(); ? ? ? ? ? ? } ? ? ? ? ? ? // 将配置文件中的time-to-live值作为缓存记录的超时时间 ? ? ? ? ? ? if (redisProperties.getTimeToLive() != null) { ? ? ? ? ? ? ? ? config = config.entryTtl(redisProperties.getTimeToLive()); ? ? ? ? ? ? }
? ? ? ? ? ? return config; ? ? ? ? } ? ? }
6. 使用注解完成开发。
2.2 @Cacheable
标注在方法上,调用方法时,如果缓存命中,直接从缓存中返回数据,如果缓存未命中,执行方法后将返回值写入缓存。
使用时必须指定缓存的名称,如@Cacheable(value="product")或者@Cacheable(cacheNames="product")或者@Cacheable("product"),三者等价。
除缓存名外,还可以指定一个key,缓存记录的完整的key是:缓存名::指定的key。 注意,必须使用SpEL表达式来指定key。例如使用方法名:key="#root.method.name"。如果要使用字符串,字符串前后要加单引号。 如果不指定key,key将使用默认值"SimpleKey []"。
2.3 @CacheEvict
标注在方法上,方法执行结束后从缓存中删除指定的记录。 使用时必须指定缓存的名称,可以指定key,指定方式和@Cacheable一致。
2.4 @CachePut
标注在方法上,方法执行结束后更新指定的缓存记录。 因为我们一般采用删除缓存的策略,所以知道是用来更新缓存的就行了。
2.5 Caching
合并多个缓存操作。
3 Redisson
官方文档:https://github.com/redisson/redisson/wiki/
3.1 环境搭建
1. 使用Maven引入redisson。 ? ? groupId: org.redisson ? ? artifactId: redisson
2. 创建配置类,注入bean。 ? ? @Configuration ? ? public class MyRedissonConfig{ ? ? ? ? @Bean(destoryMethod="shutdown") ? ? ? ? public RedissonClient redisson() throws IOException{ ? ? ? ? ? ? Config config = new Config(); ? ? ? ? ? ? // 单节点模式 ? ? ? ? ? ? config.useSingleServer().setAddress("redis://192.168.56.10:6379");
? ? ? ? ? ? return Redisson.create(config); ? ? ? ? } ? ? }
3. 测试。 ? ? @Autowired ? ? RedissonClient redissonClient;
? ? public void test(){ ? ? ? ? RLock lock = redissonClient.getLock("myLock"); ? ? ? ? try{ ? ? ? ? ? ? // 加锁 ? ? ? ? ? ? lock.lock(); ? ? ? ? ? ? Thread.sleep(30000); ? ? ? ? } catch(Exception e){ ? ? ? ? } finally{ ? ? ? ? ? ? // 解锁 ? ? ? ? ? ? lock.unlock(); ? ? ? ? } ? ? }
3.2 Redisson分布式锁
Redisson提供了各种各样的分布式锁。
● 可重入锁 ? ?RLock lock = redisson.getLock("myLock"); ● 公平锁 ? ?RLock fairLock = redisson.getFairLock("myLock"); ● 读写锁 ? ?RReadWriteLock rwlock = redisson.getReadWriteLock("myRWLock"); ? ?rwlock.readLock().lock(); ? ?// rwlock.writeLock().lock(); ● 信号量 ? ?RSemaphore semaphore = redisson.getSemaphore("semaphore"); ? ?semaphore.acquire(); ? ?// semaphore.release(); ● 闭锁 ? ?RCountDownLatch latch = redisson.getCountDownLatch("myCountDownLatch"); ? ?latch.trySetCount(3); ? ?latch.await(); ? ?// 在其他线程里 ? ?RCountDownLatch latch = redisson.getCountDownLatch("myCountDownLatch"); ? ?latch.countDown();
3.3 Redisson看门狗
Redisson看门狗机制实现了锁续命。
1. 加锁时,不给锁设置超时时间,看门狗开启。
2. 看门狗会给锁设置一个超时时间,默认为30s。这个值可以在创建RedissonClient对象时,通过setLockWatchdogTimeout方法进行设置。
3. 看门狗每隔10s(lockWatchdogTimeout的三分之一)就去检查一次占有分布式锁的线程有没有加锁,如果有,就重置锁的超时时间。
3.4 Redisson执行脚本
我们希望能够组合多个Redis命令,让这些命令能够在一个原子操作内执行。Redis对此的解决方案是:支持执行Lua脚本。
Redisson执行Lua脚本示例: ? ? String luaScript = "xxx"; ? ? result = redissonClient.getScript().evalSha(RScript.Mode.READ_ONLY,? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? luaScript,? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? RScript.ReturnType.VALUE,? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Collections.emptyList());
三、其他内容
1?缓存数据类型
一般使用JSON字符串。
1. 使用Maven引入fastjson。 ? ? groupId: com.alibaba ? ? artifactId: fastjson
2. 在向缓存中添加缓存记录前,先将对象转换成JSON字符串。 ? ? MyModel mm = new MyModel(); ? ? mm.set... ? ? String data = JSON.toJSONString(mm);
3. 从缓存中查到数据后,把JSON字符串转换成目标对象。 ? ? MyModel result = JSON.parseObject(data, MyModel.class);
|