Skip to content

Redis 缓存进阶 ​

一个 key 每秒被读十万次,与一个 key 装着几百万个元素,会给 Redis 带来不同的问题。前者可能压满一个分片的 CPU 或网络,后者可能让一次命令、传输、删除或复制变得很重。即使把热点搬进本地缓存,接下来还要回答如何失效;即使给重建加了锁,也要回答旧持有者暂停后回来会不会继续写。

本文从热 key、大 key 的定位和治理讲起,再把本地加分布式的多级缓存、SET NX PX 锁、续期与 Redlock 争议连起来。重点是每次优化到底减少了什么,以及它把责任移到了哪里。

Redis 实验固定为 8.2.2、Linux/amd64、libc 构建,使用临时单节点。文末真实复现了旧持有者误删新锁,以及校验令牌后的拒绝;fencing 部分是明确标注的本地资源模型,没有进行 Redlock、共识或集群故障验证。

热与大是两条轴,治理前先量清楚 ​

维度热 key大 key
核心定义某段时间内访问或消耗占比过高单个键关联的数据量或操作成本过高
应量什么QPS、读写比例、CPU 时间、返回带宽、热点持续时间字节数、成员数、最大成员、序列化大小、单次命令耗时
常见表象一台节点很忙,其他节点空闲少数操作尾延迟高、输出缓冲大、迁移慢
不充分的指标只数请求次数,不看每次成本只看键数,或把集合成员数当字节数
典型治理方向本地缓存、读副本、复制只读内容、拆分可合并写分片、分页、限制单次返回、异步释放、减小内容

不存在跨业务统一的“超过 10 KB 就算大”“超过一千 QPS 就算热”。10 KB 在低流量下可能无碍,在高频全量返回时却耗费大量带宽;一个巨大的冷对象可能占空间,但不一定是当前延迟事故来源。阈值应该从实例容量和请求目标反推。

同一个 key 可以同时又热又大。此时只减少命令次数可能还不够,要一起看每次传多少字节。网络输出近似为“请求次数 × 平均返回字节”,这是容量估算,不包含协议、TLS 和重传等额外开销。

怎么发现,而不在排查时再制造一次高峰 ​

先用应用侧知道“谁在访问什么” ​

应用最容易把 key 模式关联到业务接口。按采样统计键或键模式的请求量、命中率、结果大小、错误和延迟,能区分“同一用户特别热”“某类列表普遍很大”。不要直接把每个完整 key 都当作监控标签:高基数会拖垮监控,键名还可能含身份或业务敏感信息。通常统计归一化模式,必要时用受控的 top-K 样本定位具体键。

服务端 INFO commandstats 按命令给总体线索,SLOWLOG 帮助寻找执行慢的命令,但不覆盖完整网络传输和客户端排队。一个极热却很快的 GET 可能不进慢日志,一个很大的回复也可能把延迟花在命令执行之后。INFO、SLOWLOG

再用服务端工具核对尺寸与访问模式 ​

工具或命令能看到什么不能据此断言什么
redis-cli --bigkeys按类型扫描较大内容/较多成员的键集合成员数最大不等于内存字节最大
redis-cli --memkeys基于内存估算寻找大键扫描结果不是整个进程 RSS 的完整账本
MEMORY USAGE key某键和值相关的内存估算不包含所有共享缓冲与进程开销;嵌套数据默认采样
OBJECT ENCODING key当前内部表示编码名本身不能预测全部延迟
redis-cli --hotkeys / OBJECT FREQ在 LFU 淘汰策略下读取频率相关信息不是真实精确 QPS;不适用于所有淘汰策略
SCAN / HSCAN / SSCAN / ZSCAN分批遍历COUNT 不是硬返回上限,扫描不是快照,可能重复

这些能力按 8.2.2 redis-cli 源码、MEMORY USAGE、OBJECT FREQ、SCAN 核对。生产扫描仍会占 CPU 与带宽,应限速、观察尾延迟,并在明确授权与容量窗口内运行。不能为了查热点而先把线上淘汰策略随意改成 LFU。

