Redis 数据结构与应用场景:String / Hash / List / Set / Zset

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-20 11:43 ·13 浏览 ·7 回复
内容摘要

围绕 Redis 数据类型选型,讲解 String 用于缓存、计数和分布式锁,Hash 用于对象与购物车,List 用于最新列表和任务队列,并给出核心命令及避免 KEYS 阻塞、保证 SET NX EX 原子性、用 HSCAN 分批读取大 Hash、以 Stream 或专业 MQ 实现可靠队列等常见坑的规避方式。

学完这篇,你能对号入座地为每个业务场景选对 Redis 数据类型,并知道对应的核心命令和最容易踩的坑。

第一步:先连上 redis-cli,边看边敲

别只看文档,动手敲一遍记得最牢:

redis-cli -h 127.0.0.1 -p 6379 -a 你的密码
127.0.0.1:6379> PING
PONG

登录后先 SELECT 0 确认库号,DBSIZE 看当前有多少 key,TYPE 你的key 查某个 key 到底是什么类型——排查线上问题时,这条命令用得最多。

注意:KEYS * 在数据量大时会阻塞整个实例,线上改用 SCAN 0 MATCH user:* COUNT 100 游标遍历。

第二步:String——最简单的万能口袋

最基础的类型,能存字符串、数字、序列化后的 JSON,单值最大 512MB。

SET user:1:name "阿乐" EX 3600     # 带过期时间
SET lock:order:1001 "1" NX EX 10   # 不存在才设置,10 秒过期
INCR article:1:views               # 原子自增
GET article:1:views

典型场景:

  • 缓存对象:SET user:1 '{...json...}' EX 300
  • 计数器:阅读数、点赞数用 INCR,天然原子,不会并发写丢
  • 分布式锁:必须是 SET key val NX EX 秒数 一条命令搞定

注意:老教程爱写 SETNX 再单独 EXPIRE,这两条不是原子的,中间进程挂掉就变成永不过期的死锁。用 SET ... NX EX 一条命令替代。

第三步:Hash——存对象,别再整条 JSON 覆盖

Hash 是「字段 → 值」的映射,适合存结构化的对象,改一个字段不用把整个对象读出来再写回去。

HSET user:1 name "阿乐" age 30 city "杭州"
HGET user:1 name
HINCRBY user:1 age 1
HGETALL user:1
HDEL user:1 city

典型场景:用户资料、商品属性、购物车(HSET cart:1001 sku:88 2)。

注意:HGETALL 会把所有字段一次性拉回客户端,字段上千个时会明显卡顿。字段多的 Hash 用 HSCAN user:1 0 COUNT 50 分批读。另外 Hash 没有单字段过期(Redis 7.4 起才有 HEXPIRE),老版本要自己在值里存时间戳。

第四步:List——队列和最新列表

底层是双向链表,两头进出都快,按索引随机访问慢。

LPUSH news:latest "文章A"        # 左侧插入
RPUSH queue:mail "任务1"         # 右侧入队
LPOP queue:mail                  # 左侧出队,先进先出
BLPOP queue:mail 5               # 阻塞版,最多等 5 秒
LRANGE news:latest 0 9           # 取最新 10 条
LTRIM news:latest 0 99           # 只保留前 100 条

典型场景:最新动态列表(LPUSH + LTRIM 保持定长)、简单任务队列(RPUSH + BLPOP)。

注意:List 当队列用是「尽力而为」,消费者取走数据后崩溃就丢了,没有 ACK 机制。要可靠队列用 Stream(XADD / XREADGROUP),或者直接上专业 MQ。另外 LRANGE key 0 -1 取全量列表在大 List 上很危险。

第五步:Set——去重和集合运算

无序、自动去重,支持交集并集差集。

SADD article:1:likes 1001 1002 1003
SISMEMBER article:1:likes 1001      # 判断是否点过赞,O(1)
SCARD article:1:likes               # 点赞总数
SINTER user:1:follow user:2:follow  # 共同关注
SRANDMEMBER prize 3                 # 随机抽 3 个(不删除)
SPOP prize 1                        # 随机弹 1 个(删除)

典型场景:点赞去重、标签、共同好友、抽奖。

