文章目录
- Redis 入门(二):五大数据类型与最常用命令,一次讲清
- 一、String:最基础的类型,承担了最多的活儿
- 二、List:两端进出的有序序列
- 三、Hash:一个 key 装一组字段
- 四、Set:自动去重的无序集合
- 五、ZSet:带分数、能排序的集合
- 六、通用命令:与类型无关的那几个
- keys 与 scan
- exists、type、del、rename
- expire、ttl、persist
- 小结:该选哪个类型
Redis 入门(二):五大数据类型与最常用命令,一次讲清
上一篇我们把 Redis 装好、也用redis-cli连上跑通了ping。连接通了之后,紧接着的问题就是:数据到底以什么形式放进去?和 MySQL 不同,Redis 不需要先建表、先定义字段,它的类型信息挂在 value 上——同一个 key,你写进去的是普通字符串还是有序集合,决定了它能用哪些命令、适合什么业务。这篇就把最常用的五种 value 类型和配套的通用命令完整过一遍,每种类型都给出可以直接粘进redis-cli的命令、真实输出,以及线上最容易踩的坑。
一、String:最基础的类型,承担了最多的活儿
是什么:一个 key 对应一个值,值可以是普通文本、JSON 串,也可以是二进制字节。它是另外几种结构的实现基础,单个 value 上限 512MB。
常用命令
| 命令 | 作用 |
|---|---|
set key value | 写入,key 已存在会直接覆盖 |
set key value EX 60/PX 60000 | 写入并带上过期时间(秒 / 毫秒),一条命令完成 |
set key value NX | 仅当 key 不存在时写入,成功返回 OK,失败返回 nil |
get key | 取值,key 不存在返回 nil |
mset/mget | 一次写 / 读多个 key,减少网络往返 |
incr/decr | 值加 1 / 减 1,key 不存在时按 0 起算 |
incrby/decrby | 按指定步长加减,只能作用于整数 |
incrbyfloat | 按浮点步长加减 |
getset key value | 写入新值,同时把旧值返回 |
append/strlen | 尾部追加内容 / 取长度 |
getrange/setrange | 按偏移读子串 / 按偏移覆盖 |
真实输出
127.0.0.1:6379>setarticle:1001:views0OK127.0.0.1:6379>incr article:1001:views(integer)1127.0.0.1:6379>incrby article:1001:views9(integer)10127.0.0.1:6379>incrbyfloat article:1001:score1.5"1.5"127.0.0.1:6379>incr article:1001:score(error)ERR value is not an integer or out of range127.0.0.1:6379>setlock:order:9001"1"NX EX30OK127.0.0.1:6379>setlock:order:9001"1"NX EX30(nil)127.0.0.1:6379>ttl lock:order:9001(integer)26127.0.0.1:6379>getset article:1001:views0"10"127.0.0.1:6379>mset user:1:name tom user:2:name jerry OK127.0.0.1:6379>mget user:1:name user:2:name1)"tom"2)"jerry"典型场景:缓存单条 JSON、接口计数(点赞数、播放量、库存扣减计数器)、set NX EX做分布式锁的占位、把 session 或验证码存起来并自动过期。
注意点
incr系列要求值是纯整数。一旦用incrbyfloat把它变成"1.5",后面再incr就会直接报错,两类命令别混着用。setnx之后再单独发一条expire是两次操作,中间挂掉就留下一个永不过期的锁。要加过期时间就用一条set key value NX EX。getset在 Redis 6.2 之后可以用set key value GET代替,新代码优先用后者,语义更清楚。- value 别塞太大。单个几百 KB 的 JSON,序列化和网络传输的开销都会明显上升;如果对象字段多、还经常只改其中一个,就该考虑 Hash。
append、setrange是原地改内容,适合短文本拼接,用它们反复拼一个很长的字符串会不断触发内存重新分配,代价比重新set一次还高。
二、List:两端进出的有序序列
是什么:一串按插入位置排列的元素,允许重复。可以从左边或右边压入、弹出,所以既能当队列也能当栈;元素少时底层用紧凑结构存储,变大了才转成链表形态。理论上能放 40 多亿个元素。
常用命令
| 命令 | 作用 |
|---|---|
lpush/rpush key v1 v2 | 从左侧 / 右侧插入一个或多个元素 |
lpop/rpop key [count] | 从左侧 / 右侧弹出并返回 |
lrange key start stop | 按下标范围取,0 -1表示全部 |
llen key | 元素个数 |
lindex key i/lset key i v | 取 / 改指定下标的元素 |
linsert key before|after pivot v | 在某个元素前 / 后插入 |
lrem key count value | 删除匹配的元素,count 控制方向和数量 |
ltrim key start stop | 只保留区间内的元素,其余丢弃 |
rpoplpush/lmove | 从一个列表尾部弹出并压到另一个列表头部 |
blpop/brpop key timeout | 阻塞版弹出,超时秒数写 0 表示一直等 |
真实输出
127.0.0.1:6379>rpush queue:mail m1 m2 m3(integer)3127.0.0.1:6379>lrange queue:mail0-11)"m1"2)"m2"3)"m3"127.0.0.1:6379>lpop queue:mail"m1"127.0.0.1:6379>llen queue:mail(integer)2127.0.0.1:6379>lpush stack:undo u1 u2(integer)2127.0.0.1:6379>lrange stack:undo0-11)"u2"2)"u1"127.0.0.1:6379>lpop stack:undo"u2"127.0.0.1:6379>rpush feed:1 a1 a2 a3 a4(integer)4127.0.0.1:6379>ltrim feed:101OK127.0.0.1:6379>lrange feed:10-11)"a1"2)"a2"队列和栈的差别就在两端怎么配:rpush进、lpop出,先到先处理,是一个先进先出队列;lpush进、lpop出,后进先出,就是一个栈(上面的stack:undo就是这种用法)。
典型场景:异步任务队列(生产者rpush,消费者用blpop阻塞等待,没任务时不空转)、消息/邮件待发列表、只展示最近 N 条的动态流(lpush+ltrim 0 99)、撤销操作的历史栈。
注意点
- 当队列用时一定要盯住长度。生产速度长期快过消费速度,这个列表会一直涨,最后把内存吃满。
lrange key 0 -1是把整个列表一次性搬回客户端。几十万条元素的列表上执行一次,网络带宽和客户端内存都会瞬间吃紧,列表类数据要分页取。- 用
blpop做队列,消息弹出即消失,消费者拿到之后崩了这条消息就找不回来。要保证不丢,得看后面的 Stream。 - 下标访问是 O(N) 的,
lindex key 500000这种别写在接口热路径里。
三、Hash:一个 key 装一组字段
是什么:key 下面挂着一组 field-value 对,可以理解成一个很小的 Map,或者数据库里的一行记录。field 和 value 都是字符串,同一个 field 重复写就是覆盖。
常用命令
| 命令 | 作用 |
|---|---|
hset key f v [f v ...] | 设置一个或多个字段 |
hsetnx key f v | field 不存在时才设置 |
hget key f/hmget key f1 f2 | 取单个 / 多个字段 |
hgetall key | 取出全部 field 和 value |
hdel key f | 删除字段 |
hexists key f | 判断字段是否存在 |
hlen key | 字段个数 |
hkeys/hvals key | 所有 field / 所有 value |
hincrby/hincrbyfloat key f n | 字段按整数 / 浮点步长自增 |
hscan key cursor | 游标式遍历字段 |
真实输出
127.0.0.1:6379>hset user:1001 name"tom"age18city"hz"(integer)3127.0.0.1:6379>hget user:1001 name"tom"127.0.0.1:6379>hincrby user:1001 age1(integer)19127.0.0.1:6379>hkeys user:10011)"name"2)"age"3)"city"127.0.0.1:6379>hgetall user:10011)"name"2)"tom"3)"age"4)"19"5)"city"6)"hz"127.0.0.1:6379>hdel user:1001 city(integer)1127.0.0.1:6379>hlen user:1001(integer)2127.0.0.1:6379>hget user:1001 email(nil)hset的返回值是"新建了几个字段",key 本来不存在、三个字段全是新建的,所以返回 3;对已存在的 field 再写一次返回 0。
典型场景:缓存用户、商品这类对象,改一个字段只发一条hset,不用把整个对象读出来改完再写回去;购物车用cart:用户id做 key,field 是商品 id、value 是数量,加减数量直接用hincrby;把同一维度的多个计数器收在一个 key 下,避免散落成一堆 key。
注意点
hgetall同样是全量返回,字段上千个时就是一次慢查询。只要几个字段就hmget,要遍历全部就用hscan分批。- 过期时间只能挂在 key 上,Hash 里的单个 field 没有独立的 TTL。
- 输出顺序不要依赖。
hkeys、hgetall的顺序没有对外保证,需要按顺序展示就自己在客户端排。 - 字段很少、而且每次都是整体读写的对象,直接存一个 String JSON 反而更省事;Hash 的价值在于"部分更新"。
四、Set:自动去重的无序集合
是什么:一组不重复的字符串,没有顺序,也不能按下标取第几个。底层是哈希表(元素都是整数且数量不多时会用更紧凑的整数集合),单个成员的增删和判断是否存在都是 O(1)。
常用命令
| 命令 | 作用 |
|---|---|
sadd key m1 m2 | 添加成员,已存在的会被忽略 |
srem key m | 移除成员 |
scard key | 成员个数 |
smembers key | 返回全部成员 |
sismember key m | 是否是成员,是返回 1,否返回 0 |
srandmember key [count] | 随机取 count 个,取完仍留在集合里 |
spop key [count] | 随机弹出 count 个,取走即移出 |
sinter/sunion/sdiff | 交集 / 并集 / 差集 |
sinterstore/sunionstore/sdiffstore | 运算结果存进新 key |
smove src dst m | 把成员从一个集合搬到另一个 |
sscan key cursor | 游标式遍历成员 |
真实输出
127.0.0.1:6379>sadd article:1001:like u1 u2 u3 u2(integer)3127.0.0.1:6379>smembers article:1001:like1)"u1"2)"u2"3)"u3"127.0.0.1:6379>sismember article:1001:like u2(integer)1127.0.0.1:6379>sadd article:1002:like u2 u3 u4(integer)3127.0.0.1:6379>sinter article:1001:like article:1002:like1)"u2"2)"u3"127.0.0.1:6379>sunion article:1001:like article:1002:like1)"u1"2)"u2"3)"u3"4)"u4"127.0.0.1:6379>sdiffarticle:1001:like article:1002:like1)"u1"127.0.0.1:6379>scard article:1001:like(integer)3127.0.0.1:6379>srandmember article:1001:like21)"u1"2)"u3"上面的sadd连传了两次 u2,返回 3 而不是 4,说明重复成员没有被重复计入——这个返回值本身就能当"是否第一次参加"的判断依据。
典型场景:点赞、投票、活动参与名单这类需要天然去重的数据;抽奖池(smembers看池子,srandmember抽奖但保留池子,spop抽出即移出);共同好友、共同关注用sinter;"我关注了但他没关注"用sdiff;标签、兴趣人群匹配。
注意点
smembers会返回集合里的每一个成员,百万级的大集合上执行就是事故。线上统计用scard,判断用sismember,遍历用sscan。sinter、sunion、sdiff的耗时跟参与运算的集合大小成正比。多个大集合一起算会明显占用主线程,能定期算好缓存的场景就用带store的命令把结果落到固定 key 上复用。- 抽奖要区分语义:
spop是抽走就不在池子里了,srandmember是抽完还在,会不会重复中奖完全由这一条命令决定。 - Set 是无序的,
smembers的返回顺序不能当作稳定排序使用。 - 集合元素多、又需要频繁做交并差时,可以先算一次、把结果存成另一个 key 并设上过期时间,让后面的请求直接读结果,而不是每次都重新算一遍。
五、ZSet:带分数、能排序的集合
是什么:ZSet(有序集合)同样不允许成员重复,区别是每个成员都绑了一个 score。Redis 按 score 从小到大维护顺序,score 相同时按成员的字典序排列;写入、改分、按排名或分数区间查询都是 O(log N) 级别,底层是跳表加哈希表。
常用命令
| 命令 | 作用 |
|---|---|
zadd key score member [score member ...] | 添加成员或修改已有成员的分数 |
zincrby key n member | 给成员累加分数,成员不存在按 0 起算 |
zscore key member | 查成员的分数 |
zcard key | 成员个数 |
zcount key min max | 分数区间内的成员数量 |
zrank/zrevrank key member | 升序 / 降序排名,从 0 开始 |
zrange/zrevrange key start stop [WITHSCORES] | 按排名升序 / 降序取一段 |
zrangebyscore key min max | 按分数区间取,支持LIMIT offset count |
zrevrangebyscore key max min | 分数区间倒序取,参数顺序是 max 在前 |
zrem/zremrangebyrank/zremrangebyscore | 删除成员 / 按排名删 / 按分数区间删 |
zrangebylex/zlexcount | 分数相同时按字典序区间取 |
zunionstore/zinterstore | 多集合运算,分数相加后落库 |
真实输出
127.0.0.1:6379>zadd rank:20240501100user1200user2150user3(integer)3127.0.0.1:6379>zrevrange rank:2024050102WITHSCORES1)"user2"2)"200"3)"user3"4)"150"5)"user1"6)"100"127.0.0.1:6379>zrevrank rank:20240501 user3(integer)1127.0.0.1:6379>zincrby rank:2024050150user1"150"127.0.0.1:6379>zrangebyscore rank:20240501150200WITHSCORES1)"user1"2)"150"3)"user3"4)"150"5)"user2"6)"200"127.0.0.1:6379>zrevrangebyscore rank:20240501 +inf-infWITHSCORES LIMIT0101)"user2"2)"200"3)"user3"4)"150"5)"user1"6)"150"127.0.0.1:6379>zcount rank:20240501150200(integer)3127.0.0.1:6379>zscore rank:20240501 user1"150"注意zrevrank返回 1,是因为排名从 0 开始算,user2 占的是 0;zrangebyscore里 user1 和 user3 都是 150,按字典序 user1 排在前面。
典型场景:实时排行榜,key 带上时间后缀区分日榜、小时榜,取前 10 名就是zrevrange key 0 9 WITHSCORES;分数段统计和区间名单;延时队列,把任务的到期时间戳当 score,用zrangebyscore key 0 <当前时间戳>捞出所有该执行的任务;带权重的热度排序,比如浏览数加点赞数乘系数。
注意点
- score 是双精度浮点数,只有 53 位能精确表示整数,超过 2 的 53 次方(约 9×10^15)之后会出现精度丢失。毫秒时间戳当 score 完全没问题,但纳秒时间戳、雪花 ID 这类大整数直接当 score 就会出错;score 也不能写非数字,NaN 会直接报错。
- 排行榜成员会一直膨胀。只关心前 100 名却把所有参与者都留着,内存和运算都在做无用功,可以定期用
zremrangebyrank把尾部名次清掉,并且放到定时任务里做,不要每次写分数都顺带清理。 - 一个成员只能有一个 score。既要保留"当前分"又要保留"历史最高分",就得开两个 zset,比较之后再更新记录最高分的那一个。
zrangebyscore不加LIMIT时会把命中的成员全部返回,区间开大了和lrange 0 -1一样可怕,养成带LIMIT的习惯。zrangebylex只在所有成员 score 相同时才符合预期,score 不一致时结果没有意义。
六、通用命令:与类型无关的那几个
前五节回答的是"数据怎么存",这一节回答的是"key 本身怎么管"。下面这些命令不看 value 是什么类型,任何 key 都能用:判断在不在、看是什么类型、设过期、删除、改名,以及最容易被误用的keys。
顺带说一个排查技巧:拿不准某条命令的参数写法时,不用去翻文档,在客户端里敲help加命令名就行,比如help set、help expire,Redis 会把参数格式、返回值、时间复杂度一并列出来;help @string这种写法还能按类型分组列出整组命令。
keys 与 scan
keys pattern按通配符返回匹配的 key,keys *就是全量。问题在于 Redis 处理命令是单线程的,keys会把整个 key 空间从头扫一遍,扫描期间其他请求只能排队,百万级 key 的实例上执行一次可能卡住好几秒。线上应该禁用keys,改用游标式的scan:
127.0.0.1:6379>scan0MATCH user:* COUNT101)"17"2)1)"user:1:name"2)"user:2:name"127.0.0.1:6379>scan17MATCH user:* COUNT101)"0"2)(empty array)返回的第一个值是下一轮的游标,拿到 0 就说明遍历结束。COUNT只是给 Redis 的一个提示,每次返回多少条不保证,也可能重复返回同一个元素,客户端要自己去重。
exists、type、del、rename
127.0.0.1:6379>exists user:1001 user:9999(integer)1127.0.0.1:6379>typeuser:1001hash127.0.0.1:6379>typerank:20240501 zset127.0.0.1:6379>renamerank:20240501 rank:top OK127.0.0.1:6379>del queue:mail stack:undo(integer)2127.0.0.1:6379>del not:exist(integer)0exists支持一次传多个 key,返回的是存在的个数;type返回 string、list、set、zset、hash、stream 或 none,排查问题时很有用;rename在目标 key 已存在时会直接覆盖,不想覆盖就用renamenx;del返回真正删掉的个数。另外del是同步删除,删一个几十万元素的集合会阻塞主线程,Redis 4.0 之后可以用unlink让删除在后台完成。
expire、ttl、persist
127.0.0.1:6379>expire user:100160(integer)1127.0.0.1:6379>ttl user:1001(integer)58127.0.0.1:6379>persist user:1001(integer)1127.0.0.1:6379>ttl user:1001(integer)-1127.0.0.1:6379>ttl not:exist(integer)-2ttl返回 -1 表示这个 key 没有设置过期时间,返回 -2 表示 key 不存在(已经过期被删掉也算不存在);毫秒版本是pexpire和pttl;persist用来摘掉过期时间。这里有个很容易踩的坑:对一个已经设了过期时间的 key 再执行一次set,过期时间会被清掉,key 变成永不过期,想保留就加上KEEPTTL参数。
小结:该选哪个类型
| 你的需求 | 选它 | 核心命令 |
|---|---|---|
| 缓存一个值或一段 JSON、做计数器 | String | set/get/incr/set NX EX |
| 简单队列、最近 N 条、撤销栈 | List | rpush+lpop/lpush+ltrim/blpop |
| 一行对象的多个字段、购物车 | Hash | hset/hget/hincrby/hgetall |
| 去重名单、标签、共同好友、抽奖池 | Set | sadd/sismember/sinter/spop |
| 排行榜、延时队列、带权重的排序 | ZSet | zadd/zrevrange/zrangebyscore/zincrby |
选型的思路其实就一句话:先想清楚数据要回答什么问题——是"取出来用"(String、Hash),是"先进先出"(List),是"有没有、和谁重合"(Set),还是"谁排在前面"(ZSet)。类型选对了,命令自然简单;类型选错,后面就得靠一堆客户端代码去补。
这张表里的命令,下一篇都能在 Java 侧找到对应写法,比如hset对应opsForHash().put(...),zadd对应opsForZSet().add(...),命令会不会用,直接决定了代码写得顺不顺。
另外提一句,除了这五种,Redis 还有 Pub/Sub 和 Stream:前者是发布订阅,消息发出去就不管了,适合实时通知这类允许丢的场景;后者是带消费者组和 ACK 的消息队列,能补上 List 做队列时消息会丢的短板。这两个属于进阶内容,等把基础类型用熟了再回头看会轻松很多。
到现在为止,我们所有操作都是在redis-cli里手敲的。下一篇进入实战:用 Spring Boot 把 Redis 整合进来,讲清RedisTemplate的常用操作、StringRedisTemplate和它的区别,以及新手一定会遇到的那两个问题——存进去的 key 前面多了一串乱码,取出来的对象报类型转换异常,根因都在序列化配置上。