MONITOR 可以看到命令流,但会产生额外开销,也可能暴露命令参数。它适合受控、短时、明确目的的诊断,不宜长期当作无成本监控。KEYS * 则一次遍历键空间,更不应该成为大实例的日常排查入口。

治理热 key:先分清读热还是写热 ​

读热:减少到同一个分片的往返 ​

小而稳定的热点内容适合放入应用本地 L1。多个实例各自保留一份,命中后不用访问 Redis。代价是内存复制和失效传播;内容更新越频繁,省下的读取可能越容易被失效与刷新抵消。

允许旧值时,读副本也能分担读取,但副本复制存在延迟。读到自己刚写的新值、锁状态或其他依赖严格顺序的操作,不应不加区分地切到副本。Redis 复制

对几乎不更新的大热点,也可以显式存多份带后缀的只读副本,读者分散访问。这样把一致性责任交给更新流程:所有副本如何替换,漏更新多久能发现,版本如何切换?如果只是需要一个共享对象,有时 CDN、HTTP 缓存或本地文件比继续在 Redis 中复制更合适。

写热:能不能把一个动作拆成多份 ​

计数器可以按多个分桶写入,读取时再合并;这样把单键写热点换成聚合成本。但是否容许近似或延迟汇总、桶如何重置、读写期间怎样保证边界,都需要定义。库存扣减、全局唯一性或顺序控制不一定能如此拆分,不能把计数器方案不加分析地套上去。

Cluster 把不同键的槽分布到不同主节点,一个 key 仍归属一个槽,并不会自动把这个 key 的请求拆到多台机器。Cluster 规范

治理大 key:同时减小存储单元和操作单元 ​

把一个百万成员 Hash 拆成很多小 Hash,能减小单键操作和迁移的范围;按时间、业务分区或哈希分桶时,需要一套可解释的寻址规则。随之而来的代价是多键读写、聚合、清理和跨槽限制。

另一种问题是数据并不特别大,但每次都 HGETALL、SMEMBERS、LRANGE 0 -1。这时先改为按需取字段、范围分页或分批扫描,可能比改存储结构更有效。扫描期间有写入时,要接受重复、遗漏条件或用业务版本去补偿;它不是自动获得了一致性快照。

删除大键时,DEL 的释放工作可能占用执行路径。UNLINK 先把键从键空间摘除,再把符合条件的内存释放工作交给后台,从而减少前台同步释放成本;它仍有工作量,也不保证 RSS 立即下降。后台释放积压时继续大量删除,仍可能造成内存与 CPU 压力。UNLINK、lazyfree.c

压缩可以降低存储和网络字节,却增加压缩/解压 CPU,并让局部字段读取变成整块处理。选择时要把压缩后大小、压缩率、CPU、尾延迟与更新粒度放在同一组测试里。不要只比较最终压缩包大小。

多级缓存:省下一次网络请求,也多了一份旧值 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["应用实例 A / L1"] -->|"L1 未命中"| R["共享 Redis / L2"]
    B["应用实例 B / L1"] -->|"L1 未命中"| R
    R -->|"L2 未命中,由加载者回源"| D["权威数据库"]
    D -. "提交后失效/变化事件" .-> R
    R -. "失效通知或短 TTL" .-> A
    R -. "失效通知或短 TTL" .-> B
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["应用实例 A / L1"] -->|"L1 未命中"| R["共享 Redis / L2"]
    B["应用实例 B / L1"] -->|"L1 未命中"| R
    R -->|"L2 未命中,由加载者回源"| D["权威数据库"]
    D -. "提交后失效/变化事件" .-> R
    R -. "失效通知或短 TTL" .-> A
    R -. "失效通知或短 TTL" .-> B

