☰
Redis 三大数据类型:String、Hash、List 底层编码与内存优化实战
2026/10/28 20:21:14 网站建设 项目流程

Redis 的五种常见数据类型,几乎是每次带新人时绕不开的第一课。我见过太多项目,上来就是字符串一把梭,把整个对象序列化成一段文本塞进一个 key,等到内存曲线开始往上翘、接口 P99 开始抖动,才回头翻文档找原因。也有反过来的,明明只是存个开关标记,非要用 Hash 包一层,结果 key 数量翻了几倍,运维同学看着监控一脸问号。数据类型这件事,说简单也简单,说坑也真不少,关键在于你得知道每种类型在底层到底长什么样、什么时候会悄悄换一种存储结构、哪些命令看着人畜无害实际是 O(N) 的定时炸弹。

这篇主要聊 String、Hash、List 这三种,也就是日常业务里出场率最高的三兄弟,Set 和 Sorted Set 涉及集合运算和排行榜排名逻辑,内容量单独拉出来更合适,放到下篇细说。适合刚接触 Redis 不久、能敲几个命令但不太清楚内部机制的同学,也适合已经用了两三年、想把自己那套“经验主义用法”重新校准一遍的老手。下面这些内容,基本都是我在实际项目里踩过、修过、回头复盘过的,命令和参数都能直接拿去验证。

1. 先把数据类型这件事想明白:为什么 Redis 不是“一个大 Map”

1.1 从一次内存告警说起

前几年接手过一个模拟项目,功能不复杂:用户会话、页面配置、任务派发。当时的实现方式非常朴素——所有东西都用 String 存,会话是一段 JSON,配置是一段 JSON,任务队列是一段 JSON 数组,读取的时候整体反序列化,修改的时候整体覆盖写回。上线初期一切正常,QPS 上来之后问题开始暴露:内存占用是预估值的两倍多,配置项改一个字段要把整段 JSON 读出来再写回去,并发一高就出现覆盖丢失。

后来做了一轮改造,其实没换技术栈,就是把存储结构按访问模式重新分配了一遍:会话改成 Hash,按字段读写;计数器改成 String 的原生自增;任务派发改成 List 的阻塞弹出。改造后内存降了将近四成,写入的原子性问题也顺手解决了。这件事给我的教训是,Redis 提供的五种数据类型,本质上不是“五种存数据的花样”,而是五种已经封装好的、带原子操作的数据结构。你选的是数据结构,不是序列化格式。

如果只用 String,那 Redis 在你手里就退化成了一个带过期时间的分布式 HashMap,它的绝大部分能力都被浪费掉了。更麻烦的是,字符串一旦变大,网络传输、反序列化、内存碎片这三件事会一起找上门。所以真正需要想清楚的问题是:这份数据我要怎么读、怎么改、改多少、并发多高——答案基本就决定了该用哪种类型。

1.2 五种类型的分工,以及本篇要解决的三个问题

把五种类型摊开看,各自的主场其实非常清晰。String 是最基础的字节序列容器,适合计数器、缓存单值、位图操作;Hash 是小字段集合,适合对象属性、配置项;List 是有序链表,适合队列、栈、时间线;Set 是无序去重集合,适合标签、共同好友;Sorted Set 带分值排序,适合排行榜、延迟队列。这个分工不是人为规定的,是被底层编码和命令复杂度决定的。

本篇重点解决三个具体问题。第一是**“为什么我的 key 内存比预期大”,这涉及 String 的三种编码和 Hash 的两种编码,很多人压根不知道自己写进去的东西在内存里被放大过。第二是“为什么某个命令偶尔卡一下”,这涉及 List 的复杂度陷阱和 Hash 的全量读取。第三是“怎么用最少的代码把这三种类型用对”**,我会给出可以直接复现的命令序列和一段完整的落地脚本。

至于 Set 和 Sorted Set,它们更多涉及集合运算的复杂度、跳表的层数设计、分值相同时的排序规则,展开讲篇幅不比这篇短,放在下篇更从容。

1.3 选型的第一原则:先看访问模式,再看数据形态

