1. 为什么String是Redis里最容易被低估的类型
1.1 从一次缓存穿透事故说起
先讲一个我早年踩过的坑。当时在做一个商品详情页的缓存层,需求很简单:商品信息全量放Redis,key是商品ID,value是JSON字符串。接口压测的时候,发现某个冷门商品ID被刷了几万次,Redis里面没查到,直接打到了数据库,数据库连接池瞬间被打满,然后整个服务雪崩。事后排查,问题就出在String类型的使用上——我当时把商品信息的JSON整个塞进去,没有做空值缓存,也没有设置合理的过期时间,结果热点Key没管好,冷门Key也没拦住。
那次之后我才意识到,很多人对Redis String的理解停留在“存字符串、取字符串”这个层面,但String真正的能力、存储结构、命令细节,以及在不同场景下的取舍,远比表面看到的要复杂。这篇内容不打算从官网文档抄一遍命令列表,而是想把我实际使用中踩过的坑、验证过的方法、以及和存储结构相关的一些底层机制重新捋一遍,给正在学习Redis或者准备在项目里大规模用String的同学一个可以直接参考的路线。
1.2 String到底能装什么:字节、整数、浮点与序列化对象
先厘清一个最基本的认知:Redis的String并不是编程语言里的“字符串”类型,它的底层是一个动态字符串结构,能存储的内容本质上是字节序列。这意味着它可以存放文本字符串、整数、浮点数、二进制数据(图片、序列化对象、Protobuf等)。Redis没有单独的整数类型,也没有单独的二进制类型,所有这一切都通过String承载。
从存储形态上,String可以覆盖这些用途:
| 存储内容 | 示例 | 说明 |
|---|---|---|
| 文本 | SET user:name "张三" | 最常见的键值对 |
| 整数 | SET page:view 100 | 可用INCR/DECR原子操作 |
| 浮点数 | SET score 3.5 | 可用INCRBYFLOAT |
| 二进制 | 序列化后的对象字节 | 需要自己序列化,注意大小 |
| 位图 | SETBIT bitmap 10 1 | 每个bit独立操作 |
你可能会问:既然能存二进制,那为什么不直接把图片丢进去?技术上可行,但实际工程中不建议这么干,因为String的value大小是有限制的(最大512MB,但不意味着你可以真的放512MB),大Value会引发阻塞、内存碎片、主从复制延迟等一系列问题。后面我会专门讲大Key的问题。
1.3 常见误区:把String当成“字符串”而不是“字节数组”
我在社区看到不少新手提问:“为什么我存了一个数字,取出来变成字符串了?”“为什么我用SET存中文,取出来乱码了?”这些问题的根源都是同一个:把Redis String当成了编程语言中的String对象。
Redis String的操作单位是字节,不是字符。比如STRLEN返回的是字节数,而不是字符数。你用UTF-8存“你好”,STRLEN返回6(两个汉字各占3字节),而不是2。如果你在客户端用某个编码写入GBK的中文,再用另一个编码读取,就会出现乱码。这跟Redis本身没关系,是客户端的编码不一致。
正确认知应该是:Redis String是“字节数组 + 长度字段”,它不管里面是什么编码、什么格式,只是原样存取。理解了这一点,你才能理解为什么GET返回的数据必须由客户端自己解码,为什么SET key 0之后用INCR可以原子加一,为什么二进制安全的String可以用来做分布式锁的value(比如UUID字符串)。
2. Strings核心命令的实战拆解:SET/GET/INCR与批量操作
2.1 SET命令的隐藏参数:NX、XX、EX、PX如何组合
SET可能是最被人忽略的命令,因为太简单,很多人只会用SET key value。但Redis在2.6.12版本之后扩展了SET的参数,使它几乎变成了一个多功能工具:
SET key value [NX | XX] [EX seconds | PX milliseconds | EXAT timestamp | PXAT milliseconds-timestamp] [KEEPTTL]NX:只有当key不存在时设置成功,常用于分布式锁、幂等控制。XX:只有当key存在时设置成功,用于更新场景,避免覆盖不存在的key。EX:以秒为单位设置过期时间。PX:以毫秒为单位设置过期时间。EXAT/PXAT:指定绝对过期时间戳。KEEPTTL:设置新值但保留原有TTL,这个参数很容易被忽略,比如你想更新一个已有缓存的value但不想重置过期时间,就必须加它,否则SET会清掉原来的过期时间。
实际项目中,我经常用这一行命令同时完成“加锁+设置过期时间”:
SET lock:order:1001 uuid_value NX EX 10如果只用SETNX加锁,再用EXPIRE设置过期时间,这两步不是原子的,进程如果在这两步之间崩溃,锁就永远不会过期,这是经典的分布式锁bug。所以一定要用SET+ NX + EX的组合。同理,做幂等拦截时,一条SET idempotent:key 1 NX EX 60就完成了“这个请求第一次来”的判断,比先EXISTS再SET快且安全。
2.2 原子自增INCR/DECR:从计数器到限流器
INCR、DECR、INCRBY、DECRBY以及浮点版本的INCRBYFLOAT,是String类型里原子性最强的操作之一。它们的值为64位有符号整数(范围是-9223372036854775808到9223372036854775807),超出范围会报错。
计数器是最典型的使用场景:文章阅读数、视频点赞数、网站PV/UV(UV需要去重,一般用Set或HyperLogLog,这里是另一个话题)。但如果你只是把INCR当成自增来用,就浪费了它的能力。举个限流的例子:
在网关层做固定窗口限流,可以按用户在分钟维度计数:
INCR rate:user:123:202401011000 EXPIRE rate:user:123:202401011000 60但这里有个老问题:INCR和EXPIRE是两条命令,不是原子的。如果第一条执行成功,第二条执行前进程崩溃,这个key会变成永久key,内存越积越多。现代Redis(6.0+)可以用Lua脚本把两条命令原子化,或者直接用SET key 1 EX 60 NX初始化 + 后续INCR的方式。但要注意,INCR不会修改过期时间,如果key已经存在且过期时间剩余不多,自增之后TTL还是原来剩余时间,不会刷新过期时间。想要滑动窗口效果,需要每次自增后重新设置过期时间,这就回到了原子性问题上。
更优雅的写法是用Lua脚本来保证原子性。下面是一个简单的固定窗口限流脚本,返回当前窗口内的请求数,超过阈值就拒绝:
local current = redis.call('GET', KEYS[1]) if current and tonumber(current) >= tonumber(ARGV[1]) then return current end redis.call('INCR', KEYS[1]) redis.call('EXPIRE', KEYS[1], ARGV[2]) return current这个脚本在执行前会判断并刷新TTL,虽然不是完美的时间轮算法,但在单机Redis下可以满足大部分限流需求。
2.3 批量操作MSET/MGET的坑与性能提升
MSET和MGET是减少RTT(网络往返时间)的利器。当你需要一次性读写多个key时,用它们替代循环SET/GET,能显著减少网络开销,特别是在客户端与Redis不在同一机房时效果更明显。
MSET user:1:name "Alice" user:1:age 30 user:1:city "Beijing" MGET user:1:name user:1:age user:1:city这里有个容易被忽略的坑:MGET不是原子操作。它只是把多个GET请求合并成一条命令发送,减少了网络往返,但每个key的读取是独立的,如果某个key正在被另一个客户端修改,你读到的可能是一个混合快照,而不是多key之间的一致性快照。如果你需要保证多个key的读取是同一时刻的状态,MGET做不到,得用Lua脚本或WATCH/MULTI事务。
另外,MSET和MGET不支持过期时间参数,你不能在一条命令里给不同的key设置不同的TTL。如果批量设置缓存并带上过期时间,可以考虑Pipeline或Lua,但Pipeline需要自己维护一致性,Lua脚本则要小心复杂性。
2.4 值得掌握的进阶命令:APPEND、GETSET、STRLEN、GETEX
这几个命令平时用得少,但特定场景很好用。
APPEND key value:如果key不存在,等同于SET。如果key已存在,则在value末尾追加字节。适合实现简单的消息拼接、日志追加。但要注意,每次APPEND都可能触发内存重新分配,如果频繁追加导致value变大,会带来内存碎片。小数据量无妨,大数据量不要用这种方式攒字符串。
GETSET key value:先返回旧值,再设置新值,这是原子操作。常见用法是做“取走并重置”的计数器:比如每次从队列里拿奖励后清零,或者做Redis实现的分布式ID发号器:每次GETSET取出当前值并加一,但并发高时不如INCR优雅。
STRLEN key:返回value的字节长度。可以用来快速判断一个key的value是否超限,比如在业务层检查缓存对象序列化后是否超过预期大小。但注意,STRLEN对不存在的key返回0,对非String类型的key会报错(WRONGTYPE)。
GETEX key [EX seconds | PX milliseconds | EXAT timestamp | PXAT timestamp]:在读取value的同时修改过期时间,可以配合刷新缓存TTL的场景用。比如用户访问了一次缓存,我顺手把TTL重置为30分钟,用GETEX一条命令就能搞定,不需要先GET再EXPIRE,也避免了两个客户端先后操作造成TTL不一致。
3. 深入String的存储结构:SDS与内存编码
3.1 SDS是什么,为什么不用C语言字符串
Redis的String底层用的是自定义的SDS(Simple Dynamic String,简单动态字符串),而不是C语言原生的char数组。C语言字符串用\0判断结束,无法存储二进制数据(中间有\0会截断),而且修改字符串需要手动管理内存,很容易出错。SDS的设计从三个层面解决了这些问题:
- O(1)获取长度:SDS结构体里保存了len字段,取长度不需要遍历。
- 二进制安全:它以len判定结束,不以
\0判定,所以里面可以包含任意字节,包括NUL字符。 - 内存预分配与惰性释放:当SDS扩容时,会额外分配一些预留空间,减少后续追加操作的重分配次数。缩减时不会立即释放内存,而是等待将来复用,这减少了内存分配的系统调用。
SDS的结构可以简化为这样:
struct sdshdr { uint8_t len; // 已用字节数(不同版本用不同位宽,比如sdshdr5、sdshdr8、sdshdr16等) uint8_t alloc; // 已分配的总字节数(不含头和null) unsigned char flags; // 表示sds类型 char buf[]; // 实际存储的字节数组 };Redis会根据字符串长度选择不同位宽的SDS头(sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64),目的是节省内存。这个设计思路和很多语言内置的动态字符串类似(比如Go的string其实也是不可变,但底层有类似逻辑),理解这一点,你才能明白为什么String类型在存储小数据和大数据时的内存效率差异很大。
3.2 int、embstr、raw三种编码的判定边界
当把一个字符串写入Redis时,底层会根据值的大小和内容选择不同的编码方式。这在OBJECT ENCODING命令里可以直接看到:
127.0.0.1:6379> SET num 12345 OK 127.0.0.1:6379> OBJECT ENCODING num "int" 127.0.0.1:6379> SET short_str "hello" OK 127.0.0.1:6379> OBJECT ENCODING short_str "embstr" 127.0.0.1:6379> SET long_str "a...很多字符..." OK 127.0.0.1:6379> OBJECT ENCODING long_str "raw"三种编码的规则是这样的:
- int:字符串内容能被解析为64位有符号整数(长度不超过20位),且值在long范围以内,Redis会直接以整数形式存储,不分配SDS。这样做的好处是节省内存,而且
INCR可以直接在整数上操作,效率极高。 - embstr:字符串长度小于等于44字节(不同版本阈值可能略有差异,比如在Redis 6和7里都是44),并且无法解析为整数,Redis会分配一块连续的内存,同时容纳RedisObject结构体和SDS结构体,只需要一次内存分配,效率高。
- raw:字符串长度超过44字节,或者经过修改导致长度变化触发Redis重新分配,Redis会分为两次分配内存:一次分配RedisObject,一次分配独立的SDS。这种编码下,每次修改可能导致内存复制和重新分配。
这里有个容易踩的坑:一个原本是embstr的字符串,只要做了一次修改(比如APPEND),编码会立刻变成raw。因为embstr内部是不可变的(没有预分配空间),一旦修改长度就要重新申请内存,Redis索性把它升级为raw。所以如果你有频繁修改大字符串的需求,一开始就会是raw,也不用担心性能突然恶化;但如果你以为存小字符串就一直是embstr,然后循环APPEND,内存分配和复制开销会逐渐暴露。
3.3 内存碎片与过期键的清理时机
String类型的一切操作都会产生内存分配/释放,因此内存碎片是必然存在的。在INFO memory里能看到mem_fragmentation_ratio这个指标,表示物理内存的使用量和Redis实际申请内存的比例。比例大于1.5通常说明碎片严重,小于1说明可能发生了内存换出(swap),都不是好事。
碎片产生的一个常见原因是:频繁更新String value且长度变化较大。比如一个key原来存100字节的字符串,后来改成1K,SDS会重新分配内存,旧内存释放后,如果申请新内存的位置和旧内存不连续,碎片就产生了。另一个原因是键过期:过期键占用的内存不会立刻被系统回收,Redis会在惰性删除或定期删除时释放,释放后内存块不连续,也会产生碎片。
对于碎片,有几种解决方案:
- 使用
ACTIVE DEFRAG功能(Redis 4.0+提供),它会在后台自动进行内存碎片整理,但需要jemalloc支持,且会消耗CPU。 - 如果碎片率持续偏高,可以计划性地重启Redis(在低峰期),但这对高可用涉及主从切换,操作成本高。
- 更根本的方法是控制String value的长度变化,尽量避免一个key的value频繁在几十字节到几百K之间横跳。比如把大对象拆成多个key,或者使用Hash类型存储字段。
过期键的清理也一样,Redis不会每时每刻都把所有过期key立即删掉,它用的是“惰性删除”(每次访问时检查是否过期)+ “定期删除”(每100ms随机抽取部分key检查)的组合。这意味着如果你的业务在短时间内写入大量带TTL的String,然后并发很高,可能会有短暂的内存积压。监控时注意观察INFO keyspace里的过期未删数量,如果不断上涨,就要调整定期删除的频率参数或者缩短TTL。
4. 应用场景与踩坑经验:从缓存到分布式锁再到Bitmap
4.1 缓存场景:热点Key、大Key与序列化选择
缓存是String最常用的领域,但也是问题最多的地方。三个最常见的问题:热点Key、大Key、序列化方式选择。
热点Key:当某个key的访问量远高于其他key,单台Redis实例的CPU会先打到瓶颈,并且会导致缓存一过期,瞬间大量请求穿透到数据库。这个问题的解决手段很多,但都和String使用有关:
- 对热点key设置较长的TTL,甚至不设置TTL,然后手动更新。
- 给key加随机后缀,比如把一份数据复制成多个子key分布在多个Redis分片(只适合读多写少的场景,写的时候会麻烦)。
- 在本地缓存层面做保护,JVM进程内缓存热点数据。
大Key:当Value的大小超过10KB时,就要警惕了。大Key对主从复制的负面影响尤其明显:每次写操作都会把完整的Value传给从库,如果value是几MB,一次写操作就会产生几MB的网络传输。同时,删除大Key时也会发生较长时间阻塞,因为释放内存需要时间。如果不小心用DEL删了一个100MB的String,主线程会被卡住一会儿,所有请求都会变慢。
如果要删除一个大Key,应该用UNLINK命令替代DEL,它会在后台异步释放内存。如果用的是DEL,最好先确认value大小,或者直接统一改用UNLINK(Redis 4.0+)。
序列化方式:存对象时,我要么用纯JSON字符串,要么用二进制序列化。JSON可读性好、调试方便,但空间利用率低;Protobuf、MessagePack等二进制格式体积小,但需要引入额外依赖。如果是高并发低延迟的系统,我通常优先选择体积更小的二进制序列化,然后评估是否可接受压缩再提升CPU开销。不管用哪种,一定要带一个版本号或类型字段在value里,否则后续改数据结构时老数据无法兼容,只能全部清掉重建缓存。
4.2 分布式锁的SETNX误删问题与正确写法
网上很多文章教你用SETNX做分布式锁,但很少有人强调“误删锁”这个细节。经典的错误写法是这样:
# 错误的加锁方式 if redis.setnx("lock_key", "1"): redis.expire("lock_key", 30) # 业务逻辑... redis.delete("lock_key")这里有两个问题:第一,加锁和设置过期不是原子性的,之前已经提过;第二,如果锁过期之后,其他线程拿到了锁,当前线程执行完业务后直接DEL,就会把别人的锁删掉。正确的做法是在value里存一个唯一的客户端标识(比如UUID),删除前先GET比较,是自己的才删。但比较和删除是两个操作,为了避免中间插入其他线程的加锁成功造成误删,要使用Lua脚本保证原子性:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end加锁时也要用一条SET命令带上NX和EX:
SET lock:order:1001 uuid NX EX 30这样处理之后,分布式锁才算是基本可靠的。当然,Redis分布式锁本身还有锁超时后业务未执行完的问题,需要续期机制,比如Redisson的Watch Dog,那需要另开一篇讲。至少基础的误删问题一定要避免。
4.3 Bitmap的位操作命令与统计场景
String类型还有一个隐藏技能:Bitmap,也就是在位级别上的操作。Redis的Bitmap并不是独立的数据类型,底层就是String,只是提供了SETBIT、GETBIT、BITCOUNT、BITPOS、BITOP这组命令,让你可以把value当成一个bit数组来用。
典型场景是做用户签到、在线状态、好友关系等布尔型统计。以用户签到为例,假设用一年中第n天作为bit位的偏移:
SETBIT sign:user:1001 1 1 -- 第2天签到 SETBIT sign:user:1001 3 1 -- 第4天签到 BITCOUNT sign:user:1001 -- 统计签到次数 GETBIT sign:user:1001 1 -- 检查第2天是否签到一个用户一年只需要365个bit,也就是大约46字节,比用Set或者多个String省得多。但要注意几个限制:
- 偏移量不能超过2^32-1,且当偏移量很大时(比如几亿),String的value会被填充到那个位置,内部会有很多0,这会实际占用内存(不是稀疏存储)。
SETBIT的复杂度是O(1),但大偏移量的首次设置会触发内存分配,可能阻塞。BITOP做多个bitmap的AND/OR时,结果会存放在一个新的key里,这意味着会占用额外内存,如果源key很大,要谨慎。
Bitmaps适合数据密集(每个用户每天都可能有点)且按位标记的场景,但如果不确定业务未来会不会扩展,优先考虑Set类型可能更稳妥。
4.4 计数器的数据持久化与AOF重写问题
String计数器(INCR/DECR)在Redis重启后要保证数据不丢,依赖持久化机制。Redis有RDB和AOF两种。RDB是快照,不可能做到不丢任何写操作;AOF可以做到每秒刷盘甚至每次命令刷盘。但AOF有个问题:如果业务上有大量的INCR操作,AOF文件里会积累海量的INCR命令,重启后重放这些命令会非常慢。
好在Redis的AOF重写机制会把同一key的状态合并为最终结果。比如某个key被INCR了一万次,AOF重写后只会保留一条SET key 10000或者INCRBY key 10000。所以如果你的计数器数量大,一定要确认AOF重写是开启的(auto-aof-rewrite-percentage和auto-aof-rewrite-min-size默认配置),当然Redis也会自动执行,大型实例上要关注重写期间的性能影响。
在实际运维中,我发现过这样一个坑:计数器的key没有设置TTL,导致Redis中堆积了大量用后即弃的临时计数key。比如做秒杀,每个用户一个计数器,活动结束就该清理,结果开发图方便没设过期时间,内存白白涨了几十个G。对计数器来说,如果业务上不需要历史数据,一定要设置一个合理的TTL,并通过定时任务定期清理。
5. 性能调优与监控:怎么判断String用得好不好
5.1 通过redis-cli --stat和INFO命令观察内存
String类型的性能问题,很多时候不是命令慢,而是内存和网络的问题。先学会观察诊断指标。
在命令行执行redis-cli --stat,会看到实时的ops(每秒操作数)、hit-rate(命中率)、used_memory(已用内存)等。但这只能看到整体,要区分String类型的具体情况,需要用INFO memory查看used_memory_dataset、used_memory_overhead、mem_fragmentation_ratio;用INFO keyspace看看db里的key数量;用INFO commandstats看看每个命令的调用次数和耗时,如果发现SET和GET的调用量非常大,但业务请求量并不算太高,可能就是有循环重复SET同样的值,那需要检查业务逻辑。
一个判断String key大小的技巧:
redis-cli --bigkeys它会扫描Redis中比较大的Key,并统计每个类型中最大的几个key。对大String来说,这个命令能帮你快速定位大Key的位置。但注意,--bigkeys在扫描过程中可能有轻微的阻塞风险,尽量在低峰期运行。
5.2 key的命名规范与过期时间设计的金线
这一节我想聊聊工程习惯,因为String用的好不好,一半取决于命令选择,另一半取决于命名和过期时间设计。
命名规范方面,我通常会采用“业务:模块:对象类型:ID[:子字段]”的方式,比如:
order:seckill:count:1001 user:profile:info:1001 article:detail:content:20001使用冒号分隔,一方面可视化更好,另一方面也方便按前缀批量操作(虽然生产环境不建议用KEYS,但可以用SCAN)。但要注意,String key本身没有层级或目录的概念,冒号只是命名约定,不会带来查询优化。如果key名称太长,比如几百字节,会白白占用内存。key越短越省内存,但也不能短到无法辨认,建议控制在40字节以内。
过期时间设计,有一个重要的经验:不要给永远需要存在的key设置永久TTL,如果业务上允许,一律设定过期时间。原因是Redis内存有限,哪怕你再谨慎,也总会有漏网之鱼。设置TTL后,即使有异常逻辑导致key无法删除,它也有自我清理的机会。对于需要长期保留的数据,也要把TTL设成足够长,比如90天,再加上自动续期逻辑。
另外,设计过期时间时要注意“缓存雪崩”问题:如果把一批key的过期时间都设为同一个值,高峰期它们会同时过期,然后同时回源数据库。解决办法是把过期时间加上一个随机偏移,比如:
SET cache:article:1001 value EX (300 + random(0, 60))这能显著降低周期性雪崩的概率。
5.3 大Value对主从复制和持久化的隐形影响
最后讲一个容易被忽视的问题:String大Value对主从复制的影响。
Redis的主从复制是异步的,主库写完命令后,会把写命令传播给从库。如果value很大,命令传播的数据量就很大,从库处理这些数据需要时间,主库和从库之间的复制延迟就随之增大。在某些极端情况下,主库写入一个10MB的String,从库同步需要几百毫秒,如果业务要求读从库,就会读到旧数据。这不是网络问题,而是数据规模问题。
同样的道理,AOF持久化时,每一条写命令也会作为文本记录到AOF文件。一个大Value可以导致AOF文件暴涨,重写时也要处理大量数据,所以我在系统设计阶段就会给String value设定一个硬上限,比如单个Key不超过50KB。如果业务对象比较大,我可能选择Hash类型,将一个大对象拆成多个字段,这样每次读写只操作需要的字段,内存效率和网络开销都更可控。
还有一点:当String过期时,Redis如果使用DEL或惰性删除,释放内存会有一次性开销。如果一个超大String恰好过期,主线程删除时会阻塞一段时间。如果你发现Redis偶尔有几十毫秒到几百毫秒的阻塞,同时看到INFO commandstats里del的耗时很高,记得用UNLINK替代DEL。不过,过期键的删除是自动触发的,无法指定用UNLINK,所以最好的办法还是从源头避免大Value。
我自己在项目里长期维护过一套商品信息缓存,一开始图省事把所有字段拼成一个JSON塞进String,5000多个商品数据把Redis内存吃掉了好几个G,而且每次更新一个字段都得重新序列化整个对象。后来拆分成多个key配合Hash字段,内存下来了,更新也快了。String这个类型看起来简单,但真正用好,需要对SDS结构、编码规则、命令语义和业务模型都有清晰的理解。希望这篇总结能帮你少走一些我走过的弯路。如果有哪些细节你在自己的环境里验证过不同的结论,欢迎在评论区交流。