读完这篇,你能做什么
能设计先校验后写入的脚本,并给出并发冲突和脚本失败的处理方式。
学习理由、准备要求与知识点
为什么学它
秒杀和去重常依赖 Redis 原子操作,必须理解运行错误不会自动回滚已发生的写入。
先读这些内容
知识点清单
- MULTI 与 EXEC
- WATCH 乐观检查
- 运行期错误
- 无自动回滚
- Lua 原子执行
- 写前参数校验
- 脚本阻塞
- 显式 KEYS
运行前先确认Redis7.4、Java17;Jedis事务源码v5.2.0。算法只模拟SET/INCR、显式WATCH版本和排队错误;不实现网络、过期触发、脚本解释器或真实并发。
01先弄懂它是什么
MULTI/EXEC 可以让排队的 Redis 命令连续执行,中间不插入其他客户端的命令;但某条命令运行失败,不会自动撤销之前已经完成的修改。MULTI 后看到 QUEUED,只表示命令已排队;执行结果要看 EXEC 返回的每一项。不能看到一项失败,就认定整组都没做。
错误发生在哪一步,会改变结果。排队时明显的语法或参数数量错误,可能让 EXEC 拒绝整个事务;执行时才发现类型错误或整数解析错误,则其他命令可能已经成功,后面的命令也仍会执行。DISCARD 只能丢弃还没执行的队列,不能撤销已经完成的 EXEC。
WATCH 的做法是“先读和计算,执行前再检查有没有人改过”:先监视键,再读取并计算,最后用同一连接执行 MULTI/EXEC。如果监视的键发生了相关变化,EXEC 可以放弃整批执行,返回 nil;客户端常把它表示为 null。此时重试要重新读取、重新计算,不能一直提交旧结果。用完连接后还要清理事务和监视状态,免得影响下一次借用它的人。
文中这几个词,先认一认
- 原子执行
- 一组操作执行时不被其他客户端普通命令插进来;这本身不包含失败后的撤销。
- WATCH冲突
- 监视的键发生相关变化,导致 EXEC 放弃执行;需要重新读值、重新计算,并限制重试次数。
- 脚本原子性
- Lua 脚本按 Redis 规则连续执行,但脚本中途报错,不会自动撤销之前已经完成的写入。
02看看它怎么工作
在隔离实例输入MULTI、SET study:tx:n hello、INCR study:tx:n、SET study:tx:after done、EXEC。INCR因非整数失败,但n仍是hello,after仍会被设置。逐项读取EXEC结果并查询最终状态,才能观察真实边界。把这段实验与MySQL事务回滚对比,有助于去掉“事务这个名字保证相同语义”的假设。
WATCH适合少量键的读改写竞争。在连接A WATCH键并读取后,连接B修改它,再由A EXEC,验证队列被取消;随后A必须重新WATCH、重新读、重新计算。Redis7.4中过期或淘汰等键变化也可能影响WATCH检查,不能只统计业务SET。Cluster中的多键事务还受同槽约束。
Lua 可以把“读值、检查、写回”放到 Redis 服务器里一次连续完成,减少客户端来回发请求,也避免其他客户端在这几步之间插入普通命令。脚本应先检查参数、类型和能提前发现的业务条件,再开始写入;写到一半报错,不会自动撤销前面的修改。脚本运行太久会拖住其他请求,也不能把外部 HTTP 当成随手调用的普通脚本步骤。MySQL 和消息队列中的操作,仍不在这段 Redis 操作的原子范围内。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["WATCH并读取"] --> B["MULTI排队"]
B --> C{"排队是否合法"}
C -->|"否"| D["EXEC拒绝队列"]
C -->|"是"| E{"监视键有变化"}
E -->|"是"| F["放弃执行并重新读取"]
E -->|"否"| G["顺序执行每条命令"]
G --> H["逐项结果含运行错误"]
H --> I["检查最终业务状态"]按顺序看,每一步做了什么
- 先用小命令观察QUEUED与实际返回值区别。
- 制造整数或类型运行错误,检查前后命令状态。
- 用两个连接复现WATCH冲突并重新读取。
- Lua先校验后写,给工作量和键集合设边界。
- 应用逐项处理EXEC结果,连接结束时清理状态。
03对照实现代码
Jedis v5.2.0 · Transaction#exec、#clear、#discard
原创结果解码模型,非源码节选。仅演示空EXEC结果与数组内错误的区别,不发送命令或复现全部Response类型。
import java.util.*;
class SourceModel {
record Item(boolean error,String text) {}
static List<Object> decode(List<Item> replies) {
if (replies==null) return null; // WATCH放弃执行与空数组不同。
List<Object> result=new ArrayList<>();
for (Item item:replies) {
// 单项错误保留在结果中,不丢弃其余成功结果。
result.add(item.error()?new IllegalStateException(item.text()):item.text());
}
return result; // 调用者仍需逐项核对业务结果。
}
}真实exec先要求inMulti,再处理排队响应并发送EXEC,QUEUED阶段不能当成业务执行完成。
空多项回复会清空响应队列并返回null,与执行一个空队列得到空列表不同。
逐项格式化时捕获JedisDataException并放入结果列表,因此调用未抛顶层异常也不证明每条命令都成功。
exec 的 finally 会重置 inMulti、inWatch 并清理响应;捕获 JedisConnectionException 时,还会把当前 Transaction 对象的 broken 标志设为 true,后续 clear 会据此跳过正常清理流程。应用应按客户端的异常与资源关闭规则处理,不能忽略通信失败后继续把原连接当正常连接复用。
代码先调用谁,再调用谁
- MULTI与
appendCommand - exec检查状态
- 发送EXEC
- 读取多项结果
- 逐项解码错误
- finally清理事务状态
04动手写一个例子
区分排队拒绝、WATCH冲突与运行错误
在单线程字符串存储模型执行SET与INCR队列。先验证全部命令格式,再检查显式监视版本;通过后逐条执行,INCR解析或溢出错误仅作为单项结果且继续后续命令。所有写入更新键版本。
- 输入 1
SET n hello,INCR n,SET after done- 输出 1
[OK,ERR,OK],n仍hello,after为done
- 输入 2
WATCH后键被修改- 输出 2
WATCH_ABORT,队列不执行
排队格式检查在任何执行前完成,因此未知命令会阻止整批修改。运行错误则只能在读取当前值时发现,不能与前者混为一种回滚行为。
WATCH保存键版本快照,EXEC前比对全部监视键。任一变化都使本次队列不执行;本模型调用顺序就是原子边界,不模拟多个真实客户端线程。
执行阶段对每条命令分别产生结果。INCR使用精确整数解析和加法,失败不改变当前键,但先前成功写入仍在,后续SET仍会执行。
为什么这样写是对的QUEUE_ERROR和WATCH_ABORT不执行任何队列写;EXECUTED逐项按顺序反映结果,单项运行失败不撤销其他成功命令。
C为命令数,W为监视键数,L为数值解析字符总量,K为存储键数;结果列表长度为C。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Command(String op,String key,String value) {}
record Result(String status,List<String> items) {}
static class Store {
final Map<String,String> values=new HashMap<>();
final Map<String,Long> versions=new HashMap<>();
long clock;
void set(String key,String value) {
clock=Math.addExact(clock,1);
values.put(key,value); versions.put(key,clock);
}
long version(String key) { return versions.getOrDefault(key,0L); }
Result exec(List<Command> commands,Map<String,Long> watched) {
for (Command c:commands) {
// 排队格式错误在任何命令执行之前拒绝。
if (c.key()==null||!("SET".equals(c.op())||"INCR".equals(c.op()))||
("SET".equals(c.op())&&c.value()==null))
return new Result("QUEUE_ERROR",List.of());
}
for (var w:watched.entrySet())
if (version(w.getKey())!=w.getValue())
return new Result("WATCH_ABORT",List.of());
List<String> results=new ArrayList<>();
for (Command c:commands) {
if ("SET".equals(c.op())) { set(c.key(),c.value()); results.add("OK"); }
else {
try {
long old=Long.parseLong(values.getOrDefault(c.key(),"0"));
long next=Math.addExact(old,1);
set(c.key(),Long.toString(next)); results.add(Long.toString(next));
} catch (NumberFormatException | ArithmeticException error) {
results.add("ERR"); // 运行错误不撤销此前写入,也不终止后续命令。
}
}
}
return new Result("EXECUTED",List.copyOf(results));
}
}
static Command set(String k,String v) { return new Command("SET",k,v); }
static Command incr(String k) { return new Command("INCR",k,null); }
static void eq(Object expected,Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
Store s=new Store();
eq(List.of("OK","6"),s.exec(List.of(set("n","5"),incr("n")),Map.of()).items());
eq(List.of("OK","ERR","OK"),s.exec(List.of(set("n","hello"),incr("n"),set("after","done")),Map.of()).items());
eq("hello",s.values.get("n")); eq("done",s.values.get("after"));
eq("QUEUE_ERROR",s.exec(List.of(set("x","1"),new Command("BAD","z",null)),Map.of()).status());
eq(false,s.values.containsKey("x"));
Map<String,Long> watch=Map.of("n",s.version("n"));
s.set("n","changed"); // 模拟WATCH与EXEC之间另一个客户端修改。
eq("WATCH_ABORT",s.exec(List.of(set("x","1")),watch).status());
eq(false,s.values.containsKey("x"));
eq(new Result("EXECUTED",List.of()),s.exec(List.of(),Map.of()));
System.out.println("PASS 9 Redis transaction assertions");
}
}这些特殊情况也要试一试
- 空队列执行结果为空列表,区别
WATCH_ABORT状态。 - 新键INCR以零为初值,非数字和溢出是运行错误。
- 模型不包含过期或淘汰版本变化,真实WATCH需考虑这些服务器行为。
进阶选读 · Lua中的业务拒绝与写后故障
场景库存检查和用户去重希望一次完成,但脚本在部分写入后出现错误。
做法把参数、类型、库存和重复条件尽量放在所有写入之前,返回明确业务码;为不可预见的写后失败设计补偿、状态查询或可重建记录,不把脚本异常当自动撤销。
复杂度脚本工作量应与小规模键集合有界相关,禁止无界全量扫描;恢复成本由业务记录决定。
好处与代价减少网络往返与竞争窗口会把工作集中到服务器,脚本过长影响其他请求,跨库结果仍需一致性协议。
05项目里怎么用
客户端应把EXEC的顶层成功与逐项结果区分,检查返回列表中的错误对象和长度。网络断线可能让执行结果未知,盲目重试INCR等非幂等命令会重复改变状态。业务操作需要稳定身份或可查询结果,不能只根据异常类型猜测服务器是否执行。
从 WATCH、读值到 MULTI、EXEC,都要使用同一个连接,不能在中途归还再随意借一条。发生冲突后,重试次数、两次尝试之间的等待和总耗时都应有上限。很多请求争着改同一个键时,反复读了再试,可能比一个短 Lua 脚本花费更多。结束时要按客户端规则清理监视和事务状态;连接已经损坏时,交给连接管理机制处理,不能照常复用。
多个键要由同一脚本或事务一起处理时,Cluster 通常要求同槽。哈希标签应围绕确实需要一起修改的数据安排,别把所有业务都集中到一个热点槽。Redis 内的一次完整操作仍不包含 MySQL 提交、消息发送或外部 HTTP,需要可靠记录、重试和对账把后续步骤接起来。
容易踩的坑
- QUEUED就向业务报告成功。
- EXEC返回列表未抛异常便忽略其中错误项。
- Lua写后报错时假定所有修改回滚。
- WATCH冲突后不重新读取而无限重放旧计算。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
MULTI/EXEC | 将已知命令队列连续执行。 | 运行错误无数据库式自动回滚。 | 不依赖中途返回值的短命令组。 |
| WATCH乐观检查 | 可在客户端读取和计算。 | 竞争下重试与连接占用增加。 | 低冲突的小范围读改写。 |
| Lua脚本 | 一次往返执行读取校验和写入。 | 需有界执行和写后失败设计。 | 短小且边界明确的原子业务步骤。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01原子执行等于失败回滚吗?
展开参考答案与推导
不等于,Redis事务和脚本运行错误不自动撤销此前写入。
02QUEUED代表什么?
展开参考答案与推导
命令已排队,不是业务结果已经成功。
03WATCH放弃与空队列执行有何区别?
展开参考答案与推导
前者队列因冲突未执行,后者正常执行但没有命令结果。
换个情况,还会处理吗
01EXEC后第一条成功第二条类型错误怎么办?
展开参考答案与推导
检查完整结果和最终状态,不能把第一条当作已撤销,按业务协议恢复。
02WATCH持续冲突怎么办?
展开参考答案与推导
有界重试并重新读,评估热点竞争,必要时改为短脚本或调整模型。
03连接异常后直接重复INCR安全吗?
展开参考答案与推导
不一定,原请求可能已执行,应使用业务幂等或结果查询处理未知。
写代码试一试
01怎样验证排队错误不执行此前SET?
- 输入 / 条件
SET x1后出现BAD命令- 输出 / 预期
QUEUE_ERROR且x不存在
提示 先验证全部格式。
展开参考答案与推导
同时断言状态与存储,避免只检查错误码。
02怎样证明运行错误后仍继续?
- 输入 / 条件
SET n hello、INCR n、SET after done- 输出 / 预期
after存在
提示 每条命令独立产生结果。
展开参考答案与推导
断言ERR位于中间且最后OK,同时查询两个键。
03如何扩展到键过期引发WATCH变化?
- 输入 / 条件
监视键在EXEC前到期- 输出 / 预期
放弃执行
提示 过期也要更新可观察版本。
展开参考答案与推导
在模型过期删除路径更新版本或墓碑,再按监视快照比较,不能仅移除值。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能设计先校验后写入的脚本,并给出并发冲突和脚本失败的处理方式。
本篇重点加练
无自动回滚使写前验证成为关键边界,异常分类和连接状态必须能被准确观察。
- 无自动回滚
- 写前参数校验
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
两个本地客户端读取同一配额后各自准备减少额度,其中一个在 EXEC 前修改另一个被监视键。记录连接、监视对象和每次事务尝试。
- 提交内容
- 交错脚本与状态记录
- 怎样算完成
- WATCH 作用范围明确
- 区分重新读取与仅重新发送旧命令
练到的知识点MULTI 与 EXEC · WATCH 乐观检查
- 作业 02原理问答
批操作包含类型正确的写入、类型不匹配的操作和后续写入。分别讨论排队阶段错误与执行阶段错误应怎样被客户端识别。
- 提交内容
- 错误分类与响应解析契约
- 怎样算完成
- 每条返回值都有处理规则
- 不把批次当作单个布尔值
练到的知识点运行期错误 · 无自动回滚
- 作业 03代码推演
事务连接在 EXEC 返回到一半时断开,客户端只保存了前几项响应。逐项标出已知、未知和不能推断的状态。
- 提交内容
- 部分响应与状态表
- 怎样算完成
- 不把未收到响应等同未执行
- 区分命令错误和传输错误
练到的知识点无自动回滚
- 作业 04原理问答
脚本参数通过校验,但脚本访问的键可能有意外类型或缺失结构。为验证范围制定契约,区分输入约束和环境前置条件。
- 提交内容
- 前置条件清单
- 怎样算完成
- 包含数字范围与已有键状态
- 解释契约由谁维护
练到的知识点写前参数校验
综合应用
- 作业 05代码推演
Lua 脚本更新余额和消费次数,参数可能超限,余额键也可能类型错误。制定写前验证清单并审查发生错误时哪些写入可能已完成。
- 提交内容
- 脚本评审与边界用例
- 怎样算完成
- 覆盖键类型和运算范围
- 区分校验完整性与运行原子性
练到的知识点Lua 原子执行 · 写前参数校验
- 作业 06设计评审
脚本计划遍历所有用户键计算排名,并从参数拼接额外键名。评估阻塞代价和集群路由约定,提出可验证的限制。
- 提交内容
- 资源预算与键访问清单
- 怎样算完成
- 所有访问键可追踪
- 说明单次执行工作量上界
练到的知识点脚本阻塞 · 显式 KEYS
- 作业 07编程实践
在本地 Redis 比较 WATCH 与 Lua 实现的双字段额度操作,注入竞争、坏参数及错误键类型,逐次保存响应和最终状态。
- 提交内容
- 代码、初始化脚本和对照报告
- 怎样算完成
- 限制脚本循环和实验时长
- 失败后状态通过读取核实
练到的知识点MULTI 与 EXEC · WATCH 乐观检查 · 运行期错误 · 无自动回滚 · Lua 原子执行 · 写前参数校验 · 脚本阻塞 · 显式 KEYS
- 作业 08设计评审
账户扣减脚本先增加审计计数,再检查余额是否满足要求。评审审计计数应代表尝试还是成功,并分析失败后的业务解释。
- 提交内容
- 写入含义与失败矩阵
- 怎样算完成
- 每个字段有明确事实语义
- 不默认已执行写入会撤销
练到的知识点无自动回滚 · 写前参数校验
- 作业 09编程实践
为本地 Lua 小脚本制作输入变异器,改变参数类型、键类型及数值边界,逐例比较执行前后状态;限制键数量与脚本运行量。
- 提交内容
- 脚本、变异样本与报告
- 怎样算完成
- 对每次失败核验实际状态
- 不得向共享实例发送测试脚本
练到的知识点无自动回滚 · 写前参数校验
进阶挑战 · 掌握前置后选做
- 作业 10面试场景
Redis 操作响应在网络中丢失,业务端无法确定配额是否已扣。设计请求身份和恢复查询契约,评估批次重试的边界。
- 提交内容
- 状态图与重试验收表
- 怎样算完成
- 区别执行失败和结果未知
- 覆盖同身份不同业务参数
练到的知识点WATCH 乐观检查 · 无自动回滚 · Lua 原子执行 · 写前参数校验 · 显式 KEYS
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“所有参数合法、脚本单线程原子执行,但业务仍留下不可接受状态”的反例,并明确问题出在何种未验证前提。
- 提交内容
- 反例脚本场景
- 怎样算完成
- 不利用未定义行为
- 区分执行原子性和业务正确性
练到的知识点无自动回滚 · 写前参数校验
- 作业 12思维拓展 · 条件变化
将一段脚本改成需要调用外部价格服务后才决定扣款的流程。原验证及原子执行边界发生什么变化,应怎样定义可实现的业务承诺?
- 提交内容
- 边界变化与契约评审
- 怎样算完成
- 标明外部数据新鲜度
- 不假设 Redis 可以包住任意外部调用
练到的知识点无自动回滚 · 写前参数校验
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Redis:Transactions (新标签页)↗
阅读 MULTI、EXEC、WATCH 与不回滚的语义,区分原子执行和业务补偿。
- 实践教程英文
Redis:Lua 脚本入门 (新标签页)↗
阅读脚本原子执行、阻塞与显式 KEYS,控制脚本复杂度并验证写入前提。
- 深入规范英文
Redis Cluster specification (新标签页)↗
阅读哈希槽、重定向、多键操作和失效窗口,解释集群的能力边界。
- 深入参考文档英文
Jedis Transaction源码 (新标签页)↗
查看EXEC空结果、逐项错误和连接状态清理。