很多人选类型的依据是“数据长什么样”——是单个值就用 String,是对象就用 Hash,是列表就用 List。这个思路不能说错,但容易忽略更关键的一层:访问模式。同样是一个用户对象,如果你每次都要整体读、整体写,那用 String 存序列化结果反而更快,因为一次网络往返就搞定;如果你经常只改其中一个字段,比如只更新最后活跃时间,那 Hash 的 HSET 就比“读出来改完写回去”省太多。

我通常用一张简单的判断表来快速定位:

访问特征优先选择理由
整体读写、值不大、需要原子自增String单命令完成,网络往返最少
频繁单字段读写、字段数量可控Hash避免整体序列化开销
需要有序、需要两端进出、需要阻塞等待List原生支持 LPUSH/BRPOP 语义
只关心存在性、需要去重Set下篇展开
需要按分数排序、范围查询Sorted Set下篇展开

还有一条隐性原则:能用一个 key 表达清楚的,别拆成几百个 key。比如用户属性,用user:1001一个 Hash 存十个字段,比用十个user:1001:name、user:1001:age这样的独立 String 要好得多——后者不仅 key 本身的开销(每个 key 都有 dictEntry、redisObject、过期字典等额外成本)会翻倍,批量读取时还得多次往返。这一点在 key 数量上千万之后尤其明显,我在一个模拟环境里做过对比,同样一千万条属性数据,拆散存储比 Hash 存储多占了将近 60% 的内存。

2. String:最不像“类型”的类型,也是用得最多的

2.1 SDS 到底解决了 C 字符串的哪些毛病

Redis 没有直接用 C 的字符数组来存字符串,而是自己实现了一套叫 SDS(Simple Dynamic String)的结构。原因很实在:C 字符串靠\0判断结尾,所以你不能存二进制数据,图片、压缩包、序列化后的 protobuf 全都没法放;求长度要遍历一遍,是 O(N);拼接时如果不手动扩容就会溢出。

SDS 的做法是在字符数组前面加一个头部,记录当前长度len和已分配空间alloc。这样一来,取长度变成 O(1),判断是否越界变成一次比较,而且因为长度是显式记录的,中间出现\0也不影响,二进制安全这个特性就是这么来的。扩容策略上,SDS 在长度小于 1MB 时按翻倍扩容,超过 1MB 后每次多分配 1MB,这个设计是为了在“频繁追加”和“内存浪费”之间取平衡——翻倍保证追加操作的均摊复杂度是 O(1),而封顶 1MB 是为了避免大字符串翻倍后浪费过多空间。

这里有个常被忽略的限制:单个 String 值最大 512MB。这个数字不是随便定的,它和内存分配、网络传输都有关系。实际项目里没人会去碰这个上限,但它的存在提醒我们:String 不是给你存大文件的,真要存大对象,对象存储才是正解。

2.2 三种编码:int、embstr、raw 的切换边界

这是理解 String 内存占用的关键。同样一条SET命令,写进去的东西在内存里可能是三种完全不同的形态:

  • int:值能被解析成 64 位有符号整数时,直接把数值存在 redisObject 的指针位置,不额外分配内存。这是最省的一种。
  • embstr:长度小于等于 44 字节的字符串,SDS 头和内容一次性分配在连续内存里,只调用一次内存分配。
  • raw:长度大于 44 字节,SDS 头和数据分开两次分配。

44 这个数字是怎么来的?redisObject 的头结构占 16 字节,SDS 头在存短字符串时用一个sdshdr8,占 3 字节,加上结尾的\0一个字节,总共 20 字节。jemalloc 在分配内存时按 8、16、32、64 这样的档位走,64 字节是最小的能装下“16 + 3 + 44 + 1 = 64”的档位,所以 44 就成了 embstr 的天花板。超过 44 字节就得走 raw,两次分配,内存碎片风险也随之上升。

验证方法很简单,用OBJECT ENCODING就能看:

redis-cli set k1 12345 redis-cli object encoding k1 # 输出 int redis-cli set k2 "hello" redis-cli object encoding k2 # 输出 embstr redis-cli set k3 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # 48 个 a redis-cli object encoding k3 # 输出 raw

