Redis 事务先在一条连接上积累命令队列,再进入一个隔离的执行窗口。MULTI 开始排队,EXEC 按顺序执行已接受的命令。这里的关键保证是其它客户端的命令不能插进执行过程,而不是后面一条失败时,前面所有成功写入都能自动撤销。

入队与执行是两个阶段

QUEUED 表示服务端接受了命令,等待后续执行,并不表示这条命令想完成的写入已经成功。Redis 在接受命令时就会检查命令是否存在、参数数量是否正确;依赖当前数据的错误,例如向字符串键执行列表操作,则可能直到 EXEC 才暴露。不能笼统地把它们都称为语法错误,因为两种失败对应的队列处理方式并不相同。

在队列中发送 GET,同样先收到 QUEUED,真正的值出现在 EXEC 的结果数组里。客户端不能先读取这个排队 GET 的实际结果,再据此决定本次事务后面的命令。需要在应用内判断时,可先用 WATCH 保护读取依赖;必须在读取与写入之间分支时,可考虑有界的服务端脚本。

客户端缓冲又增加了一层。redis-py 的普通事务流水线先把命令放进本地列表,直到 execute 才发送 MULTI、各条命令与 EXEC。set 方法返回流水线对象,并不等于服务端已确认入队。下面使用专用连接读取原始响应,是为了观察协议的实际阶段,而不是把客户端的链式调用误当成服务器执行结果。

隔离从执行时开始

MULTI 不会替调用方提前锁住相关键。一个客户端仍在构造队列时,另一个客户端完全可以更新这些键。队列中的 GET 读取的是它执行时的值,不是 MULTI 开始时保存的快照;后面的 GET 也能看到本事务前面 SET 的写入。把不被插队说成启动时的一致快照,会误导依赖旧值的业务判断。

事务必须始终使用同一条连接,而且这条连接不能混入无关业务。如果连接池为 MULTI 和 EXEC 分别分配连接,它们就不属于同一个事务状态。应用代码应优先使用客户端提供的事务抽象;实验直接访问连接,只用于核验已固定版本的协议行为。

坏掉的样子

入队阶段出现错误参数数量,会让事务被标记为不可执行。随后 EXEC 返回 EXECABORT,已经排队的写入也不会发生。DISCARD 则是调用方主动清空尚未执行的队列。这两种操作都不是撤销已经执行过的命令,更不能在 EXEC 完成之后靠 DISCARD 回到原来的数据。

执行期的 WRONGTYPE 不同:失败命令在自己的结果位置返回错误,前面成功的写入保留,后续命令还会继续执行。只有检查每一个响应,才能判断业务操作是否完整成功。客户端抛异常,不能证明 Redis 停在那条命令,也不能证明整批都已回滚。

redis-py 默认的 execute 可能在处理完整事务响应后抛出 ResponseError。关闭 raise_on_error,可以把执行期错误作为结果元素取回,但并没有改变服务端的执行或回滚能力。连接问题还要单独处理:若 EXEC 已发送而应答丢失,客户端可能不知道事务是否完成。直接重发非幂等操作可能造成重复扣减。

事务的执行也应保持短小,因为执行期间其它请求需要等待。隔离保证没有替应用选择持久化、复制或故障切换策略;收到了成功响应,只能说明本次执行结果,不能据此声称数据会在所有故障下存活。

快速验证

需要具备 C 编译器、make、curl、tar,以及 Python 3.10 或更新版本的 POSIX 环境。此前记录在 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

在同一终端使用 "$redis_lab/venv/bin/python" 运行下方代码。它在 /tmp 创建私有 Unix 套接字,关闭 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()


def command(connection, *arguments):
    connection.send_command(*arguments)
    return connection.read_response()


with instance() as client, client.client() as queued:
    connection = queued.connection
    client.set("value", "initial")
    assert command(connection, "MULTI") == b"OK"
    assert command(connection, "GET", "value") == b"QUEUED"
    assert command(connection, "SET", "value", "own-write") == b"QUEUED"
    assert command(connection, "GET", "value") == b"QUEUED"
    assert client.get("value") == b"initial"
    client.set("value", "other-client")
    assert command(connection, "EXEC") == [b"other-client", b"OK", b"own-write"]
    assert client.get("value") == b"own-write"
    print("QUEUED does not execute; EXEC reads current state and then its own writes")

    assert command(connection, "MULTI") == b"OK"
    assert command(connection, "SET", "discarded", "yes") == b"QUEUED"
    assert command(connection, "DISCARD") == b"OK"
    assert client.get("discarded") is None
    print("DISCARD removes the unexecuted queue")

    assert command(connection, "MULTI") == b"OK"
    assert command(connection, "SET", "before-arity", "yes") == b"QUEUED"
    try:
        command(connection, "SET", "missing-value")
    except redis.ResponseError as error:
        assert "wrong number of arguments" in str(error)
    else:
        raise AssertionError("invalid SET was accepted")
    assert command(connection, "SET", "after-arity", "yes") == b"QUEUED"
    try:
        command(connection, "EXEC")
    except redis.exceptions.ExecAbortError:
        pass
    else:
        raise AssertionError("queue error did not abort EXEC")
    assert client.mget("before-arity", "after-arity") == [None, None]
    print("wrong arity fails during queueing; EXECABORT applies none of the queue")

    client.set("wrong-type", "string")
    assert command(connection, "MULTI") == b"OK"
    assert command(connection, "SET", "before-runtime", "yes") == b"QUEUED"
    assert command(connection, "LPUSH", "wrong-type", "item") == b"QUEUED"
    assert command(connection, "SET", "after-runtime", "yes") == b"QUEUED"
    responses = command(connection, "EXEC")
    assert len(responses) == 3
    assert responses[0] == responses[2] == b"OK"
    assert isinstance(responses[1], redis.ResponseError)
    assert "WRONGTYPE" in str(responses[1])
    assert client.mget("before-runtime", "wrong-type", "after-runtime") == [
        b"yes", b"string", b"yes",
    ]
    print("runtime error occupies one reply; both surrounding writes remain applied")

第一组依次排队 GET、SET、GET。在 EXEC 之前,另一条连接先把值改成 other-client,所以最终响应依次是 other-client、OK 和 own-write。执行前检查还确认排队的 SET 并未提前修改数据。这既区分了延迟执行与旧快照,也验证了事务后面的读取能够看见自己的前序写入。

DISCARD 场景留下的标记键不存在。随后,缺少值参数的 SET 在入队时立即报错,EXECABORT 之后,它前后两条已收到 QUEUED 的写入也都没有发生。这里必须查看实际键值;只看到第一条错误信息,并不足以证明整个队列确实未执行。

最后让 LPUSH 操作已有字符串,EXEC 返回三个元素:成功、WRONGTYPE、成功。前后两个标记都已写入,原字符串保持不变。四条打印摘要都放在真实响应与存储值断言之后,不是预先假设的输出。

本地实验验证的是入队与执行两个错误阶段、显式取消及执行时读取,并未模拟网络断连、崩溃恢复、集群路由或持久性。把这一机制用于不可逆业务操作之前,仍需单独验证那些边界,不能把本次断言通过扩大为完整交付保证。

来源