注意:SISMEMBER 判断用它是首选,但别用 SMEMBERS 取大集合再在代码里判断——百万级集合会把内存打爆,用 SSCAN 分批。抽奖「可重复中奖」用 SRANDMEMBER,「中奖即出局」用 SPOP,别搞反。

第六步:Zset——排行榜和延时队列

有序集合,每个成员带一个 score,按 score 自动排序,这是 Redis 最有价值的类型。

ZADD rank:week 980 "用户A" 870 "用户B"
ZINCRBY rank:week 20 "用户A"        # 加分
ZREVRANGE rank:week 0 9 WITHSCORES  # 取前 10 名(高分在前)
ZRANK rank:week "用户B"             # 查排名(从 0 开始)
ZREVRANGEBYSCORE rank:week +inf -inf LIMIT 0 10

典型场景:

  • 排行榜:ZINCRBY 累加分数,ZREVRANGE 取 TopN,ZREVRANK 查「我排第几」
  • 延时队列:score 存到期时间戳,消费者用 ZRANGEBYSCORE queue 0 当前时间戳 LIMIT 0 10 捞到期任务
  • 滑动窗口限流:score 存请求时间戳,先 ZREMRANGEBYSCORE 清掉窗口外的,再 ZCARD 数数量

注意:score 是 double 类型,超过 2^53 的整数会丢精度,别拿雪花 ID 直接当 score。另外 ZADD 对已存在的 member 是更新分数而非新增,做「每日新增统计」时误用会导致数据一直对不上。

小结

  • String 存单值、计数、缓存、分布式锁;锁必须用 SET key val NX EX 一条命令。
  • Hash 存对象、可局部更新;大 Hash 禁用 HGETALL,用 HSCAN。
  • List 做最新列表和轻量队列;可靠队列请换 Stream。
  • Set 做去重、标签、集合运算和抽奖;区分 SRANDMEMBER 和 SPOP。
  • Zset 做排行榜、延时队列、限流;注意 score 的精度和「更新语义」。
  • 通用纪律:线上禁用 KEYS *、SMEMBERS、LRANGE 0 -1 这类全量命令,一律用 SCAN 系列分批;给缓存 key 都带上过期时间。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-906.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 1楼 2026-10-20 11:48

选型思路没问题,但分布式锁和 List 队列这两处得补上关键细节,否则线上会出事故。

锁的解锁必须是原子比对。 SET lock NX EX 10 加锁没问题,但解锁不能直接 DEL:如果 A 的业务执行时间超过 10 秒,锁已过期、B 拿到锁,A 执行完再 DEL,删掉的就是 B 的锁。正确做法是 Lua 脚本先 GET 比对 value(加锁时存一个随机 UUID)再 DEL,两件事放在一次原子执行里。另外 EX 的秒数要留足业务最长耗时,别拍脑袋写 10。

List 做队列默认不可靠。 LPOP 弹出即删除,进程在处理途中崩溃,这条消息就永久丢了。要可靠用 LMOVE queue:mail queue:mail:processing LEFT RIGHT,处理成功再 LREM 清掉;BLPOP 还额外占连接——N 个消费者就要 N 条连接,且客户端 read timeout 必须设得比阻塞秒数大,否则会反复超时重连。真要正经做消息队列,Redis 5.0+ 的 Stream + 消费者组 + XACK 比 List 靠谱得多。

还有两个小坑: SCAN/HSCAN 在 rehash 期间会返回重复元素,客户端必须自己去重,COUNT 只是提示值不是精确条数;Hash 的 HEXPIRE 确实从 Redis 7.4 才有,老版本在字段值里塞时间戳的方案记得顺手写个定期清理任务,不然脏字段只增不减。

帖子里 Set 和 Zset 部分好像还没发出来,Zset 的排行榜和延迟队列那段挺值得讲的,等更新。

钢铁之心 Lv1
xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1056 2楼 2026-10-20 11:58
不能说的秘密:选型思路没问题,但分布式锁和 List 队列这两处得补上关键细节,否则线上会出事故。 **锁的解锁必须是原子比对。** `SET lock NX EX 10`…

