qinghuan
← 数据系统 / 全部笔记

单元 72 / 数据系统

P0 · 必会主线L2 · 进阶

Redis 事务与 Lua:中途失败后,哪些修改还在

通过一条故意失败的命令观察实际结果,分清连续执行、WATCH 冲突检查与失败回滚。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计先校验后写入的脚本,并给出并发冲突和脚本失败的处理方式。

学习理由、准备要求与知识点

为什么学它

秒杀和去重常依赖 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 操作的原子范围内。

正在加载流程图…

排队拒绝、WATCH冲突和执行中的单项错误是三类不同结果,不能用一个成功布尔值掩盖。
查看流程图源码(Mermaid)
flowchart TD
 A["WATCH并读取"] --> B["MULTI排队"]
 B --> C{"排队是否合法"}
 C -->|"否"| D["EXEC拒绝队列"]
 C -->|"是"| E{"监视键有变化"}
 E -->|"是"| F["放弃执行并重新读取"]
 E -->|"否"| G["顺序执行每条命令"]
 G --> H["逐项结果含运行错误"]
 H --> I["检查最终业务状态"]

按顺序看,每一步做了什么

  1. 先用小命令观察QUEUED与实际返回值区别。
  2. 制造整数或类型运行错误,检查前后命令状态。
  3. 用两个连接复现WATCH冲突并重新读取。
  4. Lua先校验后写,给工作量和键集合设边界。
  5. 应用逐项处理EXEC结果,连接结束时清理状态。

03对照实现代码

Jedis Transaction.exec逐项保留错误 (新标签页)↗

Jedis v5.2.0 · Transaction#exec、#clear、#discard

原创结果解码模型,非源码节选。仅演示空EXEC结果与数组内错误的区别,不发送命令或复现全部Response类型。

核心机制代码 · java
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 会据此跳过正常清理流程。应用应按客户端的异常与资源关闭规则处理,不能忽略通信失败后继续把原连接当正常连接复用。

代码先调用谁,再调用谁

  1. MULTI与appendCommand
  2. exec检查状态
  3. 发送EXEC
  4. 读取多项结果
  5. 逐项解码错误
  6. 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逐项按顺序反映结果,单项运行失败不撤销其他成功命令。

时间 O(C+W+L)额外空间 O(C+K)

C为命令数,W为监视键数,L为数值解析字符总量,K为存储键数;结果列表长度为C。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 59 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能设计先校验后写入的脚本,并给出并发冲突和脚本失败的处理方式。

本篇重点加练

无自动回滚使写前验证成为关键边界,异常分类和连接状态必须能被准确观察。

  • 无自动回滚
  • 写前参数校验

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    两个本地客户端读取同一配额后各自准备减少额度,其中一个在 EXEC 前修改另一个被监视键。记录连接、监视对象和每次事务尝试。

    提交内容
    交错脚本与状态记录
    怎样算完成
    • WATCH 作用范围明确
    • 区分重新读取与仅重新发送旧命令

    练到的知识点MULTI 与 EXEC · WATCH 乐观检查

  2. 作业 02原理问答

    批操作包含类型正确的写入、类型不匹配的操作和后续写入。分别讨论排队阶段错误与执行阶段错误应怎样被客户端识别。

    提交内容
    错误分类与响应解析契约
    怎样算完成
    • 每条返回值都有处理规则
    • 不把批次当作单个布尔值

    练到的知识点运行期错误 · 无自动回滚

  3. 作业 03代码推演

    事务连接在 EXEC 返回到一半时断开,客户端只保存了前几项响应。逐项标出已知、未知和不能推断的状态。

    提交内容
    部分响应与状态表
    怎样算完成
    • 不把未收到响应等同未执行
    • 区分命令错误和传输错误

    练到的知识点无自动回滚

  4. 作业 04原理问答

    脚本参数通过校验,但脚本访问的键可能有意外类型或缺失结构。为验证范围制定契约,区分输入约束和环境前置条件。

    提交内容
    前置条件清单
    怎样算完成
    • 包含数字范围与已有键状态
    • 解释契约由谁维护

    练到的知识点写前参数校验

综合应用

  1. 作业 05代码推演

    Lua 脚本更新余额和消费次数,参数可能超限,余额键也可能类型错误。制定写前验证清单并审查发生错误时哪些写入可能已完成。

    提交内容
    脚本评审与边界用例
    怎样算完成
    • 覆盖键类型和运算范围
    • 区分校验完整性与运行原子性

    练到的知识点Lua 原子执行 · 写前参数校验

  2. 作业 06设计评审

    脚本计划遍历所有用户键计算排名,并从参数拼接额外键名。评估阻塞代价和集群路由约定,提出可验证的限制。

    提交内容
    资源预算与键访问清单
    怎样算完成
    • 所有访问键可追踪
    • 说明单次执行工作量上界

    练到的知识点脚本阻塞 · 显式 KEYS

  3. 作业 07编程实践

    在本地 Redis 比较 WATCH 与 Lua 实现的双字段额度操作,注入竞争、坏参数及错误键类型,逐次保存响应和最终状态。

    提交内容
    代码、初始化脚本和对照报告
    怎样算完成
    • 限制脚本循环和实验时长
    • 失败后状态通过读取核实

    练到的知识点MULTI 与 EXEC · WATCH 乐观检查 · 运行期错误 · 无自动回滚 · Lua 原子执行 · 写前参数校验 · 脚本阻塞 · 显式 KEYS

  4. 作业 08设计评审

    账户扣减脚本先增加审计计数,再检查余额是否满足要求。评审审计计数应代表尝试还是成功,并分析失败后的业务解释。

    提交内容
    写入含义与失败矩阵
    怎样算完成
    • 每个字段有明确事实语义
    • 不默认已执行写入会撤销

    练到的知识点无自动回滚 · 写前参数校验

  5. 作业 09编程实践

    为本地 Lua 小脚本制作输入变异器,改变参数类型、键类型及数值边界,逐例比较执行前后状态;限制键数量与脚本运行量。

    提交内容
    脚本、变异样本与报告
    怎样算完成
    • 对每次失败核验实际状态
    • 不得向共享实例发送测试脚本

    练到的知识点无自动回滚 · 写前参数校验

进阶挑战 · 掌握前置后选做

  1. 作业 10面试场景

    Redis 操作响应在网络中丢失,业务端无法确定配额是否已扣。设计请求身份和恢复查询契约,评估批次重试的边界。

    提交内容
    状态图与重试验收表
    怎样算完成
    • 区别执行失败和结果未知
    • 覆盖同身份不同业务参数

    练到的知识点WATCH 乐观检查 · 无自动回滚 · Lua 原子执行 · 写前参数校验 · 显式 KEYS

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造“所有参数合法、脚本单线程原子执行,但业务仍留下不可接受状态”的反例,并明确问题出在何种未验证前提。

    提交内容
    反例脚本场景
    怎样算完成
    • 不利用未定义行为
    • 区分执行原子性和业务正确性

    练到的知识点无自动回滚 · 写前参数校验

  2. 作业 12思维拓展 · 条件变化

    将一段脚本改成需要调用外部价格服务后才决定扣款的流程。原验证及原子执行边界发生什么变化,应怎样定义可实现的业务承诺?

    提交内容
    边界变化与契约评审
    怎样算完成
    • 标明外部数据新鲜度
    • 不假设 Redis 可以包住任意外部调用

    练到的知识点无自动回滚 · 写前参数校验

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