分布式锁、限流、幂等与并发控制
系统梳理 Redis 分布式锁、Fencing Token、Redlock、限流算法、幂等状态机、延迟任务与 Go 客户端实践。
第 19 章:分布式锁、限流、幂等与并发控制
1. 本章定位
前面的章节解决了缓存、复制、集群和高并发访问问题,本章进一步讨论:多个进程同时修改同一份业务状态时,怎样减少冲突、限制流量,并控制重复执行。
本章涉及四类不同问题:
- 分布式锁:尽量避免多个客户端同时执行临界区。
- 限流:控制单位时间内允许通过的请求量。
- 幂等:让同一业务请求重复执行时,得到可预测的结果。
- 延迟任务:让任务在指定时间之后被调度,同时处理丢失和重复消费。
这四类机制经常同时出现,但不能混为一谈。例如,分布式锁不能自动实现幂等,幂等也不能完全替代并发控制;Redis 能提供低延迟的原子操作,但并不因此成为绝对强一致的分布式协调系统。
本章以 Redis 6、7 中广泛使用的 Lua 方案为基础,同时说明 Redis 8.4 的 DELEX 和 Redis 8.8 的 INCREX 等版本变化。当前 Redis Open Source 下载页已经列出 8.8,Go 示例统一使用官方 github.com/redis/go-redis/v9 客户端。(Redis)
2. 学习目标
学完本章后,应当能够:
- 使用
SET key value NX PX正确获取一个带租期的 Redis 锁。 - 解释锁值为什么必须在每次加锁时唯一,并使用 Lua 或
DELEX安全解锁。 - 分析业务执行超过锁租期、客户端暂停、网络分区和主从切换带来的风险。
- 说明 Fencing Token 的工作原理,并设计下游的令牌校验。
- 客观说明 Redlock 的基本算法、争议及适用边界。
- 比较固定窗口、滑动窗口、滑动日志、令牌桶和漏桶限流算法。
- 使用 Redis 构建包含执行状态和结果缓存的幂等状态机。
- 说明 Sorted Set 延迟任务为什么可能丢失或重复,并设计认领、确认和重试机制。
2.1 本章边界与跳转
本章是分布式锁、限流、幂等与并发控制的主章节。SET NX PX、Lua 解锁和限流脚本使用的原子组合能力以[第 9 章](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions/)为基础;缓存击穿中的互斥重建和回源保护见第 18 章;Sorted Set 作为延迟任务载体的基础建模见[第 6 章](/blog/tech/Redis/06.Set、Sorted Set 与 GEO/),Streams 消费与重试见[第 8 章](/blog/tech/Redis/08.PubSub、List 队列与 Streams/);锁和限流本身成为热点后的排查见第 20 章。
3. 核心概念
3.1 分布式并发控制不等于“加一把锁”
并发控制真正要保护的是业务不变量,例如:
- 同一订单只能支付一次。
- 库存不能扣成负数。
- 同一批结算任务不能重复入账。
- 同一资源的更新必须按版本顺序生效。
分布式锁只是实现这些不变量的手段之一。在很多场景中,数据库唯一约束、条件更新、乐观锁、幂等表或消息去重,比单独依赖 Redis 锁更加可靠。
因此,设计并发控制前应先回答:
即使两个客户端真的同时进入临界区,下游系统是否仍能拒绝错误写入?
如果答案是否定的,仅靠一个会过期的 Redis Key 往往不够。
3.2 Redis 锁本质上是带租期的所有权声明
经典获取命令为:
SET lock-key random-token NX PX lease-milliseconds
其中:
NX:只有 Key 不存在时才设置。PX:设置毫秒级租期。random-token:本次加锁请求唯一的所有权标识。- 命令返回
OK:当前 Redis 实例接受了该客户端的所有权声明。 - 返回空值:锁已经被其他客户端占用。
NX 和 PX 必须放在同一个 SET 命令中。若先执行 SETNX,再单独执行 PEXPIRE,客户端可能在两条命令之间崩溃,留下永不过期的锁。
Redis 官方文档也明确推荐使用随机 Token,并在解锁时比较当前值,避免旧客户端删除新持有者的锁。(Redis)
3.3 锁值为什么必须唯一
考虑下面的时间线:
- 客户端 A 获取锁,锁值为固定字符串
"locked",租期 10 秒。 - A 因为进程暂停,超过 10 秒仍未完成。
- 锁自动过期。
- 客户端 B 获取同一个锁,锁值仍然为
"locked"。 - A 恢复运行并执行
DEL lock-key。 - B 的锁被 A 错误删除。
如果每次请求使用唯一 Token,例如 128 位随机数,那么 A 解锁时可以检查:
当前锁值是否仍等于 A 的 Token?
不相等就说明锁已经过期或所有权已经转移,A 无权删除。
Token 应当满足:
- 每次加锁请求唯一,而不是每个客户端进程固定一个值。
- 不容易碰撞或被猜测。
- 长度适中,例如 128 位随机数。
- 不使用容易重复的时间戳、进程 ID 或机器名作为唯一来源。
3.4 解锁必须是“比较并删除”的原子操作
下面的写法仍然不安全:
GET lock-key
DEL lock-key
因为在 GET 和 DEL 之间,锁可能已经过期并被另一个客户端重新获取。
Redis 6、7 及需要兼容历史版本的系统通常使用 Lua:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
Lua 脚本在 Redis 内原子执行,因此其他命令不会插入 GET 和 DEL 之间。Redis 脚本执行期间会阻塞该 Redis 执行其他活动,所以脚本必须保持短小,不能在脚本中遍历大集合或执行无界循环。(Redis)
Redis 8.4+ 增加了原生条件删除命令:
DELEX lock-key IFEQ random-token
它可以直接表达“值相等才删除”。为兼容 Redis 6、7,后文 Go 示例仍采用 Lua。(Redis)
3.5 锁超时与过期所有者
Redis 锁的 TTL 不是单纯的清理机制,而是所有权有效期。
假设客户端 A 获取了 10 秒租期:
0 秒:A 获取锁
3 秒:A 开始调用数据库
10 秒:锁过期
11 秒:B 获取锁
12 秒:B 完成写入
15 秒:A 从暂停中恢复并继续写入
此时,即使 A 最初合法获取了锁,它在第 10 秒之后也已经成为过期所有者。如果下游没有额外校验,A 仍可能覆盖 B 的正确结果。
可能造成长时间暂停的因素包括:
- Go 进程被操作系统调度暂停。
- 容器 CPU 被严重限流。
- Stop-The-World GC、缺页和系统负载抖动。
- 网络请求长时间阻塞。
- 调试器、信号或人工操作暂停进程。
- Redis 与客户端之间发生网络分区,但客户端仍能访问数据库。
因此,锁持有者不能仅根据“我的进程还活着”推断“我仍拥有锁”。
3.6 自动续期不是无条件安全
当业务耗时难以准确估计时,可以设计续期机制:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
续期必须满足:
- 只有当前 Token 匹配时才续期。
- 续期必须早于锁到期时间。
- 一旦续期失败,业务逻辑应尽快停止或进入只读、补偿状态。
- 必须限制最大续期次数或最大总持有时间。
- 续期协程必须随业务请求结束而退出。
无限续期会让租期锁退化为可能永远不释放的锁。更危险的是,业务线程可能已经卡死,但独立看门狗仍持续续期,导致其他客户端永久无法取得锁。
Redis 官方文档同样要求续期时校验 Token,并建议限制重新获取或续期次数。(Redis)
3.7 Fencing Token:让下游拒绝过期所有者
Fencing Token 是每次成功获得执行权时生成的单调递增序号,例如:
A 获取锁:fence = 41
A 暂停,锁过期
B 获取锁:fence = 42
B 使用 42 更新数据库
A 恢复,尝试使用 41 更新数据库
数据库拒绝 41
下游资源需要保存已经接受的最大 Token:
UPDATE resource
SET
value = $1,
last_fence = $2
WHERE id = $3
AND last_fence < $2;
若 RowsAffected 为 0,说明当前请求已经过期,不得继续执行。
Fencing Token 与随机锁 Token 的用途不同:
| Token | 作用 |
|---|---|
| 随机所有权 Token | 防止旧客户端误删新锁 |
| 单调递增 Fencing Token | 防止旧客户端继续修改下游资源 |
Fencing Token 只有在下游真正执行比较时才有意义。仅生成一个递增数字,却不让数据库、对象存储或外部服务校验,不能提高安全性。
还要注意:若 Fencing Token 通过普通 Redis 主节点上的 INCR 生成,主从切换可能回滚尚未复制的计数,使令牌失去严格单调性。对正确性要求极高的场景,应由具备更强一致性保证的协调服务或数据库序列生成,并由下游条件写强制校验。
3.8 Redis 锁能保证什么,不能保证什么
| 能力 | Redis 租期锁能否单独保证 | 说明 |
|---|---|---|
| 同一 Redis 实例中,锁 Key 同时只有一个值 | 可以 | 依赖单条命令原子性 |
| 锁持有者崩溃后最终释放 | 通常可以 | 依赖 TTL |
| 防止旧客户端误删新锁 | 可以 | 必须使用唯一 Token 和条件删除 |
| 业务代码绝不重叠执行 | 不能 | 旧客户端可能在租期过后继续运行 |
| 主从切换时锁绝不丢失 | 不能 | Redis 复制通常是异步的 |
| 外部数据库操作恰好执行一次 | 不能 | Redis 与数据库之间没有共同事务 |
| 严格公平锁 | 不能 | 后到请求可能先重试成功 |
| 所有故障模型下的线性一致互斥 | 不能 | 受暂停、时钟、分区和故障转移影响 |
正确表述应当是:
Redis 锁是一种低延迟、带自动过期的分布式租约机制。它可以降低并发冲突概率,但不能单独保证所有外部副作用的强一致性。
3.9 主从切换为什么可能产生两个持有者
考虑 Redis 主从复制:
- 客户端 A 在主节点写入锁。
- 锁还没复制到副本。
- 主节点故障。
- 不包含该锁的副本被提升为新主节点。
- 客户端 B 在新主节点成功获得同一个锁。
- A 可能仍在执行业务。
这不是命令原子性问题,而是异步复制和故障转移之间的数据丢失窗口。Redis 官方分布式锁文档专门指出,简单依赖主从切换会破坏互斥安全性。(Redis)
WAIT 可以等待写入被一定数量的副本确认,WAITAOF 可以等待 AOF 刷盘,但它们主要改善数据安全窗口,并不会让 Redis 变成绝对强一致系统,也不能阻止已经过期的客户端继续操作下游资源。(Redis)
3.10 Redlock 的基本思路与争议
Redlock 使用多个互相独立的 Redis 主节点,典型数量为 5:
- 客户端生成唯一随机 Token。
- 记录开始时间。
- 依次或并行尝试在多个独立 Redis 节点获取同一个锁。
- 获得多数节点,例如 5 个节点中的至少 3 个。
- 整体耗时必须小于锁租期。
- 有效租期约为原始租期减去加锁耗时和时钟漂移余量。
- 失败或解锁时,对全部实例执行比较并删除。
Redlock 试图避免普通主从故障转移中“锁尚未复制就切主”的问题,但它仍依赖租期、节点时钟和客户端在一定时间范围内完成操作等假设。
关于 Redlock,存在两类主要观点:
- 支持者认为,它比单实例加故障转移的锁更可靠,并在合理的半同步系统假设下具有实用价值。
- 批评者认为,对正确性敏感的资源访问仍可能受到长时间暂停和时序异常影响,应使用 Fencing Token 和共识系统。
Redis 官方文档目前明确提示:长任务应采用 Fencing Token,不能因为进程仍存活就假设锁仍有效;同时指出 Redis TTL 不是基于单调时钟,时钟变化可能影响一致性。官方页面也同时列出了 Martin Kleppmann 的批评和 antirez 的回应。(Redis)
合理边界是:
- 适合:缓存重建、定时任务去重、可重试且可补偿的后台任务、偶发重叠不会破坏永久数据的场景。
- 谨慎使用:库存扣减、资金结算、主节点选举、不可逆外部设备控制。
- 正确性关键场景:优先使用数据库唯一约束、条件更新、事务型幂等表或基于共识的协调服务,并让下游校验 Fencing Token。
3.11 五种限流算法
固定窗口
将时间划分为固定区间,例如每分钟最多 100 次。
12:00:00~12:00:59:最多 100 次
12:01:00~12:01:59:重新计数
优点是实现简单、时间和空间复杂度低。缺点是窗口边界可能产生突发:客户端在前一窗口末尾发送 100 次,又在下一窗口开头发送 100 次,短时间内通过 200 次。
滑动窗口计数
保存当前窗口和前一窗口的计数,根据时间位置对前一窗口加权:
估算请求数 = 当前窗口计数 + 前一窗口计数 × 剩余权重
空间复杂度仍接近 O(1),比固定窗口平滑,但只是近似值。
滑动日志
使用 Sorted Set 保存窗口内每一次请求:
- Member:唯一请求 ID。
- Score:请求时间戳。
- 删除窗口之前的记录。
- 统计当前元素数量。
它能精确反映最近一段时间的请求数,但每个请求都占用一个元素。高流量场景会增加内存、跳表操作和删除成本。
令牌桶
桶以固定速率补充令牌:
- 请求到达时消耗令牌。
- 有令牌则通过。
- 无令牌则拒绝或等待。
- 桶容量决定允许的突发大小。
适合“长期平均速率受控,但允许短时突发”的 API。
漏桶
请求先进入队列,再按固定速度流出:
- 输出速率稳定。
- 队列满时拒绝新请求。
- 可以平滑下游负载。
- 会引入排队延迟。
适合需要严格平滑输出的调用,但不适合对排队延迟非常敏感的交互请求。
| 算法 | 精确度 | 单主体空间 | 允许突发 | 实现复杂度 |
|---|---|---|---|---|
| 固定窗口 | 较低 | O(1) | 边界突发明显 | 低 |
| 滑动窗口计数 | 近似 | O(1) | 较平滑 | 中 |
| 滑动日志 | 精确 | O(N) | 可严格控制 | 中高 |
| 令牌桶 | 按速率模型 | O(1) | 可以 | 中 |
| 漏桶 | 按排队模型 | O(1) 或 O(N) | 输出不突发 | 中高 |
3.12 Lua 原子限流
经典固定窗口通常需要:
INCR rate-key
EXPIRE rate-key 60
若 INCR 成功而 EXPIRE 未执行,Key 可能永久存在。把两步放进 Lua 可保证其他请求不会看到中间状态。
Redis 8.8+ 新增 INCREX,可以在一次原生命令中执行带边界和过期控制的增量操作,例如:
INCREX rate:user:42 BYINT 1 UBOUND 100 EX 60 ENX
当计数达到上限时,实际增量返回 0;ENX 表示仅在 Key 尚无 TTL 时设置过期时间。它适合固定窗口计数,但不能替代令牌桶、滑动日志等更复杂算法。(Redis)
3.13 幂等 Key、状态机与结果缓存
幂等设计的目标不是“拒绝所有重复请求”,而是让同一个业务请求被重复提交时产生可预测结果。
典型状态机为:
ABSENT
│ SET NX
▼
PROCESSING(owner, fingerprint, started_at)
├── 成功 ──> SUCCEEDED(response)
├── 明确失败 ──> FAILED(error)
└── 超时 ──> 允许重试或人工确认
幂等 Key 一般包含:
idem:{tenant}:{operation}:{idempotency-key}
状态中至少需要保存:
- 请求参数指纹,例如请求体的 SHA-256。
- 当前执行者 Owner Token。
- 执行状态。
- 开始时间和过期时间。
- 成功结果或结果引用。
- 必要时保存错误类别。
请求指纹非常重要。同一个幂等 Key 被用于不同参数时,应直接拒绝,而不是返回上一次请求的结果。
幂等状态通常使用两个不同的 TTL:
PROCESSINGTTL:略大于业务最大执行时间,防止执行者崩溃后永久占用。SUCCEEDEDTTL:由业务重试周期决定,可能是数小时或数天。
需要特别注意下面的崩溃窗口:
- 数据库支付成功。
- 服务尚未把 Redis 状态改成
SUCCEEDED。 - 服务进程崩溃。
PROCESSING过期。- 重试请求再次执行支付。
因此,Redis 幂等状态不能单独保证跨 Redis 和数据库的“恰好一次”。资金、订单等关键操作还应使用:
- 数据库唯一约束。
- 事务内幂等记录。
- 业务流水号。
- Transactional Outbox。
- 下游接口自身的幂等能力。
3.14 Sorted Set 延迟任务
基本模型为:
ZADD delay:{email} execute_at_ms task-id
其中:
- Member 是唯一任务 ID。
- Score 是计划执行时间。
- 任务正文可以存储在数据库、Hash 或对象存储中。
消费者查询:
score <= 当前时间
然后原子地认领任务。
直接使用 ZPOPMIN 或 BZPOPMIN 存在一个常见误区:它们弹出的是当前最小 Score 元素,而不是“等到 Score 指定的时间再弹出”。BZPOPMIN 只会在集合为空时等待;只要集合中存在未来任务,它仍可能立即弹出。(Redis)
延迟任务有两种基本失败语义:
- 先删除再执行:消费者删除后崩溃,任务丢失,接近至多一次。
- 先执行再删除:消费者执行成功后崩溃,任务可能重复,接近至少一次。
生产级方案通常增加 processing 集合:
delay ZSet
│ 到期认领
▼
processing ZSet,Score = 可见性超时时间
│
├── 执行成功:ACK,删除
└── 超时未 ACK:重新入队
消费者必须幂等。对于更复杂的消息确认、Pending Entries List 和消费者接管,可以考虑 Redis Streams;XAUTOCLAIM 能转移长时间无人确认的 Pending 消息。(Redis)
4. 命令与 Go 使用方法
4.1 必要的 redis-cli 命令
获取和释放锁
# 获取 10 秒租期锁
SET lock:{order:42} 0123456789abcdef NX PX 10000
# 检查剩余租期
PTTL lock:{order:42}
# Redis 6/7/8 通用安全解锁
EVAL '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
' 1 lock:{order:42} 0123456789abcdef
# Redis 8.4+
DELEX lock:{order:42} IFEQ 0123456789abcdef
比较 Token 后续期
EVAL '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
' 1 lock:{order:42} 0123456789abcdef 10000
固定窗口限流
EVAL '
local n = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
if n == 1 or ttl < 0 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
return n
' 1 rate:{user:42} 60000
Redis 8.8+ 可使用:
INCREX rate:{user:42} BYINT 1 UBOUND 100 EX 60 ENX
幂等占位
SET idem:{payment}:req-20260621-001 \
P:request-fingerprint:owner-token \
NX PX 30000
延迟任务
ZADD delay:{email} 1782000000000 task-001
# Redis 6.2+
ZRANGE delay:{email} -inf 1782000000000 BYSCORE LIMIT 0 10
# Redis 6.0 等历史版本
ZRANGEBYSCORE delay:{email} -inf 1782000000000 LIMIT 0 10
4.2 Go:安全解锁、Fencing Token、限流、幂等与延迟任务
下面的代码使用 redis.UniversalClient,因此可由 Standalone、Sentinel 或 Cluster 客户端实现。Cluster 模式下,同一个 Lua 脚本访问的多个 Key 必须处于同一 Slot,因此示例使用相同 Hash Tag,例如 {order:42}。
package rediscc
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
const redisOperationTimeout = 500 * time.Millisecond
type Lease struct {
Key string
Token string
TTL time.Duration
Fence int64
}
func randomToken() (string, error) {
buf := make([]byte, 16) // 128-bit random token
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate lock token: %w", err)
}
return hex.EncodeToString(buf), nil
}
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
// AcquireLease 尝试获取单 Redis 租期锁。
//
// 注意:当 err != nil 时,命令结果可能是不确定的:
// Redis 可能已经写入成功,只是响应没有及时到达客户端。
// 此时绝不能执行受保护业务,只能使用返回的 Token 做条件清理。
func AcquireLease(
ctx context.Context,
rdb redis.UniversalClient,
key string,
ttl time.Duration,
) (lease Lease, acquired bool, err error) {
if ttl <= 0 {
return Lease{}, false, errors.New("lock TTL must be positive")
}
token, err := randomToken()
if err != nil {
return Lease{}, false, err
}
lease = Lease{
Key: key,
Token: token,
TTL: ttl,
}
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
ok, err := rdb.SetNX(opCtx, key, token, ttl).Result()
if err != nil {
return lease, false, fmt.Errorf("acquire Redis lease: %w", err)
}
return lease, ok, nil
}
// ReleaseLease 只删除仍由当前 Token 持有的锁。
// deleted=false 且 err=nil 通常表示锁已过期、已被释放或所有权已经变化。
func ReleaseLease(
ctx context.Context,
rdb redis.UniversalClient,
lease Lease,
) (deleted bool, err error) {
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
n, err := unlockScript.Run(
opCtx,
rdb,
[]string{lease.Key},
lease.Token,
).Int64()
if err != nil {
return false, fmt.Errorf("release Redis lease: %w", err)
}
return n == 1, nil
}
var acquireFencedLeaseScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then
return 0
end
local fence = redis.call("INCR", KEYS[2])
redis.call("PSETEX", KEYS[1], ARGV[2], ARGV[1])
return fence
`)
// AcquireFencedLease 获取租期锁并分配 Fencing Token。
//
// lockKey 与 fenceKey 在 Redis Cluster 中必须属于同一 Slot,例如:
// lock:{order:42}
// fence:{order:42}
func AcquireFencedLease(
ctx context.Context,
rdb redis.UniversalClient,
lockKey string,
fenceKey string,
ttl time.Duration,
) (lease Lease, acquired bool, err error) {
if ttl <= 0 {
return Lease{}, false, errors.New("lock TTL must be positive")
}
token, err := randomToken()
if err != nil {
return Lease{}, false, err
}
lease = Lease{
Key: lockKey,
Token: token,
TTL: ttl,
}
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
fence, err := acquireFencedLeaseScript.Run(
opCtx,
rdb,
[]string{lockKey, fenceKey},
token,
ttl.Milliseconds(),
).Int64()
if err != nil {
return lease, false, fmt.Errorf("acquire fenced lease: %w", err)
}
if fence == 0 {
return lease, false, nil
}
lease.Fence = fence
return lease, true, nil
}
var fixedWindowScript = redis.NewScript(`
local n = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
if n == 1 or ttl < 0 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
return n
`)
type LimitResult struct {
Allowed bool
Count int64
Limit int64
}
// AllowFixedWindow 实现以首次请求为起点的固定时长窗口。
// 同一 limiterKey 上的多个并发请求由 Lua 原子串行处理。
func AllowFixedWindow(
ctx context.Context,
rdb redis.UniversalClient,
limiterKey string,
limit int64,
window time.Duration,
) (LimitResult, error) {
if limit <= 0 {
return LimitResult{}, errors.New("limit must be positive")
}
if window <= 0 {
return LimitResult{}, errors.New("window must be positive")
}
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
count, err := fixedWindowScript.Run(
opCtx,
rdb,
[]string{limiterKey},
window.Milliseconds(),
).Int64()
if err != nil {
return LimitResult{}, fmt.Errorf("run rate limiter: %w", err)
}
return LimitResult{
Allowed: count <= limit,
Count: count,
Limit: limit,
}, nil
}
type IdempotencyDecision struct {
Execute bool
InProgress bool
Owner string
Cached []byte
}
var finishIdempotencyScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2])
return 1
`)
// BeginIdempotency 尝试把状态从 ABSENT 转为 PROCESSING。
//
// fingerprint 建议使用请求关键参数的 SHA-256 十六进制字符串,
// 并且不能包含冒号。
func BeginIdempotency(
ctx context.Context,
rdb redis.UniversalClient,
key string,
fingerprint string,
processingTTL time.Duration,
) (IdempotencyDecision, error) {
if fingerprint == "" || strings.Contains(fingerprint, ":") {
return IdempotencyDecision{}, errors.New("invalid request fingerprint")
}
if processingTTL <= 0 {
return IdempotencyDecision{}, errors.New("processing TTL must be positive")
}
owner, err := randomToken()
if err != nil {
return IdempotencyDecision{}, err
}
processingState := "P:" + fingerprint + ":" + owner
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
ok, err := rdb.SetNX(
opCtx,
key,
processingState,
processingTTL,
).Result()
if err != nil {
return IdempotencyDecision{}, fmt.Errorf("create idempotency state: %w", err)
}
if ok {
return IdempotencyDecision{
Execute: true,
Owner: owner,
}, nil
}
current, err := rdb.Get(opCtx, key).Result()
if errors.Is(err, redis.Nil) {
// Key 可能恰好在 SETNX 返回 false 后过期。
// 调用方可以重新发起整个 Begin 操作。
return IdempotencyDecision{},
errors.New("idempotency state changed; retry begin operation")
}
if err != nil {
return IdempotencyDecision{},
fmt.Errorf("read idempotency state: %w", err)
}
parts := strings.SplitN(current, ":", 3)
if len(parts) != 3 {
return IdempotencyDecision{},
errors.New("invalid idempotency state encoding")
}
if parts[1] != fingerprint {
return IdempotencyDecision{},
errors.New("idempotency key reused with different request parameters")
}
switch parts[0] {
case "P":
return IdempotencyDecision{
InProgress: true,
}, nil
case "D":
result, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return IdempotencyDecision{},
fmt.Errorf("decode cached idempotency result: %w", err)
}
return IdempotencyDecision{
Cached: result,
}, nil
default:
return IdempotencyDecision{},
fmt.Errorf("unknown idempotency state %q", parts[0])
}
}
// FinishIdempotency 只有当前 Owner 仍拥有 PROCESSING 状态时,
// 才能写入最终结果。
func FinishIdempotency(
ctx context.Context,
rdb redis.UniversalClient,
key string,
fingerprint string,
owner string,
result []byte,
resultTTL time.Duration,
) (stored bool, err error) {
if owner == "" {
return false, errors.New("owner token is empty")
}
if resultTTL <= 0 {
return false, errors.New("result TTL must be positive")
}
expected := "P:" + fingerprint + ":" + owner
done := "D:" + fingerprint + ":" +
base64.RawStdEncoding.EncodeToString(result)
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
n, err := finishIdempotencyScript.Run(
opCtx,
rdb,
[]string{key},
expected,
done,
resultTTL.Milliseconds(),
).Int64()
if err != nil {
return false, fmt.Errorf("finish idempotency state: %w", err)
}
return n == 1, nil
}
var popDueTaskScript = redis.NewScript(`
local tasks = redis.call(
"ZRANGEBYSCORE",
KEYS[1],
"-inf",
ARGV[1],
"LIMIT",
0,
1
)
if #tasks == 0 then
return false
end
if redis.call("ZREM", KEYS[1], tasks[1]) == 1 then
return tasks[1]
end
return false
`)
// PopDueTask 是“查询到期元素并删除”的原子实现。
// 它属于先删除后执行,消费者崩溃时仍可能丢任务;
// 关键任务应改为 claim + ack + visibility timeout。
func PopDueTask(
ctx context.Context,
rdb redis.UniversalClient,
delayKey string,
now time.Time,
) (taskID string, found bool, err error) {
opCtx, cancel := context.WithTimeout(ctx, redisOperationTimeout)
defer cancel()
taskID, err = popDueTaskScript.Run(
opCtx,
rdb,
[]string{delayKey},
now.UnixMilli(),
).Text()
if errors.Is(err, redis.Nil) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("pop due task: %w", err)
}
return taskID, true, nil
}
4.3 关键代码说明
命令超时不等于命令一定失败
SET NX 或 Lua 调用发生超时时,可能存在两种情况:
- Redis 没收到请求。
- Redis 已执行成功,但响应在网络中丢失或延迟。
因此,客户端超时后不能直接开始业务,也不能认定自己一定没有锁。安全处理方式是:
- 不执行受保护操作。
- 保留本次随机 Token。
- 尝试按 Token 条件删除。
- 使用随机退避后重新获取。
- 对关键副作用继续依赖幂等或 Fencing Token。
Fencing Token 必须在下游校验
获取 lease.Fence 后,数据库写入应类似:
UPDATE order_guard
SET
status = 'processed',
last_fence = $1
WHERE order_id = $2
AND last_fence < $1;
Go 代码必须检查 RowsAffected()。返回 0 表示当前持有者已经过期,不得将其视为普通数据库更新失败后无限重试。
redis.Nil 只代表预期的“没有值”
代码中延迟任务找不到到期元素、幂等状态恰好过期时,可能出现 redis.Nil。应使用:
errors.Is(err, redis.Nil)
连接中断、超时、权限错误和脚本错误都不能当成 redis.Nil 忽略。
并发安全性
go-redis 客户端内部使用连接池,通常应创建后由多个 goroutine 复用,而不是每次请求创建一个客户端。脚本对象是只读的,也可以复用。(GitHub)
但以下对象仍需业务层同步:
- 同一个 Lease 的续期和释放操作。
- 请求取消与续期 goroutine 的退出。
- 本地缓存中的幂等结果。
- 同一任务的本地重试状态。
5. 典型业务场景
| 场景 | 适用方案 | 不适用或风险 | 数据量要求 | 一致性要求 | 性能风险 | 可替代方案 |
|---|---|---|---|---|---|---|
| 缓存击穿时只允许一个节点重建 | 单 Redis 租期锁、本地 singleflight | 偶发重复重建通常可接受 | 热点 Key 数量有限 | 较低 | 热锁、重试惊群 | 逻辑过期、异步刷新 |
| 同一用户一分钟最多调用 100 次 | 固定窗口或滑动窗口 | 固定窗口存在边界突发 | 活跃用户数决定 Key 数量 | 中等 | 热用户 Key、TTL Key 激增 | 网关限流、本地令牌桶 |
| 平滑调用下游支付接口 | 令牌桶或漏桶 | 单 Key 可能成为瓶颈 | 按商户或租户划分 | 中等 | 排队延迟、热点 | 网关、专用限流服务 |
| 防止订单接口重复提交 | 幂等 Key、结果缓存、数据库唯一约束 | 仅用锁不能返回历史结果 | 与请求数量和结果 TTL 有关 | 高 | 结果过大、状态 Key 膨胀 | 数据库幂等表 |
| 防止同一账单重复入账 | DB 唯一约束、事务幂等表、Fencing | 单独依赖 Redis 锁风险高 | 取决于流水量 | 极高 | 数据库约束竞争 | 事务消息、账务流水号 |
| 后台定时任务去重 | Redis 租期锁或 Fencing | 任务超时可能重叠 | 锁数通常较少 | 中等 | 长租期、无限续期 | 调度器选主、DB 抢占 |
| 延迟发送通知 | Sorted Set + claim/ack | 简单 ZREM 后执行会丢任务 | 与未执行任务数有关 | 中等 | 大 ZSet、轮询压力 | Streams、专业 MQ |
| 不可逆设备控制 | 共识协调和设备端序列校验 | Redis 锁单独使用风险过高 | 通常不大 | 极高 | 可用性与一致性权衡 | etcd、ZooKeeper、设备 CAS |
6. 底层实现
6.1 锁相关数据结构
Redis 锁通常只是一个带 TTL 的 String:
Key = lock:{order:42}
Value = 128-bit owner token
TTL = 10 seconds
核心操作成本:
SET NX PX:平均O(1)。GET:平均O(1)。DEL:删除小 String 时平均O(1)。- Token 比较:与 Token 长度有关;固定 128 位时可视为常数。
INCRFencing 计数器:平均O(1)。
锁 Key 同时存在于主字典和过期字典中。TTL 到期后,Redis通过访问时的惰性过期和后台主动过期机制清理。锁的正确性不能依赖“过期时刻一到,物理内存中立刻删除”;Redis 在命令访问时会把逻辑过期的 Key 视为不存在。
6.2 Lua 的原子性与成本
Redis 在执行 Lua 脚本期间不会穿插执行其他客户端命令,因此:
读取状态 → 条件判断 → 写入状态
可以作为一个原子步骤。
但 Lua 脚本不是关系数据库事务:
- 运行时发生错误时,不应假设前面已经执行的写入一定回滚。
- 脚本执行期间会占用 Redis 命令执行线程。
- 长脚本会增加所有客户端的尾延迟。
- 动态生成不同脚本文本会不断占用脚本缓存。
- Redis Cluster 中所有访问 Key 应通过
KEYS显式传入,并位于同一 Slot。
Redis 7.0+ 提供 Redis Functions 作为服务端持久化逻辑的替代形式;普通 EVAL 脚本仍被视为客户端应用的一部分,重启或故障转移后脚本缓存可能丢失。redis.NewScript().Run 会优先尝试 EVALSHA,在脚本不存在时回退加载执行。(Redis)
6.3 Fencing Token 的空间成本
若每个业务资源都维护一个独立计数器:
fence:{order:42}
fence:{order:43}
...
空间成本为 O(R),其中 R 是曾经参与并发控制的资源数量。
Fencing 计数器不能随意设置短 TTL。计数器过期后从 1 重新开始,会破坏令牌单调性。如果下游仍保存较大的 last_fence,新请求会持续被拒绝。
常见做法包括:
- 按资源长期保存计数器。
- 按租户或分区使用共享序列。
- 使用数据库序列或共识系统产生令牌。
- 在资源彻底删除、下游状态同步清除后,再清理计数器。
6.4 限流算法复杂度
| 算法 | 核心结构 | 单次时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 固定窗口 | String Counter | 平均 O(1) | O(A) |
| 滑动窗口计数 | 两个或少量 Counter | 平均 O(1) | O(A) |
| 滑动日志 | Sorted Set | 通常 O(log N + M) | O(Q) |
| 令牌桶 | Hash/String 状态 | 平均 O(1) | O(A) |
| 漏桶 | 状态或等待队列 | O(1) 或 O(log N) | O(A) 或 O(Q) |
其中:
A:活跃限流主体数量。Q:窗口内请求或排队请求数量。M:本次清理的过期日志数量。N:某个主体滑动日志中的元素数。
滑动日志使用 Sorted Set,是因为它既能按 Score 定位窗口边界,又能按时间顺序删除旧请求。但这种精确性需要为每次请求保存元素,热点主体会产生明显内存和 CPU 成本。
6.5 延迟队列复杂度
Sorted Set 通常由字典和跳表共同支持:
- 按任务 ID 查找或删除:利用字典。
- 按执行时间排序和范围查询:利用跳表。
典型复杂度为:
ZADD:O(log N)。- 查找最早到期任务:
O(log N + M)。 ZREM:O(log N)。ZPOPMIN count:与弹出数量及集合规模相关。
这种结构适合“按时间排序”,但本身没有 ACK、消费者归属和重试次数语义,这些可靠性状态需要应用层额外实现。
6.6 版本差异
| Redis 版本 | 本章相关变化 |
|---|---|
| Redis 2.6.12+ | SET 支持 NX、XX、EX、PX 等组合选项 |
| Redis 5.0+ | Streams、ZPOPMIN、BZPOPMIN |
| Redis 6.2+ | 推荐使用统一的 ZRANGE ... BYSCORE;增加 XAUTOCLAIM |
| Redis 7.0+ | Redis Functions 成为脚本之外的服务端编程方式 |
| Redis 7.2+ | WAITAOF 可等待本地和副本 AOF 刷盘 |
| Redis 8.4+ | DELEX ... IFEQ 可原生完成比较并删除 |
| Redis 8.8+ | INCREX 可原子完成增量、边界和 TTL 控制 |
使用 Redis 6、7 的生产系统不能直接照搬 Redis 8.4、8.8 命令,应继续保留 Lua 兼容方案。(Redis)
7. 高性能、高并发、高可用分析
7.1 高性能
避免忙轮询锁
错误做法:
for {
ok, _ := rdb.SetNX(ctx, key, token, ttl).Result()
if ok {
break
}
}
这种无等待循环会造成:
- Redis QPS 激增。
- 网络和连接池占用。
- 锁持有者释放时出现惊群。
- 大量失败请求排在真正业务命令之前。
应采用:
- 指数退避。
- 随机抖动。
- 最大重试次数。
- 请求总截止时间。
- 本地
singleflight合并同一实例中的竞争。
Lua 必须保持常数级工作量
安全解锁、状态切换等脚本只访问少量 Key,通常延迟很低。滑动日志限流若一次删除大量历史元素,则可能占用 Redis 较长时间。应限制每次清理数量,或使用分批清理和容量上限。
网络往返通常比简单命令计算更贵
把 GET、判断和 DEL 合并进 Lua,不仅是为了原子性,也减少了网络往返。但不能为了减少一次 RTT,把大量业务逻辑都放入 Lua。
热点 Key 决定单分片上限
全局锁和全局限流器会让所有请求集中到同一 Key、同一 Redis Cluster Slot。扩容 Cluster 并不能自动拆分这个热点。可采用:
- 按租户、用户或资源分片。
- 本地预分配令牌。
- 分层限流:全局额度加节点本地额度。
- 对热点锁使用排队服务或数据库条件更新。
7.2 高并发
锁竞争不提供公平性
多个客户端同时重试时,网络更快或调度更有利的客户端可能连续获锁。Redis 租期锁通常不是 FIFO 公平锁。
若业务要求严格排队,需要显式维护:
- 等待队列。
- 请求序号。
- 唤醒机制。
- 超时和取消状态。
这会显著提高实现复杂度,通常更适合消息队列或专用协调系统。
限流本身也可能成为热点
例如所有未登录用户共用:
rate:anonymous
那么全部匿名流量都会竞争一个 Key。更合理的维度可能是:
rate:{ip}
rate:{device}
rate:{tenant}
rate:{api}:{tenant}
但维度越细,Key 数量越多,需要控制 TTL 和基数,避免攻击者构造海量身份消耗内存。
重试必须以幂等为前提
网络超时后,客户端无法确定上一次写入是否成功。无条件重试可能导致:
- 重复扣款。
- 重复创建订单。
- 重复发送消息。
- 重复消耗限流额度。
因此重试策略必须和请求 ID、状态机、数据库唯一约束共同设计。
7.3 高可用
故障转移提高可用性,但可能扩大锁语义的不确定性
Redis Sentinel 或 Cluster 可以在主节点故障后恢复服务,但若锁写入尚未复制,故障转移可能使锁消失。可用性恢复和锁强一致性是两个不同目标。
WAIT、WAITAOF 不是互斥证明
可以在加锁后增加复制或落盘等待,缩小锁丢失窗口,但仍不能解决:
- 客户端在租期后继续运行。
- 下游资源不校验过期所有者。
- 网络分区。
- 应用收到超时后无法确定命令结果。
- 多个系统之间缺少共同事务。
Redis 不可用时必须定义降级策略
限流器、幂等模块和锁服务发生 Redis 故障时,需要明确选择:
- Fail-open:允许请求继续。可用性高,但可能超限或重复执行。
- Fail-closed:拒绝请求。保护下游,但业务不可用。
- 本地降级:采用进程内限流或短期缓存,存在多实例总量超限。
- 转向持久层:关键幂等操作回退数据库唯一约束。
不同接口不能使用同一种默认策略。例如搜索接口限流可 Fail-open,资金幂等通常应 Fail-closed 或回退数据库校验。
8. 常见错误与生产事故
8.1 使用固定值并直接 DEL
- 现象:偶发出现两个节点同时处理同一订单,日志显示某节点刚获得锁,锁又被删除。
- 根因:旧持有者在租期过后执行
DEL,删除了新持有者的锁。 - 排查方法:记录锁 Token、获取时间、释放时间和剩余 TTL;检查是否使用固定值。
- 修复方案:每次请求生成唯一 Token,使用 Lua 或 Redis 8.4+
DELEX IFEQ。 - 如何预防:封装统一锁库,禁止业务代码直接对锁 Key 执行
DEL。
8.2 业务耗时超过锁租期
- 现象:在数据库慢查询或下游抖动期间,同一任务被两个实例同时执行。
- 根因:租期按平均耗时配置,未覆盖长尾延迟;旧客户端过期后仍继续写入。
- 排查方法:对比锁 TTL、任务 P99/P999 耗时、GC 暂停和下游调用耗时。
- 修复方案:合理扩大租期、设置业务截止时间、增加受限续期,并在下游校验 Fencing Token。
- 如何预防:监控“任务耗时/租期”比例,接近阈值时告警。
8.3 看门狗无限续期
- 现象:某任务已经卡死数小时,但锁始终存在,其他实例无法接管。
- 根因:续期协程与实际业务执行状态脱离,且没有最大持有时间。
- 排查方法:检查锁 TTL 是否周期性回升;关联 goroutine、业务日志和请求 Context。
- 修复方案:续期绑定业务 Context,限制最大续期次数和总租期,业务失败时主动停止续期。
- 如何预防:为每类任务定义最大执行期限,而不是允许永久持锁。
8.4 主从切换后出现双持有者
- 现象:Redis 故障转移后,同一资源在两个实例上被同时处理。
- 根因:锁写入尚未复制到副本,副本提升后允许另一个客户端重新获取。
- 排查方法:检查 Sentinel/Cluster 切换时间线、复制延迟、锁获取日志和 Redis 节点 run ID。
- 修复方案:使用 Fencing Token、数据库条件写;正确性要求高时改用更强协调方案。
- 如何预防:故障演练中专门验证锁持有期间切主的行为,不把高可用等同于强一致。
8.5 固定窗口边界导致下游被打爆
- 现象:配置每分钟 1 万次,但某几秒内下游收到接近 2 万次请求。
- 根因:上一窗口末尾和下一窗口开头各通过一整份额度。
- 排查方法:按秒观察通过量,而不是只看每分钟汇总。
- 修复方案:使用令牌桶、滑动窗口或增加秒级子窗口。
- 如何预防:限流设计同时定义平均速率和允许突发量。
8.6 滑动日志导致 Redis 延迟尖刺
- 现象:限流脚本偶尔执行数十毫秒,其他命令尾延迟同步上升。
- 根因:热点用户的 Sorted Set 中积累大量元素,一次脚本删除大量过期记录。
- 排查方法:检查 Slow Log、脚本耗时、
ZCARD、Key 内存和单位窗口请求数。 - 修复方案:限制集合长度、分批清理,或改用滑动窗口计数和令牌桶。
- 如何预防:为每个限流主体设置最大日志容量,压测恶意突发场景。
8.7 外部操作成功,但幂等结果未落 Redis
- 现象:支付已经成功,但请求重试后又触发一次支付。
- 根因:支付完成与 Redis
SUCCEEDED状态写入之间发生进程崩溃。 - 排查方法:按业务流水号检查支付记录、Redis 状态和服务重启时间。
- 修复方案:下游支付接口使用同一幂等流水号;数据库事务中记录执行结果。
- 如何预防:关键副作用的幂等依据必须落在权威持久层,Redis 只作加速。
8.8 延迟任务弹出后消费者崩溃
- 现象:任务从 Sorted Set 消失,但实际邮件或通知没有发送。
- 根因:消费者先
ZREM,后执行任务;删除后进程崩溃。 - 排查方法:比较任务表、ZSet、消费者日志和执行结果记录。
- 修复方案:原子移动到
processing集合,成功后 ACK,超时后重新入队。 - 如何预防:任务负载持久化,消费者幂等,监控 processing 超时数量。
9. 方案选型与权衡
9.1 并发控制方案比较
| 方案 | 主要保证 | 优点 | 主要局限 | 适用场景 |
|---|---|---|---|---|
| 数据库唯一约束 | 相同唯一键只能成功一次 | 简单、接近权威数据 | 冲突压力落到数据库 | 创建订单、业务流水去重 |
| 数据库条件更新/CAS | 只允许符合版本的写入 | 直接保护业务状态 | 需要修改数据模型 | 库存、状态机、版本更新 |
| 单 Redis 租期锁 | 单实例租期内减少并发 | 延迟低、实现简单 | 故障转移、暂停、过期所有者 | 缓存重建、可补偿任务 |
| Redis 锁加 Fencing | 下游可拒绝旧持有者 | 比单独锁更稳健 | 下游必须支持条件写 | 长任务、共享资源更新 |
| Redlock | 多独立 Redis 节点多数派租约 | 避免普通主从切换问题 | 实现复杂,仍有时序假设 | 可容忍少量异常重叠的任务 |
| 共识协调服务 | 更强的会话和顺序语义 | 适合协调、选主 | 运维和延迟成本更高 | 正确性关键的分布式协调 |
| 幂等状态机 | 重复请求返回同一结果 | 适合重试和超时 | 跨系统仍需事务设计 | API、支付请求、消息消费 |
最常见的生产组合不是“只选一个”,而是:
Redis 锁减少竞争
+ Fencing/版本号拒绝过期写
+ 数据库唯一约束守住最终不变量
+ 幂等结果处理网络重试
9.2 限流方案选择
| 需求 | 推荐方案 | 原因 |
|---|---|---|
| 简单接口防刷 | 固定窗口 | 成本最低,容易理解 |
| 希望减少窗口边界突发 | 滑动窗口计数 | O(1) 空间且较平滑 |
| 必须精确统计任意时间窗口 | 滑动日志 | 保存每次请求,结果精确 |
| 允许一定突发,同时限制长期速率 | 令牌桶 | 桶容量直接表达突发额度 |
| 严格平滑下游处理速度 | 漏桶 | 输出速率稳定 |
| 超高 QPS 全局限流 | 分层令牌桶 | 避免单个 Redis HotKey |
| 单实例内保护 goroutine 数量 | 本地信号量 | 无需每次访问 Redis |
9.3 延迟任务方案选择
| 方案 | 可靠性能力 | 优点 | 局限 |
|---|---|---|---|
| Sorted Set 直接弹出 | 较低 | 简单、延迟低 | 删除后崩溃会丢任务 |
| Sorted Set + processing + ACK | 中高 | 可实现重试和可见性超时 | 应用层逻辑较多 |
| Redis Streams | Pending、ACK、Claim | 消费状态更完整 | 定时调度仍需额外设计 |
| 专业消息队列 | 延迟消息、重试、死信 | 运维和可靠性能力完整 | 引入额外基础设施 |
| 数据库任务表 | 事务和审计方便 | 与业务数据一致 | 扫描、锁竞争和吞吐压力 |
10. 高频面试题
10.1 问题
为什么 Redis 分布式锁要使用 SET key value NX PX,而不是 SETNX 后再 EXPIRE?
推荐回答:
SET NX PX 在一条命令中同时完成“仅不存在时写入”和“设置租期”,避免客户端在 SETNX 成功后、执行 EXPIRE 前崩溃,留下永久锁。单条 Redis 命令具备原子执行语义。
面试官追问:
- 返回超时时,能否认定加锁失败?
EX和PX有什么区别?
常见错误回答:
“因为 Pipeline 可以保证两条命令原子执行。”Pipeline 只减少网络往返,不提供事务原子性。
评分点:
- 初级:知道
NX和过期时间。 - 中级:能说明两条命令间的崩溃窗口。
- 高级:能说明网络超时导致结果不确定,以及后续业务不得直接执行。
10.2 问题
锁的 Value 为什么必须唯一?为什么不能直接 DEL?
推荐回答:
锁可能已经过期并被其他客户端重新获取。旧客户端若直接 DEL,会删除新持有者的锁。每次加锁必须生成唯一 Token,解锁时原子比较 Token,相等才删除。(Redis)
面试官追问:
- Token 用 UUID、时间戳还是进程 ID?
- Redis 8.4 有什么新命令?
常见错误回答:
“只要客户端 ID 唯一就行。”同一客户端连续两次加锁也必须使用不同 Token。
评分点:
- 初级:知道要使用唯一值。
- 中级:能描述锁过期后误删的时间线。
- 高级:能补充 Lua、
DELEX IFEQ和命令超时的不确定性。
10.3 问题
Redis 锁的业务执行时间超过 TTL 会发生什么?
推荐回答:
锁到期后其他客户端可以重新获取,但旧客户端可能仍在运行,于是临界区发生重叠。扩大 TTL 或续期只能降低概率,关键写操作还应使用 Fencing Token、版本号或数据库条件更新拒绝旧持有者。
面试官追问:
- 怎样确定合理 TTL?
- 续期失败后业务线程应该做什么?
常见错误回答:
“Redis 会自动终止旧客户端的业务代码。”Redis 只能让 Key 过期,无法终止应用进程。
评分点:
- 初级:知道 TTL 到期会释放锁。
- 中级:知道旧客户端仍可能继续执行。
- 高级:能设计截止时间、受限续期和下游防护。
10.4 问题
自动续期有哪些风险?
推荐回答:
续期必须校验 Owner Token,并且应绑定业务 Context、限制最大续期次数。如果独立看门狗无限续期,而业务线程已经卡死,锁可能长期无法释放。续期失败也意味着所有权可能丢失,业务必须停止产生新的副作用。
面试官追问:
- 续期应在剩余 TTL 的什么位置触发?
- GC 暂停是否会让续期失效?
常见错误回答:
“只要每隔 TTL/2 执行一次 EXPIRE 就一定安全。”
评分点:
- 初级:知道需要续期。
- 中级:知道续期需要比较 Token。
- 高级:能讨论调度暂停、最大租期、取消传播和过期所有者。
10.5 问题
什么是 Fencing Token?
推荐回答:
Fencing Token 是每次成功获得执行权时分配的单调递增序号。下游资源保存已接受的最大序号,只接受更大的 Token。这样,旧客户端即使在锁过期后恢复运行,也会因为 Token 较小而被拒绝。
面试官追问:
- 随机锁 Token 能否代替 Fencing Token?
- Redis
INCR产生的 Token 在切主后是否严格单调?
常见错误回答:
“只要把递增数字写进锁 Value 就完成了 Fencing。”下游不校验就没有意义。
评分点:
- 初级:知道 Token 递增。
- 中级:能描述下游条件更新。
- 高级:能指出序列生成器本身的一致性要求,以及 Redis 切主回滚问题。
10.6 问题
Redis 主从切换为什么可能破坏锁互斥?
推荐回答:
Redis 复制通常是异步的。若主节点上的锁尚未复制就发生故障,缺少该锁的副本被提升后,另一个客户端可以重新加锁,而原客户端可能仍在执行业务。WAIT 可以缩小窗口,但不能把整个锁和外部业务操作变成强一致事务。
面试官追问:
WAIT和WAITAOF分别改善什么?- Fencing 是否能解决?
常见错误回答:
“有 Sentinel 或 Cluster 后锁就不会丢。”
评分点:
- 初级:知道复制有延迟。
- 中级:能完整说明故障转移时间线。
- 高级:能区分可用性、持久性、互斥安全性和下游防护。
10.7 问题
Redlock 的基本算法是什么?是否安全?
推荐回答:
Redlock 在多个独立 Redis 主节点上用同一随机 Token 获取租期锁,要求在租期内获得多数节点成功。它比普通主从故障转移锁避免了一部分复制丢失问题,但仍依赖时钟和执行时序假设。官方文档也建议长任务使用 Fencing Token,因此不应把 Redlock描述为绝对强一致锁。(Redis)
面试官追问:
- 为什么通常使用 5 个节点?
- Redlock 和 Raft 有什么本质区别?
- 哪些业务可以接受 Redlock?
常见错误回答:
“多数节点成功就等于共识协议。”
评分点:
- 初级:知道多数派获取。
- 中级:知道多个节点必须相互独立且需扣除加锁耗时。
- 高级:能客观说明争议、系统假设、Fencing 和适用边界。
10.8 问题
固定窗口、滑动日志和令牌桶如何选择?
推荐回答:
固定窗口成本最低,但有边界突发;滑动日志能精确统计最近窗口,却需要为每个请求保存元素;令牌桶以固定速率补充令牌,适合控制长期速率并允许可配置突发。选型取决于精确度、内存预算和突发容忍度。
面试官追问:
- 滑动窗口计数为什么是近似的?
- 令牌桶与漏桶有什么区别?
常见错误回答:
“令牌桶和漏桶完全一样。”
评分点:
- 初级:知道至少两种算法。
- 中级:能比较精确度和复杂度。
- 高级:能结合业务突发、HotKey 和分层限流设计。
10.9 问题
为什么限流的 INCR 和 EXPIRE 要使用 Lua?
推荐回答:
若两条命令分开发送,客户端可能在 INCR 后崩溃,导致计数 Key 没有 TTL。Lua 可以原子完成计数和过期设置。Redis 8.8+ 的 INCREX 可以对部分固定窗口场景使用一条原生命令完成增量、上限和过期控制。(Redis)
面试官追问:
- Lua 脚本为什么不能过长?
- Cluster 中脚本 Key 有什么限制?
常见错误回答:
“Lua 是多线程执行,所以更快。”
评分点:
- 初级:知道 Lua 保证原子性。
- 中级:知道中间状态和崩溃窗口。
- 高级:能说明脚本阻塞、Slot 限制和版本替代命令。
10.10 问题
幂等 Key 应该只保存一个“已处理”标记吗?
推荐回答:
通常不够。更完整的设计应保存请求指纹、PROCESSING 状态、Owner Token、成功结果和不同 TTL。重复请求若已经成功,应返回缓存结果;若正在处理,应返回处理中或短暂等待;同一 Key 对应不同参数时应拒绝。
面试官追问:
PROCESSING过期后能否直接重试?- 结果缓存多长时间?
常见错误回答:
“只要 SETNX idempotency-key 1 就绝对不会重复执行。”
评分点:
- 初级:知道唯一请求 ID。
- 中级:能设计状态机和结果缓存。
- 高级:能分析参数指纹、Owner 转移和外部副作用崩溃窗口。
10.11 问题
Redis 幂等状态能否保证支付恰好执行一次?
推荐回答:
不能单独保证。支付成功和 Redis 状态更新属于两个系统,没有共同事务。服务可能在支付成功后、写入成功状态前崩溃。关键业务应让支付接口使用稳定业务流水号,并在数据库事务中保存唯一幂等记录。
面试官追问:
- 怎样结合 Transactional Outbox?
- Redis 在该方案中还有什么价值?
常见错误回答:
“Redis 是单线程,所以跨数据库操作也是原子的。”
评分点:
- 初级:知道要防重复。
- 中级:知道跨系统事务问题。
- 高级:能设计权威幂等表、唯一约束、消息投递和结果查询。
10.12 问题
如何使用 Sorted Set 实现延迟任务?有哪些可靠性问题?
推荐回答:
用任务执行时间作为 Score,用任务 ID 作为 Member;消费者查询 score <= now 的任务并原子认领。若直接删除后执行,消费者崩溃会丢任务;若执行后删除则可能重复。生产方案应采用 processing 集合、可见性超时、ACK、重试和幂等消费。
面试官追问:
- 为什么不能直接使用
BZPOPMIN等待任务到期? - 怎样处理消费者宕机?
- 什么时候改用 Streams 或专业 MQ?
常见错误回答:
“BZPOPMIN 会一直等待到最小 Score 对应的时间。”
评分点:
- 初级:知道 Score 保存执行时间。
- 中级:能分析丢失与重复。
- 高级:能设计 claim/ack、重试、死信和容量治理。
10.13 问题
Go 调用 Redis 锁命令超时后应该怎样处理?
推荐回答:
超时意味着结果未知,不能简单认定加锁失败并立即重试,也不能开始执行业务。应保留本次 Token,尝试条件清理,并在随机退避后重新获取。业务副作用还必须具备幂等性或 Fencing 校验。
面试官追问:
go-redis客户端能否被多个 goroutine 共享?- 是否应该对超时错误自动重试?
常见错误回答:
“只要返回 context deadline exceeded,Redis 就一定没有执行。”
评分点:
- 初级:知道设置 Context 超时。
- 中级:能区分
redis.Nil和连接错误。 - 高级:能解释不确定执行结果、重试幂等和连接池复用。
11. 一分钟面试回答
Redis 分布式锁通常使用 SET key random-token NX PX ttl 获取,Token 必须每次请求唯一,释放时用 Lua 比较 Token 后删除,Redis 8.4+ 也可以使用 DELEX IFEQ。锁本质上是租约,业务超过租期、客户端暂停、网络分区或主从切换时,都可能出现旧持有者继续执行,因此不能把 Redis 锁说成绝对强一致锁。长任务应使用受限续期,并通过单调递增的 Fencing Token 让下游拒绝旧请求。Redlock 在多个独立 Redis 节点上获取多数派租约,比普通主从切换方案更可靠,但仍有时钟和暂停方面的争议。限流需要按需求选择固定窗口、滑动窗口、滑动日志、令牌桶或漏桶,并使用 Lua 保证复合操作原子性;Redis 8.8 的 INCREX 能简化固定窗口。幂等需要请求指纹、执行状态和结果缓存,关键副作用还必须依赖数据库唯一约束。Sorted Set 可以调度延迟任务,但生产环境需要 claim、ACK、可见性超时、重试和幂等消费。
12. 本章总结
SET NX PX解决的是原子获取和自动释放,但它建立的是租期,而不是永久所有权。- 随机 Token 防止误删,Fencing Token 防止过期持有者继续修改下游,两者不能相互替代。
- 自动续期必须校验所有者、绑定业务生命周期并设置最大续期边界。
- Redis 主从切换可能丢失尚未复制的锁,
WAIT和持久化等待只能缩小窗口,不能提供跨系统强一致性。 - Redlock 应结合业务容错能力评估,不能因为采用多数节点就称为共识协议。
- 限流算法的核心权衡是精确度、突发能力、内存和热点压力。
- 幂等状态机应保存请求指纹和结果,但关键副作用仍需由权威持久层保证。
- Sorted Set 只提供按 Score 排序,不自带 ACK 和故障恢复;可靠任务必须允许重复并让消费者幂等。
- 网络超时意味着执行结果可能未知,任何自动重试都必须先分析幂等语义。
- 正确的生产方案通常是 Redis 降低竞争、数据库守住不变量、Fencing 拒绝旧写、幂等处理重复请求。
13. 自测清单
- 为什么
SETNX和PEXPIRE分开执行可能留下永久锁? - 为什么锁 Token 必须按每次加锁请求生成,而不能按进程生成?
- 业务超过锁 TTL 后,旧客户端为什么仍可能修改数据库?
- 自动续期失败时,业务线程应采取什么措施?
- 随机 Owner Token 与 Fencing Token 分别解决什么问题?
- Redis 主从故障转移为什么可能产生两个锁持有者?
- Redlock 与基于 Raft 的协调系统有哪些核心区别?
- 固定窗口为什么可能在边界处允许两倍流量?
- 幂等状态写入 Redis 与数据库业务提交之间有哪些崩溃窗口?
- 怎样把一个简单 Sorted Set 延迟队列改造成带认领、ACK 和超时重试的可靠队列?
14. 官方资料
- Redis 官方:Distributed Locks with Redis。(Redis)
- Redis 官方:
SET命令及锁模式说明。(Redis) - Redis 官方:
DELEX命令,Redis 8.4+。(Redis) - Redis 官方:
INCREX命令,Redis 8.8+。(Redis) - Redis 官方:Lua Scripting 文档。(Redis)
- Redis 官方:
ZPOPMIN与BZPOPMIN命令。(Redis) - Redis 官方:
XAUTOCLAIM命令。(Redis) - Redis 官方:
WAIT与WAITAOF命令。(Redis) - Redis 官方 GitHub:
redis/go-redis。(GitHub)