Skip to content
Redis 高级特性

Redis 高级特性

本文涵盖 Redis 事务、Lua 脚本、Pipeline、模块系统、ACL 和客户端缓存的完整指南。


1. 事务 (Transactions)

1.1 事务概述

Redis 事务提供了一种将多个命令打包执行的机制,但与关系数据库的事务有本质区别。

特性 关系数据库事务 Redis 事务
原子性 (Atomicity) 全部成功或全部回滚 命令排队执行,中间不插入其他命令
回滚 (Rollback) 支持 不支持
隔离性 (Isolation) ACID 中的 I 单线程执行,天然隔离
持久性 (Durability) 事务所写持久化 取决于 Redis 持久化配置

Redis 事务三大特性:

  • 一次性:MULTI 开启后,所有命令一次性入队,EXEC 时一次性执行。
  • 顺序性:命令按入队顺序严格执行。
  • 排他性:事务队列执行期间,其他客户端命令不会插入。

核心命令一览:

命令 作用
MULTI 开启事务,标记事务块开始
EXEC 执行事务队列中的所有命令
DISCARD 放弃事务,清空命令队列
WATCH key [key ...] 乐观锁,监视 key 变化
UNWATCH 取消所有 WATCH 监视

1.2 事务命令详解

MULTI

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET user:1:name "张三"
QUEUED
127.0.0.1:6379> SET user:1:age "28"
QUEUED
127.0.0.1:6379> INCR user:1:visits
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
3) (integer) 1

所有命令在 MULTI 后进入队列(返回 QUEUED),直到 EXEC 时才真正执行。EXEC 返回每个命令的执行结果数组。

DISCARD

放弃事务,清空队列:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET temp "will be discarded"
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> GET temp
(nil)

WATCH – 乐观锁

🔬 深入原理:WATCH 实现的是乐观锁(Optimistic Locking),类似于 CAS(Compare-And-Swap)。它监视一个或多个 key,在 EXEC 执行前,如果任意被监视的 key 被其他客户端修改,则整个事务不会执行,EXEC 返回 (nil)

# 终端 1:监视并开启事务
127.0.0.1:6379> WATCH stock:1001
OK
127.0.0.1:6379> GET stock:1001
"100"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR stock:1001
QUEUED
# 此时终端 2 执行了: SET stock:1001 99
127.0.0.1:6379> EXEC
(nil)   # 事务被取消,因为 stock:1001 已被修改
# 终端 2:在终端 1 的事务提交前修改
127.0.0.1:6379> SET stock:1001 99
OK

WATCH 的 key 列表在 EXEC 后自动取消监视,也可用 UNWATCH 手动取消。

1.3 事务的限制与特点

✅ 原子性

事务队列中的命令要么全部执行,要么因 WATCH 失败全部不执行。但执行中的错误不会引发回滚。

❌ 不支持回滚

这是 Redis 事务最容易误解的地方,分两种错误场景:

场景一:编译时错误(语法错误)—— 事务全部失败

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 "value1"
QUEUED
127.0.0.1:6379> INVALID_COMMAND   # 不存在的命令
(error) ERR unknown command 'INVALID_COMMAND'
127.0.0.1:6379> SET key2 "value2"
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET key1
(nil)   # 第一条也没执行,全部被丢弃

场景二:运行时错误 —— 错误的命令失败,其他命令继续执行

127.0.0.1:6379> SET str_key "hello"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> LPUSH str_key "world"   # 对 String 执行 List 操作
QUEUED
127.0.0.1:6379> SET key2 "good"
QUEUED
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) OK                                  # key2 仍然被正确设置
127.0.0.1:6379> GET key2
"good"

🚨 这证明了 Redis 事务没有回滚——某条命令执行失败不影响队列中的后续命令。

❌ 不支持隔离级别

Redis 是单线程模型,事务执行期间天然串行,不需要隔离级别概念。但这意味着 EXEC 执行时整个事务会阻塞其他命令。

完整 Go 示例:WATCH + 事务实现乐观锁

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

func deductStock(ctx context.Context, rdb *redis.Client, key string) error {
	txf := func(tx *redis.Tx) error {
		// 在事务外获取当前值
		n, err := tx.Get(ctx, key).Int()
		if err != nil && err != redis.Nil {
			return err
		}
		if n <= 0 {
			return fmt.Errorf("库存不足")
		}

		// 事务内操作:WATCH 之后到 EXEC 之间如果 key 被改,则返回 error
		_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
			pipe.Decr(ctx, key)
			return nil
		})
		return err
	}

	// 重试机制:WATCH 失败时自动重试
	for i := 0; i < 3; i++ {
		err := rdb.Watch(ctx, txf, key)
		if err == nil {
			return nil // 成功
		}
		if err == redis.TxFailedErr {
			// WATCH 失败(事务冲突),重试
			continue
		}
		return err // 其他错误直接返回
	}
	return fmt.Errorf("多次重试后仍失败")
}

func main() {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	rdb.Set(ctx, "stock:1001", 100, 0)

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			err := deductStock(ctx, rdb, "stock:1001")
			if err != nil {
				fmt.Println("扣库存失败:", err)
			} else {
				fmt.Println("扣库存成功")
			}
		}()
	}
	wg.Wait()
}

1.4 💡 事务应用场景

场景 方式 说明
扣库存(乐观锁) WATCH + MULTI/EXEC 避免超卖
转账操作 MULTI/EXEC 多 key 原子更新
条件更新 WATCH + 条件判断 仅在满足条件时更新

