使用 NestJs 基于 Redis 编写一个简单的demo,让你彻底搞懂 Redis事务 和 lua 脚本

Redis 中的事务功能允许你将多个命令打包成一个原子性操作。这意味着事务中的所有命令都会连续执行,而在执行期间,Redis 不会执行任何其他客户端的命令。这为你提供了一种在执行多步操作时维持数据一致性的方式。

Redis 事务基础

Redis 事务基于三个核心命令:MULTI, EXEC, 和 DISCARD。下面是这些命令的具体工作方式和用途:

  1. MULTI:这个命令用来初始化一个事务,它告诉 Redis 之后发送的命令需要被排队,直到发出 EXEC 命令为止。在这个阶段,命令不会被执行,只是被收集和存储起来。

  2. 命令队列:一旦输入 MULTI,后续的所有命令都会被添加到一个队列中,这些命令在事务执行前都不会被实际执行。这些命令的结果也不会立即返回,而是通常返回一个简单的响应 QUEUED。

  3. EXEC:执行所有在 MULTI 后面排队的命令。如果没有发现问题(例如,使用了 WATCH 命令且被监视的键未被修改),所有命令将原子性地执行。如果命令执行中有错误,错误的命令会失败,但是其他命令依然会执行。

  4. DISCARD:这个命令用来取消一个事务,它将清空当前事务队列中的所有命令。

  5. WATCH:这个命令可以在事务执行前监视一个或多个键,如果在执行事务前这些键被其他命令修改了,那么事务将不会执行,EXEC 将返回一个错误。这是一种乐观锁的机制。

  6. UNWATCH:取消对所有键的监视,或者在执行 EXEC 后自动取消之前设置的所有监视。

这些命令的组合使用为 Redis 提供了在不支持传统 ACID 事务特性的情况下,执行一组操作的能力,同时保持高性能和简单性。事务在 Redis 中是按照命令序列化执行,避免了传统数据库中事务可能导致的性能问题。通过使用这些事务命令,可以安全地执行涉及多个步骤的操作,而不必担心在执行过程中由于其他操作影响数据的完整性和一致性。

Redis 中的 ACID

ACID 是传统数据库事务的核心属性,代表原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。现在我们来看看这些属性在 Redis 中如何表现:

原子性(Atomicity)

原子性意味着事务中的所有操作都是作为一个整体执行的,要么全部成功,要么全部失败。在 Redis 中,这通过 MULTI 和 EXEC 命令来保证。当执行 EXEC 时,队列中的所有命令都会被连续执行,如果事务启动后,任何命令因为语法或运行时错误而失败,那么已经执行的命令不会被回滚,但会标记事务失败。

一致性(Consistency)

一致性确保数据库从一个有效的状态转换到另一个有效的状态。Redis 通过简单的数据模型和对命令的严格定义来维护一致性。例如,如果一个操作试图对一个整数进行递减,但这个值是一个字符串,Redis 不会改变这个值并报错,从而保持数据库状态的一致性。然而,Redis 不提供复杂的一致性保证,例如多表关系或数据完整性约束。

隔离性(Isolation)

隔离性指的是在并发环境中,事务的执行不会被其他事务干扰。Redis 通过单线程的命令执行来保证所有的命令都是序列化执行的,从而实现了强隔离性。但这也意味着 Redis 不支持传统数据库中定义的不同隔离级别。

持久性(Durability)

持久性保证一旦事务完成,对数据的修改就永久保存了。Redis 提供了几种不同的持久化机制(如 RDB 快照和 AOF 日志),允许在发生故障时恢复数据。虽然持久化操作是异步执行的,但可以通过配置来平衡持久化的及时性和性能。

在 Redis 中,这些 ACID 属性的应用与传统关系型数据库有所不同。特别是在原子性和隔离性方面,Redis 提供了基本的保证,但其简单和性能优先的设计决定了它不能提供像完全支持 ACID 的关系型数据库那样的复杂事务控制。这使得 Redis 在需要快速响应和高性能的场景下非常有用,但在需要复杂事务处理和高度数据完整性保证的应用中可能不是最佳选择。

在 NestJs 中实现事务

Redis 的事务功能是在单机环境下设计并实现的,因此完全支持在单机配置中使用。Redis 事务的基本工作机制 ------ MULTI, EXEC, DISCARD,以及 WATCH 命令 ------ 都是在单机上执行的。在单机模式下,Redis 可以确保事务内的所有命令在执行过程中不被其他命令中断,从而达到命令序列的原子性。