L1 的优势是避免网络和序列化,特别适合热点和低变化数据。代价是实例之间不再共享同一份缓存状态:A 更新 Redis 后,B 的 L1 可能仍有旧值;实例扩容和重启又会带来冷启动。

失效方式好处必须补的边界
短 TTL简单,丢通知也会最终重新读取允许一定陈旧;热点周期性刷新,不能无限回源
业务消息 / Pub/Sub可快速通知多个实例通知断连可能丢失;需要重连清空或版本对账
Redis Tracking服务端跟踪读取键并发送失效客户端必须处理失效与回复的竞态、断连清空和协议细节
业务版本 / 代次可以拒绝旧回填和回退版本版本来源与元数据生命周期必须可靠

Redis Client-side caching 的 Tracking 能把读过某键的客户端记下来,键被修改、过期或淘汰时发送失效信息。RESP3 可以在连接上传递推送消息;具体客户端还要处理响应与失效的顺序、缓存填充状态和断连。自己包一个 map,再订阅几条消息,并不自动等价于成熟客户端的实现。Tracking 官方参考

L1/L2 TTL 还可能放大内容年龄:L2 的值已经接近过期,L1 读取后又给它一个全新长 TTL,会继续保留旧数据。需要传递剩余有效期或业务更新时间,设置独立的最大陈旧期。权限或租户相关内容的缓存键还必须包含正确作用域,不能只为提高命中率去掉区分信息。

缓存层数越多,指标也要分层记录:每层命中率、命中内容年龄、失效延迟、重建耗时、实例冷启动回源。总体命中率一个数无法说明哪一层出了问题。

分布式锁:先写下“锁失效会造成什么” ​

如果锁只为避免重复构建缓存,偶尔两个加载者同时工作可能只是浪费资源。如果锁保护扣款、写文件或跨系统状态转移,两个持有者同时写就可能破坏正确性。前者可以选择概率和成本合适的协调方式;后者需要资源端也参与防止旧持有者。

SET NX PX 把创建与租期放进一条命令 ​

单实例上的基本获取方式:

text
SET lock:job <每次获取都不同的随机令牌> NX PX 30000

NX 表示键不存在才创建,PX 给出毫秒租期。两者在一条命令里,避免 SETNX 成功后进程崩溃、还没执行 PEXPIRE 就留下永久锁。令牌用于识别这一次获取,不能只用所有实例共用的常量,也不宜重复使用某台机器固定的名字。SET

没有获得锁与网络超时不同。未获得锁说明服务端明确拒绝;超时可能是获取失败,也可能是服务端已经成功设置但回复丢失。应使用唯一令牌、有限重试和清理协议,不要在状态不明时直接进入临界区。

释放必须比较令牌,而且比较与删除必须原子 ​

下面是同一实例上的安全释放步骤:

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

原因可以用实际 Redis 复现:A 获取 20 ms 租约后停住;租约过期,B 获得同一把锁;A 恢复并直接 DEL。结果:

text
unsafe-delete: 1 owner= None

A 把 B 的锁删了。换成比较令牌的 Lua,得到:

text
safe-old-release: 0 owner= token-B

返回 0 表示 A 已经不拥有这个锁,B 的令牌仍在。把 GET 和 DEL 拆成两个客户端命令仍有竞争窗口,必须在服务端原子完成判断与删除。Redis 官方锁模式

实验使用固定 token-A/token-B 只是为了复现两个不同拥有者;生产获取应生成足够随机、每次不同的令牌。令牌是所有权标识,不是后面要讲的单调 fencing token。

续期能延长租约,不能证明进程始终在线 ​

续期同样要先核对令牌,再 PEXPIRE。旧持有者不能只要看到锁存在就延长它:那可能已经是别人的锁。

text
old-renew: 0
new-renew: 1 ttl-positive= True
new-release: 1

