Redis 列表保存允许重复值的有序序列;有序集合保存唯一成员,并按数值分数排序。真正有用的区别不是谁总是更快或更省内存,而是应用需要按位置处理序列、维持唯一成员、查询分数范围,还是需要另一种数据模型才能表达的组合语义。

先定义什么算不同条目

使用 RPUSH 时,列表按追加顺序保留值,也保留重复项。随后 LPOP 移除最旧值,RPOP 则移除最新值。换一种推入与弹出端的组合,顺序也会变化,因此不能不看生产者命令就断言列表天然是先进先出。多个工作进程并行执行时,移除顺序也不保证业务完成顺序。

ZADD 为每个成员关联一个分数。再次加入同名成员,是更新分数,不是增加第二条记录。默认返回值统计新增成员数量,所以返回零也可能是成功更新。如果直接拿重复的事件载荷当成员名,多次出现会合并成同一个成员;当每次出现都代表独立任务时,这样建模就会丢掉业务区别。

分数允许并列,同分时按成员字节排序,不按到达顺序,也不是本地化文字排序;REV 同时反转同分成员的先后。排行榜可能接受这个规则,时间索引则可能需要独立事件标识和明确的并列处理方式。分数使用浮点数,因此还要选择能够保留所需数值精度的单位,不能把排序正确等同于任意精度都能表达。

位置和分数选择的是不同内容

列表不只支持两端访问。LINDEX 可以读取一个位置,LRANGE 返回包含边界的位置区间。到达内部位置需要遍历;两端访问便宜,不代表返回大量元素也无需付出相应成本。对只读取头尾、长度受控的短列表来说,保留一个小窗口完全可能是适合的方案。

ZRANGE 默认解释为从零开始的排名位置,结束位置也包含在内。BYSCORE 才把两个边界解释为数值分数,在分数前加左圆括号则排除该边界。官方给出的范围读取复杂度是 O(log N + M),其中 N 为成员数,M 为返回数量。因为有索引,就认为大结果集或很深的偏移扫描没有成本,是不成立的。

持续变化的两种结构都不会让按排名分页变成快照。插入或分数更新可能移动后续页的位置,分开的请求就可能重复或遗漏。选择容器之前应先决定读取契约。内存开销也没有普遍相近的保证:底层表示、成员数量、载荷长度和配置都会改变结果。容量是决策因素时,应测量具有代表性的数据,而不是根据类型名字推断。

坏掉的样子

时间戳分数不是成员的过期截止时间。ZREMRANGEBYSCORE 显式删除数值区间,LTRIM 显式保留按位置选择的窗口;两者都不会因为时间过去或有人读取而自动清理。追加与清理如果分成两个命令,就是两个操作,保留逻辑仍然需要自己的触发与并发策略。

有序集合也不是完整的延迟任务调度器。ZPOPMIN 会立即移除最低分成员,即使分数表达的是未来时刻。先查到期成员、再用另一条命令删除,可能让多个工作进程选到同一任务。原子认领、处理确认以及遗弃任务恢复都是额外要求,不会因为分数放了时间戳就自然具备。

列表弹出同样发生在业务处理完成之前。移除后消费者失败,若没有合适的处理协议,任务就可能丢失。需要序列操作时选列表,需要按分数索引唯一成员时选有序集合;不能从任何一种排序方式推导出送达保证。数据结构选择与业务处理协议应分别判断,避免用容器替代完整语义。

快速验证

原有实验记录使用 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,关闭 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:
    client.rpush("arrival", b"b", b"a", b"b")
    assert client.lrange("arrival", 0, -1) == [b"b", b"a", b"b"]
    assert client.lindex("arrival", 1) == b"a"
    assert client.llen("arrival") == 3
    client.zadd("ranked", {"b": 10})
    client.zadd("ranked", {"a": 10})
    assert client.zadd("ranked", {"b": 20}) == 0
    assert client.zcard("ranked") == 2
    assert client.zrange("ranked", 0, -1, withscores=True) == [(b"a", 10.0), (b"b", 20.0)]
    client.zadd("ranked", {"b": 10})
    assert client.zrange("ranked", 0, -1) == [b"a", b"b"]
    assert client.zrange("ranked", 0, -1, desc=True) == [b"b", b"a"]
    print("lists retain duplicates and positions; sorted sets update unique members and order ties by bytes")

    assert client.lpop("arrival") == b"b"
    assert client.rpop("arrival") == b"b"
    assert client.lrange("arrival", 0, -1) == [b"a"]
    client.rpush("fifo", b"old", b"new")
    client.rpush("lifo", b"old", b"new")
    assert client.lpop("fifo") == b"old"
    assert client.rpop("lifo") == b"new"
    print("RPUSH with LPOP removes oldest first; RPUSH with RPOP removes newest first")

    client.zadd("times", {"at-10": 10, "at-20": 20, "at-30": 30})
    assert client.zrange("times", 0, 1) == [b"at-10", b"at-20"]
    assert client.zrange("times", 10, 20, byscore=True) == [b"at-10", b"at-20"]
    assert client.zrange("times", "(10", 20, byscore=True) == [b"at-20"]
    assert client.zremrangebyscore("times", "-inf", "(20") == 1
    assert client.zrange("times", 0, -1) == [b"at-20", b"at-30"]
    assert client.ttl("times") == -1
    client.rpush("window", b"old", b"middle", b"new")
    client.ltrim("window", -2, -1)
    assert client.lrange("window", 0, -1) == [b"middle", b"new"]
    print("rank selects positions; score selects values; score cleanup and list trimming are explicit")

    now = 100
    client.zadd("schedule", {"future-job": now + 50})
    assert client.zrange("schedule", "-inf", now, byscore=True) == []
    assert client.zpopmin("schedule") == [(b"future-job", 150.0)]
    assert client.exists("schedule") == 0
    print("ZPOPMIN removes the minimum immediately even when its score represents a future deadline")

列表保留 b、a、b 三个条目,并允许用 LINDEX 读取中间位置。有序集合只有两个成员;更新 b 的分数返回零,分数却确实发生改变。并列后顺序变为 a、b,而非按原来插入顺序排列,REV 再把它反过来。

独立的先进先出和后进先出示例展示了弹出端的作用。范围检查区分排名与分数,也区分包含和排除边界。按分数清理只移除低于二十的值,列表裁剪则保留最新两个值;有序集合对应的键依然没有过期时间,说明分数与键过期是两套独立含义。

最后,数值测试时钟是一百,唯一任务的分数是一百五十。到期区间查询为空,但 ZPOPMIN 仍立即移除这个未来任务。这里验证的是命令语义,不是一套调度器,也没有执行内存对比、崩溃恢复协议或吞吐基准。不能把这段确定性的行为检查解释成性能结论。

来源

  • Redis RPUSH,向列表追加值,已核验 2026-09-08。
  • Redis LINDEX,按位置读取与遍历成本,已核验 2026-09-08。
  • Redis LRANGE,包含边界的位置范围与成本,已核验 2026-09-08。
  • Redis LTRIM,保留按位置选择的窗口,已核验 2026-09-08。
  • Redis ZADD,唯一成员、更新与数值分数,已核验 2026-09-08。
  • Redis ZRANGE,排名与分数查询、边界及同分排序,已核验 2026-09-08。
  • Redis ZPOPMIN,立即移除最低分成员,已核验 2026-09-08。
  • Redis ZREMRANGEBYSCORE,显式按分数清理,已核验 2026-09-08。
  • Redis 8.10.1 构建说明,仅构建核心与分配器选择,已核验 2026-09-08。