有一个坑必须提:embstr 是只读的。如果你对一个 embstr 编码的 key 执行APPEND或者SETRANGE,Redis 会先把它转成 raw 再改,而且转过去就回不来了。所以那种“先 SET 一个短字符串,再不断 APPEND 追加”的写法,实际上是在逼着 Redis 每次都重新分配,性能并不好。要拼接字符串,SET一次写完整比APPEND逐次追加更划算。

2.3 常用命令与“不该用”的命令

String 的命令看着简单,但有几个容易踩的地方值得单独说。

SET的完整形态是SET key value [NX|XX] [EX seconds|PX milliseconds] [KEEPTTL]。很多人写“设置值并加过期时间”还是分成SET+EXPIRE两条命令,这两条之间一旦断开,就会留下一个永不过期的 key。用一条SET k v EX 60就能保证原子性,同理SET k v NX EX 60是分布式锁最基础的写法。

GETSET和SET ... GET的区别也值得记一下:前者已废弃但仍可用,后者是 6.2 之后推荐的写法,返回旧值的同时写入新值,一次往返完成。

再说几个“不该随便用”的。KEYS *在生产环境基本等于自杀,它会阻塞主线程,key 越多阻塞越久,替代方案是SCAN游标迭代。GETRANGE本身没问题,但如果有人拿它做分片读取大字符串,配合上网络往返,效率会非常差。STRLEN是 O(1) 的,可以放心用。

还有一个细节:INCR只对 int 编码的值或者能完整解析为整数的字符串有效,如果值里有空格或者小数,会直接报错。这个报错其实是好事,能帮你及早发现数据类型设计上的混乱。

2.4 计数器与限流的实操写法

计数器是 String 最经典的用法。INCR是原子操作,多个客户端并发自增不会丢更新,这一点比“先 GET 再 SET”强太多。配合EXPIRE就能做出按天、按小时统计的效果:

redis-cli incr counter:api:20240520 redis-cli expire counter:api:20240520 86400

但这两条命令之间不是原子的。如果进程在中间挂掉,就会留下一个永不过期的计数器。稳妥的写法是用 Lua 脚本合并,Redis 保证脚本执行的原子性:

-- 原子自增并设置过期时间(仅首次设置) local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return current

这个模式我用得非常多,核心思路是“只在计数器从 0 变 1 的时候设过期”,这样既保证原子性,又不会每次自增都刷新过期时间——如果每次都刷,那这个 key 永远不会过期,内存就不会释放。

限流场景也是同样的路子。固定窗口限流就是上面这个脚本,current超过阈值就拒绝。滑动窗口可以用 Sorted Set 做(下篇讲),也可以用 String 的位图做粗略统计。这里提醒一句:固定窗口在窗口交界处会有双倍流量,比如限制每分钟 100 次,第 59 秒打满 100 次、第 61 秒再打满 100 次,实际上 3 秒内打了 200 次。如果业务对这个敏感,就得换滑动窗口或者令牌桶,别指望一个INCR解决所有问题。

2.5 大 key 的坑:512MB 是红线不是目标

“大 key”在行业里没有统一标准,但有个经验值可以参考:String 类型的 value 超过 10KB 就值得警惕,超过 100KB 基本要重新设计。原因有三层。

第一层是网络。一次GET返回 1MB,看起来还好,但如果这个接口 QPS 是 1000,那就是 1GB/s 的出流量,网卡先撑不住。第二层是阻塞。Redis 是单线程处理命令的,读取和传输大 value 期间,其他命令都得排队,P99 延迟会明显抬高。第三层是删除。直接DEL一个巨大的 key 会阻塞主线程,这时候得用UNLINK做异步删除,把内存回收交给后台线程。

排查大 key 可以用内置工具:

redis-cli --bigkeys redis-cli --memkeys

前者按类型统计最大的 key,后者按内存占用排序。注意这两个命令底层都是SCAN,对线上影响可控,但最好在低峰期跑。还有一个更精细的办法是用MEMORY USAGE key看单个 key 的实际占用,配合OBJECT ENCODING确认编码,基本能定位到问题所在。

我个人的习惯是,在设计阶段就把“这个 key 最大能到多大”写进注释里。比如会话数据,我会明确写“单 key 不超过 4KB,字段不超过 20 个”。有了这个约束,后面就不会有人往里塞整个用户画像。