本次实验中,旧令牌续期返回 0,新令牌返回 1。续期失败或超时后,持有者应停止发起新的工作,并把已经在途的副作用视为需要核实的状态。但只在应用里设置一个“失锁了”的布尔值不够:进程可能暂停在检查通过与发出写请求之间,或者网络中已经有延迟的旧请求。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 持有者 A
    participant L as 租约服务
    participant B as 持有者 B
    participant S as 受保护存储
    A->>L: 获取租约,令牌 A
    A->>A: 长暂停,续期未能执行
    Note over L: A 租约过期
    B->>L: 获取新租约,令牌 B
    B->>S: 写入新结果
    A->>A: 恢复,仍有旧执行状态
    A->>S: 迟到的旧写入
    Note over S: 若存储不检查代次,旧结果可能覆盖新结果
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 持有者 A
    participant L as 租约服务
    participant B as 持有者 B
    participant S as 受保护存储
    A->>L: 获取租约,令牌 A
    A->>A: 长暂停,续期未能执行
    Note over L: A 租约过期
    B->>L: 获取新租约,令牌 B
    B->>S: 写入新结果
    A->>A: 恢复,仍有旧执行状态
    A->>S: 迟到的旧写入
    Note over S: 若存储不检查代次,旧结果可能覆盖新结果

安全释放防止 A 删除 B 的锁,却没有阻止图中的最后一次业务写。这是两个不同的问题。

Fencing:让资源拒绝上一任持有者 ​

fencing token 是随新的授予单调增加的编号。受保护资源记住自己接受过的最高编号,在更新业务数据时原子检查并拒绝更旧的编号。A 带 41,B 带 42;只要资源已经接受 42,迟到的 41 就不能再覆盖它。

文末本地资源模型输出:

text
fencing-model: new= True late-old= False highest= 42

这个模型只证明“按编号检查可以拒绝该顺序下的旧写”。真正实现还需要可靠的编号生成、资源端持久且原子的检查与更新、资源重启后不倒退。若同一租约允许多次操作,还要定义相同 token 的合法重复与幂等规则;示例为了清楚只允许一个 token 成功写一次。Kleppmann 原始讨论

如果下游是不支持条件写的外部接口,应用不能单方面宣称 fencing 已经生效。可能需要把副作用改成由可靠队列串行执行,使用业务幂等键、数据库约束,或让目标服务支持版本检查。具体方案取决于资源能参与什么协议。

“用 Redis INCR 发编号”也要面对 Redis 自身复制与恢复的语义。异步故障转移若让计数器回退,编号就失去保证;在没有证明这种故障模型之前,不能把单实例单调计数自动升级成持久、全局、不回退的 fencing 服务。

Redlock 的争议,争的是哪些假设 ​

单个主节点加异步副本的锁有一个典型风险:A 在主节点拿锁成功,但锁还没复制过去,主节点故障;副本晋升后没有这把锁,B 又获取成功。官方 Redlock 方案试图通过多个独立 Redis 主节点上的多数租约来处理单节点故障,不能把普通主从切换误称为 Redlock。官方方案

在常见的五节点说明中,客户端用同一个随机令牌尝试在独立节点上获得租约,要获得至少三个成功,并从 TTL 中减去获取过程耗时及漂移余量,确认还有正的有效时间;失败则释放已经或可能获得的锁,再带随机退避重试。节点独立、超时与时钟假设都是算法讨论的一部分,少了这些条件就不是同一问题。

原始讨论关注点对使用者的具体提醒
Redis 官方 Redlock 说明多个独立节点、多数获得、租期与时钟漂移需要按完整协议实现,不能只数成功次数
Martin Kleppmann,2016进程暂停、网络延迟、正确性锁与 fencing下游若不拒绝旧写,租约到期并不能让旧进程停止产生副作用
Salvatore Sanfilippo,2016 回复对故障模型、时钟和资源协调前提的不同判断应把争论落实到系统允许哪些故障、目标资源支持哪些检查

