上一篇把单机 Redis 装起来、连上 redis-cli 之后,很多人的第一反应是敲几个 SET/GET 就以为掌握了。真正上手项目才发现,Redis 的数据类型和常见命令远不止五个名字那么简单——选错类型会让内存翻三倍,用错命令会让 QPS 从十万掉到八千。这篇就专门聊 Redis 的数据类型和常见命令,把五种基础类型、四种特殊类型、key 级通用命令,以及它们背后的编码规则、参数含义和实操坑位一次讲透。适合刚学完安装、准备在项目里用 Redis 的同学,也适合已经用了一两年但没系统梳理过底层编码的老手对照查漏。
1. 先把数据类型这张地图铺开
1.1 五种基础类型各自扛什么业务
Redis 对外暴露的基础数据类型有五种:String、Hash、List、Set、ZSet。名字都很朴素,但每一种背后对应的是完全不同的数据结构实现和适用场景,选型的时候脑子里要有一张对照表,而不是拿到什么就用 String 硬塞。
String 是唯一能直接跟键值对划等号的类型,value 可以是文本、数字、二进制序列,上限 512MB。它适合缓存单值、计数器、分布式锁、限流令牌这类场景。Hash 是一个 key 下面挂多个 field-value,天然适合存对象,比如用户信息、商品属性。List 是有序可重复的双向链表,适合做队列、栈、最新列表。Set 是无序不重复集合,适合去重、交集并集运算。ZSet 在 Set 基础上给每个成员配了一个 score,按分数排序,排行榜、延时队列、带权重的排序场景基本都靠它。
这里有个常见误区:很多人觉得「反正都是存数据,用 String 存 JSON 最省事」。短期看确实省事,但一旦业务需要对某个字段单独读写、需要按分数排序、需要做去重计数,String 就彻底不够用了,只能把整个 JSON 读出来改完再写回去,并发一高就是典型的读改写覆盖问题。所以选型的第一步不是看数据长什么样,而是看「你要对它做什么操作」。
我在实际项目里见过一个典型反例:某团队用 String 存用户的在线状态列表,每次上下线都要读取整个列表、修改、写回,用户量一上来就频繁出现状态丢失。后来换成 Set,SADD/SREM 一步到位,问题直接消失。这不是 Redis 的问题,是类型选错了。
1.2 底层编码才是性能的分水岭
同样一条 HSET 命令,在小 Hash 上和在百万级 Hash 上,耗时能差出一个数量级。原因就在于 Redis 为每种类型准备了多套底层编码,会根据数据规模和元素大小自动切换。
String 有三种编码:int(值是 Long 范围内的整数且没超过长度限制时)、embstr(短字符串,44 字节以内)、raw(长字符串)。int 和 embstr 都是一次内存分配,raw 是两次,所以短小的字符串在内存和访问效率上都更优。这个 44 字节的界限来自 redisObject 结构加 SDS 头的开销,实际用的时候不用刻意卡,但知道它存在有助于理解为什么「短 key 短 value」是被反复强调的优化点。
Hash 在小规模时用 listpack(7.0 之前是 ziplist)存储,本质是一块连续内存,遍历快、省指针开销;超过阈值就转成 hashtable,支持 O(1) 查找但每个键值对都要额外指针。Set 在元素全是整数且数量不多时用 intset,有序数组加二分查找;元素是短字符串时会用 listpack(7.2 起支持);否则用 hashtable。ZSet 在小规模时也是 listpack,大了转 skiplist 加 dict 的组合。
List 从 3.2 起统一用 quicklist,也就是「链表套 listpack」的结构,兼顾了内存连续性和插入性能。Stream 底层是 rax 前缀树,这个后面单独说。
理解编码的意义在于:你能预判数据规模变化时性能拐点在哪。比如一个 Hash 存了 200 个字段,每个 value 都是短字符串,它还是 listpack,操作很快;某天字段涨到 600 个,瞬间转成 hashtable,内存占用可能直接涨 50% 以上,这时候就该考虑拆分 key 了。
1.3 阈值配置:什么时候该动,什么时候别动
控制编码转换的配置项就那么几个,都在 redis.conf 里:
| 配置项 | 默认值 | 作用 |
|---|---|---|
| hash-max-listpack-entries | 128 | Hash 字段数上限,超过转 hashtable |
| hash-max-listpack-value | 64 | Hash 单个 value 字节上限 |
| list-max-listpack-size | -2 | 负数表示按大小(8KB)限制每个 listpack |
| set-max-intset-entries | 512 | intset 元素数上限 |
| set-max-listpack-entries | 128 | Set 用 listpack 的元素数上限 |
| zset-max-listpack-entries | 128 | ZSet 元素数上限 |
| zset-max-listpack-value | 64 | ZSet 成员字节上限 |
注意:不同大版本这两组参数的命名不一样,7.0 之前叫 hash-max-ziplist-entries,直接照抄老博客的配置会启动报错,改配置前先用 redis-cli config get 确认当前版本支持的键名。
调不调?我的经验是:默认值对 90% 的场景都够用,别乱调。把阈值调大确实能省内存,但 listpack 是连续内存,元素一多,每次插入删除都要 memmove,大 value 场景下反而更慢。真正需要调的场景通常是「元素少但每个都比较大」,比如存配置项的 Hash,字段就十几个但每个 value 有几百字节,这时候把 hash-max-listpack-value 调到 256 反而更合适。
2. String:看着最简单,坑却最多
2.1 核心命令和它们真正的用途
基础读写就是 SET/GET,但 SET 带了一堆可选参数,很多人只用过最裸的那个:
SET user:1001:name "张三" SET user:1001:token "abc123" EX 3600 NX SET counter 100 INCR counter GETSET user:1001:name "李四" MSET k1 v1 k2 v2 k3 v3 MGET k1 k2 k3EX 是秒级过期,PX 是毫秒级,NX 表示 key 不存在才设置,XX 表示存在才设置,GET 表示返回旧值。这几个参数组合起来就能实现最简单的分布式锁:SET lock:order:123 uuid NX PX 30000。注意必须是 NX 加 PX 一起用,分两步先 SETNX 再 EXPIRE 的老写法在中间崩溃时会留下永不过期的锁,这个坑我踩过不止一次。
计数器类命令 INCR/DECR/INCRBY/DECRBY/INCRBYFLOAT 是原子的,这一点非常关键。很多业务需要「先读再判断再加」,用 GET 加 SET 在并发下必然出错,用 INCR 就没有这个问题。字符串拼接用 APPEND,取子串用 GETRANGE,覆盖指定位置用 SETRANGE,求长度用 STRLEN,这些命令在日志截断、二进制协议解析里偶尔用得上。
还有一组容易被忽视的:SETBIT/GETBIT/BITCOUNT/BITOP,虽然是 String 的命令,但操作的是位,实际属于 Bitmap 的用法,后面第 7 节单独展开。
2.2 过期时间设了却不清,问题出在哪
有一类问题几乎每个用 Redis 的人都遇到过:明明设了过期时间,内存却没降下来。这通常不是 Redis 的 bug,而是理解偏差。
第一,过期删除是「惰性删除加定期删除」组合,不是到点立刻删。惰性删除指访问时才判断是否过期,定期删除指每 100ms 随机抽样一批 key 检查。所以一个过期 key 如果一直没人访问,可能要在内存里多躺一会儿,最多几分钟级别,正常情况下影响不大。
第二,用 SET 覆盖一个带过期时间的 key,如果不显式带 EX 参数,过期时间会被清掉。比如SET token abc EX 60之后又来一句SET token def,这个 key 就永不过期了。这是我最想强调的一条,线上排查内存泄漏时先看有没有这种覆盖写法。
第三,RENAME 一个 key,过期时间会跟着走;但用 SET 重建就没了。另外 PERSIST 可以主动去掉过期时间,TTL 返回 -1 表示没设过期,-2 表示 key 根本不存在,这两个返回值别搞混。
第四,主从环境下,从库不会主动淘汰过期 key,它等主库发 DEL 命令同步过来。所以在从库上读一个逻辑上已过期的 key,行为在不同版本有差异,做读写分离的时候要留意这一点。
2.3 大 key 是怎么把主线程堵死的
String 最大的雷就是大 key。一个 10MB 的 value,GET 一次要序列化 10MB,走网络要传 10MB,删除时如果直接用 DEL,释放内存的动作是同步的,主线程会卡住。而 Redis 单线程处理命令,卡住就是全站卡住。
判断标准业界比较通用的是:String 类型 value 超过 10KB 就要警惕,超过 1MB 基本可以定性为大 key;集合类型元素数超过 5000 也算。查大 key 用redis-cli --bigkeys,它会抽样扫描并给出每种类型的最大 key,但注意这个命令走 SCAN 实现,生产环境低峰期跑,别在高峰期执行。
删除大 key 的正确姿势是用 UNLINK 代替 DEL,UNLINK 会把内存释放放到后台线程,不阻塞主线程。如果版本太老不支持 UNLINK,就只能分批处理,比如把大 Hash 拆成多个小 Hash,或者用 HSCAN 一点点删。
预防比治理重要。我现在的习惯是:凡是往 Redis 写 value,先想一下这个数据理论上最大能多大。图片、大 JSON、文件内容这些一律不进 Redis,最多存个引用地址。这个约定看着简单,能挡掉八成内存事故。
3. Hash:对象存储的正确姿势
3.1 常用命令速查
Hash 的命令以 H 开头,用起来像操作一个字典:
HSET user:1001 name "张三" age 28 city "杭州" HGET user:1001 name HMGET user:1001 name age HGETALL user:1001 HKEYS user:1001 HVALS user:1001 HLEN user:1001 HEXISTS user:1001 age HDEL user:1001 city HINCRBY user:1001 age 1 HSCAN user:1001 0 COUNT 100HGETALL 是最容易出问题的命令,它一次性返回所有字段。字段少的时候无所谓,字段上千个的时候,返回的数据量加上命令执行时间都会很明显,而且这些数据全都要经过网络。生产环境我一般要求禁用 HGETALL,改用 HMGET 指定字段或者 HSCAN 分批取。
HINCRBY 和 String 的 INCRBY 一样是原子操作,做字段级计数很方便,比如统计某个用户的各类行为次数,一个 Hash 全装下,比开一堆 key 省内存也省管理成本。
3.2 存对象:Hash 还是序列化字符串
这是选型高频问题。我的判断标准就三条:
| 判断维度 | 倾向 Hash | 倾向 String(JSON) |
|---|---|---|
| 是否需要单独读写某几个字段 | 是 | 否 |
| 字段数量是否稳定 | 是 | 无所谓 |
| 是否需要整体作为一个值参与排序 | 否 | 是 |
| 数据规模 | 中等,字段几十到几百 | 极小的对象或超大对象 |
| 是否要设整个对象过期 | 无所谓 | 需要(Hash 只能对整个 key 设过期) |
Hash 的优势是字段级操作,改一个字段不用读全量写全量,并发下不会互相覆盖。劣势是不能对单个字段设过期时间,过期只能作用于整个 key;另外如果字段极多,HGETALL 会成为负担。
String 存 JSON 的优势是简单、通用、一次网络往返拿全量,适合读多写少、整体消费的场景。劣势是改一个字段要反序列化整个对象,金额、库存这种字段在并发修改时极易出错。
还有一种折中方案是 Hash 存主体字段,额外用 String 存需要单独过期或参与排序的字段。这个方案会增加一致性维护成本,非必要不建议,除非确实有强需求。
3.3 渐进式 rehash 与内存抖动
Hash 从 listpack 转成 hashtable 之后,随着元素增删,负载因子会变化,Redis 需要在后台扩容或缩容,这个动作叫 rehash。为了不阻塞主线程,它采用的是渐进式 rehash:同时保留新旧两张哈希表,每次操作顺带迁移一部分桶,直到迁移完成。
这个机制对使用者的影响是:rehash 期间内存会短暂翻倍。如果你有一个几百万元素的 Hash 正在扩容,内存监控上会看到一个明显的尖峰,容量规划时要把这部分余量算进去。缩容同理,元素大量删除后也会触发。
另外 rehash 期间查询要查两张表,性能会有轻微下降,但幅度通常在可接受范围。真正需要注意的是别让单个 Hash 无限制增长,比如把全站用户的某个属性塞进一个 Hash,这种设计迟早出问题。合理做法是按业务维度分片,比如 user:1001:profile、user:1001:settings 分开存。
4. List:队列、栈和那点阻塞的事
4.1 命令与左右之分
List 是双向链表,命令都带 L 或 R 前缀表示操作哪一端:
LPUSH queue task1 task2 task3 RPUSH queue task4 LRANGE queue 0 -1 LPOP queue RPOP queue LLEN queue LINDEX queue 0 LINSERT queue BEFORE task2 task1.5 LREM queue 1 task1 LTRIM queue 0 99这里最容易搞混的是方向。LPUSH 是往左(头部)插,RPUSH 往右(尾部)插;LPOP 从左边弹,RPOP 从右边弹。所以「左进右出」是队列,「左进左出」是栈。写代码时一定要在注释里标清楚,我在 Code Review 里见过太多因为方向搞反导致的消费顺序错乱。
LTRIM 是个很实用的命令,常用来做「只保留最近 N 条」的场景。比如存用户最近 100 条操作记录,每次 LPUSH 之后执行LTRIM key 0 99,列表长度就永远控制在 100。这个组合比手动 LREM 干净得多。
4.2 拿 List 当消息队列的真实体验
List 确实能做简易队列,生产者 LPUSH,消费者 BRPOP。BRPOP 是阻塞版本,列表为空时会挂起连接等待,直到有数据或超时,避免了消费者空转轮询。
但必须说清楚它的局限:List 做队列没有 ACK 机制。消费者 BRPOP 拿到数据的那一刻,这条数据就从 Redis 里没了,如果消费者处理到一半崩了,这条消息就永久丢失。所以它只适合对可靠性要求不高的场景,比如发个站内通知、刷新个缓存,丢了可以容忍或者有兜底。
需要 ACK、需要消费组、需要消息回溯的场景,应该用 Stream,这个在第 7 节展开。我见过的选型错误里,「用 List 做订单消息队列」排得上前三,出了问题很难补救。
另一个细节是多消费者场景下的公平性。多个消费者同时 BRPOP 同一个 key,Redis 会按先来后到分配,但没有任何负载均衡保证。如果某个消费者处理慢,它可能一直抢不到,或者抢到了一直在忙。这个在消费能力不均的集群里会造成长尾。
4.3 阻塞命令的超时与连接管理
BRPOP 的语法是BRPOP key timeout,timeout 单位是秒,写 0 表示永久阻塞。这里有两个实操要点:
第一,阻塞期间这条连接是被占用的,不能复用来执行别的命令。如果你的连接池配置得很小,又开了很多阻塞消费者,很容易把连接池耗尽。所以阻塞消费者应该用独立的连接,不要和普通业务命令混用一个池。
第二,客户端要有超时兜底。写 0 永久阻塞看着省事,但网络抖动导致连接假死时,客户端可能永远感知不到。我一般写 1 到 5 秒,超时后客户端主动重试,顺便还能做健康检查。
还有一个坑是 BRPOP 的返回值格式,它返回的是「key 加 value」的两元素数组,不是单个 value。新手经常直接拿返回值当数据用,结果拿到的是 key 名,排查半天。
5. Set:去重与集合运算的主场
5.1 命令与集合运算
Set 的命令围绕增删改查和集合运算展开:
SADD tags:article:1 redis cache database SREM tags:article:1 cache SMEMBERS tags:article:1 SISMEMBER tags:article:1 redis SCARD tags:article:1 SPOP tags:article:1 SRANDMEMBER tags:article:1 2 SINTER tags:article:1 tags:article:2 SUNION tags:article:1 tags:article:2 SDIFF tags:article:1 tags:article:2 SINTERSTORE result tags:article:1 tags:article:2SMEMBERS 和 HGETALL 一样,是全量返回,集合大了就是灾难。查成员是否存在用 SISMEMBER,统计数量用 SCARD,需要遍历用 SSCAN 分批。这几个命令该用哪个,取决于你到底要「判断存在」还是「拿全部」。
集合运算 SINTER/SUNION/SDIFF 是 Set 最有价值的地方。社交场景里的「共同关注」就是 SINTER,推荐场景里的「你可能认识」可以用 SDIFF 排除已关注的人。但要注意这些运算的时间复杂度跟集合大小线性相关,两个十万人的集合做交集,主线程会被占住几百毫秒,这种操作要么放从库,要么提前算好存起来。
带 STORE 后缀的版本会把结果存到新 key,适合离线计算。不过 SINTERSTORE 在大集合上同样是阻塞操作,别在高峰期跑。
5.2 抽奖、去重、共同好友三个实战
去重统计:最典型的是文章阅读去重。用户看文章时 SADD article:1:users user:1001,判断是否已读用 SISMEMBER,统计阅读人数用 SCARD。相比每次都往数据库写一条记录,内存占用小、速度快,日终再落库汇总。
抽奖:SRANDMEMBER 是「随机取但不删除」,SPOP 是「随机取并删除」。抽奖场景用 SPOP 最合适,抽中的人自动从集合移除,天然防止重复中奖。如果要支持「一人抽一次但奖品保留池子」,那就用 SRANDMEMBER 配合一个额外的中奖记录集合。
共同好友:SINTER 一步到位。但真实社交场景里好友列表可能上万,直接 SINTER 会很慢,工程上的做法是先用小的集合做交集,或者定期离线计算。这一点要说透:Redis 的集合运算适合中小规模实时计算,大规模的关系计算还是得靠专门的图数据库或离线任务。
5.3 SPOP 和 SRANDMEMBER 的区别别记反
这两个命令长得像,功能差一个「删不删」。SPOP 取出后元素从集合消失,SRANDMEMBER 取出后集合不变。做抽奖用 SPOP,做「随机推荐几个标签」用 SRANDMEMBER。
另外 SRANDMEMBER 带 count 参数时,如果 count 是正数,返回不重复的 count 个元素(不足则返回全部);如果是负数,返回的元素可能重复,数量是 count 的绝对值。这个负数用法在「随机生成测试数据」时挺好用,但别忘了它可能有重复。
6. ZSet:排行榜的唯一正解
6.1 命令体系
ZSet 在 Set 基础上给每个成员加了 score,命令分几类:
ZADD rank 100 user:1 200 user:2 150 user:3 ZSCORE rank user:2 ZINCRBY rank 50 user:1 ZRANGE rank 0 -1 WITHSCORES ZREVRANGE rank 0 9 WITHSCORES ZRANK rank user:2 ZREVRANK rank user:2 ZRANGEBYSCORE rank 100 200 ZCOUNT rank 100 200 ZREM rank user:3 ZREMRANGEBYRANK rank 0 0 ZREMRANGEBYSCORE rank -inf 50ZRANGE 按分数升序,ZREVRANGE 按降序,排行榜用后者。ZRANK 查的是升序排名(从 0 开始),ZREVRANK 是降序排名。取 Top N 就是 ZREVRANGE key 0 N-1,取某人的名次就是 ZREVRANK,两个命令配合几乎覆盖所有榜单需求。
ZINCRBY 是原子自增,做积分、热度、票数统计非常合适。相比「读出来加一再写回去」,它没有并发问题,这是 ZSet 相比自己维护排序数组的核心优势。
6.2 排行榜的三个实现细节
同分怎么排:ZSet 在 score 相同时按成员字典序排列。这个默认行为在很多场景下不够用,比如希望「同分时先达成的排前面」。解决办法是把时间戳编进 score,比如score = 分数 * 10^10 + (最大值 - 时间戳),这样分数优先、时间次之。代价是 score 变成大整数,要注意精度,double 只有 53 位有效数字,别超过这个范围。
分页怎么做:ZREVRANGE 的 start 和 stop 是下标,做分页没问题。但深分页(比如第 1000 页)时 ZSet 的随机访问特性决定了它比数组慢一些。如果榜单只需要前 100 名,完全可以只维护 100 个元素,用 ZREMRANGEBYRANK 定期裁剪,ZREMRANGEBYRANK rank 0 -101就能保留最后 100 个。这个技巧能显著降低内存和查询耗时。
榜单怎么刷新:日榜、周榜这种周期性榜单,常见做法是 key 带上日期后缀,比如 rank:20240101。历史榜单设个过期时间自动清理,新榜单自然形成。不要把所有周期数据塞进一个 key 用 field 区分,那样既不好查也不好删。
6.3 用 ZSet 做延时队列
延时队列的原理是把「到期执行时间戳」当 score,消费者轮询ZRANGEBYSCORE key 0 now LIMIT 0 10拿到期任务,取到后先 ZREM 再执行。ZREM 返回 1 表示抢到了,返回 0 说明被别人抢走了,靠这个实现简单的互斥。
这个方案能用,但有明显短板:轮询有延迟、没有重试机制、没有 ACK、任务堆积时 ZRANGEBYSCORE 的扫描成本会上升。真要做可靠的延时任务,Stream 加消费组或者专门的任务队列更合适。ZSet 方案适合「对延迟不敏感、丢了也能接受」的轻量场景,比如定时刷新缓存、延迟发通知。
7. 四个特殊类型:用对了省一台机器
7.1 Bitmap:一个 bit 一个状态
Bitmap 严格说不是独立类型,底层还是 String,但用法完全是另一套。它把字符串当成位数组,每一位存一个 0/1 状态,特别适合「用户签到」「活跃标记」「布隆过滤器底层」这类场景。
SETBIT sign:202401:user:1001 15 1 GETBIT sign:202401:user:1001 15 BITCOUNT sign:202401:user:1001 BITCOUNT sign:202401:user:1001 0 3 BITOP AND result bitmap1 bitmap2一个用户一个月的签到状态,最多 31 位,只占 4 字节。一百万用户一个月的签到数据,理论上也就 4MB 左右(按 key 分开存会有额外开销,实际会大一些,但依然很省)。换成每天一个 key 存布尔值,内存开销是几十倍差距。
BITCOUNT 统计 1 的个数,就是本月签到天数。BITOP 支持 AND/OR/XOR/NOT,两个 Bitmap 做与运算能得到「同时满足两个条件」的人群,做用户分群很实用。
使用注意:Bitmap 的偏移量是稀疏的,如果用户 ID 是十位数,直接拿 ID 当偏移量会撑出一个巨大的稀疏 Bitmap,中间全是 0,内存并没有省下来。正确做法是把 ID 映射成连续的较小整数再当偏移量。
7.2 HyperLogLog:12KB 数一亿个不重复
统计 UV 是个经典难题,用 Set 存用户 ID,一千万用户就是几百 MB。HyperLogLog 用概率算法,固定 12KB 就能估算基数,标准误差 0.81%,非常适合「不需要精确值」的去重计数场景。
PFADD uv:page:1 user:1001 user:1002 PFCOUNT uv:page:1 PFMERGE uv:total uv:page:1 uv:page:2PFADD 添加元素,PFCOUNT 返回估算值,PFMERGE 可以把多个 HLL 合并。合并这个特性很关键,意味着你可以按小时、按天分桶统计,最后合并出总量,不用维护一个大 key。
局限也要清楚:它不返回具体元素,只能给数量;有误差,不能用于计费、库存这种必须精确的场景;单个 HLL 只支持一种去重维度,想同时按城市和设备统计就得建多份。另外 12KB 是固定开销,元素很少的时候反而不如直接用 Set。
7.3 GEO:地理位置存查一步到位
GEO 底层是 ZSet,把经纬度编码成 score,支持范围查询和距离计算:
GEOADD stores 120.15 30.28 store:1 120.16 30.29 store:2 GEOPOS stores store:1 GEODIST stores store:1 store:2 km GEOSEARCH stores FROMLONLAT 120.15 30.28 BYRADIUS 5 km ASC做「附近的门店」「附近的人」这类需求,GEO 是最省事的方案。GEOSEARCH 支持按半径或矩形范围查询,还能按距离排序、限制数量,一步到位。老版本用的是 GEORADIUS,新版本推荐 GEOSEARCH,功能更全。
精度方面,GEO 的误差在 0.5% 左右,做门店导航足够,做精密测量就不合适了。另外 GEO 只支持二维平面距离,海拔信息它不处理。
7.4 Stream:终于有个像样的消息队列
Stream 是 5.0 引入的类型,专为消息队列设计,支持消费组、ACK、消息回溯,解决了 List 做队列的所有短板。
XADD orders * orderId 1001 amount 99 XLEN orders XRANGE orders - + XGROUP CREATE orders group1 0 XREADGROUP GROUP group1 consumer1 COUNT 10 STREAMS orders > XACK orders group1 1600000000000-0 XPENDING orders group1 XTRIM orders MAXLEN 10000XADD 里的*表示让 Redis 自动生成消息 ID,格式是「毫秒时间戳-序号」。XGROUP CREATE 建消费组,XREADGROUP 从组里读,>表示读新消息。读完处理完要 XACK 确认,没确认的会留在 pending 列表里,可以用 XPENDING 查看、用 XCLAIM 转移给其他消费者处理,这就是完整的可靠性闭环。
XTRIM 用于裁剪,控制 Stream 长度。不加限制的 Stream 会一直增长,这是新手最容易忘的一步。我一般用 MAXLEN 近似裁剪(加~符号),性能比精确裁剪好很多。
Stream 相比专业消息中间件,优势是轻量、和 Redis 一起部署,劣势是持久化和堆积能力有限,别指望它扛几千万条的积压。中等规模、对延迟敏感的异步任务,Stream 是很合适的选择。
8. key 级通用命令与过期管理
8.1 SCAN 替代 KEYS 的正确姿势
KEYS 命令按模式匹配所有 key,听起来很方便,但它是一次性全量遍历,时间复杂度 O(N),key 数量上百万时会直接把主线程卡死几秒。生产环境应该直接从权限层面禁用这个命令。
替代品是 SCAN,它用游标分批遍历,每次只返回一小部分,不阻塞主线程:
SCAN 0 MATCH user:* COUNT 100 SCAN 12345 MATCH user:* COUNT 100用法是:第一次传游标 0,返回结果里带着下一次的游标,一直循环到游标变回 0 为止。COUNT 只是「提示」不是硬性限制,Redis 会根据自己的编码情况返回接近这个数量的元素,可能多也可能少。
SCAN 的保证是:从遍历开始到结束一直存在的元素,一定会被返回至少一次。反过来,遍历期间新增的元素可能返回也可能不返回,删除的元素可能返回也可能不返回,同一个元素可能返回多次。所以基于 SCAN 做业务处理时必须幂等。
MATCH 是在返回结果上做过滤,不是先匹配再取。这意味着如果匹配率很低,你可能要扫描很多轮才能凑够你要的数据。遇到这种场景,应该用更精确的 key 设计,而不是靠 MATCH 硬筛。
8.2 过期策略与内存淘汰的区别
这两个概念经常被混淆。过期策略处理的是「单个 key 到期了怎么删」,淘汰策略处理的是「内存满了删谁」。
过期删除采用惰性加定期两种方式配合,前面讲过。淘汰策略由 maxmemory-policy 决定,常见的有 noeviction(不淘汰,写入报错)、allkeys-lru(所有 key 里淘汰最久未用的)、volatile-lru(只淘汰设了过期时间的)、allkeys-lfu(淘汰访问频率最低的)、volatile-ttl(优先淘汰剩余时间短的)。
选哪个?纯缓存场景用 allkeys-lru 或 allkeys-lfu 都行,lfu 对新数据更友好。如果 Redis 里既有缓存又有不能丢的数据,那就必须用 volatile 系列,并且保证不丢的数据不设过期时间,这样它们不会被淘汰。但这么做有风险:如果所有可淘汰的 key 都被清完了,内存还是不够,写入照样失败。所以更稳妥的做法是缓存和数据分实例部署,别混在一起。
提示:maxmemory 别设成等于机器内存,要留出 fork 做持久化时的额外开销,一般设物理内存的 60% 到 75% 比较稳妥。
8.3 TYPE 和 OBJECT ENCODING:排查问题的第一步
遇到性能问题,我第一件事是看这个 key 到底是什么类型、什么编码:
TYPE user:1001 OBJECT ENCODING user:1001 MEMORY USAGE user:1001 TTL user:1001 EXISTS user:1001TYPE 返回类型名,OBJECT ENCODING 返回底层编码,比如 listpack、hashtable、skiplist、intset。这两个信息结合起来,基本能判断出这个 key 的数据规模和内存特征。MEMORY USAGE 给出估算的字节数,排查大 key 时比 --bigkeys 更精准,因为它可以针对单个 key 查询。
TTL 前面说了,-1 表示永不过期,-2 表示不存在。EXISTS 返回 0 或 1。这几个命令组合起来,就是一次最小的诊断动作,花不了几毫秒,但能省下大量猜测时间。
9. 命令使用中的常见问题与排查实录
9.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 内存持续上涨不回落 | 过期时间被 SET 覆盖丢失;大 key 未删 | TTL 抽查关键 key;UNLINK 删大 key;检查覆盖写法 |
| 命令偶发变慢到几百毫秒 | 大 key 全量操作;集合运算;KEYS/FLUSHALL | SLOWLOG GET 查慢日志;改分批命令;禁用危险命令 |
| 写入报 OOM | maxmemory 打满且策略为 noeviction | 调淘汰策略;清理无用数据;扩容 |
| 主从延迟突然变大 | 大 key 删除、批量写、集合运算同步 | 用 UNLINK;拆分大 key;错峰操作 |
| SCAN 拿到的数据重复或缺失 | SCAN 的弱一致性保证 | 业务侧做幂等;按最终一致设计 |
| 排行榜同分排序不符合预期 | 未把时间戳编进 score | 用 score 分段拼接,注意 double 精度 |
| List 队列丢消息 | 无 ACK 机制,消费者崩溃 | 改用 Stream 消费组加 XACK |
| Hash 内存突然涨一截 | 编码从 listpack 转 hashtable | OBJECT ENCODING 确认;拆分 key 或调整阈值 |
9.2 几条私藏的操作习惯
第一条,所有写命令都带过期时间,除非有明确理由。缓存类数据不设过期,迟早变成内存黑洞。我在项目里推行的约定是:缓存 key 必须带 TTL,代码里写死一个默认值,需要覆盖的再显式指定。
第二条,key 命名统一规范。我们用的是「业务:对象:ID:属性」的格式,比如 order:detail:1001、user:1001:profile。好处是 SCAN 的时候好筛、看监控的时候好认、出问题的时候好定位。冒号分隔还有个额外好处,部分客户端工具会按冒号自动分组成树状结构,可视化查看很方便。
第三条,批量操作控制大小。MGET 一次取 100 个 key 和取 10000 个 key,网络和内存表现完全不同。我的经验值是单次批量控制在 100 到 500 之间,超了就分批。同样,pipeline 也是这个道理,不是批得越大越好。
第四条,把 OBJECT ENCODING 和 MEMORY USAGE 加进固定排查流程。遇到任何「这个 key 怎么这么大」「这个操作怎么这么慢」的疑问,先跑这两条命令,比凭经验猜靠谱得多。编码是 listpack 还是 hashtable,直接决定了这个 key 的性能特征。
第五条,新业务上线前做一次量级估算。预估一下 key 的数量、平均大小、峰值增长,算一算内存够不够、会不会触发编码转换。这一步花十分钟,能避免上线后半夜被告警叫醒。我见过太多项目栽在「没想到数据量会这么大」上,Redis 本身很稳,问题几乎都出在容量规划。
最后分享一个我一直在用的小技巧:本地起一个 Redis 实例,把 Redis 的命令当字典用,遇到不确定的命令直接HELP 命令名,Redis 会把语法、复杂度、版本要求全列出来。比翻文档快,而且不会记错版本差异。至于数据类型选型,我的判断顺序是固定的:先看要不要排序,要排序就 ZSet;再看要不要去重,要就去 Set 或 HLL;然后看是不是对象,是就 Hash;剩下需要按下标访问或做队列的才考虑 List;其余全用 String。这个顺序走下来,九成场景不会选错。