3. Hash:把“对象”放进去,而不是序列化成字符串

3.1 为什么用 Hash 存对象比 String 存 JSON 更划算

假设要存一个用户的基本信息:昵称、年龄、城市、最后活跃时间。用 String 存 JSON 的话,每次改最后活跃时间,都得把整个 JSON 读出来、解析、改字段、序列化、写回去。这中间有三个成本:网络往返两次、序列化反序列化两次、并发写有覆盖风险。

换成 Hash,HSET user:1001 last_active 1716200000一条命令就完事,其他字段完全不受影响,天然避免覆盖。读取单个字段用HGET,读全部用HGETALL,读部分用HMGET。内存上通常也更省——JSON 里的引号、花括号、字段名重复出现,而 Hash 的字段名只存一份。

但 Hash 也不是无脑更优。如果这个对象每次都是整体读取、从不单字段更新,那 String 存序列化结果反而少一次命令解析的开销。判断标准还是那句话:看访问模式。读多写少且整体读,String 可以;写多且单字段写,Hash 更合适。

还有一点值得注意:Hash 里的字段是散列存储的,没有顺序。如果你依赖字段顺序做展示,那得自己在应用层排序,别指望HGETALL返回的顺序稳定。这是很多人第一次用 Hash 时会愣一下的地方。

3.2 编码转换:listpack 与 hashtable 的阈值

和 String 一样,Hash 也有两种底层编码,而且转换是单向的——一旦转成 hashtable,即使后来字段减少了,也不会自动转回去。

小 Hash 用的是 listpack(7.0 之前是 ziplist)。它是一块连续内存,字段名和字段值紧挨着排列,查找是顺序遍历。听起来效率低,但因为元素少,遍历几个字段的成本远低于维护一张哈希表的开销,而且连续内存对 CPU 缓存友好,整体反而更快、更省内存。

当满足以下任一条件时,会转成 hashtable:字段数量超过hash-max-listpack-entries(默认 128),或者任一字段名/字段值的长度超过hash-max-listpack-value(默认 64 字节)。

redis-cli config get hash-max-listpack-entries redis-cli config get hash-max-listpack-value

这两个参数是理解 Hash 内存放大的关键。假设你有一个 Hash,里面 200 个字段,每个字段名加值平均 30 字节。200 超过了 128 的阈值,所以它是 hashtable 编码。hashtable 的每个键值对需要额外的 dictEntry 结构(约 24 字节)加上两个独立的 SDS 对象头,实际内存可能是 listpack 编码的 2 到 3 倍。我做过一个粗略测算:128 个字段、总数据量约 3.8KB 的 Hash,listpack 编码下MEMORY USAGE显示 5KB 左右;同样的数据超过阈值转成 hashtable 后,涨到 12KB 上下。

所以如果你有一批“字段不多但数量巨大”的 Hash,比如每个用户一个配置 Hash,字段数控制在 100 以内,就能一直待在 listpack 里,内存表现会好很多。反过来,如果字段天然就有几百个,那调大hash-max-listpack-entries也未必是好事——listpack 是顺序遍历,字段太多时HGET的耗时会上来,得权衡。

注意:这两个参数改了之后只对新创建的 key 生效,已有的 key 需要重建才会应用新阈值。别改完参数发现没变化就以为没生效。

3.3 字段过期与“伪过期”方案

Hash 有个让人又爱又恨的特点:过期时间只能设在 key 上,不能设在字段上。也就是说,整个user:1001要么整体过期,要么整体不过期,没法让last_active单独 10 分钟过期而nickname永久保留。

较新的版本确实引入了HEXPIRE这类字段级过期命令,但多数线上环境的版本还没跟上来,实际项目里更常见的还是自己做“伪过期”。最直接的做法是在字段值里带一个时间戳,读取时判断:

redis-cli hset session:abc token xxxx expire_at 1716203600

应用层HGET出来之后比较当前时间和expire_at,过期了就当作不存在,顺手异步清理。这个方案的缺点是清理不及时,内存里会残留过期数据,需要配一个定期扫描任务。

