【Redis篇】Redis 事务:原子性与脚本执行机制


文章目录

    • [Redis 事务:原子性与脚本执行机制](#Redis 事务:原子性与脚本执行机制)
    • 一、前言
    • [二、Redis 事务的基本概念](#二、Redis 事务的基本概念)
      • [2.1 为什么需要 Redis 事务](#2.1 为什么需要 Redis 事务)
      • [2.2 Redis 事务的三个命令](#2.2 Redis 事务的三个命令)
    • 三、事务的完整流程
      • [3.1 基本使用示例](#3.1 基本使用示例)
      • [3.2 放弃事务](#3.2 放弃事务)
    • 四、事务中的错误处理
      • [4.1 第一类错误:命令语法错误(编译期错误)](#4.1 第一类错误:命令语法错误(编译期错误))
      • [4.2 第二类错误:命令运行时错误(执行期错误)](#4.2 第二类错误:命令运行时错误(执行期错误))
      • [4.3 Redis 事务的"弱原子性"](#4.3 Redis 事务的"弱原子性")
    • [五、WATCH 命令与乐观锁](#五、WATCH 命令与乐观锁)
      • [5.1 事务存在的竞态问题](#5.1 事务存在的竞态问题)
      • [5.2 WATCH 命令](#5.2 WATCH 命令)
      • [5.3 基于 WATCH 实现乐观锁的完整示例](#5.3 基于 WATCH 实现乐观锁的完整示例)
      • [5.4 UNWATCH 命令](#5.4 UNWATCH 命令)
    • [六、Lua 脚本](#六、Lua 脚本)
      • [6.1 Redis 事务的局限性](#6.1 Redis 事务的局限性)
      • [6.2 Lua 脚本的核心优势](#6.2 Lua 脚本的核心优势)
      • [6.3 EVAL 命令语法](#6.3 EVAL 命令语法)
      • [6.4 使用示例](#6.4 使用示例)
      • [6.5 SCRIPT LOAD 与 EVALSHA](#6.5 SCRIPT LOAD 与 EVALSHA)
      • [6.6 Lua 脚本的注意事项](#6.6 Lua 脚本的注意事项)
    • [七、事务 vs Lua 脚本](#七、事务 vs Lua 脚本)
    • 八、总结

Redis 事务:原子性与脚本执行机制

一、前言

💬 这一篇讲什么:Redis 的事务机制与 Lua 脚本

🚀 核心内容

  • Redis 事务的基本流程:MULTI / EXEC / DISCARD
  • 事务中出现错误怎么处理?Redis 事务的"弱原子性"是什么意思?
  • WATCH 命令如何实现乐观锁?
  • Redis 事务的局限性是什么?
  • 为什么 Lua 脚本能弥补事务的不足?Lua 脚本的使用方式

上一篇讲完了 Redis 持久化,这一篇来看 Redis 的事务机制。很多同学从 MySQL 事务的认知出发去理解 Redis 事务,会踩不少坑------Redis 事务和 MySQL 事务差别很大,它并不支持回滚。搞清楚这些差异,才能正确地在业务中使用 Redis 事务。


二、Redis 事务的基本概念

2.1 为什么需要 Redis 事务

Redis 是单线程处理命令的,单条命令的执行是原子的。但很多业务场景需要把多条命令组合在一起原子地执行,中间不能被其他客户端的命令打断,这就是 Redis 事务要解决的问题。

经典例子:转账操作需要同时减少 A 的余额、增加 B 的余额,这两步要么都成功,要么都不执行。

2.2 Redis 事务的三个命令

Redis 事务用三个命令控制:

命令 作用
MULTI 开启事务,进入事务模式
EXEC 提交事务,顺序执行事务中排队的所有命令
DISCARD 丢弃事务,清空命令队列,退出事务模式(相当于"回滚")

三、事务的完整流程

3.1 基本使用示例

bash 复制代码
127.0.0.1:6379> MULTI          # 开启事务
OK
127.0.0.1:6379> SET k1 v1      # 命令进入队列(不立即执行)
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> EXEC           # 提交事务,顺序执行所有命令
1) OK
2) OK
3) (integer) 1

执行 MULTI 后,Redis 会返回 QUEUED 而不是立即执行命令,所有命令都放入一个命令队列中。调用 EXEC 后,Redis 顺序执行队列中的所有命令并返回结果数组。

3.2 放弃事务

执行 DISCARD 可以放弃当前事务,清空命令队列:

bash 复制代码
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> DISCARD        # 放弃事务
OK
# 事务中的命令全部取消,k1 和 k2 均未被修改

四、事务中的错误处理

这是理解 Redis 事务最重要的部分。Redis 事务对错误的处理方式与 MySQL 完全不同,且两类错误的处理方式也不一样

4.1 第一类错误:命令语法错误(编译期错误)

如果在 MULTI 和 EXEC 之间输入了一条语法错误 的命令(比如命令名写错了),Redis 会立即返回错误,并在执行 EXEC 时放弃整个事务

bash 复制代码
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SETXXX k2 v2   # 不存在的命令,语法错误
(error) ERR unknown command 'SETXXX'
127.0.0.1:6379> SET k3 v3
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
# 整个事务被放弃,k1 和 k3 均未被修改

这类错误,整个事务不执行。 表现和我们预期的"事务回滚"接近。

4.2 第二类错误:命令运行时错误(执行期错误)

如果命令语法正确,但执行时 因为数据类型不匹配等原因失败,Redis 会继续执行其他命令,不会回滚

bash 复制代码
127.0.0.1:6379> SET mystr "hello"   # mystr 是字符串类型
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> INCR mystr          # 对字符串执行自增,运行时才会报错
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> EXEC
1) OK                    # SET k1 v1 执行成功
2) (error) ERR value ... # INCR mystr 执行失败,但不影响其他命令
3) OK                    # SET k2 v2 执行成功

这类错误,只有出错的那条命令失败,其他命令继续正常执行。

4.3 Redis 事务的"弱原子性"

从上面两种错误处理可以看出:Redis 事务不支持回滚(Rollback)

在 MySQL 中,只要事务中任意一步出错,整个事务都会回滚,保证"要么全成功,要么全失败"。Redis 的事务做不到这一点:一旦 EXEC 开始执行,即使中途某条命令失败,其他命令也会继续执行,已经成功的命令不会被撤销

因此,Redis 事务被称为**"弱原子性"**:

  • ✅ 保证了事务中的命令不会被其他客户端的命令穿插打断(隔离性)
  • ✅ 保证了命令一定会被顺序执行(有序性)
  • ❌ 不保证全部成功或全部失败(不支持回滚)

为什么 Redis 不支持回滚? Redis 官方给出的理由是:运行时错误通常是编程错误(比如把字符串当数字用),正确编写的程序不应该出现这类错误。不支持回滚使得 Redis 的实现更简单,性能更好。


五、WATCH 命令与乐观锁

5.1 事务存在的竞态问题

假设要实现一个功能:只有当账户余额 balance >= 100 时,才执行扣款操作(减去 100)。用事务写出来可能是:

bash 复制代码
GET balance         # 读取余额,假设返回 200
MULTI
DECR balance 100
EXEC

问题在于:GET balanceMULTI...EXEC 之间存在时间窗口,另一个客户端可能在这个窗口内也读到了 balance=200,也发起了扣款,两个客户端都成功执行,最终 balance 变成了 0,而不是预期的 100。

这就是**Check-Then-Act(先检查后操作)**的竞态条件,普通事务无法解决。

5.2 WATCH 命令

WATCH 命令为 Redis 事务提供了乐观锁机制:

bash 复制代码
WATCH key [key ...]

WATCH 监视一个或多个 key。在 MULTI 之前执行 WATCH,如果在 MULTI 到 EXEC 期间,被监视的 key 被任何其他客户端修改,执行 EXEC 时整个事务会自动失败,返回 nil(而不是命令结果数组)。

bash 复制代码
# 客户端 A
127.0.0.1:6379> WATCH balance     # 监视 balance
OK
127.0.0.1:6379> GET balance       # 读取余额 = 200
"200"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY balance 100
QUEUED

# === 此时客户端 B 修改了 balance ===
# 客户端 B: SET balance 50

127.0.0.1:6379> EXEC              # 回到客户端 A 提交事务
(nil)                             # 返回 nil,事务失败,balance 未被修改

5.3 基于 WATCH 实现乐观锁的完整示例

实际业务中,配合重试逻辑使用 WATCH:

python 复制代码
import redis

r = redis.Redis()

while True:
    with r.pipeline() as pipe:
        try:
            pipe.watch('balance')            # 监视 balance
            balance = int(pipe.get('balance'))

            if balance < 100:
                print("余额不足,取消操作")
                pipe.unwatch()               # 取消监视
                break

            pipe.multi()                     # 开启事务
            pipe.decrby('balance', 100)
            pipe.execute()                   # 提交事务
            print("扣款成功")
            break

        except redis.WatchError:
            # 监视的 key 被其他客户端修改,重试
            print("数据被修改,重试中...")
            continue

WATCH 的工作原理:乐观锁,假设并发冲突不常见。不上锁,只在提交时检查是否有冲突;有冲突就放弃并重试,没冲突就直接提交。性能比悲观锁(每次操作都加锁)好得多,适合冲突概率低的场景。

5.4 UNWATCH 命令

bash 复制代码
UNWATCH

取消当前客户端对所有 key 的监视。执行 EXEC 或 DISCARD 后,WATCH 监视会自动取消,无需手动 UNWATCH。


六、Lua 脚本

6.1 Redis 事务的局限性

虽然 WATCH + 事务可以实现乐观锁,但 Redis 原生事务有一个根本的局限性:无法在事务执行期间根据命令结果来决定下一步操作。

比如:如果 key 不存在就 SET,如果已存在就 INCR。用事务写不了,因为 MULTI 进入队列阶段无法读取命令结果,无法做条件判断。

Lua 脚本完美解决了这个问题。

6.2 Lua 脚本的核心优势

Redis 内置了 Lua 解释器,通过 EVAL 命令可以执行 Lua 脚本。Lua 脚本在 Redis 中具有以下关键特性:

原子性 :Lua 脚本在 Redis 中是原子执行的。执行期间,其他客户端的命令不会被执行,不存在并发问题。这是比原生事务更强的保证。

支持条件判断:Lua 脚本是完整的编程语言,可以在脚本中读取 Redis 数据,然后根据数据决定执行什么操作,原生事务做不到这一点。

减少网络往返:多条命令打包在一个 Lua 脚本中,只需要一次网络请求,比多次 COMMAND 调用效率更高。

6.3 EVAL 命令语法

bash 复制代码
EVAL script numkeys key [key ...] arg [arg ...]

参数说明:

  • script:Lua 脚本内容
  • numkeys:传入的 key 数量
  • key [key ...]:传给脚本的 key 名称,在脚本中通过 KEYS[1]KEYS[2] 访问(下标从 1 开始)
  • arg [arg ...]:传给脚本的额外参数,在脚本中通过 ARGV[1]ARGV[2] 访问

在 Lua 脚本中调用 Redis 命令的方式:

lua 复制代码
redis.call('命令名', 参数1, 参数2, ...)
redis.pcall('命令名', 参数1, 参数2, ...)  -- 出错时不抛异常,返回错误信息

6.4 使用示例

示例一:基本使用

bash 复制代码
# 设置一个 key,值为传入的参数
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey hello
OK

127.0.0.1:6379> GET mykey
"hello"

示例二:条件判断(事务做不到的事)

bash 复制代码
# 如果 key 不存在则设置值,如果已存在则自增
# 这在原生事务中无法实现,但 Lua 脚本可以轻松做到
127.0.0.1:6379> EVAL "
  if redis.call('EXISTS', KEYS[1]) == 0 then
    return redis.call('SET', KEYS[1], ARGV[1])
  else
    return redis.call('INCR', KEYS[1])
  end
" 1 counter 100
OK

127.0.0.1:6379> EVAL "
  if redis.call('EXISTS', KEYS[1]) == 0 then
    return redis.call('SET', KEYS[1], ARGV[1])
  else
    return redis.call('INCR', KEYS[1])
  end
" 1 counter 100
(integer) 101

示例三:用 Lua 实现原子性的库存扣减

bash 复制代码
127.0.0.1:6379> EVAL "
  local stock = tonumber(redis.call('GET', KEYS[1]))
  if stock == nil or stock <= 0 then
    return 0
  end
  redis.call('DECRBY', KEYS[1], ARGV[1])
  return 1
" 1 product:1001:stock 1
(integer) 1   -- 返回 1 表示扣减成功,返回 0 表示库存不足

6.5 SCRIPT LOAD 与 EVALSHA

如果一个 Lua 脚本需要频繁执行,每次都传输完整的脚本内容会浪费带宽。可以先加载脚本,得到一个 SHA1 摘要,后续用摘要来执行:

bash 复制代码
# 加载脚本到 Redis,返回 SHA1 摘要
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5"

# 用 SHA1 执行脚本(传输数据量更小)
127.0.0.1:6379> EVALSHA e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5 1 mykey
"hello"

# 检查某个脚本是否已加载
127.0.0.1:6379> SCRIPT EXISTS e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5
1) (integer) 1

# 清空所有已缓存的脚本
127.0.0.1:6379> SCRIPT FLUSH
OK

6.6 Lua 脚本的注意事项

不要在 Lua 脚本中执行耗时操作。 Lua 脚本执行期间,Redis 主线程被阻塞,其他所有客户端命令都要等待。如果脚本执行了耗时的循环计算,会严重影响 Redis 的响应时间。

Redis 会超时强制终止 Lua 脚本。 通过 lua-time-limit 配置(默认 5000ms),超过此时间还未完成的脚本会被强制停止。

Lua 脚本在 Redis 集群中有限制。 脚本中涉及的所有 key 必须在同一个 slot(哈希槽)中,否则会执行失败。在集群模式下,建议使用 Hash Tags 把相关 key 固定到同一个 slot。


七、事务 vs Lua 脚本

对比维度 Redis 事务 Lua 脚本
原子性 弱(不支持回滚) 强(执行期间完全阻塞其他命令)
支持条件判断 ❌ 不支持 ✅ 支持完整的编程逻辑
错误处理 运行时错误不回滚 可用 pcall 捕获错误
并发安全 需要配合 WATCH 实现乐观锁 天然原子,无需额外处理
网络开销 多次命令 + MULTI/EXEC 一次请求
适用场景 简单的多命令原子批量执行 复杂的需要条件判断的原子操作

结论

  • 简单的多命令批量执行,且不需要条件判断 → 事务(MULTI/EXEC)
  • 需要在 Redis 端做条件判断、逻辑运算 → Lua 脚本
  • 需要乐观锁保护共享数据 → WATCH + 事务 ,或直接用 Lua 脚本

八、总结

事务三件套MULTI 开启 → 命令入队列 → EXEC 提交 / DISCARD 放弃

两类错误不同处理:语法错误 → 整个事务放弃;运行时错误 → 仅该命令失败,其他命令继续执行

弱原子性:Redis 事务不支持回滚,这是与 MySQL 事务最大的区别

WATCH 乐观锁:监视 key,如果在 MULTI~EXEC 期间被其他客户端修改,EXEC 自动失败

Lua 脚本:原子执行,支持条件判断,弥补了原生事务的最大局限

EVALSHA:预加载脚本 + SHA1 摘要执行,减少重复传输开销

相关推荐
jeffer_liu1 小时前
Spring AI 生产级实战-结构化输出
java·人工智能·后端·spring·大模型
飞天狗1111 小时前
2024第十五届蓝桥杯c/c++B组国赛题解
c语言·数据结构·c++·算法·蓝桥杯
努力攻坚操作系统1 小时前
Elasticsearch 完全教学指南:从入门到精通
大数据·数据库·elasticsearch·搜索引擎·全文检索
睡不醒男孩0308231 小时前
行业解决方案二:CLup打造企业级数据库私有云(DBaaS)平台解决方案
数据库·云计算·clup
猴哥聊项目管理1 小时前
2026年信创项目管理:如何用甘特图提升进度管控
大数据·数据库·项目管理·企业数字化转型·甘特图·敏捷开发·项目进度管理软件
Tenifs1 小时前
深入对比分析 RabbitMQ、RocketMQ 和 Kafka
后端·kafka·消息队列·rabbitmq·rocketmq·爱编程的阿彬
rsuhbsrjms1 小时前
可视采耳仪器多少钱一台?可视耳勺哪个牌子好?口碑好的可视耳勺
网络·人工智能·算法
j7~1 小时前
MySQL C语言连接库和MYSQL连接池原理与简易数据网站数据流动是如何进行的
c语言·数据库·mysql·连接池·mysqlc语言连接库
finhaz1 小时前
神经网络等机器学习模型的看法
算法