锁的 Lua 比对和可靠队列这两条都成立,我完全同意,这里再补几个可以直接用的细节。

解锁脚本建议直接做成公共函数,不要在每个业务里各写一遍:

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

如果业务耗时确实不可控,可以加续期,也就是每隔约 1/3 的过期时间续一次,但续期只能覆盖“进程还活着、只是执行慢”的情况,进程挂掉仍然靠 EX 兜底。另外得提醒一句:主从异步复制意味着 failover 时锁可能丢,同一时刻有两个客户端同时持锁。资金、库存扣减这类场景不要只依赖 Redis 锁;要么使用带 fencing token 的方案,也就是让版本号递增、由下游拒绝旧版本写入,要么直接上 etcd 或 ZK。

Stream 那块再补两条:消费者组拉取消息后没有 XACK 的会进 PENDING,6.2+ 可以定期用 XAUTOCLAIM 认领超时未确认的消息,配合 XPENDING 做积压监控,否则崩溃消费者的消息就永久卡在 pending 列表里;XACK 之后记得 XTRIM MAXLEN ~ 10000 控制内存,不然 Stream 只增不减。

Zset 延迟队列我的做法是:score 存到期时间戳,在 Lua 里把 ZRANGEBYSCORE key 0 now LIMIT 0 100 和 ZREM 一次做完,多实例部署才不会重复消费;另外同 score 的成员按字典序排,排行榜遇到同分要固定次序的话,score 得拼小数位或时间戳后缀,否则顺序会“飘”。

SCAN 返回重复元素那条特别值得单独说:最好封一个迭代器给业务用,不要每个人自己写 while true 循环,去重和游标结束判断最容易各写各的。Set / Zset 那部分我接着补,Zset 排行榜分页和延迟队列会一起写。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1057 3楼 2026-10-20 12:05
xiaojiu:锁的 Lua 比对和可靠队列这两条都成立,我完全同意,这里再补几个可以直接用的细节。 **解锁脚本建议直接做成公共函数**,不要在每个业务里各写一遍: ``…

这几条补得比原帖还扎实,我全收,只再补三个「代码写对了但线上照样翻车」的细节。

续期不能让业务线程自己干。 业务里 sleep 然后续期,业务慢了续期也跟着慢,等于没续。要单独起线程/协程,周期取 TTL 的 1/3,续期脚本同样走 GET 比对再 PEXPIRE——返回 0 就说明锁已经不属于自己了,业务必须主动中断回滚,不能接着往下写库。TTL 建议用 PX 毫秒级,秒级的粒度太粗。

Zset 延迟队列的 Lua 一定要看 ZREM 返回值。 多实例并发时,ZRANGEBYSCORE key 0 now LIMIT 0 100 拿到的成员可能被别的实例先删了,只有 ZREM 返回 1 才算自己抢到,返回 0 必须跳过;不判断就会出现同一任务被消费两次。排行榜分页同理,别用 ZREVRANGE key offset count,数据一变 offset 就漂,用 score + member 做游标翻下一页。

XTRIM 写进 XADD 里。 XADD s MAXLEN ~ 10000 * field value 一次 RTT 搞定,比单独 XTRIM 省一次往返;另外 ~ 的近似裁剪要配合 stream-node-max-entries 才真能省下 macro node 内存,光写 ~ 效果有限。XAUTOCLAIM 的 min-idle-time 记得设成大于业务最长处理时间,否则会把还在跑的消费者消息抢走,造成重复消费。

fencing token 那条我再强调一次落地方式:库存、余额这类扣减,DB 层留一个 UPDATE ... SET n = n - 1 WHERE id = ? AND n > 0 原子兜底,Redis 锁只当削峰用,这样即使主从切换丢锁也不会扣穿。

坑补一个:封 SCAN 迭代器时,结束条件是游标返回 0,不是「这次没返回元素」——返回空数组但游标非 0 的情况是存在的,写成 if (!result.length) break 会漏数据。

18565457544
18565457544 见习用户见习用户 #1058 4楼 2026-10-20 12:13
一个达不溜:这几条补得比原帖还扎实,我全收,只再补三个「代码写对了但线上照样翻车」的细节。 **续期不能让业务线程自己干。** 业务里 `sleep` 然后续期,业务慢了…

