当前位置: 代码网 > it编程>数据库>Redis > Redis数据类型之九种结构与典型用法

Redis数据类型之九种结构与典型用法

2026年09月20日 Redis 我要评论
string、hash、list、set、sorted set、bitmap、hyperloglog、geo、stream,redis 对外提供的这九种结构,决定了你能用一条命令做完哪些事。这一篇按结

string、hash、list、set、sorted set、bitmap、hyperloglog、geo、stream,redis 对外提供的这九种结构,决定了你能用一条命令做完哪些事。这一篇按结构逐个过一遍,每种都回答四个问题,它是什么,核心命令的语义是什么,编码和阈值在哪里切换,典型场景该怎么落地、什么时候该换别的方案。

命令本身查文档就有,值得写下来的是查文档看不出来的部分。

贯穿全文的线索是原子性边界。每引入一个结构,我都会问一句,这条命令本身原子吗,需要多条命令时它们之间会不会被别人插进来。

string

string 是唯一一个值本身就能当数字用的结构,所有命令都围绕「一个值」展开,后面几乎每个结构都在和它做对比。

核心命令与语义

setget 是最基本的,真正值得记住的是 set 后面那一串选项。

选项含义起始版本
nxkey 不存在时才写入2.6.12
xxkey 存在时才写入2.6.12
ex / px相对过期时间,单位秒 / 毫秒2.6.12
exat / pxat绝对过期时间戳,单位秒 / 毫秒6.2
keepttl保留原有 ttl6.0
get返回写入前的旧值6.2

nxget 同时使用是 7.0 才允许的,在那之前会直接报语法错误。getset 从 6.2 起废弃,要「写入并拿到旧值」用 set key value get。6.2 还加了 getdel,读出来的同时删掉 key,用在验证码这类一次性数据上很顺手。

set 会清掉原来的过期时间,除非带上 keepttl,本来设了五分钟过期的缓存被一次不带 exset 覆盖之后就变成永久的。

msetmget 省的是网络往返,不是原子性。mset 中间不会被别的命令插进来,但它不保证「要么全成功要么全失败」,因为它只会成功。mget 的返回数组按你传的 key 顺序排,某个 key 不存在就在对应位置返回 nil,位置严格对应,解析时不能跳过 nil。cluster 下多个 key 必须落在同一个 slot,否则报错。

计数类是 string 最有辨识度的能力。incrdecrincrbydecrbyincrbyfloat 都原子,原子性来自命令执行路径的单线程模型。边界有几条,值不是合法数字时报错,包括带空格、带单位、空字符串;超过 64 位有符号整数范围时报错而不是回绕;incrbyfloat 用 long double 计算,浮点不是精确十进制,反复累加会积累偏差,金额这类场景用整数分。

setnx 单独用要配 expire,两条命令中间有窗口,进程在这两步之间挂掉 key 就永久留下了,所以需要「不存在才写入并带过期」时用 set key value nx ex 30

ttlpttl 返回 -1 表示 key 存在但没设过期,返回 -2 表示 key 不存在,这两个值在排查时很关键,persist 移除过期时间。这套细节在 posts/redis 里那篇 redis 基础篇:从定位到 key 管理.md 展开过。

编码与阈值

string 有三种编码。能表示成 64 位整数且长度不超过 20 位时用 int,长度不超过 44 字节时用 embstr,再长就是 raw。44 来自 redisobject 的 16 字节加上 sdshdr8 的 3 字节再加 1 字节结尾,正好凑满一次 64 字节的分配,用 object encoding 能直接看到当前编码。

int 编码会共享 0 到 9999 的整数对象,大量重复的小整数计数器不会各自占一份内存。embstrraw 的差别在分配次数,前者一次分配就把对象头和字符串放在一起。embstr 不可变,appendsetrange 这类追加操作都会把它转成 raw,而且转换单向,转过去不会回来。

典型场景,缓存、计数器与分布式锁

缓存是最主流的用法,value 通常是 json 字符串,写缓存用 set key value ex 300 一条命令把值和 ttl 都定好。计数器是它的主场,点赞数、库存、未读数这类高频累加,用数据库的 update ... set n = n + 1 有行锁和写放大,用 redis 就是一次原子自增。

分布式锁的正确写法是一条命令。

set lock:order:1001 8f3c1a92-6d1e-4b2f-9a7c-2e5d8b0a4f31 nx ex 30

三个要素缺一不可。nx 保证只有一个客户端拿到锁,ex 30 保证客户端崩了锁会自己过期,value 用唯一标识保证释放时能确认这把锁还是自己的。释放锁不能直接 del,要先比对 value 再删,比对和删除必须在同一个 lua 里。

-- 只有 value 还是自己当初写入的那串才删除,避免误删别人的锁
-- keys[1] 是锁的 key,argv[1] 是加锁时写入的唯一标识
if redis.call('get', keys[1]) == argv[1] then
  return redis.call('del', keys[1])
end
return 0

不这么做会有一个很难复现的时序问题。客户端 a 检查 value 通过,还没来得及删,锁因为超时被 redis 自动释放,客户端 b 拿到了同一把锁,这时 a 的 del 删掉的是 b 的锁。

即便加了脚本,边界依然存在。业务执行时间超过锁的过期时间,锁提前释放,两个客户端同时在改同一份数据。这不是换命令能解决的,要么给锁续期,要么让业务自己幂等。这里只要记住 set nx ex 是起点不是终点。

什么时候别用 string