1.5 🚨 事务常见陷阱

  1. 认为 Redis 事务能回滚 —— Redis 不支持回滚!运行时错误不会触发回滚,其他命令照常执行。
  2. WATCH 静默失败 —— WATCH 的 key 被修改后 EXEC 返回 nil 而非报错,容易忘记处理此分支。
  3. 事务中命令过多导致阻塞 —— 单线程模型下,一个长事务会阻塞所有其他客户端。
  4. 集群模式下所有 key 必须在同一槽 —— 跨 slot 的事务在集群模式下会失败(应使用 Hash Tag 确保同槽)。
  5. WATCH 不能在 MULTI 之后使用 —— WATCH 必须在使用 MULTI 之前调用。

2. Lua 脚本

2.1 为什么用 Lua

优势 说明
原子性 整个脚本作为原子操作执行,不会被其他命令穿插
减少网络开销 多条命令封装为一个脚本,一次 RTT 搞定
复用性 脚本可通过 SCRIPT LOAD 缓存,EVALSHA 调用
灵活性 可实现复杂的判断逻辑(条件、循环、变量)

2.2 命令详解

EVAL

EVAL script numkeys key [key ...] arg [arg ...]

参数说明:

  • script:Lua 脚本内容(字符串)。
  • numkeys:KEYS 数组的长度,用于区分 KEYS 和 ARGV。
  • key [key ...]:KEYS 数组,脚本中通过 KEYS[1]KEYS[2] 访问。
  • arg [arg ...]:ARGV 数组,脚本中通过 ARGV[1]ARGV[2] 访问。
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
OK
127.0.0.1:6379> GET mykey
"myvalue"

🔬 为什么区分 KEYS 和 ARGV:在 Redis 集群模式下,节点根据 key 的哈希槽(slot)路由请求。KEYS 数组中的 key 用于确定脚本应发送到哪个节点,因此所有被脚本操纵的 Redis key 都必须放在 KEYS 中。ARGV 只存储不参与路由的辅助数据。

EVALSHA

EVALSHA sha1 numkeys key [key ...] arg [arg ...]
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"a5260dd66c02f97f8c11a3a42ab3e5f7d45a5a12"
127.0.0.1:6379> EVALSHA a5260dd66c02f97f8c11a3a42ab3e5f7d45a5a12 1 mykey
"myvalue"

如果脚本未缓存:

127.0.0.1:6379> EVALSHA deadbeef 1 mykey
(error) NOSCRIPT No matching script. Please use EVAL.

SCRIPT 命令族

命令 作用
SCRIPT LOAD script 加载脚本到缓存,返回 SHA1
SCRIPT EXISTS sha1 [sha1 ...] 检查脚本是否在缓存中
SCRIPT FLUSH [ASYNC|SYNC] 清空所有脚本缓存
SCRIPT KILL 终止正在运行的脚本(仅限未执行写操作的脚本)
SCRIPT DEBUG YES|SYNC|NO 进入调试模式

2.3 Lua 在 Redis 中的特殊规则

redis.call() vs redis.pcall()

-- redis.call():命令失败时抛出 Lua 错误,脚本终止
local v = redis.call('GET', KEYS[1])

-- redis.pcall():命令失败时返回 error 对象,脚本可继续
local result = redis.pcall('GET', KEYS[1])
if result.err then
    return "key not found"
end
方法 行为 使用场景
redis.call() 出错即终止脚本 必须成功的操作
redis.pcall() 出错返回 error table,脚本继续 允许失败、需要错误处理的场景

数据类型转换表

Redis 类型 Lua 类型 示例
status reply {ok="OK"} redis.call('SET', ...) 的返回值
error reply {err="error message"} 命令执行错误
integer number redis.call('INCR', ...) 的返回值
bulk reply (string) string redis.call('GET', ...) 的返回值
multi bulk (array) table (array) redis.call('SMEMBERS', ...) 的返回值
nil bulk reply false key 不存在时 GET 返回

Caution

Lua 中数组下标从 1 开始,不是 0。

-- KEYS[1], KEYS[2] ... 不是 KEYS[0]
local val = redis.call('GET', KEYS[1])
-- ARGV[1], ARGV[2] ...
local limit = tonumber(ARGV[1])

🔬 脚本执行期间的阻塞行为

Lua 脚本执行期间,整个 Redis 服务器被阻塞——其他客户端的所有命令都会排队等待

  • 脚本应快进快出,不要在脚本中做长时间循环或 CPU 密集计算。
  • 只读脚本可用 SCRIPT KILL 强制终止。
  • 已执行过写命令的脚本无法通过 SCRIPT KILL 终止,只能用 SHUTDOWN NOSAVE(这会导致数据丢失)。
127.0.0.1:6379> SCRIPT KILL
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

2.4 常用 Lua 脚本示例

示例 1:分布式锁安全释放

原子地校验锁的持有者身份,身份匹配才释放,防止释放别人的锁:

-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的 value(持有者标识,如 UUID)
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
# EVAL script 1 lock:order:1001 "my-uuid-1234"
127.0.0.1:6379> SET lock:order:1001 "my-uuid-1234" NX EX 30
OK
127.0.0.1:6379> EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:1001 "my-uuid-1234"
(integer) 1

示例 2:滑动窗口限流器

-- KEYS[1]: 限流 key (如 rate:user:1001)
-- ARGV[1]: 窗口内允许的最大请求数
-- ARGV[2]: 窗口大小(秒)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(redis.call('TIME')[1])     -- 当前 Unix 秒