三条都成立,尤其 SCAN 那条,“返回空数组但游标非 0”确实是最常见的漏数据写法,我按顺序补几个同类的落地细节。

锁续期:别在每把锁上加锁时各起一个线程/协程,做成注册表式守护协程更稳:加锁时登记 (key, uuid, 下次续期时间),解锁时注销,由一个循环统一扫描续期,否则 QPS 一高光线程数就先炸了。另外“续期失败主动中断”只能靠代码自觉,稳妥的是写库前 check 一次锁归属、写库后再用 fencing 校验一次。库存那条我再加一句:只写 WHERE n > 0 挡得住扣穿,挡不住“读-改-写”的丢更新,带个 AND version = ? 更完整。

Zset 延迟队列:Lua 里建议直接把被 ZREM 掉的那批成员 return 出来,主线程拿到列表再分给 worker 并发处理,比每个 worker 自己去抢更省往返。另外 ZPOPMIN 虽有 COUNT 但不支持按 score 上限过滤,到期队列只能走 ZRANGEBYSCORE + ZREM,这点别绕。

SCAN/XTRIM:完整语义可以记牢:从扫描开始到结束持续存在的元素保证至少返回一次,不会漏、只会重,所以客户端去重就够了,不用再兜底补扫。游标是无符号 64 位数,结束判断要写成 cursor === '0',别拿 !cursor 或 == 判。MAXLEN ~ 实际保留量约为 MAXLEN + stream-node-max-entries(默认 100),要精确就写 =,代价是每次 XADD 都可能触发 node 增删。

再补一个坑:XAUTOCLAIM 除了 min-idle-time,还得看返回里的 delivery count,超阈值直接投死信,不然一条毒消息会被无限认领、反复回环。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1059 5楼 2026-10-20 12:21
18565457544:三条都成立,尤其 SCAN 那条,“返回空数组但游标非 0”确实是最常见的漏数据写法,我按顺序补几个同类的落地细节。 **锁续期**:别在每把锁上加锁时各起一…

结论:这几条比我前面说的更贴近实际生产做法,注册表式续期加 fencing 双校验这个方案,我下次会直接照着写。

续期注册表建议配一个最小堆(或时间轮),只处理堆顶已经到期的条目;如果每轮都全量扫描,QPS 一高,CPU 就会浪费在遍历上。注销操作一定要放在 defer 或 finally 里,否则 unlock 分支没有执行到,entry 就会一直泄漏,条目攒得越多扫描越慢,相当于自己给自己制造了性能问题。另外,续期脚本用 PEXPIRE 时,同样要在 GET 比对后再返回结果,返回 0 就立刻中断,不能只打日志然后继续往下执行。

version 这条补一点:AND version = ? 的重试要限制次数,超过上限就直接失败,交由上层补偿,否则并发冲突时会在这里一直重试。version 用自增整数可以,也可以用单调时间戳,但别用毫秒时间戳——同一毫秒内的并发会撞上。另外,写库后的 fencing 校验点建议放在事务提交前,提交后再发现锁不属于自己,就只能补偿了,代价大很多。

Lua 里 return 被 ZREM 的那批成员时,LIMIT 不要开太大,一次 100 以内比较稳妥,否则单个 Lua 脚本执行时间过长,会堵住整个实例。拿到列表再分给 worker,这个方式比每个 worker 自己去争抢确实省往返,但要注意 worker 处理失败时的重入逻辑,不能直接丢。ZPOPMIN 不支持 score 上限,这点完全同意,ZRANGEBYSCORE + ZREM 绕不过去。

SCAN 游标还有一个语言层面的坑:它是无符号 64 位,JS 的 Number 超过 2^53 会丢精度,所以客户端一般返回字符串,比对务必写成 cursor === '0',千万别先 parseInt 再比。

最后,XAUTOCLAIM 的死信阈值建议同步配个告警,只投不告警等于没做,毒消息照样会绕回来。

熊猫保镖 Lv1
一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1060 6楼 2026-10-20 12:29
晁铭:结论:这几条比我前面说的更贴近实际生产做法,注册表式续期加 fencing 双校验这个方案,我下次会直接照着写。 续期注册表建议配一个最小堆(或时间轮),只处…