对象的字段要单独读写时别用 string,存整段 json 意味着改一个字段要读出来、反序列化、改、序列化、写回去五个步骤,这时该换 hash。value 很大时也别用,几十 kb 的 json 就是潜在的大 key。往末尾不断追加的场景同样不适合,append 会反复重新分配,用 list 或者 stream。

hash

hash 把「一个对象的多个字段」放进一个 key 里。string 能对一整个值做原子自增,hash 能对某一个字段做原子自增,粒度从值降到了字段,这个粒度变化是 hash 存在的第一个理由。

核心命令与语义

hset 写字段,hget 读字段。从 4.0 起 hset 支持一次写多个 field-value 对,在这之前批量写只能用 hmset,现在 hmset 已废弃。hsetnx 只在字段不存在时写入。

hmget 一次读多个字段,返回数组按你给的顺序排,字段不存在的位置返回 nil,行为跟 mget 一致。hgetall 返回所有字段和值,复杂度 o(n),字段多了就是一次慢查询。配套的还有 hkeyshvalshlenhexistshdel,以及 6.2 加的 hrandfield

hincrbyhincrbyfloat 只对指定字段做原子加减,不影响同一 key 里的其他字段,也不用把整个 hash 读出来再写回去。上一节说 string 的计数器一个 key 一个数,hash 把这件事变成了一组数共享一个 key。

遍历字段很多的 hash 要用 hscan。它的语义和 scan 一样,游标式、可能重复、count 只是提示。hgetall 一次性把所有字段拉回来,既占主线程时间又占网络;hscan 每次取一小批,代价是多几次往返,业务要能容忍重复。几十个字段以内 hgetall 没问题,上千个就该换 hscan

编码与阈值

hash 有两种编码,字段少且字段名和值都不大时用 listpack,超出阈值转成 hashtable。7.0 之前这个紧凑编码叫 ziplist,7.0 起统一换成 listpack,配置项也跟着改名。

# 字段数不超过这个值,且每个字段名和值的长度都不超过下面的限制,就用 listpack
hash-max-listpack-entries 512
hash-max-listpack-value 64

两个阈值是「且」的关系,任何一个超出都会触发转换。转换单向,一旦变成 hashtable,就算后来把字段删到只剩两个也不会变回来。

listpack 省内存的原因在别处也成立,紧凑结构把字段名和值连续排在一块内存里,省掉了每个字段各自的 redisobject 头、指针和哈希表桶的开销,一批小对象拆成多个 string key 存和塞进若干个 hash 里存,内存能差出一个数量级。

hash 的字段不能单独设过期时间,expire 作用在整个 key 上。redis 7.4 补上了 hexpire 这一组字段级过期命令,但客户端和周边工具的支持参差不齐,主流方案还是按整 key 过期设计。确实需要某些字段先过期,就把它们拆到单独的 key,或者在字段值里存时间戳自己判断。

典型场景

存对象是最典型的用法。user:1001 下面放 name、avatar、level、last_login 这些字段,改昵称只写一个字段,读等级只取一个字段,也方便一次 hmget 把列表页需要的字段一起拿回来。

购物车也很自然。key 是 cart:用户id,field 是商品 id,value 是数量,加购用 hincrby 加一,减购传负数,删除用 hdel,全量展示用 hgetall。数量加减天然原子,并发的加购不会互相覆盖,如果商品数量有上限,hincrby 之后的检查得靠 lua 把自增和校验合成一步才严格。

还有一个是计数器的分组版本。统计每个接口每天的调用次数,用 api:count:20250812 作为 key,接口名作为 field,hincrby 加一,一天的统计就是一个 key 而不是几万个 string key。

string json 与 hash 的逐维度对比

维度string 存 jsonhash
读写粒度整段读写字段级读写
序列化成本每次读写都要编解码字段名和值直接对应,无编解码
内存占用每 key 一套对象头和字典开销小 hash 用 listpack,同数据量更省
字段级原子性没有,只能整段覆盖有,hincrby 只动一个字段
并发安全读改写之间有覆盖窗口单字段更新天然原子
部分字段过期做不到7.4 之前也做不到,整 key 过期
客户端类型映射反序列化成对象,类型清晰全是字符串,要自己转
判断标准整取整存,结构嵌套深字段独立更新,字段数量可控

只改一个字段时,string 的路径是 get 整段、反序列化、改字段、序列化、set 整段,两次往返加两次编解码;hash 的路径是一条 hset

序列化这块,json 可读,但写入要拼字符串,读取要解析,字段越多越贵;hash 的字段名和值直接就是字符串。代价是类型信息丢了,数字和布尔值存进去出来都是字符串,布尔值要约定用 0 和 1 还是 true 和 false。

字段级原子性和并发安全是 hash 最硬的优势。string json 的读改写模式在并发下会丢更新,两个人同时改不同字段,后写的会把前一个覆盖掉,除非加锁或者用乐观版本号;hash 改不同字段的两条 hset 互不干扰。

部分字段过期两边都不行,string 整段一个 ttl,hash 整 key 一个 ttl。客户端类型映射是 string 的强项,json 反序列化之后字段有明确类型,ide 能提示,重构能查引用;hash 全靠字符串约定,字段名拼错编译期发现不了。判断标准两句话,整体读整体写用 string json,经常只改一两个字段且字段数量可控用 hash。

什么时候别用 hash

字段数量会无限增长的 hash 不要用。把每个用户的订单塞进一个 hash,field 是订单号,这个 hash 会长到几万甚至几十万字段,既是 big key,hgetall 也会直接堵住主线程,这种「一对多且多的一侧持续增长」的场景该用 zset 或者独立的 key。

