参考:
1.概念
额,为什么的话,建议先了解下我这篇文章。
我们在并发场景下,区分一个场景是否有并发问题,个人理解,锁的场景需要考虑:
- 共享:是否共享某个资源
- 竞态:如何构建竞态关系
首先,我们得拎清楚它到底会不会共享,不是说多线程它就必然要有并发问题。
比如,上面链接文章中的例子,我开多个线程不停发请求,数据都不涉及到共享,它就不会有并发问题。
然后,对于并发场景,我们大体上是乐观锁和悲观锁两种思想,他们都不是具体的锁,只是一种思想。
-
乐观锁:乐观的态度,我觉得不一定会出现问题。
-
悲观锁:悲观的态度,我觉得一定有问题,我得预防好。
这里,可以参考我这篇文章中的例子:Java-并发-synchronized
比如,我们在线编辑钉钉的表格,因为主管通知我们把手底下负责的厂商同事人天单价从一千更新到一千三。
我们刚准备改,此时,领导突然找我们有事情。
- 乐观锁:没想啥先去找领导了,回来一看,哇靠,刚才表里一千谁给我更新成一千万了,肯定谁动我电脑了。
- 悲观锁:md我怕有人乱动我电脑,我找个人给我守着,休想乱动。
当然,乐观悲观一定有好坏吗?不一定,都是结合场景来的。
然后,基于悲观锁,从例子中也可以思考到,我们的目的都是强制占用某个资源。
又回到了初学时经典的上厕所例子,对吧,我们必须要把门锁了,才能安心。
Java中,实现锁的机制有很多,比如基础的synchronized
,又比如后面来了个ReentrantLock
。
但是他们都存在一个问题,就是始终只能在我们的JVM
中,比如一个是Java程序,一个是Python程序,我搞不了啊?
分布式场景下也是一样的,核心原因就在于,咱们锁这个资源,它共享不了了。
有办法吗?当然就是标题,分布式锁,我们找一个同时能访问的第三者,构建出一个共享+竞态的条件。
就比如我们的Redis。嗯,终于扯出来了。
一个最基本的分布式锁需要满足:
- 互斥(竞态):任意一个时刻,锁只能被一个线程持有。
- 可重入:一个节点获取了锁之后,还可以再次获取锁。
- 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
除了基本条件外,最好再具备以下两点特性:
- 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
- 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。
常见的分布式锁实现方案:
- 基于Redis
- 基于ZooKeeper
- ...
本文着重介绍Redis下的分布式锁。
2.实现
2.1 简单原型
构建Redis分布式锁,最简单的场景即是使用String类型的SETNX命令。
SETNX
的全称是"SET if Not eXists",即"如果不存在则设置"。
语法:
SETNX key value
# key:要设置的键
# value:要设置的值
返回:
# 返回1:键不存在并且设置了键值对
# 返回0:键已经存在
2.1.1 加锁
直接使用setnx k v
即可。
2.1.2 解锁
使用del
命令删除对应k即可。
2.2 锁有效期
为了避免锁无法释放,我们一般都要给锁设置一个过期时间(注意原子性)。
redis的set命令支持这个场景
SET key value NX EX 3
-
SET key value
:设置键Key
的值为value
。 -
NX
:仅在键不存在时设置键。这确保了只有在lockKey
不存在时,才会设置它的值,从而实现互斥锁的效果。 -
EX 3
:设置键的过期时间为 3 秒。这确保了即使客户端在持有锁期间崩溃,锁也会在 3 秒后自动释放,防止死锁。
SET命令语法详细解释
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
# 比较抽象是吧 []代表可选 可选参数名字对了就行 顺序不重要
# [NX | XX]:表示可以是NX或者XX
# [GET]
# [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
# 表示可以是:EX PX EXAT PXAT KEEPTTL
参数 | 说明 | 示例 | 结果 |
---|---|---|---|
NX | 仅在键不存在时设置键 | SET mykey "value" NX | 只有在 mykey 不存在时,才会设置值为 "value" |
XX | 仅在键已经存在时设置键 | SET mykey "value" XX | 只有在 mykey 已经存在时,才会设置值为 "value" |
GET | 设置新值并返回设置之前的旧值 | SET mykey "newvalue" GET | 返回设置前的值,并将 mykey 的值设置为 "newvalue" |
EX seconds | 设置键的过期时间为 seconds 秒 |
SET mykey "value" EX 10 | 键 mykey 将在 10 秒后过期 |
PX milliseconds | 设置键的过期时间为 milliseconds 毫秒 |
SET mykey "value" PX 10000 | 键 mykey 将在 10000 毫秒(10 秒)后过期 |
EXAT unix-time-seconds | 设置键的过期时间为 Unix 时间戳 unix-time-seconds |
SET mykey "value" EXAT 1672531199 | 键 mykey 将在指定的 Unix 时间戳过期 |
PXAT unix-time-milliseconds | 设置键的过期时间为 Unix 时间戳 unix-time-milliseconds |
`SET mykey "value" PXAT 1672531199000 | 键 mykey 将在指定的 Unix 时间戳过期(以毫秒为单位) |
KEEPTTL | 保留键的现有 TTL(Time to Live) | SET mykey "value" KEEPTTL | 键 mykey 的值被设置为 "value" ,但保持原有的过期时间不变 |
SpringBoot程序示例,接入的话,可以参考我这篇文章:Redis-12-SpringBoot集成Redis哨兵模式
package cn.yang37.za.controller;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Slf4j
@SpringBootTest
class RedisControllerTest {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void name1() {
final String key = "yang37";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "123", 600, TimeUnit.SECONDS);
log.info("flag: {}", flag);
String value = stringRedisTemplate.opsForValue().get(key);
log.info("value: {}", value);
Long expire = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
log.info("expire: {}", expire);
}
}
再次运行,因为key已经存在,set将返回false。
2.3 锁的续期
上面讲了为了避免锁不能释放,一般我们都会设置有效期。
- 时间设置短
比如,我的事务平时执行10s妥妥够了,我没细想,给有效期设置的15s。
有天网比较卡,这个事务执行了30s之久,我都准备去走解锁的代码了,结果,告诉我,锁已经没了。
没了就没了呗,反正它释放了,我没解锁就算了。但是,但是!你忘了我们加锁的本心了吗?确保当前事务能独占资源。
在15s后,锁自动释放,别人抢到锁了,它来搞你的的独占资源了!
- 时间设置长
哈哈,那我直设置个三万八千秒。嗯,那我们设置有效期的本心又又丢失了,假设当前线程出问题没释放。锁又不释放,其他人拿不到锁,大家都别玩了?
所以,难就难在,这个有效期我们是很难把控的。
理想的状态是,如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了。
Java已经有了现成的解决方案:Redisson
2.3.1 Redisson
基本框架搭建可以参考:Redis-12-SpringBoot集成Redis哨兵模式(Lettuce或Redisson)
先来搭建一个基本demo。
@Slf4j
@RestController
@RequestMapping("/redis")
public class RedisController {
@Resource
private StringRedisTemplate stringRedisTemplate;
@GetMapping("/set/{key}/{value}/{runTime}")
public String do1(@PathVariable String key, @PathVariable String value, @PathVariable Integer runTime) {
log.info("key: {},value: {}", key, value);
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(key, value, 30, TimeUnit.SECONDS);
// 拿到锁
if (Boolean.TRUE.equals(ifAbsent)) {
// 模拟业务执行n秒
exec(runTime);
}
log.info("end...");
return "ok";
}
private static void exec(Integer runTime) {
try {
for (int i = 0; i <= runTime; i++) {
log.info("running {} s", i);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
log.error("sleep exception!", e);
}
}
}
这个例子中,get请求的路径为:
/redis/set/{key}/{value}/{runTime}
其中,锁时间固定为30秒,runTime模拟实际业务运行,路径传入。
-
正常情况下,我们传入一个运行时间小于30s的,例如20s。
在http-nio-9595-exec-7
运行的期间内,其他请求哪怕进来,如ttp-nio-9595-exec-6
,由于拿不到锁,也执行不了exce()
业务n秒的操作。
- 锁超时情况下,我们传入一个运行时间大于30s的,例如50s。
在30s后,此时http-nio-9595-exec-3
的exec
方法还未执行完成,再发起一个请求。
由于锁已经自动过期了,新请求http-nio-9595-exec-2
将能拿到锁,并开始执行exec方法,两个请求的exec方法同时开始执行,产生潜在的并发问题。
1.使用
使用Redission进行锁续期的时候,无非就是我们把exec的运行时间设置的比较长,然后观察执行期间,锁是否会到期,即有没有自动续期。
更新下代码,由于用到锁,改为使用RedissonClient。
@Slf4j
@RestController
@RequestMapping("/redis")
public class RedisController {
@Autowired
private RedissonClient redissonClient;
@GetMapping("/set/{key}/{value}/{runTime}")
public String do1(@PathVariable String key, @PathVariable String value, @PathVariable Integer runTime) {
RLock lock = redissonClient.getLock(key);
try {
// 尝试获取锁,等待时间为10秒
boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
if (isLocked) {
try {
// 模拟业务执行n秒
exec(runTime);
} finally {
lock.unlock();
}
} else {
log.info("Unable to acquire lock");
}
} catch (InterruptedException e) {
log.error("run error", e);
Thread.currentThread().interrupt();
}
return "ok";
}
private static void exec(Integer runTime) {
try {
for (int i = 0; i <= runTime; i++) {
log.info("running {} s", i);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
log.error("sleep exception!", e);
Thread.currentThread().interrupt();
}
}
}
使用很简单啊,就是:
RLock lock = redissonClient.getLock(key);
// 注意啊,这里根本没传redis中的过期时间,10s是咱们springboot里程序尝试获取锁时的超时时间。
boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
运行一下,我们把运行时间传大一点。
程序运行60s,打开redis看下,注意这里的过期时间25s。
刷新下,时间增加到29s了。
可以看到,这个锁的过期时间,确实是在自动的更新。
所以,你应该大概有个概念了。
- 使用锁续期的时候,没有传入锁过期时间,Redission每次默认增加一个30s的过期时间。
- 程序运行期间,Redission会不停的运行,帮我们不停的把过期时间刷新到30s,直到我们的业务代码运行完成(完成了肯定就是触发释放锁了呗)。
好,这里要注意一下Redission的tryLock方法,是有好几个重载方法的。
需要自动续期的话,我们要使用这一个。
你可能疑惑,用锁的时候,咋还有个尝试获取锁的超时时间?
嗯,这不就是咱们synchronized的缺点之一吗,锁的获取不能中断,即后面的升级版ReentrantLock为什么要有tryLock的形式。
假设我们是用户,我发起一个请求,你的代码里锁拿不到就死等,然后呢,那边拿到锁的逻辑执行时间又长,就阻塞着?跟你傻等一小时?
你可以阅读下我这些文章:
Java-并发-wait()、notify()和notifyAll()以及线程的状态
最后,总结下Redission的lock和tryLock方法。
只有未指定redis中的锁超时时间leaseTime,才会使用到自动续期机制。
方法签名 | 参数 | 描述 | 锁是否续期 |
---|---|---|---|
lock() | 无 | 立即获取锁,如果锁不可用,则阻塞直到锁可用。 | 是(通过 watchdog 自动续期,默认30秒) |
lock(long leaseTime, TimeUnit unit) | leaseTime:锁时间 unit:时间单位 |
获取锁,并在指定租期时间后自动释放。 | 否 |
tryLock() | 无 | 立即尝试获取锁,如果锁不可用,则返回 false。 | 是(通过 watchdog 自动续期,默认30秒) |
tryLock(long time, @NotNull TimeUnit unit) | time:等待锁最长时间 unit:时间单位 |
在指定等待时间内尝试获取锁,锁将一直保持直到显式解锁。 | 是(通过 watchdog 自动续期,默认30秒) |
tryLock(long waitTime, long leaseTime, TimeUnit unit) | waitTime:等待锁最长时间 leaseTime:锁时间 unit:时间单位 |
在指定等待时间内尝试获取锁,并在获得锁后保持指定的租期。 | 否 |
2.原理
Redission的锁续期是依赖一个看门狗(Watch Dog)的机制。
看门狗专门用来监控和续期锁,如果操作共享资源的线程还未执行完成的话,看门狗会不断地延长锁的过期时间,进而保证锁不会因为锁自动到期而被释放。
嗯,机制说起来很简单哈,就是我不停的检查你执行的代码,看有没有完成,没完成我就给你把对应的锁时间更新掉。
- 怎么创建看门狗?
- 怎么续期?
2.1 锁的格式
注意我们前面的代码中的value字段没用上了,灰色的,我们获取锁的时候只传了个键名。
// yang37
RLock lock = redissonClient.getLock(key);
boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
嗯,可以看到,在Redis中,Redission就是根据我们传入的key,自动创建了一个Hash类型的键。
是在 Redisson 中,每个 Java 进程(每个 Redisson 客户端实例)都会生成一个唯一的 ID。这个 ID 是在客户端启动时生成的,用于唯一标识该客户端实例。
哎呀,就是你程序不是起码两个节点吗?都76号线程咋办,机器1上的76线程和机器2上的76线程能一样呀?那肯定要标明具体哪个呀,就这个用处。
# 客户端实例ID + 线程 ID
key: e0da9704-ebb4-4e9f-a6e4-ca490f176c42:76
# 可重入次数
value: 1
2.2 怎么创建看门狗
Redisson 的看门狗机制并不是通过一个显式的 "创建看门狗" 方法来实现的,而是通过在锁的获取和持有过程中自动启动的定时任务来完成的。
定时任务由 scheduleExpirationRenewal
方法启动。
好,我们以lock()方法为例。
lock -> tryAcquire
tryAcquire -> tryAcquireAsync
tryAcquireAsync -> scheduleExpirationRenewal
框框里,也指明了自动续期的情况。
- 固定租期:如果指定了
leaseTime
,则锁会在该固定时间后自动释放,无需看门狗机制。 - 自动续期:如果未指定
leaseTime
(即else),则启动看门狗机制,自动续期锁的过期时间,确保锁在持有期间不会因超时而被释放。
scheduleExpirationRenewal -> renewExpiration
最后,在scheduleExpirationRenewal方法中触发了renewExpiration方法,即我们下一节讲的,具体的续期操作。
2.3 看门狗怎么续期
续期的方法,主要是在RedissonBaseLock
这个抽象类中的renewExpiration()
方法。
上节中,我们的RedissonLock
类在最后就是直接调用了这个来自父类的方法。
大体上看下,我们知道,它是一个定时定时任务。
=
private void renewExpiration() {
ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(
new TimerTask() {/**/}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS
);
ee.setTimeout(task);
}
}
先留个印象。
- EXPIRATION_RENEWAL_MAP :用来管理所有需要续期的锁的一个数据结构,它存储了每个锁的续期信息,即 ExpirationEntry对象。
- ExpirationEntry对象:包含了锁的续期任务和相关的线程信息。
比如我们启动的时候debug下:
renewExpiration
方法通过从续期映射中获取当前锁的续期条目,并创建一个定时任务,每隔租期的三分之一时间执行一次,检查并异步续期锁的过期时间。
-
如果续期成功,递归调用自身;
-
如果续期失败,记录错误日志并取消续期任务,以确保锁在持有期间不会因超时而被释放。
private void renewExpiration() {
// 从 EXPIRATION_RENEWAL_MAP 获取锁的过期条目
ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
// 创建一个定时任务,每隔 internalLockLeaseTime / 3 的时间执行一次
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
// 再次从 EXPIRATION_RENEWAL_MAP 获取锁的过期条目
ExpirationEntry ent = (ExpirationEntry)RedissonBaseLock.EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName());
if (ent != null) {
// 获取持有锁的第一个线程ID
Long threadId = ent.getFirstThreadId();
if (threadId != null) {
// 异步续期锁的过期时间,续期具体的操作在这里!!!
CompletionStage<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
// 如果续期失败,记录错误日志并从 EXPIRATION_RENEWAL_MAP 移除该锁条目
RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e);
RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName());
} else {
if (res) {
// 如果续期成功,递归调用 renewExpiration 方法,再次设置续期任务
RedissonBaseLock.this.renewExpiration();
} else {
// 如果续期失败,取消续期任务
RedissonBaseLock.this.cancelExpirationRenewal((Long)null);
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
// 设置续期任务
ee.setTimeout(task);
}
}
嗯,上面看到了续期的具体操作。
if (threadId != null) {
// 异步续期锁的过期时间,续期具体的操作在这里!!!
CompletionStage<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId); future.whenComplete((res, e) -> {
// ...
好,那么renewExpirationAsync(threadId)是啥样?
就是调用了一个lua脚本命令。
return this.evalWriteAsync(
this.getRawName(), // 锁的键名,例如 "myLock"
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(this.getRawName()), // 锁的键名,传递给 KEYS[1]。例如:yang37
this.internalLockLeaseTime, // 锁的新的过期时间,传递给 ARGV[1]。例如:30000ms
this.getLockName(threadId) // 锁的字段名,通常包含线程ID,传递给 ARGV[2]。例如:37f644b6-8e05-4eb0-afe2-a56bb2ce6fce:83
);
解释下redis的lua脚本中,基本的参数意义。
-
KEYS:键名,可以在脚本中通过索引访问。
-
ARGV:参数列表,可以在脚本中通过索引访问。
逻辑如下:
检查 Redis 中是否存在特定锁,例如:yang37 + 37f644b6-8e05-4eb0-afe2-a56bb2ce6fce:83
- 如果存在,则更新该锁的过期时间,并返回
1
表示续期成功; - 如果不存在,则返回
0
表示续期失败。
-- KEYS[1] 是锁的键名。例如:yang37
-- ARGV[1] 是锁的新的过期时间,以毫秒为单位。例如:30000ms
-- ARGV[2] 是锁的字段名,通常包含线程ID,以唯一标识持有锁的线程。例如:37f644b6-8e05-4eb0-afe2-a56bb2ce6fce:83
-- 如果 Redis 哈希表 KEYS[1] 中存在字段 ARGV[2],即检查锁是否存在
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 如果存在,则将哈希表 KEYS[1] 的过期时间设置为 ARGV[1] 毫秒
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回 1 表示锁的续期操作成功
return 1;
end;
-- 如果锁不存在,返回 0 表示锁的续期操作失败
return 0;
2.4 锁的可重入
最后来到了tryLockInnerAsync
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return this.evalWriteAsync(
this.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(this.getRawName()),
new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)}
);
}
-- KEYS[1]: 锁的键名,即 this.getRawName()。例如:yang37
-- ARGV[1]: 锁的过期时间(租期),以毫秒为单位,即 unit.toMillis(leaseTime)。例如:30000
-- ARGV[2]: 锁的字段名,包括线程ID,即 this.getLockName(threadId)。:例如:017adcbf-08d9-449d-8c93-c82819799842:84
以下逻辑:
-
如果锁不存在,创建锁并设置过期时间。
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]);
Redis 命令解释
# exists
语法:EXISTS key
功能:检查指定的键是否存在。
返回值:如果键存在,返回 1;否则返回 0。
示例:
redis.call('exists', 'myKey') -- 检查键 'myKey' 是否存在
# hincrby
语法:HINCRBY key field increment
功能:为哈希表中的指定字段的整数值加上增量值(increment)。如果字段不存在,则在执行加法操作前将其设为 0。
返回值:执行加法操作后,字段的值。
示例:
redis.call('hincrby', 'myHash', 'myField', 1) -- 将哈希表 'myHash' 中字段 'myField' 的值增加 1
# pexpire
语法:PEXPIRE key milliseconds
功能:设置键的过期时间,以毫秒为单位。
返回值:如果设置了过期时间,返回 1;如果键不存在或无法设置过期时间,返回 0。
示例:
redis.call('pexpire', 'myKey', 60000) -- 设置键 'myKey' 的过期时间为 60000 毫秒(60 秒)
# hexists
语法:HEXISTS key field
功能:检查哈希表中的指定字段是否存在。
返回值:如果字段存在,返回 1;否则返回 0。
示例:
redis.call('hexists', 'myHash', 'myField') -- 检查哈希表 'myHash' 中字段 'myField' 是否存在
# pttl
语法:PTTL key
功能:返回键的剩余生存时间,以毫秒为单位。
返回值:
- 如果键存在且设置了过期时间,返回剩余生存时间(以毫秒为单位)。
- 如果键存在但没有设置过期时间,返回 -1。
- 如果键不存在,返回 -2。
示例:
redis.call('pttl', 'myKey') -- 返回键 'myKey' 的剩余生存时间(以毫秒为单位)