3. Redis 基础回顾:核心数据结构与事务脚本

在进入熔断窗口与令牌桶实战之前,必须夯实 Redis 基础。本章将系统回顾 Redis 的四种核心数据结构(String、List、Hash、Sorted Set),以及实现原子性操作的关键工具——Redis 事务与 Lua 脚本。这些知识是后续实现令牌桶算法、管理熔断状态、处理并发请求的基石。

3.1 核心数据结构概览

Redis 提供了丰富的数据结构,每种结构都有其独特的适用场景。下表对比了本课程最常用的四种结构:

数据结构 底层实现 核心命令 典型应用场景 令牌桶中的角色
String 动态字符串 (SDS) SET, GET, INCR, DECR, SETEX 缓存、计数器、分布式锁 存储令牌桶的当前令牌数、熔断器状态
List 双向链表 / 压缩列表 LPUSH, RPUSH, LPOP, BRPOP, LLEN 消息队列、最新列表、时间线 存储熔断窗口内的请求时间戳(滑动窗口)
Hash 哈希表 / 压缩列表 HSET, HGET, HINCRBY, HGETALL 对象存储、用户属性、配置信息 存储熔断器的多维状态(失败次数、成功次数、窗口起始时间)
Sorted Set 跳表 + 哈希表 ZADD, ZRANGE, ZREMRANGEBYSCORE, ZCARD 排行榜、延时队列、范围查询 按时间戳排序的请求记录,用于精确的滑动窗口统计
选型建议: 在熔断与限流场景中,String 用于快速读写单值状态(如当前令牌数),List 和 Sorted Set 用于记录时间序列数据(如请求时间戳),Hash 用于聚合多个相关属性(如熔断器的失败计数、成功计数、状态码)。

3.2 数据结构详解与实战代码

3.2.1 String:令牌桶的核心计数器

String 是 Redis 中最基础的结构,支持原子性的增减操作,非常适合实现令牌桶中的令牌计数器。

# 初始化令牌桶:容量 100,当前令牌数 100
SET token_bucket:myapi:current_tokens 100
SET token_bucket:myapi:max_tokens 100
SET token_bucket:myapi:last_refill_time 1700000000

# 原子性减少令牌(尝试获取一个令牌)
DECR token_bucket:myapi:current_tokens

# 原子性增加令牌(定时补充)
INCR token_bucket:myapi:current_tokens

# 带过期时间的熔断器状态(30秒后自动重置)
SETEX circuit_breaker:myapi:state 30 "open"
关键点: INCR 和 DECR 是原子操作,但令牌桶的“补充令牌”和“获取令牌”需要组合多个命令,此时必须借助 Lua 脚本保证原子性。

3.2.2 List:滑动窗口的时间戳容器

List 的 LPUSH 和 RPOP 操作可以高效地维护一个时间序列队列。在熔断窗口中,我们使用 List 存储每个请求的时间戳,通过修剪过期的时间戳来判断窗口内的请求量。

# 记录请求时间戳(当前时间戳 1700000100)
LPUSH request_log:myapi 1700000100

# 修剪窗口:只保留最近 10 秒内的请求(假设窗口大小为 10 秒)
# 先获取当前时间,然后移除所有小于 (当前时间 - 10) 的时间戳
# 注意:Lua 脚本中实现更安全,这里仅展示命令思路
LTRIM request_log:myapi 0 9999  # 保留所有,后续在应用层或 Lua 中过滤

# 获取窗口内的请求数量
LLEN request_log:myapi
注意: 使用 List 实现滑动窗口时,需要在每次请求时执行“移除过期时间戳”的操作。如果窗口内请求量极大,LTRIM 可能无法精确按值删除,此时推荐使用 Sorted Set 替代。

3.2.3 Hash:熔断器的多维状态存储

熔断器需要记录多个状态字段:当前状态(closed/open/half-open)、失败次数、成功次数、窗口起始时间等。Hash 结构可以完美地将这些字段聚合在一个 key 下。

# 初始化熔断器状态
HSET circuit_breaker:myapi state "closed" \
                         failure_count 0 \
                         success_count 0 \
                         window_start 1700000000 \
                         threshold_failure 5 \
                         timeout 30

# 原子性增加失败次数
HINCRBY circuit_breaker:myapi failure_count 1

# 读取所有状态
HGETALL circuit_breaker:myapi

# 读取单个字段
HGET circuit_breaker:myapi state

3.2.4 Sorted Set:精确的滑动窗口统计

Sorted Set 的每个成员都有一个分数(score),我们可以将请求时间戳作为分数,请求 ID 或唯一标识作为成员。通过 ZREMRANGEBYSCORE 可以精确移除窗口外的记录,ZCARD 获取窗口内请求总数。

# 记录请求:成员为唯一请求ID,分数为时间戳
ZADD request_log:myapi 1700000100 "req_001"
ZADD request_log:myapi 1700000105 "req_002"
ZADD request_log:myapi 1700000115 "req_003"

