VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

Redis 数据结构与 bigkey:小用收纳袋,大用行李箱——Java 工兵连第 14 篇

「Java 工兵连」第 14 篇,MySQL 三部曲收官,进入 Redis 领地。第一问从最容易被糊弄的开始:「Redis 为什么快?」标准答案是「纯内存 + 单线程无锁」——对,但不全。真正藏功力的是后半句:Redis 为每类数据准备了「小而省」和「大而快」两套底层结构,按数据量自动切换。看懂这个自适应,bigkey 为什么危险、内存为什么爆炸、HGETALL 为什么突然变慢,全部一眼见底。

一、先建直觉:单口海关与行李打包

Redis 是单口海关(网络 IO 与命令执行单线程):每个旅客(命令)过闸必须快,任何一个人拖太久的行李,后面全部排队——这是理解本篇一切问题的总背景。

行李打包规则:东西少,用收纳袋(listpack:连续内存、无指针、极致紧凑);东西多了,换行李箱(hashtable:有把手有轮子,拿取 O(1),但占地方)。海关不问你,自动帮你换——超过阈值(元素太多或单件太大)就升级结构。

而 bigkey,就是往通道里塞集装箱:一个 key 装百万元素或几 MB 的 value。它过闸(删除、过期、迁移)的那几秒,整条通道瘫痪。

五大类型编码表、自适应切换图与 bigkey 三宗罪

二、五大类型的两套家当

对着 hero 图左半的表过一遍(Redis 7.x 编码):

String → int / embstr / raw。 纯数字直接存 long(int 编码,连字符串都不用);≤44 字节用 embstr(对象头和 SDS 一次分配成连续内存,缓存友好);再大用 raw。SDS(简单动态字符串)三件宝:len 字段让取长度 O(1)、空间预分配减少 realloc、二进制安全(内容可以含 \0)。

Hash / Set → listpack(小)→ hashtable(大)。 listpack 是一整块连续内存挨个摆元素——没有指针开销,省到极致;代价是查找 O(n),但 128 个元素以内的 n 根本不叫事。超过阈值(默认 128 项或任一 value > 64B)升级 hashtable,O(1) 定位。注意只升不降:元素删光了也回不去 listpack——所以「先塞大再删小」的用法会永久性多耗内存。

List → quicklist。 双向链表,但每个节点是一段 listpack——两端 O(1) 和内存紧凑两头全占,它就是为「消息流」这种两头进出的形状设计的。

ZSet → listpack(小)→ skiplist + dict(大)。 大模式下是双结构:dict 管「按 member 查 score」O(1),跳表管「按 score 排序和范围」O(log n)。跳表可以想成地铁快车线:底层站站停,上层越走越快,多层索引跳着走。为什么不用红黑树?实现简单一半、ZRANGE 范围查询顺着底层链表天然支持、层数按概率生成内存可调——工程上的「够好且好写」完胜理论最优。

一句话总结自适应:「条多个头大,就换正经结构」——小数据替你省钱,大数据替你保性能,两头下注。

三、bigkey 三宗罪

单线程是放大器,bigkey 的伤害全部被它放大:

一卡:DEL 一个百万元素的集合是 O(n),主线程一卡几秒,全库 QPS 掉零。更阴的是被动过期:你以为 key 到期自动没了——惰性删除是「访问时删」,那一下删除也是 O(n);定期抽样删除撞上大 key 同样卡。解药:UNLINK(4.0+,异步后台删)+ 惰性过期配置 lazyfree-lazy-expire yes。

二堵:一个 1MB 的 value,业务 1000 QPS 读它,出口就是 1GB/s——先死的不是 Redis,是网卡和带宽。

三难迁:集群模式槽迁移要逐个搬元素,大 key 让迁移卡住超时;主从全量同步也要先 RDB 序列化它——大 key 在哪,哪的运维动作就慢。

判定门槛(社区共识量级):String 的 value > 10KB、集合类元素 > 5000 或总大小 > 10MB,就该警惕了。排查三板斧:

redis-cli --bigkeys              # 线上安全(抽样扫描,SCAN 实现),给每类型最大 key
MEMORY USAGE your:key            # 精确测单个 key(含底层结构开销)
# 离线兜底:rdb-tools 分析 RDB 文件,全量找大 key 不碰线上

处方三条:

  1. 拆:user:1000 塞 500 个 field?按语义分桶 user:1000:base、user:1000:ext,或按 hash 取模拆成 N 个子 key;
  2. UNLINK 替代 DEL,批量清理用 SCAN 游标遍历,永远不要 KEYS(那是自己造的一次性全库卡顿);
  3. 业务上立规矩:Redis 是缓存和索引,不是 MongoDB——把整张表序列化成 JSON 塞 String 的需求,九成该重构。

四、顺手带走的两个内存细节

  • 碎片:INFO memory 里 mem_fragmentation_ratio(used_memory_rss / used_memory)> 1.5 说明碎片偏大——大量换编码、删大 key 之后常见;4.0+ 可开 activedefrag 主动整理。
  • 淘汰策略按「个数」不按「字节」:allkeys-lru 淘汰的是 key 数量配额下的最久未用——大 value 的 key 占着巨内存却只算「一个」,让 LRU 的判断失真。这也是控制 value 大小的隐性理由:别让一个集装箱在行李配额里只算一件手提行李。

老兵点评

  1. Redis 的内存问题,九成能在「编码切换」和「bigkey」里找到答案。HGETALL 从 1ms 变 50ms?大概率 hash 越过阈值升了 hashtable 且变大了;内存稳步上涨不回落?查 bigkey 和「只升不降」的编码。先看 OBJECT ENCODING key 和 MEMORY USAGE,再谈参数调优。
  2. 把「Redis 不是数据库」写进代码规约。它快,是因为假设你每次只搬一个小包;把它当仓库塞集装箱,单线程的福就变成单线程的祸。监控三件曲线:单 key 大小 TOP N、单命令耗时 P99、lazyfree_pending(等异步删的积压)。
  3. 面试段位差:初级背「Redis 快因为内存和单线程」;中级讲五种结构和编码切换;高级讲跳表与红黑树的工程取舍、lazyfree 的异步删除链路、以及「淘汰按个数不按字节」的策略盲区——聊到这层是在聊存储设计的成本观。

带走三句话

  1. 编码自适应口诀:小用收纳袋(listpack/intset/embstr),大用行李箱(hashtable/skiplist)——条多个头大就换,且只升不降。
  2. bigkey 三宗罪:一卡(DEL/过期 O(n),用 UNLINK)、二堵(大 value 打爆带宽)、三难迁(集群/同步被拖垮)——拆分桶是根治。
  3. 线上纪律:SCAN 别 KEYS、DEL 换 UNLINK、Redis 是缓存不是仓库——单线程的快,靠的是每个人都只带小包。

本篇是「Java 工兵连」第 14 篇。→ 上一篇:第 13 篇 · MySQL 锁全景与死锁排查 | 继续阅读:第 15 篇 · Redis 持久化与高可用选型 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →