--- tags: [Redis, 缓存, 高级特性] create time: 2026-05-15 18:15 --- # Redis 高级特性 ## 概述 当你用 Redis 做的不只是「存一个值、取一个值」时——比如转账时要同时扣 A 加 B、限流时要「先判断再计数」——单条命令就不够用了。你需要**把多条命令打包**,确保它们要么一起成功,要么逻辑上不出错。 本节围绕这个核心问题,逐一介绍三种「打包」工具: - **事务(MULTI/EXEC)**:保证命令按顺序执行、不被插队,但不能根据前一条结果决定下一步 - **Lua 脚本**:真正的「逻辑原子性」——读、判断、写一气呵成 - **Pipeline**:纯性能优化——批量发送命令,减少网络往返 此外还会介绍 Pub/Sub 和 Stream 这两种消息传递机制,以及 Keyspace Notifications 键事件监听。 ## 一、事务(MULTI / EXEC) ### 先想一个问题 假设你要给两个计数器各加 1。如果直接逐条发送 `INCR counter`、`INCR other_counter`,在高并发下可能出现这种情况: ```mermaid sequenceDiagram participant A as "Client A" participant B as "Client B" participant S as "Redis" A->>S: "INCR counter 得到 1" B->>S: "INCR counter 得到 2 (插队了!)" B->>S: "INCR other_counter 得到 1" A->>S: "INCR other_counter 得到 2" ``` 两个命令之间被别的客户端**插队**了。虽然最终结果可能没问题,但在更复杂的场景(比如「读余额→判断→扣款」)中,插队会导致严重 bug。 **事务就是用来解决这个问题的:把一组命令打包,保证它们连续执行、中间不被打断。** ### 基本用法 ```bash MULTI # 开启事务(不阻塞,之后命令只是排队) INCR counter # QUEUED INCR other_counter # QUEUED EXEC # 一次性执行所有排队的命令 ``` ```go pipe := rdb.TxPipeline() // TxPipeline ≠ MULTI,见下方说明 pipe.Incr(ctx, "counter") pipe.Set(ctx, "key", "value", 0) results, _ := pipe.Exec(ctx) ``` > [!TIP] Go 的 Pipeline vs TxPipeline —— 简单理解 > - `rdb.Pipeline()` = 批量发送,**不加锁**,谁都能插队 → 纯粹为了快 > - `rdb.TxPipeline()` = 批量发送,**前后包上 MULTI/EXEC** → 保证连续执行 > > 在 Cluster 模式下,go-redis 会按 slot 分组,对每个节点单独发 MULTI/EXEC(所以同一 pipeline 内的 key 必须在相同 hash slot,否则事务会拆开)。 | 维度 | MULTI/EXEC(TxPipeline) | Pipeline | |------|-----------|----------| | 原子性 | ✅ 连续执行,中途不插队 | ❌ 命令独立执行,可能被插队 | | 错误处理 | 语法错 → 整批拒绝;运行时错 → 只影响当前命令 | 每条命令各自报错 | | Cluster | ❌ 不支持跨 slot 事务 | ✅ 自动按 slot 分发 | | 适用场景 | 需要保证执行顺序 | 只求快,不关心顺序 | ### 事务的错误处理 ```bash MULTI SET k v NX GET KEYY xxx # ⚠️ 语法错误! EXEC ``` | 错误类型 | 发生时 | 行为 | |---------|--------|------| | **编译时错误**(语法、参数) | EXEC 之前 | EXEC 拒绝执行整个事务 | | **运行时错误**(类型不匹配) | EXEC 期间 | 当前命令报错,其余继续执行 | ```bash MULTI SET k "hello" # OK, queued INCR k # OK, queued(语法合法,EXEC 前不会检查类型) EXEC # → [OK, ERR value is not an integer...] — SET 成功,INCR 因类型不匹配报错 ``` ```mermaid sequenceDiagram participant C as "Client" participant S as "Redis Server" C->>S: MULTI S-->>C: OK C->>S: SET k v S-->>C: QUEUED C->>S: INCR counter S-->>C: QUEUED C->>S: EXEC Note over S: 依次执行所有排队命令 S-->>C: [OK, 1] ``` > [!QUESTION] 既然 MULTI 能批量执行,为什么还需要 Pipeline? > 关键区别在于**排队阶段是否立即返回结果**。MULTI 的 QUEUED 只是"记下来",命令要等 EXEC 才真正执行——这意味着你**无法根据前一条的结果决定后续命令**。而 Lua 脚本能做"读-判断-写",Pipeline 则纯粹为减少 RTT。三者各有分工,切勿混淆。 > [!WARNING] Redis 事务 ≠ 数据库事务 > - 没有 ROLLBACK / ACID 保证 > - 不能捕获异常后回滚 > - 如果需要强一致性,请用 **Lua 脚本** ## 二、Lua 脚本 ### 为什么需要 Lua?——MULTI 的局限 MULTI 能保证命令「不插队」,但它有一个致命缺陷:**你无法根据前一条命令的结果决定下一步操作**。 比如你想实现「如果余额 ≥ 100 就扣款」: 1. `GET balance` → 拿到 50 2. (根据结果判断:50 < 100,不应该扣款) 3. 但在 MULTI 里,`DECRBY balance 100` 已经在排队了——**你无法在 EXEC 之前取消它** > [!QUESTION] 怎么办? > 答案是:把「读 + 判断 + 写」放到**一个脚本**里交给 Redis 执行。这就是 Lua 脚本的核心价值——**逻辑原子性**:整个脚本在 Redis 主线程上一口气跑完,中间不会被任何其他命令插入。 简单对比一下两者的原子性: | | MULTI/EXEC | Lua 脚本 | |---|-----------|---------| | 保证什么 | 命令**连续执行**,不被插队 | 整个脚本**一气呵成**,包含条件判断 | | 不能做什么 | 根据中间结果做判断 | — | | 一句话 | 「按顺序执行,但不看中间结果」 | 「读-判断-写,一步到位」 | ```bash EVAL "return redis.call('GET', KEYS[1])" 1 mykey # ↑ script ↑ commands ↑ key count ↑ keys... ``` ### Lua 语法速查 Redis 内嵌的是 **Lua 5.1**,脚本运行在沙箱中(标准库受限)。如果你从没写过 Lua,这一节帮你快速建立直觉,之后看所有 Lua 脚本都不再陌生。 #### 变量与类型 Lua 是**动态类型**语言,用 `local` 声明局部变量(推荐始终使用 `local`,不加则为全局变量,容易污染脚本环境): ```lua local name = "alice" -- string local age = 30 -- number(Lua 5.1 只有 double 浮点数,没有 int) local ok = true -- boolean local nothing = nil -- nil ≈ 其他语言的 null / undefined ``` > [!QUESTION] 为什么 Redis 里到处都是 `tonumber()`? > `redis.call()` 返回的永远是 **string** 或 **table**——即使 `INCR` 返回的是数字,Lua 收到的也是 `"42"`(字符串)。所以需要用 `tonumber("42")` 转为数字后才能做 `>=`、`<` 等比较运算,否则是字典序比较,结果会出错。 | 转换函数 | 作用 | 示例 | |---------|------|------| | `tonumber(x)` | 转为 number,失败返回 `nil` | `tonumber("42")` → `42` | | `tostring(x)` | 转为 string | `tostring(42)` → `"42"` | #### 条件判断 ```lua if 条件 then -- ... elseif 其他条件 then -- ... else -- ... end ``` > [!WARNING] Lua 里没有 `switch/case`,也没有三元运算符 `? :`,全靠 `if...elseif...end` 硬写。 逻辑运算符和你熟悉的略有不同: | 运算符 | 含义 | 说明 | |-------|------|------| | `==` | 等于 | 注意是双等号 | | `~=` | 不等于 | 而不是 `!=` | | `and` | 逻辑与 | | | `or` | 逻辑或 | | | `not` | 逻辑非 | | #### 循环 Redis 脚本中最常见的是 `for` 循环,常用来遍历 table: ```lua -- 数值 for:从 1 到 #arr(# 是取长度运算符) local arr = {"a", "b", "c"} for i = 1, #arr do print(arr[i]) -- Lua 的索引从 1 开始! end -- while local n = 5 while n > 0 do n = n - 1 end ``` > [!NOTE] 索引从 1 开始 > 这是 Lua 初学者踩的第一个坑:`arr[0]` 不会报错,但返回 `nil`。`KEYS` 和 `ARGV` 也是从 1 开始索引:`KEYS[1]`、`ARGV[1]`。 #### 函数 ```lua local function add(a, b) return a + b end ``` Lua 函数可以**返回多个值**(这是 Lua 的特色之一): ```lua local function divmod(a, b) return math.floor(a / b), a % b -- 返回商和余数 end local q, r = divmod(17, 5) ``` #### Table:Lua 唯一的数据结构 Table 同时承担了**数组**和**字典**两个角色: ```lua -- 作为数组(索引从 1 开始) local fruits = {"apple", "banana", "cherry"} print(# fruits) -- 3(# 是长度运算符) -- 作为字典(键值对) local user = {name = "alice", age = 30} print(user.name) -- "alice"(等价于 user["name"]) ``` 在 Redis 脚本里,`redis.call()` 返回多条结果时,会以 **Lua table(数组形式)** 返回,需要遍历读取。 #### 与 Redis 交互:redis.call / redis.pcall 这是 Lua 脚本调用 Redis 命令的两种方式: | 方式 | 行为 | 适用场景 | |------|------|---------| | `redis.call()` | 命令失败时**抛出错误**,脚本终止 | 需要严格保证命令成功的场景(默认推荐) | | `redis.pcall()` | 命令失败时**返回错误对象**,脚本继续 | 需要自行处理错误的场景 | ```lua -- redis.call:失败会中断整个脚本 local val = redis.call('GET', KEYS[1]) -- 如果 key 不存在,返回 nil(不是错误) -- redis.pcall:失败只返回 err,脚本继续 local ok, err = redis.pcall('HGETALL', KEYS[1]) if err then -- 可以在这里做降级处理 end ``` #### KEYS 与 ARGV `EVAL` 命令通过位置参数把数据传进 Lua 脚本: ```bash EVAL