如果你刚接触Redis,最先要弄明白的就是它的数据类型。我遇到过很多同学,装好Redis就只会SET/GET,把对象序列化成一个JSON字符串塞进去,等要查某个字段的时候,只能整个取出来再反序列化,内存和性能都被浪费了不少。Redis虽然简单,但它的value绝对不是只有字符串一种。官方把自己定位成数据结构服务器,核心就在五种数据类型:String、Hash、List、Set、ZSet。这篇文章我会从这五个到底是什么,讲到项目里怎么选、怎么用、踩过哪些坑,争取让你看完就能直接上手。
1. 先搞懂Redis是“按key存value”的数据结构服务器
1.1 Redis的全局结构:一个相对地址的字典
要理解Redis的数据类型,先要看懂它的全局结构。你可以把Redis想象成一本巨大的字典,每个词条都有一个key,然后对应一个value。查询的时候,你只需要知道key,Redis会根据哈希算法直接定位到对应的value,整个过程接近O(1)。这里的key永远是一个字符串,而value才是我们今天的主角。
但要注意,这个value不是简单的一串字符,它可以是一个字符串、一个哈希结构、一个列表、一个集合,或者一个有序集合。Redis之所以灵活,是因为它把这五种数据结构做成了一等公民,你不需要在客户端自己处理复杂的数据关系,直接通过命令就能完成操作。
我见过很多滥用Redis的方式:把数组转成JSON字符串塞进String,结果想取其中一个元素只能整个拿出来解析;把用户对象序列化成一个大JSON,结果每次改昵称都要覆盖整个字符串。这些问题不是Redis不行,而是没有根据操作方式选择正确的数据类型。
1.2 五大核心数据类型到底是什么
这里先给一个快速概览,后面每一类我都会展开讲:
| 类型 | 内部元素 | 是否有序 | 是否去重 | 典型用途 |
|---|---|---|---|---|
| String | 一个字节序列 | 无 | 无 | 缓存、计数器、分布式锁 |
| Hash | 多个字段-值对 | 无 | 字段唯一 | 对象缓存、用户信息 |
| List | 多个字符串 | 有 | 否 | 消息队列、时间线 |
| Set | 多个字符串 | 无 | 是 | 标签、去重、抽奖 |
| ZSet | 多个member-score对 | 有(按score) | member唯一 | 排行榜、延迟队列 |
看到这个表,你应该能形成一个基本判断:如果value本身是一个对象,用Hash;如果value是一串有序的数据,用List;如果value是多个去重元素的集合,用Set;如果还需要排序,用ZSet。这不是绝对的,但大概率不会错。
1.3 选错类型的典型迹象与选型原则
我通常会问自己三个问题:这个value是单值还是多值?多值里允不允许重复?需不需要按顺序访问或排序?这三个问题问完,基本能锁定数据类型。
另外,如果发现自己天天在代码里循环遍历一个String里的JSON,或者为了改某个字段把整个对象重新SET一次,这就是选型错误。Redis的数据类型不是装饰品,每种类型背后都有对应的数据结构和复杂度保证,选对了,很多问题自然就消失了。
2. String字符串:能原子自增的万能胶水型
2.1 String不是普通字符串,而是字节序列
String是Redis里最基础、最常用的数据类型。你可能会觉得它就是字符串,但在Redis内部,它保存的其实是一个字节序列。这意味着它可以存字符串、整数、浮点数、二进制数据,甚至经过序列化后的对象。
不过,正是因为它太普通,很多人才会用顺手了之后把所有内容都往里塞。String能做的远不止缓存一个JSON,它还提供了INCR、DECR、SETNX等原子操作,这些才是它在生产环境里不可替代的原因。
注意,String的value最大是512MB,虽然日常用不到这么大,但你在设计缓存的时候心里要有这个边界。另外,Redis的key也有命名规范,我习惯用“业务:对象:ID”的格式,比如user:info:10001,看起来清晰,排查问题也方便。
2.2 6个高频命令,覆盖90%场景
用String,你只需要掌握几个命令:
SET key value GET key MSET key1 value1 key2 value2 MGET key1 key2 INCR key SETNX key valueSETNX是“只在key不存在时设置”,这是实现分布式锁的基石。INCR是可以对整数value进行原子自增的命令,在高并发下不会出现超卖。这些命令看起来简单,但组合起来非常有用。
比如用户当天签到,你可以用INCR key实现统计;比如做接口限流,你可以用INCR + EXPIRE实现计数器;比如热门文章点击量,也是INCR。很多人以为String只能缓存静态数据,其实它最适合做这种高频写、低频读的计数场景。
2.3 实战演练:商品库存扣减
举个库存扣减的例子。没有经验的做法是先GET库存,在代码里减一,再SET回去。这在单机环境下没问题,但并发稍微一上来,就会因为多线程同时读到了旧值而超卖。
正确的做法是直接用INCR或者DECR:
SET product:10086:stock 100 DECR product:10086:stockDECR是原子操作,Redis的单线程命令执行机制保证了多个客户端同时访问时,命令会排队执行,不会出现两个请求都扣成同一个数字的问题。如果你还需要在扣减前判断库存是否足够,可以用下面的Lua脚本,或者先GET判断再DECR,但要注意这个判断不是原子的,严谨场景建议用Lua。
这个案例告诉我们,String的原子操作不是为了炫技,而是解决实际问题。做计数器、限流器、库存、库存预扣,优先想到String。
2.4 底层SDS解决的两个经典问题
String的底层结构叫SDS(Simple Dynamic String,简单动态字符串)。传统C语言字符串用空字符判断结尾,有一个经典问题:如果字符串中间包含'\0',代表程序员熟悉的"截断",数据就会丢失。SDS记录了字符串的长度,所以是二进制安全的,可以存储图片、序列化对象的二进制内容,这一点非常重要。
另一个经典问题是字符串拼接。C语言字符串拼接前要手动分配内存,否则会缓冲区溢出。SDS会预分配一部分空闲空间,字符串变长时,如果不是特别大,会直接使用预分配空间,减少内存分配次数。这也是为什么Redis执行很多次APPEND操作,性能依然不错的底层原因。
所以说,面试时问SDS,不是单纯考察你背过没背过,而是想看你有没有理解Redis为什么适合做缓存、为什么能高效处理字符串操作。
3. Hash哈希:对象数据的最优解
3.1 用String存对象有什么问题
很多初学者会把用户对象转成JSON然后SET到Redis里。这样做不是完全不行,但问题是:当你只想改用户昵称时,必须先GET出来,反序列化成对象,修改字段,再序列化,再SET回去。每次都要传输整条数据,在高并发下CPU和内存开销都被放大了。
Hash类型可以完美解决这个问题。Hash里的结构类似于Java的Map<String, String>,一个field对应一个value。你可以把用户ID作为key,把姓名、年龄、手机号这些作为field,对单个字段进行修改,Redis只更新对应的字段,不会影响其他字段。
3.2 常用命令与用户缓存实战
HSET user:10001 name "张三" age 25 HGET user:10001 name HGETALL user:10001 HINCRBY user:10001 age 1 HDEL user:10001 phone比如用户资料缓存,登录成功后,把用户信息写入Hash。当用户修改头像时,只需要:
HSET user:10001 avatar "https://new-avatar.png"这个操作只更新avatar字段,其他字段不动。这一个细节,在用户频繁更新部分字段的场景里,性能差距是很明显的。
不过也要注意,Hash不适合保存过大的value。如果一个Hash里有几万个字段,HGETALL会一次性返回大量数据,容易阻塞网络和内存。设计时要考虑拆分,或者尽量只读取需要的字段。
3.3 底层编码切换和过期命中
Hash底层有两种编码方式。当字段数量少且每个value较短时,Redis用ziplist(紧凑列表)来节省内存;当字段数量变多或者某个value变大时,就会自动切换成hashtable。这个切换是Redis内部自动完成的,你只需要知道:小Hash很省内存,大Hash会牺牲一些内存换取访问性能。
在实际项目里,我给Hash设置过期时间时踩过一个坑。Hash本身可以设置过期时间,但单个field不能设置过期时间。比如你想让用户积分24小时后过期,但用户名不过期,Hash是做不到的,只能给整个key设置EXPIRE。如果遇到这种需求,要么拆key,要么在field的value里自己存过期时间。
4. List列表:从消息队列到时间线
4.1 双向链表模型:命令组合是你自由定义的栈/队列
List在Redis里的模型是一个双向链表,或者用现代版本里的quicklist结构。它可以从左边压入、从右边弹出,也可以从右到左取数据。命令组合起来,可以实现栈、队列,甚至阻塞队列。
- 栈:LPUSH + LPOP,先入后出
- 队列:LPUSH + RPOP,先入先出
- 阻塞队列:LPUSH + BRPOP,没有数据时一直等待
这里的关键是理解左右两端。我把常用命令理一下:
LPUSH listkey value1 value2 RPUSH listkey value1 value2 LPOP listkey RPOP listkey LRANGE listkey 0 -1 LLEN listkey LTRIM listkey 0 994.2 朋友圈时间线案例
List最常见的场景之一是“最新列表”。比如朋友圈,每个用户有一条自己的发帖时间线。用户发一条动态,我们用LPUSH把动态ID推送到列表头部;展示时用LRANGE start stop分页取数据。
LPUSH feed:10001 5001 LPUSH feed:10001 5002 LRANGE feed:10001 0 9这样就能拿到最新的10条动态ID,再根据ID去数据库或缓存里查详情。还有一个很实用的命令是LTRIM,它可以只保留列表里最新的N条,比如只保留最近100条动态,防止列表无限增长:
LTRIM feed:10001 0 99注意,LIst按索引取中间元素的复杂度是O(N),所以不要把它当成数组来用。你要的是“头尾操作快”,而不是“随机访问快”。
4.3 阻塞队列BRPOP的正确用法和丢消息提醒
List也经常做简单的消息队列。生产者用LPUSH,消费者用BRPOP等待任务。BRPOP会一直阻塞,直到队列里有数据。这样比轮询更省资源。但这里我要提醒一个坑:BRPOP取出来的任务如果处理失败了,任务就从队列里消失了,没有“重新投递”机制。
我做异步任务系统的时候,会用BRPOP取出任务,放进待处理集合,处理完成后再从集合里删除。如果启动时发现待处理集合里有残留任务,说明上次有任务处理到一半就崩了,需要重新执行。这是一个简单的可靠投递方案,但Redis List本身不是专业消息中间件,复杂场景还是用Kafka或Redis Streams。
另外,如果你用BRPOP,需要注意超时时间。设为0表示一直阻塞,但客户端连接可能因为长时间空闲被服务端断开;设一个合理的超时时间,比如5秒,超时后重新BRPOP,体验会更好。
5. Set集合:无序去重和集合运算
5.1 命令速查:SADD、SREM、SISMEMBER、SINTER
Set是不允许重复元素的无序集合,它最大的价值是去重和集合关系运算。你可以把Set理解为Java里的HashSet。
常用命令:
SADD user:1:tags "tech" "design" SREM user:1:tags "design" SISMEMBER user:1:tags "tech" SMEMBERS user:1:tags SCARD user:1:tags SINTER user:1:tags user:2:tags SUNION user:1:tags user:2:tags SDIFF user:1:tags user:2:tagsSINTER可以取两个集合的交集,SUNION取并集,SDIFF取差集。这几个命令是Set的灵魂,因为它们在服务端完成集合运算,不需要把数据拉到客户端处理。
5.2 三个典型场景:标签、共同好友、抽奖
第一个场景是用户标签。一个用户可以有很多标签,一个标签也可以对应很多用户。用Set存标签非常合适,每次给用户加标签就是SADD,判断用户是否有某个标签就是SISMEMBER,瞬间返回。
第二个场景是共同好友。每个人维护一个好友Set,想看A和B的共同好友,直接:
SINTER friends:A friends:B如果让你用数据库实现,可能要两条SQL或者多表JOIN,而Redis一条命令搞定。
第三个场景是抽奖。先把所有参与用户放入Set,然后随机抽取:
SADD lottery users SPOP lottery 1 # 随机弹出一个人,并且从集合中移除 SRANDMEMBER lottery 1 # 随机抽一个人,但不移除我用SPOP做过一次活动抽奖,核心逻辑只有这两行命令,比在数据库里做随机数查询简单太多了。
5.3 intset和hashtable:为什么小整数集合省内存
Set底层也有编码切换。如果所有元素都是整数且数量不大,Redis使用intset(整数集合)来存储,它是一个有序的整数数组,非常节省内存。当元素不是整数或者数量超过阈值,就会转成hashtable。
这一点在面试里经常被问到,但在实际开发中,我更多地用它来理解为什么小Set性能好。如果你要存一批ID列表,类似“已读用户ID集合”,用Set比用List更合理,因为Set天然去重,而且SISMEMBER判断是否已读是O(1)。
有个坑:SMEMBERS会返回集合里的所有元素,如果集合很大,比如几百万个ID,这条命令会阻塞Redis服务。推荐用SSCAN迭代获取,或者先SISMEMBER判断,不要无脑SMEMBERS。
6. ZSet有序集合:排行榜与延迟队列的标配
6.1 member和score:唯一元素和可排序的权重
ZSet可能是五大数据类型里最“聪明”的一个。它和Set一样,元素唯一,不同之处在于每个元素还关联了一个double类型的score。Redis按照score从小到大排序,如果score相同,再按member的字典序排列。
这个结构天然适合“有权重、需要排序”的数据。分数、积分、时间戳、优先级,都可以作为score。
6.2 排行榜实现:正序、倒序、按分数段取人
排行榜是ZSet最经典的应用。比如直播间礼物排行榜,每个用户送出礼物会增加对应分数:
ZINCRBY leaderboard:10001 100 "user_1"查看整个榜单前10名:
ZREVRANGE leaderboard:10001 0 9 WITHSCORES查看某个用户在榜单中的排名:
ZREVRANK leaderboard:10001 "user_1"ZREVRANGE是从大到小取,因为榜单一般数值大在前。如果你需要正序排名,用ZRANGE。如果你要按分数区间查询,比如“分数在80到90之间的用户”,用:
ZRANGEBYSCORE leaderboard 80 90 WITHSCORES这个操作在业务里非常有用,比如筛选积分达到某个等级的用户。
6.3 底层跳表+哈希的双结构设计
ZSet底层结合了哈希表和一个叫跳表的数据结构。哈希表负责快速定位member的score,跳表负责按score排序和范围查询。跳表可以理解为一种层级式的链表,用空间换时间,插入、删除、查找的时间复杂度都是O(log N)。
我在这儿不展开跳表的完整实现了,但你要理解一个关键点:ZSet不是像List那样只能在两端操作,它在中间插入、删除、按区间取数据都非常高效。这就是我经常说“如果数据要排序并且要频繁更新,ZSet是Redis里最合适的选择”的原因。
6.4 用ZSet做一个简易延迟队列
一个很实用的方案是延迟队列。把任务ID作为member,任务执行时间戳作为score。投递任务时:
ZADD delay_queue 1699999999 "task_123"后台任务循环执行:
ZRANGEBYSCORE delay_queue 0 current_time LIMIT 0 1如果有返回,说明有到了时间应该执行的任务,然后ZREM把它移除,执行真正的业务逻辑。
这个方案简单可靠,不需要引入额外的消息中间件。不过我要提醒:如果任务量非常大,或者你需要消息确认、重试、死信队列,还是应该用专业消息中间件。ZSet延迟队列适合中小业务,比如订单超时取消、定时刷新缓存。
7. 综合实战:会员模块的类型选型与常见排查
7.1 从需求反推:什么场景该用哪种类型
在实际设计Redis缓存时,我会先列需求,再反推类型。这里用会员系统举例:
- 登录状态:用String存token,SETEX设置过期时间
- 用户基本信息:用Hash存字段,支持单字段修改
- 用户操作日志:用List存最近的登录记录,LTRIM截断
- 用户标签/权限:用Set存,判断是否有某个权限
- 用户积分排名:用ZSet存,按积分排序
- 未读消息数或验证码次数:用String INCR原子计数
反过来,如果用户积分许久才更新一次,其实也可以直接放Hash字段。类型选择没有唯一标准,但决策流程是固定的:先看数据结构,再看操作模式,再考虑数据规模。
7.2 一个会员系统的混合用法
比如用户登录成功后,我们会同时写多个key:
SETEX session:token:abc123 86400 "user_10001" HSET user:10001 name "张三" level 3 SADD role:10001 "vip" ZINCRBY leaderboard:points 10 "user_10001" LPUSH login_log:10001 "2024-01-01 10:00:00" LTRIM login_log:10001 0 9这是非常典型的多类型混合用法。不同数据有不同的生命周期:session短,用户资料长,排行榜一直累计,日志只保留最近10条。如果全部用String,那后面做权限判断、排行榜更新、日志截断都会非常别扭。
7.3 常见错误与快速排查:TYPE、OBJECT ENCODING和内存分析
排查Redis数据类型相关问题时,我一般按这几步来:
首先用TYPE key确认value类型。比如你以为存的是Hash,结果返回string,那就说明写入时用错了命令。
然后用OBJECT ENCODING key查询内部编码。它会返回int、embstr、raw、ziplist、hashtable、quicklist、skiplist等结果。看到这个就能判断数据是否已经因为规模变大而切换了底层结构。
如果Redis内存涨得异常,我会用redis-cli --bigkeys扫描哪些key比较大。这个命令会遍历所有key,输出占用空间最大的key类型。但是要注意,在高峰期跑可能会造成延迟,建议业务低峰期执行。
还有一个常见的坑:把对象序列化成JSON后存入String,然后还想做范围查询或者字段更新,根本做不到。遇到这种需求,回想一下这篇文章,换成Hash或者ZSet。
7.4 一张表总结五大数据类型
我最后再放一张总结表,方便你收藏或贴在自己代码旁边:
| 类型 | 典型命令 | 底层结构 | 核心优势 | 一句话场景 |
|---|---|---|---|---|
| String | SET/GET/INCR/SETNX | SDS | 二进制安全、原子计数 | 缓存、计数器、锁 |
| Hash | HSET/HGET/HINCRBY | ziplist/hashtable | 对象局部更新 | 用户信息、配置 |
| List | LPUSH/RPUSH/LPOP/BRPOP | quicklist | 双向操作、阻塞读 | 队列、时间线 |
| Set | SADD/SISMEMBER/SINTER | intset/hashtable | 去重、集合运算 | 标签、共同好友 |
| ZSet | ZADD/ZINCRBY/ZRANGE | skiplist+hash | 按分数排序 | 排行榜、延迟队列 |
我个人在实际项目里体会最深的一点是,Redis数据类型的学习不是背命令,而是建立“数据结构匹配业务需求”的直觉。每当你准备把某个东西塞进Redis,先停一秒,问自己:这个东西是字符串、对象、列表、集合,还是要排序的集合?想清楚了,Redis的威力才能发挥出来。另外一个小技巧:所有命令都可以通过官方文档查,但生产环境里遇到问题,先从TYPE开始排查,往往比什么都不做直接重启要快得多。希望这篇文章能帮你少走弯路,把五种类型真正用起来。