Pipeline、事务、WATCH、Lua 与 Functions
系统梳理 Redis Pipeline、MULTI/EXEC、WATCH、Lua 与 Functions 的执行语义、原子性边界、CAS、脚本运维、Cluster Slot 约束和工程选型。
第 9 章:Pipeline、事务、WATCH、Lua 与 Functions
1. 本章定位
前几章解决了“Redis 能存什么、如何操作”,本章解决两个更接近生产环境的问题:
- 一次业务请求需要执行很多条 Redis 命令时,怎样减少网络往返。
- 多条命令存在前后依赖或一致性约束时,怎样避免并发竞争。
本章涉及的机制不能混为一谈:
- Pipeline 解决网络往返成本,不自动保证原子性。
- MULTI/EXEC 保证一组命令连续执行,但不提供关系数据库式回滚。
- WATCH 在事务之上提供乐观锁和 CAS(Compare-And-Set)能力。
- Lua 把判断和更新放到 Redis 服务器内原子执行。
- Functions 是 Redis 7.0 引入的、由服务器管理和持久化的可编程能力。
这里的“原子执行”主要指其他命令不能插入执行,不代表持久化、跨节点强一致、业务操作恰好执行一次,也不等于关系数据库完整的 ACID 事务。
2. 学习目标
完成本章后,应当能够:
- 准确区分 Pipeline、MULTI/EXEC、TxPipeline、WATCH、Lua 和 Functions。
- 解释 Pipeline 为什么提高吞吐量,以及为什么批次不能无限增大。
- 解释 Redis 事务中入队错误和执行期错误的不同处理方式。
- 使用 WATCH 实现带有限重试的乐观锁 CAS。
- 使用 Lua 实现库存扣减、幂等写入等原子操作。
- 说明长 Lua 脚本对 Redis 延迟和可用性的影响。
- 说明 EVAL、EVALSHA、SCRIPT LOAD 和 Functions 的版本及运维差异。
- 正确处理 Redis Cluster 中事务、脚本和多 Key 操作的 Slot 约束。
2.1 本章边界与跳转
本章是 Redis 原子组合能力的主章节。Go 客户端 API 的基础用法见[第 2 章](/blog/tech/Redis/02.使用 Go 操作 Redis/);Pipeline 对吞吐、内存和尾延迟的影响见[第 12 章](/blog/tech/Redis/12.Redis 高性能原理/);Cluster 同槽、MOVED、ASK 和迁移语义见[第 17 章](/blog/tech/Redis/17.Redis Cluster 分片与高可用/);Lua 在分布式锁、限流、幂等和延迟任务中的工程落地见第 19 章。
3. 核心概念
3.1 六种机制解决的是不同问题
| 机制 | 主要目标 | 是否减少 RTT | 是否防止其他命令插入 | 是否支持条件判断 | 关键限制 |
|---|---|---|---|---|---|
| Pipeline | 批量发送命令 | 是 | 否 | 客户端判断 | 不能保证原子性 |
| MULTI/EXEC | 连续执行多条命令 | 可配合 Pipeline | 是 | 事务内难以根据前一条结果分支 | 无回滚 |
| TxPipeline | Pipeline 包装 MULTI/EXEC | 是 | 是 | 通常不做客户端读改写判断 | 仍有执行期错误 |
| WATCH | 乐观锁、CAS | 视调用方式而定 | EXEC 阶段是 | 是 | 高冲突时重试严重 |
| Lua | 服务器内判断并更新 | 是 | 是 | 是 | 长脚本阻塞其他命令 |
| Functions | 持久化、命名化的服务器函数 | 是 | 是 | 是 | Redis 7.0+,需部署管理 |
Pipeline 允许客户端连续发送多条命令而不等待每条命令的响应,从而摊薄网络 RTT 和系统调用开销;Redis 事务则通过 MULTI、EXEC 将已入队命令顺序、连续地执行。二者解决的问题不同。(Redis)
3.2 Pipeline:优化网络,不优化命令复杂度
普通串行调用大致是:
发送命令 1 -> 等待响应 1
发送命令 2 -> 等待响应 2
发送命令 3 -> 等待响应 3
Pipeline 则是:
连续发送命令 1、2、3
一次或少量几次读取响应 1、2、3
设网络 RTT 为 R,每条命令服务端执行时间为 Ci:
- 串行调用耗时近似为:
N × R + ΣCi - Pipeline 耗时近似为:
少量 RTT + ΣCi
因此,Pipeline 在网络 RTT 较高、命令本身较快时收益明显。但它不会降低 ZRANGE、SUNION、大 Key 删除等命令本身的 CPU 和内存成本。
Pipeline 也不是越大越好。服务器需要暂存待返回结果,客户端也需要保存命令及响应;超大批次会造成瞬时内存上涨、长时间占用连接和尾延迟恶化。官方文档建议采用合理的分批方式,而不是一次积累所有命令。(Redis)
还要注意:
- 每条命令仍有自己的结果或错误。
- 其他客户端的命令可能插入两个 Pipeline 命令之间执行。
- 客户端超时时,部分或全部命令可能已经执行。
- 对非幂等命令盲目重试,可能造成重复
INCR、重复消费或重复扣减。 - go-redis 的 Pipeline 在超时后可能发生重发,因此写命令必须考虑重复执行风险。(GitHub)
3.3 MULTI、EXEC 与 DISCARD
Redis 事务的基本过程是:
MULTI
命令 1
命令 2
命令 3
EXEC
执行 MULTI 后,后续命令通常只返回 QUEUED,直到 EXEC 才真正执行。Redis 保证已入队命令按顺序执行,并且执行期间不会插入其他客户端的命令。
DISCARD 的作用是:
- 清空本次事务已入队的命令。
- 退出事务状态。
- 不执行这些命令。
两类事务错误
| 错误类型 | 发生时机 | 示例 | Redis 行为 |
|---|---|---|---|
| 入队错误 | EXEC 前 | 命令不存在、参数数量错误 | 标记事务失败,EXEC 拒绝整个事务 |
| 执行期错误 | EXEC 执行命令时 | 对非整数执行 INCR、类型不匹配 | 当前命令报错,其他命令继续执行 |
例如:
SET counter abc
MULTI
INCR counter
SET marker 1
EXEC
结果可能是:
1) (error) ERR value is not an integer or out of range
2) OK
INCR 失败,但 SET marker 1 仍然执行。Redis 不会因为其中一条命令运行失败而回滚其他命令。Redis 2.6.5 以后,如果事务存在入队阶段错误,EXEC 会拒绝执行整个事务。(Redis)
3.4 Redis 事务为什么不等同于关系数据库事务
Redis 事务和关系数据库事务都有“原子执行”的表述,但含义和能力边界不同。
Redis 事务缺少的典型关系数据库能力
-
没有通用回滚机制
已执行成功的 Redis 命令不会因为后续命令失败而撤销。
-
没有 MVCC 和事务快照
Redis 不提供类似可重复读、读已提交、串行化等多种隔离级别。
-
事务内难以进行客户端分支
命令在 EXEC 前只被排队,客户端无法获取第一条命令的执行结果,再决定是否入队第二条命令。
-
原子性不等于持久性
MULTI/EXEC 是否落盘、可能丢失多少数据,仍取决于 RDB、AOF、复制和故障窗口。
-
不提供跨系统事务
Redis 事务不能与 MySQL、Kafka 或外部支付系统自动形成分布式事务。
因此,更准确的描述是:
Redis 事务提供一组命令的排队和无插入执行语义,而不是关系数据库式、可回滚的完整 ACID 事务。
3.5 WATCH:基于乐观锁的 CAS
WATCH 用于解决以下经典竞争:
客户端 A:读取余额 100
客户端 B:读取余额 100
客户端 A:写入余额 80
客户端 B:写入余额 70
最终余额变成 70,客户端 A 的更新丢失。
WATCH 的流程是:
WATCH key- 读取 key 当前值。
- 在客户端计算新值。
MULTI- 入队更新命令。
EXEC
如果从 WATCH 到 EXEC 之间,被监视的 Key 发生变化,EXEC 会放弃事务。客户端需要重新读取、计算并重试。这就是乐观锁:不提前加互斥锁,而是在提交时检测冲突。
可能触发冲突的变化不仅包括其他客户端写入,还包括过期和内存淘汰。EXEC、DISCARD 或连接结束后,WATCH 状态会被清除。(Redis)
WATCH 适合:
- 冲突概率较低。
- 读改写逻辑在客户端容易表达。
- 可以接受有限次数重试。
- 操作涉及的 Key 数量较少。
WATCH 不适合:
- 秒杀库存等高冲突热点。
- 客户端计算时间较长。
- 不能接受重试放大。
- 跨多个 Cluster Slot 操作。
Redis 8.4 为 String 增加了新的条件比较更新选项,并增加了条件删除能力。对于简单的单 Key String CAS,新命令可能比 WATCH 更直接;但它们不能替代多 Key、复杂判断场景中的 WATCH 或 Lua。(Redis)
3.6 Lua:把判断和更新放到 Redis 内部
Lua 适合将这样的逻辑合并为一次调用:
读取库存
判断库存是否足够
判断订单是否已处理
扣减库存
写入幂等记录
Redis 使用内嵌的 Lua 5.1 解释器执行脚本。脚本执行期间,其他命令不能插入到脚本内部,因此可以完成跨多个 Redis 命令的原子判断与更新。(Redis)
但需要准确理解这种原子性:
- 它保证脚本执行过程不被其他命令穿插。
- 它不等于关系数据库式回滚。
- 应当先完成参数、类型和业务条件校验,再集中执行写命令。
- 脚本应保持短小,避免循环遍历大量元素。
- 脚本超时不代表可以任意安全中止。
如果脚本已经执行了写操作,SCRIPT KILL 通常不能将其终止,因为强行停止会破坏原子性;这种情况下极端处理可能只能关闭实例并放弃未持久化数据。(Redis)
KEYS 与 ARGV
脚本访问的所有 Key 都应通过 KEYS 显式传入:
local stockKey = KEYS[1]
local orderKey = KEYS[2]
local quantity = tonumber(ARGV[1])
不要根据数据库内容动态拼接隐藏 Key。这样做既影响 Cluster 路由,也使 Redis 无法准确分析脚本涉及的 Key。(Redis)
3.7 EVAL、EVALSHA 与 SCRIPT LOAD
EVAL
直接发送并执行脚本源码:
EVAL "return redis.call('GET', KEYS[1])" 1 user:1
优点是简单,不依赖脚本缓存;缺点是每次都携带脚本源码。
SCRIPT LOAD
将脚本加载到脚本缓存,但不执行:
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
返回脚本源码的 SHA1 摘要。
EVALSHA
通过 SHA1 执行已缓存脚本:
EVALSHA <sha1> 1 user:1
脚本缓存不是业务数据的一部分,重启、故障切换或执行 SCRIPT FLUSH 后可能丢失。客户端必须处理 NOSCRIPT,重新发送源码或执行 SCRIPT LOAD。go-redis 的 redis.NewScript(...).Run(...) 会优先尝试 EVALSHA,遇到 NOSCRIPT 时回退到 EVAL。(Redis)
将 EVALSHA 放入 Pipeline 时需要特别谨慎:等客户端收到整个 Pipeline 的响应时,已经无法在原批次中间插入 SCRIPT LOAD。可选择:
- 提前确认并加载脚本。
- 在 Pipeline 中直接使用参数化 EVAL。
- 使用能够正确管理脚本缓存的客户端封装。
Redis 7.4 开始,对通过 EVAL/EVAL_RO 不断产生的脚本缓存增加了数量限制和 LRU 淘汰机制,进一步说明动态拼接脚本源码是反模式。(Redis)
3.8 Functions:服务器管理的持久化函数
Redis Functions 自 Redis 7.0 引入。它与 Lua 脚本的核心执行语义相似:都在服务器中运行,都能完成原子、数据本地化的逻辑,也都必须保持短小。
主要区别如下:
| 对比项 | EVAL 脚本 | Functions |
|---|---|---|
| 引入版本 | Redis 2.6 | Redis 7.0 |
| 管理者 | 客户端应用 | Redis 服务器 |
| 调用方式 | EVAL/EVALSHA | FCALL |
| 命名 | 通常使用 SHA1 | 注册函数名 |
| 持久化 | 脚本缓存易失 | 随数据库持久化和复制 |
| 组织形式 | 独立脚本 | 函数库 Library |
| 发布方式 | 应用运行时携带或加载 | FUNCTION LOAD 部署 |
| 适合场景 | 少量、轻量脚本 | 多应用共享、需版本化管理的逻辑 |
Functions 通过 FUNCTION LOAD 加载函数库,通过 FCALL 调用。函数库作为数据库的一等软件制品参与持久化和复制,解决了 EVAL 脚本缓存易失、SHA1 难以运维以及多个应用实例重复管理脚本的问题。(Redis)
但 Functions 不是将大量业务代码搬入 Redis 的理由。它仍然会占用 Redis 命令执行时间,复杂循环、外部依赖和长耗时业务逻辑都不适合放入 Functions。
3.9 Cluster 中的 Slot 约束
Redis Cluster 将 Key 分配到 16384 个 Slot。事务、Lua、Functions 和其他多 Key 原子操作通常要求所有 Key 属于同一个 Slot。
可以使用 Hash Tag:
stock:{sku-42}
reservation:{sku-42}:order-1001
audit:{sku-42}
Redis 只对 {sku-42} 部分计算 Slot,因此这些 Key 会进入同一 Slot。
需要特别注意:
- “在同一个物理节点”不等于“在同一个 Slot”。
- Cluster 扩缩容时 Slot 可能迁移,因此必须依赖 Hash Tag,而不是当前节点分布。
- Lua 和 Functions 访问的 Key 必须显式传入。
- 跨 Slot 的 MULTI/EXEC、TxPipeline 或脚本通常会返回
CROSSSLOT。 - 普通 Pipeline 可以包含不同 Slot 的独立命令,Cluster 客户端可能按节点拆分发送,但这不构成跨节点事务。
Redis Cluster 采用异步复制,并明确存在已确认写入在特定故障窗口中丢失的可能。因此,同 Slot 原子执行也不能推导出跨故障的强一致或零数据丢失。(Redis)
4. 命令与 Go 使用方法
4.1 redis-cli 操作
Pipeline 没有独立命令
Pipeline 是客户端协议使用方式,而不是一条名为 PIPELINE 的 Redis 命令。
redis-cli --pipe 可以批量导入 RESP 请求:
redis-cli --pipe < batch.resp
性能测试时也可通过 -P 设置 Pipeline 深度:
redis-benchmark -t set,get -n 100000 -P 16 -q
这些操作都不提供事务原子性。
MULTI 与 EXEC
MULTI
SET account:{42}:state active
INCR account:{42}:version
EXEC
DISCARD
MULTI
SET account:{42}:state disabled
INCR account:{42}:version
DISCARD
上述两条写命令不会执行。
入队错误
MULTI
SET only-one-argument
INCR counter
EXEC
参数数量错误会导致事务在 EXEC 时整体拒绝执行。
执行期错误
SET counter abc
MULTI
INCR counter
SET marker done
EXEC
INCR 失败,但 SET marker done 仍可成功。
WATCH
客户端 A:
WATCH balance:{42}
GET balance:{42}
MULTI
SET balance:{42} 80
INCR account:{42}:debit-count
EXEC
如果客户端 B 在 A 执行 EXEC 前修改:
SET balance:{42} 90
客户端 A 的 EXEC 将放弃事务,需要重新读取并重试。
EVAL
EVAL "
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local delta = tonumber(ARGV[1])
redis.call('SET', KEYS[1], current + delta)
return current + delta
" 1 counter:{42} 5
SCRIPT LOAD 与 EVALSHA
SCRIPT LOAD "return redis.call('INCRBY', KEYS[1], ARGV[1])"
EVALSHA <返回的SHA1> 1 counter:{42} 5
Functions
函数库文件 counter.lua:
#!lua name=counterlib
redis.register_function('incr_with_audit', function(keys, args)
local delta = tonumber(args[1])
if not delta then
return redis.error_reply('invalid delta')
end
local value = redis.call('INCRBY', keys[1], delta)
redis.call('HINCRBY', keys[2], args[2], 1)
return value
end)
加载与调用:
redis-cli -x FUNCTION LOAD REPLACE < counter.lua
FCALL incr_with_audit 2 counter:{tenant-1} audit:{tenant-1} 5 api
4.2 Go 客户端初始化
package rediscase
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func NewRedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
DialTimeout: 2 * time.Second,
ReadTimeout: 1500 * time.Millisecond,
WriteTimeout: 1500 * time.Millisecond,
PoolTimeout: 2 * time.Second,
})
}
redis.Client 内部管理连接池,可以被多个 goroutine 并发复用;单个 Pipeline、Tx 或事务回调应当限制在当前调用内,不要由多个 goroutine 并发写入。(GitHub)
4.3 Go 实现 Pipeline 批量查询
func BatchGetNames(
ctx context.Context,
rdb *redis.Client,
ids []int64,
) (map[int64]string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if len(ids) == 0 {
return map[int64]string{}, nil
}
cmds := make([]*redis.StringCmd, len(ids))
_, execErr := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
for i, id := range ids {
key := fmt.Sprintf("user:%d:name", id)
cmds[i] = pipe.Get(ctx, key)
}
return nil
})
// Pipeline 返回的是首个错误。GET 未命中可能使其等于 redis.Nil,
// 因此还需逐条检查结果。
if execErr != nil && !errors.Is(execErr, redis.Nil) {
return nil, fmt.Errorf("execute pipeline: %w", execErr)
}
result := make(map[int64]string, len(ids))
for i, cmd := range cmds {
name, err := cmd.Result()
switch {
case err == nil:
result[ids[i]] = name
case errors.Is(err, redis.Nil):
// 缓存未命中,不作为基础设施异常。
continue
default:
return nil, fmt.Errorf(
"get user %d name: %w",
ids[i],
err,
)
}
}
return result, nil
}
关键点:
Pipelined在回调结束后自动执行。- Pipeline 整体错误不能替代逐条检查。
redis.Nil表示 Key 不存在,不等于网络或服务端故障。- 应限制
ids长度,例如每批数百条,再根据 Value 大小、RTT、内存和延迟压测调整。 - 本例只执行 GET,超时重试的副作用较小;写 Pipeline 必须重点考虑幂等性。
go-redis 还提供 Pipeline() 与手动 Exec();二者的核心区别只是控制方式,不改变 Pipeline 的原子性。(Redis)
4.4 Go 实现 TxPipeline
当多条命令必须连续执行,但不需要先读后判断时,可以使用 TxPipeline:
func RecordLogin(
ctx context.Context,
rdb *redis.Client,
userID string,
at time.Time,
) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
countKey := fmt.Sprintf("login:{%s}:count", userID)
lastKey := fmt.Sprintf("login:{%s}:last", userID)
_, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Incr(ctx, countKey)
pipe.Set(ctx, lastKey, at.UnixMilli(), 0)
return nil
})
if err != nil {
return fmt.Errorf("record login transaction: %w", err)
}
return nil
}
TxPipelined 会使用 MULTI/EXEC 包装命令,同时采用 Pipeline 方式发送。它能够减少往返并提供事务的连续执行语义,但执行期错误仍然不会触发回滚。(Redis)
4.5 Go 使用 WATCH 实现余额 CAS
var (
ErrAccountNotFound = errors.New("account not found")
ErrInsufficientFund = errors.New("insufficient fund")
)
func DebitWithWatch(
ctx context.Context,
rdb *redis.Client,
accountID string,
amount int64,
) error {
if amount <= 0 {
return fmt.Errorf("amount must be positive")
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
balanceKey := fmt.Sprintf("account:{%s}:balance", accountID)
countKey := fmt.Sprintf("account:{%s}:debit-count", accountID)
const maxRetries = 5
for attempt := 0; attempt < maxRetries; attempt++ {
err := rdb.Watch(ctx, func(tx *redis.Tx) error {
balance, err := tx.Get(ctx, balanceKey).Int64()
switch {
case errors.Is(err, redis.Nil):
return ErrAccountNotFound
case err != nil:
return fmt.Errorf("read balance: %w", err)
}
if balance < amount {
return ErrInsufficientFund
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, balanceKey, balance-amount, 0)
pipe.Incr(ctx, countKey)
return nil
})
if err != nil {
return fmt.Errorf("commit debit: %w", err)
}
return nil
}, balanceKey)
switch {
case err == nil:
return nil
case !errors.Is(err, redis.TxFailedErr):
// 业务错误、网络错误、解析错误不应当都按冲突重试。
return err
}
// WATCH 冲突,执行有上限的退避重试。
delay := time.Duration(attempt+1) * 10 * time.Millisecond
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf(
"watch conflict after %d retries: %w",
maxRetries,
redis.TxFailedErr,
)
}
关键点:
Watch回调失败并不代表都要重试,只有redis.TxFailedErr表示监视冲突。- 重试必须有次数上限,并结合退避和抖动。
- 两个事务 Key 使用相同 Hash Tag,保证在 Cluster 中同 Slot。
- 本例普通
SET会重置已有 TTL,因此业务上有 TTL 时必须显式保留或重新设置。 - WATCH 只适合低冲突场景。热点余额或库存可能形成重试风暴。
go-redis 的 Watch 使用事务连接执行回调,WATCH 冲突时 TxPipelined 会返回 redis.TxFailedErr。(Redis)
4.6 Go 使用 Lua 实现库存原子扣减
var (
ErrStockNotFound = errors.New("stock not found")
ErrInsufficientStock = errors.New("insufficient stock")
)
var reserveStockScript = redis.NewScript(`
local quantity = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
if not quantity or quantity <= 0 or not ttl or ttl <= 0 then
return -2
end
-- 幂等:同一个订单已经预占过,不再次扣减。
if redis.call("EXISTS", KEYS[2]) == 1 then
return 2
end
local rawStock = redis.call("GET", KEYS[1])
if not rawStock then
return -1
end
local stock = tonumber(rawStock)
if not stock then
return -3
end
if stock < quantity then
return 0
end
-- 校验完成后再集中执行写操作。
redis.call("DECRBY", KEYS[1], quantity)
redis.call("SET", KEYS[2], quantity, "EX", ttl)
return 1
`)
func ReserveStock(
ctx context.Context,
rdb *redis.Client,
sku string,
orderID string,
quantity int64,
ttl time.Duration,
) error {
if quantity <= 0 {
return fmt.Errorf("quantity must be positive")
}
if ttl < time.Second {
return fmt.Errorf("ttl must be at least one second")
}
ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
stockKey := fmt.Sprintf("stock:{%s}", sku)
reservationKey := fmt.Sprintf(
"reservation:{%s}:%s",
sku,
orderID,
)
code, err := reserveStockScript.Run(
ctx,
rdb,
[]string{stockKey, reservationKey},
quantity,
int64(ttl/time.Second),
).Int64()
if err != nil {
return fmt.Errorf("run reserve stock script: %w", err)
}
switch code {
case 1:
return nil
case 2:
// 重复请求,之前已经成功预占,按幂等成功处理。
return nil
case 0:
return ErrInsufficientStock
case -1:
return ErrStockNotFound
case -2:
return fmt.Errorf("invalid script argument")
case -3:
return fmt.Errorf("stock value is not an integer")
default:
return fmt.Errorf("unexpected script result: %d", code)
}
}
该实现具有以下性质:
- 库存判断、扣减和幂等记录在一个脚本中执行。
- 同一订单重复请求不会重复扣减。
- 所有 Key 通过
KEYS传递。 - 两个 Key 使用相同的
{sku}Hash Tag。 redis.NewScript会管理 EVALSHA 与 EVAL 回退。- 脚本对象可作为只读的包级变量复用,不要在运行时动态拼接脚本源码。(GitHub)
Lua 能解决 Redis 内部原子性,却不能使“Redis 扣库存”和“数据库创建订单”自动成为一个跨系统事务。仍需结合状态机、补偿、Outbox 或消息幂等设计。
4.7 Go 部署和调用 Functions
const counterLibrary = `#!lua name=counterlib
redis.register_function('incr_with_audit', function(keys, args)
local delta = tonumber(args[1])
if not delta then
return redis.error_reply('invalid delta')
end
local value = redis.call('INCRBY', keys[1], delta)
redis.call('HINCRBY', keys[2], args[2], 1)
return value
end)
`
func DeployCounterLibrary(
ctx context.Context,
rdb *redis.Client,
) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := rdb.FunctionLoadReplace(ctx, counterLibrary).Err(); err != nil {
return fmt.Errorf("load counter function library: %w", err)
}
return nil
}
func IncrementWithAudit(
ctx context.Context,
rdb *redis.Client,
tenantID string,
delta int64,
source string,
) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
counterKey := fmt.Sprintf("counter:{%s}", tenantID)
auditKey := fmt.Sprintf("counter-audit:{%s}", tenantID)
value, err := rdb.FCall(
ctx,
"incr_with_audit",
[]string{counterKey, auditKey},
delta,
source,
).Int64()
if err != nil {
return 0, fmt.Errorf("call incr_with_audit: %w", err)
}
return value, nil
}
函数库部署应当属于发布流程,而不是在每个业务请求中执行。在 Redis Cluster 中,函数库需要部署到所有相关主节点;Redis Cluster 不会自动把一次 FUNCTION LOAD 广播到所有主节点。(Redis)
5. 典型业务场景
| 场景 | 推荐机制 | 不适用情况 | 数据量要求 | 一致性要求 | 性能风险 | 可替代方案 |
|---|---|---|---|---|---|---|
| 批量读取用户缓存 | Pipeline | 需要整体原子结果 | 每批数十到数百,按 Value 大小调整 | 各 Key 独立 | 批次过大、连接占用、响应内存 | MGET、数据模型聚合 |
| 同时更新登录次数和最后时间 | TxPipeline | 更新前需要读取判断 | 少量同 Slot Key | 命令不可插入 | 执行期错误不回滚 | Lua、单 Hash |
| 低冲突余额 CAS | WATCH | 高热点、高冲突 | 少量 Key | 冲突时放弃提交 | 重试放大 | Lua、数据库事务 |
| 库存检查并扣减 | Lua | 跨系统强事务、逻辑过长 | 少量 Key、短逻辑 | Redis 内原子 | 热点、长脚本阻塞 | 原生命令、队列串行化、数据库 |
| 多应用共享原子逻辑 | Functions | Redis 6 或更早版本 | 小型函数库 | Redis 内原子 | 部署版本不一致 | EVAL 脚本 |
| 批量导入独立缓存 | Pipeline | 每条命令相互依赖 | 分批写入 | 最终一致即可 | 超时重试导致重复写 | MSET、离线导入 |
| MySQL 转账并同步 Redis | 不应只用 Redis 事务 | 需要跨系统提交 | 取决于数据库 | 强业务一致性 | 双写不一致 | 数据库事务、Outbox、CDC |
6. 底层实现
6.1 核心结构与算法
| 机制 | 核心结构 | 执行方式 | 时间复杂度 | 空间成本 |
|---|---|---|---|---|
| Pipeline | 客户端命令缓冲、连接写缓冲、响应队列 | 连续发送,批量读取 | O(ΣCi),另加编码和网络成本 | O(请求字节数 + 响应字节数) |
| MULTI/EXEC | 客户端事务状态、已入队命令序列 | EXEC 时逐条顺序执行 | O(ΣCi) | O(命令数量 + 参数总大小) |
| WATCH | 被监视 Key 与客户端事务状态关联 | Key 改变后标记事务失效 | 注册近似随 Key 数量增长;整体受重试次数影响 | 与监视关系数相关 |
| Lua | Lua 解释器、脚本源码及 SHA1 缓存 | 服务器内解释执行 | 取决于脚本及内部命令 | 脚本缓存、Lua 临时对象和结果 |
| Functions | 函数库注册表、Lua 执行环境 | 按函数名查找并调用 | 取决于函数逻辑 | 函数库代码及运行时对象 |
这里不能只看渐近复杂度。例如一个 O(N) 脚本若扫描几十万个元素,即使只发生一次网络往返,也可能比多条 O(1) Pipeline 命令更危险。
6.2 为什么 Redis 事务不实现回滚
关系数据库回滚通常需要:
- Undo Log。
- 旧版本数据。
- 锁或 MVCC。
- 事务状态和崩溃恢复协议。
Redis 的设计重点是低延迟内存操作。它要求命令本身尽量简单、确定,并通过入队检查和应用层校验减少错误,而不是为每条 Redis 命令维护通用撤销信息。代价是事务一旦出现执行期错误,已经成功的命令不会撤销。
6.3 Pipeline 为什么能提高吞吐量
Pipeline 的收益不主要来自“Redis 一次执行多条命令”,而来自:
- 减少客户端等待 RTT。
- 减少客户端和服务器的读写系统调用次数。
- 让网络缓冲和协议解析更连续。
- 提高单连接在单位时间内传输的命令数量。
但批量请求进入服务器后,命令仍要执行。Pipeline 可能把均匀流量变成突发流量,使 CPU、复制缓冲、AOF 和网络响应出现短时尖峰。(Redis)
6.4 版本差异
| 版本 | 相关变化 |
|---|---|
| Redis 2.2 | WATCH 乐观锁机制已提供 |
| Redis 2.6 | EVAL、EVALSHA、SCRIPT LOAD 和 Lua 5.1 脚本能力 |
| Redis 7.0 | 引入 Functions;脚本效果复制成为统一方式 |
| Redis 7.4 | 对不断通过 EVAL/EVAL_RO 产生的脚本缓存增加 LRU 淘汰控制 |
| Redis 8.4 | String 增加更直接的条件比较更新、条件删除能力 |
版本升级不会改变核心原则:Pipeline 不等于事务,长脚本仍会阻塞命令执行,Cluster 多 Key 操作仍需考虑 Slot。(Redis)
7. 高性能、高并发、高可用分析
7.1 高性能
网络
Pipeline、Lua 和 Functions 都可以减少网络往返:
- Pipeline 批量传输多个独立命令。
- Lua 和 Functions 将多步读改写压缩为一次调用。
- TxPipeline 同时利用批量传输和 MULTI/EXEC。
CPU
它们不会消除服务器 CPU 成本:
- 1000 条 Pipeline 命令仍然需要执行 1000 次。
- Lua 中的循环和大集合操作可能长时间占用命令执行线程。
- Functions 的运行成本与函数逻辑相关。
内存
- 大 Pipeline 会占用客户端命令缓存和服务器响应内存。
- 事务在 EXEC 前需要保存已入队命令。
- 动态生成大量 Lua 源码会污染脚本缓存。
- Lua 构造大表或大结果会消耗额外内存。
磁盘与复制
Pipeline 不会绕过 AOF 和复制。批量写可能造成:
- AOF 写入突发。
- 主从复制流量突发。
- 副本应用延迟升高。
- 大量响应同时返回。
因此,批量大小必须通过业务流量和尾延迟压测确定。
7.2 高并发
WATCH 冲突
假设单次 CAS 冲突概率为 p,平均尝试次数会随 p 增大。热点库存可能出现:
并发增加
-> WATCH 冲突增加
-> 请求重试
-> Redis 流量进一步增加
-> 冲突继续增加
因此必须设置:
- 最大重试次数。
- 指数或线性退避。
- 随机抖动。
- 总超时。
- 失败后的降级策略。
Lua 热点
Lua 避免了 WATCH 重试,但不能消除热点 Key:
- 所有库存扣减仍集中到同一主节点。
- 一个脚本执行时其他命令不能插入。
- 流量突增会形成排队延迟。
重复执行
客户端超时只表示客户端没有及时收到结果,不表示 Redis 没有执行。以下操作不应无条件重试:
INCRLPUSHXADD- 非幂等 Lua
- 已提交但响应丢失的 EXEC
应使用业务请求 ID、幂等 Key、结果缓存或状态机识别重复请求。
7.3 高可用
故障切换与脚本缓存
EVAL 脚本缓存可能在重启或故障切换后丢失,客户端必须处理 NOSCRIPT。Functions 随数据持久化和复制,运维语义更稳定,但 Cluster 的所有主节点仍需完成函数库部署。(Redis)
结果不确定
发生以下情况时,客户端可能无法判断命令是否已经执行:
客户端发送 EXEC/EVAL
Redis 完成执行
响应返回途中连接中断
客户端收到超时
此时直接重试可能重复执行。因此,高可用客户端不仅要“会重连”,还必须处理提交结果不确定性。
原子性不等于故障后不丢数据
MULTI/EXEC、Lua 和 Functions 的原子性发生在当前主节点内部。Redis Cluster 使用异步复制,主节点确认写入后、复制到副本前发生故障,仍可能丢失该写入。需要结合业务 RPO、持久化、复制和幂等设计判断风险。(Redis)
8. 常见错误与生产事故
8.1 把 Pipeline 当成事务
- 现象: 订单状态已修改,但库存或计数没有同步更新。
- 根因: 多条命令通过 Pipeline 发送,其他客户端命令在中间插入,或部分命令执行失败。
- 排查方法: 检查代码是否使用
Pipeline/Pipelined而不是TxPipeline/Lua;逐条检查命令结果。 - 修复方案: 根据业务改为 TxPipeline、WATCH 或 Lua。
- 如何预防: 代码评审中明确标注“批量优化”和“原子操作”两类需求。
8.2 Pipeline 批次过大导致延迟抖动
- 现象: 平均吞吐量上升,但 P99 延迟、连接池等待和 Redis 内存同时恶化。
- 根因: 单批包含数万条命令或大量大 Value,服务器积累大量响应。
- 排查方法: 查看批次长度、请求与响应字节数、连接池等待、内存和网络带宽。
- 修复方案: 按命令数和字节数双重分批。
- 如何预防: 在压测中同时观察吞吐量、P95/P99、内存和失败率,而不是只看 QPS。
8.3 误以为事务错误会回滚
- 现象: EXEC 返回一个错误,但事务中的其他写命令已经生效。
- 根因: 错误发生在执行期,Redis 继续执行后续命令。
- 排查方法: 检查 EXEC 返回数组中的每一项,而不是只看是否收到响应。
- 修复方案: 提前校验类型和参数;复杂逻辑改用经过充分校验的 Lua。
- 如何预防: 面向错误路径编写集成测试,包括 WRONGTYPE、非法整数和 OOM 场景。
8.4 WATCH 热点形成重试风暴
- 现象: 秒杀开始后 TxFailedErr 激增,Redis QPS 暴涨但成功率下降。
- 根因: 大量客户端同时读取同一个版本,只有少数提交成功,其余全部重试。
- 排查方法: 统计 WATCH 冲突率、每请求重试次数和热点 Key。
- 修复方案: 改为 Lua 原子扣减、请求排队或分片库存。
- 如何预防: 只在低冲突场景使用 WATCH,并设置重试上限和退避。
8.5 Lua 脚本阻塞实例
- 现象: 整个 Redis 实例延迟突然升高,客户端出现
BUSY、超时和连接池堆积。 - 根因: 脚本循环扫描大集合、处理大批 Key 或存在近似死循环。
- 排查方法: 检查 SLOWLOG、LATENCY、正在执行的脚本及近期发布。
- 修复方案: 将大任务拆分,限制循环次数;只读且尚未写入的脚本可评估
SCRIPT KILL。 - 如何预防: 为脚本设置规模上限、压测最坏数据量,并进行脚本代码审查。
8.6 故障切换后出现 NOSCRIPT
- 现象: 主从切换后 EVALSHA 大量失败。
- 根因: 新主节点没有相应脚本缓存。
- 排查方法: 确认错误是否为
NOSCRIPT,检查最近的重启、切换或 SCRIPT FLUSH。 - 修复方案: 使用
redis.NewScript.Run或实现 EVALSHA 失败后 EVAL 回退。 - 如何预防: 不假设脚本缓存永久存在;Pipeline 内提前加载脚本或使用 EVAL。
8.7 Cluster 返回 CROSSSLOT
- 现象: 单机正常,迁移到 Cluster 后事务、Lua 或 Functions 调用失败。
- 根因: 多个 Key 不属于同一个 Slot。
- 排查方法: 使用
CLUSTER KEYSLOT检查所有 Key 的 Slot。 - 修复方案: 统一设计 Hash Tag,或拆分操作并接受非原子语义。
- 如何预防: 在 Key 设计阶段确定原子操作边界,而不是上线后临时加
{}。
8.8 Functions 只部署到一个 Cluster 主节点
- 现象: 部分 Key 调用 FCALL 成功,部分返回函数不存在。
- 根因:
FUNCTION LOAD只发送给一个主节点,Cluster 没有自动广播到其他主节点。 - 排查方法: 逐个主节点执行
FUNCTION LIST。 - 修复方案: 在部署流程中向所有主节点加载同一版本的函数库。
- 如何预防: 将函数库版本、校验值和节点覆盖率纳入发布检查。
9. 方案选型与权衡
| 方案 | 网络优化 | 无插入执行 | 客户端条件判断 | 高冲突表现 | Cluster 要求 | 运维成本 |
|---|---|---|---|---|---|---|
| 单条原生命令 | 一般 | 单命令原子 | 很弱 | 通常最好 | 单 Key 最简单 | 低 |
| Pipeline | 强 | 否 | 可,但存在竞争 | 无冲突检测 | 可按节点拆分 | 低 |
| MULTI/EXEC | 可优化 | 是 | 不适合事务内分支 | 不重试 | 多 Key 同 Slot | 低 |
| TxPipeline | 强 | 是 | 不适合先读后写 | 不重试 | 多 Key 同 Slot | 低 |
| WATCH + 事务 | 一般 | 提交阶段是 | 强 | 冲突时重试 | 多 Key 同 Slot | 中 |
| Lua | 强 | 是 | 强 | 无 WATCH 重试,但会排队 | Key 显式且同 Slot | 中 |
| Functions | 强 | 是 | 强 | 与 Lua 类似 | Key 同 Slot;各主节点部署 | 中高 |
| 关系数据库事务 | 不属于 Redis 网络优化 | 由数据库保证 | 强 | 通过锁/MVCC处理 | 不受 Redis Slot 限制 | 中高 |
选型顺序可以概括为:
- 能用单条原生命令解决,就不要写脚本。
- 只是减少网络往返,使用 Pipeline。
- 多条固定命令需要连续执行,使用 TxPipeline。
- 低冲突读改写,考虑 WATCH。
- 高冲突、短小的 Redis 内部条件更新,考虑 Lua。
- Redis 7.0+、多应用共享和集中部署的脚本逻辑,考虑 Functions。
- 涉及跨系统强一致,回到数据库事务、状态机、消息或补偿方案。
10. 高频面试题
10.1 Pipeline 和事务有什么区别?
推荐回答:
Pipeline 优化网络往返,事务保证一组命令连续执行,两者不是同一层能力。
Pipeline 将多条命令批量发送并批量读取结果,其他客户端的命令仍可能插入。MULTI/EXEC 会先排队,再由 EXEC 顺序执行,执行期间不会插入其他命令。TxPipeline 是客户端将 Pipeline 与 MULTI/EXEC 组合起来,既减少 RTT,又获得事务执行语义。(Redis)
面试官追问:
- Pipeline 中一条命令失败会怎样?
- TxPipeline 是否支持回滚?
- Pipeline 批次为什么不能无限大?
常见错误回答:
“Pipeline 会把所有命令作为一个原子操作执行。”
评分点:
- 初级: 能说出 Pipeline 减少 RTT。
- 中级: 能区分原子性、错误处理和 TxPipeline。
- 高级: 能讨论批次内存、超时结果不确定和重试幂等。
10.2 Redis 事务为什么不等同于关系数据库事务?
推荐回答:
Redis 事务主要保证命令顺序执行且不被插入,但没有通用回滚、MVCC、隔离级别和跨系统提交能力。
执行期错误不会撤销已经成功的命令;事务内命令在 EXEC 前只是入队,因此也不适合根据上一条结果进行客户端分支。持久性仍由 RDB、AOF 和复制策略决定。
面试官追问:
- Redis 事务能否称为原子操作?
- Redis 为什么不实现 Undo Log?
- 转账业务能否只依赖 MULTI/EXEC?
常见错误回答:
“Redis 事务满足完整 ACID,和 MySQL 事务相同。”
评分点:
- 初级: 知道 Redis 事务不回滚。
- 中级: 能解释无隔离级别和事务内分支限制。
- 高级: 能区分原子性、持久性、业务一致性和故障一致性。
10.3 Redis 事务有哪些错误类型?
推荐回答:
分为入队错误和执行期错误。
入队错误如命令不存在、参数数量不正确,会使 EXEC 拒绝整个事务;执行期错误如 WRONGTYPE 或非法整数,只影响当前命令,后续命令仍继续执行。(Redis)
面试官追问:
- 客户端应如何检查 EXEC 返回值?
- 为什么执行期错误不回滚?
- DISCARD 能否撤销已经 EXEC 的事务?
常见错误回答:
“任意一条命令失败,整个事务都会自动回滚。”
评分点:
- 初级: 知道无回滚。
- 中级: 能准确区分两类错误。
- 高级: 能给出预校验和故障测试方案。
10.4 WATCH 的工作原理是什么?
推荐回答:
WATCH 是乐观锁。客户端先监视 Key 并读取当前值,再通过 MULTI/EXEC 提交更新;如果监视期间 Key 被修改、过期或淘汰,EXEC 放弃提交,客户端重新读取并重试。
它本质上是 CAS,不会阻塞其他客户端。冲突率低时成本较小,热点 Key 下会因大量重试降低吞吐。(Redis)
面试官追问:
- WATCH 冲突后谁负责重试?
- WATCH 是否会锁住 Key?
- 为什么热点库存不适合 WATCH?
常见错误回答:
“WATCH 会给 Key 加一把分布式锁。”
评分点:
- 初级: 知道修改后 EXEC 会失败。
- 中级: 能说明 CAS 和有限重试。
- 高级: 能分析冲突率、退避和热点放大。
10.5 库存扣减应该用 WATCH 还是 Lua?
推荐回答:
低冲突、逻辑简单且需要在 Go 中计算时可以使用 WATCH;高冲突库存通常更适合短 Lua,因为判断和扣减一次完成,不产生 WATCH 冲突重试。
但 Lua 仍会串行处理热点库存,不能消除单 Key 热点;流量更大时还需要排队、库存分片、限流或数据库侧最终确认。
面试官追问:
- Lua 如何防止重复扣减?
- 库存 Key 与幂等 Key 在 Cluster 中怎么设计?
- Redis 扣减成功而数据库下单失败怎么办?
常见错误回答:
“Lua 能保证整个下单系统的分布式事务。”
评分点:
- 初级: 知道 Lua 可以原子扣库存。
- 中级: 能加入幂等 Key 和 Hash Tag。
- 高级: 能讨论跨系统补偿、状态机及热点治理。
10.6 Lua 为什么是原子的,又为什么会阻塞 Redis?
推荐回答:
Redis 执行 Lua 时,不允许其他命令插入脚本内部,因此脚本对其他客户端表现为原子操作。也正因为如此,脚本如果执行很久,其他命令只能等待,从而导致实例级延迟升高。
Lua 应限制循环和数据规模,并优先调用低复杂度 Redis 命令。已经写入数据的长脚本通常不能通过 SCRIPT KILL 强行终止。(Redis)
面试官追问:
- 原子执行是否等于可回滚?
- 如何排查长脚本?
- Lua 是否适合扫描大集合?
常见错误回答:
“Lua 在独立线程运行,因此不会影响其他命令。”
评分点:
- 初级: 知道 Lua 原子。
- 中级: 知道长脚本会阻塞。
- 高级: 能说明终止限制、复杂度和最坏数据量测试。
10.7 EVAL、EVALSHA 和 SCRIPT LOAD 有什么区别?
推荐回答:
EVAL 携带源码并执行;SCRIPT LOAD 只加载脚本并返回 SHA1;EVALSHA 通过 SHA1 调用缓存脚本。
EVALSHA 能减少脚本源码传输,但客户端必须处理 NOSCRIPT。重启、故障切换或 SCRIPT FLUSH 后缓存可能丢失。go-redis 的 redis.NewScript.Run 会自动尝试 EVALSHA 并在缺失时回退到 EVAL。(Redis)
面试官追问:
- EVALSHA 放进 Pipeline 有什么问题?
- 能否动态生成脚本源码?
- SHA1 相同是否代表业务版本管理完善?
常见错误回答:
“执行过一次 EVAL 后,脚本在所有 Redis 节点永久存在。”
评分点:
- 初级: 知道三个命令的基本功能。
- 中级: 能处理 NOSCRIPT。
- 高级: 能分析 Pipeline、故障切换和脚本发布策略。
10.8 Redis Functions 与 Lua 脚本是什么关系?
推荐回答:
Functions 延续了 Lua 的服务器端原子执行能力,但将代码作为 Redis 管理的命名函数库进行持久化和复制。
EVAL 脚本通常由客户端维护,缓存可能丢失;Functions 从 Redis 7.0 开始提供,通过 FUNCTION LOAD 部署、FCALL 调用,更适合多应用共享和统一版本管理。(Redis)
面试官追问:
- Functions 会不会阻塞 Redis?
- Cluster 中函数库如何部署?
- Redis 6 环境如何替代 Functions?
常见错误回答:
“Functions 在后台线程运行,所以可以执行长任务。”
评分点:
- 初级: 知道 Functions 是 Redis 7.0+。
- 中级: 能区分生命周期和调用方式。
- 高级: 能讨论发布、回滚、集群节点覆盖和可观测性。
10.9 Cluster 中事务和 Lua 有什么限制?
推荐回答:
事务、脚本和 Functions 涉及的多个 Key通常必须属于同一个 Hash Slot。应通过 Hash Tag 让关联 Key 使用相同的 {...} 部分。
仅仅位于当前同一台主节点并不够,因为 Slot 可能迁移。普通 Pipeline 可以包含不同 Slot 的独立命令,但不能因此获得跨节点原子性。(Redis)
面试官追问:
- 如何检查 Key 的 Slot?
- 为什么同一个节点但不同 Slot 仍可能失败?
- 跨 Slot 业务还能怎样设计?
常见错误回答:
“只要两个 Key 当前在同一 Redis 节点,就能执行事务。”
评分点:
- 初级: 知道 CROSSSLOT。
- 中级: 会使用 Hash Tag。
- 高级: 能在 Key 设计阶段确定聚合边界和分片权衡。
10.10 go-redis 中 Pipeline、Pipelined、TxPipeline、TxPipelined 有什么区别?
推荐回答:
Pipeline() 和 TxPipeline() 返回 Pipeliner,由调用方手动执行 Exec();Pipelined() 和 TxPipelined() 使用回调并自动执行。
前两组是否带 Tx 决定是否使用 MULTI/EXEC。redis.Client 可以并发复用,但具体 Pipeline 实例不应由多个 goroutine 并发写入。(Redis)
面试官追问:
- Pipeline 的 Exec 返回什么错误?
- 如何处理某个 GET 返回 redis.Nil?
- 能否在 Pipeline 回调里启动多个 goroutine?
常见错误回答:
“go-redis Client 不能并发使用,所以每个请求都要创建一个 Client。”
评分点:
- 初级: 会调用 Pipelined。
- 中级: 能正确逐条检查结果。
- 高级: 能讨论连接池、批次、并发安全与超时重试。
10.11 EXEC 或 Lua 超时后能否直接重试?
推荐回答:
不能默认直接重试。客户端超时时,Redis 可能尚未执行、执行了一部分 Pipeline,或者已经完成 EXEC/Lua 但响应丢失,因此结果处于不确定状态。
非幂等操作必须引入请求 ID、幂等 Key、结果记录或状态查询。重试策略应区分连接前失败、写入后超时和明确业务失败。
面试官追问:
- 如何实现扣款幂等?
- Pipeline 超时与事务超时有何不同?
- Redis 能否提供 exactly-once?
常见错误回答:
“超时代表 Redis 没执行,重试一定安全。”
评分点:
- 初级: 知道写操作重试有风险。
- 中级: 能实现幂等 Key。
- 高级: 能描述结果不确定、状态机和端到端语义。
10.12 Lua 或事务原子执行后,故障切换是否还会丢数据?
推荐回答:
可能。原子执行只说明当前主节点上的命令不会被其他命令插入,不代表副本已经同步,也不代表数据已经按照所需策略持久化。
Redis Cluster 使用异步复制,主节点回复后、写入到达副本前发生故障,仍存在已确认写入丢失的窗口。需要结合 AOF、复制、WAIT、业务幂等和补偿机制评估。(Redis)
面试官追问:
- WAIT 能否把 Redis 变成强一致系统?
- AOF everysec 的数据窗口是什么?
- 原子性、持久性和高可用分别解决什么问题?
常见错误回答:
“Lua 是原子的,所以故障切换后也绝不会丢失。”
评分点:
- 初级: 知道原子性不等于持久性。
- 中级: 能说明异步复制窗口。
- 高级: 能结合 RPO、RTO、持久化和业务补偿回答。
11. 一分钟面试回答
Pipeline、事务、WATCH、Lua 和 Functions 解决的是不同问题。Pipeline 通过批量发送和读取命令减少网络 RTT,但不提供原子性,其他客户端命令可能插入,而且超时重试要考虑重复执行。MULTI/EXEC 会先排队再连续执行命令,但没有关系数据库式回滚;入队错误会导致 EXEC 拒绝事务,执行期错误只影响当前命令。TxPipeline 是 Pipeline 与 MULTI/EXEC 的组合。WATCH 是乐观锁,通过监视 Key 实现 CAS,适合低冲突场景,高冲突时会产生重试风暴。Lua 将判断和更新放到 Redis 内原子执行,适合库存、限流和幂等,但长脚本会阻塞其他命令。EVALSHA 依赖易失脚本缓存,需要处理 NOSCRIPT;Redis 7.0 的 Functions 将函数作为可持久化、可复制的软件制品管理。Cluster 中多 Key 事务、脚本和函数通常要求所有 Key 位于同一 Slot,应使用 Hash Tag。最后,Redis 内部原子性不等于跨系统事务、强一致或故障后零数据丢失。
12. 本章总结
- Pipeline 的核心价值是减少网络往返,不是提供事务。
- Redis 事务保证无插入执行,但不提供通用回滚。
- 入队错误会拒绝事务,执行期错误不会阻止其他命令继续执行。
- WATCH 是乐观锁,冲突由客户端负责有限重试。
- Lua 将读取、判断和写入合并到服务器内,避免客户端竞态。
- Lua 和 Functions 的原子性来自阻塞式执行,因此逻辑必须短小。
- EVALSHA 必须考虑脚本缓存丢失和 NOSCRIPT。
- Functions 改善了脚本的持久化、命名、发布和多应用共享能力。
- Cluster 中原子多 Key 操作应通过 Hash Tag 保证同 Slot。
- 原子执行不代表持久化、强一致、Exactly-Once 或跨系统事务。
13. 自测清单
- 为什么 Pipeline 可以提高吞吐量,却不能保证多条命令的原子性?
- Pipeline 批次过大会分别给客户端和服务器带来什么风险?
- MULTI 之后的命令什么时候真正执行?
- 入队错误与执行期错误分别如何影响 EXEC?
- 为什么 Redis 事务不提供关系数据库式回滚?
- WATCH 冲突时,go-redis 会返回什么错误,客户端应该怎样重试?
- 为什么高并发热点库存通常不适合 WATCH?
- EVALSHA 在故障切换和 Pipeline 中分别有什么风险?
- Functions 相比 EVAL 脚本解决了哪些生命周期问题?
- 如何设计库存 Key 和订单幂等 Key,使 Lua 能在 Cluster 中执行?
14. 官方资料
- Redis 官方文档:Pipelining,说明 Pipeline 的 RTT、吞吐量与批次内存问题。(Redis)
- Redis 官方文档:Transactions,说明 MULTI、EXEC、DISCARD、错误处理和 WATCH。(Redis)
- Redis 官方文档:Scripting with Lua,说明 Lua 原子执行、KEYS/ARGV、脚本缓存和长脚本。(Redis)
- Redis 官方命令文档:EVAL、EVALSHA、SCRIPT LOAD、SCRIPT KILL。(Redis)
- Redis 官方文档:Redis Functions、FUNCTION LOAD 与 FCALL。(Redis)
- Redis 官方文档:Redis Cluster Specification,说明 Hash Slot、多 Key 限制和异步复制的数据安全边界。(Redis)
- go-redis 官方文档与源码:Pipeline、事务、WATCH、Script 和并发安全。(Redis)