--- tags: [Redis, 缓存, 高级特性] create time: 2026-05-15 18:15 --- # Redis 高级特性 ## 概述 掌握事务、Lua 脚本和 Pipeline,是 Redis 从「基础缓存」迈向「生产级系统」的关键。本节逐一剖析其原理、场景和陷阱。 ## 一、事务(MULTI / EXEC) ### 基本用法 ```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 | Pipeline | |------|-----------|----------| | 原子性 | ✅ 整体执行,中途不插队 | ❌ 命令独立发送执行 | | 错误处理 | 编译期错误全部拒绝;运行时错误逐个记录 | 每条返回各自结果 | | Cluster 支持 | ❌ | ✅(但需同 key tag) | | 性能 | 同 Pipeline | 批量发送减少 RTT | ### 事务的错误处理 ```bash MULTI SET k v NX GET KEYY xxx # ⚠️ 语法错误! EXEC ``` | 错误类型 | 发生时 | 行为 | |---------|--------|------| | **编译时错误**(语法、参数) | EXEC 之前 | EXEC 拒绝执行整个事务 | | **运行时错误**(类型不匹配) | EXEC 期间 | 当前命令报错,其余继续执行 | ```bash MULTI SET k 123 # OK, queued INCR k # OK, queued (先 SET 再 INCR 是合法的) EXEC # → [OK, 124] — 两个都成功了 ``` ```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 只能保证"不插队",Lua 能保证"逻辑原子性"(读-判断-写一气呵成)。 ```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 // Go 调用——与上方 Lua 脚本等价的分布式锁实现 script := redis.NewScript(` -- 尝试获取锁: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]) return 1 end return 0 -- 被其他客户端持有 `) // KEYS[1] = lock key, ARGV[1] = TTL 秒数, ARGV[2] = 唯一令牌(如 UUID) ok, err := script.Run(ctx, rdb, []string{"lock:order:" + orderId}, lockTTL, clientToken).Int() if ok == 1 { defer func() { // 解锁:只有持有者才能释放(Lua 保证原子性) unlockScript.Run(ctx, rdb, []string{"lock:order:" + orderId}, clientToken) }() doBiz() // 临界区代码 } ``` > [!tip] 分布式锁深入阅读 > 本节仅展示 Lua 实现分布式锁的核心代码。关于安全解锁、锁续期(Watchdog)、Redlock 算法、可重入锁等进阶内容,详见 [[hhs/Redis/09-高级特性/分布式锁]]。 ### 经典场景 2:限流器(令牌桶简化版) ```lua -- rate_limit.lua local limit = tonumber(ARGV[1]) -- 每分钟上限 local window = tonumber(ARGV[2]) -- 窗口秒数 local current = tonumber(redis.call('get', KEYS[1]) or '0') if current >= limit then return 0 -- 超限,拒绝 end local pipe = redis.pipeline() pipe.incr(KEYS[1]) pipe.expire(KEYS[1], window) pipe.exec() 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 numkeys args... # 只传 SHA1,省带宽 ``` > [!TIP] 使用 EVALSHA 的最佳实践 > ```go > script := redis.NewScript(...) > // 首次调用失败可能是 NOSCRIPT(缓存未命中),会自动重试 EVAL > result := script.Run(ctx, ...).Int() > ``` > go-redis 内部已处理了 NOSCRIPT → EVAL fallback 的逻辑。 ### Lua 沙箱限制 ```lua -- ❌ 不可用:非确定性函数(传统 Redis < 7.2) math.random() -- 无法预测结果 os.date() -- 依赖系统时间 -- ✅ 推荐做法:在客户端生成随机值,通过 ARGV 传入 -- local token = client.generateUUID() -- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入 ``` > [!NOTE] 关于 Lua 沙箱中的数学函数 > `math.max`、`math.min`、`math.log`、`math.abs` 等纯函数在沙箱中可用——它们的输出完全由输入决定,属于确定性函数。但 `math.random` **始终不可用**:即使设置相同 seed,伪随机序列也属于非确定性行为,会破坏主从一致性和 AOF 重放。随机值必须由客户端生成后通过 ARGV 传入。 > [!QUESTION] 为什么 Lua 脚本不能有随机函数? > Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题: > 1. **主从不一致**:主节点执行返回 42,从节点重放却得到 87,数据分裂。 > 2. **AOF 重放不可靠**:重启后从 appendonly.aof 重放脚本,结果与上次不同,状态机紊乱。 > 因此 Redis 在 Lua 环境中屏蔽了所有非确定性 API,保证「同样的输入一定得到同样的输出」。 ## 三、Pipeline ### 为什么需要 Pipeline? Redis 协议本质是 request/response,每个命令一次网络往返(RTT)。假设客户端到服务器 RTT 为 1ms,执行 1000 条命令: | 方式 | RTT 次数 | 总耗时估算 | |------|---------|-----------| | 单次发送 | 1000 | ~1s | | Pipeline (batch=200) | 5 次 | ~5ms | ### 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 // 方式二:Watch + TxPipeline(乐观锁 —— 读-改-写原子的核心模式) // 核心思路:WATCH 监听 key → 在回调内读-判断-写 → 如果 key 被别人改了则整个回调失败重试 err := rdb.Watch(ctx, func(tx *redis.Tx) error { // 1. 读:获取当前余额 acc, err := tx.Get(ctx, "account:alice").Result() if err != nil { return err } 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 }, "account:alice") if err == redis.TxFailedErr { // 被 Watch 的 key 被其他客户端修改了,需要重试整个操作 log.Println("并发冲突,重试...") } ``` > [!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 pipe := rdb.Pipeline() for i := 0; i < len(srcKeys); i += batchSize { batch := srcKeys[i : min(i+batchSize, len(srcKeys))] for _, key := range batch { val, _ := rdb.Get(ctx, key).Result() newKey := strings.TrimPrefix(key, "old:") pipe.Set(ctx, newKey, val, 0) } _, err := pipe.Exec(ctx) if err != nil { return err } pipe.Close() pipe = rdb.Pipeline() // 重建 pipeline 避免内存泄漏 } return nil } ``` ## 四、Pub/Sub —— 发布订阅 ### 基础用法 ```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 广播、实时通知、配置变更推送 ### Stream —— 可靠的替代方案 Stream 是 Redis 5.0+ 引入的消息队列数据结构,弥补了 Pub/Sub 的可靠性缺陷。核心设计围绕 **Consumer Group(消费者组)**: ```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 ``` > [!TIP] `$` vs `>` —— 初学者最容易踩的坑 > | 标识符 | 含义 | 使用场景 | > |--------|------|---------| > | `$` | "最后一条已写入的消息" | 新 consumer 加入时,只接收**将来**的消息 | > | `0` | "历史第一条" | 数据迁移/重放(谨慎用于大 stream) | > | `>` | "我只取未分配给我的新消息" | **正常消费循环中始终用 `>`**,不取到 pending 消息 | > [!QUESTION] 如果消费者挂了怎么办? > 好消息会被移到 **pending 列表**。用 `XPENDING mystream group1` 查看,用 `XCLAIM` 将 pending 消息转移给其他 consumer 重新处理。这就是 Stream 比 Pub/Sub 可靠的地方:**有状态、可追责**。 ```mermaid flowchart LR P["Producer"] -->|"XADD"| S["Stream (持久化)"] S -->|"读取并标记 pending"| G1["Consumer Group 1
consumer-a"] S -->|"读取并标记 pending"| G2["Consumer Group 2
consumer-b"] G1 -->|"XACK"| S style S fill:#dfd,stroke:#090 style G1 fill:#ddf,stroke:#669 style G2 fill:#ddf,stroke:#669 ``` > [!TIP] Pub/Sub vs Stream 选型 > | 场景 | 推荐 | > |------|------| > | WebSocket 实时推送 | Pub/Sub | > | 任务队列、订单事件 | Stream | > | 海量消息、高吞吐 | Kafka/RabbitMQ | > [!tip] Stream 深入阅读 > 本节仅覆盖 Stream 基础用法。Consumer Group 内部机制、消息确认与重试(XCLAIM/XAUTOCLAIM)、Dead Letter Queue 模式、容量管理等进阶内容,详见 [[hhs/Redis/15-Stream]]。 ## 五、Keyspace Notifications —— 键事件监听 ### 原理与配置 Redis 会在每个 key 发生变更时发送通知,订阅者通过 Pub/Sub 协议接收。开启方式: ```conf # redis.conf(或运行时 CONFIG SET) notify-keyspace-events KEA # K = Keyspace events → 发布到 __keyspace@__ prefix # E = Keyevent events → 发布到 __keyevent@__ prefix # A = All events 简写 → 覆盖 g/l/s/h/z/x/e(不包含 K/E/d/t/n) # g = DEL/EXPIRE/RENAMExxx 等通用命令 # l = list, s = set, h = hash, z = sorted-set # x = expired, e = evicted (maxmemory 淘汰) ``` ```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` 通配一切 ## 六、选型指南——如何选择? 面对一个需求,到底该用事务、Lua、Pipeline 还是 Stream?以下是决策流程: ```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 | 无状态、低延迟 | > [!TIP] 一句话总结 > - **Pipeline**:省网络,不保证原子性 > - **MULTI**:保证顺序,不保证逻辑原子 > - **Lua**:真正的原子性,但阻塞主线程(脚本要短小精悍) > - **Stream**:需要消息可靠投递时的首选 ## 关联笔记 - [[hhs/Redis/09-高级特性/分布式锁]] — 分布式锁完整解析(Redlock、Watchdog、可重入锁) - [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳) - [[hhs/Redis/07-集群方案]] — Cluster 下 Pipeline/Lua 的限制 - [[hhs/Redis/README]] — 知识索引总览