Redis Expire

实际应用场景中经常会涉及时效性的数据,即数据在一定时间后或特定时间点后不再有效。例如,长期未访问的内容应当认为不再有价值,用户会话通常在一定的不活跃时间后被销毁以保障安全,计算的临时数据应当及时销毁。

上述场景的过期数据,虽然可以由开发者手动编写代码来处理和清除,但是,一方面,数据的过期本身是一个典型的模式,要求应用重复编写类似代码不仅累赘,还容易出错,另一方面,在 Redis 的特定场景下,应用和 Redis 服务器的通信也会导致应用主动发起的清理逻辑性能不佳甚至遇到网络分区影响过期的生效。因此,在 Redis 服务器一侧提供自动移除过期数据的功能,能够在开发的便利性和应用的正确性上带来很大的提升。

本文将从 Redis 提供的自动过期相关的原语、实际应用的例子和背后的实现原理三个方面介绍 Redis 的自动过期功能。

自动过期的原语

自动过期的动作自然地对应到设定数据的过期时间上,Redis 提供了四个基本的原语来支持这一自然语言到 Redis 行为的映射。

  1. EXPIRE key seconds 设定给定的键在给定秒数后过期
  2. PEXPIRE key milliseconds 设定给定的键在给定毫秒数后过期
  3. EXPIREAT key seconds_timestamp 设定给定的键在秒级精度的 UNIX 时间戳之后过期
  4. PEXPIREAT key milliseconds_timestamp 设定给定的键在毫秒级精度的 UNIX 时间戳之后过期

这四个原语从概念上均可统一到 PEXPIREAT 上,而在实际的 Redis 发展历史中,EXPIRE 是随发布即存在的原语,EXPIREAT 在 1.2.0 中引入,另外两个原语在 2.6.0 中引入,也许这就是为什么有秒级和毫秒级的区分的原因吧。

关于这四个原语的返回值以及对过期时间的影响,除去上面的自然语言描述,其中的以为微妙的点列举如下。

  • 返回 1 代表成功设置过期时间,0 代表键不存在,设置失败。值得一提的是, EXPIREAT 设置一个早于当前时间戳的时间能够成功,这相当于立即过期给定的键。
  • 重新执行以上原语将更新键的过期时间。例如,EXPIRE msg 10 之后,经过数秒并在 msg 过期之前再次执行 EXPIRE msg 10 将更新键 msg 的过期时间。这可以作为 Keep-Alive 的一种实现方向。
  • 可以看到,上面提及数据过期时均以键为论述对象,这是因为 Redis 提供的数据过期支持是在键的粒度上的。对于简单的字符串来说,这符合直觉;对于其他数据结构来说,则要注意这是对键关联的整个数据结构的过期时间设置。Redis 并不支持针对例如散列特定字段的过期时间设置。

如果应用想要完全取消之前设置的过期时间,也就是说,即取消过期时间,又不像上面所说的重新设置过期时间,Redis 在 2.2.0 以后提供了一个原语来实现这个目的。

  • PERSIST key 取消给定的键的过期时间

该原语返回 1 代表取消成功,0 代表键不存在或者键没有设置过期时间。

对于字符串来说,调用 SET 原语将覆盖原来的键,当然也就重置了过期时间的设置。

在为键设置了过期时间之后,一个自然的需求就是获取键的生存时间(Time to live,TTL),Redis 为此提供了两个原语。

  1. TTL key 以秒为单位返回给定的键的生存时间
  2. PTTL key 以毫秒为单位返回给定的键的生存时间

对于未设置的过期时间的情况,返回 -1 表示给定的键存在,返回 -2 表示给定的键不存在。

随着过期时间在 Redis 使用场景的实践,大家发现写入一个字符串并设置它的过期时间是一个极其常见的需求。

SET key value
EXPIRE key seconds

同时,使用两条命令来完成这个功能在分布式系统三态(成功、失败、超时)的背景下不是原子的。也就是有可能写入了字符串,但是后续设置过期时间却失败了。对于实现简易的分布式锁或者其他对过期强依赖的场景来说,这是致命的错误。

基于此观察,Redis 在 2.6.12 以后提供了 SET 原语的两个选项来支持在写入字符串的同时设置它的过期时间。

  • SET key value [EX seconds] [PX milliseconds]

这一增强只实现在 SET 原语上。

自动过期的应用实例

上面提到的自动释放的分布式锁可以用 Redis 的自动过期特性来实现,以下代码出自《Redis 使用手册》。

VALUE_OF_LOCK = "locking"

class TimingLock:

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

  def acquire(self, timeout):
    """
    尝试获取一个使用实现为 timeout 的锁,timeout 单位为秒。
    成功时返回 True,失败时返回 False
    """
    result = self.client.set(self.key, VALUE_OF_LOCK, ex=timeout, nx=True)
    return result is not None

  def release(self):
    """
    尝试释放锁。
    成功时返回 True,失败时返回 False
    """
    return self.client.delete(self.value) == 1

