Redis 有序集合保存唯一的字节串成员,每个成员关联一个数值分数。分数决定排序,但分数本身可以重复。这个模型可以用于排行榜和时间索引,却不会把分数自动解释成过期时间。成员是否存在、处于哪个名次、什么时候清理,是需要分别定义边界的三件事,不能因为数据有序就假定这些动作已经一并完成。

成员不能重复,分数可以相同

ZADD 在同一条命令内插入新成员,或者更新已有成员的分数。重复使用同一个成员名不会产生第二条记录。默认返回值统计新增成员数,所以返回零也可能表示分数已经成功更新。加上 CH 后,返回值才统计新增成员以及确实改变了分数的更新;不能把原分数不变的重复写入算作一次实际变化。

NX 只允许新增成员,XX 只允许更新已有成员。GT 限制替换后的分数必须更高,但单独使用它仍然允许插入新成员。如果契约是“只提高已有成员的分数”,就需要同时指定 XX 和 GT,并检查执行结果,而不是把命令没有语法错误等同于排行榜发生了变化。是否允许新成员入榜和是否允许降分,是两项不同的条件。

ZRANGE 默认按分数从低到高排列,同分时再按成员的二进制字节字典序排序,不是按插入先后,也不是按本地语言的排序规则。REV 会同时反转分数顺序和同分成员顺序。名次从零开始,按名次查询的两端都包含在结果内,因此从零到九最多得到十个成员。ZRANK 和 ZREVRANK 分别报告对应方向上的位置。

名次和分数是两套坐标

按名次查询选择的是位置,ZRANGE BYSCORE 选择的是数值范围。分数边界默认包含自身,加左括号前缀后才排除该端点;按分数查询再使用 REV 时,需要先给上界、再给下界。无论采用哪一种坐标,查询都不会预留成员,也不会冻结后续分页。两次正常读取之间插入一个成员,就可能让后面的所有名次发生偏移。

分数使用双精度浮点数。从负的二的五十三次方到正的二的五十三次方,这个闭区间内的整数都能被精确表示;超过范围后,相邻整数就可能映射到同一个分数。因此,如果要求每一纳秒都可区分,就不能直接把原始纪元纳秒时间当分数。精确标识可以放在成员数据中,分数采用什么单位、允许多大范围,应由业务精度要求明确决定。

坏掉的样子

按分数查询只是读取。存入时间戳,再查询最近一段窗口,不会自动删除更老的成员,也不会创建成员 TTL。ZREMRANGEBYSCORE 才会显式删除指定分数区间,键级过期则会删除整个集合。应用必须安排清理触发点,并明确截止分数本身是保留还是删除;如果没有清理动作,旧成员完全可能持续积累。

有序访问也不意味着返回多少结果都耗费相同时间。ZRANGE 的文档复杂度为 O(log N + M),其中 M 是返回成员数;按分数范围删除的成本同样取决于实际删除数量。超大响应和集中清理依然占用执行时间,不能用“已经排好序”忽略它们。分页不是快照,单条 ZADD 的原子性也不会把另外几条读取、判断和写入自动合成一个事务。

快速验证

原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 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

在同一个终端中用 "$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():
    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", "noeviction",
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                             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.zadd("board", {"ada": 10, "ben": 20, "cy": 20}) == 3
    assert client.zrange("board", 0, -1) == [b"ada", b"ben", b"cy"]
    assert client.zrange("board", 0, 1, desc=True) == [b"cy", b"ben"]
    assert client.zrank("board", "ada") == 0
    assert client.zrevrank("board", "cy") == 0
    print("rank starts at zero; REV reverses scores and bytewise tie order")

    assert client.zadd("board", {"ada": 30}) == 0
    assert client.zcard("board") == 3
    assert client.zrange("board", 0, 0, desc=True) == [b"ada"]
    assert client.zadd("board", {"ben": 25}, xx=True, gt=True, ch=True) == 1
    assert client.zadd("board", {"ben": 24}, xx=True, gt=True, ch=True) == 0
    assert client.zadd("board", {"new": 99}, xx=True, gt=True, ch=True) == 0
    assert client.zscore("board", "ben") == 25
    assert client.zscore("board", "new") is None
    assert client.zadd("admission", {"new": 99}, gt=True) == 1
    print("updates keep cardinality; XX GT CH gates updates; GT alone admits new members")

    requested = 2**53 + 1
    client.zadd("precision", {"event": requested})
    observed = int(client.zscore("precision", "event"))
    assert observed == 2**53 and observed != requested
    print(f"score precision: requested {requested}, stored {observed}")

    client.zadd("window", {"old": 100, "edge": 200, "fresh": 300})
    assert client.zrange("window", 200, "+inf", byscore=True) == [
        b"edge", b"fresh",
    ]
    assert client.zrange("window", "(200", "+inf", byscore=True) == [b"fresh"]
    assert client.zcard("window") == 3
    assert client.ttl("window") == -1
    assert client.zremrangebyscore("window", "-inf", "(200") == 1
    assert client.zrange("window", 0, -1) == [b"edge", b"fresh"]
    print("score queries do not expire members; explicit exclusive-bound cleanup removes old")

    client.zadd("pages", {"a": 10, "b": 20, "c": 30})
    first = client.zrange("pages", 0, 1)
    client.zadd("pages", {"new": 5})
    second = client.zrange("pages", 2, 3)
    assert first == [b"a", b"b"] and second == [b"b", b"c"]
    print("rank pagination is not a snapshot: inserting before page two repeats b")

第一组结果证明名次从零开始,倒序查询也会反转同分次序。更新 Ada 的分数返回零,却把它移动到了第一位,成员数量仍然不变。XX GT CH 接受 Ben 的加分,拒绝降分和不存在的成员;在另一个集合中,只使用 GT 又确实可以插入新成员。这几项分别验证返回计数、更新条件和准入条件,不能相互替代。

精度断言请求写入 9007199254740993,实际读回的是 9007199254740992。包含与排除截止分数的查询返回不同边界,但存储中仍有三个成员;显式删除后才移除了旧成员。最后,在下一页之前插入一个更靠前的成员,使成员 b 出现在两页中。这里用可控的操作交错证明分页会移动,不把它包装成并发基准。实验覆盖排序与命令语义,没有验证生产吞吐、清理任务调度或分布式一致性。

来源