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
OKWATCH 的 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 🚨 事务常见陷阱
- 认为 Redis 事务能回滚 —— Redis 不支持回滚!运行时错误不会触发回滚,其他命令照常执行。
- WATCH 静默失败 —— WATCH 的 key 被修改后 EXEC 返回 nil 而非报错,容易忘记处理此分支。
- 事务中命令过多导致阻塞 —— 单线程模型下,一个长事务会阻塞所有其他客户端。
- 集群模式下所有 key 必须在同一槽 —— 跨 slot 的事务在集群模式下会失败(应使用 Hash Tag 确保同槽)。
- 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 -- 限流
endEVAL "..." 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
OKFCALL 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 常见陷阱
-
脚本执行时间过长 —— Lua 脚本执行期间整个 Redis 阻塞。避免脚本中有
for循环遍历大量 key、长耗时计算等。 -
集群模式下所有 key 必须位于同一槽 —— 脚本中操作的所有 key 必须路由到同一节点,否则报错
CROSSSLOT。解决方案是使用 Hash Tag:# 正确的 Hash Tag 用法 SET {order:1001}:stock 100 SET {order:1001}:users "set_key" -
脚本中写入不确定性命令 —— 以下命令在脚本中受限:
- 随机命令:
SPOP、SRANDMEMBER(Redis 5.0+ 可以通过redis.replicate_commands()绕过) - 时间命令:
TIME(Redis 5.0+ 同样可绕过) - 跨节点命令:
RANDOMKEY - 原因:Redis 主从复制需要保证确定性,否则从库结果不一致。
- 随机命令:
-
复制模式混用 —— 默认复制模式是复制整个脚本(script replication),Redis 3.2+ 支持 effects replication。若脚本中用了随机命令,需要:
redis.replicate_commands() -- 开启命令级别复制 local v = redis.call('SRANDMEMBER', 'myset') -
不要在脚本中做 CPU 密集计算 —— 比如大 JSON 解析、复杂算法。Redis 是单线程事件循环,CPU 密集 = 全局阻塞。
-
EVALSHA 缓存丢失 —— 脚本缓存不持久,重启或
SCRIPT FLUSH后失效,业务代码必须处理 NOSCRIPT 回退。 -
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 常见陷阱
-
一次发送过多命令 —— 大量命令一次性注入,可能撑爆客户端/服务端缓冲区和内存。应合理分片(如按 100-200 条一组)。
-
误认为 Pipeline 是原子的 —— Pipeline 仅是网络优化,不保证原子性。如果业务需要原子性,请使用 Lua 脚本或事务。
-
集群模式跨槽 —— 标准 Pipeline 中的 key 如果分散在不同 slot,需要客户端自行按槽分组(go-redis 的
clusterClient.Pipeline()会自动处理分组路由)。 -
Pipeline 中某条命令失败不影响其他命令 —— 这是预期行为,但如果业务需要"全成功或全失败"的语义,Pipeline 做不到。
-
忽略返回值的错误检查 —— 每条命令都可能失败,需逐个检查,不能只看
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 countRediSearch 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 LOAD5.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⚠️ 两种方式互斥——aclfile 和 redis.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 +latency5.4 💡 ACL 最佳实践
- 为每个应用创建独立用户 —— 不要所有服务共用同一个密码和用户。
- 遵循最小权限原则 —— 只授予所需的最小命令和 key 范围。
- default 用户务必加密码 ——
user default on >password,生产环境中可设为off。 - 禁用危险命令 —— 对不需要的普通用户禁用
KEYS、FLUSHALL、FLUSHDB、CONFIG、DEBUG、SHUTDOWN。 - 定期 ACL LOG 审查 —— 监控被拒绝的命令,发现误配置或攻击行为。
- 使用 ACL 文件管理 —— 将用户定义写入
users.acl,配合ACL SAVE持久化,避免重启丢失。 - 密码使用 ACL GENPASS 生成 —— 避免使用简单密码,开发环境也不要用弱密码。
- key 模式使用合理前缀 —— 如
~app1:*限制特定应用的 key 命名空间。
5.5 🚨 ACL 常见陷阱
-
忘记
ACL SAVE—— 命令行ACL SETUSER创建的修改仅存在于内存,重启丢失。务必ACL SAVE或在配置文件中定义。 -
default 用户无密码 —— 新安装的 Redis 6.0+ 的 default 用户默认
nopass,任何能连接 Redis 的人都能执行所有命令。立即加上密码或将其off。 -
权限过宽或过窄 —— 过宽等于没有 ACL;过窄导致应用报错,难以排查。建议先在 staging 环境验证再上线。
-
ACL 规则顺序问题 ——
ACL SETUSER的规则按先后顺序执行,后面的覆盖前面的:ACL SETUSER test on +@all -set # 先允许所有,再禁止 SET ACL SETUSER test on -set +@all # 先禁止 SET,再允许所有 → SET 可用! -
外部 ACL 文件与 redis.conf 冲突 ——
aclfile和user指令互斥,同时使用会导致启动失败。 -
忘记
~*导致无法操作任何 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 服务端主动通知客户端数据变化,大幅提高缓存一致性。
工作机制:
- 客户端开启 tracking,Redis 记录该客户端读取了哪些 key。
- 当这些 key 被修改时,Redis 向客户端发送失效消息(invalidation message)。
- 客户端收到消息后,清除本地缓存中的对应数据。
- 下次请求时会重新从 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 OPTINOPTIN / 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 常见陷阱
- 默认模式的内存开销 —— 每个追踪的客户端对应一张 key 映射表,大量客户端和 key 时内存压力显著。
- 广播模式的消息风暴 —— BCAST 模式下所有开启 BCAST 的客户端都会收到所有匹配前缀的失效消息,可能产生不必要的网络开销。
- RESP2 vs RESP3 —— RESP2 下失效消息和数据响应在同一个连接,需要使用 REDIRECT。RESP3 原生区分,但要求客户端库支持。
- 客户端复杂度 —— 客户端需要处理失效消息、本地缓存管理、TTL 策略等,比传统缓存模式复杂得多。