• Redis 到底能不能保证原子性?

Redis 到底能不能保证原子性?

2025-04-27 10:40:08 栏目:宝塔面板 22 阅读

Redis是 Java程序员工作中经常使用的一个 NoSQL,很多人把它对标成数据库,因此,原子性成了特别关注的问题。那么,Redis到底能不能保证原子性?这篇文章来聊一聊。

一、原子性

要想弄清楚这个问题,我们需要对“原子性”这个概念有一个清晰的认识,因此,首先要分析的是原子性的概念。

1. 通常意义的原子性

通常意义上,我们说的原子性是指关系型数据库 RDBMS(比如 MySQL)的原子性,也就是 ACID(Atomicity、Consistency、Isolation、Durability)中 Atomicity这一项特性。

ACID 中的原子性指:事务中的所有操作要么全部执行,要么全部不执行。

这里以银行转账,账户A 给账户B 转账100元为例来解释原子性:

  • 账户A 减去100元;
  • 账户B 增加100元;

原子性是指上面两个过程,要么全部执行,要么全部不执行。也就是说,账户A 减去 100元的同时,账户B 必须增加100元,否则,该操作就不具备原子性。Java代码简要实现如下图:

2. Lua 原子性

在分析 Lua的原子性之前,我们先看看 Lua是什么,下图摘自 Lua官方描述:

从官方描述可以得知:Lua 是一种功能强大、高效、轻量级、可嵌入的脚本语言。它支持过程编程、面向对象编程、函数式编程、数据驱动编程和数据描述。 Lua 将简单的过程语法与基于关联数组和可扩展语义的强大数据描述结构相结合。Lua 是动态类型的,通过使用基于寄存器的虚拟机解释字节码来运行,并具有自动内存管理和增量垃圾回收功能,使其成为配置、脚本编写和快速原型设计的理想选择。

Lua 本身并没有提供对于原子性的直接支持,它只是一种脚本语言,通常是嵌入到其他宿主程序中运行,比如 Redis。

在 Redis中执行 Lua的原子性是指:整个 Lua脚本在执行期间,会被当作一个整体,不会被其他客户端的命令打断。

为了对 Redis执行 Lua的原子性有一个感官上的认识,这里以 Lua脚本中需要完成 SET key1 value1 和 INCRBY key2 value2 和 SET key3 value3 三个命令为例:

上述例子,整个 luaScript 字符串脚本作为一个整体被执行且不被其他事务打断,这就是一个原子性的操作。

好了,总结下 ACID的原子性和 Redis执行 Lua脚本原子性在概念上的差异:

  • ACID的原子性是指:事务中的命令要么全执行,要么全部不执行;
  • Redis中执行 Lua脚本原子性是指:Lua脚本会作为一个整体执行且不被其他客户端打断,至于 Lua脚本里面的命令是否必须全部成功,或者全部失败,并不要求。关于这一点,在接下来的内容也会详细解释;

在分析原子性概念时,我们可以发现“原子性”其实是事务中的一项特性,因此,接下来分析 Redis的事务。

二、Redis 事务

下图是 Redis官方对事务描述的摘要:

文档看起来很长,总结成一句话:Redis 事务允许执行一批命令,通过执行 MULTI命令开启事务,执行 EXEC命令结束事务,WATCH 和 DISCARD 配合事务一起使用,提供了一种 CAS(check-and-set) 乐观锁的机制。WATCH 用于监听 Key,如果被监听的 Key有任何一个发生变化,则中止事务(被动关闭事务),而 DISCARD 用于主动中止事务。

1. MULTI/EXEC

用一个示例来理解 MULTI/EXEC:

通过执行的结果可以看出:Redis的事务是以 MULTI命令开启,以 EXEC命令结束,期间所有的命令都是先进入队列,只有执行 EXEC命令时,才会把队列中的所有命令顺序串行执行,并且返回一个所有命令执行结果的数组,包括命令执行的错误信息。

需要注意的是:在 EXEC 执行后,即使事务队列中有命令执行失败,队列中的所有其他命令也会被处理,Redis 不会停止执行这些命令。

DISCARD 和 WATCH 也是 Redis 中用于事务的两个命令,它们与 MULTI 和 EXEC 一起使用,提供更复杂的事务处理机制。

2. WATCH

WATCH 命令用于监听一个或多个 Key,如果在执行事务期间这些 Key中任何一个Key的 value被其他事务修改,当前整个事务将会被中止。(需要注意:低于 6.0.9 的 Redis 版本,Key过期不会中止事务)

