1.Redis的内存淘汰机制(保持数据库和redis一致性)
1.1 内存淘汰
Redis 提供了多种内存淘汰策略,当内存使用达到配置的最大限制时,Redis 会根据策略决定如何删除某些键,以释放内存空间。
常见的内存淘汰策略有:
- volatile-lru:在设置了过期时间的键中,优先删除最近最少使用的键。
- allkeys-lru:在所有键中,优先删除最近最少使用的键。
- volatile-lfu:在设置了过期时间的键中,优先删除最不常使用的键。
- allkeys-lfu:在所有键中,优先删除最不常使用的键。
- volatile-random:在设置了过期时间的键中,随机删除键。
- allkeys-random:在所有键中,随机删除键。
- volatile-ttl:在设置了过期时间的键中,优先删除剩余生存时间(TTL)最短的键。
- noeviction:不删除任何键,而是返回错误。
1.2 超时剔除
Redis允许为每个键设置一个过期时间(TTL),当键超过其生存时间时,Redis会自动删除它。可以使用 EXPIRE
命令设置TTL。
自动剔除过期键的过程是由Redis的定期扫描和惰性删除两种机制共同完成的:
- 定期扫描:Redis每隔一段时间会随机检查一部分键,并删除已过期的键。
- 惰性删除:当访问一个键时,如果该键已过期,则Redis会立即删除该键。
上面两个方式都是通过对redis进行设置即可
1.3 主动更新
主动更新机制指的是在缓存中主动更新某些键的值,以确保缓存中的数据是最新的。
主动更新策略有3个问题需要考虑。
这里首先介绍单机模式下的缓存与数据库的一致性。(单机模式下选择 1.将缓存与数据库操作放在一个事务 2.先更新数据库再删除缓存)
为什么先更新数据库再删除缓存?
原因一:并发问题
假设有两个并发请求A和B,如果顺序是先删除缓存,再更新数据库,可能会出现以下情况:
- 请求A删除缓存。
- 请求B读取缓存,发现缓存已删除,读取数据库中的旧数据,并将其写入缓存。
- 请求A更新数据库,此时缓存中的数据是旧的,导致缓存与数据库不一致。
原因二:缓存雪崩
如果顺序是先删除缓存,再更新数据库,短时间内大量请求会直接访问数据库,增加数据库负载,甚至可能导致数据库崩溃。这种现象被称为缓存雪崩。
如果顺序是先删除缓存,再更新数据库,短时间内大量请求会直接访问数据库,增加数据库负载,甚至可能导致数据库崩溃。这种现象被称为缓存雪崩。
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null){
return Result.fail("店铺id不能为空");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return null;
}
1.4结合实际场景选择上述的缓存更新策略
2.缓存穿透
2.1解决方案一 缓存空字符串
解决策略:使用存空值如缓存实现 先查缓存,如果缓存存在redis且不为null则返回。 若不为null,则表明是空字符串,则返回‘店铺信息不存在’。 为空值,则将空字符串缓存存到redis,显示‘店铺不存在’,返回前端。
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY +id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空字符串
if(shopJson != null){
//返回错误信息
return Result.fail("店铺信息不存在");
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.数据库中不存在,返回错误
if (shop == null) {
//将空字符串写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回店铺信息
return Result.ok(shop);
}
2.2解决方案二 手写布隆过滤
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
import java.util.Random;
public class BloomFilter {
private BitSet bitSet;
private int bitSetSize;
private int numHashFunctions;
private int[] hashSeeds;
public BloomFilter(int bitSetSize, int numHashFunctions) {
this.bitSetSize = bitSetSize;
this.numHashFunctions = numHashFunctions;
this.bitSet = new BitSet(bitSetSize);
this.hashSeeds = new int[numHashFunctions];
// 初始化哈希函数的种子
Random random = new Random();
for (int i = 0; i < numHashFunctions; i++) {
hashSeeds[i] = random.nextInt();
}
}
public void add(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
for (int seed : hashSeeds) {
int hash = hash(bytes, seed);
bitSet.set(Math.abs(hash % bitSetSize), true);
}
}
public boolean mightContain(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
for (int seed : hashSeeds) {
int hash = hash(bytes, seed);
if (!bitSet.get(Math.abs(hash % bitSetSize))) {
return false;
}
}
return true;
}
private int hash(byte[] bytes, int seed) {
int hash = 0;
for (byte b : bytes) {
hash = seed * hash + b;
}
return hash;
}
public static void main(String[] args) {
BloomFilter bloomFilter = new BloomFilter(1000, 3);
// 添加元素到布隆过滤器
bloomFilter.add("hello");
bloomFilter.add("world");
// 检查元素是否在布隆过滤器中
System.out.println(bloomFilter.mightContain("hello")); // true
System.out.println(bloomFilter.mightContain("world")); // true
System.out.println(bloomFilter.mightContain("java")); // false
}
}
如果你不想手写布隆过滤器,则可以使用例如hutool包中封装的BloomFilter。
布隆过滤器(Bloom Filter)是一种高效的概率型数据结构,用于测试一个元素是否在一个集合中。它具有较高的空间效率和查询效率,但允许一定的误判率(即可能会误判一个不存在的元素为存在)。布隆过滤器不会产生假阴性(false negative),即如果它认为一个元素不存在,该元素一定不存在,但会产生假阳性(false positive),即它可能会认为一个不存在的元素存在。
2.3布隆过滤器的基本原理
- 数据结构:布隆过滤器由一个位数组(bit array)和一组独立的哈希函数组成。
- 哈希函数:布隆过滤器使用 kkk 个不同的哈希函数,每个哈希函数将输入元素映射到位数组中的一个位置。
- 添加元素:
- 当添加一个元素到布隆过滤器时,使用 kkk 个哈希函数分别计算该元素的哈希值,并将对应的 kkk 个位置的位设置为1。
- 例如,对于一个元素 xxx,假设位数组长度为 mmm,使用的哈希函数为 h1,h2,…,hkh_1, h_2, …, h_kh1,h2,…,hk,则计算 h1(x),h2(x),…,hk(x)h_1(x), h_2(x), …, h_k(x)h1(x),h2(x),…,hk(x),并将这些位置设置为1。
- 查询元素:
- 当查询一个元素是否在布隆过滤器中时,同样使用 kkk 个哈希函数计算该元素的哈希值,检查对应的 kkk 个位置的位是否都为1。
- 如果所有的位置都为1,则该元素可能在集合中;如果有任意一个位置为0,则该元素一定不在集合中。
5.优点:
- 空间效率高:相比于其他数据结构,如哈希表,布隆过滤器可以在较小的空间内表示大量元素。
- 查询效率高:查询操作只需要计算 kkk 个哈希函数并检查 kkk 个位置,非常高效。6.
6.缺点:
- 误判率:布隆过滤器可能会误判一个不存在的元素为存在。
- 删除困难:由于哈希冲突的存在,删除元素会影响其他元素的查询结果。
2.4如何将数据批量写入布隆过滤器
bloomFilter.add();
写入数据的效率过低。如何高效写入数据?
Lua
脚本实现。
-- bloom_filter_batch_add.lua
local key = KEYS[1]
local values = ARGV
for i, value in ipairs(values) do
redis.call('BF.ADD', key, value)
end
return true
具体的lua脚本语法,可自行去学习。
2.5避免缓存穿透,前期预防也很重要
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
3.缓存雪崩
在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
3.1解决方案
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
目前未在实际项目中使用,后续补充代码实现
4.缓存击穿
4.1互斥锁实现缓存重建
解决策略:添加互斥锁实现缓存重建 先查缓存,如果缓存存在redis且不为null则返回。 如果为空字符,则尝试获取互斥锁,如果获取锁成功,则继续执行。查询数据库,查到写入redis,查不到则返回错误信息。 如果获取锁失败,则休眠一短时间,重新从头开始执行查询操作。
//线程池
private static final ExecutorService CACAHE_REBUID_EXECUTOR = Executors.newFixedThreadPool(10);
//互斥锁代码逻辑
public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY +id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空字符串
if(shopJson != null){
//返回错误信息
return Result.fail("店铺信息不存在");
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2判断是否获取成功
if(!isLock){
//4.3失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功,根据id查询数据库
shop = getById(id);
// 模拟重建的延时
Thread.sleep(200);
//5.数据库中不存在,返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//7.释放互斥锁
unlock(lockKey);
//8.返回店铺信息
return shop;
}
//获取锁方法
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁方法
private void unlock(String key){
stringRedisTemplate.delete(key);
}
4.2添加过期时间expire 解决热点数据过期问题
解决策略:添加过期时间expire 先查缓存,如果缓存不存在,直接返回空。 查到缓存,则查询过期时间expire是否过期。 如果未过期,则返回店铺信息,已经过期则获取互斥锁,实现缓存重建,然后释放锁。 获取互斥锁失败,则返回过期的店铺信息。
//添加逻辑过期解决缓存击穿
public Shop queryWithLogicalExpire(Long id){
String key = CACHE_SHOP_KEY +id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.不存在,直接返回
return null;
}
//4.命中,需要把json反序列为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//5.1 未过期,直接返回店铺信息
return shop;
}
//5.2已过期,需要缓存重建
//6.缓存重建
//6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//6.2判断是否获取锁成功
if (isLock) {
//6.3.成功,开启独立线程,实现缓存重建
CACAHE_REBUID_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
}
//6.4.失败,返回过期的店铺信息
return shop;
}
//获取锁方法
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁方法
private void unlock(String key){
stringRedisTemplate.delete(key);
}