loong博客

Redis 执行 Lua,如何保证原子性?

目录

  • 什么是原子性 ?
  • Redis 如何执行 Lua ?
  • Redis 执行 Lua 需注意什么 ?
  • Redis命令是单线程执行的,为什么还有事务 ?
  • Redis事务不提供自动回滚机制,事务还有什么用处 ?
  • Redis事务与 Redis Lua有什么区别 ?

什么是原子性?

在讨论 Redis Lua 脚本的原子性时,需要区分两种类型的原子性:

  • 执行原子性(Execution Atomicity)

脚本作为一个整体被执行,没有其他命令可以干扰其执行。这类似于 Redis 中单个命令的特性,由于单线程模型,脚本的执行是隔离的,其他客户端必须等待。

  • 事务性原子性(Transactional Atomicity)

脚本的操作要么全部成功,要么全部失败,具有”全或无”的特性。如果某个操作失败,前面的操作应该自动回滚。比如关系型数据库RDBMS的原子性,也就是 ACID (Atomicity、Consistency、Isolation、Durability) 中 Atomicity 这项特性。ACID中的原子性指是事务中的所有操作要么全部执行成功,要么全部失败回滚。

  • 执行原子性与事务性原子性的差异
类型定义Lua脚本是否支持备注
执行原子性脚本作为一个整体执行,无其他命令干扰由 Redis 单线程模型保证,类似单个命令的执行
事务性原子性操作要么全部成功,要么全部失败,有回滚机制需要手动实现错误处理,无内置回滚

Redis 如何执行 Lua ?

Redis使用嵌入的 Lua 解释器来运行 Lua 脚本,确保执行过程是原子的,即在脚本运行期间不会处理其他命令。用户通过 EVALEVALSHA 命令发送 Lua 脚本 , Redis服务器接收后通过 redis.call()redis.pcall() 执行Redis命令。执行期间,Redis服务器阻塞其他命令,确保执行原子性。执行完成后,返回结果给客户端。

EVAL 命令
  • 语法
EVAL script numkeys [key [key ...]] [arg [arg ...]]
  • 流程

    1. Redis 接收到 EVAL 命令后,会计算脚本内容的 SHA1 摘要

    2. 将脚本存入缓存(键为 SHA1 摘要)

    3. 执行脚本逻辑

EVAL "return ARGV[1]" 0 hello
EVALSHA 命令
  • 语法
EVALSHA sha1 numkeys [key [key ...]] [arg [arg ...]]
  • 流程

    1. 使用 EVALSHA 前,需先用 SCRIPT LOAD 命令加载脚本

    2. Redis 根据 SHA1 摘要,查找缓存的脚本

    3. 若找到,直接执行脚本;若未找到,返回错误 NOSCRIPT No matching script

redis> SCRIPT LOAD "return ARGV[1]"
"232fd51614574cf0867b83d384a5e898cfd24e5a"

redis> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0 hello
"hello"
EVAL与EVALSHA的差异
特性EVALEVALSHA
首次执行需要发送脚本体,编译并缓存需要预加载脚本,使用 SHA1 哈希执行
性能首次较慢,后续可能使用缓存每次执行快,无需发送脚本体
缓存依赖自动缓存,易失性,服务器重启后清空依赖预加载,需确保哈希有效
使用场景简单测试或一次性执行频繁执行,性能敏感场景

Redis 执行 Lua 需注意什么 ?

在实际使用中,开发者应注意以下几点:

  • 性能优化

    脚本应尽量简短,避免长时间运行,以免阻塞其他操作。

  • 错误处理

    Redis执行Lua脚本的时候,可以保证脚本的原子性执行,即脚本作为一个整体在Redis中不会被其他命令中断,所有命令按顺序执行。但是如果脚本内部有错误导致中途停止,已经执行的命令不会被回滚,这时候原子性无法保证。因此,正确编写的Lua脚本(没有运行时错误)可以保证原子性,而脚本本身的错误可能导致部分执行,破坏原子性。由于没有自动回滚,开发者需要在脚本中实现错误检查。

  • 集群兼容性

    设计脚本时,确保所有访问的键都在同一节点,否则可能导致执行失败。在 Redis 集群中,脚本的执行需要特别注意键的分布,所有访问的键必须作为输入参数明确提供,确保它们位于同一节点。这是为了维持原子性,因为集群将数据分布在多个节点,每个节点独立处理请求。例如,如果脚本尝试修改分布在不同节点的键,Redis 无法保证跨节点的原子执行。用户需要使用 Redis 集群的哈希标签功能控制键的放置,确保所有相关键在同一节点。

Redis命令是单线程执行的,为什么还有事务 ?

