Appearance
04|hash、list、set、zset
string 只能存一个值对应一个 key。但很多数据天然是"一个对象多个属性"——用户有 name/age/city、文章有 title/author/views、商品有 name/price/stock。先用 string 方案试一下,再讲 Redis 另外四种数据结构各自解决什么问题。
一、hash:一个对象多个属性
用 string 存一个用户的三个属性:
bash
SET user:1001:name "zhangsan"
SET user:1001:age "28"
SET user:1001:city "beijing"存是能存,但取的时候麻烦。要拿到这个用户的完整信息,得 GET 三次:
bash
GET user:1001:name
GET user:1001:age
GET user:1001:city三个 key 分布在键空间里,改一个属性要操作一个 key,不影响其他属性这件事得靠应用层保证。key 数量也翻了几倍——一万个用户,string 方案要三万个 key。
hash 把同一个对象的所有属性收进一个 key 里:
bash
HSET user:1001 name "zhangsan" age "28" city "beijing"
HGET user:1001 name
# "zhangsan"
HGETALL user:1001
# name zhangsan
# age 28
# city beijingHGETALL 一次命令拿回所有 field-value,不用往返三次。改 city 只动那一个 field,其他不动。
常用命令:
bash
HSET user:1001 name "zhangsan" # 设单个 field
HGET user:1001 name # 取单个
HDEL user:1001 city # 删某个 field
HEXISTS user:1001 name # field 是否存在
HINCRBY user:1001 age 1 # 某个数字 field 自增
HLEN user:1001 # 有多少个 fieldHINCRBY 在 hash 上也能原子自增——存访问量这种数字属性时,不需要取出再写回。
HGETALL 在大 hash 上慢
HGETALL 的复杂度是 O(n),n 是 field 数量。几百个 field 没问题,几千个就要用 HSCAN 分批取:
bash
HSCAN user:1001 0 COUNT 100生产里 HGETALL 上慢日志,最常见的原因就是 field 数量超预期。
另一个更隐蔽的点:hash 在 field 少时用压缩编码(ziplist),内存很省;field 数量超过阈值(默认 512)或单个值超过 64 字节后转成 hashtable 编码,内存会跳涨。一个存了几百个短 field 的 hash 突然翻倍涨内存,多半是触发了编码转换。阈值由 hash-max-ziplist-entries 和 hash-max-ziplist-value 控制。
二、list:有序可重复的序列
用 string 做消息队列行不通——没有"取出一条消息同时把它从队列里删掉"的原子操作。应用层 GET 再 DEL 是两步,中间可能插进别的客户端,同一条消息被两个消费者同时拿到。
list 是双向链表,首尾插入和删除都是 O(1),天然适合存有序序列:
bash
LPUSH queue "task1" # 左侧(队头)插入
LPUSH queue "task2"
RPUSH queue "task3" # 右侧(队尾)插入
LRANGE queue 0 -1 # 看全部:task2 task1 task3
LPOP queue # 从队头取
RPOP queue # 从队尾取
LLEN queue # 长度LPUSH + RPOP 组合做 FIFO 队列——先入先出。BLPOP/BRPOP 支持阻塞等待:队列为空时消费者挂起,有新元素立刻返回,应用层不用轮询空转:
bash
BLPOP queue 0 # 阻塞等队列有元素,0 表示无限等待list 消费消息有个局限:消息 RPOP 出去就消失了——消费者处理到一半崩溃,消息就丢了。需要 ACK 机制、消费者组、消息持久化的队列场景,要用 Redis 5.0 引入的 Stream。Stream 比 list 多了消费者组、消息 ID 和 ACK 机制,专门为消息队列设计。
三、set:无序不重复的集合
用 string 存文章的标签,每个标签一个 key:
bash
SET article:1:tag1 "redis"
SET article:1:tag2 "database"去重困难——同一个标签被重复写入,应用层得自己检查。算"两篇文章的共同标签"更麻烦,所有标签拉回应用层再算交集。
set 元素不重复,增删查都是 O(1):
bash
SADD article:1:tags "redis" "database" "nosql"
SISMEMBER article:1:tags "redis" # 是否存在
SMEMBERS article:1:tags # 取所有成员
SCARD article:1:tags # 元素个数
SREM article:1:tags "nosql" # 删成员真正有用的是集合运算:
bash
SINTER article:1:tags article:2:tags # 交集:共同标签
SUNION article:1:tags article:2:tags # 并集:所有标签
SDIFF user:1:friends user:2:friends # 差集:user1 有但 user2 没有的好友经典场景:社交关系的"共同好友"——SINTER user:1001:friends user:1002:friends 一条命令算出来。
但要注意数据量。SINTER/SUNION/SDIFF 的复杂度是 O(n),n 是参与运算的所有元素总数。两个集合各几十万人,这一条命令会阻塞 Redis 数秒,其他请求全排队。大数据量的集合运算应该在从库或离线环境跑,别在主库上算。
四、zset:带分数的有序集合
用 string 做排行榜,每次更新分数要走三步:
bash
GET player1_score # 取出当前分数
# 应用层加 5、排序
SET player1_score 105多客户端同时更新时,跟 INCR 的竞态条件一样——覆盖丢失。更麻烦的是排序,string 里存的是孤立数值,Redis 不知道谁大谁小,排一次序要遍历所有 key。
zset(sorted set)每个元素关联一个 score(浮点数),插入时自动按 score 排序:
bash
ZADD leaderboard 100 "player1" 95 "player2" 88 "player3"
ZRANGE leaderboard 0 -1 WITHSCORES # 按 score 升序
ZREVRANGE leaderboard 0 2 WITHSCORES # 前 3 名(降序)
ZREVRANK leaderboard "player1" # player1 排第几(从 0 开始)
ZSCORE leaderboard "player1" # 查 player1 的分数
ZINCRBY leaderboard 5 "player1" # 加 5 分插入或更新分数是 O(log n),查排名也快——千万级用户的排行榜也能响应。
zset 的其他用途:
带权重的任务队列——score 存优先级,按优先级取任务。
延迟队列——score 存"应该被处理的时间戳",消费者用 ZRANGEBYSCORE 取出到期的任务:
bash
ZRANGEBYSCORE delay_queue 0 "$(date +%s)" LIMIT 0 10处理完 ZREM 删除。但延迟队列同样没 ACK——消费者崩溃任务就丢。生产延迟队列用专门的消息中间件更稳妥。
五、五种类型怎么选
| 要存什么 | 用什么 | 为什么 |
|---|---|---|
| 单值缓存、计数器 | string | 单值场景最直接 |
| 一个对象的多个属性 | hash | 一个 key 装多个 field,省 key 数、查对象一次往返 |
| 消息序列、最近 N 条 | list | 有序、可重复、首尾操作快 |
| 去重、标签、共同关系 | set | 不重复、集合运算 |
| 排行榜、按分数取 | zset | 自动排序、按区间取 |
选错能跑通但性能差几个数量级。用 string 存排行榜:每次更新要 GET 取值、应用层排序、再 SET 回去——多往返、非原子、慢。用 zset 存同一批数据,一条 ZADD 或 ZINCRBY 就够了。
六、其他类型
除了这五种,Redis 后续版本加了一些专门类型:
- Stream:带消费者组、消息 ID、ACK 机制的消息队列,比 list 当队列可靠
- Geospatial:地理位置索引,算距离、范围查询
- HyperLogLog:基数估算,UV 这种"去重计数"用很少内存
- Bitmap:位操作,存布尔状态、做位统计
这些用得没有五大类型多,用到时查文档,本系列主线讲五大类型。