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 | 排行榜、延时队列、范围查询 | 按时间戳排序的请求记录,用于精确的滑动窗口统计 |
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
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
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
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
- 原子性: 整个脚本作为一个原子操作执行,无需担心并发竞争。
- 减少网络开销: 将多个命令打包在一个脚本中,只需一次网络往返。
- 逻辑封装: 可以在脚本中实现复杂的业务逻辑(如令牌补充计算、状态判断)。
- 可复用: 脚本通过
EVALSHA缓存,后续调用只需传递 SHA 值,性能更高。
3.4 数据结构与事务的关系图
下图展示了在熔断窗口与令牌桶场景中,Redis 数据结构与 Lua 脚本的协作关系:
3.5 本章小结
- String 是令牌桶计数器和熔断器状态的首选,支持原子增减。
- List 和 Sorted Set 均可实现滑动窗口,但 Sorted Set 更精确、更易维护。
- Hash 适合存储熔断器的多维状态,减少 key 数量,方便管理。
- Lua 脚本 是保证多命令原子性的最佳方案,在令牌桶和熔断器逻辑中必须使用。
- Redis 事务(MULTI/EXEC)虽然提供原子性,但缺乏回滚和条件判断能力,在复杂业务中不如 Lua 脚本灵活。