另一种思路是拆 key,把生命周期不同的字段分到不同的 Hash 里。比如把长期不变的资料和短期活跃的会话分开存,各自设过期时间。这样结构更清晰,代价是读的时候可能要多一次往返,可以用 pipeline 合并。

我更倾向第二种,因为“伪过期”那套逻辑散落在业务代码里,时间一长就没人记得清理,最后变成内存泄漏的温床。而 key 级别的过期是 Redis 原生保证的,省心。

3.4 购物车、配置中心这类场景的具体写法

购物车是个典型例子:用户加商品、改数量、删商品、看全量。用 Hash 存,商品 ID 做字段名,数量做字段值:

redis-cli hset cart:1001 sku:8801 2 redis-cli hincrby cart:1001 sku:8801 1 redis-cli hdel cart:1001 sku:8801 redis-cli hgetall cart:1001

HINCRBY是原子的,并发加购不会出现数量错乱,这是 Hash 相对于“读 JSON 改数字写回”最直接的优势。要注意的是HGETALL在字段多的时候是 O(N) 并且会一次性返回全部数据,购物车里几十个商品没问题,但如果有人把它当“用户行为日志”用,几千个字段全量拉取,延迟就上去了。这种场景应该改用HSCAN分批遍历。

配置中心也是类似。一个服务的配置放一个 Hash,启动时HGETALL拉一次,之后靠发布订阅或者定时轮询拿增量。HGETALL只在启动时调用,不影响运行期性能,这个用法就很健康。

这里补一个我踩过的坑:Hash 的 key 本身也会占内存。曾经有个设计是“每个商品一个 Hash 存库存、价格、状态”,商品数量大几百万,结果 key 数量爆炸,光是 key 的元数据就吃掉不少内存,还拖慢了SCAN和持久化。后来改成按品类聚合,一个 Hash 里放同品类的几百个商品,key 数量降了两个数量级,内存和遍历速度都好转了。

4. List:被低估的轻量队列

4.1 quicklist 的设计取舍

List 的底层编码经历过几次演进:早期是 ziplist 和 linkedlist 二选一,元素少时用 ziplist 省内存,元素多了转成双向链表保证插入效率。3.2 之后统一成了 quicklist,本质是“用双向链表把多个 listpack 串起来”,每个节点是一块连续内存,装一批元素。

这个设计解决了一个核心矛盾:纯链表每个元素都要额外的前后指针,内存开销大、缓存不友好;纯连续内存插入删除要挪动数据,元素多了就慢。quicklist 折中一下,节点内部是连续内存,节点之间用指针连接,既保证了两端插入的 O(1),又控制了单个节点的规模。

控制单个节点大小的参数是list-max-listpack-size,默认 128。它的含义取决于正负:正数表示每个节点最多装多少个元素,负数表示每个节点最大多少 KB(-1 是 4KB,-2 是 8KB,依次翻倍到 -5 的 64KB)。默认 128 意味着每个 listpack 节点最多 128 个元素,超过就分裂成新节点。

这个参数调大,内存更省(节点少、指针少),但单个节点内的插入删除要挪动更多数据;调小则相反。除非你有明确的压测数据,否则别动它,默认值在绝大多数场景下是平衡得比较好的。

4.2 左右两端操作的复杂度与常见误用

List 的核心能力是两端操作:LPUSH/RPUSH从头部或尾部插入,LPOP/RPOP从两端弹出,这四个都是 O(1)。加上BLPOP/BRPOP的阻塞版本,就构成了一套完整的队列原语。

但 List 的中间操作就没那么友好了。LINDEX按下标取值是 O(N),LSET按下标赋值也是 O(N),LINSERT在某个元素前后插入更是 O(N)。这意味着List 不适合按下标随机访问。

我见过一个很典型的误用:有人拿 List 存分页数据,然后用LINDEX list 5000去取第 5000 条。单次调用可能只要几毫秒,但在高并发下,这些 O(N) 操作会叠加起来拖慢整个实例。正确的做法是用LRANGE start end一次取一批,虽然底层还是要遍历,但一次网络往返拿到一批数据,效率高得多。

