2026-05-24 11:42:38 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags: [Redis, 缓存, 高级特性]
|
|
|
|
|
|
create time: 2026-05-15 18:15
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# Redis 高级特性
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
当你用 Redis 做的不只是「存一个值、取一个值」时——比如转账时要同时扣 A 加 B、限流时要「先判断再计数」——单条命令就不够用了。你需要**把多条命令打包**,确保它们要么一起成功,要么逻辑上不出错。
|
|
|
|
|
|
|
|
|
|
|
|
本节围绕这个核心问题,逐一介绍三种「打包」工具:
|
|
|
|
|
|
- **事务(MULTI/EXEC)**:保证命令按顺序执行、不被插队,但不能根据前一条结果决定下一步
|
|
|
|
|
|
- **Lua 脚本**:真正的「逻辑原子性」——读、判断、写一气呵成
|
|
|
|
|
|
- **Pipeline**:纯性能优化——批量发送命令,减少网络往返
|
|
|
|
|
|
|
|
|
|
|
|
此外还会介绍 Pub/Sub 和 Stream 这两种消息传递机制,以及 Keyspace Notifications 键事件监听。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
## 一、事务(MULTI / EXEC)
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
### 先想一个问题
|
|
|
|
|
|
|
|
|
|
|
|
假设你要给两个计数器各加 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。
|
|
|
|
|
|
|
|
|
|
|
|
**事务就是用来解决这个问题的:把一组命令打包,保证它们连续执行、中间不被打断。**
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
### 基本用法
|
|
|
|
|
|
|
|
|
|
|
|
```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)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
> [!TIP] Go 的 Pipeline vs TxPipeline —— 简单理解
|
|
|
|
|
|
> - `rdb.Pipeline()` = 批量发送,**不加锁**,谁都能插队 → 纯粹为了快
|
|
|
|
|
|
> - `rdb.TxPipeline()` = 批量发送,**前后包上 MULTI/EXEC** → 保证连续执行
|
|
|
|
|
|
>
|
|
|
|
|
|
> 在 Cluster 模式下,go-redis 会按 slot 分组,对每个节点单独发 MULTI/EXEC(所以同一 pipeline 内的 key 必须在相同 hash slot,否则事务会拆开)。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
| 维度 | MULTI/EXEC(TxPipeline) | Pipeline |
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|------|-----------|----------|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
| 原子性 | ✅ 连续执行,中途不插队 | ❌ 命令独立执行,可能被插队 |
|
|
|
|
|
|
| 错误处理 | 语法错 → 整批拒绝;运行时错 → 只影响当前命令 | 每条命令各自报错 |
|
|
|
|
|
|
| Cluster | ❌ 不支持跨 slot 事务 | ✅ 自动按 slot 分发 |
|
|
|
|
|
|
| 适用场景 | 需要保证执行顺序 | 只求快,不关心顺序 |
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
### 事务的错误处理
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
MULTI
|
|
|
|
|
|
SET k v NX
|
|
|
|
|
|
GET KEYY xxx # ⚠️ 语法错误!
|
|
|
|
|
|
EXEC
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| 错误类型 | 发生时 | 行为 |
|
|
|
|
|
|
|---------|--------|------|
|
|
|
|
|
|
| **编译时错误**(语法、参数) | EXEC 之前 | EXEC 拒绝执行整个事务 |
|
|
|
|
|
|
| **运行时错误**(类型不匹配) | EXEC 期间 | 当前命令报错,其余继续执行 |
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
MULTI
|
2026-05-28 12:41:37 +08:00
|
|
|
|
SET k "hello" # OK, queued
|
|
|
|
|
|
INCR k # OK, queued(语法合法,EXEC 前不会检查类型)
|
2026-05-24 11:42:38 +08:00
|
|
|
|
EXEC
|
2026-05-28 12:41:37 +08:00
|
|
|
|
# → [OK, ERR value is not an integer...] — SET 成功,INCR 因类型不匹配报错
|
2026-05-24 11:42:38 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-25 20:51:57 +08:00
|
|
|
|
```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。三者各有分工,切勿混淆。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
> [!WARNING] Redis 事务 ≠ 数据库事务
|
|
|
|
|
|
> - 没有 ROLLBACK / ACID 保证
|
|
|
|
|
|
> - 不能捕获异常后回滚
|
|
|
|
|
|
> - 如果需要强一致性,请用 **Lua 脚本**
|
|
|
|
|
|
|
|
|
|
|
|
## 二、Lua 脚本
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
### 为什么需要 Lua?——MULTI 的局限
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
MULTI 能保证命令「不插队」,但它有一个致命缺陷:**你无法根据前一条命令的结果决定下一步操作**。
|
|
|
|
|
|
|
|
|
|
|
|
比如你想实现「如果余额 ≥ 100 就扣款」:
|
|
|
|
|
|
1. `GET balance` → 拿到 50
|
|
|
|
|
|
2. (根据结果判断:50 < 100,不应该扣款)
|
|
|
|
|
|
3. 但在 MULTI 里,`DECRBY balance 100` 已经在排队了——**你无法在 EXEC 之前取消它**
|
|
|
|
|
|
|
|
|
|
|
|
> [!QUESTION] 怎么办?
|
|
|
|
|
|
> 答案是:把「读 + 判断 + 写」放到**一个脚本**里交给 Redis 执行。这就是 Lua 脚本的核心价值——**逻辑原子性**:整个脚本在 Redis 主线程上一口气跑完,中间不会被任何其他命令插入。
|
|
|
|
|
|
|
|
|
|
|
|
简单对比一下两者的原子性:
|
|
|
|
|
|
|
|
|
|
|
|
| | MULTI/EXEC | Lua 脚本 |
|
|
|
|
|
|
|---|-----------|---------|
|
|
|
|
|
|
| 保证什么 | 命令**连续执行**,不被插队 | 整个脚本**一气呵成**,包含条件判断 |
|
|
|
|
|
|
| 不能做什么 | 根据中间结果做判断 | — |
|
|
|
|
|
|
| 一句话 | 「按顺序执行,但不看中间结果」 | 「读-判断-写,一步到位」 |
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
|
|
|
|
|
|
# ↑ script ↑ commands ↑ key count ↑ keys...
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 经典场景 1:分布式锁
|
|
|
|
|
|
|
|
|
|
|
|
```lua
|
|
|
|
|
|
-- acquire_lock.lua
|
|
|
|
|
|
-- KEYS[1] = lock key
|
|
|
|
|
|
-- ARGV[1] = expire seconds (TTL)
|
|
|
|
|
|
-- ARGV[2] = unique token (client ID)
|
|
|
|
|
|
|
|
|
|
|
|
if redis.call('set', KEYS[1], ARGV[2], 'NX', 'EX', ARGV[1]) == 'OK' then
|
|
|
|
|
|
return 1 -- 成功获取
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
if redis.call('get', KEYS[1]) == ARGV[2] then
|
|
|
|
|
|
-- 已持有且未过期,原子性续期
|
|
|
|
|
|
redis.call('expire', KEYS[1], ARGV[1])
|
|
|
|
|
|
return 1
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
return 0 -- 被其他客户端持有
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-24 21:18:14 +08:00
|
|
|
|
// Go 调用——与上方 Lua 脚本等价的分布式锁实现
|
2026-05-24 11:42:38 +08:00
|
|
|
|
script := redis.NewScript(`
|
2026-05-24 21:18:14 +08:00
|
|
|
|
-- 尝试获取锁:SET NX EX(只有 key 不存在时才设置,原子操作)
|
|
|
|
|
|
if redis.call("set", KEYS[1], ARGV[2], "NX", "EX", ARGV[1]) == "OK" then
|
|
|
|
|
|
return 1 -- 成功获取
|
|
|
|
|
|
end
|
|
|
|
|
|
-- 已持有锁且未过期,续期(防止业务未完成锁就释放)
|
|
|
|
|
|
if redis.call("get", KEYS[1]) == ARGV[2] then
|
|
|
|
|
|
redis.call("expire", KEYS[1], ARGV[1])
|
2026-05-24 11:42:38 +08:00
|
|
|
|
return 1
|
|
|
|
|
|
end
|
2026-05-24 21:18:14 +08:00
|
|
|
|
return 0 -- 被其他客户端持有
|
2026-05-24 11:42:38 +08:00
|
|
|
|
`)
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
// 解锁脚本:只有持有者才能释放,防止误删别人的锁
|
|
|
|
|
|
unlockScript := redis.NewScript(`
|
|
|
|
|
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
|
|
|
|
return redis.call("del", KEYS[1])
|
|
|
|
|
|
end
|
|
|
|
|
|
return 0 -- 锁不属于自己,拒绝释放
|
|
|
|
|
|
`)
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
// KEYS[1] = lock key, ARGV[1] = TTL 秒数, ARGV[2] = 唯一令牌(如 UUID)
|
|
|
|
|
|
ok, err := script.Run(ctx, rdb, []string{"lock:order:" + orderId},
|
2026-05-24 11:42:38 +08:00
|
|
|
|
lockTTL, clientToken).Int()
|
|
|
|
|
|
if ok == 1 {
|
2026-05-24 21:18:14 +08:00
|
|
|
|
defer func() {
|
|
|
|
|
|
// 解锁:只有持有者才能释放(Lua 保证原子性)
|
|
|
|
|
|
unlockScript.Run(ctx, rdb, []string{"lock:order:" + orderId}, clientToken)
|
|
|
|
|
|
}()
|
2026-05-24 11:42:38 +08:00
|
|
|
|
doBiz() // 临界区代码
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-27 00:12:00 +08:00
|
|
|
|
> [!tip] 分布式锁深入阅读
|
|
|
|
|
|
> 本节仅展示 Lua 实现分布式锁的核心代码。关于安全解锁、锁续期(Watchdog)、Redlock 算法、可重入锁等进阶内容,详见 [[hhs/Redis/09-高级特性/分布式锁]]。
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
### 经典场景 2:限流器(固定窗口计数器)
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
```lua
|
2026-05-28 12:41:37 +08:00
|
|
|
|
-- rate_limit.lua(固定窗口计数器 —— Lua 脚本本身就是原子的,无需 pipeline)
|
|
|
|
|
|
-- KEYS[1] = 计数器 key
|
|
|
|
|
|
-- ARGV[1] = 每窗口上限
|
|
|
|
|
|
-- ARGV[2] = 窗口秒数
|
|
|
|
|
|
|
|
|
|
|
|
local limit = tonumber(ARGV[1])
|
|
|
|
|
|
local window = tonumber(ARGV[2])
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
local current = tonumber(redis.call('get', KEYS[1]) or '0')
|
|
|
|
|
|
|
|
|
|
|
|
if current >= limit then
|
|
|
|
|
|
return 0 -- 超限,拒绝
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
-- INCR 返回自增后的新值,首次写入时才设置 TTL(避免每次请求重置窗口)
|
|
|
|
|
|
local new_count = redis.call('incr', KEYS[1])
|
|
|
|
|
|
if new_count == 1 then
|
|
|
|
|
|
redis.call('expire', KEYS[1], window)
|
|
|
|
|
|
end
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
return 1 -- 通过
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 经典场景 3:订单状态机
|
|
|
|
|
|
|
|
|
|
|
|
```lua
|
|
|
|
|
|
-- order_status.lua
|
|
|
|
|
|
-- KEYS[1] = order key
|
|
|
|
|
|
-- ARGV[1] = from_status
|
|
|
|
|
|
-- ARGV[2] = to_status
|
|
|
|
|
|
|
|
|
|
|
|
local current = redis.call('hget', KEYS[1], 'status')
|
|
|
|
|
|
if current ~= ARGV[1] then
|
|
|
|
|
|
return 0 -- 状态不匹配(第一个返回值:失败)
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
redis.call('hset', KEYS[1], 'status', ARGV[2])
|
|
|
|
|
|
return 1 -- 成功(第一个返回值),后续通过 GET 读取新状态
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### EVAL vs EVALSHA
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
EVAL "script..." numkeys args... # 每次传完整脚本
|
|
|
|
|
|
EVALSHA <sha1> numkeys args... # 只传 SHA1,省带宽
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] 使用 EVALSHA 的最佳实践
|
|
|
|
|
|
> ```go
|
|
|
|
|
|
> script := redis.NewScript(...)
|
|
|
|
|
|
> // 首次调用失败可能是 NOSCRIPT(缓存未命中),会自动重试 EVAL
|
|
|
|
|
|
> result := script.Run(ctx, ...).Int()
|
|
|
|
|
|
> ```
|
|
|
|
|
|
> go-redis 内部已处理了 NOSCRIPT → EVAL fallback 的逻辑。
|
|
|
|
|
|
|
|
|
|
|
|
### Lua 沙箱限制
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
Redis 为了让 Lua 脚本在**主从同步**和 **AOF 重放**时结果一致,在沙箱中屏蔽了部分标准库函数。核心原则:**「给同样的输入,永远得到同样的输出」的函数才能用**。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
```lua
|
|
|
|
|
|
-- ❌ 不可用:非确定性函数
|
|
|
|
|
|
math.random() -- 依赖内部 PRNG 状态,跨平台/版本结果不同
|
|
|
|
|
|
os.time() -- 依赖系统时间
|
|
|
|
|
|
os.getenv() -- 依赖环境变量
|
|
|
|
|
|
|
|
|
|
|
|
-- ✅ 推荐做法:在客户端生成随机值/时间戳,通过 ARGV 传入
|
|
|
|
|
|
-- local token = ARGV[2] -- 由应用层生成的 UUID
|
|
|
|
|
|
-- local now = ARGV[3] -- 由应用层传入的 unix 时间戳
|
|
|
|
|
|
-- redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[4])
|
2026-05-25 20:51:57 +08:00
|
|
|
|
```
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
2026-05-25 23:50:33 +08:00
|
|
|
|
> [!NOTE] 关于 Lua 沙箱中的数学函数
|
2026-05-28 12:41:37 +08:00
|
|
|
|
> - ✅ `math.max`、`math.min`、`math.abs`、`math.floor` 等——纯函数,输入确定输出就确定
|
|
|
|
|
|
> - ❌ `math.random`——虽然同一平台 + 同一 seed 理论上可复现,但 **Lua 5.1 PRNG 的实现在不同平台(Linux vs macOS、不同 libc 版本)之间不一致**,会导致主节点返回 42 而从节点重放得到 87,数据分裂
|
|
|
|
|
|
>
|
|
|
|
|
|
> 所以随机值必须在客户端生成,通过 ARGV 传进去。
|
|
|
|
|
|
|
|
|
|
|
|
> [!NOTE] Redis 7.0+ 两种沙箱模式
|
|
|
|
|
|
> Redis 7.0 引入了更严格的 Lua 沙箱。默认行为(等效于 `newdata-safe` 模式):
|
|
|
|
|
|
>
|
|
|
|
|
|
> | 模式 | 行为 | 说明 |
|
|
|
|
|
|
> |------|------|------|
|
|
|
|
|
|
> | Legacy | 仅屏蔽非确定性函数 | 与 Redis < 7.0 兼容,标准库大部分可用 |
|
|
|
|
|
|
> | Newdata-safe(默认) | **仅允许 `redis.*` 和 `math`/`table`/`string`/`cjson`/`cmsgpack`** | 屏蔽 `loadfile`、`os.*`、`io.*`、`pcall` 等,安全性更高 |
|
|
|
|
|
|
>
|
|
|
|
|
|
> 同时 Redis 7.0 改变了 Lua 脚本的**复制方式**:默认将脚本产生的写命令(而非脚本本身)发送给从节点,从而避免从节点重复执行脚本,提升主从一致性。
|
|
|
|
|
|
> 生产环境建议保持默认——它能防止恶意脚本访问系统资源,并减少主从不一致的风险。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
> [!QUESTION] 为什么 Lua 脚本不能有随机函数?
|
|
|
|
|
|
> Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题:
|
|
|
|
|
|
> 1. **主从不一致**:主节点执行返回 42,从节点重放却得到 87,数据分裂。
|
|
|
|
|
|
> 2. **AOF 重放不可靠**:重启后从 appendonly.aof 重放脚本,结果与上次不同,状态机紊乱。
|
|
|
|
|
|
> 因此 Redis 在 Lua 环境中屏蔽了所有非确定性 API,保证「同样的输入一定得到同样的输出」。
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
### Lua 脚本最佳实践
|
|
|
|
|
|
|
|
|
|
|
|
Lua 脚本在 Redis **主线程**上同步执行——脚本运行期间,所有其他命令都会被阻塞。因此:
|
|
|
|
|
|
|
|
|
|
|
|
| 实践 | 说明 |
|
|
|
|
|
|
|------|------|
|
|
|
|
|
|
| 脚本尽量短小 | 只封装「必须原子执行」的逻辑,不要在里面做大循环 |
|
|
|
|
|
|
| 控制执行时间 | `lua-time-limit`(默认 5s)控制超时;超时后其他客户端可发送 `SCRIPT KILL`,**但前提是脚本尚未执行写操作**——若已写入,只能 `SHUTDOWN NOSAVE` |
|
|
|
|
|
|
| 脚本大小适度 | 超大脚本(几十 MB)会导致 EVAL 传输慢 + 编译耗时,应拆分到客户端或使用 EVALSHA 复用 |
|
|
|
|
|
|
| 复杂计算放客户端 | Lua 只负责「读-判断-写」,数据聚合/排序等重计算应在应用层完成 |
|
|
|
|
|
|
| 优先 EVALSHA | 避免每次都传输完整脚本,利用 SHA1 缓存减少带宽 |
|
|
|
|
|
|
|
|
|
|
|
|
> [!WARNING] 生产环境踩坑
|
|
|
|
|
|
> 曾有人在 Lua 里遍历几千个 key 做 `HGETALL`,导致 Redis 阻塞数秒——其他所有请求全部超时。**记住:Lua 脚本 = 临界区代码,越快越好。** 如果脚本已执行了写操作(`SET`/`DEL` 等),`SCRIPT KILL` 将失效,只能强制 `SHUTDOWN NOSAVE`——这在生产环境意味着数据丢失风险。
|
|
|
|
|
|
|
|
|
|
|
|
### Lua 脚本调试
|
|
|
|
|
|
|
|
|
|
|
|
开发阶段,可以用 `redis-cli --eval` 直接在命令行测试 Lua 脚本,无需启动应用:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 语法:redis-cli --eval <脚本文件> <key1> <key2> , <arg1> <arg2>
|
|
|
|
|
|
# 注意逗号两边必须有空格,左边是 KEYS,右边是 ARGV
|
|
|
|
|
|
redis-cli --eval rate_limit.lua mykey , 100 60
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] `SCRIPT DEBUG` 模式
|
|
|
|
|
|
> 在 redis-cli 中执行 `SCRIPT DEBUG SYNC` 后,接下来执行的 `EVAL` 会进入调试模式——你可以在脚本中用 `redis.log(redis.LOG_WARNING, variable)` 打印变量值,日志会输出到 Redis 日志文件。调试完毕后执行 `SCRIPT DEBUG NO` 关闭。
|
|
|
|
|
|
>
|
|
|
|
|
|
> 生产环境务必关闭调试模式,它会让脚本**同步执行**(正常是异步),显著影响性能。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
## 三、Pipeline
|
|
|
|
|
|
|
|
|
|
|
|
### 为什么需要 Pipeline?
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
前面讲的事务和 Lua 解决的是「正确性」问题——保证命令不出错。但还有一个完全不同的问题:**性能**。
|
|
|
|
|
|
|
|
|
|
|
|
Redis 的通信协议是「你一句我一句」——客户端发一条命令,等服务器返回结果,再发下一条。每次这样的网络来回叫做 **RTT(Round-Trip Time,网络往返时间)**。假设 RTT = 1ms,执行 1000 条命令:
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
sequenceDiagram
|
|
|
|
|
|
participant C as "Client"
|
|
|
|
|
|
participant S as "Redis"
|
|
|
|
|
|
Note over C,S: "逐条发送 1000 次 RTT 约 1s"
|
|
|
|
|
|
C->>S: "SET key1 val1"
|
|
|
|
|
|
S-->>C: "OK"
|
|
|
|
|
|
C->>S: "SET key2 val2"
|
|
|
|
|
|
S-->>C: "OK"
|
|
|
|
|
|
C->>S: "... 还有 998 条"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Pipeline 的思路很简单:把多条命令攒一批一起发,服务器也一批返回。** 网络只往返一次(或少数几次),而不是每条命令都来回跑。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
| 方式 | RTT 次数 | 总耗时估算 | 比喻 |
|
|
|
|
|
|
|------|---------|-----------|------|
|
|
|
|
|
|
| 逐条发送 | 1000 | ~1s | 寄 1000 封信,每封等回信再寄下一封 |
|
|
|
|
|
|
| Pipeline (batch=200) | 5 次 | ~5ms | 攒 200 封信一起寄,回信也一批到 |
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
### Pipeline vs TxPipeline
|
|
|
|
|
|
|
|
|
|
|
|
在 go-redis 中,有两种管道模式可用:
|
|
|
|
|
|
|
|
|
|
|
|
| 方式 | 适用场景 | 注意事项 |
|
|
|
|
|
|
|------|---------|---------|
|
|
|
|
|
|
| `Pipeline` | 独立命令批量 | 不保证原子性 |
|
|
|
|
|
|
| `TxPipeline` (go-redis) | 需要原子的批量 | 对应 MULTI/EXEC |
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// 方式一:普通 Pipeline(性能优化首选)
|
|
|
|
|
|
pipe := rdb.Pipeline()
|
|
|
|
|
|
pipe.Set(ctx, "user:1:name", "alice", 0)
|
|
|
|
|
|
pipe.Set(ctx, "user:1:age", "25", 0)
|
|
|
|
|
|
pipe.HSet(ctx, "user:1:profile", "email", "a@x.com")
|
|
|
|
|
|
results, _ := pipe.Exec(ctx) // 返回 []redis.Result
|
|
|
|
|
|
_ = results // 实际使用中检查 error
|
|
|
|
|
|
|
2026-05-25 23:50:33 +08:00
|
|
|
|
// 方式二:Watch + TxPipeline(乐观锁 —— 读-改-写原子的核心模式)
|
|
|
|
|
|
// 核心思路:WATCH 监听 key → 在回调内读-判断-写 → 如果 key 被别人改了则整个回调失败重试
|
2026-05-24 11:42:38 +08:00
|
|
|
|
err := rdb.Watch(ctx, func(tx *redis.Tx) error {
|
2026-05-25 23:50:33 +08:00
|
|
|
|
// 1. 读:获取当前余额
|
2026-05-24 11:42:38 +08:00
|
|
|
|
acc, err := tx.Get(ctx, "account:alice").Result()
|
|
|
|
|
|
if err != nil { return err }
|
2026-05-25 23:50:33 +08:00
|
|
|
|
balance, _ := strconv.ParseInt(acc, 10, 64)
|
|
|
|
|
|
if balance < 100 {
|
|
|
|
|
|
return fmt.Errorf("余额不足")
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2. 改-写:在 Watch 回调内用 TxPipeline 保证原子性
|
|
|
|
|
|
pipe := tx.TxPipeline()
|
|
|
|
|
|
pipe.DecrBy(ctx, "account:alice", 100)
|
|
|
|
|
|
pipe.IncrBy(ctx, "account:bob", 100)
|
|
|
|
|
|
_, err = pipe.Exec(ctx)
|
|
|
|
|
|
return err
|
2026-05-24 11:42:38 +08:00
|
|
|
|
}, "account:alice")
|
2026-05-25 23:50:33 +08:00
|
|
|
|
if err == redis.TxFailedErr {
|
|
|
|
|
|
// 被 Watch 的 key 被其他客户端修改了,需要重试整个操作
|
|
|
|
|
|
log.Println("并发冲突,重试...")
|
2026-05-24 11:42:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
> [!QUESTION] WATCH 是什么?为什么需要它?
|
|
|
|
|
|
> `WATCH` 是 Redis 提供的**乐观锁**机制——在事务开始前「监视」一个或多个 key,如果这些 key 在 EXEC 之前被其他客户端修改了,整个事务会被拒绝(返回 `TxFailedErr`),你需要自行重试。
|
|
|
|
|
|
>
|
|
|
|
|
|
> 回顾上面的转账例子:`WATCH account:alice` → 读余额 → 判断 → `TxPipeline` 扣款。如果在你读余额之后、扣款之前,别人修改了 `account:alice`,`EXEC` 会失败,从而避免「余额不足却被扣款」的竞态条件。
|
|
|
|
|
|
>
|
|
|
|
|
|
> **一句话**:WATCH 让 MULTI/EXEC 具备了「检查-执行」的能力,弥补了事务不能读中间结果的短板。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
> [!WARNING] Pipeline 的注意事项
|
|
|
|
|
|
> - 单个 pipeline 内的命令可能跨多个节点(Cluster 模式下会分发执行)
|
|
|
|
|
|
> - 避免在 pipeline 中混用 WATCH/MULTI(Cluster 不支持)
|
|
|
|
|
|
> - 批量越大越好?**不是**——单 batch 建议 50~200 条,过大反而降低吞吐且增加超时风险
|
|
|
|
|
|
|
|
|
|
|
|
### Pipeline 实战——数据迁移
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func BatchMigrate(ctx context.Context, rdb *redis.Client, srcKeys []string) error {
|
|
|
|
|
|
const batchSize = 100
|
2026-05-28 12:41:37 +08:00
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
for i := 0; i < len(srcKeys); i += batchSize {
|
|
|
|
|
|
batch := srcKeys[i : min(i+batchSize, len(srcKeys))]
|
2026-05-28 12:41:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 第一轮:用 Pipeline 批量 GET(而非逐条 Get,否则失去 Pipeline 意义)
|
|
|
|
|
|
getPipe := rdb.Pipeline()
|
|
|
|
|
|
getCmds := make([]*redis.StringCmd, len(batch))
|
|
|
|
|
|
for j, key := range batch {
|
|
|
|
|
|
getCmds[j] = getPipe.Get(ctx, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
getPipe.Exec(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
// 第二轮:用 Pipeline 批量 SET
|
|
|
|
|
|
setPipe := rdb.Pipeline()
|
|
|
|
|
|
for j, key := range batch {
|
|
|
|
|
|
val, err := getCmds[j].Result()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
continue // 跳过不存在的 key
|
|
|
|
|
|
}
|
2026-05-24 11:42:38 +08:00
|
|
|
|
newKey := strings.TrimPrefix(key, "old:")
|
2026-05-28 12:41:37 +08:00
|
|
|
|
setPipe.Set(ctx, newKey, val, 0)
|
2026-05-24 11:42:38 +08:00
|
|
|
|
}
|
2026-05-28 12:41:37 +08:00
|
|
|
|
if _, err := setPipe.Exec(ctx); err != nil {
|
2026-05-24 11:42:38 +08:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-05-28 12:41:37 +08:00
|
|
|
|
// Exec 后 pipeline 已清空,无需 Close() / 重建
|
2026-05-24 11:42:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 四、Pub/Sub —— 发布订阅
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
前面讲的事务、Lua、Pipeline 都是「一个客户端跟 Redis 对话」。但有些场景需要**多个客户端之间通信**——比如 WebSocket 服务要广播消息给所有在线用户,或者配置中心更新后通知所有微服务。
|
|
|
|
|
|
|
|
|
|
|
|
Pub/Sub 就是 Redis 内置的「广播电台」:一个客户端发布消息,所有订阅了对应频道的客户端都能收到。
|
|
|
|
|
|
|
|
|
|
|
|
> [!WARNING] 先记住最关键的一点
|
|
|
|
|
|
> Pub/Sub 是**即发即忘**的——消息不会存储,订阅者如果下线了就收不到。如果你需要可靠投递(比如任务队列),请直接跳到后面的 [[#Stream]]。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
### 基础用法
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 消费者 A
|
|
|
|
|
|
SUBSCRIBE channel:newposts
|
|
|
|
|
|
|
|
|
|
|
|
# 消费者 B(也可以 subscribe 同一频道)
|
|
|
|
|
|
SUBSCRIBE channel:newposts
|
|
|
|
|
|
|
|
|
|
|
|
# 发布者
|
|
|
|
|
|
PUBLISH channel:newposts '{"id":42,"title":"Redis Tips"}'
|
|
|
|
|
|
→ 消费者 A 和 B 同时收到消息
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// go-redis 订阅者
|
|
|
|
|
|
pubsub := rdb.Subscribe(ctx, "channel:newposts")
|
|
|
|
|
|
defer pubsub.Close()
|
|
|
|
|
|
|
|
|
|
|
|
ch := pubsub.Channel() // chan redis.Message,阻塞接收
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
fmt.Printf("收到: %s\n", msg.Payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// go-redis 发布者 — 无返回值,发出去就不管了
|
|
|
|
|
|
rdb.Publish(ctx, "channel:newposts", `{"id":42,"title":"Redis Tips"}`)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!WARNING] Pub/Sub 是无状态的
|
|
|
|
|
|
> - **连接级绑定**:每个 SUBSCRIBE 会独占一个 Redis 连接——线上建议限流并发 subscriber 数量
|
|
|
|
|
|
> - **消息不持久化**:订阅者下线就丢失,重连不会收到离线期间的消息
|
|
|
|
|
|
> - **适合场景**:WebSocket 广播、实时通知、配置变更推送
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
### Sharded Pub/Sub(Redis 7.0+)
|
|
|
|
|
|
|
|
|
|
|
|
普通 Pub/Sub 在集群模式下有个隐患:**所有消息都由接收 PUBLISH 命令的单个节点转发给所有订阅者**,该节点容易成为瓶颈。
|
|
|
|
|
|
|
|
|
|
|
|
Redis 7.0 引入了 Sharded Pub/Sub,核心改进是**消息按 slot 分发到各节点**,避免单点压力:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 分片发布(消息只会发送到该 slot 所在节点的订阅者)
|
|
|
|
|
|
SSUBSCRIBE channel:order
|
|
|
|
|
|
SPUBLISH channel:order '{"id":42}'
|
|
|
|
|
|
|
|
|
|
|
|
# 普通发布(广播到所有节点的订阅者)
|
|
|
|
|
|
SUBSCRIBE channel:order
|
|
|
|
|
|
PUBLISH channel:order '{"id":42}'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | 普通 Pub/Sub | Sharded Pub/Sub |
|
|
|
|
|
|
|------|-------------|----------------|
|
|
|
|
|
|
| 集群消息路由 | 单节点广播 → 所有节点 | 按 slot → 仅目标节点 |
|
|
|
|
|
|
| 适用场景 | 需要全局广播 | 高吞吐、按 key 分区消费 |
|
|
|
|
|
|
| 最低版本 | 所有版本 | Redis 7.0+ |
|
|
|
|
|
|
|
|
|
|
|
|
> [!QUESTION] 什么时候该用 Sharded Pub/Sub?
|
|
|
|
|
|
> 如果你的频道消息量大、且订阅者只需要消费属于自己 slot 的消息(比如按用户 ID 分片),Sharded Pub/Sub 能显著降低单节点压力。但如果需要**全局广播**(所有节点都收到),还是用普通 Pub/Sub。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
### Stream —— 可靠的替代方案
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
如果 Pub/Sub 是「聊天群」(消息刷过去就没了),那 Stream 就是「消息队列」——**消息会持久化,消费者必须确认收到,挂了可以重新投递**。
|
|
|
|
|
|
|
|
|
|
|
|
Stream 是 Redis 5.0+ 引入的数据结构,核心设计围绕 **Consumer Group(消费者组)**:
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart LR
|
|
|
|
|
|
P["Producer"] -->|"XADD: 写入消息"| S["Stream (持久化存储)"]
|
|
|
|
|
|
S -->|"XREADGROUP: 读取并标记 pending"| C1["Consumer A<br/>处理消息"]
|
|
|
|
|
|
S -->|"XREADGROUP: 读取并标记 pending"| C2["Consumer B<br/>处理消息"]
|
|
|
|
|
|
C1 -->|"XACK: 确认处理完成"| S
|
|
|
|
|
|
C2 -->|"XCLAIM: 接管超时消息"| S
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
核心流程三步走:
|
|
|
|
|
|
1. **生产者**用 `XADD` 写入消息
|
|
|
|
|
|
2. **消费者组**中的消费者用 `XREADGROUP` 读取——消息会被标记为 pending(待确认)
|
|
|
|
|
|
3. **消费者**处理完后用 `XACK` 确认——确认后消息才算真正消费完
|
|
|
|
|
|
|
|
|
|
|
|
如果消费者中途挂了,pending 消息不会丢失,其他消费者可以用 `XCLAIM` 接管。
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 生产者写入
|
|
|
|
|
|
XADD mystream * user_id 123 action login ip 192.168.1.1
|
|
|
|
|
|
|
|
|
|
|
|
# 创建消费者组
|
|
|
|
|
|
XGROUP CREATE mystream group1 $ # $ = 从最新消息开始消费
|
|
|
|
|
|
# XGROUP CREATE mystream group1 0 # 0 = 从头开始(生产环境慎用)
|
|
|
|
|
|
|
|
|
|
|
|
# 消费者读取(阻塞模式,无消息时等待指定毫秒数)
|
|
|
|
|
|
XREADGROUP GROUP group1 consumer1 COUNT 1 STREAMS mystream >
|
|
|
|
|
|
|
|
|
|
|
|
# 确认消费完成(必须调用,否则该条消息会一直留在 pending 列表)
|
|
|
|
|
|
XACK mystream group1 <entry-id>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] `$` vs `>` —— 初学者最容易踩的坑
|
|
|
|
|
|
> | 标识符 | 含义 | 使用场景 |
|
|
|
|
|
|
> |--------|------|---------|
|
|
|
|
|
|
> | `$` | "最后一条已写入的消息" | 新 consumer 加入时,只接收**将来**的消息 |
|
|
|
|
|
|
> | `0` | "历史第一条" | 数据迁移/重放(谨慎用于大 stream) |
|
|
|
|
|
|
> | `>` | "我只取未分配给我的新消息" | **正常消费循环中始终用 `>`**,不取到 pending 消息 |
|
|
|
|
|
|
|
|
|
|
|
|
> [!QUESTION] 如果消费者挂了怎么办?
|
|
|
|
|
|
> 好消息会被移到 **pending 列表**。用 `XPENDING mystream group1` 查看,用 `XCLAIM` 将 pending 消息转移给其他 consumer 重新处理。这就是 Stream 比 Pub/Sub 可靠的地方:**有状态、可追责**。
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] Pub/Sub vs Stream 选型
|
|
|
|
|
|
> | 场景 | 推荐 |
|
|
|
|
|
|
> |------|------|
|
|
|
|
|
|
> | WebSocket 实时推送 | Pub/Sub |
|
|
|
|
|
|
> | 任务队列、订单事件 | Stream |
|
|
|
|
|
|
> | 海量消息、高吞吐 | Kafka/RabbitMQ |
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
> [!tip] Stream 深入阅读
|
|
|
|
|
|
> 本节仅覆盖 Stream 基础用法。Consumer Group 内部机制、消息确认与重试(XCLAIM/XAUTOCLAIM)、Dead Letter Queue 模式、容量管理等进阶内容,详见 [[hhs/Redis/15-Stream]]。
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
## 五、Keyspace Notifications —— 键事件监听
|
|
|
|
|
|
|
|
|
|
|
|
### 原理与配置
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
简单来说,Keyspace Notifications 就是 Redis 的「事件总线」——当某个 key 发生变化(被删除、过期、被修改等),Redis 会通过 Pub/Sub 发出一条通知。
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] 最常见的用途
|
|
|
|
|
|
> 监听 key 过期事件。比如:用户 session 过期时自动清理关联数据、分布式锁到期时告警。
|
|
|
|
|
|
|
|
|
|
|
|
开启方式很简单,在 `redis.conf` 中设置(或运行时 `CONFIG SET`):
|
2026-05-24 11:42:38 +08:00
|
|
|
|
|
|
|
|
|
|
```conf
|
|
|
|
|
|
notify-keyspace-events KEA
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
这一串字母是事件类型的组合,不用全部记住,按需选用即可:
|
|
|
|
|
|
|
|
|
|
|
|
| 字母 | 含义 | 举例 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| `K` | Keyspace 事件(按 key 名通知) | 有人操作了 `user:123` |
|
|
|
|
|
|
| `E` | Keyevent 事件(按事件类型通知) | 有 key 过期了 |
|
|
|
|
|
|
| `A` | All events 简写,覆盖下方所有类型 | 一次性开启全部 |
|
|
|
|
|
|
| `x` | 过期事件 | `EXPIRE` 到期触发 |
|
|
|
|
|
|
| `e` | 淘汰事件 | 内存满时被驱逐 |
|
|
|
|
|
|
| `g` | 通用命令 | `DEL`、`EXPIRE`、`RENAME` 等 |
|
|
|
|
|
|
| `l/s/h/z` | 数据结构相关 | list/set/hash/sorted-set 操作 |
|
|
|
|
|
|
|
|
|
|
|
|
> [!NOTE] K 和 E 的区别——通知「谁」vs 通知「什么」
|
|
|
|
|
|
> - `__keyspace@0__:<keyname>` → "有人动了 `user:123`"(以 key 为中心)
|
|
|
|
|
|
> - `__keyevent@0__:expired` → "有 key 过期了"(以事件为中心)
|
|
|
|
|
|
>
|
|
|
|
|
|
> 两者是同一件事的两种视角,按需选用。**生产环境最常用 `Ex`(只监听过期事件)。**
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
```bash
|
|
|
|
|
|
# 订阅过期事件(Keyevent 模式:更精确)
|
|
|
|
|
|
PSUBSCRIBE __keyevent@0__:expired
|
|
|
|
|
|
|
|
|
|
|
|
# 另一个连接设置过期 key
|
|
|
|
|
|
EXPIRE test:key 2
|
|
|
|
|
|
→ 收到: __keyevent@0__:expired "test:key"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// go-redis 监听器
|
|
|
|
|
|
psub := rdb.PSubscribe(ctx, "__keyevent@0__:expired")
|
|
|
|
|
|
defer psub.Close()
|
|
|
|
|
|
|
|
|
|
|
|
ch := psub.Channel()
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
fmt.Printf("Key %s expired\n", msg.Payload)
|
|
|
|
|
|
// 可以触发清理逻辑、计数器归零等
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!WARNING] 生产环境注意事项
|
|
|
|
|
|
> - **性能开销**:每条写命令都会生成通知,高 QPS + 全量监听 (`KAE`) 会显著增加 CPU/内存
|
|
|
|
|
|
> - **异步不保证送达**:本质是 Pub/Sub,网络抖动时通知可能丢失
|
|
|
|
|
|
> - **从节点不会推送**:默认只在写入节点生效,哨兵/集群切换后需重新订阅
|
|
|
|
|
|
> - **建议**:只订阅你需要的事件类型(如 `KElx`),而非 `KExa` 通配一切
|
|
|
|
|
|
|
2026-05-25 20:51:57 +08:00
|
|
|
|
## 六、选型指南——如何选择?
|
|
|
|
|
|
|
2026-05-28 12:41:37 +08:00
|
|
|
|
面对一个需求,到底该用事务、Lua、Pipeline 还是 Stream?
|
|
|
|
|
|
|
|
|
|
|
|
### 决策心法——三个问题搞定
|
|
|
|
|
|
|
|
|
|
|
|
拿到需求后,先问自己三个问题:
|
|
|
|
|
|
|
|
|
|
|
|
1. **我需要「原子性」吗?** → 如果只是批量写入追求性能,Pipeline 就够了
|
|
|
|
|
|
2. **我需要「读-判断-写」吗?** → 如果答案是 yes,只有 Lua 能做到真正的逻辑原子性
|
|
|
|
|
|
3. **我在做「消息传递」吗?** → 实时广播用 Pub/Sub,需要可靠投递用 Stream
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] 一句话记忆
|
|
|
|
|
|
> - **Pipeline**:只求快,不关心顺序 → 「快递员一次送 200 个包裹」
|
|
|
|
|
|
> - **MULTI/EXEC**:要求连续执行,不被插队 → 「排队结账,不允许别人插队」
|
|
|
|
|
|
> - **Lua 脚本**:需要根据中间结果做判断 → 「医生先检查再开药,不能分开」
|
|
|
|
|
|
> - **Pub/Sub**:实时广播,丢了就丢了 → 「微信群消息」
|
|
|
|
|
|
> - **Stream**:消息必须可靠送达 → 「挂号信,签收才算数」
|
|
|
|
|
|
|
|
|
|
|
|
下面是完整的决策流程:
|
2026-05-25 20:51:57 +08:00
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
|
|
|
|
|
A["需求场景"] --> B{"批量命令?"}
|
|
|
|
|
|
B -->|"否"| C["单条命令即可"]
|
|
|
|
|
|
B -->|"是"| D{"需要原子性?"}
|
|
|
|
|
|
D -->|"否"| E["Pipeline"]
|
|
|
|
|
|
D -->|"是"| F{"读-判断-写?"}
|
|
|
|
|
|
F -->|"是"| G["Lua 脚本"]
|
|
|
|
|
|
F -->|"否"| H{"支持 Cluster?"}
|
|
|
|
|
|
H -->|"是"| G
|
|
|
|
|
|
H -->|"否"| I["MULTI / EXEC"]
|
|
|
|
|
|
A --> J{"消息传递?"}
|
|
|
|
|
|
J -->|"可靠投递/任务队列"| K["Stream"]
|
|
|
|
|
|
J -->|"实时广播/通知"| L["Pub/Sub"]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| 需求 | 推荐方案 | 理由 |
|
|
|
|
|
|
|------|---------|------|
|
|
|
|
|
|
| 批量写入、减少 RTT | Pipeline | 无原子性要求,纯性能优化 |
|
|
|
|
|
|
| 读-判断-写原子操作 | Lua 脚本 | 逻辑原子性,一气呵成 |
|
|
|
|
|
|
| 简单批量 + 单机 | MULTI / EXEC | 轻量,无需写 Lua |
|
|
|
|
|
|
| 任务队列、事件溯源 | Stream | 持久化 + Consumer Group |
|
|
|
|
|
|
| 实时通知、WebSocket 广播 | Pub/Sub | 无状态、低延迟 |
|
|
|
|
|
|
|
2026-05-24 11:42:38 +08:00
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
2026-05-27 00:12:00 +08:00
|
|
|
|
- [[hhs/Redis/09-高级特性/分布式锁]] — 分布式锁完整解析(Redlock、Watchdog、可重入锁)
|
2026-05-24 11:42:38 +08:00
|
|
|
|
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳)
|
|
|
|
|
|
- [[hhs/Redis/07-集群方案]] — Cluster 下 Pipeline/Lua 的限制
|
|
|
|
|
|
- [[hhs/Redis/README]] — 知识索引总览
|