这段代码是分布式锁的简易实现,release 方法的调用并未检查锁的持有情况,这依赖于调用约定。由于 Redis 仅提供一些基础的支持,在使用单个键的前提下,在极端的竞争情况下,即使满足调用约定,也可能会出现不理想的情况。如果要实现更准确的保证,则需要引入其他键,而协同这些键会引入更多的复杂性。总的来说,在容忍一定的不准确或不公平的情况下,利用 Redis 实现简易的分布式锁是可以的。

另外一个场景是自动过期的用户令牌。

DEFAULT_TIMEOUT = 1 * 60 * 60 # 1 hour

class TokenAssigner:

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

  def create(self, user_id):
    token = generate_token()
    self.client.set("user_id::{token}", user_id, ex=self.timeout)
    return token

  def access(self, input_token):
    key = "user_id::{input_token}"
    user_id = self.client.get(key)
    if user_id is None:
      return None

    succ = self.client.expire(key, self.timeout)
    return user_id if succ else None

用户登录时调用 create 方法创建新的令牌,随后用户可凭令牌作为身份验证,后台通过 access 方法由令牌得到用户的标识符。

对于用户登出,可以同时传入令牌并显式删除 Redis 上令牌的键值对。对于用户修改密码等需要失效此前所有创建令牌的场景,则需要引入额外的复杂度。例如,新增一个用户密码任期的键,并将令牌键对应的值由密码任期和用户标识符编码而成,验证令牌时解码值并校验密码任期;或者引入可逆的编码算法,将用户密码作为参数参与令牌到键的映射的计算。

自动过期的实现原理

Redis 的实现代码堪称学习 C 语言编程的绝佳材料,逻辑并不复杂,涉及到 C 语言高级(High-Level)编程的方方面面。关于 Redis 中涉及的 C 语言编程的知识,以及其中使用 C 语言实现一系列典型数据机构的内容,计划有另一系列文章来做介绍。本节我们只会涉及到抽象的数据结构概念。

Redis 服务器维护一个 redisDb 结构保存跟 Redis 数据相关的方方面面的信息,其中所有的键值对均由一个 Redis 实现的字典 dict 管理。我们平常写入和读取数据基本都是对这个字典进行操作。为了支持过期功能,Redis 并未在 dict 字典上新增信息,而是在 redisDb 中维护了一个专门的过期字典(expires)来保存所有设置了过期时间的键及其过期时间。

过期字典的键是一个指向数据库某个键对象的指针,也就是索引到某个具体的键值对;过期字典的值是一个 long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间,表示为一个毫秒精度的 UNIX 时间戳。

当客户端执行设置过期时间的命令时,Redis 服务器会在数据库的过期字典中关联给定的数据库键和过期时间。如前所述,不同过期时间的命令都会统一到唯一的实现上,具体转换过程大致如下。

/* This is the generic command implementation for EXPIRE, PEXPIRE, EXPIREAT
 * and PEXPIREAT. Because the commad second argument may be relative or absolute
 * the "basetime" argument is used to signal what the base time is (either 0
 * for *AT variants of the command, or the current time for relative expires).
 *
 * unit is either UNIT_SECONDS or UNIT_MILLISECONDS, and is only used for
 * the argv[2] parameter. The basetime is always specified in milliseconds. */
void expireGenericCommand(client *c, long long basetime, int unit);

/* EXPIRE key seconds */
void expireCommand(client *c) {
    expireGenericCommand(c,mstime(),UNIT_SECONDS);
}

/* EXPIREAT key time */
void expireatCommand(client *c) {
    expireGenericCommand(c,0,UNIT_SECONDS);
}

/* PEXPIRE key milliseconds */
void pexpireCommand(client *c) {
    expireGenericCommand(c,mstime(),UNIT_MILLISECONDS);
}

/* PEXPIREAT key ms_time */
void pexpireatCommand(client *c) {
    expireGenericCommand(c,0,UNIT_MILLISECONDS);
}

实际执行该方法时,过期字典将新增一个过期时间。其他原语(PERSIST/TTL/PTTL)的实现是比较显然的。

通过过期字典,程序可以在处理给定键的请求的时候按照下面的流程检查给定键是否过期。

  1. 检查给定键是否存在于过期字典。如果存在,那么取得过期时间;否则,返回不过期。
  2. 检查当前系统 UNIX 时间戳是否大于键的过期时间。如果是,返回键已过期;否则,返回不过期。

