最近在给团队做技术分享时,总有人问我:Redis不是有String吗,为什么还要搞Hash、List那一堆类型?直接把对象JSON序列化塞进String,存取都方便,不是更省事?这个问题每次都会让我想起自己踩过的一个大坑。早年做某个用户中心模块,当时图省事,把所有用户信息用String存了个大JSON,一个key里面包含了十几个字段。结果改一次手机号要经历GET、反序列化、改字段、重新序列化、SET这一整套流程,并发稍微上来,Redis和业务接口双双超时,还因为Value太大拖慢了主线程。后来我把这个key拆成了Hash,字段级更新,问题立刻消失。
那次之后我才算真正明白,Redis的五种基本数据类型不是随意设计的,每一种都对应着不同的内存布局和操作特性。想用好Redis,必须先从原理层面理解它们,再结合实际场景选型。这篇内容不打算泛泛而谈,我会把String、List、Hash、Set、ZSet这五种类型从底层编码讲到命令实操,再讲到线上踩坑,最后给出一张可以直接参考的选型表。内容偏实战,适合刚入门Redis但想深入理解原理的同学,也适合已经用了一段时间但想排查线上问题、优化内存的开发者。
1. String:最常用,也最容易出事的类型
如果只给新手一个任务,大概率会把所有东西都塞进String。这确实是最简单的用法,但也是最容易在业务量上来之后翻车的用法。要理解为什么,得先看看一个String value在Redis内部到底是怎么存储的。
1.1 三种编码和SDS:一个看似简单的value,内部有很多讲究
String类型表面上就是“键值对”,value是字符串。但这个“字符串”并不是直接用C语言的char*数组存下来的,Redis自己实现了一个叫SDS(Simple Dynamic String,简单动态字符串)的结构。SDS除了保存字节数据之外,还会维护长度、已分配空间等信息,所以它可以在O(1)时间内拿到字符串长度,同时是二进制安全的,存入什么字节就能取出什么字节,不会因为中间出现\0被截断。
很多人忽略的是,SDS本身还有多种头结构,比如sdshdr8、sdshdr16、sdshdr32、sdshdr64,Redis会根据字符串的实际长度选择最小的头,目的是省那点元数据内存。单个key看不出来,但如果是几千万个key,每个key省几个字节,总量就非常可观了。
String对象主要有三种编码:
| 编码 | 触发条件 | 内存布局特点 |
|---|---|---|
| int | value能解析为整数,且长度在long long范围内 | 直接存整数值,做INCR/DECR时无需解析字符串 |
| embstr | value长度小于等于44字节 | 对象头和SDS分配在同一块连续内存,减少内存碎片 |
| raw | value长度大于44字节,或无法用整数表示 | 对象头和SDS分别分配,可能有两次内存分配,拷贝成本更高 |
注意那个44字节阈值,网上很多文章还停留在39字节,那是Redis 3.2之前的老黄历了。7.0版本里如果你存字符串时长度小于等于44个字节,大概率是embstr;一旦超过就会转成raw。你可以用OBJECT ENCODING key命令查看当前key的编码,这是排查内存问题非常有用的一个命令。
为什么要关心embstr和raw的区别?因为embstr的redisObject和SDS是一块连续内存,创建和释放只需要一次内存分配;raw需要两次分配,而且当字符串很长时,底层SDS扩容会涉及内存重新分配和数据拷贝。换句话说,Value越大,写入和读取时对主线程的影响就越明显。主线程阻塞,所有客户端都会跟着等。
1.2 命令实操:缓存、计数、分布式锁的正确姿势
String最常用的命令不外乎SET、GET、MSET、MGET、INCR、DECR、INCRBY、SETNX、SETEX。这里说几个容易被写错的点。
第一,INCR是原子操作。这是Redis单线程模型带来的天然优势,多个客户端同时执行INCR,结果不会出现并发覆盖。实际业务里我经常用它做计数器,比如记录文章点击量、接口调用次数、每天注册人数。如果你用的是GET再SET,那就要加锁或者Lua脚本,否则一定会有并发问题。
第二,SET命令可以带参数:SET key value EX 60 NX。这一行同时完成了“设置值”和“设置过期时间”以及“不存在才设置”三件事,是手写分布式锁的基础实现。很多人还习惯用SETNX加EXPIRE两条命令去实现分布式锁,这样如果第二条命令执行前进程崩溃,锁就永远不会过期,其他线程会被卡死。所以最低限度也要用SET key value EX NX这种原子写法。
第三,MSET和MGET能成批读写多个key,把多次网络请求合并成一次,对于减少RTT效果显著。但要注意,MGET拿回来的值不是按key名字自动映射成对象,需要你自己去对应顺序,使用的时候别拿错。
1.3 翻车记录:Value过大、TTL与原子性
这一小节必须说说我实际踩过的那些坑,希望你能绕开。
第一个坑是大Value。曾经有人把一整个报表数据序列化之后存成String,Value有好几MB,读写一次慢,序列化一次更慢,还特别容易导致Redis慢查询日志里全是SET和GET。Redis是单线程,一次大Value的读写会阻塞后面所有操作,哪怕你的QPS再高,整体吞吐也会被拉垮。后来我们把大JSON拆成了按天、按维度的多个小key,问题才算解决。
第二个坑是忘记设置TTL。如果你用String做缓存,很多业务场景里没有“失效时间”,那么key会一直占着内存,最终把内存打满。做缓存的时候,一定要想清楚这个数据是否能容忍短暂的不一致,能的话就设一个合理的过期时间,比如SET cache_key value EX 300。
第三个坑是更新操作的原子性。像“扣减某个库存先查询再判断再更新”这种逻辑,不要用GET、DECR两条命令去拼,应该直接用DECR或者用Lua脚本保证原子性。否则两个请求同时读到同一个库存数,都会认为库存够用,然后各自扣减,最终库存变成负数。即使是Redis,不合适的命令组合照样会超卖。
2. List:队列、栈,以及并发下的那些副作用
List是Redis里最容易和“消息队列”产生联想的数据类型。它确实可以当队列用,但和专业的消息中间件相比,它少了很多可靠性语义。理解底层quicklist结构,会帮你明白为什么网上有些教程“教你用List做MQ”是很危险的。
2.1 底层quicklist:为什么阻塞操作不能乱用
List底层在Redis 3.2之前有两种实现:压缩列表和双向链表。3.2之后统一成了quicklist,采用“双向链表 + 节点内压缩列表”的组合结构。简单说,quicklist就是一个双向链表,每个节点里装了一个压缩列表,把多个元素紧凑地存到一个节点里,既保留了双向链表在两端插入删除的高效,又通过压缩列表减少了指针带来的内存开销。
到了Redis 7.0,内部的一些压缩列表被listpack(紧凑列表)替代,但整体思路仍然是:列表项少的适合紧凑存储,列表项多而且长的用链表结构。Redis提供了list-max-listpack-size等配置来控制节点内能存多少个元素,一般默认就够用,不建议随意调大,调太大反而会让单个节点内插入、删除的耗时上升。
很多文章会强调LPUSH、RPOP是O(1),但很少有人提阻塞命令的代价。BLPOP、BRPOP会让客户端在列表为空时进入阻塞等待,直到有生产者推入数据或者超时。如果业务里大量存在这种阻塞指令,Redis需要维护每个等待的客户端,连接数一旦上来,会占用大量内存和文件描述符,处理不当还会拖慢主线程。
2.2 实战:怎么用List做一个足够用的任务队列
List做任务队列的标准姿势是:生产者使用LPUSH,消费者使用BRPOP。BRPOP key timeout会阻塞等待直到拿到数据或超时返回nil。这样做的好处是避免消费者空转轮询,减少无谓的请求。
命令大概长这样:
# 生产者 LPUSH task_queue "task_001" LPUSH task_queue "task_002" # 消费者,阻塞10秒 BRPOP task_queue 10在并发消费者场景下,多个消费者同时执行BRPOP,Redis会保证每条消息只被一个消费者拿走,不会出现多消费者抢同一条消息的情况。这一点比很多自己用数据库轮询做的任务表好得多。
但必须明确,这里的“可靠性”很低。BRPOP把消息从List里弹出来之后,消息就已经不在Redis里了。如果消费者拿到消息后服务宕机,这条消息就彻底丢失。对于允许丢任务、同时不会造成重大损失的后台任务,比如清理临时文件、发送通知,这么干问题不大。但如果是订单处理、支付扣款这类核心链路,还是老老实实用Redis Stream或者专业MQ,支持消费者确认和重新投递。
2.3 别学网上乱用:LRANGE的大坑
List还有一个很容易被误用的命令LRANGE。它的作用是取范围内的元素,复杂度是O(N)。如果你只是想看一眼List的前几条,LRANGE 0 0没问题;但如果你是写了个定时任务去扫描整个队列,直接LRANGE 0 -1,当List长度到了几十万甚至上百万时,Redis主线程一次性要把这么多元素全部序列化并发送给客户端,慢查询卡顿几乎必然出现。
正确做法是分批读取,比如每次取1000条,用游标记录位置:
LRANGE task_queue 0 999 LRANGE task_queue 1000 1999同时,还要避免List只进不出。很多List变成大key,根本原因是消费者挂了,生产者还在拼命往里塞数据。所以监控里一定要看List的长度,连续上涨就要报警。LLEN是O(1),可以放心用。
3. Hash:对象存储的正确打开方式
如果你要存储一个对象,比如用户资料、商品详情、购物车数据,Hash类型几乎总是比String存JSON更好的选择。这一节我从底层编码讲起,然后再看具体命令怎么用。
3.1 从ziplist到listpack:小hash为什么又省内存又快
Hash在Redis中有两种编码:
- 当field数量少且每个field的key/value长度都不大时,使用紧凑结构(旧版为ziplist,7.0之后为listpack),把所有field-value按顺序平铺在连续内存中。
- 当field数量超过
hash-max-listpack-entries,或者某个field的key/value长度超过hash-max-listpack-value时,自动转为hashtable,也就是标准的哈希表结构。
listpack和hashtable的区别就像“一个按顺序摆放的小储物箱”和“一个带索引货架的大仓库”。小规模时,顺序遍历成本很低,而且因为内存连续,缓存局部性好,CPU效率高;转到hashtable之后,虽然每个字段的读写都变成O(1),但每个entry都有额外的指针和元数据,内存开销会大不少。
我用实际数据验证过,一个小对象如果字段总数在100以内,每个字段长度不超过几十字节,用listpack编码存储的内存量比hashtable编码能少一半甚至更多。所以Redis配置中默认的hash-max-listpack-entries=128、hash-max-listpack-value=64是有道理的,没必要为了追求“O(1)查询”而强行调小阈值。
3.2 HSET/HINCRBY的日常:购物车、用户实时属性
Hash的常用命令有:HSET、HGET、HMSET、HMGET、HGETALL、HINCRBY、HDEL、HEXISTS。
拿购物车举例子:每个用户一个key,商品ID作为field,商品数量作为value。
HSET cart:1001 sku_a 2 HSET cart:1001 sku_b 1 HINCRBY cart:1001 sku_a 1这样用户往购物车加一件商品,只需要知道当前数量再+1,用HINCRBY一条命令就搞定,不需要把整个购物车读出来再写回去。
再比如用户资料,过去我们习惯存一个大JSON字符串,修改手机号就要整体覆盖。换成Hash之后:
HSET user:1001 name "tom" age 18 HSET user:1001 phone "138xxxx"你只改一个field,Redis只处理这一个field的写入,网络传输量也远小于整体覆盖,性能差距在高并发下非常明显。Hash还能对不同field做局部老化吗?整体过期只能针对整个key,不能单独给field设置TTL(除非使用7.4版本开始支持的field级过期命令,但这是新特性,不宜在核心场景依赖)。所以需要整个对象存活周期一致时,用Hash非常合适。
3.3 HGETALL的隐患与HSCAN替代
Hash最大的坑之一,是HGETALL在key的field数量很大时,会把所有field-value一次性拉出来。如果这个Hash有几十万field,HGETALL就会变成慢查询,还要在网络上传输一大块数据,可能直接把Redis的带宽打满。
排查时可以用HSTRLEN之类的命令观察大小,但规避办法是改用HSCAN分批遍历:
HSCAN user:1001 0 COUNT 100HSCAN和SCAN一样,是增量迭代命令,每次返回一部分field-value,需要不断用返回的游标继续遍历,直到游标回到0。它不会像HGETALL那样一次性产生巨大的阻塞。
另外,Hash也不适合用来存放无界增长的集合。比如你想把某个用户的所有操作日志都存在一个Hash的field里,日志不断增加,这个Hash迟早会变成超大key。遇到这种情况,应该用List存储按时间追加的日志,或者为每天/每小时单独设一个key,让每个key保持可控大小。
4. Set与ZSet:去重、关系运算,以及排行榜背后的数学细节
把Set和ZSet放在一起讲,是因为它们都建立在“集合”语义上,但一个无序、一个有序;一个没有分数,一个每个成员都绑定一个double类型的score。两者在底层编码上也有相通之处,实际场景里经常配合使用。
4.1 Set的intset与hashtable:多少元素触发升级
Set的编码有两种:intset(整数集合)和hashtable。当集合中所有元素都是整数,并且元素个数不超过set-max-intset-entries(默认512)时,使用intset。intset内部是一个有序的整数数组,元素从小到大排列,查找时使用二分法,内存非常紧凑。一旦有非整数元素加入,或者元素数量超过512个,它就会转为hashtable编码,之后每个元素都作为哈希表的key存储,内存开销会大幅上升。
这个阈值对你的业务选型是有影响的。如果你要存储一个小范围枚举值集合,并且数量很少,比如用户性别偏好0/1/2,用intset编码极省内存。但如果你用Set存几万个UUID字符串,那就别指望intset了,一开始就会是hashtable编码,内存开销按几个GB来算也不奇怪。
Set常用命令有SADD、SREM、SISMEMBER、SCARD、SMEMBERS、SPOP、SRANDMEMBER、SINTER、SUNION、SDIFF。其中SISMEMBER是O(1),非常快,适合做去重判断。SMEMBERS会返回所有元素,复杂度O(N),大集合上要慎用,应该用SSCAN。
还有一点容易踩坑:SINTER在对多个大集合做交集时,复杂度大约是O(N*M),不仅消耗CPU,还会产生一个巨大的结果集存储在内存中(如果用了SINTERSTORE)。如果只是需要判断两个集合是否有交集,而且集合很大,最好还是在应用层把小集合的成员逐个SISMEMBER到大集合里,或者使用Redis 7.0引入的SINTERCARD命令只返回交集的基数,避免结果集展开。
4.2 ZSet的跳表细节:为什么同分会按字典序排
ZSet(有序集合)是Redis里最“重量级”的一个基本类型。它同时维护了两套结构:一个哈希表,用来按member精确查询score,复杂度O(1);一个跳表(skiplist),用来按score范围查询,以及按score排序。两者通过指针共享member和score数据。
跳表本质上是一个多层级的有序链表,通过概率算法在插入时确定节点层数,平均查询、插入、删除都是O(logN)。Redis选择跳表而不是平衡树的原因之一是实现简单、内存占用可控、范围查询友好,而且并发场景下不需要复杂的旋转操作。
这个底层结构带来了一个很反直觉的行为:当多个成员的score相同时,ZSet并不是按插入顺序排序,而是按member的字典序升序排列。也就是说,如果两个用户积分都是200,那么用户“bob”会排在“alice”后面,即使“bob”是更早加入排行榜的人。这个问题不弄明白,写出来的排行榜很可能会“看起来很怪”。
ZSet常用命令包括ZADD、ZINCRBY、ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZSCORE、ZRANK、ZREVRANK、ZREM。需要注意ZRANGE默认是升序,而排行榜通常需要从高分到低分,所以要用ZREVRANGE,千万别搞反。
4.3 实战:排行榜同分、抽奖、好友关系的完整设计
先看一个最常见的排行榜场景。把用户ID作为member,把积分作为score:
ZADD rank:game 1000 user_01 ZADD rank:game 1500 user_02 ZINCRBY rank:game 200 user_01 # 取前10名 ZREVRANGE rank:game 0 9 WITHSCORES如果业务要求同分时先到先得,也就是“积分相同,按达到该分数的先后排序”,不能只存一个积分。常见做法是把score设计成一个复合值:score = 积分 * 常数 + (某个倒转后的时间戳)。但score是double类型,精度有限,整数部分太大时会丢失精度,需要谨慎使用。更稳的方案是把ZSet的score只作为第一排序维度,用另一个字段记录时间戳,在应用层做二次排序,或者使用多级zset。
再讲一个不太起眼但很实用的场景:用ZSet实现延迟队列。如果把每条消息的执行时间作为score,那么通过ZRANGEBYSCORE queue -inf 现在时间 LIMIT 0 100就能取出所有已经到期的任务。处理完再用ZREM移除。缺点是和List队列一样,任务被取出后如果处理失败,需要自己负责重新放回。但比起List,ZSet的优势是可以让消费者“只取到期的任务”,而且能够通过重复查询模拟延时触发。
# 添加一个5秒后执行的任务,假设当前时间戳为 10000000000 ZADD delay_queue 10000000005 task_001 # 循环执行:取出所有已到期的任务 ZRANGEBYSCORE delay_queue -inf 10000000000 WITHSCORESSet在社交关系场景也很好用。一个用户的好友ID集合用Set存,共同好友就是两个Set的SINTER结果;用户点赞过的内容也可以用Set存,再次点赞时用SISMEMBER判断是否已经点过。抽奖活动里,如果你希望抽完还在池子里,用SRANDMEMBER;抽完就移除,用SPOP。我发现不少团队会把“抽奖去重”做成一个独立的布隆过滤器,其实一个SADD就能天然去重,还能顺便统计参与人数SCARD。
5. 选型决策与线上排查:数据量上去之后怎么办
到这里,五种类型的基本原理和常见用法都过了一遍。最后这部分我想聚焦到“选型”和“排障”上,给你一张可以直接参考的决策表,以及在线上最常用的几个排查命令。
5.1 一张选型表:每种类型的核心特征与适用场景
| 类型 | 底层编码(常见) | 核心复杂度 | 最擅长的场景 | 尽量不要做的事 |
|---|---|---|---|---|
| String | int、embstr、raw | GET/SET O(1) | 缓存、计数器、分布式锁、短文本 | 存超大对象、频繁整体覆盖 |
| List | quicklist(节点内listpack) | 两端操作O(1),中间O(N) | 简单队列、栈、最新消息列表 | 直接全量LRANGE、当可靠MQ |
| Hash | listpack、hashtable | 单field操作O(1),HGETALL O(N) | 对象存储、购物车、实时属性修改 | 用HGETALL取超大hash |
| Set | intset、hashtable | SADD/SISMEMBER O(1),集合运算O(N*M) | 去重、标签、关系运算、抽奖 | 在大集合上频繁做SINTERSTORE |
| ZSet | listpack、skiplist+hashtable | 增删改查O(logN),范围查询O(logN+M) | 排行榜、延迟队列、在线用户排序 | 忽视同分时的字典序规则 |
这张表不是让你背的,而是让你心里有个大概框架。真正选型的时候,先问三个问题:这个数据是一个对象还是一个集合?需要不需要排序?读多写少还是写多读少?回答完这三点,基本就能锁定类型了。
5.2 用OBJECT ENCODING实测内存优化
排查线上Redis内存问题时,OBJECT ENCODING和MEMORY USAGE是我的首选组合。进入redis-cli,直接执行:
SET counter 123 OBJECT ENCODING counter # 输出: int SET short_str "hello" OBJECT ENCODING short_str # 输出: embstr SET long_str "a一千字的长字符串..." OBJECT ENCODING long_str # 输出: raw HSET small_hash f1 v1 f2 v2 OBJECT ENCODING small_hash # 输出: listpack SADD small_set 1 2 3 OBJECT ENCODING small_set # 输出: intset当你写的程序运行在线上,出现了Redis内存上涨过快的问题,我建议先抽样看看占比最大的那些key分别是什么编码。如果一堆大Hash已经变成hashtable,而你的业务字段其实不多,可以考虑调整hash-max-listpack-entries阈值,或者主动把大key拆开。这里有个经验:编码从紧凑结构变成hashtable之后,即使后面你再把field删到很少,它通常也不会退回紧凑结构,所以一旦发现大Hash已经生成,尽早拆key,别指望它会自己瘦回去。
5.3 大Key排查、内存碎片与持久化建议
大Key是Redis线上事故的常见元凶。可以用redis-cli --bigkeys来扫描整个实例,它会按类型分别找出最大的几个key,并给出字节数。这个命令是异步扫描,线上执行比想象中安全,但深夜低峰期跑更稳妥。发现大key之后,不要直接DEL,因为删除大key本身也会阻塞主线程。Redis 4.0之后提供了UNLINK,它会在后台异步释放内存,删除大key时优先用这个。
内存碎片方面,当你的应用频繁更新大value、分配和释放内存不对齐时,碎片率会升高。执行INFO memory可以看到mem_fragmentation_ratio,如果长期大于1.5,说明碎片化比较严重。可以尝试调整activerehashing、maxmemory-policy,必要的时候通过维护窗口分批迁移数据来释放碎片,而不是简单粗暴重启。
最后补充一点持久化建议。Redis使用RDB持久化时,fork子进程需要复制父进程的页表。如果实例里有很多大key,内存占用巨大,fork的耗时也会明显变长,导致主线程停顿。这也是为什么前面反复强调控制单key大小的原因。你可以把单key最大的value控制在几十KB以内,大部分场景完全没有必要用到MB级别的value。
我个人的体会是,数据类型选型不是玄学,它和底层编码关系特别大。当你理解了String的raw为什么慢、Hash的listpack为什么省内存、ZSet的跳表为什么能支撑排行榜,很多线上的异常都能直接推断出原因。如果读完这篇文章,你只能记住三件事,那我建议是这几个:线上多用UNLINK删除大key、大集合别一次性HGETALL/SMEMBERS/LRANGE、以及排行榜同分问题早做设计。Redis的能力很强,但它的高性能是建立在“合理使用类型”之上的。