本文已参与「新人创作礼」活动,一起开启掘金创作之路。
让我们复习一个命令: setnx
setnx: set一个值, 如果set的值已经存在则返回0, 不存在则会将值设置返回1
如图:
需要准备的jar包:
1. jedis-2.9.0.jar (操作 redis 的 jar包)
2. commons-pool2-2.6.0.jar (redis 连接池的 jar包)
3. guava-27.1-jre.jar (布隆过滤器的 jar包)
这三个jar都能在maven中央仓库找到, 根据配置进行选择版本
PS: 点击可以传送
在处理之前先创建一个连接池以及通过setnx设计一个互斥锁
连接池工具:
public class JedisPoolTest {
/**
* 创建连接池
*/
private static JedisPool pool = new JedisPool("192.168.146.240", 6379);
/**
* 获得jedis实例
*
* @return 实例
*/
public static Jedis getJedis() {
// 认证
Jedis jedis = pool.getResource();
jedis.auth("admin123");
return jedis;
}
}
互斥锁实现:
public class RedisLock {
/*
* 原理:redis 的setnx如果key存在就会做任何操作 不存在就会set
* 根据这一点可以实现一个简单的互斥锁
*
* 注意: 锁超时时不能直接del 如果在del之前有其他线程获得了锁, 那么可能造成锁的释放
*/
/**
* 锁超时过期时间 200方便看出测试效果
*/
private static final long EXPIRED = 200;
/**
* 尝试获取分布式锁
*
* @param jedis jedis连接
* @param lock 锁名称
* @return true获得到锁
*/
public static long tryLock(Jedis jedis, String lock) {
// 设置锁的过期时间
Long lockValue = System.currentTimeMillis() + EXPIRED + 1;
// 尝试获取锁
Long setnx = jedis.setnx(lock, String.valueOf(lockValue));
// 判断是否得到锁
if (setnx == 1) {
return lockValue;
} else {
// 获取锁的值
Long oldLockValue = Long.valueOf(jedis.get(lock));
// 判断锁是否超时
if (oldLockValue < System.currentTimeMillis()) {
// 将锁赋值 并获取为改变的值
String getOldLockValue = jedis.getSet(lock, String.valueOf(lockValue));
// 再进行判断
if (Long.valueOf(getOldLockValue).equals(oldLockValue)) {
// 锁设置成功
return lockValue;
} else {
// 其他线程抢到了锁
return 0;
}
} else {
return 0;
}
}
}
/**
* 释放分布式锁
*
* @param jedis jedis连接
* @param lock 锁名称
* @param timeOut 超时时间
*/
public static void unLock(Jedis jedis, String lock, long timeOut) {
// 先获取锁
String lockValue = jedis.get(lock);
if (lockValue == null) {
return;
} else if (Long.valueOf(lockValue).equals(timeOut)) {
// 删除key
jedis.del(lock);
}
}
}
回顾一下如何解决缓存雪崩?
方案1: 设置互斥锁 mutex: 单机使用 lock 分布式使用setnx
方案2: 建立备份缓存, A 和 B, 当 A 过期后, 读取缓存 B 并且查询数据库更新 A 和 B
方案3: 设置缓存的时候加一个随机时间, 这样可以介绍计提时效的概率, 一定晨程度免了雪崩
方案4: 使用阻塞队列查询
这里使用 方案1 、方案3 和 方案4 进行解决
缓存雪崩解决方案 1 (互斥锁):
优点: 思路简单, 数据一致
缺点: 复杂度大, 容易死锁
/**
* 互斥锁 同缓存击穿策略 查找时策略
* <p>
* 优点: 思路简单 保证数据一致性
* 缺点: 代码复杂度增大 存在死锁概率
*/
private static String exam1(Jedis jedis, String key) throws InterruptedException {
// 获取数据
String s = jedis.get(key);
if (s == null) {
// 获得锁
long lockValue = RedisLock.tryLock(jedis, "lock:snow1");
if (lockValue != 0) {
System.out.println("by$ BD");
// 从数据库查找
String valueByDB = getValueByDB(key);
// 插入到redis
jedis.set(key, valueByDB);
// 释放锁
RedisLock.unLock(jedis, "lock:snow1", lockValue);
return valueByDB;
} else {
// 没获得到锁 休眠 已经有其他线程在查询数据了
Thread.sleep(200);
return exam2(jedis, key);
}
} else {
System.out.println("by$ Cache");
return s;
}
}
缓存雪崩解决方案 2 (设置随机时间):
优点: 简单方便
缺点: 时间不宜维护
/**
* 随机时间 添加时策略
* <p>
* 优点: 简单方便
* 缺点: 时间不宜维护
*/
private static void exam2(Jedis jedis, String key, String value, int seconds) {
// 设置值
jedis.set(key, value);
// 设置过期时间 随机多分配几分钟这样可以防止同时失效
jedis.expire(key, seconds + (new Random().nextInt(5) * 60));
// 打印过期时间
System.out.println("ttl: #" + jedis.ttl(key));
}
缓存雪崩解决方案 3 (阻塞队列):
优点: 思路简单
缺点: 代码复杂度大, 队列处理可能处理不及时
public static void main(String[] args) {
// 从连接池获取链接
Jedis jedis = JedisPoolTest.getJedis();
// 启动监听MQ
new Thread(() -> {
try {
execMQ();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
exam3(JedisPoolTest.getJedis(), "snow:exam3:" + new Random().nextInt(2));
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
/**
* 创建阻塞队列
*/
private static LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>(200);
/**
* 阻塞队列的方式 查找时策略
* 优点: 思路简单
* 缺点: 阻塞队列 代码复杂度增大
*/
private static String exam3(Jedis jedis, String key) throws InterruptedException {
// 获取值
String value = jedis.get(key);
// 如果值过期
if (value == null) {
queue.put(key);
Thread.sleep(50);
return exam3(jedis, key);
} else {
System.out.println("by Cache");
return value;
}
}
/**
* 监听处理MQ
*/
private static void execMQ() throws InterruptedException {
// 从连接池获取jedis实例
Jedis jedis = JedisPoolTest.getJedis();
// 监听队列
for (; ; ) {
// 获得队列的key
String key = queue.take();
// 查看redis是否有数据
String value = jedis.get(key);
// 如果有数据证明队列头已经处理完毕
if (value == null) {
// 没有数据查询数据库
jedis.set(key, getValueByDB(key));
jedis.expire(key, 20);
System.out.println("queue DB");
} else {
System.out.println("queue Cache");
}
}
}
回顾一下如何解决缓存穿透?
方案1: 使用布隆过滤器, 储存 key 的 bitmap, 如果没有查询到key, 则直接被过滤
方案2: 当查询数据库为空时, 将空数据也写入redis, 并且设置较短的过期时间
缓存穿透解决策略 1(布隆过滤器):
优点: 思路简单, 保证一致性, 性能强
缺点: 代码复杂度大, 需要维护一个集合来放缓存的key, bloom不支持删操作
public static void main(String[] args) {
// 从连接池获取jedis实例
Jedis jedis = JedisPoolTest.getJedis();
// 初始化bloom
bloomInit(jedis);
exam1(jedis, "a");
exam1(jedis, "b");
exam1(jedis, "c");
exam1(jedis, "d");
exam1(jedis, "7");
}
/**
* 创建bloom过滤器
*/
private static BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 2000);
/**
* 初始化bloom过滤器
*/
private static void bloomInit(Jedis jedis) {
// 遍历所有key将所有的数据添加bloom
ScanParams scanParams = new ScanParams().count(100);
// 指针
String cursor = "0";
do {
// 遍历
ScanResult<String> scan = jedis.scan(cursor, scanParams);
// 获得所有key
List<String> result = scan.getResult();
result.forEach(s -> {
filter.put(s);
});
cursor = scan.getStringCursor();
} while (!"0".equals(cursor));
System.out.println("boolmFilterCount#: " + filter.approximateElementCount());
}
/**
* 布隆过滤器的巨大用处就是, 能够迅速判断一个元素是否在一个集合中
* <p>
* 优点: 思路简单, 保证一致性, 性能强
* 缺点: 代码复杂度大, 需要维护一个集合来放缓存的key, bloom不支持删操作
*/
private static String exam1(Jedis jedis, String key) {
String value = jedis.get(key);
if (value == null) {
// 如果不存在查看bloom是否存在
if (!filter.mightContain(key)) {
System.out.println("none");
return "";
} else {
// 查询数据库
String valueByDB = getValueByDB(key, false);
System.out.println("by DB");
jedis.set(key, valueByDB);
return valueByDB;
}
} else {
System.out.println("by Cache");
return value;
}
}
缓存穿透解决策略 2(设置空值):
优点: 思路简单
缺点: 需要维护新的key
/**
* 设置空值, 如果查询数据库, 没有查到的话设置一个会过期的空值
* <p>
* 优点: 思路简单
* 缺点: 需要维护一个新的key
*/
private static String exam2(Jedis jedis) {
// 查询缓存
String value = jedis.get("null:key1");
// 缓存没有查询数据库
if (value == null) {
// 查询数据库
String valueByDB = getValueByDB("null:key1");
System.out.println("by DB");
// 如果数据库为空
if (valueByDB == null) {
valueByDB = "";
}
// set一个空值
jedis.set("null:key1", valueByDB);
jedis.expire("null:key1", 60 * 2);
return "";
} else {
System.out.println("by Cache");
return value;
}
}
回顾一下如何解决缓存击穿?
方案1: 设置互斥锁 mutex: 单机使用 lock 分布式使用setnx
方案2: 异步构建缓存
缓存击穿解决策略 1(互斥锁):
优点: 思路简单, 保证数据一致性
缺点: 代码复杂度大, 存在死锁概率
/**
* 使用互斥锁
* 该方法是比较普遍的做法, 在根据key获得的value值为空时, 先锁上, 再从数据库加载, 加载完毕, 释放锁, 若其他线程发现获取锁失败, 则睡眠后重试
* 至于锁的类型, 单机环境用并发包的Lock类型就行, 集群环境则使用分布式锁 (redis的setnx)
* <p>
* 优点: 思路简单 保证数据一致性
* 缺点: 代码复杂度增大 存在死锁概率
*/
private static String exam1(Jedis jedis, String key) throws InterruptedException {
// 获取数据
String s = jedis.get(key);
if (s == null) {
// 获得锁
long lockValue = RedisLock.tryLock(jedis, "lock:break1");
if (lockValue != 0) {
System.out.println("by BD");
// 从数据库查找
String valueByDB = getValueByDB(key);
// 插入到redis
jedis.set(key, valueByDB);
// 释放锁
RedisLock.unLock(jedis, "lock:break1", lockValue);
return valueByDB;
} else {
// 没获得到锁 休眠 已经有其他线程在查询数据了
Thread.sleep(200);
return exam1(jedis, key);
}
} else {
System.out.println("by Cache");
return s;
}
}
缓存击穿解决策略 2(构建异步缓存):
优点: 体验最佳, 无需用户等待
缺点: 无法保证一致性
/**
* 构建线程池
*/
private static ExecutorService threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
/**
* 构建一个队列
*/
private static LinkedBlockingQueue MQqueue = new LinkedBlockingQueue(200);
/**
* 处理消息的队列
*/
private static void execMQ() {
Jedis jedis = JedisPoolTest.getJedis();
for (; ; ) {
try {
// 无限处理队列, 如果没有阻塞
String key = (String) MQqueue.take();
Map<String, String> map = jedis.hgetAll(key);
if (map.isEmpty() || Long.valueOf(map.get("timeout")) <= System.currentTimeMillis()) {
System.out.println("by MQ DB");
jedis.hset(key, "value", getValueByDB(key));
jedis.hset(key, "timeout", String.valueOf(System.currentTimeMillis() + 2000));
} else {
System.out.println("by Cache");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 异步构建缓存
* 构建缓存采取异步策略, 会从线程池中取线程来异步构建缓存, 从而不会让所有的请求直接怼到数据库上
* 该方案redis自己维护一个timeout, 当timeout小于System.currentTimeMillis()时, 则进行缓存更新,否则直接返回value值
* <p>
* 优点: 体验最佳, 用户无需等待
* 缺点: 无法保证缓存一致性
*/
private static String exam2(Jedis jedis, String key) throws InterruptedException {
// 获取数据
Map<String, String> map = jedis.hgetAll(key);
if (Long.valueOf(map.get("timeout")) <= System.currentTimeMillis()) {
// 需要更新数据 异步后台执行
threadPool.execute(() -> {
MQqueue.add(key);
});
} else {
// 无需更新数据
System.out.println("by Cache");
}
return map.get("value");
}
结束
这就是我对redis问题解决篇的总结, 这里没用测试缓存预热和缓存更新, 代码都经过了并发测试, 而且没问题
如果你们在测试过程中发现有bug 请评论, 相互学习共同进步