从概念上说,我们可以通过 TTL/PTTL 命令来实现这一逻辑。不过这样的方案虽然概念上比较简洁,也许还符合所谓的封装原则,但是这样的方案会产生一条新的命令。由于引入过期时间后我们对每个给定键的请求都要检查一遍过期字典,直接内联实现代码是实现上性能与冗余的交换。

现在,我们了解了自动过期在 Redis 服务器数据库的实现方式,也知道了在请求给定键的时候如何检查是否过期,那么还有一个问题是,过期的键的清理是如何实现的呢?

总的来说,过期删除有两个实现方向,一个是主动删除,另一个是被动删除。主动删除指的是服务器追踪过期字典中的键的过期时间,主动的删除过期的键。具体实现形式可以是对每个过期命令都关联一个定时器(timer)并在触发时移除过期的键,或者定时对数据库进行扫描以清除其中的过期键。被动删除指的是在请求给定键时如果发现键已过期,则进行清除,此时可以认为是客户端主动触发了键的删除。

这三种策略的优劣如下。

定时删除策略即基于定时器的删除策略对于内存是最友好的,因为它保证过期键尽可能快的被删除,从而释放过期键占用的内存。但是这样的策略对 CPU 是最不友好的,因为它以键为粒度调用 CPU 执行过期删除的过程。一旦某个时间段内有大量的键过期,CPU 将有可能被抢占完成删除过期键的任务,这有可能导致 Redis 对外服务的性能明显降低。

此外,为了支持定时器按照过期时间的优先级先后被处理,并且支持动态修改过期时间,我们不能使用列表这样的数据结构,否则无序列表在查找最近过期时间的定时器需要扫描全链表,有序列表在插入和更改时的调整时间在最坏情况下也是 O(N) 量级的。在其他优先级调度实现,例如 Flink 的 Timer 实现中,通常是使用优先级队列(具体地说,堆)来组织定时器。这不仅带来了额外的概念复杂度和实际的数据结构内部的指针开销,由于 Redis 本身使用过期字典维护过期键的集合,这样的实现方式将要求逻辑代码协同两个数据结构,而这将大大增加代码的复杂度。

因此,无论是性能上考虑,还是改良性能带来的复杂度考虑,定时删除在 Redis 删除过期键的场景下都不是好的策略。

被动删除策略对 CPU 是最友好的,它仅在用户请求给定键时,如果给定键过期,才进行删除。这样可以保证服务器的 CPU 时间不会被不必要的过期键删除所占用。不过它的问题也是非常明显的,自动过期的一个常见场景是用户对给定的键不再感兴趣,因此用户可能在过期之后根本就不再访问给定的键。如果完全采用被动删除的策略,将有可能导致大量过期键残留在服务器数据库中。这不仅对内存极不友好,还会影响快照的质量。

软件开发中一项重要的技能就是折中(trade-off)。在上面的讨论中,我们看到定时删除和被动删除各有各的问题,但也有自己的长处,在现实的问题处理中,我们从来没有限定说必须只能用某一种策略,而是可以组合不同的思路和实现。定期删除即通过运行定期任务(cron)来删除过期键就是思路上的组合。

定期删除策略每隔一段时间执行一次过期删除键的操作,并且限制单次扫描处理的键的数量,避免定期任务运行时 Redis 服务器对外不可用。定期策略是一种主动删除的策略,不同于定时策略,它的触发条件是特定的时间点,这是 Redis 数据库粒度的属性而不是键粒度的属性。由于它是主动的删除策略,因此即使过期键不再被请求,仍然可以被清除,不会成为孤儿。

实际的 Redis 实现中,过期策略是被动删除和定期删除的组合,这是实现上的组合。也就是说,在过期的键被请求时,服务器会将它删除,同时服务器定期运行过期键删除的任务来清理过期键。

这其中还需要考量的点包括以下几点。

  • 直观的,定期策略如何定期,每次执行允许处理的键的数量阈值是多少。这是一个非常玄学的参数问题。如果过于频繁或者开销太大,会陷入定时策略的缺陷中;反之,如果太长,则内存可能被更多的垃圾占据。
  • 其次,过期删除的同步性。这一点是更细致的优化,即我们未必要在被动删除时立即执行键值对的删除甚至空间回收,而是及时响应客户端,并将删除逻辑放到后台执行。当然,异步的执行会涉及到更多的同步即访问共享对象的问题,这里不做展开。
  • 过期逻辑与数据库快照的结合。
  • 过期逻辑与分布式 Redis 的结合。

最后,关于删除过期键相关代码在源代码中的位置,这里不做粘贴,仅留下方法索引。对应 Redis 6.0.3 版本代码,不同版本代码位置可能有变,名称变化应该不大。

  • 过期字典的定义位于 server.c/keyptrDictType
  • 被动删除的逻辑位于 db.c/expireIfNeeded
  • 定期删除的逻辑位于 expire.c/activeExpireCycle
编辑于 2021-11-15 15:07