Redis 列表是有顺序的字节值序列,可以从两端插入和取出。选择哪一端写、哪一端读,决定了它表现为队列还是栈。阻塞操作只是等待数据到达,并不自动提供分布式锁、消息确认或完整的任务处理协议。这些能力必须根据业务需要另外明确,不能从“支持阻塞”直接推导出来。

先把两端的约定说清楚

RPUSH 配合 LPOP 按先进先出顺序移除元素,早进入的元素先离开;RPUSH 配合 RPOP 则是后进先出。一条 LPUSH 带多个值时,会依照参数顺序逐个往左插入,因此传入 a、b、c 后,从左到右看到的是 c、b、a。设计队列时要验证生产者真正执行的命令,而不是只看图里画了哪一端。

列表允许重复值,相同消息插入两次就占两个位置,不会自动完成去重。当两次投递必须能够区分时,应使用不同的任务标识。LRANGE 的起止位置都包含在返回范围里,-1 表示最后一个元素。最后一个元素被移除时,整个列表键也会消失;对不存在的列表弹出会返回空结果,而不是创建一个空列表占位。

两端操作的单元素成本较低,但返回很大的范围或一次删除许多元素,仍然需要相应的工作量。LTRIM 可以保留最近一段窗口,不过用它裁剪待处理任务,就等于主动丢弃被裁掉的条目。生产者写入和随后的单独裁剪是两条操作,不能当成原子提交。保留哪一段应由数据用途决定,不能把日志窗口策略直接套到任务队列上。

等待的是连接,不是整个服务器

BLPOP 在数据到来或等待超时前阻塞当前连接,其他连接仍可正常执行,生产者也能照常投递。在 redis-py 中,成功结果包含键名和取出的值,超时返回 None;超时参数为零则表示无限等待。客户端的套接字超时需要容纳预期的服务器等待时间及响应时间,否则可能先在客户端超时。

消费者没有业务工作时,阻塞调用仍会占用连接。因此应给消费者设置有界的连接预算,并避免让生产者与正在等待的消费者共用同一条连接。还要区分移除顺序与完成顺序:FIFO 保证先移走较早的元素,多个工作者却可能先完成后来的任务。仅凭阻塞等待,无法保证某个业务资源只有一个持有者。

坏掉的样子

LPOP 和 BLPOP 都在业务处理完成之前把任务从列表里移除。消费者刚取到任务就崩溃,或者服务器弹出后响应丢失,该任务都已经不在待处理列表中。Redis 不会自动把它恢复回来,也不要求消费者确认完成。单独配置持久化,并不能修补这类应用层交接窗口。

Redis 6.2 开始提供 LMOVE,可以在一条原子命令中把任务从待处理列表转移到处理中列表;BLMOVE 是对应的阻塞版本。使用 Redis Cluster 时,两个列表键必须落在同一哈希槽;名称看起来相关,并不能保证满足这一条件。成功处理后,可通过 LREM 按唯一任务标识移除一个条目,作为应用约定的确认。对于长时间没有完成的任务,应用仍需定义恢复策略,因为列表不会自动为每个元素维护租约、时间戳或重试次数。

如果业务副作用已经完成,但确认操作失败,恢复时就可能重复执行。因此唯一任务标识、幂等处理和迟到工作者的处置策略都很重要,两个列表之间的移动不等于恰好执行一次。下面只把一个已知任务明确放回待处理列表,用来验证机制,不把手动操作冒充自动故障检测或生产级恢复调度器。

快速验证

原有实验记录使用 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" 执行下面的代码。程序仅通过 /tmp 下的私有 Unix socket 连接自己启动的 Redis,关闭 TCP、定时快照和追加日志。退出时只终止自己创建的进程并移除本次运行目录。下载依赖需要网络,实验不访问默认本机 Redis,也不会清理其他实例的数据。

