BigKey、内存与多级缓存:容量测量和副本更新
同样是一个 Redis key,可能保存一千字节的文本,也可能包含几十万个集合元素。键数相同,读取、序列化、删除和复制的成本却相差很大。应用再增加一层本地缓存后,同一份内容还会在多个 JVM 中各占一份内存。
容量规划要看具体保存了什么、每次操作处理多少数据,以及这些副本怎样失效。
区分内容大小、集合规模与进程内存
BigKey 应按实际操作判断
BigKey 通常指内容很大或元素很多的键。单个 String 的字节数、Hash 的字段数和 Sorted Set 的成员数,描述的是不同维度;不存在一个适用于所有业务的固定字节阈值。
| 对象 | 先测什么 | 常见成本 |
|---|---|---|
| String | STRLEN、每次 GET 返回字节 | 网络传输、客户端解码、整值重写 |
| Hash | HLEN、字段和值大小 | HGETALL 全量返回、编码转换 |
| List / Set | 元素数量与单元素大小 | 全量读取、集合运算、删除 |
| Sorted Set | 成员数与查询范围 | 排序维护、范围结果与聚合 |
| 多个小 key | 键名、对象与索引开销 | 总内存和批量命令往返 |
STRLEN 计算字节,不是中文字符数;HLEN 返回字段数量,不是整个 Hash 的内存。命令约定分别见 STRLEN 和 HLEN。
一个很大的 Hash,如果每次只查询少数字段,传输量可以较小;若业务反复 HGETALL,成本就随整个集合增长。反过来,一个不大的 String 也可能因为每秒被读取很多次而成为热键。内容规模与访问频率应分开测量。
编码会随内容变化
Redis 对小集合可能采用紧凑编码,以减少每个元素的对象和指针开销。元素数量或单个值长度超过相应条件后,内部编码可以变化。客户端仍使用 HGET/HSET,但内存和操作成本可能出现明显变化。
OBJECT ENCODING 可以查看实际编码。实验中 32 个短字段的 Hash 返回 listpack;这个观察只对应固定服务器版本和配置,不应要求任意 Hash 都使用相同编码。命令见 OBJECT ENCODING,小集合的配置条件见 Memory optimization。
不能因为紧凑编码省内存,就把阈值无限调大。线性查找、插入移动或编码转换成本也会变化,应使用实际字段分布和访问方式比较。
MEMORY USAGE 与 INFO 观察的范围不同
MEMORY USAGE key 估算一个键及其值相关的内存分配,包括部分管理开销。对嵌套类型可能采用采样;增加采样数能改善估计,但也增加执行工作。SAMPLES 0 会遍历全部嵌套元素,不适合在未知规模的大集合上随意执行。具体含义见 MEMORY USAGE。
进程层面还要看更多对象:
Redis 进程使用的内存
├── 数据内容与键索引
├── 过期、对象和分配器管理开销
├── 客户端输入 / 输出缓冲
├── 复制与持久化相关缓冲
└── 分配器保留空间与其他运行开销
后台快照或重写期间
└── 写时复制可能增加额外物理内存used_memory 是 Redis 通过分配器统计的使用量,used_memory_rss 是操作系统观察的驻留内存。删除许多键后,分配器可能保留页用于后续分配,RSS 不一定立即同步下降。碎片比例在数据很少时也可能被固定运行开销放大,需要结合绝对字节数判断。
maxmemory 是 Redis 的内存管理阈值,不能直接等同于容器内存限制。复制或 AOF 缓冲中有些内存不参与相同的驱逐计量,mem_not_counted_for_evict 可提供线索。指标定义见 INFO。
持久化期间还需预留空间
RDB 快照和 AOF 重写涉及后台任务与内存页共享。写入持续发生时,写时复制可能增加物理页占用;磁盘空间、写入速率和后台任务持续时间也影响峰值。
因此容器内存不宜紧贴 maxmemory 设置。应测量正常流量、批量导入、快照/重写以及复制恢复时的峰值,再留出相应余量。持久化方式与后台行为见 Redis persistence。
这一类运维动作要在具有备份和恢复方案的环境中执行。后面的容量实验关闭持久化,只观察内存策略,不通过一次小数据实验推导生产持久化峰值。
用容量策略控制保存与删除
达到 maxmemory 后采用什么动作
| 策略族 | 主要行为 | 选择时需要明确 |
|---|---|---|
| noeviction | 内存不足时拒绝需要增加内存的相关命令 | 应用怎样处理写入错误,哪些读取仍可继续 |
| allkeys-lru / allkeys-lfu | 从所有键中按近似最近使用或频率选择淘汰对象 | 这些数据是否都允许重新生成 |
| volatile-lru / volatile-lfu | 只在带 TTL 的键中选择 | 没有可淘汰候选时怎样处理 |
| volatile-ttl | 优先选择更接近过期的候选 | TTL 是否同时代表保留优先级 |
| random 类策略 | 在相应候选集合随机选择 | 工作负载能否接受随机失去条目 |
过期与驱逐不同。过期根据条目的时间条件发生;驱逐为了在容量压力下腾出空间,可能提前删除尚未到期的键。策略、候选集合与近似算法见 Key eviction。
会话、幂等结果和版本水位可能不能按普通派生缓存丢弃。把它们与可淘汰商品描述放在同一个全键驱逐实例中,需要承担它们也可能被淘汰的后果。重要状态可以使用独立存储、不同实例或明确的可靠来源,不能只靠一个更长 TTL 保护。
先缩小一次操作,再处理总量
大响应可以按字段投影、分页或范围查询缩小。大型内容适合放到对象存储,Redis 保存受控标识和必要元数据;频繁小幅修改的对象可评估字段级操作,避免每次重写整个大值。
拆分一个集合时,要保留顺序、查询索引和更新规则。把一百万个成员分成多个键后,跨键聚合和原子修改可能更复杂;Cluster 中这些键的槽位分布也会影响操作方式。拆分不是只有节省内存这一项收益,应比较整个读写过程。
压缩会减少网络和保存字节,却增加压缩与解压 CPU、临时缓冲和格式管理。命中路径已经受 CPU 限制时,压缩可能让延迟更高。
SCAN 按游标迭代,COUNT 只是提示
SCAN 0 MATCH ca11:...:* COUNT 100
→ 返回下一游标与一批键
→ 使用返回游标继续
→ 下一游标回到 0 才结束一次返回空列表但游标非 0 时,仍须继续。COUNT 不是严格的每页条目数;集合在扫描期间变化时,也不能把一次迭代当作一致快照。完整遍历可能返回重复键,消费方需要允许重复处理或去重。SCAN说明了游标和保证范围。
MATCH 过滤结果不意味着服务器只检查这些业务键。针对整个大键空间反复执行扫描仍有成本,应限速、分批,并由了解目标实例的人操作。
UNLINK 移除键与回收内存是两个时刻
UNLINK 从键空间移除指定键,把主要的对象释放工作交给后台处理。命令完成后该键可已查询不到,内存回收和 RSS 变化仍可能稍后发生。与 DEL 相比,它可以减少大对象同步释放对命令处理的影响;具体成本见 UNLINK。
UNLINK 不保证业务数据可恢复。对可靠状态执行同样会丢失当前记录,因此清理前必须确认键的所有者、来源和恢复方式。批量清理只处理明确的应用前缀或受控键集合,不使用 FLUSHALL 清理共享实例。
多级缓存怎样增加副本与失效过程
本地命中为什么看不到远端变化
L1 命中后直接返回,才能省掉远端访问。Redis 中版本已经更新,并不会主动进入当前 JVM 的对象;如果每次都访问 Redis 比较版本,就保留了远端往返,只省掉一部分大值传输或解码。
同一内容在十个 JVM 中各保存一份,会放大总内存。Caffeine maximumSize 计量条目数,maximumWeight 使用应用提供的权重,也不自动测得每个 Java 对象完整堆占用。具体计量与可变对象问题见 Caffeine 本地缓存。
失效通知、TTL 与重连互相配合
更新后可以向应用实例发送失效通知,让它们删除对应 L1 条目。通知应携带稳定对象标识或版本;若消息重复、乱序,要避免旧事件覆盖新内容。
普通 Pub/Sub 断线期间可能错过通知。重连后可以清空该实例不可信的 L1,再逐步回填;也可以依据可靠日志位置补齐失效。仅重新订阅并继续使用原有缓存,会把断线期间的旧条目保留下来。
短 TTL 是另一条恢复途径,但它与回填时间、刷新和内容年龄有关,不应被写成不依赖条件的全局一致性保证。相关时序见 数据库与缓存一致性。
Redis Client Tracking 的职责
Client Tracking 能让服务器记录客户端读取过的键,或按广播前缀发送失效通知。它帮助客户端知道哪些本地条目需要移除,但仍需要客户端缓存实现处理推送、连接生命周期和重连。
RESP3 push 与普通命令回复属于不同消息类型。客户端要使用相应支持能力,不把失效消息误当作 GET 的回复。连接断开后原本依赖这条通知链维护的新旧判断也需要重建。模式与连接要求见 Client-side caching。
是否适合引入 L1,应由热点收益、允许的旧值窗口和失效可靠性共同决定。低命中、对象很大或变更频繁的查询,增加本地副本可能只增加管理成本。
在隔离 Redis 中测量和验证
运行值大小、扫描与两级缓存实验
下载 缓存实验工程。使用普通 Linux 用户、Bash、Docker 和 unzip;按 工程准备取得 PROJECT_DIR、LAB_DIR 并下载依赖,再按 Redis 网络准备启动 ca11-redis。
实验使用 Redis 8.10.1,无宿主端口发布,只在 ca11-lab internal 网络中运行。Maven 容器使用宿主 UID/GID,不需要在容器内取得 root。
test "$(id -u)" -ne 0 || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-memory-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=CacheMemoryTest test预期三个测试通过。Java 17 用对应 Maven 镜像重复执行;连接失败或依赖缺失按前面的准备步骤恢复,不跳过测试。
业务输出如下,其中编码名称以实际服务器为准:
stringLengths=1024,65536 hashFields=32 largerAllocation=true hashEncoding=listpack
scanMatchedUnique=25 unlinked=25 unrelatedRetained=true
afterRemoteUpdateA=v2 B=v1 remoteReads=3 afterBResync=v2 remoteReads=4第一项写入真实 String 和 Hash,比较 STRLEN、HLEN 与 MEMORY USAGE。测试只断言大值分配量更大,不把某次分配器返回的精确字节数写成跨平台标准。
第二项扫描 25 个固定测试前缀的键,收集完整游标结果并去重,再 UNLINK。另一个前缀的键保持可读,说明清理没有扩大范围。
第三项使用两个真实 Caffeine LoadingCache 和同一个 Redis 键。A、B 最初各读取一次 v1;Redis 更新到 v2 后,只给 A 执行本地失效,A 回源取得 v2,B 仍返回 v1。清空 B 后再读取,它才取得 v2。远端读取次数来自实际 loader,分别为 3、4。
不向 B 发送失效操作,用于观察未收到通知后的缓存状态;实验未建立 Pub/Sub 连接,未覆盖断线与重连。
在两个新实例上比较内存策略
工程中的 memory-capacity.sh 会创建 ca11-memory-reject、ca11-memory-evict 两个一次性 Redis 容器,分别配置 8 MB maxmemory 和 noeviction、allkeys-lru。无认证、关闭持久化、无宿主端口发布,Redis 进程为 UID/GID 999。
test "$(id -u)" -ne 0 || exit 1
docker network inspect ca11-lab >/dev/null
test -f "$PROJECT_DIR/memory-capacity.sh" || exit 1
bash "$PROJECT_DIR/memory-capacity.sh"脚本先检查同名容器不存在,成功创建后才登记为自己的资源。每次写入六万五千五百三十六字节,最多写入 256 个键,检查两种结果:
noeviction: rejectedWrite=OOM retainedFirstBytes=65536 serverResponding=PONG
allkeys-lru: writes=256 evictedKeys=实际淘汰数 remainingKeys=实际键数 finalBytes=65536noeviction 分支必须实际收到 OOM 命令错误,同时验证第一条旧记录仍可读、服务器仍响应 PING。这种 OOM 是 Redis 拒绝命令,不是操作系统已经杀死了容器。
allkeys-lru 分支要求 256 次 SET 成功、evicted_keys 大于 0、剩余键数少于 256,最后写入的值仍可读。具体淘汰数随运行状态变化,不要求每台机器完全相同,也不指定哪一条历史键必须成为 LRU 牺牲者。
脚本退出会删除它自己创建的这两个容器,丢弃其中全部临时数据;不删除原来的 ca11-redis、网络和宿主工程。若同名容器已存在,脚本直接停止,先确认来源,不自动替读者覆盖。
读取实例指标
普通 ca11-redis 的只读观察命令为:
docker exec ca11-redis redis-cli INFO memory
docker exec ca11-redis redis-cli INFO stats
docker exec ca11-redis redis-cli INFO persistence
docker stats --no-stream ca11-redis比较 used_memory、used_memory_rss、maxmemory、evicted_keys、expired_keys 和相关缓冲,而不是只看一个总百分比。docker stats 使用容器层计量,与 Redis 分配器统计的范围也不同。
共享生产实例不应直接执行实验中的容量填充、配置更改或扫描清理。需要排查时,先获得明确的实例和键范围,采用只读指标与受控采样,再制定可恢复操作。
常见现象与下一步
| 现象 | 优先检查 | 后续处理 |
|---|---|---|
| 键数少但内存很高 | 单值大小、集合元素、键名和缓冲 | 拆分大操作,调整数据模型 |
| 删除后 RSS 不降 | used_memory 是否下降、分配器和后台释放 | 持续观察绝对字节,勿立即反复重启 |
| 内存阈值附近写入失败 | noeviction 或没有可驱逐候选 | 处理写失败并校正容量/策略 |
| miss 上升且 evicted_keys 增加 | 驱逐压力、热度与容量 | 调整保留集合和容量,限制回源 |
| Redis 已新,部分应用仍旧 | L1 命中与通知状态 | 清理不可信本地副本,修复同步 |
| 扩应用后总内存明显上涨 | 每实例 L1 对象与重复率 | 重新分配本地额度 |
| 持久化或复制时容器退出 | 内存峰值、磁盘与后台任务 | 预留额外资源,检查实际退出原因 |
容量调整完成后,还要检查正常查询、批量更新和冷启动时的数据库负载。减少 Redis 内存占用可能增加 miss;过度保留本地副本则可能延长旧值存活,两侧都需要观察。