-- 窗口起始时间
local window_start = now - window

-- 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

-- 当前窗口内的请求数
local current = redis.call('ZCARD', key)

if current < limit then
    redis.call('ZADD', key, now, now .. ':' .. ARGV[3])  -- ARGV[3] 为请求 ID 去重
    redis.call('EXPIRE', key, window + 1)
    return 1   -- 放行
else
    return 0   -- 限流
end
EVAL "..." 1 rate:user:1001 5 60 req-001

示例 3:秒杀扣库存 + 一人一单

-- KEYS[1]: 库存 key (如 seckill:stock:1001)
-- KEYS[2]: 购买记录 key (如 seckill:users:1001)
-- ARGV[1]: 用户 ID
local userId = ARGV[1]
local stockKey = KEYS[1]
local orderKey = KEYS[2]

-- 1. 检查库存
local stock = tonumber(redis.call('get', stockKey) or 0)
if stock <= 0 then
    return 0   -- 库存不足
end

-- 2. 检查是否已购买(一人一单)
local exist = redis.call('sismember', orderKey, userId)
if exist == 1 then
    return 2   -- 已购买过了
end

-- 3. 扣库存 + 标记已购买
redis.call('decr', stockKey)
redis.call('sadd', orderKey, userId)
return 1       -- 抢购成功

Go 端调用:

const luaSeckill = `
local userId = ARGV[1]
local stockKey = KEYS[1]
local orderKey = KEYS[2]
local stock = tonumber(redis.call('get', stockKey) or 0)
if stock <= 0 then return 0 end
if redis.call('sismember', orderKey, userId) == 1 then return 2 end
redis.call('decr', stockKey)
redis.call('sadd', orderKey, userId)
return 1
`

func seckill(ctx context.Context, rdb *redis.Client, stockKey, orderKey, userId string) (int, error) {
    result, err := rdb.Eval(ctx, luaSeckill, []string{stockKey, orderKey}, userId).Int()
    if err != nil {
        return -1, err
    }
    switch result {
    case 0: fmt.Println("库存不足")
    case 1: fmt.Println("抢购成功")
    case 2: fmt.Println("已经购买过了")
    }
    return result, nil
}

2.5 🔬 EVALSHA 缓存机制

脚本缓存的生命周期:

  • 脚本缓存保存在服务器内存中,不与 RDB/AOF 持久化
  • Redis 重启后缓存清空。
  • 通过 SCRIPT FLUSH 手动清空。

客户端最佳实践:

func EvalWithFallback(ctx context.Context, rdb *redis.Client, sha, script string, keys []string, args ...interface{}) *redis.Cmd {
    // 优先使用 EVALSHA(节省带宽)
    result := rdb.EvalSha(ctx, sha, keys, args...)
    if err := result.Err(); err != nil && strings.HasPrefix(err.Error(), "NOSCRIPT") {
        // 缓存未命中,回退到 EVAL
        result = rdb.Eval(ctx, script, keys, args...)
    }
    return result
}

Go-redis 的 EvalSha 在 NOSCRIPT 时返回错误,需要在业务代码中做 fallback。

2.6 Redis Functions (Redis 7.0+)

Redis 7.0 引入了 Functions 机制,旨在取代传统的 EVAL 脚本。Function 是持久化的——随 RDB 和 AOF 一起保存,重启后自动加载。

与 EVAL 的对比

特性 EVAL Script Redis Function
持久化 不持久化,重启丢失 随 RDB/AOF 持久化
管理 无名称,仅靠 SHA1 有名称,可 LIST/DELETE
库组织 可按库(library)组织
版本控制 支持函数版本
调用方式 EVAL/EVALSHA FCALL/FCALL_RO

基本用法

# 加载函数库
127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib\nredis.register_function('my_get', function(keys, args) return redis.call('GET', keys[1]) end)"

# 列出所有函数
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "my_get"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)

# 调用函数(FCALL 代替 EVAL)
127.0.0.1:6379> FCALL my_get 1 mykey
"myvalue"

# 删除函数库
127.0.0.1:6379> FUNCTION DELETE mylib
OK

FCALL vs EVAL 参数差异

-- EVAL 方式的函数签名
-- EVAL "..." 2 key1 key2 arg1 arg2
-- 脚本中: KEYS[1]=key1, KEYS[2]=key2, ARGV[1]=arg1, ARGV[2]=arg2

-- FUNCTION LOAD 方式的函数签名
-- redis.register_function('myfunc', function(keys, args)
--     local key1 = keys[1]
--     local arg1 = args[1]
-- end)
-- FCALL myfunc 2 key1 key2 arg1 arg2

更多库组织示例:

#!lua name=cache_lib

-- 缓存读取 + 自动回源(简化版 Cache-Aside)
redis.register_function(
    'cache_get_or_set',
    function(keys, args)
        local cache_key = keys[1]
        local ttl = tonumber(args[1])

        local cached = redis.call('GET', cache_key)
        if cached then
            return cached
        end

        -- 回源逻辑由调用方通过 args 传入标志位控制
        -- 这里简化为返回 nil,由客户端回源
        return false
    end
)

💡 选型建议

  • 新项目优先使用 Redis Functions(持久化 + 可管理)。
  • 临时脚本、调试场景可用 EVAL。
  • 旧版 Redis(<7.0)只能使用 EVAL。