需要按字段排序或者范围查询的,hash 不支持,字段无序,hscan 的顺序也不保证,只能全部取出来在客户端排。

list

list 是双向链表语义的列表,两头都能进能出,按下标能随机访问,按区间能截取。它的定位是「有序且允许重复的序列」,消息队列和时间线这两个场景就是靠它撑起来的。

核心命令与语义

lpushrpush 从两端压入,lpoprpop 从两端弹出。6.2 起 lpoprpop 可以带一个 count,一次弹多个并返回数组,批量消费时能省掉大量往返。lpushxrpushx 只在 key 已经存在时才压入,用它可以避免在没人消费的时候凭空造出一个空列表。

llen 取长度,lindex 按下标取元素,lset 按下标改,linsert 在某个元素前后插入,lrem 按值删指定个数的元素,ltrim 保留区间内的元素。这几条里 linsertlrem 都是 o(n),要线性找位置,列表长了就是慢查询。

lrange 取区间,支持负索引,0 -1 就是全量。lrange key 0 -1 的复杂度是 o(n),一个几十万元素的列表跑一次就是把整个列表打包传回客户端,既占主线程时间又占网络和内存,分页要用小范围。

list 的编码有两种。元素少且都小的时候用 listpack,元素多了或者大了就变成 quicklist。quicklist 可以理解成把多个 listpack 串起来的双向链表,每个节点的大小由 list-max-listpack-size 控制,默认值 -2 表示每个节点最多 8kb。

阻塞命令的语义

blpopbrpop 在列表为空时把客户端挂起,等到有元素再返回,应用层就不用写轮询了。

超时的单位是秒,6.0 起支持小数,可以写 0.5。超时返回 nil,注意是 nil 而不是空数组,很多客户端封装会把这个差别吃掉。传 0 表示一直阻塞。可以一次传多个 key,命令按顺序检查,返回第一个有数据的 key 和弹出的元素。阻塞客户端不占 cpu,是因为它被从就绪队列里移出去了,事件循环不会轮询它,等到有数据写入对应 key 时才把它放回去,所以哪怕几万个连接在 blpop 上等着,服务端负担也不大。

blmove 是 6.2 加的,brpoplpush 是它的前身,现在建议用 blmove,因为它能把源和目标的弹出压入方向都写清楚。它们的用途是把消息从一个队列原子地搬到另一个队列,比如从待处理队列搬到处理中队列。

典型场景,消息队列与时间线

lpushbrpop 做队列是最经典的写法。生产者往左压,消费者从右弹,先入先出;想先入后出就两边都用 lpushlpop。这条路的两个缺陷必须讲透。

一个是消息丢失。brpop 是弹出即删除,消费者拿到消息之后如果进程崩了,或者处理逻辑抛了异常,这条消息就没了,redis 里找不到任何痕迹。补救只能在应用层加一层,比如弹出来先写到一个「处理中」的 list 里,处理完再删,失败了靠定时任务捞回来。

另一个是没有 ack 和消费组。list 不记录谁拿走了哪条消息,也没有「未确认」的概念,多个消费者之间是纯抢的关系,消费者扩容缩容、某个消费者挂掉之后它手上的消息怎么办,这些都得业务自己想。

list 做队列只适合两类场景。一类是允许丢的,比如日志投递、非关键的通知推送;另一类是有补偿的,比如任务重跑代价低,或者有对账任务兜底。需要至少一次投递、需要消费组、需要回溯的,用 stream。

时间线是另一个很自然的用法。用户的动态流用 lpush 写进去,读的时候 lrange key 0 9 取最近十条,ltrim key 0 999 只保留最近一千条,内存能通过 ltrim 控制住。要注意 ltrim 之后老数据就真没了,需要翻很久以前的动态还是得回数据库查。

什么时候别用 list

需要按值查找、按值删除、按下标中间插入的,list 全是 o(n),数据量一大就难受,该考虑 zset 或者别的结构。需要可靠投递的,前面说过了,换 stream。

set

set 是不重复、无序的字符串集合,最值钱的地方是集合运算。

核心命令与语义

sadd 添加成员,返回值是实际新增的个数,不是命令执行的个数。srem 删除成员,sismember 判断单个成员是否存在,6.2 起有 smismember 可以一次判断多个,返回 0 和 1 组成的数组,比循环 sismember 省往返。scard 取基数。

smembers 返回全部成员,复杂度 o(n),上万成员的集合用它会直接拖慢主线程,这时该用 sscansrandmember 随机取成员,正数 count 表示取不重复的若干个,负数 count 表示取允许重复的若干个,抽奖要唯一中奖者用正数,模拟随机行为用负数。spop 是随机弹出并从集合里删掉,可以用来做「从池子里随机取一个并移除」的分配逻辑。

集合运算有三对命令。sinter 求交集,sunion 求并集,sdiff 求差集,各自的 *store 版本把结果落到目标 key 里。6.2 加了 sintercard,只返回交集大小而不返回成员,还能带 limit,一旦达到 limit 就提前返回,做「两个人有没有共同好友」这类判断特别省。

集合运算的复杂度与缓解

sinter 的复杂度是 o(n*m),n 是最小集合的大小,m 是集合个数,看着还行。但 sunionsdiff 是 o(n),n 是所有集合的元素总数,大集合上跑一次就是几百毫秒级别的阻塞。