如下示例:事务1 watch key1 key2,事务2在事务1执行期间修改 key2 = 10,当事务1执行 exec命令时,因为 watch监听到 key2被其他事务(事务2)修改了(value=10) , 因此事务1被取消,事务队列中的所有命令被清除,即 set key1 value1 和 incrby key 2两条命令都不执行,key2的 value还是10;

事务1

事务2

watch key1 key2


multi


set key1 value1


incrby key2 2

set key2 10

exec


keys * // 只有key2=10

keys * // 只有key2=10DISCARD

DISCARD 命令用于中止事务。

如下示例,执行 DISCARD命令后,当前事务被中止,因此,执行 EXEC 时会报“ERR EXEC without MULTI”错误。

3. 事务中的错误

事务中主要会出现两种类型的错误:

(1) 事务命令进入事务队列之前出错。例如,命令语法错误(参数错误、命令名称错误等),或者可能存在一些关键情况,比如内存不足。如下示例,命令incr key2 1/0 在进入事务队列之前报错,所以,当前事务被中止,执行 EXEC命令会报错:

(2) 调用 EXEC 命令后,事务队列中的命令执行失败。例如,对字符串值进行加1操作。如下示例,key的 value是字符串,当对 key 执行incr key 操作时报错,因此,该条命令执行失败:

4. 事务回滚

Redis的事务不支持回滚。 官方说明如下:

Redis 不支持事务回滚,因为支持回滚会对 Redis 的简单性和性能产生重大影响。

官方说明简明扼要,其实,多加思考也能理解:"Redis" 是 "REmote DIctionary Server" 的缩写,翻译为“远程字典服务”,设计的初衷是用于缓存,追求快速高效。而了解过 ACID事务的小伙伴应该能明白事务回滚的复杂度,因此,Redis不支持事务回滚似乎也合情合理。

到此,我们也对 Redis事务做个小结:Redis的事务由 MULTI/EXEC 两个命令完成,WATCH/DISCARD 两个命令的加持,给 Redis事务提供了 CAS 乐观锁机制。Redis 事务不支持回滚,它和关系型数据库(比如 MySQL)的事务(ACID)是不一样的。

三、Redis 如何执行 Lua?

分析完原子性和 Redis事务这些理论知识后,我们就得动手实操,看看 Redis是如何执行 Lua的。

一般情况下,Redis执行 Lua常用的方法有 2种:

  • 原生命令,比如 EVAL/EVALSHA命令等;
  • 编程工具,比如编程语言中提供的三方工具包或类库;

在编写 Lua脚本时,需要注意区分 redis.call() 和 redis.pcall() 两个命令的使用。

1. EVAL

语法:

EVAL script numkeys [key [key ...]] [arg [arg ...]]

EVAL语法很简单,EVAL script numkeys 是必填项,[key [key ...]] [arg [arg ...]]是选填项。

如下示例截图,分别展示了不传Key,传 1个key 和 2个 key 3种场景:

下图示例展示了 [key [key ...]] [arg [arg ...]] 和 numkeys 匹配错误时报错的场景:

2. redis.call()

redis.call() 用于执行 Redis的命令。当命令执行出错时,会阻断整个脚本执行,并将错误信息返回给客户端。

如下示例:当执行INCRBY key2 1/0 失败时,会抛异常,后续流程被阻断,即SET key3 value3没有被执行。

Redis原生命令执行示例如下:

EVAL "redis.call('SET', 'key1', 'value1'); redis.call('INCRBY', 'key2', 1/0); redis.call('SET', 'key3', 'value3')" 0

使用 Jedis框架执行 Lua示例如下:

查看 Lua执行后各个key的值。

3. redis.pcall()

redis.pcall() 也用于执行 Redis的命令。当命令执行出错时,不会阻断脚本的执行,而是内部捕获错误,并继续执行后续的命令。

如下示例:当执行INCRBY key2 1/0 失败时,不会抛异常,后续流程继续执行,即SET key3 value3 也被执行。

Redis原生命令执行示例:

EVAL "redis.pcall('SET', 'key1', 'value1'); redis.pcall('INCRBY', 'key2', 1/0); redis.pcall('SET', 'key3', 'value3')" 0

使用 Jedis框架执行 Lua示例:

对于 Lua中 redis.call() 和 redis.pcall() 如何选择,需要根据实际业务来判断,标准是:当 Lua脚本中某条命令执行出错时,是否需要阻断后续的命令执行。