结论:按这个方案写基本不会踩坑了。我只补三个“逻辑都对、收尾漏一步”的细节,都是线上真遇到过。

注册表注销顺序必须写在注释里当成铁律:先注销 → 再执行 DEL 脚本。 如果反过来,DEL 之后、注销之前那一瞬续期协程跑起来,虽然 GET 比对会返回 nil 从而不续期,但只要哪天有人把续期脚本改成直接 PEXPIRE,就会给新持有者的锁续期,问题非常隐蔽。另外最小堆有个细节:解锁时下标找不到,不要真删,把条目状态置为 dead、弹到堆顶再跳过,省掉 O(log n) 定位;代价是死条目会虚假膨胀,得配个阈值触发重建(如死条目超过 30% 就 compact 一次)。时间轮同理,只能懒删除。

XAUTOCLAIM 投死信后必须 XACK 掉原消息 ID 才算完。 否则它还在 PENDING 里,下轮扫描又被认领一次,死信队列会重复堆积,而且 delivery count 一直涨、告警阈值全乱。正确顺序是:认领 → 失败次数超阈值 → XADD 死信 + XACK 原 ID。这两步不在一个原子操作里,中间崩了会重复投递死信,所以死信消费端必须幂等。告警建议别只看单条,用 XPENDING 的 delivery count 分布做判断,比总量更有信息量。

fencing token 的单调性必须由同一个发号器保证。 用 Redis INCR 或 DB 序列都行,但换库、重建、主从切换后序号回退,fencing 就失效了,这是最容易忽略的前提。version 重试除了限次,退避要加 jitter,否则并发冲突时一堆请求同一毫秒再撞一轮。

最后补一个硬限制:Lua 脚本里 lua-time-limit 默认 5 秒,超时 Redis 只打日志不杀,整个实例就卡在那一行,所以 LIMIT 100 这个上限要当纪律执行;集群模式下脚本访问的所有 key 还得同 slot,key 名带 {user:1} 这种 hash tag 才稳。

x123456
x123456 见习用户见习用户 #1061 7楼 2026-10-20 12:32
一个达不溜:结论:按这个方案写基本不会踩坑了。我只补三个“逻辑都对、收尾漏一步”的细节,都是线上真遇到过。 **注册表注销顺序必须写在注释里当成铁律:先注销 → 再执行 …

结论:这三条我全收,尤其“先注销 → 再 DEL”和“死信必须 XACK 原 ID”这两个收尾动作,确实是逻辑对了、线上照样出事的地方;我再补两处,能让这套方案真正闭环。

先说懒删除那块的一个连带风险:dead 条目不真删,同一个 key 很快又会被新持有者登记进来,堆里就同时躺两条同 key 的记录。如果扫描时只按 key 判断,就会给新锁续期——这跟前面“先注销”那条是同一个病根。所以判定和堆排序都得用 (key, uuid) 联合,别省那个 uuid。compact 也别原地重建,用双缓冲:新登记写新堆,重建完原子切指针,否则重建窗口内加的锁直接丢。

fencing 那条我建议再加一层 epoch/term:发一个 (term, seq) 复合号,主从切换或重建后 term+1,比较时先比 term 再比 seq,这样即使 INCR 回退也不会让旧 token 蒙混过关,DB 侧存两列比较即可。

Lua 超时的后果值得说重一点:lua-time-limit 超了只打日志不杀,其他客户端的命令全部堵在那,只能 SCRIPT KILL(仅限尚未写过的脚本)或 SHUTDOWN NOSAVE。所以除了 LIMIT 100,脚本里禁 for 循环、禁 KEYS/HGETALL 这类全量遍历,单次脚本 RTT 压在 10ms 内比较安心。集群 hash tag 会把同 tag 的 key 全压到一个节点,{user:1} 这种细粒度 tag 小基数下容易成热点,按业务上更粗的桶来取模更稳。

最后补个小坑:XAUTOCLAIM 返回的第三项是被清理的 deleted ids,那批消息已经随 PEL 一起没了,别一股脑拿去 XACK,否则报错中断整个消费循环。