文章目录
- 一、概述
- 二、Redis的应用场景
- 三、实战
-
- [3.1 Redis为什么这么快](#3.1 Redis为什么这么快)
- [3.2 缓存穿透](#3.2 缓存穿透)
- [3.2 缓存雪崩](#3.2 缓存雪崩)
- [3.3 ReadTimeout](#3.3 ReadTimeout)
- [3.4 Redis与DB数据一致性](#3.4 Redis与DB数据一致性)
-
- [3.4.1 缓存与数据库双写数据一致性](#3.4.1 缓存与数据库双写数据一致性)
- [3.4.2 少卖](#3.4.2 少卖)
- [3.5 Redis实现分布式锁](#3.5 Redis实现分布式锁)
-
- [3.5.1 需要场景1-超卖](#3.5.1 需要场景1-超卖)
- [3.5.2 需要场景2-缓存击穿](#3.5.2 需要场景2-缓存击穿)
- [3.5.3 Redis分布式锁问题&解决](#3.5.3 Redis分布式锁问题&解决)
- [3.6 RedLock](#3.6 RedLock)
- [3.7 Redssion](#3.7 Redssion)
- [3.8 匹配key](#3.8 匹配key)
- [3.9 MySQL实现分布式锁](#3.9 MySQL实现分布式锁)
一、概述
Redis
- Redis是非关系型(NoSQL)的键值对数据库
- 数据是存在内存中的,每秒可以处理超过 10万次读写操作,故广泛应用于缓存
数据类型
Redis主要有5种数据类型:String,List,Set,Zset,Hash
String
应用场景
1)缓存
java
set(k,v)
get(k)
2)计数、分布式ID
java
incr(k)
get(k)
3)分布式锁
java
setNx
del
Hash
应用场景
1)对象
java
Map<Integer, String> skuIdAndNameMap = new HashMap<>();
skuIdAndNameMap.put(1, "苹果");
skuIdAndNameMap.put(2, "香蕉");
skuIdAndNameMap.put(3, "橘子");
skuIdAndNameMap.forEach((field, value) -> jedis.hset("323-20240909", field, value));
Map<Integer, String> map = jedis.hgetAll("323-20240909");
String name = jedis.hget("323-20240909", "3");// 橘子
List
粉丝列 表、评论列表;分布式队列:可以通过 lpush 和 rpop 写入和读取消息、或者将库存存入,然后扣减
Set
交集、并 集、差集 的操作,两个人的共同好友
Zset
去重、排序, 获取排名前几名的用户
二、Redis的应用场景
1、计数器
2、自增ID
2、缓存
3、分布式锁
- SETNX
- RedLock
三、实战
3.1 Redis为什么这么快
- 内存
- 高效的数据结构和算法(跳表、)
- 采用单线程(无切换、无锁)-文件事件分派器队列的消费是单线程
- 非阻塞 IO,文件事件处理器使用 I/O 多路复用
3.2 缓存穿透
缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案:布隆过滤器(Bloom Filter),先用布隆过滤器判断下,如果不存在,直接不用查了
3.2 缓存雪崩
并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
过期时间打散
java
set("mjp", String.valueOf(18),
30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS);
3.3 ReadTimeout
在setNx时,出现异常,异常
java
e.getMessage().contains("ReadTimeout") {
// redis服务端,已经加锁成功了,只不过在返回给客户端的时候,超时了,这种场景我们认为是加锁成功了
}
3.4 Redis与DB数据一致性
3.4.1 缓存与数据库双写数据一致性
1、思路
读的时候,先读缓存,缓存没有的话,就读db,然后取出数据后放入缓存,同时返回响应
更新的时候,先更新db,然后再删除缓存
2、问题
如果删除缓存失败了,那么会导致db是新数据,缓存中是旧数据,数据就出现了不一致
3、解决
归根原因是,CAP中的C一致性
如果业务可以接受:最终一致性,则
- 方案一:延时双删
java
putToDB(key,value);
deleteFromRedis(key);
// 数秒后重新执行删除操作,时间 = 读业务逻辑数据的耗时 + 几百毫秒
deleteFromRedis(key,5);
- 方案二:消息队列
把删除失败的key放到消息队列,重试删除
分布式事务,参考:分布式事务-队列实现最终一致性
3.4.2 少卖
1、背景
锁资源粒度,将100个库存,分别放在redis的10个list中
2、下单,扣减库存(先更新db,再更新缓存)
- 如果redis中有库存,则允许下单,先扣减redis库存,再扣减db库存
java
ListOperations<String, String> list = redisTemplate.opsForList();
String val = list.leftPop("key");
if (StringUtils.isEmpty(val)) {
// 已卖完
} else {
// 扣减db
}
3、少买问题
Redis扣减了100个,但是db90个扣减成功,10个扣减失败。少买了10个品
4、解决
本质是数据一致性问题,即分布式事务问题
可以catch住db异常,然后将redis的库存补回
3.5 Redis实现分布式锁
3.5.1 需要场景1-超卖
java
//获取库存数目
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
//库存数目大于零则减库存
if(stock > 0){
int finalStock = stock - 1;
//更新库存
redisTemplate.opsForValue().set("stock",Integer.toString(finalStock));
}
1、背景
- 假如此时库存,只有1个了
- t1和t2并行进入,get拿到库存后,发现都是1 > 0
- 二者都进行了扣减库存操作,导致库存出现 负数问题,即超卖
2、解决:
分布式锁
3.5.2 需要场景2-缓存击穿
1、背景
- 热点key过期了
- 此时高并发查询此key,1wQPS
- 请求直接打到db,打崩
2、解决
分布式锁,在缓存和db之间加分布式锁
java
// 读缓存
String key = "key";
String val = redisTemplate.opsForValue().get(key);
// 缓存中无数据,则可能击穿
if (StringUtils.isEmpty(val)) {
// 1.加分布式锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "v");
// 2、获取锁成功
if (success) {
// 3.再读一次缓存
val = redisTemplate.opsForValue().get(key);
if (StringUtils.isNoneEmpty(val)) {
return val;
} else {
// 读db
// 回写缓存
return "查询结果";
}
}
} else {
return val;
}
3.5.3 Redis分布式锁问题&解决
死锁
1、背景
- t1在finally中有释放锁代码,但未设置锁过期,此时客户端server1服务器宕机
- t2,尝试在server2加锁,获取不到锁
- "死锁了"
2、解决
只要是加锁,就必须设置锁过期时间,否则一旦服务器宕机,未释放锁,其它服务器,都没有机会再获取到此锁,再处理了
锁过期-导致锁失效
1、背景
java
// 1.加锁-setNx
// 2.执行业务
// 3.释放锁-delete
t1,setNx(k1,v1),并设置锁超时时间为3s
- t1执行业务,耗费了5s
- 虽然t1尚未执行释放锁,但是锁已经因为超时失效了
此时t2,进来
- t2,能够获取锁,正常执行
此时t1,执行完业务,然后释放锁。此时t1释放的是t2的锁了
导致锁失效
这里有两个问题
-
问题1:t1线程未执行完,锁就过期了,对于t1而言,本质相当于没加锁,对于共享数据,相当于没加锁
-
问题2:发生了问题1的情况下,t1释放了t2的锁,是另一个问题,解铃必须系铃人!
这里如果没有问题1,就不会有问题2
2、解决
在加锁,返回true之前:
Timer定时器 + lua脚本为锁续期
- 使用juc包下的Timer定时器, 启一个子线程,每隔10(1/3的过期时间)s,执行一次lua脚本
- lua脚本
- 查看自己的锁是否存在
- 如果存在,则将其过期时间,重置为30s
java
private boolean lock(String key, String val, Long expire) {
stringRedisTemplate.opsForValue().set(key, val, expire, TimeUnit.SECONDS);
renewExpire(key,val,expire, 10, 0);
return true;
}
private void renewExpire(String key, String val, Long expire, int maxRetries, int retryCount) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// KEYS[1]即key、ARGV[1]即val(一般是我们设置的uuid-解铃还须系铃人)、expire锁过期时间
String lua = "if redis.call('exists', KEYS[1], ARGV[1]) == 1 then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>(lua, Boolean.class);
Boolean executed = stringRedisTemplate.execute(script, Collections.singletonList(key), val, String.valueOf(expire));
if (executed) {
// 如果锁存在,lua重置锁过期时间成功了,则延时1/3过期时间后,再执行一次
// 加锁时,设置了过期时间为30s,返回true加锁成功之前,执行此方法。
// 此方法,延时1/3时间执行run任务,即10s后执行任务将锁的过期时间重置为30s
// 下一次再延时10s,执行run任务
// 直到业务方手动释放了锁,lua脚本执行返回false,就不会再执行renewExpire了
if (retryCount <= maxRetries) {
renewExpire(key, val, expire, maxRetries, retryCount+1);
}
}
}
}, expire * 1000 / 3); // 延时(1/3的过期时间)执行一次
}
释放别人加的锁
1、背景
1)场景1
上述锁过期问题,会发生释放别人的锁
2)执行释放锁-恶意解锁
3)防御式编程
某些场景下加锁失败,只剩下释放锁逻辑,就会释放别人的锁
java
代码问题导致
// 场景1下加锁成功
// 场景2下加锁失败了
finally中释放锁
t1,场景2下加锁失败了,t2进来,加锁成功,t1执行释放锁。t2加的锁被别人释放了
2、解决
只能释放自己加的锁
java
public void t(String key){
ThreadLocalRandom random = ThreadLocalRandom.current();
UUID uuid = new UUID(random.nextLong(), random.nextLong());
String val = String.valueOf(uuid);
try {
Boolean lock = redisTemplate.opsForValue().setIfAbsent(key, val, 3, TimeUnit.SECONDS);
if (lock) {
// 业务
}
} finally {
val = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotEmpty(val) && val.contains(uuid.toString())) {
redisTemplate.delete(key);
}
}
}
-
生成uuid
-
setNx时,val加个前缀uuid
-
释放时,先判断k-对应的val前缀为此uuid,再删除
3、存在问题
-
判断是自己的,刚准备删除时,锁已经过期释放了
-
然后再执行删除锁,此时锁刚好被别人获取了,相当于还是删除了别人的锁
-
根本原因:判断 + 删除锁 ,不是原子操作
4、解决
将判断 + 删除,使用lua脚本打包。lua脚本将二者一次性发送给redis,对于redis而言这个lua脚本就是一个指令
java
finally{
// 使用Lua脚本释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
// 执行脚本
Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), val);
// 根据结果判断锁是否释放成功
if (result == 0L) {
// 锁不属于当前客户端,释放失败
} else {
// 锁释放成功
}
}
此时完整的加锁 、续约锁时间、 释放锁流程
java
public void t() throws Exception {
String key = "mjp";
ThreadLocalRandom random = ThreadLocalRandom.current();
UUID uuid = new UUID(random.nextLong(), random.nextLong());
String val = String.valueOf(uuid);
Long expire = 30L;
try {
lock(key, val, expire);
TimeUnit.SECONDS.sleep(20);
} finally {
// 使用Lua脚本释放锁
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
// 执行脚本
Long result = stringRedisTemplate.execute(script, Collections.singletonList(key), val);
// 根据结果判断锁是否释放成功
if (result == 0L) {
// 锁不属于当前客户端,释放失败
} else {
// 锁释放成功
System.out.println("/success");
}
}
}
// 加锁 和 重置超时
private boolean lock(String key, String val, Long expire) {
stringRedisTemplate.opsForValue().set(key, val, expire, TimeUnit.SECONDS);
renewExpire(key,val,expire, 10, 0);
return true;
}
private void renewExpire(String key, String val, Long expire, int maxRetries, int retryCount) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// KEYS[1]即key、ARGV[1]即val(一般是我们设置的uuid-解铃还须系铃人)、expire锁过期时间
String lua = "if redis.call('exists', KEYS[1], ARGV[1]) == 1 then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>(lua, Boolean.class);
Boolean executed = stringRedisTemplate.execute(script, Collections.singletonList(key), val, String.valueOf(expire));
if (executed) {
// 如果锁存在,lua重置锁过期时间成功了,则延时1/3过期时间后,再执行一次
// 加锁时,设置了过期时间为30s,返回true加锁成功之前,执行此方法。
// 此方法,延时1/3时间执行run任务,即10s后执行任务将锁的过期时间重置为30s
// 下一次再延时10s,执行run任务
// 直到业务方手动释放了锁,lua脚本执行返回false,就不会再执行renewExpire了
if (retryCount <= maxRetries) {
renewExpire(key, val, expire, maxRetries, retryCount+1);
}
}
}
}, expire * 1000 / 3); // 延时(1/3的过期时间)执行一次
}
集群单节点宕机-锁失效
1、问题描述
t1
- 获取锁成功,写入master-server1
- master同步slaver过程中,master宕机了
server2-选举为新master
t2
- 尝试获取锁,从master-server2中,能够正常获取锁
- 此时对于t1而言,加的锁失效了,因为t2也能进来
2、解决:
RedLock红锁
3.6 RedLock
1、背景
redis中red即红锁,专门用于解决集群单节点故障,导致的锁失效问题
2、加锁过程
redis集群,节点之间是独立的,无master、slaver
1)应用程序,系统当前时间,curTime
2)使用setNx并设置超时时间,依次从每个redis server中尝试获取锁
- 超时时间:避免死等某个宕机了的redis server
3)判断获取锁是否成功
依次从server1-server5执行setNx后,应用程序,系统当前时间 = newCurTime
diff = newCurTime - curTime
- 条件1:diff < 锁设置的过期时间(消耗时间已经 > 锁过期时间了,即使获取到锁也没意义了)
- 条件2:有一半的服务器获取锁成功
当条件1和条件2都满足时,才认为获取锁成功
- 如果成功了,则计算剩余的锁过期时间(真正给应用程序用的) = 原设置的锁失效时间 - 累加t消耗时间
- 如果失败了,则对所有节点执行释放锁操作
3、特点
实际中不易实现、且非主从架构
3.7 Redssion
3.7.1 快速开启
1、pom
code栏下,快速开启指南
java
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.1</version>
</dependency>
2、程序方式配置
Wiki栏下,配置
1)单节点-自己主机
2)单节点-其它主机
3)主从集群
RedissonClient
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 单节点-主机
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
3、使用分布式锁锁
Wiki栏下,分布式锁和同步器
java
@Resource
private RedissonClient redissonClient;
@Test
public void t() throws Exception {
String key = "mjp";
long expire = 30L;
RLock lock = redissonClient.getLock(key);
lock.lock(expire, TimeUnit.SECONDS);
}
3.7.2 加锁原理
lock
lock ->RedissonLock#lock -> tryAcquire -> tryAcquireAsync -> tryLockInnerAsync
底层就是lua脚本
lua
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
- 如果key-锁不存在,
- 则直接获取锁
- 并且设置锁的过期时间
- return nil
- 如果锁存在
- 如果是自己的锁
- 则对锁的重入次数 + 1
- 然后重置过期时间
- 返回nil
续时原理
lock ->RedissonLock#lock -> tryAcquire -> tryAcquireAsync -> scheduleExpirationRenewal -> renewExpiration
底层使用的netty的HashedWheelTimer,而非juc的Timer
正常的lua脚本执行renewExpirationAsync
java
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
- 判断是否是自己的锁
- 是,则重置过期时间
java
if (res) {
// reschedule itself
renewExpiration();
} else {
cancelExpirationRenewal(null);
}
重置成功,则后续会再次重置,否则取消
3.7.3 解锁
unlock -> RedissonLock#unlock -> unlockAsync -> unlockInnerAsync -> RedissonLock#unlockInnerAsync
lua脚本
lua
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
- 如果不是自己的锁,则end结束
- 是
- 如果-1后的counter >0,则返回0并重置过期时间
- 否则,del锁
3.7.4 semaphore限流
java
// 分布式
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
//限流的量,允许的并发量
semaphore.tryAcquire(10);
// 尝试获取资源(10个资源之一)
semaphore.acquire();
// 业务
// 释放资源
semaphore.release();
3.7. 5 CountDownLatch
作用等效juc中的CountDownLatch,这里是分布式
java
// main线程
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
// 英雄联盟,一局游戏10个玩家
latch.trySetCount(10);
latch.await();//必须10个玩家都加载到100%,才能进入游戏画面
// 在其他线程或其他JVM里
// 其他每个玩家(10分之一),完成加载100%,则执行countDown
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();
- 可以阻塞等待整体完成,才能进入下一步
java
@Resource
private RedissonClient redissonClient;
@GetMapping("/test/latch")
public String testLatch(){
RCountDownLatch cdl = redissonClient.getCountDownLatch("cdl");
cdl.trySetCount(5);
try {
cdl.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "5个人玩家都ready了";
}
@GetMapping("/test/countdown")
public String testCountdown(){
RCountDownLatch cdl = redissonClient.getCountDownLatch("cdl");
try {
TimeUnit.SECONDS.sleep(1);
cdl.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "一个玩家准备好了";
}
执行5次testCountdown方法,testLatch方法才会返回结果
- 也可以一步一步阻塞等待上一步完成,才能进入下一步
3.7.6 实现RedLock
java
public class RedissonRedLockExample {
public static void main(String[] args) {
// 配置多个 Redis 服务器
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://128.10.10.101:6379");
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建多个 RedissonClient
RedissonClient redisson1 = Redisson.create(config1);
RedissonClient redisson2 = Redisson.create(config2);
// 创建多个 RLock 实例
RLock lock1 = redisson1.getLock("anyLock");
RLock lock2 = redisson2.getLock("anyLock");
// 使用 RedLock
RLock redLock = new RedissonRedLock(Arrays.asList(lock1, lock2));
try {
// 尝试获取锁,最多等待 100 秒,上锁以后 10 秒自动解锁
boolean res = redLock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
// 执行业务逻辑
} finally {
// 释放锁
redLock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
// 关闭 RedissonClient
redisson1.shutdown();
redisson2.shutdown();
}
}
3.7.7 使用注意事项
1、问题
在事务内部使用锁,锁在事务提交前释放了
2、解决
不要在事务内部使用锁
java
public void func() {
RLock lock = redissonClient.getLock(paymentOrder.getBizNo());
lock.lock();
try {
createPaymentOrderNoLock(paymentOrder);
} finally {
//释放锁
lock.unlock();
}
}
@Transactional
public void createPaymentOrderNoLock(PaymentOrder paymentOrder) {
// 双本地写
}
3.8 匹配key
假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如果将它们全部找出来?
SCAN
命令时,你可以指定一个模式(pattern)来匹配key
3.9 MySQL实现分布式锁
1、独占排他(必须)
使用唯一键:lock_name
insert语句,insert失败支持重试
2、超时时间(必须有,否则客户端宕机,则死锁)
使用lock_time
- 定时轮询,将过期的数据del
- 或者在insert失败后,看下锁是否过期,过期则删除然后inser(事务)
3、可重入
funcA() {
// 加锁key成功
// 业务
funcB();
// 业务
}
funcB(){
// 加锁key也应该能成功,锁要满足可重入
}
使用tl字段,使用ThreadLocal存tl,保证一个线程可重入(线程id、重入次数)
4、释放锁(必须)
deleteById(insert成功后,会将主键id回写到DO中)
5、续时
总结
1、简易:mysql
2、性能:redis
3、可靠性:zk