先说一个我自己踩过的坑。以前做内容社区的时候,用户资料是 String 一把梭,整个 JSON 序列化之后丢进 Redis。表面上看很方便,GET 一下全有了,但用户改头像、改昵称、改积分的时候,我只能整条读出来、反序列化、再整个写回去。流量一大,GC 频繁不说,Redis 实例的带宽也被无辜消耗。后来把用户对象改成 Hash,一个字段改一个字段 set,线上系统立刻安静了很多。这件事让我对 Redis 的"数据类型"有了完全不同的理解:别把 Redis 当成只会存字符串的缓存,它本质上是数据结构服务器。
这篇文章围绕 Redis 的数据类型展开,覆盖 String、Hash、List、Set、ZSet 这五种核心结构,以及 Bitmaps、HyperLogLog、GEO、Streams 这些进阶类型。我会把每种类型背后"为什么这么设计"讲清楚,再给一套可以直接抄走的命令和落地案例。无论你是刚接触 Redis 的新手,还是已经会在项目里用的工程师,只要还在为选型、大 Key、缓存治理、序列化这些问题头疼,这篇应该都能帮上忙。
1. 数据类型全景:Redis 能存的不只是字符串
1.1 五大数据类型的定位与形态
Redis 官方文档里最常出现的一句话是"Redis 是一个开源的、内存中的数据结构存储"。注意"数据结构"这个词,它没有说自己是那种只存字符串的 key-value 系统,因为它确实不是。Redis 的每个 key 后面,value 可以有完全不同的内部结构,不同结构对应不同的命令和场景。
先把五大基础类型放在一起看,这样心里有个骨架:
| 类型 | 底层存储形态 | 核心能力 | 典型业务场景 |
|---|---|---|---|
| String | 字节数组 / 整数 | 简单读写、原子自增 | 缓存、计数器、分布式锁 |
| Hash | 字段值对 | 对象局部更新 | 用户资料、购物车 |
| List | quicklist 双向链表 | 头尾插入、阻塞弹出 | 消息队列、最新列表 |
| Set | intset / 哈希表 | 去重、交并补运算 | 标签系统、共同好友 |
| ZSet | listpack / 跳表+哈希表 | 按分数排序、范围查询 | 排行榜、延迟队列 |
看到这张表,你应该能感受到 Redis 设计者当初的思路:每一种数据类型都对应一类高频问题。String 解决缓存和计数的基本盘;Hash 解决对象多字段的频繁读写;List 解决顺序存储和消息传递;Set 解决集合关系;ZSet 解决排序场景。它们不是随意拼凑的 API,而是对业务建模时最常出现的几种数据形态做了服务端原生支持。
1.2 从"为什么要这么多类型"看选型逻辑
很多人在面试题里背过"Redis 有五种数据类型",但真正到业务里选型时就懵了。根源在于没有理解类型之间的取舍。
String 最简单,但"全量更新"非常浪费。用户改一个字段,你要把整个 JSON 序列化送回 Redis,多字段对象反复读写时,网络开销和 CPU 开销都很高。Hash 就是为了解决这个问题的,它可以单独更新某个字段,HINCRBY还能做字段级原子加减。List 解决的是"按到达顺序排队"的问题,在并发环境下用 String 拼一个数组手动维护队列,很容易出现竞态丢数据。Set 解决的是"集合关系"问题,比如两个用户共同关注了哪些人,用 Set 的SINTER一下就能拿到结果,你手动拆字符串要遍历两遍。ZSet 解决的是"每次读都要排序"的问题,关系型数据库 ORDER BY 在低并发下能撑住,一旦流量上来,循环查排序很容易被打爆,而 ZSet 在写入时就已经维护好了顺序。
所以选型逻辑不是"背熟每个命令",而是判断业务到底属于哪一类问题:是缓存、是对象、是队列、是集合、还是排名。想清楚这个,你自然就知道该选谁了。
2. 五种核心数据类型的命令、编码与应用
2.1 String:最常被低估的基本盘
String 看起来最简单,但牵扯的命令不少。基础读写命令是SET、GET、MSET、MGET,批量命令能有效减少网络往返。再往上是SETEX(写入并指定过期时间)、SETNX(不存在时才写入,分布式锁的核心)、INCR/DECR/INCRBY/INCRBYFLOAT(计数器操作)、APPEND、STRLEN、GETRANGE、SETRANGE。
String 内部有三种编码:int、embstr、raw。如果你写入的值能被解析成 64 位整数,Redis 直接以整数形式存储;如果长度小于等于 44 字节,会用embstr这种紧密结构,一次内存分配搞定;超过 44 字节才转成raw。这个细节很实用,比如计数器用INCR走 int 编码,内存占用非常小;短 ID 走 embstr,也不占空间。反过来,你要是把整篇大文章塞进去,raw 编码加上内存碎片,Redis 的大 Key 问题很快就来了。
String 最大的价值是"原子性"。INCR不是"读出来加一写回去"这种非原子操作,而是服务端直接完成的自增。做访问量、库存扣减、限流计数时就别再用 GET + SET 了,直接用INCRBY,并发下不会丢更新。
有一个通用建议:String 里尽量放"短平快"的数据,比如 JSON 字符串、序列化后的短视频、Token 等;别放超大二进制文件,虽然单个 String 上限是 512MB,但 Redis 是单线程处理命令,一个大对象读取或写入会阻塞其他请求。
> SET user:1:token "a1b2c3d4" EX 7200 OK > INCR visits:today (integer) 1001 > SET order:lock:1001 "uuid-123" NX EX 30 OK2.2 Hash:给对象一个"字段级维护"的容器
Hash 非常适合表示一个对象。用户信息、商品详情、配置项、购物车,都是天然的字段值结构。常用命令是HSET、HGET、HMSET、HMGET、HGETALL、HDEL、HEXISTS、HINCRBY、HLEN、HSCAN。
Hash 什么时候用小编码?Redis 7.0 引入了listpack,在字段不多、字段长度不大的时候,整个 Hash 会保存成一段连续内存,既节省内存又加快遍历。当字段数量或单个字段值超过阈值(通常默认 128 个字段、64 字节),它会转成hashtable,牺牲紧凑性换取 O(1) 的随机访问。你可以通过CONFIG GET hash-max-listpack-entries查当前版本的阈值。
这个类型的优势表现在"局部更新"上。我前面提到的用户资料场景就是典型代表:用户改昵称,HSET user:1 nickname "老王"就完了,不用把整个用户 JSON 反序列化再写回去。另一个典型是购物车,HSET cart:user1 sku:1001 2代表加了两件商品,HINCRBY cart:user1 sku:1001 1再追加一件,全部是字段级操作。
但 Hash 有个很常见的坑:HGETALL会把所有字段一次性拉出来。开发环境里对象只有 5 个字段还好,上线后字段膨胀到几百个,内存和带宽都会被打爆。正确做法是用HSCAN分批遍历,或者在业务层只拉需要的字段。另外,如果一个 Hash 里的单个字段塞了一个超长 JSON,整个 key 都会从 listpack 转为 hashtable,反而降低紧凑性,所以不要觉得 Hash 能无限堆字段就乱塞。
> HSET user:1001 name "张三" level 5 points 1200 OK > HINCRBY user:1001 points 300 (integer) 1500 > HMGET user:1001 name points 1) "张三" 2) "1500"2.3 List:时间线、队列和快速链表
List 是基于 quicklist 实现的双端链表结构。quicklist 是"多个紧密节点 + 双向指针"的组合体,兼顾了内存紧凑和头尾操作的高效。LPUSH/RPUSH往头尾加元素是 O(1),LPOP/RPOP弹出也是 O(1)。但注意,LINDEX和LRANGE的时间复杂度是 O(N),因为链表不能像数组一样通过下标直接跳到中间。
常用命令包括LPUSH、RPUSH、LPOP、RPOP、LRANGE、LINDEX、LTRIM、LLEN、LINSERT,以及阻塞版本BLPOP、BRPOP。List 最常见的场景是消息队列:生产者用LPUSH塞消息,消费者用BRPOP阻塞等待,超时时间设为 0 就是一直等。还有一个很常用的技巧:用RPOPLPUSH做可靠队列,从主队列取任务的同时备份一份到 processing 队列,处理完再删,防止消费者崩了丢消息。
List 也很适合做"最新动态"这类场景。比如用户最近浏览 20 条记录,每次访问执行LPUSH user:1:history content:1001,随后LTRIM user:1:history 0 19把超出 20 条的尾部裁掉。这样 List 的长度永远被控制在 20 条,内存不会无限增长,查询时LRANGE 0 -1直接拿全量。
需要提醒的是:List 没有去重能力,消息可能重复;也没有原生的确认机制,数据被POP出来就消失了。早期项目拿 List 当消息队列用,是在 Redis 3.x 时代没有 Streams 的妥协方案。现在如果你要做相对可靠的消息投递、消费组、ACK 确认,应该优先考虑 Streams,而不是执着于 List。
> LPUSH task:queue "task-001" "task-002" (integer) 2 > BRPOP task:queue 5 1) "task:queue" 2) "task-002"2.4 Set:无序集合的三板斧
Set 是一个不重复的无序集合。常用命令是SADD、SREM、SISMEMBER、SMEMBERS、SCARD、SPOP、SRANDMEMBER、SINTER、SUNION、SDIFF,以及对应的 STORE 变体SINTERSTORE、SUNIONSTORE、SDIFFSTORE。
Set 的底层有两种形态:当元素全部是整数且数量较少时,使用intset,也就是一段有序整数数组,查询走二分查找;一旦出现非整数元素或数量超过阈值,就转成哈希表结构。这个设计让小集合的内存非常紧凑。
Set 在实际业务里真的太好用了。第一个经典场景是标签系统:每个内容有一个 Set 存标签,每个用户有一个 Set 存偏好标签,推荐时用SINTER取交集,得到用户最可能感兴趣的内容。第二个是去重:比如每天给同一个用户发放一次性福利,用SADD today:gift:user1 product:1001,如果返回 0 就说明今天已经领过了。第三个是抽奖:SPOP随机弹出一个元素,SRANDMEMBER随机查看一个元素但不删除,两种需求都能覆盖。
注意 Set 的全量操作其实很危险。SMEMBERS会把集合内全部元素返回,SINTER和SUNION的复杂度与集合大小直接挂钩,如果集合到了几十万量级,很可能造成慢查询。我在生产环境里见过因为SMEMBERS一个几十万元素的集合导致 Redis 阻塞几秒钟的案例。优先用SSCAN分批遍历,或者控制集合的规模,比如只保留最近 30 天的数据。
> SADD content:1001:tags redis cache (integer) 2 > SADD user:1:tags redis python (integer) 2 > SINTER user:1:tags content:1001:tags 1) "redis"2.5 ZSet:有序集合才是 Redis 的王牌类型
ZSet 是我个人最偏爱的类型,它同时具备了"不重复"和"按分数排序"两个能力。每个元素绑定一个 double 类型的分数,Redis 内部维护了一份跳表,保证按分数范围查询的高效。常用命令有ZADD、ZREM、ZSCORE、ZCARD、ZCOUNT、ZINCRBY、ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZRANK、ZREVRANK、ZPOPMIN、BZPOPMIN。
底层结构在小数据量时用 listpack 紧凑存储;数据量上来之后会转成"跳表 + 哈希表"的组合。跳表负责按分数排序和范围查询,哈希表负责 O(1) 地获取分数。为什么用跳表而不用红黑树?因为 ZSet 要频繁支持范围查询,跳表实现起来比红黑树简单,配合哈希表查分数也很快,一组合既简单又高效。
最经典的应用是排行榜。比如ZINCRBY rank:total 1 content:1001给内容增加一次热度,运营要榜单时用ZREVRANGE rank:total 0 9 WITHSCORES取前 10 名。还有延迟队列:把任务的执行时间戳作为分数,ZADD delay:task 1735689600 task-001,worker 一直执行ZRANGEBYSCORE delay:task 0 now LIMIT 0 10,取出到期的任务,处理后用ZREM删除。这样就用一个简单的结构实现了一个轻量级延迟队列。
ZSet 有个细节容易忽略:当分数相同的时候,它按照成员名字的字典序排列,不是"先到先得"。如果你要做严格的时间排序,可以把分数设计成"数值 = 计数 * 大常数 + 时间戳",但要注意 double 的精度有限制,数字太大会失真。遇到这种情况建议用分数存业务分数本身,再在成员里带上时间戳字段,比如member = "content_1001_20231201",需要时再解析。
> ZINCRBY rank:total 1 content:1001 "1" > ZINCRBY rank:total 1 content:1002 "1" > ZREVRANGE rank:total 0 1 WITHSCORES 1) "content:1002" 2) "1"2.6 进阶类型:Bitmaps、HyperLogLog、GEO 与 Streams
这四类不算"基础数据类型",但在实际场景中非常值得掌握。
Bitmaps 本质是 String 上的位操作。它用位存储状态,比如用户签到,SETBIT user:sign:202312 0 1表示 12 月 1 号签到,BITCOUNT统计整个月的签到天数。这种方案极端节省内存,一亿个用户只需要 12MB 左右。
HyperLogLog 是用来估算唯一基数的,典型命令是PFADD和PFCOUNT。比如统计一个页面当天的 UV,每天有百万级访问,直接存 Set 会占很多内存,而 HyperLogLog 用约 12KB 内存就能在 0.81% 误差内给出估算值。注意它是"估计值",不是精确值,所以金融对账这类场景不能用,运营看趋势规模完全够用。
GEO 是基于 ZSet 实现的地理位置工具。GEOADD存经纬度,GEODIST算距离,GEOSEARCH查附近的人或门店。因为底层是 ZSet,所以执行效率和稳定性都不错。做门店 App、打车调度都能直接用。
Streams 是 Redis 5.0 引入的日志型数据结构,可以说就是给消息队列场景准备的。它有XADD往流里追加消息,XREAD读消息,XGROUP做消费组,XACK确认消息。相比 List 的简易队列,Streams 支持多消费者组、消息持久化、ACK 机制,适合真正需要消息可靠性的场景。
3. 完整实操:用 Redis 数据类型搭一个"最近浏览 + 排行榜"功能
3.1 业务设计与键规划
假设现在做一个内容 App,用户会在 App 里浏览文章,系统需要记录"每个人最近浏览过的 20 篇内容",同时要维护"全站最热内容排行榜",运营后台还要按标签做相关推荐。这个需求非常适合用 Redis 的数据类型来撑。
我们需要这些 key:
content:{contentId}:用 Hash 存文章详情(标题、作者、发布时间、标签)。user:{userId}:history:用 List 存最近浏览记录。rank:total:用 ZSet 存全站热度排行。user:{userId}:tags与content:{contentId}:tags:用 Set 存偏好标签和内容标签。
先规划好 key 的含义,这是 Redis 建模最容易忽略的部分。key 要可读、有统一前缀、层次清晰,比如user:{id}:history,尽量避免无意义的长随机串。这样排查问题时你用redis-cli --scan --pattern "user:*"能一眼看出数据长什么样。
3.2 落盘命令:从写入到查询的全流程
当用户点开一篇文章时,写入流程拆成四步:
# 第一步:如果内容第一次进入 Redis,把详情写进 Hash > HSET content:1001 title "Redis数据类型实战" author "张三" tags "redis,cache" # 第二步:把浏览记录往用户的历史 List 头部插入,并裁剪到 20 条 > LPUSH user:1:history content:1001 > LTRIM user:1:history 0 19 # 第三步:内容热度 +1 > ZINCRBY rank:total 1 content:1001 # 第四步:把内容标签同步到用户偏好 Set(可简化,仅演示标签关系) > SADD user:1:tags redis查询流程也拆成三步:
# 取用户最近浏览列表 > LRANGE user:1:history 0 -1 # 取全站热度榜 TOP 5 > ZREVRANGE rank:total 0 4 WITHSCORES # 取用户标签与内容标签的交集,用于推荐 > SINTER user:1:tags content:1001:tags这套实现可以在毫秒级完成一次浏览记录和排行榜更新,而且不需要任何关系型数据库参与。如果你把同样的功能放在 MySQL 里,每次浏览都要 INSERT 一条历史记录,还有 UPDATE 内容热度,然后排行榜要实时 ORDER BY,压力完全不是一个等级。
3.3 数据读写过程中的性能优化
写这套功能时,有几点能明显提升性能:
用 Pipeline 批量提交。用户浏览一篇文章要同时写 Hash、List、ZSet、Set,如果用四次 RTT(Round-Trip Time),单条命令耗时会叠加。用 Redis Pipeline 或者客户端的多命令批量接口,可以把四步合并成一次网络往返,在高并发接口里这个收益非常明显。
List 的裁剪时机很关键。LTRIM虽然本身很快,但如果你每次 LPUSH 之后都执行,确实多一次命令。可以把裁剪放到异步任务里定时做,或者每次 LPUSH 后只在 List 长度超过一定值时再 LTRIM,减少无效调用。
Hash 字段尽量精简。文章的标签如果本身是字符串列表,可以不拆成 Set,先放在 Hash 的 tags 字段里。但标签后期会涉及交集推荐,所以拆出来放 Set 更合理。这就是建模时的取舍,宁可多一个 key,也不要在业务里反复反序列化。
3.4 序列化与可视化客户端带来的坑
Redis 存储的 value 本质是字节数组,你用什么语言写,它就存什么字节。Java 原生序列化写进 Redis 的是一段带类型信息的二进制流,在 redis-cli 里看就是乱码;JSON 序列化则相对可读。这本身不是问题,但排障时会很痛苦。
我自己排查线上数据时,最常用的工具是 RedisInsight 和 Another Redis Desktop Manager。它们能直观看到 key 的类型、TTL、内存占用。不过当你看到一个 value 全是乱码时,先别怀疑数据损坏,大概率是应用层用了二进制序列化。建议在数据近源侧统一使用 JSON,或者至少把 key 设计成可读模式,让可视化工具真正发挥作用。
还有一个小技巧:用OBJECT ENCODING key看内部编码。如果某个 Hash 的编码从 listpack 变成了 hashtable,说明它已经超过阈值,这个信息有助于你判断是否需要拆分对象。
> OBJECT ENCODING user:1:history "quicklist" > OBJECT ENCODING rank:total "skiplist"4. 常见问题速查与缓存治理实战笔记
4.1 大 Key、热 Key 与慢查询
Redis 是单线程执行命令,一个慢命令会阻塞后面所有请求。最常见的"慢命令"都和数据类型的误用有关。
KEYS *是新手最容易踩的雷,它要遍历全库所有 key,数据量一大直接卡死。正确做法是用SCAN游标分批扫。集合类型里的SMEMBERS、HGETALL、ZRANGE 0 -1同样危险,如果对应的 key 是大 Key,输出几十万条数据,也会造成阻塞。判断一个大 Key 是否出现,可以用:
redis-cli --bigkeys这个命令会按类型扫描并输出占用空间最大的 key。扫出来之后,处理方式无非三种:拆分(把一个大 Hash 拆成多个小 Hash)、压缩(把大 value 换成更紧凑的序列化格式)、淘汰(只保留最近 N 条)。
热 Key 则是另一个问题。某个 key 被每秒上万次访问,Redis 单实例会成为瓶颈。业界常见的解法是本地缓存 + Redis 的结合,把热点数据放在 JVM/进程内缓存里,Redis 只兜底。也可以用 Redis Cluster 把 key 分散到多个节点,虽然同一个 key 还是会落在同一个节点,但整体负载能摊开。
4.2 数据一致性与过期策略
Redis 的过期删除采用"惰性删除 + 定期删除"结合的策略。惰性删除是访问时发现过期就删,定期删除是每隔一段时间抽样删除一批过期 key。这样的设计节省 CPU,但代价是如果同时有大量 key 过期,定期删除未必来得及扫完,内存会暂时被过期数据占据。
所以生产环境里给 key 设置 TTL 时,要避免"整点全军覆没"的情况。比如一批缓存数据同时写入、同时设置 3600 秒过期,大概率会在同一秒集体失效,引发缓存雪崩。做法是给 TTL 加一个随机偏移量,例如3600 + random(0, 300)秒。这是我做过多次压测后强烈建议的写法,成本极低,收益非常明显。
缓存层面还有两个经典问题:穿透和击穿。穿透是指查询一个必然不存在的数据,请求直接打到数据库;击穿是指某个热点 key 在过期的一瞬间有大量请求同时打到数据库。穿透的解法是缓存空值或布隆过滤器;击穿的解法是加锁重建缓存,或者用逻辑过期时间提前做异步刷新。
4.3 分布式锁到底该用哪种类型实现
分布式锁最标准的实现是 String 配合SET NX EX。NX保证只有在 key 不存在时才能写入,EX保证锁有自动过期时间,不会因为持有者崩溃而死锁。释放锁时不能简单DEL,因为有可能你已经持有锁,但操作超时锁过期了,别人拿到了新锁,这时你再去 DEL 就把别人的锁释放了。
正确姿势是:写入时 value 设置一个随机值,删除时用 Lua 脚本先检查 value 再删除。比如:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end这个脚本必须保证原子性。Redis 执行 Lua 脚本时不会被其他命令插入,所以这是最安全的释放锁方式。为什么不用 ZSet 或 Set 来做锁?因为 String 的SET NX EX单命令天生满足"加锁 + 过期"两个条件,Set 虽然有SADD,但没有原子的设置过期时间;ZSet 更不合适,它的排序和分数设计在这里毫无用途。选型也要遵循"什么场景用什么结构"的原则,锁就该用最直接的 String。
4.4 Redis 序列化与内存治理
很多人忽略序列化对内存和性能的影响。同一个对象,用 JSON 存储和用 Java 二进制序列化存储,体积可能差出几倍。JSON 可读性好,适合调试;二进制序列化体积小,但不可读。Go 的 json、Java 的 Jackson、Python 的 pickle,选型时需要结合业务。
在 Redis 里,key 本身也有内存开销。一个空 String 的 key 加上基础元数据,大概需要几十字节。如果你的 key 设计得又长又随机,比如user_logined_info_{uuid}_20231201235959_{random},那么光 key 名的内存开销就可能超过 value。建议 key 尽量短小、有规律,能用1001就不要用long-content-id-1001。
还有一个内存治理技巧:INFO memory看内存碎片率,redis-cli --stat观察持续吞吐。如果内存碎片率长期高于 1.5,可以考虑重启或MEMORY PURGE,但生产环境别乱用。更稳妥的方式是调整maxmemory-policy,比如allkeys-lru或volatile-lru,让 Redis 在内存满时自动淘汰冷数据。
5. 我的个人使用经验与选型口诀
做 Redis 相关项目这么多年,我最后基本形成了一套自己的选择依据,可以当成口诀用:能用 Hash 不用 String,能用 ZSet 不自己排序,能用 Set 不 List 去重,能用 Streams 不 List 当消息队列。
这句话不是说 String 不好,而是说要看数据模型。简单缓存、计数器、锁,用 String 没毛病;对象模型优先 Hash;顺序列表和简单队列用 List;去重和集合计算用 Set;排行榜、延迟队列直接用 ZSet;复杂消息场景直接上 Streams。
我还有一个习惯,就是每次上线前用OBJECT ENCODING检查几个核心 key 的编码,顺带用redis-cli --bigkeys扫一遍全库。很多内存问题、慢查询问题,其实在数据量达到一定水平之前就有苗头,越早发现代价越小。数据类型这事,看着是背命令,实则是建模能力。你把这些结构彻底理解了,Redis 就不再是一个简单的缓存,而是一整套可以承载业务的数据结构服务器。