缓解办法有几种。大集合的运算尽量放到从库上跑,别让分析类计算和线上请求抢同一个线程。结果要反复用的,用 sinterstore 把中间结果落下来并设个短过期时间。sinterstore 的结果为空时会删掉目标 key,如果代码依赖目标 key 一定存在,这里会出问题。

cluster 下的限制要说清楚。sinterstore 这类多 key 命令要求所有 key 在同一个 slot,而 slot 是按整个 key 名算的,user:1001:tagsuser:1001:likes 大概率不在同一个槽。解决办法是给每个 key 加 hash tag,用 {...} 把要落到同一槽的部分框起来,比如 {user:1001}:tags{user:1001}:likes。注意 hash tag 必须写在每个 key 上,只写一个不起作用。

编码与阈值

set 有三种编码。元素全是整数且数量不超过 set-max-intset-entries(默认 512)时用 intset,元素数量少且元素不大时用 listpack,再大就转 hashtable。listpack 编码是 7.2 才加的,对应的配置项是 set-max-listpack-entries(默认 128)和 set-max-listpack-value(默认 64)。转换同样是单向的,intset 一旦加入非整数元素或者超过数量阈值就升级成 hashtable,不会降回来。smembers 上万成员要用 sscan 替代,道理和 hash 里的 hgetallhscan 一样。

典型场景,点赞、标签、共同好友与去重

点赞有两种建模方向,代价不一样。一种是「谁点赞了这篇文章」,key 是 post:1001:likes,成员是用户 id,用 sadd 点赞,srem 取消,sismember 判断某个人有没有点过,scard 取总数。另一种是「这个人点赞了哪些文章」,key 是 user:1001:likes,成员是文章 id。两种方向的查询代价是对称的,前者查「我赞过的文章」要扫全站,后者查「这篇文章谁赞过」要扫全站,实际系统里通常两个方向都要,用两份数据冗余。

热门文章的点赞集合可能有几百万人,smembers 直接不可用,scard 是 o(1) 所以没问题,但任何全量操作都会出事。常见的处理是分片,按用户 id 取模把点赞拆到 post:1001:likes:0post:1001:likes:9 十个 key 里,总数靠十次 scard 相加。

标签是 set 最自然的用法。每篇文章挂一个标签集合,筛选「同时属于 a 和 b」用 sinter,「属于 a 或 b」用 sunion,「属于 a 但不属于 b」用 sdiff。如果标签对应的文章集合很大,sinter 的代价会很高,实际系统更常见的做法是把标签组合的结果预先算好,用 sinterstore 加过期做一层缓存。

共同好友用 sinter 算,但先要分清两种关系。关注是单向的,互关是双向的,业务要的是「共同关注」,直接对两个人的关注集合求交就行;要的是「共同好友」,得先确认双方都关注了对方。大 v 的关注列表可能有几百万人,拿大 v 和普通用户求交,虽然 sinter 的成本主要落在小集合那一侧,但几百万成员的大 v 集合依然不建议在线上主库直接跑,通常是给大 v 单独做一层缓存,或者用采样近似。

去重只能用 set 做精确去重。hyperloglog 能做基数估计,但它不存成员,你没法用它判断某个元素是否已经存在。业务要的是「这个用户是不是已经参与过」,必须用 set,代价是内存随元素数线性增长;只要「大概有多少人参与过」,hyperloglog 就够了。

什么时候别用 set

需要有序的,set 无序,换 zset。需要按时间排序的点赞列表,得用 zset 把时间当 score。只需要判断存在性、元素量极大,而且元素能映射成连续整数下标的,用 bitmap 比 set 省得多。

sorted set

sorted set 是 set 加上一个分数,成员不重复,按分数排序,分数相同时按成员字典序排。最后这半句很多人不知道,但它决定了排行榜在分数相同时显示顺序是否稳定,也决定了 bylex 查询能不能按预期工作。

核心命令与语义

zadd 添加或更新成员,它的选项组合是这一节最值得记的。

选项含义起始版本
nx只在成员不存在时添加6.2
xx只在成员已存在时更新6.2
gt只在新分数大于当前分数时更新6.2
lt只在新分数小于当前分数时更新6.2
ch返回值改成发生变化的成员数3.0.2
incr变成对分数的自增,返回新分数3.0.2

nxgtlt 三者互斥,同时给会报错。默认返回值是新增成员的数量,不包含被更新的成员,所以只更新已有成员的分值时返回值是 0,需要变化数量时加 ch