在单机 Redis 中使用事务与在任何其他 Redis 配置中的使用没有差异。下面是一些关键点:

  1. 原子性:Redis 事务确保了事务内的所有命令作为一个原子操作序列执行。这意味着要么所有命令都执行,要么一个都不执行。

  2. 隔离性:虽然 Redis 事务提供了一定程度的隔离,即事务中的命令序列执行时不会被其他客户端的命令中断,但它并不保证传统意义上的隔离级别。Redis 事务在执行过程中不会进行命令的预检查,也不会在事务开始时锁定相关的键。

  3. 没有回滚:如果事务中的某个命令执行失败,Redis 不会回滚之前已经执行的命令。事务中的命令会一直执行到结束,即使某些命令可能因为错误而失败。这一点与传统数据库系统中的事务处理不同。

在传统的关系型数据库(如 MySQL、PostgreSQL 等)中,事务具有完整的 "ACID" 特性(原子性、一致性、隔离性和持久性)。特别是原子性,意味着事务中的所有操作要么全部成功执行,要么在遇到失败时全部回滚(即取消已执行的操作),以保持数据的一致性。

然而,Redis 的事务处理与此不同,主要体现在以下几点:

  1. 无条件执行命令:在 Redis 中,一旦事务开始(即发送了 MULTI 命令),所有后续的命令都会被队列起来,直到发送 EXEC 命令。在这个过程中,即便某些命令可能因为错误而执行失败,其他命令仍会被尝试执行。这意味着 Redis 不会因为一个命令的失败而停止或撤销整个事务中已队列的其他命令。

  2. 错误的处理:在执行 EXEC 之后,如果事务中的任何命令失败了(比如因为类型错误或数据问题),Redis 不会回滚其他已经成功执行的命令。例如,如果一个事务中的第一个命令是设置一个键的值,而第二个命令由于某种原因失败了,第一个命令的效果仍然会被保存。

  3. 事务执行结果:Redis 事务在执行时会返回每个命令的结果。这包括成功的命令的输出和失败的命令的错误信息。这样,用户可以知道事务中哪些命令执行成功,哪些失败了。

在实际应用中,这种事务处理方式意味着开发者需要更加谨慎地处理事务中的错误和数据一致性问题。比如,如果在事务执行过程中某些操作失败了,可能需要手动进行一些回滚操作或其他错误恢复处理,以保证应用的数据一致性和业务逻辑的正确性。

假设我们在 NestJs 中对 Redis 有这样的操作:

ts 复制代码
  async performTransaction() {
    const { redisClient } = this;
    try {
      await redisClient.multi({ pipeline: false }); // 开始事务
      redisClient.set('react', 'moment');

      redisClient.incr('react');
      const results = await redisClient.exec(); // 执行事务

      console.log('事务结果:', results);
      return results;
    } catch (error) {
      console.error('事务错误:', error);
      await redisClient.discard(); // 放弃事务

      throw error;
    }
  }

如果 react 的值不是数字,INCR react 命令将失败,但 SET react moment 命令仍然会成功执行,并且它的改动会被保留,即使事务中有命令失败。

如下图所示,这是事务执行的结果:

如下图所示,这是服务端返回给客户端的最终结果:

我们将其代码进行修改一下:

ts 复制代码
  async performTransaction() {
    const { redisClient } = this;
    try {
      await redisClient.multi({ pipeline: false }); // 开始事务
      redisClient.set('react', 'moment');

      redisClient.incr('count');
      const results = await redisClient.exec(); // 执行事务

      console.log('事务结果:', results);
      return results;
    } catch (error) {
      console.error('事务错误:', error);
      await redisClient.discard(); // 放弃事务

      throw error;
    }
  }

如果成功的话就返回所有成功的结果,如下图所示

这种行为需要开发者在设计和实现基于 Redis 的事务系统时进行额外的错误处理考虑,以确保系统的健壮性和数据的准确性。

Lua 脚本

在 Redis 中,Lua 脚本提供了一种强大的机制来执行多个命令,这些命令在执行时可以保证原子性。这意味着,在一个 Lua 脚本执行期间,没有其他脚本或命令可以执行,这就确保了脚本中的所有操作要么全部执行成功,要么全部不执行。Lua 脚本的这种特性非常适合实现复杂的逻辑和事务性操作。

为什么使用 Lua 脚本?