# 移除窗口外的记录(窗口大小 10 秒,当前时间 1700000120)
ZREMRANGEBYSCORE request_log:myapi 0 1700000110

# 获取窗口内请求数量
ZCARD request_log:myapi

# 获取窗口内所有请求(用于调试)
ZRANGE request_log:myapi 0 -1 WITHSCORES
推荐: 在熔断窗口的滑动统计中,Sorted Set 是最佳选择。它提供了精确的范围删除和计数功能,且时间复杂度为 O(log N),性能优异。

3.3 Redis 事务与 Lua 脚本:保证原子性

在令牌桶和熔断器场景中,多个 Redis 命令必须作为一个原子单元执行。例如,“检查当前令牌数是否足够 → 减少令牌 → 记录请求”这三个步骤不能被打断。Redis 提供了两种原子性方案:事务(MULTI/EXEC)和 Lua 脚本。

3.3.1 Redis 事务(MULTI/EXEC)

Redis 事务通过 MULTI 开启,将后续命令排队,最后通过 EXEC 一次性执行。事务保证所有命令按顺序执行,且不会被其他客户端的命令插入。

# 事务示例:尝试获取令牌并记录请求
MULTI
DECR token_bucket:myapi:current_tokens
LPUSH request_log:myapi 1700000100
EXEC
事务的局限性: Redis 事务不支持回滚。如果事务中的某个命令失败(例如对 String 执行 LPUSH),后续命令仍会执行。此外,事务无法实现“先检查后执行”的逻辑(例如:先检查令牌数是否大于 0,再减少),因为 WATCH 的乐观锁机制在竞争激烈时效率较低。

3.3.2 Lua 脚本:原子性与逻辑的完美结合

Lua 脚本在 Redis 中作为原子操作执行,脚本内的所有命令要么全部成功,要么全部不执行(在脚本执行期间,Redis 不会处理其他命令)。这使得 Lua 脚本成为实现令牌桶和熔断器逻辑的首选方案。

-- Lua 脚本:原子性获取令牌
-- KEYS[1] = token_bucket:myapi:current_tokens
-- KEYS[2] = token_bucket:myapi:max_tokens
-- KEYS[3] = token_bucket:myapi:last_refill_time
-- ARGV[1] = 当前时间戳
-- ARGV[2] = 补充速率(每秒补充令牌数)

local current = redis.call('GET', KEYS[1])
local max_tokens = redis.call('GET', KEYS[2])
local last_refill = redis.call('GET', KEYS[3])

if not current or not max_tokens or not last_refill then
    return -1  -- 初始化失败
end

-- 计算需要补充的令牌数
local elapsed = tonumber(ARGV[1]) - tonumber(last_refill)
local tokens_to_add = math.floor(elapsed * tonumber(ARGV[2]))
if tokens_to_add > 0 then
    current = math.min(tonumber(current) + tokens_to_add, tonumber(max_tokens))
    redis.call('SET', KEYS[1], current)
    redis.call('SET', KEYS[3], ARGV[1])
end

-- 尝试获取令牌
if tonumber(current) >= 1 then
    redis.call('DECR', KEYS[1])
    return 1  -- 获取成功
else
    return 0  -- 获取失败
end
Lua 脚本的优势:
  • 原子性: 整个脚本作为一个原子操作执行,无需担心并发竞争。
  • 减少网络开销: 将多个命令打包在一个脚本中,只需一次网络往返。
  • 逻辑封装: 可以在脚本中实现复杂的业务逻辑(如令牌补充计算、状态判断)。
  • 可复用: 脚本通过 EVALSHA 缓存,后续调用只需传递 SHA 值,性能更高。

3.4 数据结构与事务的关系图

下图展示了在熔断窗口与令牌桶场景中,Redis 数据结构与 Lua 脚本的协作关系:

Redis 数据结构与 Lua 脚本协作架构 应用层(Java/Python/Go) Lua 脚本(原子性执行) EVALSHA / EVAL String 令牌数、状态、时间戳 SET / GET / INCR / DECR List 请求时间戳队列 LPUSH / LTRIM / LLEN Sorted Set 精确滑动窗口 ZADD / ZREMRANGEBYSCORE Hash 熔断器多维状态 HSET / HINCRBY / HGETALL 核心原则: 所有对数据结构的读写操作必须通过 Lua 脚本封装,以保证原子性。应用层不直接调用 Redis 命令,而是调用 Lua 脚本。

3.5 本章小结

  • String 是令牌桶计数器和熔断器状态的首选,支持原子增减。
  • List 和 Sorted Set 均可实现滑动窗口,但 Sorted Set 更精确、更易维护。
  • Hash 适合存储熔断器的多维状态,减少 key 数量,方便管理。
  • Lua 脚本 是保证多命令原子性的最佳方案,在令牌桶和熔断器逻辑中必须使用。
  • Redis 事务(MULTI/EXEC)虽然提供原子性,但缺乏回滚和条件判断能力,在复杂业务中不如 Lua 脚本灵活。