Redis 深入了解键的过期时间

在实际开发中经常会遇到一些有时效的数据,比如,限时优惠活动,缓存或验证码等,过了一定时间就需要删除这些数据。在关系性数据库中一般需要额外的一个字段记录到期时间,然后定期检测删除过期数据。在 Redis 中提供了键的过期时间这个功能来解决这个问题。通过这个功能,可以让特定的键在指定的时间之后自动删除,而不需要手动执行删除操作。

1. 设置生存(过期)时间

Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除):

  • 生存时间
    • EXPIRE 命令用于将键的生存时间设置为 ttl 秒,即保存 ttl 秒后被删除。
    • PEXPIRE 命令用于将键的生存时间设置为 ttl 毫秒,即保存 ttl 毫秒后被删除。
  • 过期时间
    • EXPIREAT 命令用于将键的过期时间设置为 timestamp 所指定的秒数时间戳,即在 timestamp 秒时间戳过期。
    • PEXPIREAT 命令用于将键的过期时间设置为 timestamp 所指定的毫秒数时间戳,即在 timestamp 毫秒时间戳过期。

虽然有多种不同单位和不同形式的设置命令,但实际上 EXPIRE、PEXPIRE、EXPIREAT 三个命令都是使用 PEXPIREAT 命令来实现的:

(1) EXPIRE 命令转换成 PEXPIRE 命令:

def EXPIRE(key, ttl_in_sec):
# 将TTL从秒转换成毫秒
ttl_in_ms = sec_to_ms(ttl_in_sec)
PEXPIRE(key,ttl_in_ms)

(2) PEXPIRE 命令转换成 PEXPIREAT 命令:

def PEXPIRE(key, ttl_in_ms):
# 获取以毫秒计算的当前UNIX时间戳
now_ms = get_current_unix_timestamp_in_ms()
# 当前时间加上TTL,得出毫秒格式的键过期时间
PEXPIREAT(key, now_ms + ttl_in_ms)

(3) EXPIREAT 命令转换成 PEXPIREAT 命令:

def EXPIREAT(key, expire_time_in_sec):
# 将过期时间从秒转换为毫秒
expire_time_in_ms = sec_to_ms(expire_time_in_sec)
PEXPIREAT(key, expire_time_in_ms)