四、如何保证原子性?

首先,可以肯定的是:Redis执行 Lua脚本可以保证原子性,不过这和 Redis Server的部署方式密不可分。

Redis是典型的 C/S(Client/Server) 模型,如下图:

因此,Redis 通常有 3种不同的部署方式,部署方式不同,原子性的保证也不一样。

1. 单机部署

不管 Lua脚本中操作的 key是不是同一个,都能保证原子性;

2. 主从部署

Redis 主从复制是用于将主节点的数据同步到从节点,以保持数据的一致性。而Redis的所有写操作都在主节点上,所以,不管 Lua脚本中操作的 key是不是同一个,都能保证原子性;

需要注意:当主节点执行写命令时,从节点会异步地复制这些写操作。在这个复制的过程中,从节点的数据可能与主节点存在一定的延迟。因此,如果在 Lua 脚本中包含读操作,并且该脚本在主节点上执行,可能会读到最新的数据,但如果在从节点上执行,可能会读到稍有延迟的数据。

3. Cluster集群部署

如果 Lua脚本操作的 key是同一个,能保证原子性;

如果操作的 Key不相同,可能被 hash 到不同的 slot,也可能 hash 到相同的 slot,所以不一定能保证原子性;

因此,在 Cluster集群部署的环境下使用 Lua脚本时一定要注意:Lua脚本中操作的是同一个 Key;

4. 原子性保证

这里以 Redis单机部署为例:当客户端向服务器发送一个带有 Lua脚本的请求时,Redis会把该脚本当作一个整体,然后加载到一个脚本缓存中,因为 Redis读写命令是单线程操作(关于 Redis的单线程模型和多路复用线程模型会在其他的文章中讲解),最终,Lua脚本的读写在 Redis服务器上可以简单地抽象成下图,所有的 Lua脚本会按照进入顺序放入队列中,然后串行进行读写,这样就保证每个 Lua不会被其他的客户端打断,从而保证了原子性:

五、面试该如何回答?

在面试中,Redis 执行 Lua脚本时,能否保证原子性?这个问题如何作答?

  • 第一步,需要解释这里的原子性是什么?它和关系数据事务 ACID中的一致性的差异是什么?消除原子性在具体载体(RDBMS/NoSQL)上概念的差异;
  • 第二步,需要解释 Redis的事务,说明 RDBMS/NoSQL 在事务上的差异点;
  • 第三步,需要解释 Redis在不同部署方式下原子性能否保证。Redis部署方式有3种:单机部署,主从部署,Cluster集群部署,需要说明在哪些部署方式下能保证原子性,哪些不能保证原子性;
  • 第四步,解释 Redis 执行 Lua脚本是如何保证原子性;
  • 第五步,分析下 Redis的单线程模型 和 IO多路复用模型(加分项),这步是可选项;

六、Why Lua?

既然 Redis事务能保证原子性,为什么还需要 Lua脚本呢?

  • Lua 是一种嵌入式语言,是 Redis官方推荐的脚本语言;
  • Lua 脚本一般比 MULTI/EXEC 更快、更简单;
  • Redis 事务中,事务队列中的所有命令都必须在 EXEC命令执行才会被执行,对于多个命令之间存在依赖关系,比如后面的命令需要依赖上一个命令结果的场景,Redis事务无法满足,因此 Lua 脚本更适合复杂的场景;
  • Redis 事务能做的 Lua能做,Redis事务做不到的 Lua也能做;

七、Lua注意事项

Redis执行 Lua脚本时,Lua的编写需要注意以下几个点:

  • 不要在 Lua脚本中使用阻塞命令(如BLPOP、BRPOP等)。因此这些命令可能会导致 Redis服务器在执行脚本期间被阻塞,无法处理其他请求;
  • 不要编写过长的 Lua脚本。因为 Redis读写命令是单线程,过长的脚本,加载,解析,运行会比较耗时,导致其他命令的延迟延迟增加;
  • 不要在 Lua脚本中进行复杂耗时的逻辑;因为 Redis读写命令是单线程的,长时间运行脚本可能导致其他命令的延迟增加;
  • Lua脚本中,需要注意区分 redis.call() 和 redis.pcall() 命令;
  • Lua 索引表从索引 1 开始,而不是 0;