Sanfilippo 的回应具体指出:客户端应在收到多数成功回复后再次检查获取耗时,已经耗尽租期就不能开始工作;他认为相对计时速率有界是可接受的前提,并讨论使用唯一随机令牌配合资源端 Check-and-Set。Kleppmann 则强调,即使获取阶段通过检查,持锁后的暂停或迟到写入仍需资源端拒绝旧请求。两者对时间与资源能力的假设不同;随机令牌的相等比较也不能直接当成单调编号的顺序比较。

两位作者的原文分别是 How to do distributed locking 和 Is Redlock safe?。这里不把争议概括成“Redlock 永远安全”或“永远不能用”。

可以明确给出的选择原则是:如果锁失败只导致重复缓存重建,应把复杂度、成本与可接受的额外工作量一起衡量;如果锁保护不可重复的业务正确性,应先说明如何拒绝旧持有者、如何在暂停和分区中保持资源约束,再选择协调机制。一个随机所有权令牌不自带单调顺序,多数 Redis 返回成功也不自动让第三方资源加入同一个原子事务。

本文未运行 Redlock,未模拟网络分区,也没有证明任一实现满足完整故障模型。单机 Lua 测试不能被用来替代这些证据。

最后把检查落在运行现场 ​

为热键治理至少保留热点前后流量、命中率、返回字节、尾延迟与陈旧年龄;为大键治理保留单对象尺寸、分片规则、迁移/清理耗时和恢复方案;为锁保留获取失败、等待、持有时长、续期失败、失锁后的停止行为及下游拒绝旧 token 的计数。

如果这些指标缺一半,优化可能只是把问题从 Redis 移到了数据库、客户端内存或错误的数据版本里。先测清压力在哪,再选择减少请求、减少数据还是加强正确性协议,通常比一口气增加缓存层和分布式锁更稳妥。

依据与验证范围 ​

  • Redis 8.2.2:redis-cli、lazyfree.c;正文分别链接内存、频率、扫描、删除、复制与槽机制文档。
  • Redis Tracking:服务端辅助的客户端缓存与失效协议。
  • Redis 分布式锁模式:单节点唯一令牌、释放与多节点 Redlock 方案。
  • Kleppmann 原文、Sanfilippo 回复:租约故障模型与 fencing 争议的原始论述。
  • 实测:Redis 8.2.2、Linux/amd64、libc,临时回环地址实例,验证旧拥有者直接删除的错误、令牌校验释放、令牌校验续期。fencing 为单进程模型,程序中已标明。
  • 未实测:生产热点扫描、治理前后性能、Tracking 断连/重连、多节点锁与集群分区。没有用示例推导普遍性能或强一致保证。

完整可运行实验 ​

需要 Python 3、Bash、Redis 8.2.2。使用官方归档,对照 redis-hashes 中 SHA-256 4e340e8e822a82114b6fb0f7ca581b749fa876e31e36e9fbcb75416bec9d0608;本次编译命令为 make -j4 MALLOC=libc BUILD_TLS=no OPTIMIZATION=-O2 redis-server redis-cli。

三个文件放在同一目录。with-redis.sh 只让测试命令连接自己启动并核对 PID 的临时实例;实例关闭时数据一起清理。resp.py 是按 UTF-8 解码的最小 RESP2 教学客户端,不是生产库。实验用很短租约刻意构造过期,不是生产租期建议。

bash
export REDIS_BIN="$PWD/redis-8.2.2/src"
export REDIS_PORT=16389
"$REDIS_BIN/redis-server" --version
bash with-redis.sh python3 locks.py

with-redis.sh ​

bash
#!/usr/bin/env bash
set -euo pipefail
: "${REDIS_BIN:?Set REDIS_BIN to the directory containing redis-server and redis-cli}"
export REDIS_PORT=${REDIS_PORT:-16389}
work=$(mktemp -d)
log=${REDIS_RUN_LOG:-$work/server.log}
pid=
cleanup() {
  if [[ -n "$pid" ]]; then
    kill -TERM "$pid" 2>/dev/null || true
    wait "$pid" 2>/dev/null || true
  fi
  rm -rf -- "$work"
}
trap cleanup EXIT
"$REDIS_BIN/redis-server" --bind 127.0.0.1 --port "$REDIS_PORT" \
  --protected-mode yes --save '' --appendonly no --dir "$work" \
  --daemonize no > "$log" 2>&1 &