Redis的线程模型
  • 单线程执行读写命令

    Redis使用单线程的事件循环(Event Loop)处理所有客户端请求。所有客户端发送的读写命令(如 GET、SET、INCR 等)会被顺序执行,不会并发处理。单线程避免了多线程的锁竞争和上下文切换开销,简化了实现并保证了原子性。

  • 网络 I/O 多线程(Redis 6.0+)

    I/O线程异步处理网络读/写(解析请求、返回响应),但命令执行仍由主线程单线程处理。降低网络延迟对性能的影响,但核心数据操作不受多线程干扰。

  • 后台任务多线程

    主线程将耗时任务交给后台线程处理,避免阻塞主线程。适用异步删除大Key(UNLINK)、持久化(AOF/RDB)、集群同步等场景。

Redis为什么需要事务 ?

单线程执行就已经保证了原子性,为什么还需要事务?这里先解释单个命令的原子性和多个命令组合的原子性之间的区别。比如,用户希望将多个命令作为一个整体执行,中间不被其他命令干扰,这时候事务就派上用场了。而单线程模型虽然按顺序执行命令,但如果没有事务,客户端发送的多个命令可能会被其他客户端的命令穿插执行,导致结果不符合预期。举个例子,用户可能需要先读取一个键的值,然后根据这个值进行修改。在单线程模型中,虽然每个命令是原子的,但两个命令之间可能会有其他命令执行,导致读取的数据不是最新的。这时候就需要事务来包裹这两个命令,确保在EXEC时这两个命令连续执行,中间没有其他命令插入,从而保证操作的原子性。另外,事务的另一个重要方面是错误处理。在Redis事务中,如果在命令入队时出现错误(比如语法错误),整个事务会被拒绝执行;而如果在执行时出现错误(比如对错误类型的数据进行操作),只有出错的命令会失败,其他命令仍然会执行。

  • 单线程的原子性 ≠ 多命令的原子性

    1. 单命令原子性:每个 Redis 命令(如 SET、INCR)本身是原子执行的,不会被其他命令打断。

    2. 多命令原子性:若需将多个命令组合成一个逻辑单元(例如”先读后写”),单线程模型无法保证这些命令连续执行。其他客户端的命令可能插入其中,导致数据不一致。

  • 隔离性

    虽然单线程保证了命令按顺序执行,但事务可以确保在EXEC执行前,这些命令不会被其他客户端的命令插入,从而保证隔离性。Redis 事务通过 MULTI 将多个命令缓存在队列中, EXEC 时一次性执行,这样可确保:

    1. 命令队列的连续性:事务内的所有命令按顺序执行,不会被其他客户端的操作打断。

    2. 逻辑隔离:事务执行期间,其他客户端看到的是事务开始前的数据快照(通过 WATCH 实现乐观锁)。

  • 乐观锁控制

    Redis的WATCH命令可以监视某些键,如果在事务执行期间这些键被修改,则事务会失败,这需要事务机制来支持。

  • 错误处理与回滚

    1. 入队时错误(如语法错误):整个事务被拒绝执行。

    2. 运行时错误(如对字符串执行 INCR):仅错误命令失败,其他命令仍执行。

    3. 与原子性的关系:Redis 事务不提供回滚机制(与关系型数据库不同),需开发者通过 WATCH 或 Lua 脚本自行处理。

Redis事务不提供自动回滚机制,事务还有什么用处 ?

Redis 的事务虽然不支持回滚,但它在实际场景中仍具有重要价值,主要依赖于其隔离性、批量执行和乐观锁机制来满足特定需求。

Redis事务的核心价值
  1. 命令的隔离性保证:Redis事务通过 MULTI/EXEC 将多个命令打包成一个队列,确保这些命令在单线程模型中连续执行,不会被其他客户端的操作打断。

  2. 通过 WATCH 监视键,若被监视的键在事务执行前被修改,事务会失败(类似 CAS 机制),客户端可重试或处理冲突。

  3. 事务允许一次性发送多个命令,减少客户端与 Redis 服务器之间的网络往返次数,减少网络开销。

无回滚机制下的应对策略

虽然事务内命令出错不会回滚,但可通过以下方式保证数据一致性:

  1. 前置校验(Pre-validation):在事务开始前,通过逻辑判断确保操作合法性。

  2. 使用 Lua 脚本替代事务,脚本执行失败时会自动终止并回滚已执行的命令。

  3. 业务层补偿机制:若事务部分命令失败(如扣款成功但更新状态失败),需在业务代码中设计补偿逻辑(如记录日志、发起退款等)。

Redis事务与 Redis Lua 有什么区别 ?

Lua脚本在Redis中是原子执行的,且更灵活,可以包含条件判断等逻辑。而事务则是通过命令队列的方式执行,不支持条件逻辑,但可以通过 WATCH 实现乐观锁。

特性事务(MULTI/EXEC)Lua 脚本
原子性命令队列连续执行,但无回滚脚本整体原子执行,但不提供事务性原子性
灵活性仅支持简单命令组合支持条件判断、循环等复杂逻辑
性能轻量,适合简单批量操作解析脚本有开销,适合复杂逻辑
并发控制依赖 WATCH 实现乐观锁天然原子性,无需额外控制