2.7 🚨 Lua 常见陷阱

  1. 脚本执行时间过长 —— Lua 脚本执行期间整个 Redis 阻塞。避免脚本中有 for 循环遍历大量 key、长耗时计算等。

  2. 集群模式下所有 key 必须位于同一槽 —— 脚本中操作的所有 key 必须路由到同一节点,否则报错 CROSSSLOT。解决方案是使用 Hash Tag:

    # 正确的 Hash Tag 用法
    SET {order:1001}:stock 100
    SET {order:1001}:users "set_key"
  3. 脚本中写入不确定性命令 —— 以下命令在脚本中受限:

    • 随机命令:SPOPSRANDMEMBER(Redis 5.0+ 可以通过 redis.replicate_commands() 绕过)
    • 时间命令:TIME(Redis 5.0+ 同样可绕过)
    • 跨节点命令:RANDOMKEY
    • 原因:Redis 主从复制需要保证确定性,否则从库结果不一致。
  4. 复制模式混用 —— 默认复制模式是复制整个脚本(script replication),Redis 3.2+ 支持 effects replication。若脚本中用了随机命令,需要:

    redis.replicate_commands()  -- 开启命令级别复制
    local v = redis.call('SRANDMEMBER', 'myset')
  5. 不要在脚本中做 CPU 密集计算 —— 比如大 JSON 解析、复杂算法。Redis 是单线程事件循环,CPU 密集 = 全局阻塞。

  6. EVALSHA 缓存丢失 —— 脚本缓存不持久,重启或 SCRIPT FLUSH 后失效,业务代码必须处理 NOSCRIPT 回退。

  7. KEYS 和 ARGV 混用 —— 集群路由依赖 KEYS,所有被脚本操作的 Redis key 都应放在 KEYS 中,不应通过 ARGV 传递 key。


3. Pipeline(管道)

3.1 原理

🔬 深入原理:普通的客户端-服务端交互中,每发一条命令都要等待回复,一次通信 = 1 RTT(Round-Trip Time)。Pipeline 将一批命令打包成一次 TCP 发送,服务端批量处理后一次性批量返回,从而将 N 次 RTT 压缩为 1 次。

无 Pipeline:
Client:  → SET key1 v1 →  等待  → ← OK  → SET key2 v2 →  等待  → ← OK

有 Pipeline:
Client:  → SET key1 v1 → SET key2 v2 → SET key3 v3 → 发送
Server:                                      ← OK ← OK ← OK  批量返回

Pipeline 与事务的关键区别

维度 Pipeline 事务 (MULTI/EXEC)
原子性 ❌ 不保证原子性,其他客户端命令可插入 ✅ 队列连续执行
回滚 不涉及 ❌ 不支持
使用方式 客户端行为,服务端无感知 服务端机制,MULTI/EXEC 包围
适用场景 批量读写,不要求原子性 需要原子操作的场景

3.2 性能对比

测试场景:连续执行 10000 次 SET 命令。

方式 RTT 次数 理论延迟(假设 1ms RTT)
逐个发送 10000 ~10 秒
Pipeline (100 条/批) 100 ~0.1 秒
Pipeline (1000 条/批) 10 ~0.01 秒
Pipeline (10000 条/批) 1 ~0.001 秒

在高延迟网络(如跨机房、云环境)中,Pipeline 带来的性能提升更加显著——最高可达 50-100 倍。

3.3 使用示例

Go-redis Pipeline(手动管理)

package main

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
)

func main() {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	// 方式一:Pipeline() - 手动管理
	pipe := rdb.Pipeline()

	// 入队命令(尚未发送到 Redis)
	set1 := pipe.Set(ctx, "user:1:name", "Alice", 0)
	set2 := pipe.Set(ctx, "user:1:age", 30, 0)
	incr := pipe.Incr(ctx, "user:1:logins")

	// Exec() 一次性发送所有命令并接收结果
	cmds, err := pipe.Exec(ctx)
	if err != nil {
		panic(err)
	}

	// 逐个获取结果
	for i, cmd := range cmds {
		fmt.Printf("cmd[%d]: %v\n", i, cmd)
	}
	fmt.Println("set1:", set1.Val())  // OK
	fmt.Println("set2:", set2.Val())  // OK
	fmt.Println("incr:", incr.Val())  // 1
}

Go-redis Pipelined(自动管理)

// 方式二:Pipelined() - 回调式,自动 Exec
var incr *redis.IntCmd
_, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
	pipe.Set(ctx, "key1", "val1", 0)
	pipe.Set(ctx, "key2", "val2", 0)
	incr = pipe.Incr(ctx, "counter")
	return nil
})
if err != nil {
	panic(err)
}
fmt.Println("counter:", incr.Val())

批量获取示例

// 批量获取多个 key 的值
func batchGet(ctx context.Context, rdb *redis.Client, keys []string) (map[string]string, error) {
    pipe := rdb.Pipeline()
    cmds := make(map[string]*redis.StringCmd, len(keys))

    for _, key := range keys {
        cmds[key] = pipe.Get(ctx, key)
    }

    _, err := pipe.Exec(ctx)
    if err != nil && err != redis.Nil {
        return nil, err
    }

    result := make(map[string]string, len(keys))
    for key, cmd := range cmds {
        val, err := cmd.Result()
        if err == redis.Nil {
            continue // key 不存在跳过
        }
        if err != nil {
            return nil, err
        }
        result[key] = val
    }
    return result, nil
}

3.4 ⚡ 注意事项