通过 EXPIRE 命令或者 PEXPIRE 命令,可以以秒级或者毫秒级为某个键设置生存时间(TimeToLive,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除这个键:

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> expire k1 5 // 设置生存时间为5s,即5s后过期
(integer) 1
127.0.0.1:6379> get k1 // 5s 之内执行
"v1"
127.0.0.1:6379> get k1 // 5s 之外执行
(nil)
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> pexpire k1 5000 // 设置生存时间为5000ms,即5000ms后过期
(integer) 1
127.0.0.1:6379> get k1 // 5000ms 之内执行
"v1"
127.0.0.1:6379> get k1 // 5000ms 之外执行
(nil)

SETEX 命令可以在设置一个字符串键的同时为键设置过期时间,其原理与 EXPIRE 命令设置过期时间的原理是完全一样的。

EXPIREAT 命令或 PEXPIREAT 命令,可以以秒级或者毫秒级为某个键设置过期时间(expire time)。过期时间是一个 UNIX 时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键:

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> expireat k1 1636988400 // 设置过期时间戳(秒)为 1636988400,即到达 2021-11-15 23:00:00 后过期
(integer) 1
127.0.0.1:6379> get k1 // 设置时间点之前执行
"v1"
127.0.0.1:6379> get k1 // 设置时间点之后执行
(nil)
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> pexpireat k1 1636988580000 // 设置过期时间戳(毫秒)为 1636988580000,即到达 2021-11-15 23:03:00 后过期
(integer) 1
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k1
(nil)

2. 保存过期时间

redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,这个字典称为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
  • 过期字典的值是一个 longlong 类型的整数,这个整数保存了键所指向的数据库键的过期时间,一个毫秒精度的 UNIX 时间戳。
typedef struct redisDb {
// ...
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// 过期字典,保存着键的过期时间
dict *expires;
// ...
} redisDb;

下图展示了一个带有过期字典的数据库例子,在这个例子中,键空间保存了数据库中的所有键值对(在这展示了两个键值对):

  • 第一个键值对的键为 a 键对象,数据类型为一个 List
  • 第二个键值对的键为 b 键对象,数据类型为一个 Hash

而过期字典则保存了数据库键的过期时间,在这保存了两个键值对:

  • 第一个键值对的键为 a 键对象,值为 1637071200000,这表示键 a 的过期时间为 1637071200000(2021-11-16 22:00:00)。
  • 第二个键值对的键为 b 键对象,值为 1637071800000,这表示键 b 的过期时间为 1637071800000(2021-11-16 22:10:00)。

为了展示方便,上图的键空间和过期字典中重复出现了两次 a 键对象 和 b 键对象。在实际中,键空间的键和过期字典的键都指向同一个键对象,所以不会出现任何重复对象,也不会浪费任何空间。

当客户端执行 PEXPIREAT 命令(或者其他三个会转换成 PEXPIREAT 命令的命令)为一个键设置过期时间时,服务器会在数据库的过期字典中关联给定的键和过期时间。举个例子,如果在服务器执行以下命令之后:

127.0.0.1:6379> set c c-v
OK
127.0.0.1:6379> pexpireat c 1637077800000 // 设置过期时间戳(毫秒)为 1637077800000,即到达 2021-11-16 23:50:00 后过期
(integer) 0
127.0.0.1:6379> get c
"c-v"

过期字典将新增一个键值对,其中键为 c 键对象,而值则为 1637077800000(2021-11-16 23:50:00),如下图所示。

以下是 PEXPIREAT 命令的伪代码定义:

def PEXPIREAT(key, expire_time_in_ms):
# 如果给定的键不存在于键空间,那么不能设置过期时间
if key not in redisDb.dict:
return 0
# 在过期字典中关联键和过期时间
redisDb.expires[key] = expire_time_in_ms
# 过期时间设置成功
return 1

3. 查看过期时间

如果想知道一个键还有多久时间会被删除,可以使用 TTL 或者 PTTL 命令,TTL 命令以秒为单位返回键的剩余生存时间,而 PTTL 命令则以毫秒为单位返回键的剩余生存时间:

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> expire k1 30 // 设置生存时间为30s,即30s后过期
(integer) 1
127.0.0.1:6379> ttl k1 // 使用 ttl 命令查看 k1 的过期时间
(integer) 27
127.0.0.1:6379> pttl k1 // 使用 pttl 命令查看 k1 的过期时间
(integer) 20807
127.0.0.1:6379> get k1 // 30s 之内执行
"v1"
127.0.0.1:6379> get k1 // 30s 之外执行
(nil)
127.0.0.1:6379> ttl k1
(integer) -2

随着时间的流逝,k1 键的生存时间逐渐减少,30 秒后会被删除。当键不存在时 TTL(PTTL) 命令会返回 -2。

TTL 和 PTTL 两个命令都是通过计算键的过期时间和当前时间之间的差来实现的,以下是这两个命令的伪代码实现:

def PTTL(key):
# 键不存在于数据库
if key not in redisDb.dict:
return -2
# 尝试取得键的过期时间
# 如果键没有设置过期时间,那么 expire_time_in_ms 将为 None
expire_time_in_ms = redisDb.expires.get(key)
# 键没有设置过期时间
if expire_time_in_ms is None:
return -1
# 获得当前时间
now_ms = get_current_unix_timestamp_in_ms()
# 过期时间减去当前时间,得出的差就是键的剩余生存时间
return (expire_time_in_ms - now_ms)

def TTL(key):
# 获取以毫秒为单位的剩余生存时间
ttl_in_ms = PTTL(key)
if ttl_in_ms < 0:
# 处理返回值为-2-1的情况
return ttl_in_ms
else:
# 将毫秒转换为秒
return ms_to_sec(ttl_in_ms)

当键不存在时,返回 -2;当键存在但没有设置剩余生存时间时,返回 -1;否则,以秒为单位,返回 key 的剩余生存时间。在 Redis 2.8 以前,当键不存在,或者键没有设置剩余生存时间时,命令都返回 -1。

举个例子,对于一个过期时间为 1637077800000(2021-11-16 23:50:00)的键 c 来说,如果当前时间为 1637077200000(2021-11-16 23:40:00):

  • 对键 c 执行 PTTL 命令将返回 600000,这个值是通过用 c 键的过期时间减去当前时间计算得出的:1637077800000 - 1637077200000 = 600000。
  • 对键 c 执行TTL命令将返回 600,这个值是通过计算 c 键的过期时间减去当前时间的差,然后将差值从毫秒转换为秒之后得出的。

4. 清除过期时间

如果想取消键的过期时间,可以使用 PERSIST 命令,如下所示:

127.0.0.1:6379> EXPIRE k 50
(integer) 1
127.0.0.1:6379> GET k
"v"
127.0.0.1:6379> PERSIST k
(integer) 1
127.0.0.1:6379> TTL k
(integer) -1

PERSIST 命令就是 PEXPIREAT 命令的反操作:在过期字典中查找给定的键,然后解除键和值(过期时间)在过期字典中的关联。如果数据库当前的状态如上图所示,那么当服务器执行以下命令之后:

127.0.0.1:6379> PERSIST b
(integer) 1

那么数据库的状态将更新为如下图所示:

可以看到,当 PERSIST 命令执行之后,过期字典中原来的 b 键值对消失了,这代表数据库键 b 的过期时间已经被移除。以下是 PERSIST 命令的伪代码定义:

def PERSIST(key):
# 如果键不存在,或者键没有设置过期时间,那么直接返回
if key not in redisDb.expires:
return 0
# 移除过期字典中给定键的键值对关联
redisDb.expires.remove(key)
# 键的过期时间移除成功
return 1

从上我们可以知道如果过期时间被成功的清除则返回 1,否则返回 0。

5. 注意

5.1 覆盖命令会清除过期时间

在使用 DEL、SET、GETSET 等会覆盖键对应值的命令时,如果操作一个设置了过期时间的键,则对应键的过期时间会被清除:

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> expire k1 30 // 设置生存时间为30s,即30s后过期
(integer) 1
127.0.0.1:6379> get k1 // 30s 之内执行
"v1"
127.0.0.1:6379> set k1 v2 // 30s 之内执行 重新赋值会清除过期时间
OK
127.0.0.1:6379> get k1 // 30s 之内执行
"v2"
127.0.0.1:6379> ttl k1 // 30s 之内执行
(integer) -1
127.0.0.1:6379> get k1 // 30s 之外执行
"v2"

5.2 修改命令不会清除过期时间

在使用 INCR、LPUSH、HSET 等只是修改键的值,而不是覆盖整个值的命令时,不会清除键的过期时间:

127.0.0.1:6379> set k1 1
OK
127.0.0.1:6379> expire k1 30 // 设置生存时间为30s,即30s后过期
(integer) 1
127.0.0.1:6379> get k1 // 30s 之内执行
"1"
127.0.0.1:6379> ttl k1 // 查看过期时间
(integer) 19
127.0.0.1:6379> incr k1 // 自增1
(integer) 2
127.0.0.1:6379> ttl k1 // 过期时间没有被清除
(integer) 8

5.3 RENAME命令转移老键的过期时间到新键上

使用 RENAME 命令会转移老键的过期时间到新键上。在使用例如:RENAME k1 k2 命令将 k1 重命名为 k2,不管 k2 有没有设置过期时间,新的键 k2 将会继承 k1 的所有特性:

127.0.0.1:6379> set k1 v1 ex 120 // 设置生存时间为120s,即120s后过期
OK
127.0.0.1:6379> set k2 v2 ex 60 // 设置生存时间为60s,即60s后过期
OK
127.0.0.1:6379> ttl k1
(integer) 113
127.0.0.1:6379> ttl k2
(integer) 53
127.0.0.1:6379> rename k1 k2 // k1 重命名 k2
OK
127.0.0.1:6379> ttl k2
(integer) 103

欢迎关注我的公众号和博客:

参考:

赏几毛白!