返回文章列表

分布式锁、限流、幂等与并发控制

系统梳理 Redis 分布式锁、Fencing Token、Redlock、限流算法、幂等状态机、延迟任务与 Go 客户端实践。

第 19 章:分布式锁、限流、幂等与并发控制

1. 本章定位

前面的章节解决了缓存、复制、集群和高并发访问问题,本章进一步讨论:多个进程同时修改同一份业务状态时,怎样减少冲突、限制流量,并控制重复执行。

本章涉及四类不同问题:

  1. 分布式锁:尽量避免多个客户端同时执行临界区。
  2. 限流:控制单位时间内允许通过的请求量。
  3. 幂等:让同一业务请求重复执行时,得到可预测的结果。
  4. 延迟任务:让任务在指定时间之后被调度,同时处理丢失和重复消费。

这四类机制经常同时出现,但不能混为一谈。例如,分布式锁不能自动实现幂等,幂等也不能完全替代并发控制;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. 学习目标

学完本章后,应当能够:

  1. 使用 SET key value NX PX 正确获取一个带租期的 Redis 锁。
  2. 解释锁值为什么必须在每次加锁时唯一,并使用 Lua 或 DELEX 安全解锁。
  3. 分析业务执行超过锁租期、客户端暂停、网络分区和主从切换带来的风险。
  4. 说明 Fencing Token 的工作原理,并设计下游的令牌校验。
  5. 客观说明 Redlock 的基本算法、争议及适用边界。
  6. 比较固定窗口、滑动窗口、滑动日志、令牌桶和漏桶限流算法。
  7. 使用 Redis 构建包含执行状态和结果缓存的幂等状态机。
  8. 说明 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 实例接受了该客户端的所有权声明。
  • 返回空值:锁已经被其他客户端占用。

NXPX 必须放在同一个 SET 命令中。若先执行 SETNX,再单独执行 PEXPIRE,客户端可能在两条命令之间崩溃,留下永不过期的锁。

Redis 官方文档也明确推荐使用随机 Token,并在解锁时比较当前值,避免旧客户端删除新持有者的锁。(Redis)


3.3 锁值为什么必须唯一

考虑下面的时间线:

  1. 客户端 A 获取锁,锁值为固定字符串 "locked",租期 10 秒。
  2. A 因为进程暂停,超过 10 秒仍未完成。
  3. 锁自动过期。
  4. 客户端 B 获取同一个锁,锁值仍然为 "locked"
  5. A 恢复运行并执行 DEL lock-key
  6. B 的锁被 A 错误删除。

如果每次请求使用唯一 Token,例如 128 位随机数,那么 A 解锁时可以检查:

当前锁值是否仍等于 A 的 Token?

不相等就说明锁已经过期或所有权已经转移,A 无权删除。

Token 应当满足:

  • 每次加锁请求唯一,而不是每个客户端进程固定一个值。
  • 不容易碰撞或被猜测。
  • 长度适中,例如 128 位随机数。
  • 不使用容易重复的时间戳、进程 ID 或机器名作为唯一来源。

3.4 解锁必须是“比较并删除”的原子操作

下面的写法仍然不安全:

GET lock-key
DEL lock-key

因为在 GETDEL 之间,锁可能已经过期并被另一个客户端重新获取。

Redis 6、7 及需要兼容历史版本的系统通常使用 Lua:

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0

Lua 脚本在 Redis 内原子执行,因此其他命令不会插入 GETDEL 之间。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

续期必须满足:

  1. 只有当前 Token 匹配时才续期。
  2. 续期必须早于锁到期时间。
  3. 一旦续期失败,业务逻辑应尽快停止或进入只读、补偿状态。
  4. 必须限制最大续期次数或最大总持有时间。
  5. 续期协程必须随业务请求结束而退出。

无限续期会让租期锁退化为可能永远不释放的锁。更危险的是,业务线程可能已经卡死,但独立看门狗仍持续续期,导致其他客户端永久无法取得锁。

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 主从复制:

  1. 客户端 A 在主节点写入锁。
  2. 锁还没复制到副本。
  3. 主节点故障。
  4. 不包含该锁的副本被提升为新主节点。
  5. 客户端 B 在新主节点成功获得同一个锁。
  6. A 可能仍在执行业务。