还有LREM,按值删除元素,也是 O(N),因为它得从头扫一遍找到匹配的。如果这个 List 有几十万元素,LREM就是一个隐藏的慢查询。类似的还有LTRIM,虽然它常用于“只保留最近 N 条”这种场景,本身是 O(N),但因为通常配合定长使用,影响可控。

4.3 阻塞命令做简易任务队列的完整实现

BRPOP是 List 最有价值的能力之一。它会在列表为空时阻塞等待,一旦有数据就立刻返回,超时时间到了才返回 nil。这样消费者不用轮询,不会空转浪费 CPU。

一个最小可用的生产者消费者模型:

# 生产者 redis-cli lpush queue:tasks '{"id":1,"type":"sync"}' # 消费者(阻塞 5 秒,取不到就返回) redis-cli brpop queue:tasks 5

多个消费者同时BRPOP同一个 key,Redis 会保证每个元素只被一个消费者拿到,这个分发是原子的。所以横向扩消费者实例非常方便,不需要额外加锁。

但这里有几个必须知道的边界。第一,BRPOP返回的是“key + value”两个元素,解析的时候别搞错。第二,消费者拿到数据后如果处理失败,数据已经从这个 List 里消失了,没有重试机制。第三,消费者进程崩溃,正在处理的那条数据就永久丢失。

针对这几点,实践中通常会做一层补偿:消费前先用RPOPLPUSH(或者 6.2 之后的LMOVE)把元素挪到一个“处理中”的 List,处理成功再删掉:

# 原子地把元素从待处理队列移到处理中队列 redis-cli brpoplpush queue:tasks queue:processing 5 # 处理成功后删除 redis-cli lrem queue:processing 1 '{"id":1,"type":"sync"}'

这样即使消费者崩溃,queue:processing里的数据还在,可以起一个补偿任务定期把超时未确认的元素搬回待处理队列。这个模式的可靠性比裸BRPOP高一个档次,代价是多一个 List 和一部分清理逻辑。

不过要说清楚:这一套只是“够用”,不是“可靠”。真正需要消息确认、消费组、失败重投的场景,应该用 Stream,它原生提供了消费组和 pending 列表。List 做队列适合的是“丢了也能接受”或者“量小、可以接受人工补偿”的场景,别拿它去跑订单、支付这类不能丢的链路。

4.4 用 List 做队列的可靠性边界

我把 List 做队列的适用边界归纳成四条,供参考。

第一,数据可以丢。比如日志采集、埋点上报、非关键的缓存预热,丢几条不影响业务。第二,消费幂等。因为可能重复投递(补偿逻辑会把处理中的元素搬回去),消费端必须能承受重复处理。第三,队列长度可控。List 没有长度上限,理论上能存 42 亿个元素,但真堆到这个量级,内存早就爆了。生产端必须做限流,或者在LPUSH之后检查LLEN,超过阈值就拒绝新任务。第四,不追求严格顺序。多个消费者并行时,处理顺序是无法保证的,如果你需要严格 FIFO,只能单消费者,吞吐就上不去。

这四条只要有一条不满足,就该考虑换方案。我在一个模拟项目里就吃过亏:本来用 List 做邮件发送队列,后来业务要求“发送失败必须重试三次”,硬生生在 List 上加了重试计数和延迟队列,越写越复杂,最后还是迁到了 Stream。早换早省事。

5. 实操过程与核心环节实现

5.1 环境与参数准备

找一台测试机,用容器起一个实例就行,重点是能改配置、能看编码。启动时把几个阈值调小一点,方便观察编码转换:

docker run -d --name redis-lab -p 6379:6379 redis:7.2 \ redis-server --hash-max-listpack-entries 8 \ --hash-max-listpack-value 16 \ --list-max-listpack-size 4

把 Hash 的阈值调到 8 个字段、16 字节,是为了让转换来得快一些,两三条命令就能看到编码变化。生产环境当然不能这么设,这里纯粹是为了演示。

验证参数生效:

redis-cli config get hash-max-listpack-entries redis-cli config get hash-max-listpack-value redis-cli config get list-max-listpack-size

5.2 编码验证脚本

先验证 String 的三种编码边界:

