Appearance
03|string 与键空间操作
连上 Redis,先存一条数据:
bash
redis-cli
127.0.0.1:6379> SET user:1001 "zhangsan"
OK
127.0.0.1:6379> GET user:1001
"zhangsan"SET 存进去,GET 取出来。一个 key 对应一个值,这是 Redis 最基础的操作模式。
再存一个数字:
bash
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> GET counter
"10"数字 10 取出来时外面裹着一对引号,变成了文本 "10"。在大多数数据库里,整数和字符串是两种类型,存 10 和存 "10" 是两回事。Redis 不区分——所有 value 在底层都是一串字节,文本、数字、JSON、二进制图片,存进去都是同样的类型。这个类型叫 string。
string 是 Redis 最基础的类型,缓存、计数、分布式锁这些后面要讲的场景一开始都落在它身上。这一篇除了 string 本身的操作,还要讲所有 key 都会遇到的通用操作:查找、删除、改名、设过期。
一、SET 与 GET:读写一个 key
SET 的语法很直接:一个 key,一个 value。key 不存在就新建,存在就覆盖。
bash
SET user:1001 "zhangsan"
GET user:1001
# "zhangsan"GET 一个不存在的 key,返回 nil——这个信号后面会反复用到,意思是"缓存里没有":
bash
GET user:9999
# (nil)string 能存什么:文本、数字字符串、JSON、序列化后的对象、甚至二进制字节(比如图片或 protobuf)。Redis 不会因为内容里有 \0 就截断,也不会因为长得像数字就自动当数字处理——存进去什么字节,取出来就是什么字节。这种特性让它能胜任各种缓存场景。
二、计数:INCR 为什么比 GET+SET 可靠
string 最常见的用途之一是计数——文章阅读量、接口调用次数、商品库存。先用最笨的办法给 counter 加一:GET 出来、应用层加一、SET 写回。
开一个终端跑 500 次:
bash
for i in $(seq 1 500); do
cur=$(redis-cli GET counter)
redis-cli SET counter $((cur + 1))
done单线程跑,数对得上。但开两个终端,各自循环跑 500 次这个"读-改-写":
bash
# 终端 A 和终端 B 各跑一遍
for i in $(seq 1 500); do
cur=$(redis-cli GET counter)
redis-cli SET counter $((cur + 1))
done两边各加了 500 次,理论上 counter 应该到 1000。跑完 GET counter 看一眼,实际值多半停在 600 多或 800 多——离 1000 差一截。
问题出在"读出去再加回来"这个组合中间有时间窗口。A 读到 100、还没写回 101 时,B 也读到 100、写回 101——两次加一,最终只多了一。没有锁保护,谁先谁后就乱套。
INCR 把这件事压成一条命令:
bash
SET counter 0
INCR counter # 返回 1
INCR counter # 返回 2
INCRBY counter 10 # 一次加 10,返回 12
DECR counter # 减 1数对得上。原因在 Redis 处理命令的核心是单线程——一条 INCR 从读到改到写都在这一条命令里完成,中间没有别的命令能插进来。不需要应用层加锁,也不需要事务。
INCR 对值有要求:得能解析成整数。对一个文本 key 执行 INCR,Redis 直接拒绝:
ERR value is not an integer or out of range遇到这个错,先 GET 一下那个 key——多半是被某个 SET 存了 JSON 或文本进去,跟计数用的指到了同一个 key。多人协作一个实例时这个冲突很常见。
INCR 的用途比直觉里多:接口限流用 INCR rate:user:1 配合过期时间做计数窗口;全局序列号用 INCR order:seq,比数据库自增 ID 快、永远不重复;文章阅读量、页面访问量都靠它,更新时一条命令,不用查了再写。
三、并发写入冲突:SET NX
两个客户端同时想改同一个 key,后写入的会覆盖先写入的。比如客户端 A 和 B 同时执行:
bash
# 客户端 A
SET status:job:1001 "running"
# OK
# 客户端 B(几乎同时)
SET status:job:1001 "failed"
# OK两个都返回 OK,但 key 里最终只保留最后写入的那个值——另一个被无声覆盖了。如果这两个写入代表不同的业务状态,覆盖就意味着状态丢失。
NX 选项让 SET 只在 key 不存在时才写入:
bash
# 终端 A
SET lock:job:1001 "owner-a" NX EX 30
# OK
# 终端 B(key 已存在)
SET lock:job:1001 "owner-b" NX EX 30
# (nil)只有第一个返回 OK,第二个返回 nil——key 已经被占了,拒绝写入。EX 30 是给这个 key 设 30 秒过期,防止拿到"锁"的客户端崩溃后 key 永远留在那里。
这种用法在 Redis 里常被当作分布式锁的实现基础。但它有两个坑要知道:
一是业务执行时间可能比过期时间长——锁 30 秒到期,业务跑了 40 秒,到第 30 秒锁自动释放,第二个客户端立刻拿到锁开始干同一件事,两个客户端同时进入了本该互斥的代码段。缓解办法是在业务侧开后台线程续期(延长 TTL),业务结束再主动删 key。
二是主从切换时锁可能丢失。Redis 主从复制是异步的,写主库成功、还没同步给从库时主库挂了,新主库上根本没有这把锁,第二个客户端又能拿到。单实例 Redis 锁保护不了关键业务——要么用 Redlock 算法在多个实例上加锁过半数才算成功,要么在业务层做幂等,即使锁丢了重复执行也不会出问题。
四、key 多了怎么管:命名与通用操作
Redis 没有表、没有数据库 schema,整个实例是一块平地,所有 key 平铺在一起。在一个有不少 key 的实例上敲 DBSIZE:
bash
DBSIZE
# (integer) 1523一千多个 key 混在一起,没有前缀的话根本分不出谁是谁。给 key 分组靠命名约定,常用 业务:对象:id:属性 模式:
bash
user:1001:name
user:1001:age
article:1001:views
order:2025:001冒号没有语法意义,只是约定俗成的分隔符,阅读性最好,也能用前缀匹配查一类 key。命名要事先在团队里约定好,上线后再改成本极高——小写、冒号分层、含义明确、长度适中。
拿到一个陌生 key,不知道它是什么类型、该用哪条命令访问时,先 TYPE 一下:
bash
TYPE user:1001:name # 返回 string
TYPE user:1001 # 返回 hash(如果这确实是一个 hash key)TYPE 返回 string/hash/list/set/zset 之一,按返回值决定后面用哪套命令。除了 TYPE,键本身这几条操作对任何类型都通用:
bash
EXISTS user:1001:name # key 是否存在,返回 0 或 1
DEL user:1001:name # 删除 key,返回删除的数量
RENAME user:1001 user:1002 # 改名
RANDOMKEY # 随机取一个 keyDEL 删多个 key 一次传:DEL k1 k2 k3。
五、遍历 key:KEYS 的危险与 SCAN
想知道实例里有哪些 key,很容易想到 KEYS:
bash
# 先灌几千个 key 做测试
for i in $(seq 1 5000); do redis-cli SET "user:$i:name" "x"; done
KEYS user:*在几千个 key 的实例上这一条就卡得明显——redis-cli 像是停住了。这时另开一个终端连进去敲一条 GET,跟着一起停住;等 KEYS * 返回,另一条 GET 才跟着回来。
这不是 Redis 挂了。Redis 处理命令的核心是单线程,KEYS 在主线程里从头扫到尾,扫完之前其他命令都在后面排队。生产实例几十上百万 key,一条 KEYS * 能让整个 Redis 堵住几秒,期间所有请求超时——监控图上是一道尖峰毛刺,应用侧成片报超时。
替代办法是 SCAN,游标式分批扫:
bash
SCAN 0 MATCH user:1001:* COUNT 100
# 返回一个新游标和一批 key,用新游标继续,直到游标回到 0 表示扫完SCAN 每次只取一批就立刻返回主线程,中间穿插处理别的命令,不会卡住 Redis。但两个坑要知道。
一是它遍历的是当下变化的键空间,不是某一刻的快照。扫的期间有 key 被增删,那批结果里可能出现重复的 key,也可能漏掉一部分。SCAN 适合巡检和清理这种不需要精确全量的场景,不能用来做"必须一个不漏"的清点。
二是 COUNT 只是建议值,实际返回的 key 数可能远多于也可能远少于 COUNT。要反复调用直到游标回到 0 才算遍历完,别指望一次 COUNT 100 就只返 100 个。
六、key 到期自动消失:TTL
缓存数据、会话、临时标记,这些"过段时间就不需要"的东西如果永远留在 Redis 里,内存迟早被占满。string 上最常用的清理机制是过期时间:
bash
SET session:abc "user-data" EX 3600 # 写入同时设 1 小时过期
EXPIRE session:abc 3600 # 给已存在的 key 单独设过期
EXPIREAT session:abc 1717257600 # 在指定 Unix 时间戳过期
TTL session:abc # 还剩多少秒
PERSIST session:abc # 取消过期,变成永久 key自己跑一遍最容易看清:
bash
SET temp:key "x" EX 2 # 写入时设 2 秒过期
EXISTS temp:key # 立刻查,返回 1
# 等 3 秒...
EXISTS temp:key # 返回 0 ——key 自己没了
GET temp:key # 返回 nilTTL 的返回值有三种情况:
| 返回值 | 含义 |
|---|---|
| 正数 | 剩余秒数 |
-1 | key 存在,但没有过期时间——永久 key |
-2 | key 不存在——可能从没建过,也可能已经过期被删掉 |
-1 和 -2 容易看漏。一个 TTL 返回负数,先用 EXISTS 确认 key 在不在,再判断是永久还是已不存在。
写入时设过期,用 SET key value EX seconds 比 SET 完再单独发一条 EXPIRE 要好——一次命令完成"写值 + 设过期",没有中间状态。
七、string 的边界
string 能存的最大 value 是 512 MB。绝大多数场景够用了,但如果有人在 string 里塞大 JSON 或序列化对象,要留意体积——过大的 string 会影响网络传输和复制效率。
SET 覆盖现有 key 时,原 value 的类型不管是什么都会被替换成 string。一个 hash key 被 SET 覆盖后,再用 HGET 访问会报错——因为类型已经变了。这种误操作在多人共享一个实例、命名约定不统一时容易发生。TYPE 在修改前先查一下,能避免这类错误。