Redis 流水线允许客户端连续发送多条命令,不必等前一条回应以后再发下一条。它减少了请求与响应之间的等待,也能分摊套接字输入输出开销。但服务端仍需要执行每条命令并生成回复;批处理不代表这些命令并行运行,也不自动形成事务边界。
少等几次,而不是少执行几条
同步循环不断重复发送一条、等待一条。流水线则先发送一个序列,再按对应顺序收取回复。把一批近似理解为一次往返,可以帮助估算延迟,但不能据此承诺只有一个 TCP 数据包、一次系统调用,或所有回应在同一时刻到达。实际传输仍受缓冲区、数据量和协议处理影响。
收益也不只出现在远程链路。把套接字读写集中起来,可以减少系统调用和调度开销,所以即使本地连接,也可能在不分片的情况下提高实际吞吐。反过来,流水线消除不了昂贵命令本身的工作和带宽上限。应先定位真实瓶颈,而不是看到单节点就断言下一步必须加分片。
普通流水线没有“不被插队”的保证。其它客户端可能在批次的命令之间执行操作,但具体某一次运行也可能碰巧全部连续完成。观察到一次连续执行,并不能把它上升为事务隔离承诺。正确性不能依赖一次测试里没有出现的调度交错。
区分顺序依赖与回复依赖
同一批命令不必完全无关。先 SET 计数器,再 INCR,最后 GET,可以放在一个流水线中,因为每条请求的参数在发送前都已确定;命令顺序本身就表达了服务端的数据依赖。真正无法放进同一个预先构造批次的,是必须根据前面回复计算键名或参数的下一条请求。
这种回复依赖应拆成不同阶段,或使用合适的原生命令、有界脚本。如果应用内的条件判断还需要并发保护,可以考虑 WATCH。MULTI/EXEC 提供执行隔离,却不会在运行期错误之后自动撤销其它已成功命令,这与减少往返是不同的问题。
还要检查客户端默认行为。redis-py 8.1.0 的 pipeline() 默认使用事务,显式传 transaction=False 才是普通流水线。因此拿默认包装器做测量,会同时测到批处理与 MULTI/EXEC。下面不仅写出选项,还读取服务端命令计数,核对实际有没有调用事务指令。
坏掉的样子
普通流水线中一条命令报错,不会取消已经提交的后续命令。错误回复仍占据结果序列中的对应位置,必须逐项检查;仅仅收到一个数组,不表示整个业务动作成功。忽略中间失败的写入,可能留下形状看似完整却不一致的应用数据。
redis-py 默认可能在读完回复后抛出 ResponseError,execute(raise_on_error=False) 则把命令错误放在结果元素里,两者都不会撤销成功写入。连接中断是另一类问题:缺少回应可能让已执行的前缀无法确定。即使从最后收到确认的命令之后继续,也可能重复一条已经执行、只是回应丢了的写入。
因此不能盲目重试递增或其它非幂等效果。即便反复写同一个固定值,如果中途另一个写入方已经更新数据,也需要并发约定来避免覆盖。可靠恢复是业务操作的职责,不是流水线传输优化顺带提供的能力。
批量应同时限制命令条数和字节数。客户端缓冲请求与结果,Redis 也可能暂存客户端还没读取的回应。大负载或慢读取方会增加内存与延迟成本;只按命令数量给出一个通用批量上限,忽略了不同命令和回复大小的差异。
快速验证
原有实验记录使用 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 套接字的 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()
def calls(client, name):
return client.info("commandstats").get(f"cmdstat_{name}", {}).get("calls", 0)
with instance() as client:
initial_multi = calls(client, "multi")
initial_exec = calls(client, "exec")
with client.pipeline(transaction=False) as batch:
assert batch.set("counter", 4) is batch
batch.incr("counter")
batch.get("counter")
assert client.get("counter") is None
assert batch.execute() == [True, 5, b"5"]
assert calls(client, "multi") == initial_multi
assert calls(client, "exec") == initial_exec
print("plain pipeline buffers locally and returns ordered SET, INCR, GET results")
with client.pipeline() as batch:
batch.set("default-counter", 4)
batch.incr("default-counter")
assert batch.execute() == [True, 5]
assert calls(client, "multi") == initial_multi + 1
assert calls(client, "exec") == initial_exec + 1
print("redis-py's default pipeline also sends MULTI and EXEC")
client.set("wrong-type", "text")
with client.pipeline(transaction=False) as batch:
batch.set("before-error", "written")
batch.lpush("wrong-type", "item")
batch.set("after-error", "written")
responses = batch.execute(raise_on_error=False)
assert len(responses) == 3
assert responses[0] is responses[2] is True
assert isinstance(responses[1], redis.ResponseError)
assert "WRONGTYPE" in str(responses[1])
assert client.mget("before-error", "wrong-type", "after-error") == [
b"written", b"text", b"written",
]
print("a plain pipeline returns the error in place and keeps both surrounding writes")
with client.pipeline(transaction=False) as batch:
batch.incr("raise-before")
batch.lpush("wrong-type", "item")
batch.incr("raise-after")
try:
batch.execute()
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("default execute should raise the command error")
assert client.mget("raise-before", "raise-after") == [b"1", b"1"]
print("raising the client exception does not cancel already-sent later commands")
第一组 SET 只返回流水线对象,随后再排队 INCR 和 GET;execute 之前检查,计数器还不存在。执行后按顺序得到 True、整数五和表示五的字节串,证明客户端先缓冲,也展示了不需要中间回复的合法顺序依赖。
服务端计数确认,这个显式非事务批次没有增加 MULTI 或 EXEC。随后默认 pipeline() 各增加一次,两种机制因此得到了独立观察。实验只在隔离实例中运行,没有其它业务流量同时修改这些计数。
下一组把对字符串执行的 LPUSH 放在两条正常写入之间。错误以元素形式返回时,WRONGTYPE 留在中间位置,前后写入都保留。最后一组恢复默认异常模式,两次递增仍在应用接到异常之前完成,说明客户端抛错并不等于后续命令被取消。
四条摘要都在响应、命令计数和实际键值的断言之后输出。本实验验证批处理语义,不保证固定加速倍数、不强求某种跨客户端调度顺序,也未模拟断线恢复。单独的性能文章会测受控负载的完成时间,不能把这些正确性断言直接当作性能证据。
来源
- Redis 流水线,请求批处理与套接字开销,已核验 2026-09-08。
- Redis 事务,执行隔离及不提供自动回滚,已核验 2026-09-08。
- redis-py 8.1.0 客户端实现,默认事务模式与流水线响应处理,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅编译核心及选择分配器,已核验 2026-09-08。