主题
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
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 30000NX 表示键不存在才创建,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= NoneA 把 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: 若存储不检查代次,旧结果可能覆盖新结果
安全释放防止 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.pywith-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()