事项 说明
单次 Pipeline 大小 建议每批不超过 100 条命令(避免内存/带宽压力过大)
非原子性 Pipeline 中的命令可能被其他客户端命令穿插执行
命令无依赖 Pipeline 适合没有因果依赖的批量操作
网络缓冲 适当的分批大小可以充分利用 TCP 缓冲区
集群模式 所有 key 必须在同一槽(除非客户端做了槽路由)

3.5 🚨 Pipeline 常见陷阱

  1. 一次发送过多命令 —— 大量命令一次性注入,可能撑爆客户端/服务端缓冲区和内存。应合理分片(如按 100-200 条一组)。

  2. 误认为 Pipeline 是原子的 —— Pipeline 仅是网络优化,不保证原子性。如果业务需要原子性,请使用 Lua 脚本或事务。

  3. 集群模式跨槽 —— 标准 Pipeline 中的 key 如果分散在不同 slot,需要客户端自行按槽分组(go-redis 的 clusterClient.Pipeline() 会自动处理分组路由)。

  4. Pipeline 中某条命令失败不影响其他命令 —— 这是预期行为,但如果业务需要"全成功或全失败"的语义,Pipeline 做不到。

  5. 忽略返回值的错误检查 —— 每条命令都可能失败,需逐个检查,不能只看 Exec() 的返回值。


4. Redis Modules(模块)

4.1 模块生态概述

Redis Modules 是可动态加载的扩展库,以 .so (Linux) 或 .dll 文件形式存在,通过动态链接库机制为 Redis 扩展新的数据类型和命令。

# 启动时加载
redis-server --loadmodule /path/to/module.so

# 运行时加载
redis-cli MODULE LOAD /path/to/module.so

# 查看已加载模块
127.0.0.1:6379> MODULE LIST
1) 1) "name"
   2) "bf"
   3) "ver"
   4) (integer) 20600
命令 作用
MODULE LOAD <path> 加载模块
MODULE UNLOAD <name> 卸载模块
MODULE LIST 列出已加载的模块

4.2 主要模块详解

4.2.1 RedisBloom — 概率数据结构

RedisBloom 提供四种概率型数据结构,以内存换精度,适合海量数据去重/计数。

布隆过滤器 (Bloom Filter)

# 创建布隆过滤器(容量 1000,误判率 0.01)
BF.RESERVE users:bloom 0.01 1000

# 添加元素
BF.ADD users:bloom "user:1001"
BF.MADD users:bloom "user:1002" "user:1003" "user:1004"

# 检查元素是否存在
BF.EXISTS users:bloom "user:1001"
# (integer) 1    -- 可能存在(不会误判为不存在)

BF.EXISTS users:bloom "user:9999"
# (integer) 0    -- 一定不存在

布谷鸟过滤器 (Cuckoo Filter):相比布隆过滤器支持删除操作。

CF.RESERVE items:cf 1000
CF.ADD items:cf "item-001"
CF.EXISTS items:cf "item-001"
CF.DEL items:cf "item-001"
CF.COUNT items:cf "item-001"

Count-Min Sketch:频率估计,适合统计热门元素。

CMS.INITBYDIM top:pages 2000 5
CMS.INCRBY top:pages "/home" 1
CMS.INCRBY top:pages "/products" 5
CMS.QUERY top:pages "/home"

Top-K:维护近似 Top K 排名。

TOPK.RESERVE top:tags 10 2000 7 0.925
TOPK.ADD top:tags "golang" "redis" "python"
TOPK.LIST top:tags
结构 支持删除 误判方向 典型场景
Bloom Filter 假阳性(不存在判为存在) 缓存穿透、去重
Cuckoo Filter 假阳性 需要删除的去重
Count-Min Sketch 偏高估计 频率统计
Top-K 近似排序 热门榜单

4.2.2 RedisJSON — JSON 文档操作

将 JSON 作为一等公民存入 Redis。

# 设置 JSON 文档
JSON.SET user:1001 $ '{"name":"张三","age":28,"tags":["golang","redis"]}'

# 获取整个文档
JSON.GET user:1001 $
# [{"name":"张三","age":28,"tags":["golang","redis"]}]

# 获取指定路径(JSONPath)
JSON.GET user:1001 $.name
# ["张三"]

JSON.GET user:1001 $.tags[0]
# ["golang"]

# 修改指定字段
JSON.SET user:1001 $.age 29

# 数值递增
JSON.NUMINCRBY user:1001 $.age 1
# [30]

# 数组操作
JSON.ARRAPPEND user:1001 $.tags '"kafka"'
JSON.ARRLEN user:1001 $.tags
JSON.ARRINDEX user:1001 $.tags '"redis"'

# 字符串追加
JSON.STRAPPEND user:1001 $.name ' (工程师)'

# 删除
JSON.DEL user:1001 $.tags[-1]

适用场景

  • 文档型存储需求(替代 MongoDB 简单场景)
  • 配置管理(JSON 格式配置)
  • API 响应缓存(直接存 JSON)

4.2.3 RediSearch — 全文搜索

# 创建索引
FT.CREATE idx:articles ON HASH PREFIX 1 article: SCHEMA title TEXT WEIGHT 5.0 body TEXT author TAG

# 添加文档
HSET article:1 title "Redis入门指南" body "Redis是一个开源的内存数据库..." author "张三"
HSET article:2 title "Redis高级特性" body "本文介绍Redis事务与Lua脚本..." author "李四"

