缓存概念
- 根据数据使用的规则,二八规律:有20%的数据最常用,加载入缓存/有80%的数据不常用,最好不占用缓存
- 不一定是缓存的数据库结果,而是缓存业务结果(数据库的结果经过一些处理)
外存 : 计算机内存与CPU缓存之外的储存器,一般断电数据不丢失,用于数据持久化
内存 : 外存与CPU沟通的桥梁,一般断电之后数据也会被清空
缓存 : 把一些外存的数据存到内存而已 java中一般缓存通过Map来实现的,广义的缓存就是把一些慢存的数据保存到快存上,加快系统运行的速度和效率
常见的缓存举例
CPU的一级缓存、二级缓存
maven的本地仓库
京东的仓储
数据库的索引,以空间换取时间
什么样的数据适合缓存?
- 访问频率高 – 读多写少
- 更改频率低
- 一致性要求不高(比如转账,金融项目不适合大量缓存的原因)
为什么缓存速度快? 内存速度 > 磁盘速度
缓存效能? 最小内存(昂贵)-> 最大功用
重要的指标:
总读取次数 = 从缓存中读取次数 + 从慢速设备上读取的次数
命中率 = 从缓存中读取次数 / 总读取次数
Miss率 = 没有从缓存中读取的次数 / 总读取次数
缓存的命中率是表明缓存是否运行良好的指标,做缓存一定要监控这个指标
缓存在java中的实现
首先需要配置好缓存管理器
import java.lang.reflect.Method;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
@Configuration
@EnableCaching
public class CaCheConfig extends CachingConfigurerSupport {
//不支持过期时间
// @Bean
// public CacheManager cacheManager() {
// //jdk里,内存管理器
// SimpleCacheManager cacheManager = new SimpleCacheManager();
// cacheManager.setCaches(Collections.singletonList(new ConcurrentMapCache("province")));
// return cacheManager;
// }
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager.builder(connectionFactory).cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
// 缓存时间绝对 过期时间 20s
.entryTtl(Duration.ofSeconds(20))).transactionAware().build();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setValueSerializer(serializer);
//使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
查询数据时的缓存实现逻辑
- 从缓存中读取数据
- 如果命中,直接返回
- 如果未命中,则查询数据库,并将数据加入到缓存中
@Resource
private CacheManager cacheManager;
private static final String CACHE_NAME = "province";
@Override
public Provinces detail(String provinceid) {
// 1. 从缓存中取数据
ValueWrapper valueWrapper = cacheManager.getCache(CACHE_NAME).get(provinceid);
if (valueWrapper != null) {
// 当然实际过程中 还需要统计缓存命中率参数 如果缓存命中率过低 没有使用缓存的必要
logger.info("缓存中得到数据");
return (Provinces) valueWrapper.get();
}
// 2. 从数据库查询数据
Provinces provinces = super.detail(provinceid);
// 3. 从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
if (null != provinces) {
logger.info("缓存中得到数据");
cacheManager.getCache(CACHE_NAME).put(provinceid, provinces);
}
return provinces;
}
更新数据时的缓存实现逻辑
更新数据时,要进行双删,更改之前和更改之后都进行删除。
为啥要在更改之前进行删除?如果不进行更改前删除,但是后续更改数据库成功,但是更改之后的删除缓存失败,就会导致缓存的不一致了。
为啥要在更改之后进行删除?因为在你进行更改的过程中,可能存在其他线程进行数据库的查询,而且查询通常比
更新要快,因此在更改完成之前又存在了缓存数据。(第二次删除变为存储缓存?)
在更新数据库之前更新缓存,但此时未必就能得到业务结果(输入为key,返回为业务结果,从key到业务结果还有一段路程),通过delete,
实现最简单,不引入业务复杂度
双删不是必须的,提高缓存一致性
@Override
public Provinces update(Provinces entity) {
cacheManager.getCache(CACHE_NAME).evict(entity.getProvinceid());
provincesDao.update(entity);
cacheManager.getCache(CACHE_NAME).evict(entity.getProvinceid());
return entity;
}
代码优化空间分析–逻辑上的变与不变
- 缓存如何使用,它的使用流程框架可以确定,不会再有变化—可抽象成一段宏观的模板性代码
- 具体缓存器内部怎么实现,用什么来实现(redis?Memcache?java数组),现在无法确定,但是可以甩锅(只提需求接口)----cache接口
- 上述两点结合,就是一个天然的设计模式—模板方法模式
- 模板方法模式:即先设计出主业务流程,如下面代码:
------主设计师设计出detail方法的流程
----1. 从mysql中查询一条数据
----2. 将数据返回
----3. mysql数据具体怎么查 我不管
SpringCache的使用
SpringCache的使用(不仅仅是redis,还有es、mongodb 比如abcde搜索,五个元素的排列组合,数据大膨胀,使用es)
- SpringCache统一定义了缓存器Cache接口 org.springframework.cache.Cache
- SpringCache还规定这些缓存器必须要有一个管理器来管理它们 org.springframework.cache.CacheManager
(1) manager负责创建/查找缓存器,查找过程,以key-value形式记录映射关系
(2)rediscache的manager实现了cache的自动创建,即当你指定的cache不存在时,自动创建一个供你使用
(3)通过标签进行标注
key的自定义
第一种 :使用SPEL表达式指定
第二种: 扩展key的生成机制 org.springframework.cache.interceptor.KeyGenerator
/**
* key的生成 springcache的内容 跟具体实现缓存器有关
*/
@Bean
public KeyGenerator keyGenerator() {
return new KeyGenerator() {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(method.getName());
for (Object object : params) {
sb.append(object.toString());
}
return sb.toString();
}
};
}
@Service
@CacheConfig(cacheNames = "province")
public class ProvincesServiceImpl implements ProvincesService {
@Autowired
private ProvincesDao provincesDao;
@Autowired
private CitiesDao citiesDao;
@Override
public List<Provinces> list() {
return provincesDao.list();
}
@Cacheable(key = "#id")
@Override
public Provinces detail(String id) {
Provinces provinces = = provincesDao.detail(id);
if (null != provinces) {
provinces.setCities(citiesDao.list(id));
}
return provinces;
}
@CachePut(key = "#entity.provinceid")
@Override
public Provinces update(Provinces entity) {
provincesDao.update(entity);
return entity;
}
// @Caching(put = @CachePut(key = "#entity.provinceid"))
@CachePut(key = "#entity.provinceid")
@Override
public Provinces add(Provinces entity) {
provincesDao.insert(entity);
System.out.println("新增数据 == 》" + entity);
return entity;
}
@CacheEvict(key = "#id")
@Override
public void delete(String id) {
provincesDao.delete(id);
}
}
使用SpringCache带来了方便,但是少了灵活性
缓存一致性问题
问题:同时有一个请求 A 进行更新操作,一个请求 B 进行查询操作。可能出现:
(1)请求 A 进行写操作(key = 1 value = 2),先删除缓存 key = 1 value = 1
(2)请求 B 查询发现缓存不存在
(3)请求 B 去数据库查询得到旧值 key = 1 value = 1
(4)请求 B 将旧值写入缓存 key = 1 value = 1
(5)请求 A 将新值写入数据库 key = 1 value = 2
导致缓存中数据永远都是脏数据
比较推荐操作顺序:
先删除缓存,再更新数据库,再删缓存(双删,第二次删可异步延时)
缓存使用带来的一致性问题 — 数据同步 有四类方式
缓存过期与一致性问题 — 缓存一致性问题,无论你怎么做,都有漏洞
缓存失效策略
取决于业务要求的一致性和不一致的容忍度
- 固定间隔 ----- 缓存数据2分钟后删除(数据变了,多多海涵)
- 定时任务 ----- 每天凌晨统一全量刷新
- 实时更新 – 同步去调用Cache增删改
- 准实时更新 — 甩锅第三方 观察者模式/发布订阅/MQ
方案名称 | 技术特点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
数据实时同步更新 | 强一致性,更新数据库同时更新缓存,使用缓存工具类和或编码实现 | 数据一致性强 | 代码耦合 运行期耦合 影响正常业务 | 数据一致实时性要求比较高的场景,如:银行业务、证券交易; |
数据准实时更新 | 准一致性,更新数据库后,异步更新缓存,使用观察者模式/发布订阅/MQ实现; | 数据同步有较短延迟 与业务解耦 不影响正常业务 | 实现复杂,架构较重 | 不适合写操作频繁并且数据一致实时性要求严格的场景; |
缓存失效机制 | 弱一致性,基于缓存本身的失效机制 | 实现简单 | 有一定延迟 不保证强一致性 存在缓存雪崩问题; | 适合读多写少的场景,能接受一定数据延时; |
任务调度更新 | 最终一致性,采用任务调度框架,按照一定频率更新; | 不影响正常业务; | 不保证一致性 依赖定时任务 容易堆积垃圾数据; | 适合复杂统计类数据缓存更新,对数据一致实时性要求低的场景;如:统计类数据,BI分析等; |
缓存回收策略
FIFO :First In First Out 先进先出算法,即先放入缓存的先被移除
LRU :Least Recently Used 最久未使用算法,使用时间距离现在最久的那个被移除
LFU:Least Frequently Used 最近最少使用算法 一段时间段内使用次数(频率)最少的那个被移除
TTL:Time To Live 存活期 即从缓存中创建时间点开始直到它到期的一个时间段(不管在这个时间段内有没有访问过期)
TTI : Time To Idle 空闲期,即一个数据多久没有被访问将从缓存中移除的时间
也就是如下的概念:
绝对过期:比如设置10分钟有效,则从数据加入缓存开始算,10分钟后清除掉
滑动过期:比如web中的session机制,如果在最近30分钟之内未被访问,就进行回收
缓存问题
缓存击穿 :某一个key刹那间实现,导致大量的查询打到数据库上
缓存雪崩 :部分缓存key在一段时间集中失效,导致所有的查询都打到数据库上,大量的击穿就是雪崩
缓存穿透:恶意请求不存在的数据,故意避开缓存,大量请求数据库
对于上述缓存同步方案的第三种 缓存失效策略 存在雪崩风险 即某个key失效时,外围刚好有大量并发请求到达 若放任大并发传递到mysql 会大概率造成mysql宕机
- 缓存击穿(雪崩)的解决方法:
限流加锁 - 缓存穿透的解决方法
–》 根据业务特点, 搞出一个规则来效验缓存请求的有效性(有规律的订单号、手机号码)
–》使用布隆过滤器 布隆过滤器的使用方法 类似于java的set集合,只不过它能以更小的内存,存储更大的数据
/**
* 解决缓存雪崩 -- > 加锁限流
* 解决缓存穿透 -- > 布隆过滤器
*/
@Service
public class ProvincesServiceImp2 extends DefaultProvincesService implements ProvincesService {
// 等效成一个Set集合
private BloomFilter<String> bf = null;
@PostConstruct
public void init() {
// 在bean初始化完成后,实例化BloomFilter,并加载数据
List<Provinces> provinces = this.list();
bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), provinces.size());
for (Provinces province : provinces) {
bf.put(province.getProvinceid());
}
}
private static final Logger logger = LoggerFactory.getLogger(ProvincesService.class);
@Resource
private CacheManager cacheManager;
private static final String CACHE_NAME = "province";
@Override
public Provinces detail(String provinceid) {
// 1.先判断布隆过滤器中是否存在该值,值存在才允许访问缓存和数据库
if (!bf.mightContain(provinceid)) {
System.out.println("非法访问--------" + System.currentTimeMillis());
return null;
}
// 2. 从缓存中取数据
ValueWrapper valueWrapper = cacheManager.getCache(CACHE_NAME).get(provinceid);
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) valueWrapper.get();
}
// 3. 加锁排队 阻塞式锁 32个省 最多只有32把锁 1000个线程
doLock(provinceid);
try {
valueWrapper = cacheManager.getCache(CACHE_NAME).get(provinceid);
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) valueWrapper.get();
}
Provinces provinces = super.detail(provinceid);
// 4.从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
if (null != provinces) {
cacheManager.getCache(CACHE_NAME).put(provinceid, provinces);
}
return provinces;
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
} finally {
// 5. 解锁
releaseLock(provinceid);
}
}
private ConcurrentHashMap<String, Lock> locks = new ConcurrentHashMap<String, Lock>();
private void releaseLock(String key) {
ReentrantLock oldLock = (ReentrantLock) locks.get(key);
if (oldLock != null && oldLock.isHeldByCurrentThread()) {
oldLock.unlock();
}
}
private void doLock(String key) {
ReentrantLock newLock = new ReentrantLock();
Lock oldLock = locks.putIfAbsent(key, newLock);
if (oldLock == null) {
newLock.lock();
} else {
oldLock.lock();
}
}
}