zrange 在 6.2 做了统一,把过去分散在 zrevrangezrangebyscorezrangebylex 里的能力合并进来,用 byscorebylexrevlimit 区分。zrange key 0 -1 按排名取,zrange key (1 5 byscore 按分数取,zrange key [a [z bylex 按字典序取。bylex 要求所有成员的分数相同,否则结果没有定义。

zrankzrevrank 查排名,withscore 选项是 7.2 加的,能一次把排名和分数都拿到。zscore 查单个成员的分数,zincrby 对分数做原子自增,zcard 取总数,zcount 按分数区间计数,zlexcount 按字典序区间计数,zrem 删成员,zremrangebyrankzremrangebyscorezremrangebylex 按区间批量删,zpopminzpopmax 弹出最小或最大,bzpopminbzpopmax 是它们的阻塞版本。

编码与内部结构

zset 有两种编码。成员数量不超过 zset-max-listpack-entries(默认 128)且每个成员长度不超过 zset-max-listpack-value(默认 64)时用 listpack,超出就转成跳表加字典的双结构。字典负责「给成员找分数」,所以 zscore 是 o(1);跳表负责「按分数找排名」和「按排名找成员」,所以 zrangezrank 是 o(log n)。

场景一,排行榜

用户 id 作为成员,分数作为分数,zincrby 加分,zrevrange key 0 9 取前十,zrevrank key 用户id 查某个人排第几。

几个工程上的细节。周榜月榜要分 key,rank:week:202532rank:month:202508 这样,然后给每个 key 设过期时间,比统计周期长一点,方便事后查历史。同分时的并列名次是个产品问题,zrank 返回的是唯一排名,分数相同的两个人会按字典序分出先后,产品要求并列名次的话,你得在应用层按分数分组处理。zrevrange key 100000 100009 是 o(log n) 定位加 o(m) 返回,但排名靠后意味着要跨过很多节点,页数越深越慢,通常只开放前若干页。另外一个常被忽略的点,zset 的成员是唯一的,排行榜要保留同一个用户的多次成绩,得把成绩 id 拼进成员名。

场景二,延迟队列

延迟队列用 zset 的 score 存到期时间戳,毫秒值。生产端写进去,消费端按当前时间取到期的任务。

zrangebyscore delay:queue -inf 1755000000000 limit 0 10

问题在于,取出来和删除是两步。先取再删,两个消费者可能同时取到同一批任务,重复执行;先删再取,取的过程崩了任务就丢了。所以取出即删除必须用 lua 合成一步。

-- 取到期任务并原子删除,避免多个消费者重复拿到同一个任务
-- keys[1] 是延迟队列的 zset,argv[1] 是当前时间戳毫秒,argv[2] 是本次最多取多少个
local now = tonumber(argv[1])
local limit = tonumber(argv[2])
local jobs = redis.call('zrangebyscore', keys[1], '-inf', now, 'limit', 0, limit)
if #jobs == 0 then
  return {}
end
-- 取到就立刻删掉,查询和删除在同一个脚本里,别的客户端插不进来
redis.call('zrem', keys[1], unpack(jobs))
return jobs

脚本有个边界要提,unpack 在 lua 5.1 里对参数个数有限制,limit 别给太大,几百以内比较安全。

另一个取舍是 bzpopmin。它能在队列为空时阻塞等待,比轮询省事,但它不看分数,只要有元素就弹出来,哪怕这个元素的到期时间还没到。所以用它的写法是,弹出来之后判断到期时间,没到就再 zadd 回去然后睡一会儿。这个做法在高并发下会把队列搅乱,因为放回去的任务可能立刻又被另一个消费者弹出来。我的建议是延迟队列用轮询加 lua。

场景三,滑动窗口限流

固定窗口限流用 increxpire 就能做,问题是窗口边界会放过两倍流量,限制每分钟 100 次时,用户可以在边界两侧各打满一次。滑动窗口把每次请求当成 zset 里的一个成员,score 是请求时间戳,然后三步操作,清掉窗口外的旧记录,数一下窗口内还剩多少,没超限就记上自己。这三步必须原子,否则并发下会超发。

-- 滑动窗口限流,清理、计数、写入三步合成一个原子操作
-- keys[1] 是限流 key,argv[1] 当前时间戳毫秒,argv[2] 窗口长度毫秒
-- argv[3] 窗口内允许的最大请求数,argv[4] 本次请求的唯一标识
local now = tonumber(argv[1])
local window = tonumber(argv[2])
local limit = tonumber(argv[3])
local member = argv[4]
-- 第一步,把窗口之外的请求记录清掉
redis.call('zremrangebyscore', keys[1], 0, now - window)
-- 第二步,数一下窗口内还剩多少个请求
local count = redis.call('zcard', keys[1])
if count >= limit then
  return 0
end
-- 第三步,没超限就把本次请求记进去,并刷新过期时间
redis.call('zadd', keys[1], now, member)
redis.call('pexpire', keys[1], window)
return 1

返回 1 表示放行,返回 0 表示拒绝。member 必须是唯一值,通常是请求 id 或者随机字符串,用同一个值会把记录覆盖掉,计数就不准了。

相比固定窗口,滑动窗口的优势是任意一个长度为窗口的区间内请求数都不会超阈值,代价是每个请求都占一个 zset 成员,qps 高的时候内存涨得很快。所以滑动窗口适合中小流量、精度要求高的场景,超大流量下更常见的做法是令牌桶。

什么时候别用 zset

需要按值查询而不是按分数查询的,zset 做不到,它只按分数和字典序组织。成员数量巨大且只关心排名的,zset 的内存开销是每个成员一份跳表节点加一份字典条目,几千万成员会吃掉可观的内存,这时候得考虑分片或者近似方案。需要多个维度排序的,一个 zset 只能有一个分数,多维排序要么编码成复合分数,要么用多个 zset 各自维护。

bitmap、hyperloglog 与 geo

这三个放在一起讲,是因为它们的共同点是「为特定问题定制的紧凑结构」。bitmap 是 string,hyperloglog 也是 string,geo 是 zset,但它们在各自的问题上比通用结构省得多,代价是只能回答那一类问题。

bitmap

bitmap 的本质是把 string 当比特数组用。setbit key offset 1 把第 offset 位置 1,getbit 读一位,bitcount 数一共有多少位是 1,bitpos 找第一个 0 或 1 的位置,bitop and dest src1 src2 做位运算并把结果落到目标 key,bitfield 支持按位域读写和自增,还能用 overflow wrapsatfail 指定溢出行为。

7.0 起 bitcountbitpos 支持 bytebit 两种单位,可以精确到位区间,在这之前只能按字节算。

setbit key 1000000 1 会立刻把 value 撑到 125kb,因为 redis 按最右边的偏移量分配内存,中间没写的位全是 0 也要占空间。用户 id 是稀疏的大整数时直接用 id 当偏移量,一个用户就占十几 kb,解决办法是把 id 映射成连续序号,或者按 id 分片。

签到是最典型的用法。一个用户一个月一个 bitmap,setbit sign:用户id:202508 日期-1 1,一天一位,31 天只占 4 个字节,查某天有没有签用 getbit,查这个月签了多少天用 bitcount

连续签到怎么算,不能只说用位运算就完了,思路是把 1 号到今天这一段位一次性取出来,再从最低位数连续的 1,bitfield 可以一次取整段。

import redis
r = redis.redis(decode_responses=true)
def check_in(user_id, today, month):
    """签到,today 是当月的第几天,从 1 开始"""
    key = f"sign:{user_id}:{month}"
    # 第 1 天写偏移 0,第 today 天写偏移 today - 1
    r.setbit(key, today - 1, 1)
    # 给整个月的位图一个略长的过期时间,避免冷 key 一直占内存
    r.expire(key, 40 * 24 * 3600)
def consecutive_days(user_id, today, month):
    """连续签到天数,一条 bitfield 把 1 号到今天一次取出来"""
    key = f"sign:{user_id}:{month}"
    # u<today> 的宽度正好覆盖 1 号到今天的位
    # 位偏移 0 对应这个无符号整数的最高位,今天对应最低位
    val = r.execute_command("bitfield", key, "get", f"u{today}", 0)[0] or 0
    # 从最低位往上数连续的 1,就是最近这段连续签到
    days = 0
    while val & 1:
        days += 1
        val >>= 1
    return days

关键在位的顺序。redis 的 bitmap 里偏移 0 是第一个字节的最高位,所以 bitfield key get u31 0 拿到的整数,1 号在最高位,31 号在最低位,从最低位数连续的 1,数出来就是截至今天的连续天数。

用户状态也可以用 bitmap,一个用户一位,一次 bitop and 就能算出「同时满足两个条件的用户」,前提还是用户 id 能映射成紧凑的连续整数。

hyperloglog

hyperloglog 解决的是基数估计,也就是「大概有多少个不重复的元素」。pfadd 加元素,pfcount 取估计值,pfmerge 把多个 hll 合并成一个。

它的标准误差是 0.81%。内存占用固定,稠密编码下每个寄存器 6 位,16384 乘 6 位正好 12kb,跟里面装了多少元素无关;元素少时用稀疏编码,超过 hll-sparse-max-bytes(默认 3000)转成稠密编码。

为什么能这么省,思路是把每个元素的哈希值分成两部分,前面若干位决定落到哪个寄存器,后面的位用来数前导零的个数,每个寄存器只记它见过的最大前导零数。估计基数时把 16384 个寄存器的值做一次调和平均,再乘一个修正系数。存的是统计量不是元素本身,所以内存和元素数量无关。

它不保存成员,你不能问「某个用户算不算在 uv 里」,只能问「大概有多少人」。pfcount 传多个 key 时算的是并集基数,交集做不到,因为 hll 只能合并不能求交,用容斥原理反推交集在小交集上误差会大到没法用。

uv 统计是它的主场。按天一个 hll,pfadd uv:20250812 用户id,查当天 uv 用 pfcount,算周活月活用 pfmerge 把七天的 hll 合并成一个临时的再 pfcount,合并出来的临时 key 记得设过期时间。

geo

geo 底层就是 zset,成员是地点标识,score 是经纬度编码成的 52 位整数。所以 zset 的命令在 geo 数据上基本都能用,zrem 删地点,zcard 数地点数。

geoadd 添加地点,geopos 取回经纬度,geodist 算两点距离,geohash 返回标准 geohash 字符串。查询用 geosearch,它是 6.2 加的,替代了 georadius 系列。用法是把中心点和范围分开写,中心点用 frommemberfromlonlat,范围用 byradiusbybox,再加上 ascdesc 排序、count 限制数量、withcoordwithdistwithhash 附加信息,geosearchstore 把结果落到另一个 key 里。

52 位编码的原理是把经纬度各自转成 26 位整数,交叉排列成一个 52 位整数,地理上相邻的点编码之后前缀也相邻,于是可以用范围查询近似做邻近搜索。代价是有精度损失,定位误差在米级,geodist 的相对误差在 0.5% 以内,做「附近的人」够用,做精确定位不够。

附近的人的完整思路是这样的。位置上报时 geoadd nearby:users 经度 纬度 用户id,同一个人重复上报会覆盖分数,所以位置更新是幂等的。查询时用 geosearch nearby:users fromlonlat 经度 纬度 byradius 5 km asc count 20 拿最近的 20 个人。

性能考量主要在位置更新这一侧。上报很频繁的话,比如每秒一次,几百万在线用户就是几百万 qps 的写,geoadd 单条虽然是 o(log n),总量摆在那里,通常是限制上报频率、做位置去重(移动距离小于阈值就不上报),或者按城市分 key 把单个 zset 的规模压下来。

边界情况要提一下。靠近 180 度经线或者极点的地方,按矩形框做邻近搜索需要额外处理,否则可能漏掉本该命中的点。经纬度的合法性也要校验,geoadd 的经度范围是 -180 到 180,纬度是 -85.05112878 到 85.05112878,超出会报错。

三者对比

维度bitmaphyperlogloggeo
底层结构stringstringzset
内存开销按最大偏移量线性增长固定约 12kb,稀疏时更少每个地点一份成员和分数
精度精确约 0.81% 标准误差米级定位误差
能回答什么某一位是不是 1,一共多少位是 1大概有多少个不重复元素附近有哪些点,相距多远
不能回答什么稀疏大下标会浪费内存具体成员是谁,精确交集复杂地理计算,厘米级定位

三者的共同点是为一个窄问题换来了极高的空间效率,代价是回答不了这个窄问题之外的东西。选型时先问两句,业务要的是精确答案还是估计答案,答案里的元素需不需要能取回来。

什么时候别用它们

需要取回成员的,别用 hyperloglog。下标稀疏的,别用 bitmap。需要精确地理边界判断的,别用 geo。

stream

stream 是 redis 5.0 加的结构,也是这几个结构里唯一一个为「消息」设计的。它的模型是只追加的日志,每条消息有唯一 id,支持消费组、未确认列表和回溯读取。

核心命令与消息 id

xadd 追加消息,id 用 * 让 redis 自己生成,格式是毫秒时间戳加序号,比如 1755000000000-0,同一个毫秒内追加多条,序号递增。也可以显式指定 id,但必须严格大于当前最后一条的 id,否则报错。maxlenminid 用来裁剪,加 ~ 表示近似裁剪,也就是按内部节点的边界裁,性能比精确裁剪好得多。nomkstream 是 6.2 加的,表示 key 不存在时不创建。

xlen 取长度,xrangexrevrange 按 id 区间读,-+ 表示最小和最大 id。xdel 删除消息,但它只是逻辑删除,宏节点里那部分内存要等整个节点被释放才回收,真要回收内存得靠 xtrimxtrim 手动裁剪,同样支持 maxlenminid~

xread 是不带消费组的多播读取,支持 count 限制条数、block 阻塞等待、$ 表示只读从现在开始的新消息。xread 不维护读取位点,每次调用都要你自己传 id,传 $ 就等于放弃之前没读的消息。多个客户端用 xread 读同一个 stream,每个人都能读到全部消息,这是广播语义,不是队列语义。要做「一条消息只被一个消费者处理」,必须用消费组。

消费组与 pending list

xgroup create key group id 创建组,id 给 $ 表示只消费新消息,给 0 表示从头消费,mkstream 让 stream 不存在时自动创建。配套的还有 xgroup createconsumer 手动创建消费者,delconsumer 删掉一个消费者并清空它的 pending,setid 重置组的位点,destroy 销毁整个组。

xreadgroup group group consumer streams key > 是消费组读消息的标准写法。> 表示只读从来没有投递给任何消费者的新消息。把 > 换成一个具体 id,读的是这个消费者自己的 pending 列表,用来重读没处理完的消息,没有就是空。

消息被投递之后就进入这个消费者的 pending list,也就是 pel。xpending key group 给概要信息,包括待确认总数、最小 id、最大 id 和每个消费者的待确认数量,加 idle 和起止 id、数量参数可以查明细。处理成功之后用 xack key group id 确认,消息从 pel 移除。

消费者处理到一半挂了,它手上的消息会一直挂在 pel 里。xclaim 能把这些消息转给另一个消费者,需要指定最小闲置时间,避免抢走正在处理的消息。xautoclaim 是 6.2 加的,它把「扫描 pel 加认领」合成了一条命令,从给定起始 id 开始扫,把闲置超过阈值的消息一次性认领过来,justid 选项让返回只带 id 不带消息体。版本细节要注意,xautoclaim 的返回值在 6.2 是两项,7.0 起变成三项,第三项是被清理掉的、已经不在 stream 里的消息 id。

这套机制给的是至少一次语义,不是恰好一次。消息投递出去之后,只要没有 xack,它就会在 pel 里等着被重投,所以重复消费是可能的,消费方必须幂等。还要澄清一个常见误解,xack 只是把消息从 pel 里移除,消息本身还在 stream 里,要让它真正消失得靠裁剪。

排查与裁剪

xinfo stream 给出长度、第一个和最后一个消息 id、消费组数量、底层节点信息。xinfo groups 给出每个组的 pending 数、消费者数、最近投递的 id 和 lag,lag 就是还没投递给这个组的消息条数,这个指标是判断消费是否跟得上的关键。xinfo consumers 给出每个消费者的 pending 数和空闲时间。

xtrimxaddmaxlen 只管 stream 的长度,不管 pel,也就是说它会裁掉还没被确认的消息。裁掉之后 pel 里会留下已经不存在的 id,xpending 还能看到它们,但 xclaim 拿不到消息体。这就是 xautoclaim 返回值第三项存在的原因,它顺手把这些幽灵 id 从 pel 里清掉。

所以裁剪策略要跟消费进度配合。要么把 maxlen 设得足够大,确保任何未确认的消息都不会被裁到;要么在监控里盯着 xinfo groups 的 pending 数,pending 不降就说明有消费者卡住,这时候裁剪等于丢数据。

stream 与 list 的对比

维度liststream
投递语义弹出即删除,最多一次至少一次
ack没有xack 显式确认
消费组没有,靠多 key 分片模拟原生支持
重复消费不会,丢了就是丢了会,未确认会被重投
回溯弹出后不可读xrange 可按 id 回溯
内存回收弹出即释放需要 xtrim 主动裁剪
消息 id没有,只有位置时间戳加序号,可排序可查
适用场景允许丢或能补偿的任务分发需要确认和重投的消息处理

list 的优势是简单,两条命令就能跑起来,内存回收自动,没有 pel 要维护。场景是「投出去,处理不完就重跑」,list 完全够用,别为了用 stream 而用 stream。stream 的优势是把消息队列该有的东西补齐了,每条消息有身份,可以确认、重投、回溯、按组消费,代价是要维护消费组位点、处理重复消费、自己决定裁剪策略。

stream 与 kafka 的对比

维度streamkafka
定位内存里的消息结构分布式的持久化日志系统
投递语义至少一次至少一次,配合事务可做到精确一次
吞吐量级单实例受单线程和内存限制水平扩展,可以很高
持久化与副本跟着 redis 的 rdb 和 aof,数据在内存数据落盘,多副本,保留期与内存无关
消息保留受内存限制,通常按长度裁剪按时间或大小保留,可以留很久
分区与顺序性单 key 内严格有序,靠分片横向扩分区内有序,分区数决定并行度
位点与 rebalance消费组位点存在 redis 里位点在消费者组,有 rebalance 机制
运维复杂度低,跟着现有的 redis 走高,要单独部署和调优
生态客户端支持有限,周边工具少连接器、监控、流处理生态成熟

这张表里有一行要单独说,kafka 在大吞吐和长保留这两件事上是碾压 stream 的。kafka 的数据落盘,保留期可以按天甚至按月算,stream 的数据在内存,保留长度直接吃内存,一个实例能留多少条消息有硬上限;kafka 的分区模型能把吞吐水平扩上去,stream 在单个 key 上只能靠主线程串行处理。所以日志管道、事件溯源、需要保留几天的消息,用 kafka 是对的,不要试图用 stream 硬扛。

stream 的优势在别的地方。它就在你的 redis 里,不需要额外部署一套集群,运维成本几乎为零,适合「顺带需要一个消息通道」的场景,比如订单状态变更通知、异步任务派发、缓存更新事件,消息量不大、保留时间短、可靠性要求不苛刻。一句话概括,量级和保留期决定要不要 kafka,有没有基本可靠性要求决定要不要 stream 而不是 list。

消费组消费骨架

下面这个骨架可以直接跑,包含创建组、读新消息、处理、确认,以及用 xautoclaim 兜底超时未确认的消息。

import time
import redis
r = redis.redis(host="127.0.0.1", port=6379, decode_responses=true)
stream = "order:events"
group = "order-workers"
consumer = "worker-1"
# 创建消费组,id 给 0 表示从已有的第一条开始消费
# mkstream 让 stream 不存在时自动创建,避免启动顺序问题
try:
    r.xgroup_create(stream, group, id="0", mkstream=true)
except redis.exceptions.responseerror as e:
    # busygroup 表示组已经存在,这是正常情况,忽略
    if "busygroup" not in str(e):
        raise
def handle(msg_id, fields):
    # 真正的业务处理必须幂等,因为重复投递是可能的
    print("处理", msg_id, fields)
def consume_new():
    # ">" 表示只读从未投递给任何消费者的新消息
    resp = r.xreadgroup(group, consumer, {stream: ">"}, count=10, block=2000)
    if not resp:
        return
    for _, messages in resp:
        for msg_id, fields in messages:
            try:
                handle(msg_id, fields)
                # 处理成功才确认,从 pending 列表移除
                r.xack(stream, group, msg_id)
            except exception as exc:
                # 不确认,留在 pending 里等兜底逻辑重投
                print("处理失败", msg_id, exc)
def reclaim_stale():
    # 把闲置超过 60 秒的 pending 消息认领过来重新处理
    # min_idle_time 的单位是毫秒,start_id 给 0-0 表示从头扫
    resp = r.xautoclaim(stream, group, consumer,
                        min_idle_time=60000, start_id="0-0", count=10)
    # 6.2 返回两项,7.0 起返回三项,第三项是被清掉的已删除消息 id
    claimed = resp[1]
    for msg_id, fields in claimed:
        try:
            handle(msg_id, fields)
            r.xack(stream, group, msg_id)
        except exception as exc:
            print("重投仍失败", msg_id, exc)
while true:
    consume_new()
    reclaim_stale()
    time.sleep(0.1)

骨架里有几个地方是有意这么写的。handle 里的业务必须幂等,因为 reclaim_stale 可能把已经处理过但没来得及确认的消息再处理一次。

什么时候别用 stream

需要极高吞吐或者长保留期的,用 kafka 这类专门的系统。需要扇出给大量独立订阅者的,用 pub/sub 或者多个消费组,单靠一个 stream 加一个组做不到。消息处理逻辑很重、耗时很长的情况,不该让消费者长时间占着 pending,更合适的做法是 stream 只做入口,取到之后丢给任务队列或者工作流引擎,尽快确认。

收尾

回到开头那条线索。九个结构之间的差别,很大程度上是「一条命令能原子地做完多大粒度的事」的差别。string 能原子地加减一个值,hash 能原子地加减一个字段,set 能原子地做集合运算,zset 能原子地按分数插入和排名,stream 能原子地追加一条带 id 的消息。超出这个粒度的事情就得靠 lua 合成一步,这一篇里出现的三个脚本,延迟队列的取出即删除、滑动窗口的限流、释放分布式锁的身份校验,都是同一个问题的三种表现。选结构时先问两句,我要的原子操作是哪条命令能提供的,要用多条的话它们之间会不会被别人插进来。

到此这篇关于redis数据类型之九种结构与典型用法的文章就介绍到这了,更多相关redis数据类型内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com