# 全文搜索
FT.SEARCH idx:articles "Redis"
FT.SEARCH idx:articles "@title:Redis"
FT.SEARCH idx:articles "@author:{张三}"

# 聚合查询
FT.AGGREGATE idx:articles "*" GROUPBY 1 @author REDUCE COUNT 0 AS count

RediSearch vs Elasticsearch

维度 RediSearch Elasticsearch
部署复杂度 低(Redis 扩展) 高(独立集群)
延迟 极低(内存) 低(内存+磁盘)
数据量 受限于内存 可支撑 PB 级
功能丰富度 中等 极丰富
中文分词 需额外配置 内置 IK 分词器

4.2.4 RedisTimeSeries — 时序数据

# 创建时序
TS.CREATE temp:sensor:1 RETENTION 86400000 LABELS sensor_id 1 type temperature

# 添加数据点
TS.ADD temp:sensor:1 * 23.5
TS.ADD temp:sensor:1 * 24.1

# 按范围查询
TS.RANGE temp:sensor:1 1650000000 1650086400

# 聚合查询(按小时平均)
TS.RANGE temp:sensor:1 1650000000 1650086400 AGGREGATION avg 3600000

# 降采样规则(写入时自动聚合)
TS.CREATE temp:sensor:1:avg
TS.CREATERULE temp:sensor:1 temp:sensor:1:avg AGGREGATION avg 60000

# 多 key 查询
TS.MRANGE 1650000000 1650086400 AGGREGATION avg 3600000 FILTER type=temperature

适用场景:IoT 传感器数据、监控指标、股票行情。

4.2.5 RedisGraph — 图数据库

# 创建节点和关系
GRAPH.QUERY social "CREATE (:Person {name:'Alice', age:30})"
GRAPH.QUERY social "CREATE (:Person {name:'Bob', age:32})"
GRAPH.QUERY social "MATCH (a:Person {name:'Alice'}), (b:Person {name:'Bob'}) CREATE (a)-[:FRIEND]->(b)"

# 查询(Cypher 语法)
GRAPH.QUERY social "MATCH (a:Person)-[:FRIEND]->(b:Person) RETURN a.name, b.name"
GRAPH.QUERY social "MATCH (a:Person) WHERE a.age > 30 RETURN a.name, a.age"

4.2.6 RedisGears — 服务端编程框架

RedisGears 允许在 Redis 服务端执行数据处理逻辑,类似数据库的存储过程。

# RedisGears 使用 Python 编写处理逻辑
def process(x):
    redis.call('INCR', 'processed_count')
    return x

GB('StreamReader').map(process).register()

4.3 💡 模块选型建议

需求 推荐模块 备注
URL/ID 去重 RedisBloom (Bloom Filter) 内存极省,无法删除
去重 + 删除需求 RedisBloom (Cuckoo Filter) 略大于布隆,支持删除
JSON 文档存储 RedisJSON 比存序列化字符串操作更灵活
全文搜索 RediSearch 轻量场景可替代 ES
时序监控 RedisTimeSeries 自动降采样和保留策略
社交关系/推荐 RedisGraph 图遍历天然高效
自定义数据流 RedisGears 需 Python 能力

⚠️ 注意:模块增加运维复杂度(版本依赖、升级兼容性),按需使用,不要贪多


5. ACL (Access Control List) — Redis 6.0+

5.1 为什么需要 ACL

Redis 6.0 之前只有单密码机制(requirepass),存在明显不足:

旧方式的问题 ACL 的改进
只有一个密码,所有客户端共用 多用户独立认证
无法区分不同应用 为每个应用创建独立用户
无法限制可执行的命令 可精确到命令级别控制
无法限制可访问的 key 可按 key 模式控制访问范围
没有 Pub/Sub 频道控制 可限制频道订阅

5.2 ACL 命令详解

用户管理

# 列出所有 ACL 规则
ACL LIST
# 1) "user default on nopass ~* &* +@all"
# 2) "user app on >password123 ~cache:* +@all -@dangerous"

# 列出所有用户
ACL USERS
# 1) "default"
# 2) "app"

# 查看当前用户
ACL WHOAMI
# "default"

# 查看用户详情
ACL GETUSER app
# 1) "flags"
# 2) (empty array)
# 3) "passwords"
# 4) 1) "..."
# ...

创建/修改用户:ACL SETUSER

ACL SETUSER <username> [规则...]

完整语法规则:

规则 含义 示例
on / off 启用/禁用用户 on
>password 设置密码(明文) >mypassword
#hash 设置密码(SHA256 hash) #abc123...
<password 删除密码 <oldpassword
~pattern 允许的 key 模式 ~cache:*
+command 允许的命令 +get
-command 禁止的命令 -keys
+@category 允许的命令分类 +@read
-@category 禁止的命令分类 -@dangerous
+@all / -@all 允许/禁止所有命令 +@all
&pattern 允许的 Pub/Sub 频道 &chat:*
nopass 无需密码 nopass
resetpass 清除所有密码 resetpass
resetkeys 清除 key 权限 resetkeys
reset 恢复为默认设置 reset
# 创建只读用户
ACL SETUSER readonly_user on >readonly_pass ~* +@read -@write -@dangerous

# 创建缓存专用用户(只能操作 cache: 前缀的 key)
ACL SETUSER cache_user on >cache_pass ~cache:* +@all -keys -flushall -flushdb -config

# 创建管理员
ACL SETUSER admin on >admin_secret ~* &* +@all

# 禁用用户
ACL SETUSER readonly_user off

命令分类