from concurrent.futures import ThreadPoolExecutor

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("fifo", b"a", b"b", b"c")
    assert [client.lpop("fifo") for _ in range(3)] == [b"a", b"b", b"c"]
    assert client.exists("fifo") == 0
    assert client.lpop("fifo") is None
    client.rpush("stack", b"a", b"b", b"c")
    assert [client.rpop("stack") for _ in range(3)] == [b"c", b"b", b"a"]
    client.lpush("left", b"a", b"b", b"c")
    assert client.lrange("left", 0, -1) == [b"c", b"b", b"a"]
    client.rpush("duplicates", b"same", b"same")
    assert client.llen("duplicates") == 2
    print("FIFO a,b,c; LIFO c,b,a; multi-value LPUSH reverses argument order; duplicates stay")

    def consume():
        with client.client() as connection:
            connection.client_setname("list-lab-consumer")
            return connection.blpop("channel", timeout=1)

    with ThreadPoolExecutor(max_workers=1) as worker:
        future = worker.submit(consume)
        deadline = time.monotonic() + 1
        while not any(
            entry["name"] == "list-lab-consumer" and "b" in entry["flags"]
            for entry in client.client_list()
        ):
            assert time.monotonic() < deadline, "consumer never blocked"
            time.sleep(0.01)
        client.rpush("channel", b"job:1")
        assert future.result(timeout=2) == (b"channel", b"job:1")
    assert client.blpop("empty", timeout=0.1) is None
    print("observed a blocked connection; RPUSH wakes it; empty BLPOP times out")

    client.rpush("ready", b"job:2", b"job:3")
    job = client.lmove("ready", "processing", "LEFT", "RIGHT")
    assert job == b"job:2"
    assert client.lrange("ready", 0, -1) == [b"job:3"]
    assert client.lrange("processing", 0, -1) == [b"job:2"]
    assert client.lmove("processing", "ready", "LEFT", "LEFT") == job
    assert client.lrange("ready", 0, -1) == [b"job:2", b"job:3"]
    assert client.lmove("ready", "processing", "LEFT", "RIGHT") == job
    assert client.lrem("processing", 1, job) == 1
    assert client.exists("processing") == 0
    print("LMOVE retains an in-flight job; explicit requeue and LREM acknowledgement work")

    client.set("wrong", b"not a list")
    try:
        client.lmove("ready", "wrong", "LEFT", "RIGHT")
    except redis.ResponseError as error:
        assert "WRONGTYPE" in str(error)
    else:
        raise AssertionError("LMOVE must reject a non-list destination")
    assert client.lrange("ready", 0, -1) == [b"job:3"]
    assert client.get("wrong") == b"not a list"
    client.rpush("recent", b"1", b"2", b"3", b"4")
    client.ltrim("recent", -2, -1)
    assert client.lrange("recent", 0, -1) == [b"3", b"4"]
    print("wrong-type move preserves source; LTRIM keeps only the requested window")

第一组断言分别验证先进先出、后进先出、多参数 LPUSH 的次序以及重复条目不会消失。生产者先通过 CLIENT LIST 确认消费者连接确实处于阻塞状态,再提交任务;另外一次对空列表的弹出则在有限等待后返回 None。这是在观察真实阻塞与唤醒,不是靠提前写好数据来模拟,也不要求精确的调度延迟。

LMOVE 取出的任务仍留在处理中列表,反向移动能把它重新入队,后续的 LREM 则移除确认条目。当目标键类型错误时,命令报错而源列表没有丢失元素。最后,LTRIM 只保留指定的两个值。这些检查证明命令行为,没有验证崩溃持久性、自动恢复、吞吐量或分布式锁语义。

来源

  • Redis LPUSH,多值插入的顺序,已核验 2026-09-08。
  • Redis LRANGE,包含端点的区间与操作成本,已核验 2026-09-08。
  • Redis BLPOP,连接阻塞、超时和移除式投递,已核验 2026-09-08。
  • Redis LMOVE,原子转移与处理中列表模式,已核验 2026-09-08。
  • Redis LREM,按值与数量移除元素,已核验 2026-09-08。
  • Redis Cluster 规范,多键命令的同槽边界,已核验 2026-10-15。
  • Redis 8.10.1 构建说明,只编译核心及分配器选择,已核验 2026-09-08。