Redis的多IO线程只是用来处理网络请求的,对于读写操作命令Redis仍然使用单线程来处理
Redis分布式锁面试题及答案
问题1:Redis分布式锁如何实现?
答案
在Redis中可以利用setnx(SET if not exists)
命令来实现分布式锁。由于Redis是单线程的,执行该命令时,只会有一个客户端能对某一个key
设置值。在该key
没有过期或被删除时,其他客户端无法设置此key
,以此达到加锁的效果。例如,假设有多个客户端同时尝试对lock_key
进行setnx
操作,只有第一个执行成功的客户端获得了锁,后续客户端操作失败,即未获取到锁。
问题2:如何控制Redis实现分布式锁的有效时长?
答案
单纯使用Redis的setnx
指令较难控制锁的有效时长。在实际应用中,常采用Redis的框架Redisson来实现。在Redisson中,需要手动加锁,并且能够灵活控制锁的失效时间和等待时间。当被锁住的业务尚未执行完成时,Redisson引入了看门狗机制。该机制会每隔一段时间检查当前业务是否仍持有锁,如果持有则增加锁的持有时间。当业务执行完成后,使用释放锁的操作即可。此外,在高并发场景下,当客户1持有锁时,客户2不会马上被拒绝,而是会自选不断尝试获取锁,一旦客户1释放锁,客户2就能立即持有锁,从而提升了性能。例如,设置锁的失效时间为10秒,看门狗检查周期为5秒,若业务在10秒内未执行完,看门狗检查时发现业务仍持有锁,就会延长锁的持有时间,保证业务正常执行。
问题3:Redisson实现的分布式锁是可重入的吗?
答案
Redisson实现的分布式锁是可重入的。可重入性的设计是为了避免死锁的产生。其内部实现机制是判断当前获取锁的线程是否为已持有锁的线程。如果是当前线程持有的锁,就对重入次数进行计数;当释放锁时,重入次数减一。在存储数据时采用哈希结构,大key
可根据业务进行定制,小key
为当前线程的唯一标识,value
则是当前线程的重入次数。例如,一个线程对同一把锁进行多次加锁操作,每次加锁时重入次数递增,释放锁时重入次数递减,确保线程安全。
问题4:Redisson实现的分布式锁能解决主从一致性的问题吗?
答案
Redisson实现的分布式锁不能解决主从一致性问题。例如,当线程1加锁成功后,master节点的数据会异步复制到slave节点。此时若持有Redis锁的master节点宕机,slave节点被提升为新的master节点。假如这时来了一个线程2,由于之前master节点宕机导致数据未及时同步到新的master节点(原slave节点),线程2可能会错误地获取到锁,从而引发一致性问题。
文章目录
主线程和IO线程是如何协作的
-
阶段一:服务端和客户端建立Socket连接,并分配处理线程
首先,主线程负责接收建立连接请求,当有客户端请求和实例建立Socket连接时,主线程会创建和客户端的连接,并把 Socket放入全局等待队列中。紧接着,主线程通过轮询方法把Socket连接分配给IO线程 -
阶段二:IO线程读取并解析请求
主线程一旦把Socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所以,这个过程很快就可以完成。 -
阶段三:主线程执行请求操作
等到IO线程解析完请求,主线程还是会以单线程的方式执行这些命令操作
-
阶段四:IO线程回写Socket和主线程清空全局队列
当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待IO线程,把这些结果回写到Socket中,并返回给客户端。和IO线程读取和解析请求一样,IO线程回写Socket时,也是有多个线程在并发执行,所以回写Socket的速度也很快。等到IO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求。
Unix网络编程中的五种IO模型
Blocking IO - 阻塞IO
NoneBlocking IO - 非阻塞IO
IO multiplexing - IO多路复用 ★★★
signal driven IO - 信号驱动IO(偏C)
asynchronous IO - 异步IO(偏C)
Linux世界一切皆文件
文件描述符、简称FD,句柄
FileDescriptor:
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统
I/O 的读和写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的 |
零拷贝
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA技术,也就是直接内存访问(Direct Memory Access)技术。
什么是 DMA技术?简单理解就是,在进行I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样CPU 就可以去处理别的事务。
那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。
具体过程:
- 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
- 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
- DMA 进一步将 I/O 请求发送给磁盘;
- 磁盘收到 DMA的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA发起中断信号,告知自己缓冲区已满;
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用CPU,CPU 可以执行其他任务;
- 当 DMA 读取了足够多的数据,就会发送中断信号给CPU;
- CPU 收到 DMA的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
可以看到,整个数据传输的过程,CPU不再参与数据搬运的工作,而是全程由 DMA 完成,但是CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。
传统的文件传输有多糟糕?
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
-
第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
-
第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
-
第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
-
第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
总结
早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。
于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。
传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。
为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。
Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。
需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。
在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。
生产上限制keys *、flushdb、flushall等危险命令
keys * 遍历查询100W数据花费时长
配置禁用这些命令
redis.conf 在 SECURITY 这一项中
rename-command keys ""
rename-command flushdb ""
rename-command FLUSHALL ""
RDB、AOF和混合持久化机制
BigKey案例
多大算Big
参考《阿里云Redis开发规范》
缓存更新策略
Redis内存不足的缓存淘汰策略
- noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键
- allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
- volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
- allkeys-random:加入键的时候如果过限,从所有key随机删除
- volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
- volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
- volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键 allkeys-lfu:从所有键中驱逐使用频率最少的键
先删缓存再操作数据库
理想情况
多线程竟态条件下
多线程竟态条件下
好巧不巧,缓存失效了,此时线程2要采用先更新数据库再删除缓存的策略,但由于更新数据库没有线程1查询数据库快,所以查到的还是未更新前的旧值10;
线程2更新完毕之后删除了redis缓存,线程1获取时间片后又将10写回了缓存,导致数据库缓存不一致的情况
先操作数据库再删除缓存【胜出】
理想情况
总结
给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案
我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。
也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以数据落库DB为准
项目实践【黑马点评】
目标
缓存一致性
com.sddp.service.impl.ShopServiceImpl#update
事务保证原子性,如果在微服务系统中,这两步不在一个方法当中,甚至不在一个服务当中,那么就需要mq消息通知删除缓存的服务,可以借助TCC来保证分布式事务的原子性
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。如果被恶意用户利用,对服务器会造成负载,严重会导致服务不可用
常见的解决方案有两种:
com.sddp.service.impl.ShopServiceImpl#queryById
在这里插入代码片
缓存穿透解决方案调研
实战解决商铺信息缓存穿透
如果提交的商铺id本身就是瞎写的,查询数据库之后必然没有数据,那此时,redis则将此id存在redis并赋值为null,下次在查询此id时直接走redis返回null即可
总结
缓存雪崩
TTL随机数分散降低机率
Redis宕机:利用集群提高服务的可用性
快速失败、拒绝服务
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂 (下图第 2 步比较耗时,导致多线程访问的时候短时间为写入缓存,期间的流量都打到DB上了)的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
缓存击穿解决方案调研
互斥锁:CP(强一致)
逻辑过期:AP(高可用)
实战解决缓存击穿
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁 来锁住它。
其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。
后面的线程进来发现已经有缓存了,就直接走缓存
/**
* @auther zzyy
* @create 2021-05-01 14:58
*/
@Service
@Slf4j
public class UserService {
public static final String CACHE_KEY_USER = "user:";
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate redisTemplate;
/**
* 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
* @param id
* @return
*/
public User findUserById(Integer id)
{
User user = null;
String key = CACHE_KEY_USER+id;
//1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
user = (User) redisTemplate.opsForValue().get(key);
if(user == null)
{
//2 redis里面无,继续查询mysql
user = userMapper.selectByPrimaryKey(id);
if(user == null)
{
//3.1 redis+mysql 都无数据
//你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
return user;
}else{
//3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
redisTemplate.opsForValue().set(key,user);
}
}
return user;
}
/**
* 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
* @param id
* @return
*/
public User findUserById2(Integer id)
{
User user = null;
String key = CACHE_KEY_USER+id;
//1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
// 第1次查询redis,加锁前
user = (User) redisTemplate.opsForValue().get(key);
if(user == null) {
//2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
synchronized (UserService.class){
//第2次查询redis,加锁后
user = (User) redisTemplate.