redis-cli flushall redis-cli set s:int 100 redis-cli set s:emb "12345678901234567890123456789012345678901234" # 44 字节 redis-cli set s:raw "123456789012345678901234567890123456789012345" # 45 字节 for k in s:int s:emb s:raw; do echo -n "$k -> " redis-cli object encoding $k done

预期输出依次是int、embstr、raw。如果 44 字节那个显示成了 raw,检查一下是不是有多余的换行或者引号被算进去了。

再验证 Hash 的编码转换:

redis-cli del h:test redis-cli hset h:test f1 v1 redis-cli object encoding h:test # listpack # 塞到超过 8 个字段 for i in $(seq 2 10); do redis-cli hset h:test f$i v$i > /dev/null; done redis-cli object encoding h:test # hashtable # 删掉字段,看是否回退 for i in $(seq 5 10); do redis-cli hdel h:test f$i > /dev/null; done redis-cli object encoding h:test # 仍然是 hashtable

最后一步是关键验证:编码转换不可逆。字段数已经降到 5 个,但编码还是 hashtable。这也解释了为什么有些 key 明明数据量很小,内存却下不来——它可能曾经大过。

5.3 一个“会话 + 计数器 + 队列”的完整 demo

下面这段用 Python 的 redis 客户端把三种类型串起来,模拟一个完整的业务闭环:用户登录写会话(Hash)、接口调用计数(String)、异步任务派发(List)。

import json import time import redis r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) SESSION_KEY = "session:{uid}" COUNTER_KEY = "counter:api:{uid}:{day}" QUEUE_KEY = "queue:tasks" def login(uid, token): key = SESSION_KEY.format(uid=uid) pipe = r.pipeline() pipe.hset(key, mapping={ "token": token, "login_at": int(time.time()), "last_active": int(time.time()), "status": "online", }) pipe.expire(key, 1800) pipe.execute() def touch(uid): key = SESSION_KEY.format(uid=uid) r.hset(key, "last_active", int(time.time())) r.expire(key, 1800) # 每次活跃续期 def check_session(uid, token): data = r.hgetall(SESSION_KEY.format(uid=uid)) if not data: return False return data.get("token") == token and data.get("status") == "online" INCR_LUA = """ local c = redis.call('INCR', KEYS[1]) if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return c """ incr_script = r.register_script(INCR_LUA) def count_and_limit(uid, limit=1000, ttl=86400): day = time.strftime("%Y%m%d") key = COUNTER_KEY.format(uid=uid, day=day) current = incr_script(keys=[key], args=[ttl]) return current <= limit, current def push_task(task): r.lpush(QUEUE_KEY, json.dumps(task)) def consume_task(timeout=5): item = r.brpop(QUEUE_KEY, timeout=timeout) if not item: return None return json.loads(item[1])

几个实现上的细节值得注意。login里用了 pipeline,把HSET和EXPIRE打包成一次往返,同时保证两条命令连续执行,减少中间被打断的窗口。touch是每次活跃都续期,这在实际业务里很常见,但要意识到它会让会话一直不过期,如果用户一直有请求,这个 key 就永远在。想控制总时长的话,得改成“只在首次登录时设过期,不续期”。count_and_limit用 Lua 保证自增和设过期的原子性,避免中间断开留下永久 key。

5.4 压测与内存观测

写完代码,跑一轮压测看实际表现。用redis-benchmark打一下基础命令:

# 10 万次 HSET,50 并发,看 QPS 和延迟 redis-benchmark -n 100000 -c 50 -t hset -q # 纯 LRANGE 批量读 redis-benchmark -n 100000 -c 50 -t lrange -q

压测期间另开一个终端看内存和慢查询:

redis-cli info memory | grep -E "used_memory_human|used_memory_peak_human|mem_fragmentation_ratio" redis-cli slowlog get 10 redis-cli memory usage session:1001

mem_fragmentation_ratio这个指标值得盯一下,正常在 1.0 到 1.5 之间,超过 1.5 说明碎片偏多,可能是大量短生命周期 key 造成的。这个值低于 1 反而要警惕,说明发生了 swap,性能会断崖式下跌。

慢查询日志默认只记录超过 10ms 的命令,如果怀疑有 O(N) 命令拖慢实例,可以临时把阈值调低:

redis-cli config set slowlog-log-slower-than 5000 # 5ms redis-cli slowlog reset redis-cli slowlog get 20