这不是命令原子性问题,而是异步复制和故障转移之间的数据丢失窗口。Redis 官方分布式锁文档专门指出,简单依赖主从切换会破坏互斥安全性。(Redis)

WAIT 可以等待写入被一定数量的副本确认,WAITAOF 可以等待 AOF 刷盘,但它们主要改善数据安全窗口,并不会让 Redis 变成绝对强一致系统,也不能阻止已经过期的客户端继续操作下游资源。(Redis)


3.10 Redlock 的基本思路与争议

Redlock 使用多个互相独立的 Redis 主节点,典型数量为 5:

  1. 客户端生成唯一随机 Token。
  2. 记录开始时间。
  3. 依次或并行尝试在多个独立 Redis 节点获取同一个锁。
  4. 获得多数节点,例如 5 个节点中的至少 3 个。
  5. 整体耗时必须小于锁租期。
  6. 有效租期约为原始租期减去加锁耗时和时钟漂移余量。
  7. 失败或解锁时,对全部实例执行比较并删除。

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:

  • PROCESSING TTL:略大于业务最大执行时间,防止执行者崩溃后永久占用。
  • SUCCEEDED TTL:由业务重试周期决定,可能是数小时或数天。

需要特别注意下面的崩溃窗口:

  1. 数据库支付成功。
  2. 服务尚未把 Redis 状态改成 SUCCEEDED
  3. 服务进程崩溃。
  4. PROCESSING 过期。
  5. 重试请求再次执行支付。

因此,Redis 幂等状态不能单独保证跨 Redis 和数据库的“恰好一次”。资金、订单等关键操作还应使用:

  • 数据库唯一约束。
  • 事务内幂等记录。
  • 业务流水号。
  • Transactional Outbox。
  • 下游接口自身的幂等能力。

3.14 Sorted Set 延迟任务

基本模型为:

ZADD delay:{email} execute_at_ms task-id

其中:

  • Member 是唯一任务 ID。
  • Score 是计划执行时间。
  • 任务正文可以存储在数据库、Hash 或对象存储中。

消费者查询:

score <= 当前时间

然后原子地认领任务。

直接使用 ZPOPMINBZPOPMIN 存在一个常见误区:它们弹出的是当前最小 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 调用发生超时时,可能存在两种情况:

  1. Redis 没收到请求。
  2. 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 位时可视为常数。
  • INCR Fencing 计数器:平均 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 查找或删除:利用字典。
  • 按执行时间排序和范围查询:利用跳表。

典型复杂度为:

  • ZADDO(log N)
  • 查找最早到期任务:O(log N + M)
  • ZREMO(log N)
  • ZPOPMIN count:与弹出数量及集合规模相关。

这种结构适合“按时间排序”,但本身没有 ACK、消费者归属和重试次数语义,这些可靠性状态需要应用层额外实现。


6.6 版本差异

Redis 版本本章相关变化
Redis 2.6.12+SET 支持 NXXXEXPX 等组合选项
Redis 5.0+Streams、ZPOPMINBZPOPMIN
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 可以在主节点故障后恢复服务,但若锁写入尚未复制,故障转移可能使锁消失。可用性恢复和锁强一致性是两个不同目标。

WAITWAITAOF 不是互斥证明

可以在加锁后增加复制或落盘等待,缩小锁丢失窗口,但仍不能解决:

  • 客户端在租期后继续运行。
  • 下游资源不校验过期所有者。
  • 网络分区。
  • 应用收到超时后无法确定命令结果。
  • 多个系统之间缺少共同事务。

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 StreamsPending、ACK、Claim消费状态更完整定时调度仍需额外设计
专业消息队列延迟消息、重试、死信运维和可靠性能力完整引入额外基础设施
数据库任务表事务和审计方便与业务数据一致扫描、锁竞争和吞吐压力

10. 高频面试题

10.1 问题

为什么 Redis 分布式锁要使用 SET key value NX PX,而不是 SETNX 后再 EXPIRE

推荐回答:

SET NX PX 在一条命令中同时完成“仅不存在时写入”和“设置租期”,避免客户端在 SETNX 成功后、执行 EXPIRE 前崩溃,留下永久锁。单条 Redis 命令具备原子执行语义。

面试官追问:

  • 返回超时时,能否认定加锁失败?
  • EXPX 有什么区别?

常见错误回答:

“因为 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 可以缩小窗口,但不能把整个锁和外部业务操作变成强一致事务。

面试官追问:

  • WAITWAITAOF 分别改善什么?
  • Fencing 是否能解决?

常见错误回答:

“有 Sentinel 或 Cluster 后锁就不会丢。”

评分点:

  • 初级:知道复制有延迟。
  • 中级:能完整说明故障转移时间线。
  • 高级:能区分可用性、持久性、互斥安全性和下游防护。

10.7 问题

Redlock 的基本算法是什么?是否安全?

推荐回答:

Redlock 在多个独立 Redis 主节点上用同一随机 Token 获取租期锁,要求在租期内获得多数节点成功。它比普通主从故障转移锁避免了一部分复制丢失问题,但仍依赖时钟和执行时序假设。官方文档也建议长任务使用 Fencing Token,因此不应把 Redlock描述为绝对强一致锁。(Redis)

面试官追问:

  • 为什么通常使用 5 个节点?
  • Redlock 和 Raft 有什么本质区别?
  • 哪些业务可以接受 Redlock?

常见错误回答:

“多数节点成功就等于共识协议。”

评分点:

  • 初级:知道多数派获取。
  • 中级:知道多个节点必须相互独立且需扣除加锁耗时。
  • 高级:能客观说明争议、系统假设、Fencing 和适用边界。

10.8 问题

固定窗口、滑动日志和令牌桶如何选择?

推荐回答:

固定窗口成本最低,但有边界突发;滑动日志能精确统计最近窗口,却需要为每个请求保存元素;令牌桶以固定速率补充令牌,适合控制长期速率并允许可配置突发。选型取决于精确度、内存预算和突发容忍度。

面试官追问:

  • 滑动窗口计数为什么是近似的?
  • 令牌桶与漏桶有什么区别?

常见错误回答:

“令牌桶和漏桶完全一样。”

评分点:

  • 初级:知道至少两种算法。
  • 中级:能比较精确度和复杂度。
  • 高级:能结合业务突发、HotKey 和分层限流设计。

10.9 问题

为什么限流的 INCREXPIRE 要使用 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. 本章总结

  1. SET NX PX 解决的是原子获取和自动释放,但它建立的是租期,而不是永久所有权。
  2. 随机 Token 防止误删,Fencing Token 防止过期持有者继续修改下游,两者不能相互替代。
  3. 自动续期必须校验所有者、绑定业务生命周期并设置最大续期边界。
  4. Redis 主从切换可能丢失尚未复制的锁,WAIT 和持久化等待只能缩小窗口,不能提供跨系统强一致性。
  5. Redlock 应结合业务容错能力评估,不能因为采用多数节点就称为共识协议。
  6. 限流算法的核心权衡是精确度、突发能力、内存和热点压力。
  7. 幂等状态机应保存请求指纹和结果,但关键副作用仍需由权威持久层保证。
  8. Sorted Set 只提供按 Score 排序,不自带 ACK 和故障恢复;可靠任务必须允许重复并让消费者幂等。
  9. 网络超时意味着执行结果可能未知,任何自动重试都必须先分析幂等语义。
  10. 正确的生产方案通常是 Redis 降低竞争、数据库守住不变量、Fencing 拒绝旧写、幂等处理重复请求。

13. 自测清单

  1. 为什么 SETNXPEXPIRE 分开执行可能留下永久锁?
  2. 为什么锁 Token 必须按每次加锁请求生成,而不能按进程生成?
  3. 业务超过锁 TTL 后,旧客户端为什么仍可能修改数据库?
  4. 自动续期失败时,业务线程应采取什么措施?
  5. 随机 Owner Token 与 Fencing Token 分别解决什么问题?
  6. Redis 主从故障转移为什么可能产生两个锁持有者?
  7. Redlock 与基于 Raft 的协调系统有哪些核心区别?
  8. 固定窗口为什么可能在边界处允许两倍流量?
  9. 幂等状态写入 Redis 与数据库业务提交之间有哪些崩溃窗口?
  10. 怎样把一个简单 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 官方:ZPOPMINBZPOPMIN 命令。(Redis)
  • Redis 官方:XAUTOCLAIM 命令。(Redis)
  • Redis 官方:WAITWAITAOF 命令。(Redis)
  • Redis 官方 GitHub:redis/go-redis。(GitHub)