在 Redis 中使用 Lua 脚本有多个好处,这些好处源于 Lua 脚本能在 Redis 服务器上以原子方式执行,以及它提供的灵活性和效率。以下是使用 Lua 脚本的一些主要原因和优势:

  1. 原子操作:在 Redis 中执行 Lua 脚本可以确保脚本内的所有操作都是原子性执行的,这意味着脚本中的命令在执行过程中不会被其他命令中断。这对于需要多个步骤的复杂操作来说非常重要,因为它保证了数据的一致性和完整性,无需担心在命令序列执行期间数据被其他客户端改变。

  2. 减少网络往返次数:使用 Lua 脚本可以将多个操作打包在一个脚本中发送到服务器,这样只需要单次往返通信。这比发送多个独立的 Redis 命令要高效得多,尤其是在处理大量的数据和复杂的逻辑时。网络延迟经常是性能瓶颈,通过减少通信次数,Lua 脚本显著提高了操作效率。

  3. 代码在服务器端执行:将逻辑放在服务器端执行可以减少客户端和服务器之间的数据传输。对于数据密集型的操作,这意味着不需要将大量数据传输到客户端进行处理,然后再将结果发送回服务器。一切处理都在服务器端完成,只需要将最终结果传回客户端,从而降低了带宽需求和响应时间。

  4. 简化客户端逻辑:通过使用 Lua 脚本,复杂的逻辑可以封装在服务器端,客户端仅需要调用一个脚本即可执行多步操作。这简化了客户端的编程工作,使得客户端更加轻量,也便于管理和维护。

  5. 动态脚本:Lua 脚本可以动态地编写和部署,无需重新启动 Redis 服务器就可以更新或修改逻辑。这为开发提供了极大的灵活性,使得可以快速适应需求的变化。

  6. 性能优化:Lua 脚本运行在 Redis 的内置 Lua 解释器中,这使得执行速度非常快。因为脚本是在单个执行环境中运行的,避免了多个操作间上下文切换的开销。此外,Redis 还对常用的脚本进行缓存,进一步提高了执行效率。

  7. 适用于复杂的事务:对于需要事务支持的复杂操作,Lua 脚本提供了一种比传统的 MULTI/EXEC 更强大的解决方案。在脚本中可以处理错误并采取适当的措施,而不是简单地依赖 Redis 的事务机制,这给复杂的数据操作带来了更高的控制精度和灵活性。

总之,使用 Lua 脚本能够有效提升 Redis 的操作效率和灵活性,同时减轻客户端负担,提高数据处理的一致性和安全性。这些特点使得 Lua 脚本成为处理复杂逻辑和数据密集型任务的理想选择。

Lua 脚本的基础

Lua 是一种轻量级、可嵌入的脚本语言,它在 Redis 中的应用主要是为了执行复杂的操作序列,以保证原子性、减少网络往返次数,并在服务器端集中处理逻辑。下面我会详细介绍 Lua 脚本在 Redis 中的基础知识,包括其语法、如何编写脚本,以及如何在 Redis 中执行这些脚本。

1. Lua 语法基础

Lua 的语法简洁,学习起来非常容易。以下是一些基础元素。

Lua 中的变量不需要声明类型,直接赋值使用即可。

lua 复制代码
local a = 10  -- 局部变量
b = "hello"  -- 全局变量

Lua 支持常见的控制结构,如 ifforwhile 等。

lua 复制代码
if a > 10 then
    print("a is greater than 10")
elseif a == 10 then
    print("a is equal to 10")
else
    print("a is less than 10")
end

Lua 中定义函数也非常简单。

lua 复制代码
function add(x, y)
    return x + y
end

表(table)是 Lua 中唯一的数据结构化类型,可以表示数组、字典等。

lua 复制代码
local t = {}       -- 创建一个空表
t[1] = "apple"     -- 数组用法
t["name"] = "Bob"  -- 字典用法

在 Redis 中使用 Lua 脚本时,你可以直接访问 Redis 命令,这些命令通过全局 redis 对象的 call 方法调用。

lua 复制代码
local key = KEYS[1]
local value = ARGV[1]
local response = redis.call('SET', key, value)
return response

这段脚本接受键和值作为参数,然后使用 Redis 的 SET 命令来设置值。

在 Redis 中执行 Lua 脚本主要使用 EVAL 命令,其基本语法如下:

使用 EVAL 命令可以直接在 Redis 命令行中执行 Lua 脚本。

shell 复制代码
EVAL script numkeys key [key ...] arg [arg ...]
  • script:要执行的 Lua 脚本。

  • numkeys:脚本中将会处理的键的数量。

  • key [key ...]:脚本中将会访问的键名。

  • arg [arg ...]:传递给脚本的额外参数。

shell 复制代码
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue

