Redis 过期机制回答的是一个键什么时候应当停止可见,而不是整个服务最多使用多少内存。截止时间属于键,后续命令可能保留、替换或者移除它。因此,可靠地使用 TTL,既要检查首次写入,也要覆盖更新路径。看到写命令成功,不能证明之前设置的截止时间仍然存在,更不能直接推断数据最终会按原计划消失。
截止时间不是自动滑动计时器
EXPIRE 使用秒,PEXPIRE 使用毫秒。Redis 将过期信息保存为绝对 Unix 时间戳,所以服务器停止运行期间,时间仍然继续流逝。系统时钟必须稳定,跨机器恢复数据时尤其需要注意时钟差异。它不是独立于系统时间变化的单调时长承诺。下面的实验只使用正常流逝的时间,不调整本机系统时钟。
GET 不会刷新过期时间。INCR 这类原地修改保留键已有的超时;普通成功的 SET 则替换值,并清除原来的超时。SET KEEPTTL 可以保留原期限,再次显式设置过期命令则会改变截止时间。需要滑动会话的应用必须主动实现续期策略,不能以为用户每次读取数据都会自然延长访问窗口。
当值和初始寿命属于同一次操作时,应使用 SET EX 或 SET PX 一起写入。分成 SET、EXPIRE 两条命令,会留下一个值已经存在、期限却尚未写入的失败窗口。客户端如果恰好在两条命令之间崩溃,就可能留下持久条目。这是写入协议缺陷,不是后台过期周期执行得太慢,调整清理频率也修复不了它。
检查真正存在的状态
TTL 返回剩余秒数;键存在但没有过期时间时返回 -1,键不存在时返回 -2。PTTL 提供毫秒分辨率,PEXPIRETIME 则返回绝对截止时间。要验证一次读取有没有续期,比较绝对期限比比较两次倒计时更可靠,因为两条查询之间时间正常流逝,本来就可能让剩余数值减少。查询结果只是当时的快照,不保证键一定能存活到那个时刻。
EXPIRE NX 只在没有期限时添加期限。GT 只接受更晚的截止时间,其中持久键被当成无限寿命处理。PERSIST 则显式移除过期设置。调用方需要检查命令返回的结果,不能把“命令被接受”理解成“条件成立且状态已经改变”。相对过期时间为零或负数时,键会立即被删除,并不是改成永久保存的另一种写法。
坏掉的样子
被动过期在访问时检查截止时间,主动过期负责处理那些过期后没有再被访问的键。物理清理延迟,不等于普通主节点 GET 可以持续交付已经过期的值。反过来,条目被移除,也不保证分配器马上把页面交还操作系统。读取可见性、数据结构清理与进程 RSS 是不同观察对象,不能只看其中一个就替其他两个下结论。
TTL 不是硬内存预算。写入速率、值的大小、尚未到期的数据、命令开销和分配器行为都会影响占用;逐出还可能让键在期限到来之前消失。业务授权不能只检查缓存键是否存在,也不能从单实例实验推导复制或故障切换保证。那些工作流仍需明确自己的业务截止时间、读写路径及一致性边界,分别建立验收证据。
快速验证
准备 POSIX 环境、C 编译器、make、curl、tar 和 Python 3.10 或更新版本。此前记录在 macOS 上使用 Python 3.14.4、Redis 8.10.1 与 redis-py 8.1.0;本轮没有重跑。命令只用 libc 编译 Redis 核心,并在临时虚拟环境安装客户端,不进行系统级安装,也不编译附带的可选模块。
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
在同一个 shell 中,用 "$redis_lab/venv/bin/python" 执行 Python 代码。它在 /tmp 的专用临时目录下创建 Unix socket,启动独立 Redis,并关闭 TCP 监听、定时快照和追加日志。请求不会连接默认的本机 Redis。结束时只关闭自己创建的进程、移除自己的临时运行目录;下载阶段需要外网,实验交互不需要网络服务。
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance(policy="noeviction"):
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", policy,
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=True,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
assert client.ttl("missing") == -2
client.set("session", "A", px=60000)
deadline = client.pexpiretime("session")
assert client.get("session") == "A"
assert client.pexpiretime("session") == deadline
client.set("session", "B", keepttl=True)
assert client.pexpiretime("session") == deadline
print("GET and SET KEEPTTL preserve the absolute deadline")
client.set("session", "C")
assert client.ttl("session") == -1
assert client.expire("session", 60, nx=True)
assert not client.expire("session", 120, nx=True)
assert client.expire("session", 120, gt=True)
assert not client.expire("session", 30, gt=True)
assert client.persist("session")
assert client.ttl("session") == -1
print("plain SET clears TTL; NX, GT and PERSIST follow their conditions")
client.set("counter", "1", px=60000)
deadline = client.pexpiretime("counter")
assert client.incr("counter") == 2
assert client.pexpiretime("counter") == deadline
assert client.expire("counter", 0)
assert client.get("counter") is None
print("INCR preserves TTL; EXPIRE zero deletes immediately")
client.pexpire("session", 100)
limit = time.monotonic() + 3
while client.get("session") is not None:
assert time.monotonic() < limit, "key did not expire"
time.sleep(0.01)
assert client.ttl("session") == -2
print("after expiry: GET is None and TTL is -2")
绝对截止时间在 GET 和 SET KEEPTTL 之后保持不变;普通 SET 则使 TTL 变成 -1。条件过期的断言区分了成功更新与条件不满足,PERSIST 又把条目恢复为无期限状态。另一个计数器执行 INCR 后仍保留原期限,EXPIRE 零则让它立即不可读取,证明两种写入语义不能混用。
最后,一百毫秒超时使 GET 返回 None,TTL 返回 -2。程序通过有上限的轮询观察实际消失,不要求操作系统恰好在某个毫秒调度检查。这些结果验证命令语义和普通的过期读取,不证明物理回收时点、主动清理吞吐量、复制行为、故障切换或已经被其他地方复制走的数据是否安全销毁。
延伸阅读
来源
- Redis EXPIRE,期限更新、条件选项、时钟及主动与被动过期,已核验 2026-10-15。
- Redis SET,值与寿命的原子写入及 KEEPTTL,已核验 2026-10-15。
- Redis TTL,剩余寿命及 -1、-2 的返回含义,已核验 2026-10-15。
- Redis 8.10.1 编译说明,仅编译核心及分配器选择,已核验 2026-10-15。