redis
- redis
- 一.redis是单线程架构还是多线程架构
- 二.单线程的redis为什么这么快
- 三.IO多路复用技术
- 前置知识(fd)
- 前置知识(内核空间和用户空间)
- 前置知识(IO和阻塞)
- 核心
- epoll两种模式
- epoll与select、poll的对比
- mmap基本原理和分类
- 四.redis处理流程
- 六.redis有哪些慢操作?什么样的操作会影响它的性能
- 1.使用复杂度过高的命令,影响主线程
- 2.操作bigkey 写入和删除bigkey的内存和分配和回收比较耗时,会影响主线程
- 3.大量key集中过期:导致当前这次请求大量时间都在删除过期key,影响主线程
- 4.缓存淘汰也是在主线程中执行
- 5.网络IO操作比较慢,主线程处理网络请求的IO速度不够快,怎么办
- 七.聊一聊redis的高可用
- sentinel(哨兵)
- 主观下线和客观下线
- 分布式存储
- 分布式解决方案
- Cluster
- 八.redis分布式锁
- 单体项目,加入sync代码块(本地锁),可以解决库存超卖的情况
- 本地锁在多节点下失效(集群/分布式)
- Redisson源码
redis
一.redis是单线程架构还是多线程架构
redis整体来说并非只有一个线程(多线程),只是redis在处理网络请求,k/v读写操作这个过程是用一个线程来处理的,它的其他功能:其他功能:持久化,异步删除,集群同步都是采用额外的线程来完成的
二.单线程的redis为什么这么快
1.大部分操作基于内存,有高效的数据结构(简单动态字符串 双向链表 压缩列表 哈希表 跳跃表 整数数组)
2.选择单线程,避免了多线程上下文切换和竞争
3.redis底层采用io多路复用技术,能够保证大量并发下的效率,提高系统的吞吐量
redis瓶颈往往在 内存 和 网络I/O
三.IO多路复用技术
前置知识(fd)
linux为了实现一切皆文件的设计哲学,不仅将数据抽象成了文件,也将一切操作和资源抽象成了文件,比如说硬件设备,socket,磁盘,进程,线程等。这样的设计将系统的所有动作都统一起来,实现了对系统的原子化操作,大大降低了维护和操作的难度,想想看,对于socket,硬件设备,我们只要读读写写文件就能对其进行操作是多么爽的一件事。
那么在操作这些所谓的文件的时候,我们不可能没操作一次就要找一次名字吧,这样会耗费大量的时间和效率。咱们可以每一个文件操作一个索引,这样,要操作文件的时候,我们直接找到索引就可以对其进行操作了。我们将这个索引叫做文件描述符**(file descriptor),简称fd**,在系统里面是一个非负的整数。每打开或创建一个文件,内核就会向进程返回一个fd,第一个打开文件是0,第二个是1,依次递增。
前置知识(内核空间和用户空间)
对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。
具体的实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。
为什么需要区分内核空间与用户空间
在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。
所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。
内核态与用户态
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。
对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行(Linux 可是个多任务系统啊!)。
所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性。
如何从用户空间进入内核空间
其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。
比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。其实就是通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以开开心心的往下执行了。
简单说就是应用程序把高科技的事情(从磁盘读取文件)外包给了系统内核,系统内核做这些事情既专业又高效。
前置知识(IO和阻塞)
堵塞方式,是在下达指令后,一直等待指令的回应结果。等待期间,无法做其他事情。编程简单。
非堵塞方式,在下达指令后,可以去做其他工作,如果收到指令完成的通知,再去处理指令结果。编程复杂。
阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.
非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.
IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么(都是轮询 for循环)?
1) select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子
2) poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神
3) epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你。(内核帮忙管理)
上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候, 你已经站在宿舍门口等着女神的, 此时你属于阻塞状态
接下来是异步IO的情况:
你告诉女神我来了, 然后你就去打游戏了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口。 此时属于逆袭做法
核心
阻塞IO
注册socket
绑定端口和socket
监听
阻塞等待
连接
接受
返回数据
while(true)非阻塞I/O
注册socket
绑定端口和socket
监听
设置非阻塞(nonblock) —可以先做其他事情,过会自己主动去问(轮询)
连接
接受
返回数据I/O多路复用
创建epoll对象,创建监听树(红黑树),创建就绪队列(双向链表)
创建socket
绑定端口和socket
监听
设置非阻塞
添加描述符到监听树
阻塞等待注册的事件发生(轮询)
连接
处理分发
增删改连接
epool参数说明
- int epoll_create(int size)
功能:
内核会产生一个epoll 实例数据结构并返回一个文件描述符,这个特殊的描述符就是epoll实例的句柄,后面的两个接口都以它为中心(即epfd形参)。
size参数表示所要监视文件描述符的最大值,不过在后来的Linux版本中已经被弃用(同时,size不要传0,会报invalid argument错误)
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
功能:
将被监听的描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改
typedef union epoll_data {
void ptr; / 指向用户自定义数据 /
int fd; / 注册的文件描述符 /
uint32_t u32; / 32-bit integer /
uint64_t u64; / 64-bit integer */
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 描述epoll事件 /
epoll_data_t data; / 见上面的结构体 */
};
对于需要监视的文件描述符集合,epoll_ctl对红黑树进行管理,红黑树中每个成员由描述符值和所要监控的文件描述符指向的文件表项的引用等组成。
op参数说明操作类型:
EPOLL_CTL_ADD:向interest list添加一个需要监视的描述符
EPOLL_CTL_DEL:从interest list中删除一个描述符
EPOLL_CTL_MOD:修改interest list中一个描述符
struct epoll_event结构描述一个文件描述符的epoll行为。在使用epoll_wait函数返回处于ready状态的描述符列表时,
data域是唯一能给出描述符信息的字段,所以在调用epoll_ctl加入一个需要检测的描述符时,一定要在此域写入描述符相关信息
events域是bit mask,描述一组epoll事件,在epoll_ctl调用中解释为:描述符所期望的epoll事件,可多选。
常用的epoll事件描述如下:
EPOLLIN:描述符处于可读状态
EPOLLOUT:描述符处于可写状态
EPOLLET:将epoll event通知模式设置成edge triggered
EPOLLONESHOT:第一次进行通知,之后不再监测
EPOLLHUP:本端描述符产生一个挂断事件,默认监测事件
EPOLLRDHUP:对端描述符产生一个挂断事件
EPOLLPRI:由带外数据触发
EPOLLERR:描述符产生错误时触发,默认检测事件
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
功能:
阻塞等待注册的事件发生,返回事件的数目,并将触发的事件写入events数组中。
events: 用来记录被触发的events,其大小应该和maxevents一致
maxevents: 返回的events的最大个数
处于ready状态的那些文件描述符会被复制进ready list中,epoll_wait用于向用户进程返回ready list。events和maxevents两个参数描述一个由用户分配的struct epoll event数组,调用返回时,内核将ready list复制到这个数组中,并将实际复制的个数作为返回值。注意,如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。
另外,struct epoll event结构中的events域在这里的解释是:在被监测的文件描述符上实际发生的事件。
参数timeout描述在函数调用中阻塞时间上限,单位是ms:
timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;
timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;
timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。
epoll两种模式
epoll的两种触发方式
epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。
select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。
1.水平触发的时机
对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在尚没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
2.边缘触发的时机
对于读操作
当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
当有新数据到达时,即缓冲区中的待读数据变多的时候。
当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
对于写操作
当缓冲区由不可写变为可写时。
当有旧数据被发送走,即缓冲区中的内容变少的时候。
当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
在ET模式下, 缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
举例:
读缓冲区刚开始是空的,读缓冲区写入2KB数据
水平触发和边缘触发模式此时都会发出可读信号,收到信号通知后,读取了1KB的数据,读缓冲区还剩余1KB数据
水平触发会再次进行通知,而边缘触发不会再进行通知
(可能会有数据丢失,通知机制不同)
epoll与select、poll的对比
- 用户态将文件描述符传入内核的方式
select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
- 内核态检测文件描述符读写状态的方式
select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
- 找到就绪的文件描述符并传递给用户态的方式
select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。
- 重复监听的处理方式
select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
epoll:无需重新构建红黑树,直接沿用已存在的即可。
八、epoll更高效的原因
select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。
select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。
select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中。(select,poll需要自己维护fd_set,每次需要从用户空间拷贝到内核空间 epoll自己维护)
select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。
epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符
总结:
epoll 优势
1.没有fd数量限制(也跟内存有关),好处是不会随着fd数量的增多IO的效率降低
2.epoll不需要每次将fd拷贝到内核空间,只用在需要的时候拷贝一次即可,后面复用
3.mmap技术加速用户空间和内核空间的数据访问
select
优势 跨平台
弊端
1.应用需要自己维护fd_set,每次都要将fdset从用户空间拷贝到内核空间,如果fd比较多是一个耗时操作,此外内核接受到fdset之后需要进行遍历,如果有大量的fd,但是只有少量的是活跃的fd,性能不高
2.单个进程金钩打开的fd是有上限的,linux<1024
poll
本质上和select差不多,只不过没有fd的限制,但其实跟内存也是有关系的,更重要的是,如果fd越多,io的性能是越低的
mmap基本原理和分类
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
mmap在write和read时会发生什么
1.write1.进程(用户态)将需要写入的数据直接copy到对应的mmap地址(内存copy)
2.若mmap地址未对应物理内存,则产生缺页异常,由内核处理
3.若已对应,则直接copy到对应的物理内存
4.由操作系统调用,将脏页回写到磁盘(通常是异步的)
因为物理内存是有限的,mmap在写入数据超过物理内存时,操作系统会进行页置换,根据淘汰算法,将需要淘汰的页置换成所需的新页,所以mmap对应的内存是可以被淘汰的(若内存页是"脏"的,则操作系统会先将数据回写磁盘再淘汰)。这样,就算mmap的数据远大于物理内存,操作系统也能很好地处理,不会产生功能上的问题。
2.read
从图中可以看出,mmap要比普通的read系统调用少了一次copy的过程。因为read调用,进程是无法直接访问kernel space的,所以在read系统调用返回前,内核需要将数据从内核复制到进程指定的buffer。但mmap之后,进程可以直接访问mmap的数据(page cache)。
四.redis处理流程
这三个阶段之间是通过事件机制串联了,在 Redis 启动阶段首先要注册socket连接建立事件处理器:当客户端发来建立socket的连接的请求时,对应的处理器方法会被执行,建立连接阶段的相关处理就会进行,然后注册socket读取事件处理器
当客户端发来命令时,读取事件处理器方法会被执行,对应处理阶段的相关逻辑都会被执行,然后注册socket写事件处理器
当写事件处理器被执行时,就是将返回值写回到socket中。
总结下:
用户态请求内核态
内核态创建epoll对象,创建监听树(红黑树),创建就绪队列(双向链表)
创建socket
绑定端口和socket
监听
设置非阻塞
添加描述符到监听树
阻塞等待注册的事件发生
用户去队列拿有事件的结果,如果再来一个也就是收到accept创建一个新用户socket,然后把这个socket也添加到红黑树,红黑树同时监听多个(socket).如果socket有事件的话就会将对应的信息放到队列里面去.
read只需要搞个主线程,不断轮询的从那个队列中取信息就可以了,每个元素相当于是某个socket上的对应的事件信息,取回来后一个for循环拿到每个元素进行分发处理,
只有6379这个端口才会创建或者删除,而其他连接会有处理的消息
六.redis有哪些慢操作?什么样的操作会影响它的性能
1.使用复杂度过高的命令,影响主线程
Irange key 0 -1;smembers key;hkeys key;keys * 都是全量获取数据
如何定位一条命令执行时间过长 :slowlog慢日志
配置: slowlog-log-slower-than 10000 slowlog-max-len 128;
获取慢日志:slowlog get N
解决方式:使用一种渐进式获取数据的方式SCAN ;需要业务开发人员主动规避
在Redis2.8版本之前,我们可以使用keys命令按照正则匹配得到我们需要的key。但是这个命令有两个缺点:
没有limit,我们只能一次性获取所有符合条件的key,如果结果有上百万条,那么等待你的就是“无穷无尽”的字符串输出。
keys命令是遍历算法,时间复杂度是O(N)。如我们刚才所说,这个命令非常容易导致Redis服务卡顿。
因此,我们要尽量避免在生产环境使用该命令。
在满足需求和存在造成Redis卡顿之间究竟要如何选择呢?面对这个两难的抉择,Redis在2.8版本给我们提供了解决办法——scan命令。
相比于keys命令,scan命令有两个比较明显的优势:
scan命令的时间复杂度虽然也是O(N),但它是分次进行的,不会阻塞线程。
scan命令提供了limit参数,可以控制每次返回结果的最大条数。这两个优势就帮助我们解决了上面的难题,不过scan命令也并不是完美的,它返回的结果有可能重复,因此需要客户端去重
SCAN命令的遍历顺序是
0->2->1->3
这个顺序看起来有些奇怪。我们把它转换成二进制就好理解一些了。
00->10->01->11
我们发现每次这个序列是高位加1的。普通二进制的加法,是从右往左相加、进位。而这个序列是从左往右相加、进位的。这一点我们在redis的源码中也得到印证。
在dict.c文件的dictScan函数中对游标进行了如下处理
v = rev(v);
v++;
v = rev(v);
意思是,将游标倒置,加一后,再倒置,也就是我们所说的“高位加1”的操作。
这里大家可能会有疑问了,为什么要使用这样的顺序进行遍历,而不是用正常的0、1、2……这样的顺序呢,这是因为需要考虑遍历时发生字典扩容与缩容的情况(不得不佩服开发者考虑问题的全面性)。
我们来看一下在SCAN遍历过程中,发生扩容时,遍历会如何进行。加入我们原始的数组有4个元素,也就是索引有两位,这时需要把它扩充成3位,并进行rehash。
原来挂接在xx下的所有元素被分配到0xx和1xx下。在上图中,当我们即将遍历10时,dict进行了rehash,这时,scan命令会从010开始遍历,而000和100(原00下挂接的元素)不会再被重复遍历。
再来看看缩容的情况。假设dict从3位缩容到2位,当即将遍历110时,dict发生了缩容,这时scan会遍历10。这时010下挂接的元素会被重复遍历,但010之前的元素都不会被重复遍历了。所以,缩容时还是可能会有些重复元素出现的。
2.操作bigkey 写入和删除bigkey的内存和分配和回收比较耗时,会影响主线程
定位:./redis-cli --bigkeys ;在客户端中执行如下明显: debug object key(生成环境下不建议使用); memory usage key(给出一个key和它值在RAM中占用的字节数)
实际解决方案: scan + memory usage key 应用端编写程序自动发现bigkey
如何避免产生bigkey呢?能够在业务上做一些key的拆分
redis 4.0之后提供了:lazyfree机制:lazyfree-lazy-expire yes 过期key删除后的内存释放可以异步来执行
unlink(非阻塞异步删除bigkey) 代替 del
redis 6.0 del命令也可以是异步的: lazyfree-lazy-user-del yes; del命令支持异步删除key
3.大量key集中过期:导致当前这次请求大量时间都在删除过期key,影响主线程
解决办法:将key的过期时间打散,(加一个随机时间),lazyfree-lazy-expire yes
有三种不同的删除策略:
(1):立即删除。在设置键的过期时间时,创建一个回调事件,当过期时间达到时,由时间处理器自动执行键的删除操作。
(2):惰性删除。键过期了就过期了,不管。每次从dict字典中按key取值时,先检查此key是否已经过期,如果过期了就删除它,并返回nil,如果没过期,就返回键值。
(3):定时删除。每隔一段时间,对expires字典进行检查,删除里面的过期键。
可以看到,第二种为被动删除,第一种和第三种为主动删除,且第一种实时性更高。下面对这三种删除策略进行具体分析。
立即删除
立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对cpu是最不友好的。因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力。
而且目前redis事件处理器对时间事件的处理方式–无序链表,查找一个key的时间复杂度为O(n),所以并不适合用来处理大量的时间事件。
惰性删除
惰性删除是指,某个键值过期后,此键值不会马上被删除,而是等到下次被使用的时候,才会被检查到过期,此时才能得到删除。所以惰性删除的缺点很明显:浪费内存。dict字典和expires字典都要保存这个键值的信息。
举个例子,对于一些按时间点来更新的数据,比如log日志,过期后在很长的一段时间内可能都得不到访问,这样在这段时间内就要拜拜浪费这么多内存来存log。这对于性能非常依赖于内存大小的redis来说,是比较致命的。
定时删除
从上面分析来看,立即删除会短时间内占用大量cpu,惰性删除会在一段时间内浪费内存,所以定时删除是一个折中的办法。
定时删除是:每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,来减少删除操作对cpu的影响。另一方面定时删除也有效的减少了因惰性删除带来的内存浪费。
redis使用的策略
redis使用的过期键值删除策略是:惰性删除加上定期删除,两者配合使用。
4.缓存淘汰也是在主线程中执行
当redis的内存使用达到maxmemory,每次写入的时候需要淘汰一些key
解决方法:从业务上规避(热点数据),扩容
5.网络IO操作比较慢,主线程处理网络请求的IO速度不够快,怎么办
主线程负责接收,建连请求,读事件到来(收到请求)则放到一个全局等待读处理队列
主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程,然后主线程忙等待(spinlock 的效果)状态
IO 线程将请求数据读取并解析完成(这里只是读数据和解析并不执行)
主线程执行所有命令并清空整个请求等待读处理队列(执行部分串行)
对网络事件进行监听,分发给 work thread 进行处理,处理完以后将主动权交还给 主线程,进行 执行操作,当然后续还会有,执行后的结果依然交由 work thread 进行响应数据的 socket write 操作
Redis 6.0划分了主线程和多个IO线程,IO线程负责处理网络IO操作,命令的执行仍然是由主线程完成得,
这个功能6.0默认是关闭的,需要在配置文件中开始
io-threads-do-reads no #yes代表开启io多线程
io-threads 4 #配置线程数 官方建议小于cpu的核数
七.聊一聊redis的高可用
写能力受限制是因为主宕机,slave无法写入
存储能力受限制是每个redis节点存储的数据都一样
sentinel(哨兵)
原理
三个定时任务
发现salve节点,确定主从
发布订阅
相互订阅,发送,交换对节点的看法和自身的信息
心跳检测,服务不可达的依据
主观下线和客观下线
判断有故障了,需要进行故障转移
故障转移的工作主需要一个sentinel节点去完成即可,所以,sentienl节点之间要进行选举操作,选出一个leader来完成故障转移的操作,完成故障转移后大家的身份又统一了.
sentinel节点选举
1.每个在线的sentinel都是有可能成为leader的
2.每个sentinel节点只有一张选票,也只能投给一个sentinel节点,遵循先到先得原则,所以基本上sentinel的选举就是哪个sentinel节点先完成客官下线它就是leader了(别人还在判断,它已经开始选票了,谁先问sentinel要票sentinel就给谁)
3.如果某个sentinel节点得票数>=max(quorum,num(sentinels)/2+1),name该sentinel就选举成功
4.如果在一轮选举中,没有选出leader,会进入下一轮选举(进下一个纪元)
接下来就是sentinel-leader节点完成故障转移操作
1.从已下线的主节点的从节点中选一个出来,并且升级为主节点
2.其他的从节点要去复制新的主节点的数据
3.将已下线的主节点设置为新主节点的从节点,当它重新上线之后去复制新的主节点的数据
4.要将故障转移的结果通知到其他的sentinel节点
会改配置文件的
分布式存储
假如现在有这么个业务场景,假如公司是个商城业务,商品数量很多,需要redis存贮的数据大约200G,但是,公司只有100G的机器,主从哨兵的时候就会发现其实每台redis的存贮数据都是一样的,每个redis实力都是全量存储,也就是主从结构+哨兵可以实现高可用故障切换+冗余备份,但是并不能解决数据容量的问题
根本原因:redis的数据存储是集中式的,并不是分布式的
解决办法:提供redis的分布式解决方案
分布式存储:数据分区存储,按照一定的规则,将全量的数据划分到不同的节点上,每个节点上存储全量数据的一个子集
分布式解决方案
1.客户端分区方案
优点是分区逻辑可控,缺点是需要自己处理数据路由、高可用、故障转移等问题,比如在redis2.8之前通常的做法是获取某个key的hashcode,然后取余分布到不同节点,不过这种做法无法很好的支持动态伸缩性需求,一旦节点的增或者删操作,都会导致key无法在redis中命中,这个方案真的和高可用不沾边也就不多说了。
2.代理分区方案
客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis实例,然后根据Redis的响应结果返回给客户端。
3.查询路由分区方案
客户端随机地请求任意一个redis实例,然后由Redis将请求转发给正确的Redis节点。Redis Cluster实现了一种混合形式的查询路由,但并不是直接将请求从一个redis节点转发到另一个redis节点,而是在客户端的帮助下直接redirected到正确的redis节点。
Cluster
redis定义了16384个槽(slot),编号范围:0-16383;数据key来了之后首先经过一个哈希函数算出一个值,这个值会映射到某一个slot里
客户端随机地请求任意一个redis实例,然后由Redis将请求转发给正确的Redis节点。Redis Cluster实现了一种混合形式的查询路由,但并不是直接将请求从一个redis节点转发到另一个redis节点,而是在客户端的帮助下直接redirected到正确的redis节点。
分区的缺点
有些特性在分区的情况下将受到限制:
涉及多个key的操作通常不会被支持。例如你不能对两个集合求交集,因为他们可能被存储到不同的Redis实例(实际上这种情况也有办法,但是不能直接使用交集指令)。
同时操作多个key,则不能使用Redis事务.
分区使用的粒度是key,不能使用一个非常长的排序key存储一个数据集(The partitioning granularity is the key, so it is not possible to shard a dataset with a single huge key like a very big sorted set).
当使用分区的时候,数据处理会非常复杂,例如为了备份你必须从不同的Redis实例和主机同时收集RDB / AOF文件。
分区时动态扩容或缩容可能非常复杂。Redis集群在运行时增加或者删除Redis节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。
搭建集群
1是表示一主一从
集群伸缩
八.redis分布式锁
单体项目,加入sync代码块(本地锁),可以解决库存超卖的情况
private final static Logger log = LoggerFactory.getLogger(Lock0321.class);
@Autowired
RedisTemplate<String,String> redisTemplate;
String maotai = "maotai20210321001";//茅台商品编号
@PostConstruct
public void init(){
//此处模拟向缓存中存入商品库存操作
redisTemplate.opsForValue().set(maotai,"100");
}
@GetMapping("/get/maotai")
public String seckillMaotai() {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
}
@GetMapping("/get/maotai2")
public String seckillMaotai2() {
synchronized (this) {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
}
}
本地锁在多节点下失效(集群/分布式)
**原因:**本地锁只能锁住本地JVM进程中的多个线程,对于多个JVM进程的不同线程间是锁不住的.
如何解决:我们需要一种分布式锁,期望能在分布式环境下提供锁服务,并且达到本地锁的效果:不仅能锁住同一jvm进程下的不同线程,更要能锁住不同jvm进程下的不同线程
为什么需要分布式锁:
- 为了效率:防止不同节点之间做相同的事情,浪费资源
- 为了安全:有些事情在同一时间允许一个线程去做
分布式锁的特点
- 互斥性
- 锁超时:支持锁的自动释放,防止死锁
- 正确,高效,高可用:解铃还须系铃人(加锁和解锁必须是同一个线程),加锁和解锁操作一定要高效,提供锁的服务要具备容错性
- 可重入:如果一个线程拿到了锁之后继续去获取锁还能获取到,我们称锁是可重入的(方法的递归调用)
- 阻塞/非阻塞:如果获取不到直接返回视为非阻塞的,如果获取不到会等待锁的释放直到获取锁或者等待超时,视为阻塞的
- 公平/非公平:按照请求的顺序获取锁视为公平的
基于redis实现分布式锁
setnx结合expire
if (setnx(key,value) == 1) {
expire(key,30);
try{
//业务操作
}finally{
//释放锁
del key;
}
}
@GetMapping("/get/maotai3")
public String seckillMaotai3() {
/*Boolean islock = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
redisConnection.setNX(lockey.getBytes(),"1".getBytes());
return null;
}
});*/
//获取锁
Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey, "1");
if (islock) {
//设置锁的过期时间
redisTemplate.expire(lockey,5, TimeUnit.SECONDS);
try {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
redisTemplate.delete(lockey);
}
}
return "dont get lock";
}
问题:1.setnx和expire是非原子性操作
两种解决方案
- 2.6之前可是要lua脚本
- 2.6之后可用set命令
2.错误解锁:如何保证解铃还须系铃人:给锁加唯一标识
@GetMapping("/get/maotai4")
public String seckillMaotai4() {
String requestid = UUID.randomUUID().toString() + Thread.currentThread().getId();
/*String locklua ="" +:
"if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then redis.call('expire',KEYS[1],ARGV[2]) ; return true " +
"else return false " +
"end";
Boolean islock = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean eval = redisConnection.eval(
locklua.getBytes(),
ReturnType.BOOLEAN,
1,
lockey.getBytes(),
requestid.getBytes(),
"5".getBytes()
);
return eval;
}
});*/
//获取锁
Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey,requestid,5,TimeUnit.SECONDS);
if (islock) {
try {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
//判断是自己的锁才能去释放 这种操作不是原子性的
/*String id = redisTemplate.opsForValue().get(lockey);
if (id !=null && id.equals(requestid)) {
redisTemplate.delete(lockey);
}*/
String unlocklua = "" +
"if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('del',KEYS[1]) ; return true " +
"else return false " +
"end";
redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean eval = redisConnection.eval(
unlocklua.getBytes(),
ReturnType.BOOLEAN,
1,
lockey.getBytes(),
requestid.getBytes()
);
return eval;
}
});
}
}
return "dont get lock";
}
3.锁续期/锁续命
拿到锁之后执行业务,业务的执行时间超过了锁的过期时间
如何做?
**给拿到锁的线程创建一个守护线程(看门狗),守护线程定时/延迟 ** 判断拿到锁的线程是否还继续持有锁,如果持有则为其续期
//模拟一下守护线程为其续期
ScheduledExecutorService executorService;//创建守护线程池
ConcurrentSkipListSet<String> set = new ConcurrentSkipListSet<String>();//队列
@PostConstruct
public void init2(){
executorService = Executors.newScheduledThreadPool(1);
//编写续期的lua
String expirrenew = "" +
"if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('expire',KEYS[1],ARGV[2]) ; return true " +
"else return false " +
"end";
executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Iterator<String> iterator = set.iterator();
while (iterator.hasNext()) {
String rquestid = iterator.next();
redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean eval = false;
try {
eval = redisConnection.eval(
expirrenew.getBytes(),
ReturnType.BOOLEAN,
1,
lockey.getBytes(),
rquestid.getBytes(),
"5".getBytes()
);
} catch (Exception e) {
log.error("锁续期失败,{}",e.getMessage());
}
return eval;
}
});
}
}
},0,1,TimeUnit.SECONDS);
}
@GetMapping("/get/maotai5")
public String seckillMaotai5() {
String requestid = UUID.randomUUID().toString() + Thread.currentThread().getId();
//获取锁
Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey,requestid,5,TimeUnit.SECONDS);
if (islock) {
//获取锁成功后让守护线程为其续期
set.add(requestid);
try {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
//seckillMaotai5();
//模拟业务超时
TimeUnit.SECONDS.sleep(10);
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//解除锁续期
set.remove(requestid);
//释放锁
String unlocklua = "" +
"if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('del',KEYS[1]) ; return true " +
"else return false " +
"end";
redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean eval = redisConnection.eval(
unlocklua.getBytes(),
ReturnType.BOOLEAN,
1,
lockey.getBytes(),
requestid.getBytes()
);
return eval;
}
});
}
}
return "dont get lock";
}
4.如何支持可冲入
重试次数/过期时间
获取 获取 获取 释放 释放 释放
基于本地实现
//支持锁重入 代码示例,仅作为思路参考,并非生产实践 切记
private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key,String value,String expiration) {
Map<String, Integer> lockers = LOCKERS.get();
if (lockers.containsKey(key)) {
lockers.put(key, lockers.get(key) + 1);
return true;
} else {
if (tryLock(key,value,expiration)) {
lockers.put(key, 1);
return true;
}
}
return false;
}
// 解锁
public void unlock(String key,String clientid) {
Map<String, Integer> lockers = LOCKERS.get();
if (lockers.getOrDefault(key, 0) <= 1) {
lockers.remove(key);
//DEL key
tryUnlock(key,clientid);
} else {
lockers.put(key, lockers.get(key) - 1);
}
}
public boolean tryLock(String lockName,String clientId,String expiration) {
String locklua = "" +
"if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then redis.call('expire',KEYS[1],ARGV[2]) ; return true " +
"else return false " +
"end";
Boolean islock = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean lock = redisConnection.eval(
locklua.getBytes(),
ReturnType.BOOLEAN,
1,
lockName.getBytes(),
clientId.getBytes(),
expiration.getBytes()
);
return lock;
}
});
return islock;
}
public boolean tryUnlock(String lockName,String clientid) {
String unlocklua = "" +
"if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('del',KEYS[1]); return true " +
"else return false " +
"end";
Boolean unlock = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean unlock = redisConnection.eval(unlocklua.getBytes(),
ReturnType.BOOLEAN,
1,
lockName.getBytes(),
clientid.getBytes());
return unlock;
}
});
return unlock;
}
基于redis但是更换了数据类型,采用hash类型来实现
key field value
锁key 请求id 重入次数
用lua实现
5.阻塞/非阻塞的问题:现在的锁是非阻塞的,一旦获取不到锁直接返回了
如何做一个阻塞锁呢?
获取不到就等待锁的释放,直到获取到锁或者等待超时
- 基于客户端轮询的方案
- 基于redis的发布/订阅方案
或者用Redisson
@Value("${spring.redis.host}")
String host;
@Value("${spring.redis.port}")
String port;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://"+host+":"+port);
return Redisson.create(config);
}
@Autowired
RedissonClient redissonClient;
@GetMapping("/get/maotai6")
public String seckillMaotai6() {
//要去获取锁
RLock lock = redissonClient.getLock(lockey);
lock.lock();
try {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
//如果还有库存
if (count > 0) {
//抢到了茅台,库存减一
redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
//后续操作 do something
log.info("我抢到茅台了!");
return "ok";
}else {
return "no";
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();;
}
return "";
}
Redisson源码
Redisson的源码剖析
1 ,加锁的(是否支持重入)
2,锁续期的
3,阻塞获取
4,释放
redisson_lock__channel:{maotailock}
源码如下:
1,加锁
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
#如果锁key不存在
"if (redis.call('exists', KEYS[1]) == 0) then " +
#设置锁key,field是唯一标识,value是重入次数
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
#设置锁key的过期时间 默认30s
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
#如果锁key存在
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
#重入次数+1
"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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
2,锁续期
private void scheduleExpirationRenewal(final long threadId) {
if (expirationRenewalMap.containsKey(getEntryName())) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
续期函数的真正实现
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
expirationRenewalMap.remove(getEntryName());
if (!future.isSuccess()) {
log.error("Can't update lock " + getName() + " expiration", future.cause());
return;
}
if (future.getNow()) {
reschedule itself 再次调用自己,最终形成的结果就是每隔10秒续期一次
scheduleExpirationRenewal(threadId);
}
}
});
}
internalLockLeaseTime=30 1000 即30秒
}, internalLockLeaseTime 3, TimeUnit.MILLISECONDS); 303=10秒后异步执行续期函数
if (expirationRenewalMap.putIfAbsent(getEntryName(), new ExpirationEntry(threadId, task)) != null) {
task.cancel();
}
}
续期的lua脚本:判断key,field存在则重置过期时间
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), 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.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
4,阻塞锁实现
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(leaseTime, unit, threadId);
lock acquired
if (ttl == null) {
return;
}
如果没有获取到锁,则订阅:redisson_lock__channel:{key} 频道
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
尝试再获取一次
ttl = tryAcquire(leaseTime, unit, threadId);
lock acquired
if (ttl == null) {
break;
}
waiting for message 阻塞等待锁订阅频道的消息,一旦锁被释放,就会得到信号通知,继续尝试获取锁
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
获取到锁后取消订阅
unsubscribe(future, threadId);
}
get(lockAsync(leaseTime, unit));
}
5,解锁
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
key已经不存在了,则向redisson_lock__channel:{key}频道发布锁释放消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
hash 中的field 不存在时直接返回,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
重入次数-1后如果还大于0,延长过期时间
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
重入次数-1后如果归0,则删除key,并向redisson_lock__channel:{key}频道发布锁释放消息
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}