Redis 的键值对操作看起来只有 SET 和 GET,真正用在缓存、计数、分布式锁等场景时,写入策略、过期时间、读取方式和删除手段都会影响最终效果。本文把键值对操作拆成写入、读取、更新、删除四个环节,围绕字符串类型说明命令用法、返回值和常见坑点。

一、写入键值对:SET 不只是简单赋值
Redis 里最常用的写入命令是 SET key value。它用于保存一个字符串类型的值,如果键已经存在,默认会直接覆盖旧值。这个行为看似没有问题,但在缓存场景里会带来一个非常隐蔽的坑:覆盖写入不会保留原来的 TTL。比如先执行 SET session:1001 token001 EX 1800,这个键会在 30 分钟后过期。如果中途又执行 SET session:1001 token002,新值会变成永久有效,过期时间被悄悄清空。很多缓存键就是这么慢慢堆积起来,最后导致内存占用异常。
要避免 TTL 丢失,最直接的做法是每次覆盖写入时都重新声明过期时间。SET 命令支持 EX seconds 设置秒级过期时间,也支持 PX milliseconds 设置毫秒级过期时间。此外还有两个参数非常重要:NX 表示只在键不存在时写入,XX 表示只在键已存在时写入。借助这些参数可以实现原子化的分布式锁。比如执行 SET lock:order:1001 1 EX 60 NX,如果返回 OK 说明当前请求抢到了锁,如果返回 nil 说明已经有其他客户端写过这个键。这比先 GET 再 SET 的两步操作安全得多,因为检查和写入在 Redis 里是一条命令完成的,不会被并发请求穿插打断。
SET cache:user:1001 "{\"name\":\"A\",\"age\":28}" EX 3600
# 返回 OK
SET cache:user:1001 "{\"name\":\"A\",\"age\":29}" XX EX 3600
# 仅当键存在时覆盖,并重新声明过期时间
SETNX lock:order:1001 1
# 返回 1 表示写入成功,0 表示键已存在
MSET cache:user:1002 "{\"name\":\"B\",\"age\":30}" cache:user:1003 "{\"name\":\"C\",\"age\":31}"
# 一次写入多个键,返回 OK
如果需要一次性写入多个键,可以用 MSET key value [key value ...]。它会把多个键值对在一次命令中完成写入,减少客户端与服务端的往返次数。注意 MSET 是覆盖式写入,它不会带上 NX 或 EX 参数,因此不能通过 MSET 直接实现带过期时间的批量写入。如果既有批量需求又有过期需求,可以配合 EXPIRE 命令逐个设置,或者在业务层拆成多条 SET 命令。
二、读取键值对:GET 返回值与 MGET 批量读取
读取字符串值使用 GET key。这个命令的返回值可以分成三种情况:如果键存在且类型为字符串,返回值内容;如果键不存在,返回 nil;如果键存在但类型不是字符串,则返回一个 WRONGTYPE 错误。最后一种情况容易被忽略,因为 Redis 是强类型存储,同一个键不能同时是字符串和列表。如果先执行 LPUSH queue:task job01 再执行 GET queue:task,就会立刻报错。排查这种问题时,应该先用 TYPE key 确认键的类型,再用对应类型的命令操作。
判断键是否存在可以使用 EXISTS key,返回 1 表示存在,返回 0 表示不存在。需要注意的是,EXISTS 对已经过期的键也会返回 0,因为过期键在访问时会被 Redis 清理。对于批量判断多个键是否存在,可以使用 EXISTS key1 key2 key3,它会返回存在的键数量,但无法告诉你具体哪些键存在。如果需要精确判断每个键,可以改用 MGET 并逐个检查返回结果。
GET cache:user:1001
# 返回 "{\"name\":\"A\",\"age\":29}"
MGET cache:user:1001 cache:user:1002 cache:user:1003
# 返回 1) "..." 2) (nil) 3) "..."
TYPE cache:user:1001
# 返回 string
EXISTS cache:user:1001
# 返回 1
在接口里需要读取多个键时,不建议使用循环执行 GET。Redis 每执行一条命令都要经过一次网络往返,循环二十次 GET 就会放大二十次延迟。使用 MGET key1 key2 key3 可以把多个键的读取合并到一条命令中,返回结果保持键的顺序,不存在或类型错误的键会返回 nil。这样既降低了网络开销,也能让调用方一次性拿到完整数据。不过 MGET 涉及多个键时也可能跨哈希槽,在 Redis Cluster 架构下要注意多个键是否落在同一个槽位,否则会遇到 CROSSSLOT 错误。
三、更新键值对:覆盖、追加与原子自增
更新键值对本质上也是写入,但从业务语义上需要区分覆盖更新和数值增减。覆盖更新直接使用 SET key value,适合缓存对象替换、token 刷新、配置更新等场景。如果原键带有过期时间,更新时必须重新声明 EX 或 PX 参数,否则 TTL 会被清空。比如会话 token 刷新逻辑中,如果只更新 token 值而不刷新过期时间,用户会话就会变成永久有效,这对安全性非常不利。更稳妥的写法是 SET session:1001 newtoken EX 1800 XX,只在键存在时更新,同时重新续期。
对于数字型数据,应该使用 INCR、INCRBY、DECR、DECRBY 和 INCRBYFLOAT 系列命令。它们会把字符串值解析为整数或浮点数,并在一次原子操作中完成加一、减一或指定步长的增减。以 INCR order:id 为例,如果键不存在,Redis 会先将其初始化为 0,再加一,最终返回 1。这个特性非常适合生成订单号、编号或计数。因为是单条命令,多个客户端同时执行 INCR 也不会出现读到旧值再加一的竞态问题。不过 Redis 的整数范围是 64 位有符号整数,超过范围会报 overflow 错误。
INCR order:id # 返回 1,键不存在时先初始化为 0 再加 1 INCR order:id # 返回 2 DECR order:id # 返回 1 APPEND cache:user:1001 ":extra" # 返回追加后的字符串总长度 GETRANGE cache:user:1001 0 5 # 返回该字符串的前 6 个字符
如果更新操作只是想在原字符串末尾追加内容,可以使用 APPEND key value。它会返回追加后的字符串总长度,对不存在的键执行 APPEND 等价于 SET。与 GET 后拼接再 SET 的方式相比,APPEND 更直接,也避免了客户端读取大字符串再写回的网络开销。如果还需要按区间截取字符串,可以使用 GETRANGE key start end 或 SETRANGE key offset value,它们适合处理日志片段、固定格式文本和部分覆盖更新。
四、删除键值对:DEL、UNLINK 与过期管理
删除键使用 DEL key [key ...],它会返回被删除的键数量。DEL 命令在大多数情况下足够用,但它有一个容易忽略的问题:DEL 是同步阻塞删除。如果一个键保存了几百 MB 的大字符串,或者一个包含大量元素的列表,DEL 操作会占用主线程较长时间。Redis 是单线程处理命令的,删除大键期间其他客户端请求都会被阻塞,造成响应延迟甚至超时。因此在处理大键删除时,建议优先考虑 UNLINK。
UNLINK 从 Redis 4.0 开始提供,它的语法和 DEL 相似,也会返回被删除键的数量。不同之处在于,UNLINK 会先把键从键空间中解除关联,再根据值的大小决定是同步回收还是交给后台线程异步回收内存。对于小键,UNLINK 的行为和 DEL 基本一致;对于大键,UNLINK 可以减少主线程阻塞时间。因此如果对删除延迟比较敏感,而键的值又可能很大,使用 UNLINK 会比 DEL 更安全。
SET temp:bigfile "large-payload" # 返回 OK EXPIRE temp:bigfile 60 # 返回 1,表示过期时间设置成功 TTL temp:bigfile # 返回剩余秒数,范围 0 到 60 UNLINK temp:bigfile # 返回 1,表示键已被解除关联 EXISTS temp:bigfile # 返回 0,键已不存在
如果不想主动删除键,可以让它在指定时间后自动过期。使用 EXPIRE key seconds 可以设置秒级过期时间,PEXPIRE key milliseconds 可以设置毫秒级过期时间。TTL key 返回剩余秒数,-1 表示键存在但没有设置过期时间,-2 表示键不存在。业务代码里经常拿 TTL 判断一个键是否接近过期,以便提前刷新缓存。给一个已经带过期时间的键重新执行 EXPIRE,会覆盖原来的 TTL,相当于重新计时。Redis 的过期键回收采用惰性删除和定时抽样删除相结合的方式,访问过期键时会被立即清理并返回 nil,后台也会周期性扫描部分键进行回收。
最后还要注意重命名操作 RENAME key newkey。重命名后键名变化,但值、TTL 和类型保持不变。如果 newkey 已经存在,RENAME 会直接覆盖目标键。在 Redis Cluster 集群中,RENAME 要求源键和目标键必须在同一个哈希槽,否则会返回 CROSSSLOT 错误。跨槽迁移键名时,更稳妥的做法是先读取旧键内容,写入新键,再使用 DEL 或 UNLINK 删除旧键。