八、总结

  • 原子性需要区分具体使用的载体,在关系型数据库(比如 MySQL))和 No SQL(比如Redis)中,原子性的概念是不相同的;
  • Redis的事务(MULTI/ESXEC)和关系型数据库(比如 MySQL)的事务(ACID)也是不相同的;
  • ACID的原子性指:命令要么全部执行,要么全部不执行;
  • Redis执行 Lua脚本的原子性指:Lua脚本会当作一个整体被执行且不被其他事务打断,但是 Lua 脚本里面的命令无法保证“要么全部执行,要么全部不执行”;
  • Lua脚本使用 redis.pcall() 执行命令出错时会被catch,后续命令会正常执行;
  • Lua脚本使用 redis.call() 执行命令出错时会抛给客户端,后续命令会被阻断;
  • Lua 脚本一般比 MULTI/EXEC 更快、更简单;
  • Redis的部署方式决定了 Redis执行 Lua脚本是否能保证原子性,编写 Lua脚本时,特别需要注意在一个事务中是否要求操作同一个 key。

本文地址:https://www.yitenyun.com/136.html

搜索文章

Tags

数据库 API FastAPI Calcite 电商系统 MySQL 数据同步 ACK 双主架构 循环复制 Web 应用 异步数据库 生命周期 序列 核心机制 Deepseek 宝塔面板 Linux宝塔 Docker JumpServer JumpServer安装 堡垒机安装 Linux安装JumpServer esxi esxi6 root密码不对 无法登录 web无法登录 Windows Windows server net3.5 .NET 安装出错 宝塔面板打不开 宝塔面板无法访问 SSL 堡垒机 跳板机 HTTPS Windows宝塔 Mysql重置密码 无法访问宝塔面板 HTTPS加密 查看硬件 Linux查看硬件 Linux查看CPU Linux查看内存 ES 协同 修改DNS Centos7如何修改DNS scp Linux的scp怎么用 scp上传 scp下载 scp命令 防火墙 服务器 黑客 Serverless 无服务器 语言 存储 Oracle 处理机制 Spring SQL 动态查询 Linux 安全 网络架构 工具 网络配置 开源 PostgreSQL 存储引擎 RocketMQ 长轮询 配置 加密 场景 MySQL 9.3 缓存方案 缓存架构 缓存穿透 HexHub Canal Rsync 架构 InnoDB 日志文件 MIXED 3 信息化 智能运维 响应模型 线上 库存 预扣 监控 B+Tree ID 字段 索引 数据 业务 AI 助手 数据库锁 聚簇 非聚簇 单点故障 GreatSQL Hash 字段 分库 分表 优化 万能公式 云原生 DBMS 管理系统 SpringAI Redis 自定义序列化 Redis 8.0 openHalo OB 单机版 数据集成工具 sqlmock SVM Embedding PostGIS 系统 SQLark 虚拟服务器 虚拟机 内存 SQLite Redka ​Redis 机器学习 推荐模型 sftp 服务器 参数 共享锁 RDB AOF Netstat Linux 服务器 端口 分页查询 排行榜 排序 prometheus Alert SQLite-Web 数据库管理工具 自动重启 运维 缓存 聚簇索引 非聚簇索引 • 索引 • 数据库 Entity 开发 技术 Testcloud 云端自动化 查询 EasyExcel MySQL8 向量数据库 大模型 同城 双活 容器化 分布式架构 分布式锁​ 数据类型 OAuth2 Token StarRocks 数据仓库 Doris SeaTunnel 分页 数据结构 不宕机 IT 数据备份 Postgres OTel Iceberg MongoDB 容器 LRU Milvus IT运维 人工智能 推荐系统 连接控制 机制 AIOPS Python Web 部署 悲观锁 乐观锁 池化技术 连接池 Caffeine CP 崖山 新版本 高可用 向量库 Ftp redo log 重做日志 电商 MCP mini-redis INCR指令 MVCC 事务隔离 磁盘架构 流量 开放协议 字典 QPS 高并发 原子性 对象 微软 SQL Server AI功能 单线程 线程 速度 服务器中毒 Web 接口 R2DBC 双引擎 RAG HelixDB 数据脱敏 加密算法 窗口 函数 频繁 Codis 主库 Order Crash 代码 ZODB SSH 网络 Pottery dbt 数据转换工具 1 PG DBA 工具链 引擎 优化器 List 类型 性能 InfluxDB 模型 传统数据库 向量化 发件箱模式 意向锁 记录锁 事务同步 UUIDv7 主键 网络故障 仪表盘 Redisson 锁芯 线程安全 INSERT COMPACT Undo Log LLM 订单 JOIN 连接数