pid=$!
echo "redis_pid=$pid port=$REDIS_PORT temporary_dir=$work" >&2
ready=0
for ((i=0;i<100;i++)); do
  kill -0 "$pid" 2>/dev/null || { cat "$log" >&2; exit 1; }
  if "$REDIS_BIN/redis-cli" -h 127.0.0.1 -p "$REDIS_PORT" INFO server 2>/dev/null | tr -d '\r' | grep -qx "process_id:$pid"; then
    ready=1; break
  fi
  sleep 0.05
done
[[ "$ready" == 1 ]] || { cat "$log" >&2; exit 1; }
"$@"

resp.py ​

代码块 · 37 行 · 点击收起 / 展开
python
import os
import socket

class RedisError(Exception):
    pass

class Redis:
    def __init__(self):
        self.sock = socket.create_connection(('127.0.0.1', int(os.environ.get('REDIS_PORT', '16389'))), timeout=3)
        self.f = self.sock.makefile('rb')
    def close(self):
        self.f.close()
        self.sock.close()
    def read(self):
        line = self.f.readline()
        if not line.endswith(b'\r\n'):
            raise EOFError('incomplete RESP line')
        kind, body = line[:1], line[1:-2]
        if kind == b'+': return body.decode()
        if kind == b'-': return RedisError(body.decode())
        if kind == b':': return int(body)
        if kind == b'$':
            n = int(body)
            if n == -1: return None
            data = self.f.read(n)
            if len(data) != n or self.f.read(2) != b'\r\n': raise EOFError('incomplete bulk')
            return data.decode()
        if kind == b'*':
            n = int(body)
            return None if n == -1 else [self.read() for _ in range(n)]
        raise ValueError('unsupported RESP type')
    def call(self, *args):
        vals = [str(a).encode() for a in args]
        wire = b'*%d\r\n' % len(vals)
        wire += b''.join(b'$%d\r\n' % len(v) + v + b'\r\n' for v in vals)
        self.sock.sendall(wire)
        return self.read()

locks.py ​

python
import time
from resp import Redis

RELEASE = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end"
RENEW = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE',KEYS[1],ARGV[2]) else return 0 end"
r=Redis()
try:
    def handover(key):
        assert r.call('SET',key,'token-A','NX','PX',20)=='OK'
        deadline=time.monotonic()+3
        while time.monotonic()<deadline:
            if r.call('SET',key,'token-B','NX','PX',60000)=='OK': return
            time.sleep(0.005)
        raise TimeoutError('A lease did not expire')
    handover('lab08:unsafe')
    print('unsafe-delete:',r.call('DEL','lab08:unsafe'),'owner=',r.call('GET','lab08:unsafe'))
    handover('lab08:safe')
    print('safe-old-release:',r.call('EVAL',RELEASE,1,'lab08:safe','token-A'),'owner=',r.call('GET','lab08:safe'))
    print('old-renew:',r.call('EVAL',RENEW,1,'lab08:safe','token-A',60000))
    print('new-renew:',r.call('EVAL',RENEW,1,'lab08:safe','token-B',60000),'ttl-positive=',r.call('PTTL','lab08:safe')>0)
    print('new-release:',r.call('EVAL',RELEASE,1,'lab08:safe','token-B'))
    # This is a local protected-resource model, not a Redis consensus test.
    highest=0
    def write(token):
        global highest
        if token<=highest: return False
        highest=token
        return True
    print('fencing-model: new=',write(42),'late-old=',write(41),'highest=',highest)
finally:
    r.close()

保留各书来源与许可 · 示例运行条件以正文为准