1. 缓存
1.1. 缓存的使用
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落 盘工作。
哪些数据适合放入缓存?
- 即时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率 来定),后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。
data = cache.load(id);//从缓存加载数据
If(data == null){
data = db.load(id);//从数据库加载数据
cache.put(id,data);//保存到 cache 中
}
return data;
注意:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没 有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致 问题。
一般使用hashmap或者redis进行缓存
1.2 整合redis作为缓存
- 添加依赖
<!--引入redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
- yml配置redis
spring:
redis:
host: 47.93.21.100
port: 6379
- 使用 RedisTemplate 操作 redis
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void test(){
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set("hollow","word");
String hollow = valueOperations.get("hollow");
System.out.println(hollow);
}
2. 缓存失效问题
2.1 缓存穿透
- 缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数 据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
- 在流量大时,可能DB就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
- 解决: 缓存空结果、并且设置短的过期时间。
2缓存雪崩
- 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
- 解决: 原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
2.3缓存击穿
- 对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问, 是一种非常“热点”的数据。
- 这个时候,需要考虑一个问题:如果这个 key 在大量请求同时进来前正好失效,那么所 有对这个 key 的数据查询都落到 db,我们称为缓存击穿。
- 解决: 加锁
3.分布式锁的原理与使用
3.1分布式下如何加锁
本地锁,只能锁住当前进程,所以我们需要分布式锁
3.2 分布式锁演进-基本原理
我们可以同时去一个地方“占坑”,如果占到,就执行逻辑。否则就必须等待,直到释放锁。 “占坑”可以去redis,可以去数据库,可以去任何大家都能访问的地方。 等待可以自旋的方式
3.2.1 分布式锁演进-阶段一
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1. 分布式锁 去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if(lock){
//加锁成功
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
redisTemplate.delete("lock");
return dataFromDb;
}else {
//加锁失败 重试 synchronize
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
3.2.2分布式锁演进-阶段二
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1. 分布式锁 去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if(lock){
//加锁成功
//2. 设置过期时间
redisTemplate.expire("lock",30,TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
redisTemplate.delete("lock");
return dataFromDb;
}else {
//加锁失败 重试 synchronize
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
3.3.3 分布式锁演进-阶段三
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1. 分布式锁 去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",300,TimeUnit.SECONDS);
if(lock){
//加锁成功
//2. 设置过期时间
// redisTemplate.expire("lock",30,TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
redisTemplate.delete("lock");
return dataFromDb;
}else {
//加锁失败 重试 synchronize
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
3.3.4 分布式锁演进-阶段四
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
String uuid = UuidUtils.generateUuid().toString();
//1. 分布式锁 去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
if(lock){
//加锁成功
//2. 设置过期时间
// redisTemplate.expire("lock",30,TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
String lockValue = redisTemplate.opsForValue().get("lock");
if(lockValue.equals(uuid)) {
//删除自己的锁
redisTemplate.delete("lock");
}
return dataFromDb;
}else {
//加锁失败 重试 synchronize
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
3.3.5 分布式锁演进-阶段五-最终形态
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
String uuid = UuidUtils.generateUuid().toString();
//1. 分布式锁 去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
if(lock){
//加锁成功
Map<String, List<Catelog2Vo>> dataFromDb = null;
try {
dataFromDb = getDataFromDb();
}finally {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//删除锁
Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
// String lockValue = redisTemplate.opsForValue().get("lock");
// if(lockValue.equals(uuid)) {
// //删除自己的锁
// redisTemplate.delete("lock");
// }
return dataFromDb;
}else {
//加锁失败 重试 synchronize
return getCatalogJsonFromDbWithRedisLock();//自旋的方式
}
}
4.Redissn完成分布式锁
4.1.简介
Redisson 是架设在 Redis 基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工 具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。 官方文档:https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
4.2整合Redisson作为分布式锁等功能的框架
4.2.1.引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
4.2.2 进行配置
@Configuration
public class MyRedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient对象
* @return
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
//1.创建配置
Config config = new Config();
config.useSingleServer().setAddress("redis://47.93.21.100:6379");
//根据config创建出RedissonClient示例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
4.2.3进行测试
@SpringBootTest
class GulimallProductApplicationTests {
@Autowired
private RedissonClient redissonClient;
@Test
public void test1(){
System.out.println(redissonClient);
}
}
4.3 锁
4.3.1 可重入锁(Reentrant Lock)
可重入锁是某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。再次获取锁的时候会判断当前线程是否是已经加锁的线程,如果是对锁的次数+1,释放锁的时候加了几次锁,就需要释放几次锁。
基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
@ResponseBody
@GetMapping("/hello")
public String hello(){
//1.获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");
//2.加锁
lock.lock();//阻塞式等待 默认加的锁是30s
//1. 锁的自动续期 如果业务超长,运行期间自动给锁续上新的30s,不用担心业务时间长,锁自动过期会删除
//2.加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s后自动删除
try {
System.out.println("加锁成功,执行业务"+Thread.currentThread().getId());
Thread.sleep(30000);
}catch (Exception e){
e.printStackTrace();
}finally {
//3.解锁
System.out.println("释放锁"+Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
特定
- 锁的自动续期 如果业务超长,运行期间自动给锁续上新的30s,不用担心业务时间长,锁自动过期会删除
- 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s后自动删除
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
lock.lock(10, TimeUnit.SECONDS);//10秒自动解锁,自动解锁时间一定要大于业务的执行时间
- 问题:lock.lock(10, TimeUnit.SECONDS) 在锁时间到了以后不会自动续期
1.如果我们传递了锁的过期时间,就发送给redis执行脚本,进行占锁,默认超时时间就是我们指定的时间
2.如果我们指定了锁的超时时间,就使用30*1000[lockWatchdogTimeout看门狗的默认时间];只要占锁成功,就会启动一个定时任务[重新给锁设置过期时间,新的过期时间就是看门狗的默认时间]定时任务的周期的看门狗默认时间的三分之一进行自动续期,续期时间为满30s
4.3.2 读写锁
基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
@GetMapping("/write")
@ResponseBody
public String writeValue(){
String s="";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock lock = readWriteLock.writeLock();
try {
//改数据加写锁 读数据加读锁
lock.lock();
s = UUID.randomUUID().toString();
Thread.sleep(30000);
redisTemplate.opsForValue().set("writeValue",s);
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
return s;
}
@GetMapping("/read")
@ResponseBody
public String readValue(){
String s="";
RReadWriteLock writeLock = redisson.getReadWriteLock("rw-lock");
//加读锁
Lock rLock = writeLock.readLock();
try {
rLock.lock();
s = redisTemplate.opsForValue().get("writeValue").toString();
Thread.sleep(30000);
}catch (Exception e){
e.printStackTrace();
}finally {
rLock.unlock();
}
return s;
}
结论
- 保证一定可以读到最新的数据,修改期间,写锁是一个排他锁(互斥锁,独享锁).读锁是一个共享锁
- 写锁没有释放 读就必须等待
- 读 + 读:相当于无锁并发读,只会的redis中记录好,所有当前的读锁,他们都会同时加锁成功
- 写 + 读:等待写锁释放
- 写 + 写:阻塞方式
- 读 + 写:有读锁也需要等待
- 只要有写锁的存在,都必须等待
4.3.3 信号量
基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
这里以停车位为例,当停车时,获取一个信号量,获取到信号量之后进行停车,车开走之后可以在释放一个信号量
/**
* 车库停车
* @return
* @throws InterruptedException
* 信号量 可以用作分布式限流
*/
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park");
park.acquire();//获取一个信号量,获取一个信号量占一个车位
return "ok";
}
@GetMapping("/go")
@ResponseBody
public String go(){
RSemaphore park = redisson.getSemaphore("park");
park.release();//释放一个车位
return "ok";
}
4.3.4 闭锁
4.3.4.1. 闭锁的原理
闭锁相当于一扇门,在闭锁到达结束状态之前,这扇门一直是关闭着的,没有任何线程可以通过,当到达结束状态时,这扇门才会打开并容许所有线程通过。它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,初始化为一个正式,正数表示需要等待的事件数量。countDown方法递减计数器,表示一个事件已经发生,而await方法等待计数器到达0,表示等待的事件已经发生。CountDownLatch强调的是一个线程(或多个)需要等待另外的n个线程干完某件事情之后才能继续执行。
4.3.4.2 应用场景
10个运动员准备赛跑,他们等待裁判一声令下就开始同时跑,当最后一个人通过终点的时候,比赛结束。10个运动相当于10个线程,这里关键是控制10个线程同时跑起来,还有怎么判断最后一个线程到达终点。可以用2个闭锁,第一个闭锁用来控制10个线程等待裁判的命令,第二个闭锁控制比赛结束。
4.3.4.3示例
5个班放学,当5个班的同学都走完之后,锁门
@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5);
door.await();
return "放假了";
}
@GetMapping("/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id){
RCountDownLatch door = redisson.getCountDownLatch("door");
door.countDown();//计算减一
return id+"班的人走完了";
}
4.4 数据一致性问题
4.4.1 双写模式
4.4.2 失效模式
4.4.3 解决方案
• 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略);
• 总结:
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保 证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性 • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
5.Spring Cache
5.1 简介
- Spring 从 3.1 开始定义了 org.springframework.cache.Cache 和org.springframework.cache.CacheManager 接口来统一不同的缓存技术; 并支持使用 JCache(JSR-107)注解简化我们开发;
- Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合; Cache 接 口 下 Spring 提 供 了 各 种 xxxCache 的 实 现 ; 如 RedisCache , EhCacheCache , ConcurrentMapCache 等;
- 每次调用需要缓存功能的方法时,Spring 会检查检查指定参数的指定的目标方法是否已 经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓 存结果后返回给用户。下次调用直接从缓存中获取。
- 使用 Spring 缓存抽象时我们需要关注以下两点;
- 确定方法需要被缓存以及他们的缓存策略
- 从缓存中读取之前缓存存储的数据
5.2 基础概念
5.3 SpringCache整合SpringBoot简化缓存开发
5.3.1引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.3.2 写配置
- 自动配置:CacheAutoConfiguration会导入RedisCacheConfiguration自动配置好缓存管理器RedisCacheManager
- 使用redis作为缓存
spring.cache.type=redis
5.3.3测试使用缓存
@Cacheable | 触发将数据保存到缓存的操作 |
---|---|
@CacheEvict | 触发数据从缓存删除的操作 |
@CachePut | 不影响方法执行更新缓存 |
@Caching | 组合以上多个操作 |
@CacheConfig | 在类级别共享缓存的相同配置 |
5.3.3.1. 开启缓存功能
@EnableCaching
@MapperScan("com.atguigu.gulimall.product.dao")
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.atguigu.gulimall.product.feign")
@EnableCaching
public class GulimallProductApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallProductApplication.class, args);
}
}
5.3.3.2. @Cacheable测试
//每一个需要缓存的数据我们都来指定要放到那个名字的缓存【缓存的分区(按照业务类型分)】
@Cacheable({"category"}) //代表当前方法的结果需要进行缓存,如果缓存中有方法不用调用,缓存中没有会调用方法,最后将方法的结果存入缓存
@Override
public List<CategoryEntity> getLevel1Categorys() {
QueryWrapper<CategoryEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("parent_cid",0);
List<CategoryEntity> list = baseMapper.selectList(queryWrapper);
return list;
}
效果
5.3.3.2.1 结论
- 每一个需要缓存的数据我们都来指定要放到那个名字的缓存【缓存的分区(按照业务类型分)】
- .@Cacheable({“category”})
- 代表当前方法的结果需要进行缓存,如果缓存中有方法不用调用
- 缓存中没有会调用方法,最后将方法的结果存入缓存
- 默认行为
- 如果缓存中有方法不调用
- key默认自动生成:缓存的名字::SimpleKey[] (自主生成的key值)
- 缓存的value值:默认使用jdk的序列化机制,序列化的数据存到redis
- 默认ttl -1秒
5.3.3.2.2 自定义参数
- 指定生成缓存使用的key:key属性指定一个,接受一个SpEL
@Cacheable(value = {"category"},key = "'level'")
@Override
public List<CategoryEntity> getLevel1Categorys() {
QueryWrapper<CategoryEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("parent_cid",0);
List<CategoryEntity> list = baseMapper.selectList(queryWrapper);
return list;
}
- 指定缓存数据的缓存时间:配置文件里面改ttl
spring.cache.redis.time-to-live=60000
- 将数据存为JSON格式
配置类
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
// @Autowired
// CacheProperties cacheProperties;
/**
* 配置文件中的东西没有用上
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties {
* @return
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
配置参数
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
# 如果指定了前缀就用指定的前缀,如果没有就默认使用缓存的名字作为前缀
spring.cache.redis.key-prefix=CACHE
# 是否使用缓存前缀 默认为TRUE
spring.cache.redis.use-key-prefix=true
# 是否缓空值 防止缓存穿透
spring.cache.redis.cache-null-values=true
5.3.3.3. @CacheEvict测试
/**
* 级联更新所有关联的数据
* 缓存失效模式
* 1.同时进行多种操作 @caching
* 2.指定删除某个分区下的所有数据 @CacheEvict(value = {"category"},allEntries = true)
* 3.存储同一类型的数据,都可以指定成同一分区,分区名默认就是缓存的前缀
* @param category
*/
@Override
// @Caching(
// evict = {
// @CacheEvict(value = {"category"},key = "'getLevel1Categorys'"),
// @CacheEvict(value = {"category"},key = "'getCatalogJson'")
// }
// )
@CacheEvict(value = {"category"},allEntries = true)
@Transactional
public void updateDetail(CategoryEntity category) {
//修改分类
updateById(category);
//修改其他的分类信息
categoryBrandRelationService.updateCategpry(category.getCatId(),category.getName());
// TODO
}
缓存失效模式
- 同时进行多种操作
@caching
- 指定删除某个分区下的所有数据
@CacheEvict(value = {"category"},allEntries = true)
- 存储同一类型的数据,都可以指定成同一分区,分区名默认就是缓存的前缀
5.4 spring-cache的不足
5.4.1 读模式
- 缓存穿透:查一个null值的数据,解决:清空缓存数据 spring.cache.redis.cache-null-values=true
- 缓存击穿:大量并发进来同时查询一个正好过期的数据,默认不加锁,可以加锁
@Cacheable(value = {"category"},key = "#root.method.name",sync = true)
只有@Cacheable可以 - 缓存雪崩:大量的key同时过期 解决:加随机时间
5.4.2写模式
- 读写加锁
- 引入Canal,感知mysql的更新去更新数据库
- 读多写多,直接去数据库查询
5.4.3 总结
- 常规数据(读多写少,即实时性一致性要求不高的数据)完全可以使用Spring-cache,写模式(只有缓存的数据的过期时间足够就可以了)
- 特殊数据:特殊设计