查看所有命令分类:

ACL CAT
# 1) "keyspace"
# 2) "read"
# 3) "write"
# 4) "set"
# 5) "sortedset"
# 6) "list"
# 7) "hash"
# 8) "string"
# 9) "bitmap"
# 10) "hyperloglog"
# 11) "geo"
# 12) "stream"
# 13) "pubsub"
# 14) "admin"
# 15) "fast"
# 16) "slow"
# 17) "blocking"
# 18) "dangerous"
# 19) "connection"
# 20) "transaction"
# 21) "scripting"

# 查看某分类下有哪些命令
ACL CAT dangerous
# 1) "flushdb"
# 2) "acl"
# 3) "slowlog"
# 4) "debug"
# 5) "role"
# 6) "keys"
# 7) "pfselftest"
# 8) "client"
# ...

其他管理命令

# 删除用户
ACL DELUSER readonly_user

# 生成安全密码
ACL GENPASS
# "5d60k28ea..."

# 查看 ACL 拒绝日志
ACL LOG
# 1)  1) "count"
#     2) (integer) 5
#     3) "reason"
#     4) "command"
#     5) "context"
#     6) "toplevel"
#     7) "object"
#     8) "flushall"
#     9) "username"
#    10) "cache_user"
#    11) "age-seconds"
#    12) "120.5"
#    13) "client-info"
#    14) "id=55555 addr=127.0.0.1:42112 ..."

# 清空 ACL 日志(需要权限)
ACL LOG RESET

# 持久化 ACL 到文件 / 从文件加载
ACL SAVE
ACL LOAD

5.3 ACL 配置文件

推荐使用 ACL 文件管理用户(而非命令行),避免重启后丢失。

方式一:redis.conf 中引用外部 ACL 文件

# redis.conf
aclfile /etc/redis/users.acl

方式二:直接在 redis.conf 中使用 ACL 命令

# redis.conf
user default off
user admin on >admin_secret ~* &* +@all
aclfile /etc/redis/users.acl

⚠️ 两种方式互斥——aclfileredis.conf 中的 user 指令不能同时使用。

users.acl 示例:

# 禁用 default 用户(安全加固)
user default off

# 管理员:全部权限
user admin on >admin_secret ~* &* +@all

# 只读用户:只能读,不能写和执行危险命令
user app_readonly on >readonly_pass ~* +@read -@write -@dangerous

# 缓存服务:只能操作 cache:* 前缀
user app_cache on >cache_pass ~cache:* +@all -keys -flushall -flushdb -config

# 监控用户:无数据访问,仅监控
user monitor on >monitor_pass +ping +info +slowlog +client +cluster +latency

5.4 💡 ACL 最佳实践

  1. 为每个应用创建独立用户 —— 不要所有服务共用同一个密码和用户。
  2. 遵循最小权限原则 —— 只授予所需的最小命令和 key 范围。
  3. default 用户务必加密码 —— user default on >password,生产环境中可设为 off
  4. 禁用危险命令 —— 对不需要的普通用户禁用 KEYSFLUSHALLFLUSHDBCONFIGDEBUGSHUTDOWN
  5. 定期 ACL LOG 审查 —— 监控被拒绝的命令,发现误配置或攻击行为。
  6. 使用 ACL 文件管理 —— 将用户定义写入 users.acl,配合 ACL SAVE 持久化,避免重启丢失。
  7. 密码使用 ACL GENPASS 生成 —— 避免使用简单密码,开发环境也不要用弱密码。
  8. key 模式使用合理前缀 —— 如 ~app1:* 限制特定应用的 key 命名空间。

5.5 🚨 ACL 常见陷阱

  1. 忘记 ACL SAVE —— 命令行 ACL SETUSER 创建的修改仅存在于内存,重启丢失。务必 ACL SAVE 或在配置文件中定义。

  2. default 用户无密码 —— 新安装的 Redis 6.0+ 的 default 用户默认 nopass,任何能连接 Redis 的人都能执行所有命令。立即加上密码或将其 off

  3. 权限过宽或过窄 —— 过宽等于没有 ACL;过窄导致应用报错,难以排查。建议先在 staging 环境验证再上线。

  4. ACL 规则顺序问题 —— ACL SETUSER 的规则按先后顺序执行,后面的覆盖前面的:

    ACL SETUSER test on +@all -set   # 先允许所有,再禁止 SET
    ACL SETUSER test on -set +@all   # 先禁止 SET,再允许所有 → SET 可用!
  5. 外部 ACL 文件与 redis.conf 冲突 —— aclfileuser 指令互斥,同时使用会导致启动失败。

  6. 忘记 ~* 导致无法操作任何 key —— 新建用户默认没有 key 权限:

    ACL SETUSER newuser on >pass +@all   # 没有 ~* 规则,无法操作任何 key!
    ACL SETUSER newuser on >pass ~* +@all  # 正确

6. Client-Side Caching(客户端缓存)— Redis 6.0+

6.1 概述

传统的客户端缓存需要自行处理失效逻辑(TTL、主动删除等),容易产生脏数据。Redis 6.0 引入了服务端辅助的客户端缓存(Server-Assisted Client-Side Caching),由 Redis 服务端主动通知客户端数据变化,大幅提高缓存一致性。

工作机制:

  1. 客户端开启 tracking,Redis 记录该客户端读取了哪些 key。
  2. 当这些 key 被修改时,Redis 向客户端发送失效消息(invalidation message)。
  3. 客户端收到消息后,清除本地缓存中的对应数据。
  4. 下次请求时会重新从 Redis 拉取最新值。

