Redis实战二:基于Redis实现缓存(黑马点评)
在做商户缓存时,一个商户实体类可以作为String进行Redis存储,我们只需要做Json到String直接的转化。
@Override
public Result queryById(Long id) {
// 1. 从redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
Shop shop = getById(id);
if(shop == null){
return Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
// 2,判断是否存在
return Result.ok(shop);
}
当要做商品类型查询时,涉及到多个商品类型,这种可以做List保存,也可以做String进行存储,这里主要演示List,因为List方便随时扩充和删除。
@Override
public Result queryTypeList() {
ListOperations<String, String> listOps = stringRedisTemplate.opsForList();
Long size = listOps.size(RedisConstants.CACHE_SHOP_KEY+"typeList");
List<ShopType> shopTypes = new ArrayList<>();
if(size > 0){
for (long i = 0; i < size; i++) {
String shopTypeJson = listOps.index(RedisConstants.CACHE_SHOP_KEY+"typeList", i);
if (shopTypeJson != null && !shopTypeJson.isEmpty()) {
try {
// 使用 Jackson 将 JSON 字符串转换为 ShopType 对象
ShopType shopType = JSONUtil.toBean(shopTypeJson, ShopType.class);
shopTypes.add(shopType);
} catch (Exception e) {
e.printStackTrace();
}
}
}
return Result.ok(shopTypes);
}
List<ShopType> types = query().orderByAsc("sort").list();
if(types.isEmpty()){
return Result.fail("失败查询类型");
}
for(ShopType shopType: types){
stringRedisTemplate.opsForList().rightPush(RedisConstants.CACHE_SHOP_KEY+"typeList",JSONUtil.toJsonStr(shopType) );
}
return Result.ok(types);
}
缓存更新
对于缓存如何保证内存不被占满?超时剔除
设置超时重传TTL
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );
对于缓存来说 ,如何保证缓存和数据?
在数据更新的时候作为整体事务,先更新数据库,再删除Redis的缓存
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺ID不能为空");
}
updateById(shop);
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
return Result.ok();
}
缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
解决方法
- 缓存null对象: 将大量不存在的数据也放入redis中,保证下一次访问的时候一定是redis,而不是直接访问Mysql
- 布隆过滤:采用布隆过滤器判断数据是否存在,若存在则放行到Redis中。
缓存null对象,并设置超时剔除
if(shopJson != null){
return Result.fail("店铺不存在");
}
Shop shop = getById(id);
if(shop == null){
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
return Result.fail("店铺不存在");
}
布隆过滤
- 在应用启动时,将所有可能有效的ID添加到布隆过滤器中
- 在处理请求时,
- 当接收到查询请求时,首先检查布隆过滤器。
- 如果布隆过滤器判断该请求的 ID 存在,继续查询缓存
- 如果缓存中没有数据,再去查询数据库。
- 如果布隆过滤器判断该 ID 不存在,直接返回一个空或错误响应,不再查询缓存和数据库。
手动实现布隆过滤器
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
public class BloomFiler {
private static final int DEFAULT_SIZE = 1 << 24;
private static final int[] SEEDS = new int[]{31, 41, 59, 93, 97, 3, 5, 7, 11}; // 哈希函数种子
private BitSet bitSet;
private StringRedisTemplate stringRedisTemplate;
public BloomFiler(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate;
this.bitSet = new BitSet(DEFAULT_SIZE);
}
public void add(String value) {
for(int seed : SEEDS){
int hash = hash(value, seed);
bitSet.set(hash);
}
}
public boolean contains(String value) {
for (int seed : SEEDS) {
int hash = hash(value, seed);
if (!bitSet.get(hash)) {
return false;
}
}
return true;
}
public int hash(String value, int seed) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
int result = 0;
for (byte b : bytes) {
result = seed * result + b;
}
return Math.abs(result) % DEFAULT_SIZE;
}
}
在ShopServiceImpl中进行初始化,系统第一次加载的时候,将从数据库里面加载所有商品ID
private BloomFiler bloomFilter;
@PostConstruct
public void init(){
this.bloomFilter = new BloomFiler(stringRedisTemplate);
List<Shop> list = list();
for(Shop shop: list){
String id = String.valueOf(shop.getId());
bloomFilter.add(id);
}
}
在通过ID查询时候首先检测ID是否存在
@Override
public Result queryById(Long id) {
// 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
String value = String.valueOf(id);
if(!bloomFilter.contains(value)){
return Result.fail("店铺不存在");
}
// 1. 从redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
if(shopJson != null){
return Result.fail("店铺不存在");
}
Shop shop = getById(id);
if(shop == null){
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
return Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );
// 2,判断是否存在
return Result.ok(shop);
}
关于布隆过滤器实现,最好还是采用Redis来存储BitSet信息,这样能保证数据一致性。这里只是单机实现。
直接防止缓存穿透的发生,1. 在API涉及时增加IP的复杂度,并进行数据的基础格式校验 2. 热点参数的限流
后续进行介绍
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
具体解决在SpringCloud微服务里面
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
互斥锁(牺牲可用性)
逻辑过期(牺牲一致性)
互斥锁实现
Redis实现互斥锁主要是利用了Setnx这个操作,如果数据存在则不能设置,如果数据不存在才能设置。将获取的ID作为Value. 对于通过业务设置同样的变量, 每次处理就进行Setnx, 对变量进行赋值,成功则进行数据库重建等操作,失败则重试。
// 缓存击穿,互斥锁
public Shop queryWithMutex(Long id){
// 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
String value = String.valueOf(id);
if(!bloomFilter.contains(value)){
return null;
}
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if(shopJson != null){
return null;
}
//开始缓存重建
// 获取互斥锁
String localKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(localKey);
// 判断是否获取成功
if(!isLock){
// 失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
shop = getById(id);
if(shop == null){
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES );
return null;
}
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES );
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
unlock(localKey);
}
return shop;
}
逻辑过期实现
原理就是,在数据过期时任然返回过期数据,另开线程来进行缓存重建。引入数据不一致性,解决击穿。需要进行数据预热,不然不会访问到任何数据。具体就是对于热点数据,不使用Redis的过期时间,手动让数据携带时间字段判断过期。
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//缓存击穿 逻辑过期
public Shop queryWithLogicalExpire(Long id){
// 为了ID安全,通过设置为String类型,但黑马为数字,这里做一次类型转换
// 布隆过滤
String value = String.valueOf(id);
if(!bloomFilter.contains(value)){
return null;
}
// 1. 从redis中查询店铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJson)){
return null;
}
// 将店铺信息 反序列化
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 未过期
return shop;
}
// 已经过期进行重建
String localKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(localKey);
if(isLock){
// 新开线程进行访问
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//需要进行二次过期判断, 可能别的线程完成了重建
//重建缓存
this.savaShop2Redis(id, RedisConstants.CACHE_SHOP_TTL);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(localKey);
}
});
}
// 返回过期的商品信息
return shop;
}
封装工具类
主要是泛型修改,将代码变成通用代码。
总结
缓存作为Redis中间件的主要功能,也存在相应问题,没有绝对好的操作,只能在一致性和速度直接权衡选择合适的措施。