这里 EVAL 后的第一个参数是 Lua 脚本,第二个参数是键的数量,后面是键名和任意多的参数。

首先用 SCRIPT LOAD 命令加载脚本并获取 SHA1 校验和,然后使用 EVALSHA 命令来执行脚本。

shell 复制代码
SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"
EVALSHA <sha1> 1 mykey myvalue

Redis 提供了一组 Lua API 来与 Redis 数据交互:

  • redis.call(): 执行 Redis 命令并返回结果。

  • redis.pcall(): 类似于 redis.call(),但是在遇到错误时不会停止脚本,而是返回一个错误。

  • redis.log(): 将消息输出到 Redis 日志。

Lua 脚本的使用使得 Redis 的操作更加灵活和强大。通过学习 Lua,你可以在 Redis 中实现更复杂的逻辑处理,提升应用的性能和效率。

在 nest 中使用 Lua 脚本实现 Redis 事务

在 NestJS 中使用 Lua 脚本实现 Redis 事务可以有效地合并多个操作为一个原子操作,同时减少与 Redis 服务器之间的往返通信。

接下来我们在 NestJs 服务中编写一个 Lua 脚本并调用,来实现原子操作,如下所示:

ts 复制代码
import { Injectable, Inject, OnModuleDestroy } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";

import { ObjectType } from "../types";
import { isObject } from "../../utils";

@Injectable()
export class RedisService implements OnModuleDestroy {
  constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}

  onModuleDestroy(): void {
    this.redisClient.disconnect();
  }

  async execLuaScript(
    script: string,
    keys: string[],
    args: any[]
  ): Promise<any> {
    return await this.redisClient.eval(script, keys.length, ...keys, ...args);
  }

  async updateMultipleKeys() {
    const script = `
      local node = KEYS[1]
      local go = KEYS[2]
      local value1 = ARGV[1]
      local value2 = ARGV[2]
  
      redis.call('SET', node, value1)
      redis.call('SET', go, value2)
  
      return {redis.call('GET', node), redis.call('GET', go)}
    `;

    const keys = ["node", "go"];
    const values = ["太强了", "非常不错"];
    const result = await this.execLuaScript(script, keys, values);

    return result;
  }
}

当我们访问录用的时候调用该服务,最终结果如下图所示:

我们的 Redis 的数据也更新了,如下图所示:

通过这种方式,你可以利用 Lua 脚本在 Redis 服务器端执行复杂的事务逻辑,确保操作的原子性并减少网络延迟。这种方法特别适合于需要执行多步操作且步骤之间有依赖关系的场景。在实现时,确保脚本的正确性和效率,避免编写可能导致性能问题的长脚本。

总结

从定义上来说, Redis 中的 Lua 脚本本身就是一种事务,所以任何在事务里可以完成的事,在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

Redis 的事务通过命令如 MULTI, EXEC, WATCH, 和 DISCARD 提供了一种方式,可以批量执行多个命令,确保这些命令在执行期间不会被其他命令中断,从而保持操作的原子性。而 Lua 脚本在 Redis 中则允许在服务器端执行复杂的脚本逻辑,提供了执行多个操作的原子性以及减少网络延迟的优势。通过使用 Lua 脚本,用户可以在单一的脚本执行中完成复杂的逻辑判断和数据处理,有效地扩展了 Redis 的功能。

相关推荐
anyup_前端梦工厂28 分钟前
Vuex 入门与实战
前端·javascript·vue.js
程序员阿鹏41 分钟前
ArrayList 与 LinkedList 的区别?
java·开发语言·后端·eclipse·intellij-idea
聂 可 以1 小时前
在SpringBoot项目中利用Redission实现布隆过滤器(布隆过滤器的应用场景、布隆过滤器误判的情况、与位图相关的操作)
java·spring boot·redis
Mr.Demo.1 小时前
[Redis] Redis中的set和zset类型
数据库·redis·缓存
bxnms.1 小时前
Redis存储原理
数据库·redis·缓存
gergul1 小时前
lettuce引起的Redis command timeout异常
数据库·redis·缓存·jedis·lettuce·redis timeout
争不过朝夕,又念着往昔1 小时前
Redis中Hash(哈希)类型的基本操作
数据库·redis·缓存·哈希算法
星眺北海1 小时前
【redis】常用数据类型及命令
数据库·redis·缓存
长安初雪1 小时前
Java客户端SpringDataRedis(RedisTemplate使用)
java·redis
你挚爱的强哥1 小时前
【sgCreateCallAPIFunctionParam】自定义小工具:敏捷开发→调用接口方法参数生成工具
前端·javascript·vue.js