Redis Lua 脚本可以先读取数据、判断条件,再更新数据,而不让另一个客户端的命令插进这些步骤之间。但这不意味着每个语句都会成功,也不意味着后面发生错误时会撤销前面的写入,更不意味着执行结果自动具备持久性。原子执行与失败回滚,是必须拆开理解的两个问题。
把判断放在数据旁边
例如,仅当令牌仍属于当前持有者时才删除它。客户端先 GET、稍后再 DEL,中间可能已经被新持有者替换了值;随后删除就误伤了别人。脚本可以在同一次执行中比较当前值,匹配才删除。因此旧持有者即使带着之前读到的令牌来操作,也不会直接删除新持有者的值。
脚本应通过 redis.call 访问 Redis 数据,这正是脚本接口的用途。沙箱不提供普通文件系统或网络访问,不能把邮件、支付或文件读写搬进脚本。所有会访问的键都应通过 KEYS 传入,普通值用 ARGV,不要根据数据库里读出的内容临时拼接键名。Redis Cluster 中的常规多键使用还要求相关键位于同一哈希槽。
如果原生原子命令已经表达了条件,应优先使用它。Redis 8.4 新增的 DELEX 配合 IFEQ 就能按字符串值比较后删除。下方 Lua 版本用于理解分支与响应,不代表需要用自定义代码替换原生命令。删除所有权令牌本身也不是完整的分布式锁协议,无法单独阻止过期工作者继续修改外部资源。
坏掉的样子
没有被捕获的 redis.call 错误会让脚本停止。例如先 SET 一个标记,再对字符串键执行 LPUSH,后者失败时,前面的 SET 仍然保留,而失败语句之后的代码不会继续运行。这又与 MULTI/EXEC 有区别:事务中单条命令发生运行期错误,通常不会阻止后面的排队命令继续执行。两者都不提供先前成功写入的自动回滚。
redis.pcall 会把 Redis 命令错误作为包含 err 字段的表交回脚本,由脚本显式判断并决定是否继续。捕获错误既没有撤销旧写入,也不能把失败的业务操作自动变成成功。应尽可能在第一条写入之前校验参数和预期类型,并明确剩余失败路径如何收场,而不是依赖脚本名义上的原子性掩盖部分结果。
调用方超时也不等于取消,更不能证明服务器什么都没做。直接重发递增脚本,可能把计数增加两次。对于不允许重复效果的业务,需要结果确认或幂等机制。SCRIPT KILL 只能中断尚未修改数据集的脚本;一旦已经产生写入,它就不是可以安全使用的撤销按钮。
保留源码,限制工作量
EVALSHA 用摘要标识缓存中的脚本,不等于安装了永久保存的存储过程。脚本缓存是易失的,重启、故障切换或 SCRIPT FLUSH 都可能让它消失;通过 EVAL 加载的脚本,还可能受到 Redis 7.4 引入的脚本缓存淘汰策略影响。应用要保留参数化源码,并通过客户端支持的加载机制处理明确的 NOSCRIPT 响应。
不要为每个输入值拼出一份不同脚本文本,变化应放进参数。明确收到 NOSCRIPT,表示这次调用没有执行那个缺失的脚本;重新加载或发送 EVAL 解决的是“代码不在缓存里”,不是之前一次业务动作执行结果未知的问题。这两种失败不能共用盲目重试结论。
有界不仅指循环次数,还包括单条命令处理的数据量。脚本只有几行,却读取一个没有规模限制的集合,仍可能长时间占用服务端。把逻辑移到 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 套接字,关闭 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()
compare_delete = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
with instance() as client, client.client() as rival:
client.set("lease", "owner-a")
assert client.get("lease") == b"owner-a"
rival.set("lease", "owner-b")
assert client.eval(compare_delete, 1, "lease", "owner-a") == 0
assert client.get("lease") == b"owner-b"
assert client.eval(compare_delete, 1, "lease", "owner-b") == 1
assert client.eval(compare_delete, 1, "lease", "owner-b") == 0
print("compare-and-delete preserves a new owner's token and removes only a match")
client.set("wrong-type", "string")
fails_after_write = """
redis.call('SET', KEYS[1], 'before')
redis.call('LPUSH', KEYS[2], 'item')
redis.call('SET', KEYS[3], 'after')
return 1
"""
try:
client.eval(fails_after_write, 3, "written", "wrong-type", "not-reached")
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("script should fail on the string key")
assert client.mget("written", "wrong-type", "not-reached") == [
b"before", b"string", None,
]
print("redis.call error stops the script but keeps the earlier write")
catches_error = """
local reply = redis.pcall('LPUSH', KEYS[1], 'item')
if type(reply) == 'table' and reply.err then
redis.call('SET', KEYS[2], 'handled')
return reply.err
end
return reply
"""
response = client.eval(catches_error, 2, "wrong-type", "handled-error")
assert response.startswith(b"WRONGTYPE")
assert client.get("handled-error") == b"handled"
assert client.eval(catches_error, 2, "new-list", "unused-marker") == 1
assert client.lrange("new-list", 0, -1) == [b"item"]
assert client.get("unused-marker") is None
print("redis.pcall exposes errors for handling and preserves successful command replies")
digest = client.script_load(compare_delete)
assert client.script_exists(digest) == [True]
client.set("lease", "owner-c")
assert client.evalsha(digest, 1, "lease", "owner-c") == 1
client.set("lease", "owner-d")
assert client.script_flush(sync_type="SYNC") is True
assert client.script_exists(digest) == [False]
try:
client.evalsha(digest, 1, "lease", "owner-d")
except redis.exceptions.NoScriptError:
pass
else:
raise AssertionError("flushed script remained available")
assert client.get("lease") == b"owner-d"
assert client.eval(compare_delete, 1, "lease", "owner-d") == 1
print("SCRIPT FLUSH causes NOSCRIPT; EVAL with retained source can run it again")
第一组先把 owner-a 替换成 owner-b,再让旧持有者运行脚本。旧令牌得到零且保留 owner-b,匹配令牌得到一并删除键;再次执行则因为键不存在而返回零。脚本以当前存储值为准,没有信任先前在客户端读取的旧结果。
下一段脚本先写标记,再对字符串做列表操作。断言证明错误之前的写入保留,而最后一个标记没有执行到。随后使用 pcall 处理同一种错误,并写入 handled 标记;对正常列表执行时,则返回整数结果,不写错误标记。类型判断区分数字与错误表,避免在成功响应上读取 err 字段时再次报错。继续执行是代码显式选择的行为,不是自动回滚。
最后 SCRIPT LOAD 返回摘要,EVALSHA 能据此执行;然后只对这个私有实验实例调用 SCRIPT FLUSH 清空脚本缓存。NOSCRIPT 发生后,令牌仍存在;再使用保存的源码调用 EVAL,就能删除它。不要把清缓存的步骤复制到多人共用的实例上,它会影响其它应用依赖的缓存脚本。
四条输出摘要均放在返回值和存储状态断言之后。本次没有测吞吐量、运行长时间脚本或复现网络故障,也不验证完整分布式锁、跨分片或持久性保证。它验证的是条件分支、错误后的实际状态,以及源码缓存缺失与执行失败的区别。
来源
- Redis Eval 脚本简介,执行隔离与易失的脚本缓存,已核验 2026-09-08。
- Redis Lua API,沙箱、命令调用与错误转换,已核验 2026-09-08。
- Redis EVAL,键参数及 EVAL 加载脚本的淘汰,已核验 2026-09-08。
- Redis SCRIPT KILL,脚本已经写入后的限制,已核验 2026-09-08。
- Redis SCRIPT FLUSH,显式清空脚本缓存,已核验 2026-09-08。
- Redis DELEX,Redis 8.4 起提供的原生条件删除,已核验 2026-09-08。
- Redis Cluster 规范,多键操作的哈希槽边界,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅编译核心及选择分配器,已核验 2026-09-08。