TTL、PTTL:获取键的剩余生存时间

在为键设置了生存时间或者过期时间之后,用户可以使用 TTL 命令或者 PTTL 命令查看键的剩余生存时间,即键还有多久才会因为过期而被移除。

其中,TTL命令将以秒为单位返回键的剩余生存时间:

TTL key

而PTTL命令则会以毫秒为单位返回键的剩余生存时间:

PTTL key

作为例子,以下代码展示了如何使用TTL命令和PTTL命令获取msg键的剩余生存时间:

redis> TTL msg
(integer) 297 -- msg键距离被移除还有297s
redis> PTTL msg
(integer) 295561 -- msg键距离被移除还有295561ms

没有剩余生存时间的键和不存在的键

如果给定的键存在,但是并没有设置生存时间或者过期时间,那么TTL 命令和PTTL命令将返回-1:

redis> SET song_title "Rise up, Rhythmetal"
OK
redis> TTL song_title
(integer) -1
redis> PTTL song_title
(integer) -1

如果给定的键并不存在,那么TTL命令和PTTL命令将返回-2:

redis> TTL not_exists_key
(integer) -2
redis> PTTL not_exists_key
(integer) -2

TTL命令的精度问题

在使用TTL命令时,有时候会遇到命令返回0的情况:

redis> TTL msg
(integer) 0

出现这种情况的原因在于TTL命令只能返回秒级精度的生存时间,所以当给定键的剩余生存时间不足1s时,TTL命令只能返回0作为结果。这时,如果使用精度更高的PTTL命令去检查这些键,就会看到它们实际的剩余生存时间,表12-6非常详细地描述了这一情景。

image 2025 01 05 16 34 36 212
Figure 1. 表12-6 PTTL命令在TTL命令返回0时仍然可以检测到键的剩余生存时间

其他信息

  • 复杂度:TTL命令和PTTL命令的复杂度都为O(1)。

  • 版本要求:TTL命令从Redis 1.0.0版本开始可用,PTTL命令从Redis 2.6.0版本开始可用。

示例:自动过期的登录会话

在第3章,我们了解到了如何使用散列去构建一个会话程序。正如图 12-1所示,当时的会话程序会使用两个散列分别存储会话的令牌以及过期时间戳。这种做法虽然可行,但是存储过期时间戳需要消耗额外的内存,并且判断会话是否过期也需要用到额外的代码。

在学习了Redis的自动过期特性之后,我们可以对会话程序进行修改,通过给会话令牌设置过期时间来让它在指定的时间之后自动被移除。这样一来,程序只需要检查会话令牌是否存在,就能够知道是否应该让用户重新登录了。

代码清单12-4展示了修改之后的会话程序。因为Redis的自动过期特性只能对整个键使用,所以这个程序使用了字符串而不是散列来存储会话令牌,但总的来说,这个程序的逻辑与之前的会话程序的逻辑基本相同。不过由于新程序无须手动检查会话是否过期,所以它的逻辑简洁了不少。

image 2025 01 05 16 35 36 255
Figure 2. 图12-1 会话程序创建的散列数据结构
代码清单12-4 带有自动过期特性的会话程序:/expire/login_session.py
import random
from hashlib import sha256

# 会话的默认过期时间
DEFAULT_TIMEOUT = 3600*24*30    # 一个月

# 会话状态
SESSION_NOT_LOGIN_OR_EXPIRED = "SESSION_NOT_LOGIN_OR_EXPIRED"
SESSION_TOKEN_CORRECT = "SESSION_TOKEN_CORRECT"
SESSION_TOKEN_INCORRECT = "SESSION_TOKEN_INCORRECT"

def generate_token():
    """
    生成一个随机的会话令牌。
    """
    random_string = str(random.getrandbits(256)).encode('utf-8')
    return sha256(random_string).hexdigest()


class LoginSession:

    def __init__(self, client, user_id):
        self.client = client
        self.user_id = user_id
        self.key = "user::{0}::token".format(user_id)

    def create(self, timeout=DEFAULT_TIMEOUT):
        """
        创建新的登录会话并返回会话令牌,
        可选的 timeout 参数用于指定会话的过期时间(以秒为单位)。
        """
        # 生成会话令牌
        token = generate_token()
        # 储存令牌,并为其设置过期时间
        self.client.set(self.key, token, ex=timeout)
        # 返回令牌
        return token

    def validate(self, input_token):
        """
        根据给定的令牌验证用户身份。
        这个方法有三个可能的返回值,分别对应三种不同情况:
        1. SESSION_NOT_LOGIN_OR_EXPIRED —— 用户尚未登录或者令牌已过期
        2. SESSION_TOKEN_CORRECT —— 用户已登录,并且给定令牌与用户令牌相匹配
        3. SESSION_TOKEN_INCORRECT —— 用户已登录,但给定令牌与用户令牌不匹配
        """
        # 获取用户令牌
        user_token = self.client.get(self.key)
        # 令牌不存在
        if user_token is None:
            return SESSION_NOT_LOGIN_OR_EXPIRED
        # 令牌存在并且未过期,那么检查它与给定令牌是否一致
        if input_token == user_token:
            return SESSION_TOKEN_CORRECT
        else:
            return SESSION_TOKEN_INCORRECT

    def destroy(self):
        """
        销毁会话。
        """
        self.client.delete(self.key)

以下代码展示了这个会话程序的基本使用方法:

>>> from redis import Redis
>>> from login_session import LoginSession
>>> client = Redis(decode_responses=True)
>>> uid = "peter"
>>> session = LoginSession(client, uid) # 创建会话
>>> token = session.create() # 创建令牌
>>> token
'89e77eb856a3383bb8718286802d32f6d40e135c08dedcccd143a5e8ba335d44'
>>> session.validate("wrong token") # 验证令牌
'SESSION_TOKEN_INCORRECT'
>>> session.validate(token)
'SESSION_TOKEN_CORRECT'
>>> session.destroy() # 销毁令牌
>>> session.validate(token) # 令牌已不存在
'SESSION_NOT_LOGIN_OR_EXPIRED'

为了演示这个会话程序的自动过期特性,我们可以创建一个有效期非常短的令牌,并在指定的时间后再次尝试验证该令牌:

>>> token = session.create(timeout=3) # 创建有效期为3s的令牌
>>> session.validate(token) # 3s内访问
'SESSION_TOKEN_CORRECT'
>>> session.validate(token) # 超过3s之后,令牌已被自动销毁
'SESSION_NOT_LOGIN_OR_EXPIRED'

示例:自动淘汰冷门数据

本章开头在介绍EXPIRE命令和PEXPIRE命令的时候曾经提到过,当用户对一个已经带有生存时间的键执行EXPIRE命令或PEXPIRE命令时,键原有的生存时间将被新的生存时间取代。值得一提的是,这个特性可以用于淘汰冷门数据并保留热门数据。

举个例子,第6章曾经介绍过如何使用有序集合来实现自动补全功能,但是如果仔细分析这个自动补全程序,就会发现它有一个潜在的问题:为了实现自动补全功能,程序需要创建大量自动补全结果,而补全结果的数量越多、体积越大,需要耗费的内存也会越多。

为了尽可能地节约内存,一个高效的自动补全程序应该只存储热门关键字的自动补全结果,并移除无人访问的冷门关键字的自动补全结果。要做到这一点,其中一种方法就是使用第6章介绍过的排行榜程序,为用户输入的关键字构建一个排行榜,然后定期地删除排名靠后的关键字的自动补全结果。

排行榜的方法虽然可行,但是却需要用程序定期删除自动补全结果,使用起来相当麻烦。一个更方便也更优雅的方法,就是使用EXPIRE命令和PEXPIRE命令的更新特性去实现自动的冷门数据淘汰机制。为此,我们可以修改自动补全程序,让它在每次处理用户输入的时候,为相应关键字的自动补全结果设置生存时间。这样一来,对于用户经常输入的那些关键字,它们的自动补全结果的生存时间将会不断得到更新,从而产生出一种“续期”效果,使得热门关键字的自动补全结果可以不断地存在下去,而冷门关键字的自动补全结果则会由于生存时间得不到更新而自动被移除。

经过上述修改,自动补全程序就可以在无须手动删除冷门数据的情况下,通过自动的数据淘汰机制达到节约内存的目的,代码清单12-5展示了修改后的自动补全程序。

代码清单12-5 能够自动淘汰冷门数据的自动补全程序:/expire/auto_complete.py
class AutoComplete:

    def __init__(self, client):
        self.client = client

    def feed(self, content, weight=1, timeout=None):
        """
        根据用户输入的内容构建自动补全结果,
        其中 content 参数为内容本身,而可选的 weight 参数则用于指定内容的权重值,
        至于可选的 timeout 参数则用于指定自动补全结果的保存时长(单位为秒)。
        """
        for i in range(1, len(content)):
            key = "auto_complete::" + content[:i]
            self.client.zincrby(key, weight, content)
            if timeout is not None:
                self.client.expire(key, timeout)  # 设置/更新键的生存时间

    def hint(self, prefix, count):
        """
        根据给定的前缀 prefix ,获取 count 个自动补全结果。
        """
        key = "auto_complete::" + prefix
        return self.client.zrevrange(key, 0, count-1)

在以下代码中,我们同时向自动补全程序输入了 "Redis" 和 "Coffee" 这两个关键字,并分别为它们的自动补全结果设置了10s的生存时间:

>>> from redis import Redis
>>> from auto_complete import AutoComplete
>>> client = Redis(decode_responses=True)
>>> ac = AutoComplete(client)
>>> ac.feed("Redis", timeout=10); ac.feed("Coffee", timeout=10) # 同时执行两个调用

然后在10s之内,我们再次输入 "Redis" 关键字,并同样为它的自动补全结果设置10s的生存时间:

>>> ac.feed("Redis", timeout=10)

现在,在距离最初的feed()调用执行十多秒之后,如果我们执行 hint()方法,并尝试获取"Re"前缀和"Co"前缀的自动补全结果,那么就会发现,只有"Redis"关键字的自动补全结果还保留着,而"Coffee"关键字的自动补全结果已经因为过期而被移除了:

>>> ac.hint("Re", 10)
['Redis']
>>> ac.hint("Co", 10)
[]

表12-7完整地展示了在执行以上代码时,"Redis"关键字的自动补全结果是如何进行续期的,而"Coffee"关键字的自动补全结果又是如何被移除的。在这个表格中,"Redis"关键字代表的就是热门数据,而"Coffee"关键字代表的就是冷门数据:一直有用户访问的热门数据将持续地存在下去,而无人问津的冷门数据则会因为过期而被移除。

image 2025 01 05 16 42 20 944
Figure 3. 表12-7 冷门数据淘汰示例
image 2025 01 05 16 42 37 578

除了自动补全程序之外,我们还可以把这一机制应用到其他需要淘汰冷门数据的程序中。为了做到这一点,我们必须理解上面所说的“不断更新键的生存时间,使得它一直存在”这一原理。