6.2 两种模式

Default Mode(追踪模式)

服务端记录每个客户端读取过哪些 key(需要内存存储映射关系):

# 客户端开启追踪
CLIENT TRACKING ON

工作原理:

Client A: CLIENT TRACKING ON → GET user:1 → (缓存到本地)
Client B: SET user:1 "new"   → Redis 检测到 A 读过 user:1
Client A: ← (invalidate user:1)   → A 清除本地缓存中的 user:1

⚠️ 默认模式需要服务端为每个客户端维护一个 key 映射表(invalidation table),key 越多内存开销越大。

Broadcasting Mode(广播模式)

服务端不追踪每个客户端读了什么,而是将 key 失效消息广播给所有开启了广播模式的客户端:

CLIENT TRACKING ON BCAST PREFIX user:
  • BCAST:启用广播模式。
  • PREFIX user::仅接收以 user: 为前缀的 key 的失效消息(可指定多个 PREFIX)。
维度 Default Mode Broadcasting Mode
服务端内存 需要记录每个客户端的读取历史 不需要
网络开销 精确推送 广播(有无关消息)
客户端过滤 无需 需要自行过滤
适用场景 客户端数量少、读取 key 分散 大量客户端共用同一前缀

6.3 命令详解

# 开启默认模式追踪
CLIENT TRACKING ON

# 开启广播模式追踪(仅接收 user: 前缀的失效通知)
CLIENT TRACKING ON BCAST PREFIX user:

# 开启追踪 + 指定重定向客户端 ID(失效消息发送到另一个连接)
CLIENT TRACKING ON REDIRECT 12345

# 关闭追踪
CLIENT TRACKING OFF

# 查看追踪状态
CLIENT TRACKINGINFO
# 1) "flags"
# 2) "on bcast"
# 3) "redirect"
# 4) (integer) 0
# 5) "prefixes"
# 6) 1) "user:"

为什么需要 REDIRECT:在 RESP2 协议下,失效消息和正常响应在同一条连接上混合传输,客户端难以分拣。通过 REDIRECT 可以指定一个专门的连接来接收失效通知。

6.4 RESP3 的支持

RESP3 协议原生区分了推送消息类型,不再需要 REDIRECT 通道。RESP3 的推送消息格式为 >3(resp3 push type marker)。

# 使用 RESP3 连接
redis-cli -3

# 开启追踪
CLIENT TRACKING ON

# 开启追踪,不重定向(RESP3 自动区分)
CLIENT TRACKING ON OPTIN

OPTIN / OPTOUT 控制细粒度追踪:

  • OPTIN:默认不追踪,用 CLIENT CACHING YES 手动标记要追踪的 key。
  • OPTOUT:默认全部追踪,用 CLIENT CACHING NO 排除不追踪的 key。

6.5 💡 适用场景与局限性

适用场景

  • 高频读取、低频写入的数据(读多写少的缓存加速)
  • 微服务间的共享配置(服务端推送更新)
  • 实时排行榜(频繁查询,变化时自动失效)

局限性

  • 默认模式的服务端内存开销较大(需存储 key-client 映射)
  • 客户端需要实现缓存管理和失效处理逻辑
  • RESP3 生态尚未完全普及(部分客户端库不支持)
  • 不适合高频写入的 key(失效消息过多,得不偿失)

6.6 Go 客户端示例(概念性)

// go-redis v9 支持 RESP3, 但客户端缓存需要自行实现
// 以下为概念性示例

rdb := redis.NewClient(&redis.Options{
    Addr:            "localhost:6379",
    Protocol:        3,        // 使用 RESP3
    DisableIdentity: true,     // 某些版本的兼容设置
})

// 开启追踪
rdb.Do(ctx, "CLIENT", "TRACKING", "ON")

// 本地缓存层(需要自行实现)
type LocalCache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *LocalCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.data[key]
    return v, ok
}

func (c *LocalCache) Del(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.data, key)
}

// 读取时优先本地缓存,miss 时回源 Redis
func GetWithCache(ctx context.Context, cache *LocalCache, rdb *redis.Client, key string) (string, error) {
    // 1. 先查本地缓存
    if val, ok := cache.Get(key); ok {
        return val, nil
    }
    // 2. 回源 Redis
    val, err := rdb.Get(ctx, key).Result()
    if err != nil {
        return "", err
    }
    // 3. 写入本地缓存(带 TTL 的逻辑需自行实现)
    // cache.Set(key, val, ttl)
    return val, nil
}

注:实际生产级实现复杂得多,涉及 RESP3 推送消息的解析、失效消息分发等。建议评估收益后再决定是否自建,或使用 Redis Enterprise 等商业方案。

6.7 🚨 Client-Side Caching 常见陷阱

  1. 默认模式的内存开销 —— 每个追踪的客户端对应一张 key 映射表,大量客户端和 key 时内存压力显著。
  2. 广播模式的消息风暴 —— BCAST 模式下所有开启 BCAST 的客户端都会收到所有匹配前缀的失效消息,可能产生不必要的网络开销。
  3. RESP2 vs RESP3 —— RESP2 下失效消息和数据响应在同一个连接,需要使用 REDIRECT。RESP3 原生区分,但要求客户端库支持。
  4. 客户端复杂度 —— 客户端需要处理失效消息、本地缓存管理、TTL 策略等,比传统缓存模式复杂得多。

参考资源