我在模拟环境里跑过一次对比:同样存一万个用户会话,Hash 方案平均内存是 1.2KB/条,String 存 JSON 是 1.9KB/条。差值主要来自 JSON 里重复的字段名和转义符号。这个差距在数据量上来之后会非常明显。

6. 常见问题与排查技巧实录

6.1 参数改了但编码没变

这是问得最多的问题。原因前面提过:编码是在 key 创建或修改时决定的,改配置只影响后续操作,不会追溯已有的 key。想验证,可以DEL掉重建,或者用DEBUG OBJECT看详细信息。

还有一种情况是“我以为它该是 listpack,结果是 hashtable”。这时候先看字段值的长度,很多人只关注字段数没超阈值,忽略了某个字段值特别长,超过了hash-max-listpack-value。用HSCAN遍历一遍,找出最长的那个字段,问题就清楚了。

6.2 内存比预期大很多

排查顺序建议是这样:先用--bigkeys找出最大的几个 key,再用OBJECT ENCODING看编码,最后用MEMORY USAGE确认实际占用。三步下来通常能定位。

常见原因有三个。一是编码不对,预期 listpack 实际 hashtable,内存直接翻倍。二是过期时间没设或者设错了,导致本该死的数据一直留着。三是“伪过期”逻辑没清理,字段值里的时间戳过期了但 key 还在。第二种和第三种建议用SCAN配合TTL抽样检查,写个小脚本跑一遍,比人肉看监控靠谱。

6.3 阻塞队列收不到消息

BRPOP看起来简单,但坑不少。第一,超时时间单位是秒,不是毫秒,写成 0.5 会被当成 0,也就是永久阻塞。第二,如果生产者用的是LPUSH,消费者就得用BRPOP,反过来配对没错,但两边都用LPUSH/LPOP就会变成栈的行为,顺序反了。第三,多个 key 的BRPOP k1 k2是按顺序检查的,不是同时监听,别指望它做优先级队列。

还有一种“假死”情况:消费者连接断了但没被感知,重连之后从新连接开始阻塞,之前那个连接占着的客户端槽位要等超时才释放。监控connected_clients能发现这种泄漏。

6.4 快速排查对照表

现象可能原因排查命令处理方式
Hash 内存偏高编码是 hashtableOBJECT ENCODING控制字段数/值长度,或重建 key
String 比预期大存在 embstr→raw 转换OBJECT ENCODING、STRLEN避免 APPEND,一次写完整
偶发高延迟O(N) 命令阻塞SLOWLOG GET、LATENCY HISTORY换批量命令或用 SCAN
队列元素丢失裸 BRPOP 无补偿检查queue:processing改用 BRPOPLPUSH + 补偿
内存不释放大 key DEL 阻塞或伪过期残留--bigkeys、TTL抽样用 UNLINK,补清理任务
key 数量暴涨一字段一 keyINFO keyspace、DBSIZE合并成 Hash

6.5 我自己的三条经验

第一条:新项目上线前,用真实数据量跑一遍MEMORY USAGE。别拿十条数据估算,至少拿一万条,因为编码转换往往在某个临界点之后才发生,小样本根本测不出来。我见过一个项目,测试环境一切正常,上线三天内存就顶到上限,原因就是测试数据没超过 listpack 阈值。

第二条:给每个 key 定一个容量上限,写进代码注释。会话最多 20 个字段,队列最多 10 万条,计数器每天重置。有了这个约束,后面接手的人不会乱塞东西,容量规划也有据可依。

第三条:别迷信“默认配置”。hash-max-listpack-entries默认 128,如果你的业务里 Hash 普遍只有 10 个字段,那这个默认值完全够用,不需要动;但如果普遍有 100 到 150 个字段,那就在临界点上反复横跳,每次新增字段都可能触发转换,内存曲线会非常难看。这种时候要么把阈值调高一点,要么把结构拆开,让字段数稳定落在阈值以下。

Set 和 Sorted Set 那部分,涉及交集并集的复杂度、跳表的随机层数、同分值下的字典序排列,还有延迟队列和排行榜这两个高频场景的具体实现,下一篇再展开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询