主题
五种数据类型的典型业务用法
本文是 Redis 系统学习系列的 L1 入门篇。前置:27. Redis 入门:从安装到第一个缓存。 学完可以配合面试题食用:01-data-structures-encoding
为什么数据类型重要
Redis 能撑起缓存、计数、社交、排行榜那么多场景,底层靠的就是五种基本数据类型。每个类型不是简单的"存什么读什么",而是携带了特定的操作指令,能让你用一条命令完成一个业务逻辑,不用把数据拉到客户端算。
举个例子:你要个排行榜,用 MySQL 写 ORDER BY score DESC LIMIT 10 每次查一遍,数据量大了就不行。Redis 的 ZSet 用 ZREVRANGE 一行命令搞定,O(logN) 复杂度。选对类型,少写代码还快。
String:不只是存字符串
String 是 Redis 最基础的类型,value 最大 512MB,但实际应该控制在几百 KB 以下。
计数器
INCR key 自增 1,INCRBY key n 加 n。原子操作,天然适合限流计数器:
bash
# 接口限流:每分钟 IP 最多 100 次
SET ip:192.168.1.1:rate:20250823-09 0 EX 60 NX
INCR ip:192.168.1.1:rate:20250823-09
# 返回 1..100 正常,超过表示需要限流分布式锁(初版)
SET key value NX EX 30 是分布式锁的基础版。NX 保证只有 key 不存在时才设置成功,EX 30 是自动过期防死锁。生产环境还需要 value 放唯一标识(防误删)和 Lua 脚本保证原子释放,但最核心的就是这一行。
位图签到
SETBIT user:1001:sign:202508 3 1 把第 3 位(签到日)置 1。BITCOUNT 统计总签到天数,GETBIT 查某天是否签到。一个用户一年的签到记录只占 46 字节(365 位),比存一个字符串列表省两个数量级。
什么不该用 String 做:存大 JSON 对象,尤其是频繁改部分字段的场景。比如用户信息有 20 个字段,每次改一个都要整个序列化/反序列化。这时候应该用 Hash。
Hash:对象存储的首选
HSET user:1001 name "张三" age 28 city "北京" 把用户信息存成一个 Hash,操作字段互相独立。
购物车
bash
# 用户 1001 的购物车
HSET cart:1001 sku:1001 2 # 商品 1001,数量 2
HSET cart:1001 sku:1002 1
HGETALL cart:1001 # 一次取全部商品和数量
HDEL cart:1001 sku:1001 # 移除商品
HLEN cart:1001 # 商品种类数Hash vs JSON String 的取舍
| 对比项 | Hash | JSON String |
|---|---|---|
| 单字段读写 | 是,HGET/HSET | 否,必须整个取出再反序列化 |
| 内存效率 | 略高(小字段用 ziplist 编码) | 取决于序列化库 |
| 过期时间 | 仅 key 级 | 仅 key 级 |
| 嵌套结构 | 不支持 | 支持 |
选型规则:如果经常要读写对象的部分字段,用 Hash;如果多数时候是整体存取,或者对象字段数不确定/嵌套深,用 JSON String 更省事。
什么不该用 Hash 做:字段数超过 5000 的 Hash 会成为 bigkey,HGETALL 一次拉出几 MB 数据会让 Redis 线程卡住。大 Hash 应该拆分成多个小 Hash,按 field_hash % N 分桶。
List:最新动态与简易队列
LPUSH + LTRIM 实现最新 N 条
bash
# 用户最新 100 条动态
LPUSH feed:user:1001 "动态内容3"
LPUSH feed:user:1001 "动态内容2"
# 每次 LPUSH 后 trim 只保留前 100 条
LTRIM feed:user:1001 0 99
# 取最新 10 条
LRANGE feed:user:1001 0 9LTRIM 是 O(N) 操作(N 是删除的元素数),但每次只删多出的一条,实际代价很低。
简易队列
bash
# 生产者
LPUSH task:queue "job1"
# 消费者,阻塞等待
BRPOP task:queue 0BRPOP 阻塞式弹出,实现了一个最简单的队列。但注意:List 不适合做可靠消息队列。消息被 BRPOP 消费后立即从 List 删除,如果消费者拿到消息后处理失败,消息就丢了。没有 ACK 机制、没有消息重投、没有死信处理。正规场景用 Redis Stream(5.0+)或专门的 MQ。
什么不该用 List 做:可靠消息队列、需要 ACK 的工作流、消息去重。
Set:去重与集合运算
共同关注
bash
SADD user:1001:follow "user:2001" "user:2002" "user:2003"
SADD user:1002:follow "user:2001" "user:2004"
# 共同关注
SINTERSTORE common:1001:1002 user:1001:follow user:1002:follow
# 结果:user:2001SINTERSTORE 把交集结果存入新 key,适合"共同好友"这类场景。如果只是判断是否存在,用 SISMEMBER key member,O(1)。
抽奖
bash
# 参与抽奖的用户
SADD lottery:20250823 "user:1001" "user:1002" "user:1003" "user:1004"
# 抽 1 个奖
SPOP lottery:20250823 # 随机弹出(不重复)
# 或者抽完还保留参与者(如显示中奖名单)
SRANDMEMBER lottery:20250823 3 # 随机取 3 个,不删除去重
bash
# 记录某篇文章的点赞用户
SADD article:2001:like "user:1001"
SADD article:2001:like "user:1002"
SADD article:2001:like "user:1001" # 重复,忽略
SCARD article:2001:like # 返回 2什么不该用 Set 做:带权重的集合(用 ZSet)、需要保持插入顺序的列表(用 List)。
ZSet:排行榜与时间窗口
排行榜
bash
# 玩家积分变化
ZINCRBY game:leaderboard 150 "player:1001" # 加 150 分
ZINCRBY game:leaderboard 200 "player:1002"
# 前 10 名
ZREVRANGE game:leaderboard 0 9 WITHSCORES
# 查某玩家排名
ZREVRANK game:leaderboard "player:1001"
# 查某玩家分数
ZSCORE game:leaderboard "player:1001"ZINCRBY 是原子自增,不用担心并发加分冲突。ZREVRANGE 按分数从高到低取,O(logN + M) 复杂度,百万级数据随便查。
滑动窗口限流
bash
# 用户 ID 1001,每分钟最多 5 次请求
# 用当前时间戳(毫秒)作为 score
ZADD rate:user:1001:20250823-09 1725312000000 "req:1"
ZADD rate:user:1001:20250823-09 1725312001000 "req:2"
# 移除 60 秒前的记录
ZREMRANGEBYSCORE rate:user:1001:20250823-09 -inf 1725311940000
# 统计当前窗口内请求数
ZCARD rate:user:1001:20250823-09这个方案在时间窗口内的请求数精确、无毛刺,但每个用户每分钟要写一条 ZADD。如果 QPS 很高,ZSet 的 ZREMRANGEBYSCORE 会 O(logN) 删除,对内存也有压力。高并发场景下更推荐用令牌桶或本地滑动窗口,不做精确计数。
什么不该用 ZSet 做:纯 FIFO 队列(用 List)、不需要排序的唯一集合(用 Set)。
动手实操
以下用 redis-cli 演示每个类型的一个典型场景:
bash
# 1. String 计数器
127.0.0.1:6379> INCR site:visit:total
(integer) 1
127.0.0.1:6379> INCR site:visit:total
(integer) 2
# 2. Hash 用户信息
127.0.0.1:6379> HSET user:1001 name "Alice" level "VIP"
(integer) 2
127.0.0.1:6379> HGET user:1001 name
"Alice"
127.0.0.1:6379> HINCRBY user:1001 score 50 # 分数加 50
(integer) 50
# 3. List 最新动态
127.0.0.1:6379> LPUSH news:hot "news3" "news2" "news1"
(integer) 3
127.0.0.1:6379> LTRIM news:hot 0 2
OK
127.0.0.1:6379> LRANGE news:hot 0 -1
1) "news3"
2) "news2"
3) "news1"
# 4. Set 去重
127.0.0.1:6379> SADD tags:article:99 "java" "redis" "spring" "java"
(integer) 3 # 重复的 "java" 只算一次
127.0.0.1:6379> SMEMBERS tags:article:99
1) "redis"
2) "spring"
3) "java"
# 5. ZSet 排行榜
127.0.0.1:6379> ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
(integer) 3
127.0.0.1:6379> ZREVRANGE leaderboard 0 1 WITHSCORES
1) "player2"
2) "200"
3) "player3"
4) "150"常见误区与小结
- 误区:String 存所有。能用 Hash 存对象字段就尽量用 Hash,避免频繁序列化整个对象。
- 误区:List 做可靠队列。List 没有 ACK 机制,消费失败消息就丢了。需要可靠消费用 Stream。
- 误区:ZSet 的 score 只存整数。score 是 64 位浮点数,可以存时间戳、分数、甚至组合的整数编码(如
时间戳<<32 | 自增ID)。 - 误区:SADD 重复不报错是正常的。Set 天然去重,重复插入返回 0,不是 bug。
- 误区:单 key 存几万条数据没问题。一个 ZSet 百万级成员、一个 Hash 几千字段都会变成 bigkey,影响集群迁移和查询性能。
小结:五种数据类型各有适用场景,选型依据是"业务操作模型"而不是"数据长什么样"。String 适合计数和锁,Hash 适合对象,List 适合列表和简易队列,Set 适合去重和集合运算,ZSet 适合排序。下一篇数据结构拆解:SDS、Dict、Listpack 与 Skiplist 会深入底层,看这些类型在内部是怎么存储的。
参考
参考:Redis 官方文档 Data Types — 每个类型都有交互式教程 Redis 源码
src/t_string.c、src/t_hash.c、src/t_list.c、src/t_set.c、src/t_zset.c— 各类型的命令实现入口