Redis 到底快在哪:从一次SET看清数据结构与单线程模型
一、一次KEYS,让整个实例停住
去年排查商品详情接口超时。日志里到处是Read timed out,数据库慢查询日志却干净得可疑。最后在 Redis 监控面板上看到一条命令的耗时曲线垂直拉升,时间点和告警对得上——有人在大促前敲了:
KEYS cart:*他的本意是"看看购物车数据长什么样"。而KEYS是全量键空间遍历,执行期间其他客户端的命令全部排队。实例上几十万个 key,所有依赖它的服务一起卡住。
多数人对 Redis 的理解停在一句话上:“它很快,因为它在内存里,因为它是单线程的。”
这句话不能指导任何决策。要回答的是:快在哪个环节?单线程约束了什么?为什么同一个SET,有时瞬间返回,有时能把整条链路拖垮?
二、一条命令的旅程
2.1 从字节到函数调用
SET cart:1001 "[]"在内部经过五步:
- TCP 收包:编码成 RESP 数组(
*3\r\n$3\r\nSET\r\n...),纯文本协议,解析成本极低。 - 协议解析:epoll 发现 socket 可读,把字节流拆成参数数组。
- 命令表查找:用
SET查处理函数,校验参数个数、ACL、内存上限;Cluster 下判断 slot 是否归自己管,不归就回MOVED。 - 单线程执行:在内存哈希表上定位或创建 key,写入值,做过期检查。
- 回复缓冲:
+OK\r\n进输出缓冲区,下一轮事件循环写回 socket。
[图:一条命令从客户端发出到回复返回的五步路径,标出 I/O 线程与主线程的分界]
2.2 "单线程"只修饰第 4 步
Redis 的"单线程"特指命令执行串行,不是"整个进程只有一个线程"。
| 环节 | 谁在干 | 是否卡住其他命令 |
|---|---|---|
| 执行命令本身 | 主线程 | —(它就是那条串行队列) |
| socket 读写、协议解析 | 主线程;6.0 起可配io-threads交给 I/O 线程 | 否 |
| RDB 快照 / AOF 重写 | fork出的子进程 | fork瞬间停顿,子进程干活不阻塞 |
AOFfsync | everysec下由后台线程做;always下阻塞 | always会 |
大 key 异步释放(UNLINK、FLUSHDB ASYNC) | 后台线程(BIO) | 否 |
| 过期键删除 | 主线程(惰性 + 定期) | 会占用主线程时间 |
| 集群 gossip 消息处理 | 主线程(走单独 cluster bus 端口) | 会占用主线程时间 |
注意最后一行:"集群通信是后台线程"是错的,cluster bus 的消息仍在主线程事件循环里处理,节点多、心跳频繁时会挤占主线程。
2.3 两个结果
好处:命令天然原子。无锁竞争、无线程切换,所有操作在同一份数据结构上顺序发生——这是INCR、HINCRBY、SETNX可以当原子操作用的根本原因。
代价:一条慢命令会阻塞后面所有请求,这是生产上绝大多数"抖动"的根源:
- 复杂度 O(N):
KEYS、FLUSHALL、HGETALL几十万字段的 Hash、SMEMBERS一个大 Set、SORT。 - 单个 key 太大:对几百万元素的 List 执行
DEL,回收是 O(N) 的。大 key 用UNLINK,小 key 直接DEL——UNLINK有入队开销;也可配lazyfree-lazy-user-del yes(默认no)让DEL整体异步。
三、"快"的三层原因,和它的边界
- 数据在内存里,不涉及磁盘寻道;持久化是另一条路径(第 03 篇),不影响命令执行。
- 执行模型省掉了协调成本,不需要加锁,也不需要在多核之间同步状态。
- 数据结构与协议为"小操作"优化过,
listpack把一组小对象压进连续内存,避免指针追逐。
但**"快"有前提**:命令足够小、复杂度足够低。破坏前提后,前面三条一条都救不了你——判断标准只有一条:复杂度是否和 key 里的元素数量成正比。
四、五种结构,对应的是五种访问模式
选错结构往往不是"性能差一点",而是根本没解决业务问题:
| 结构 | 主场 | 关键命令 | 典型反例 |
|---|---|---|---|
| String | 计数、位图、会话、分布式锁 | INCR、SETBIT/BITCOUNT、SET NX PX | 整对象序列化进去,改一个字段读写全量 |
| Hash | 对象的字段级更新 | HSET、HINCRBY(慎用HGETALL) | 字段数上万还整取,变成慢命令 |
| List | 时间线、分页列表、简单队列 | LPUSH+LRANGE、BRPOP | 当可靠队列用 |
| Set | 去重、交并差、判存在 | SADD、SISMEMBER、SINTER | 做判重却没算内存 |
| ZSet | 排行榜、延时队列、滑动窗口限流 | ZINCRBY、ZRANGE ... REV、ZRANGE ... BYSCORE | 榜单不分桶,一个 key 无限增长 |
三条值得展开:
- 位图签到:
SETBIT sign:1001:202610 28 1记录当天签到,一年只占几十字节;BITCOUNT sign:1001:202610统计当月签到总天数。连续天数BITCOUNT算不了——它返回 1 的总数而非连续段,得靠客户端遍历、BITFIELD分段取或 Lua 逐位判断。 - ZSet 延时队列:score 存应被消费的时间戳,
ZRANGE key 0 now BYSCORE LIMIT 0 10捞任务。它不是可靠队列——捞出来到处理完之间进程崩了任务就丢,需要一张"处理中"集合兜底。 - ZSet 滑动窗口限流:
ZREMRANGEBYSCORE清窗口外 →ZCARD判断 →ZADD,三步不原子,用 Lua 包。
List 当队列必须说重一点:LPUSH+BRPOP没有 ACK、没有消费组、没有重投,消费者拿到消息后进程被杀,消息就永久消失。不能接受丢消息,用 Stream(有消费者组和XACK)或专门的消息中间件。
4.1 编码地图:同一种结构,三种内存布局
写入"123"和写入 100 字节 JSON,内存里的对象类型并不相同。Redis 为每种结构选最省内存的编码,并在超过阈值时"升级"。
[图:String / Hash / Set / ZSet 的编码随数据规模切换的阈值示意图]
| 结构 | 紧凑编码 | 通用编码 | 触发阈值(官方默认值,非实测) |
|---|---|---|---|
| String | int(能放进long long的整数)、embstr(≤44 字节) | raw | 超long long范围或超 44 字节 |
| Hash | listpack | hashtable | hash-max-listpack-entries128、-value64 |
| List | quicklist(节点内是listpack) | 无(始终 quicklist) | list-max-listpack-size控制单节点大小 |
| Set | intset(全整数)、7.2 起新增listpack | hashtable | set-max-intset-entries512、set-max-listpack-entries128、set-max-listpack-value64 |
| ZSet | listpack | skiplist | zset-max-listpack-entries128、-value64 |
三点必须知道:
- 7.0 把
ziplist换成了listpack,配置项随之改名:hash-max-ziplist-entries→hash-max-listpack-entries,zset-max-ziplist-entries→zset-max-listpack-entries。旧名在 7.0 仍作别名可用,新代码统一用listpack。OBJECT ENCODING的返回值也变了:7.x 是listpack,6.x 是ziplist。 - Set 的
listpack编码是 7.2 才有的。7.0 及更早,装字符串的小 Set 直接就是hashtable。 - 编码升级不可逆。Hash 从
listpack变成hashtable后,即使字段删到只剩一个也不退回,除非删 key 重建。这就是"内存占用不是线性的"最直接的原因:数据量减半,内存没降。
五、把编码和坑都跑一遍
环境:Redis 7.x(docker run -d -p 6379:6379 redis:7.2)、JDK 17、spring-boot-starter-data-redis(默认 Lettuce)、jackson-databind。
5.1 阈值前后的编码切换
redis-cli# --- String:int / embstr / raw ---SET n12345OBJECT ENCODING n# int(超出 long long 范围的整数不会是 int,而是 embstr 或 raw)SET s"hello"OBJECT ENCODING s# embstr(≤44 字节)SET s2"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"STRLEN s2# 45,先自证长度再断言编码OBJECT ENCODING s2# raw# --- Hash:listpack → hashtable ---CONFIG SET hash-max-listpack-entries3DEL h HSET h f1 v1 f2 v2 f3 v3 OBJECT ENCODING h# listpackHSET h f4 v4 OBJECT ENCODING h# hashtable(超过 3 个字段)HDEL h f4 f3 f2 OBJECT ENCODING h# 仍是 hashtable —— 编码不回退# --- Set:intset 跨阈值 ---CONFIG SET set-max-intset-entries2DEL st SADD st12OBJECT ENCODING st# intsetSADD st3OBJECT ENCODING st# 7.0 及更早:hashtable# 7.2+:满足 listpack 条件则为 listpack,否则 hashtableZSet 同理,CONFIG SET zset-max-listpack-entries 2后加三个短成员,编码从listpack变skiplist;成员值超过zset-max-listpack-value(默认 64)也会触发同样的切换。CONFIG SET改的是运行时配置、重启失效,实验用的极小阈值生产上记得还原。
5.2 同一个购物车,两种实现
String + JSON 版本——真实项目里见过最多的写法:
// 位于 @Service 中:redis / mapper 为注入字段,log 由类上的 @Slf4j(Lombok)生成publicvoidaddItem(longuserId,longskuId,intdelta){Stringk="cart:"+userId;Stringraw=redis.opsForValue().get(k);// ① 读全量Cartcart;try{cart=(raw==null)?newCart():mapper.readValue(raw,Cart.class);}catch(JsonProcessingExceptione){log.warn("cart json broken, drop and rebuild. key={}",k,e);redis.delete(k);// 脏数据删掉,下次重建thrownewIllegalStateException("购物车数据异常,请重试",e);}cart.setQuantity(skuId,cart.getQuantity(skuId)+delta);try{redis.opsForValue().set(k,mapper.writeValueAsString(cart));// ② 写全量}catch(JsonProcessingExceptione){log.error("cart serialize failed. key={}",k,e);thrownewIllegalStateException("购物车保存失败",e);}}上面这段没设 TTL。真实业务应该写成redis.opsForValue().set(k, json, Duration.ofDays(30)),否则购物车 key 永不过期,只能靠内存淘汰或手工清理兜底。
两个问题,第二个更严重:读写都是全量,30 个商品改一个要把 30 个都读出来再写回去;更要命的是丢失更新——两个请求同时执行 ①②,后写的用旧快照覆盖掉前一个的修改。这不是理论风险,是必然发生。
Hash 版本:
publicvoidaddItem(longuserId,longskuId,intdelta){Stringk="cart:"+userId;Longafter=redis.opsForHash().increment(k,String.valueOf(skuId),delta);// HINCRBY 服务端原子if(after!=null&&after<=0){redis.opsForHash().delete(k,String.valueOf(skuId));// 与上一步非原子}}HINCRBY服务端原子,并发改同一商品不丢更新。但"归零则删字段"是第二步,两步之间有竞态:可能刚删掉字段,另一个请求又把它写成 1。要干净得用 Lua:
-- cart_incr.lualocalv=redis.call('HINCRBY',KEYS[1],ARGV[1],ARGV[2])ifv<=0thenredis.call('HDEL',KEYS[1],ARGV[1])endreturnvprivatestaticfinalRedisScript<Long>CART_INCR=buildScript();privatestaticRedisScript<Long>buildScript(){try{Stringlua=newClassPathResource("lua/cart_incr.lua").getContentAsString(StandardCharsets.UTF_8);returnnewDefaultRedisScript<>(lua,Long.class);}catch(IOExceptione){thrownewIllegalStateException("cart_incr.lua 加载失败",e);}}publicLongaddItemAtomic(longuserId,longskuId,intdelta){returnredis.execute(CART_INCR,List.of("cart:"+userId),String.valueOf(skuId),String.valueOf(delta));}(getContentAsString()抛IOException,写在字段初始化位置编译不过,必须用静态工厂包一层;DefaultRedisScript的构造签名在不同小版本间也有差异,以当前版本文档为准。)
| String + JSON | Hash | |
|---|---|---|
| 改一个字段的读写量 | O(购物车大小) | O(1) |
| 并发改不同商品 | 互相覆盖 | 互不影响 |
| 并发改同一商品 | 丢失更新 | HINCRBY原子 |
| 取整个购物车 | 一次GET | HGETALL(字段多时是慢命令) |
结论不是"Hash 一定更好"。业务永远"整个对象一起读、一起写"时,String 存 JSON 更简单,不必关心 Hash 的字段数阈值和逐字段操作;一旦出现"只改其中一个字段",Hash 的优势就是决定性的。
5.3 ZSet 热度榜,记得分桶
ZINCRBY hot:article:202610292111001ZINCRBY hot:article:202610292151002ZRANGE hot:article:202610292109REV WITHSCORES# Top 10(6.2 起推荐 REV 形式)ZREVRANK hot:article:20261029211001# 查单个成员排名(0 开始);未废弃,查排名用它即可EXPIRE hot:article:2026102921172800# 2 天后清理publicvoidhit(longarticleId){Stringbucket="hot:article:"+LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHH"));redis.opsForZSet().incrementScore(bucket,String.valueOf(articleId),1);redis.expire(bucket,Duration.ofDays(2));// 与上一步非原子,见下}ZREVRANGE、ZRANGEBYSCORE、ZREVRANGEBYSCORE自6.2 起 deprecated,7.x 仍可用,新代码请写ZRANGE key start stop REV、ZRANGE key min max BYSCORE(LIMIT只对BYSCORE/BYLEX生效)。ZREVRANK没有废弃,照常用。
incrementScore和expire是两次调用,中间宕机就留下一个永不过期的 key,用 pipeline 或 Lua 合并(第 02 篇细讲)。
分桶也不只是为了好清理:ZINCRBY在skiplist下是 O(log N),但小 zset 走listpack时是O(N)——榜单越界那一刻,单次写入代价会跳变。聚合时用ZUNIONSTORE按权重合并,或只读"最近 24 个桶"。
5.4 亲手造一个大 key
seq1100000|awk'{print "RPUSH biglist item-"$1}'|redis-cli--pipeMEMORY USAGE biglist# 占多少字节(采样估算,可传 SAMPLES)OBJECT ENCODING biglist# quicklist# DEL biglist # 危险:主线程同步释放UNLINK biglist# 后台线程回收,主线程立即返回要警惕的不是"这个 key 有多大",而是"有没有一条命令会把它整个读出来或整个删掉"。
六、Key 设计,和那 16 个 db
Key 命名固定成业务:对象:标识,如cart:1001。排查时能用SCAN 0 MATCH cart:* COUNT 100圈定范围(COUNT只是提示,单次返回可能多于也可能少于 100),做容量分析时按前缀归类。别把时间戳、随机数放在前缀位置。
key 多本身不是问题,但上 Cluster 后 key 按CRC16(key) mod 16384落到 slot,前缀过度集中会让数据倾斜到少数 slot——第 04 篇展开。
关于SELECT切库:默认 16 个 db(databases 16),很多人拿它做多业务隔离。我不建议:它不是权限隔离,任何能连上的客户端都能切到任意 db;危险命令范围失控,在错误的 db 上FLUSHDB清的是别人的数据;Cluster 模式下只有 db 0。
结论:用 key 前缀做逻辑隔离,不要用 db。
七、Lettuce 还是 Jedis
Spring Boot 2.x 起默认 Lettuce。差别不在"谁更快",而在连接模型:
| Lettuce | Jedis | |
|---|---|---|
| 连接模型 | 单连接多路复用、线程安全;也可配池 | 一个连接同时只处理一条命令,必须靠池 |
阻塞命令(BRPOP) | 需单独建连接,否则占住共享连接 | 占住池中一条连接,池小容易打满 |
| 响应式 / 集群 / 哨兵 | 原生 Reactor;集群哨兵均支持 | 无响应式;集群哨兵均支持 |
绝大多数业务用默认的 Lettuce 就行,不该切。共享连接执行阻塞命令会把整条连接卡住,BRPOP场景要么单独建连接,要么改用 Jedis 的池模型。真要切,靠依赖排除——Spring Boot 发现 classpath 上没有 Lettuce 就会自动装配 Jedis:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><exclusions><exclusion><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId></exclusion></exclusions></dependency><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId></dependency>代价是失去响应式支持、连接池要自己管。"连接池越大越好"是错的——连接数超过实例处理能力,只会让排队变长(第 06 篇展开)。
最后一个容易漏的前提:spring-boot-starter-data-redis不会带进commons-pool2,而任何连接池配置都要它——Lettuce 的spring.data.redis.lettuce.pool.*和 Jedis 的spring.data.redis.jedis.pool.*一样,缺这个依赖,池参数写了也不生效。切 Jedis 时最容易漏,因为大家以为只是换个客户端。(以上是 Spring Boot 3.x 前缀,2.x 统一为spring.redis.*。)
八、踩坑清单
- “单线程所以没有并发问题”—— 我写过:先
GET库存,判断大于 0,再SET减一。压测时超卖了。单线程保证的是单条命令原子,GET和SET之间隔着一次网络往返,能插进任意多条别的命令。要原子就用INCR/HINCRBY,或者 Lua。 - 线上敲
KEYS *—— 见过三次,每次都是"我就看一眼"。用SCAN游标迭代,或在从库上跑--bigkeys(第 06 篇细讲)。 - 整对象塞 JSON,改一个字段读写全量—— 见 5.2,附赠一个丢失更新的 Bug。
- 拿 List 当可靠队列—— 写过。
BRPOP之后服务重启,那批订单状态永远停在"处理中"。 - 以为编码固定不变—— 小 Hash 在测试环境是
listpack,上线两个月变hashtable,内存涨一截,谁也说不清为什么。 - 用
SELECT做业务隔离—— 迁 Cluster 那天,所有SELECT都要删掉重写。
九、小结
- Redis 快在"内存 + 无协调 + 紧凑数据结构",慢在"O(N) 命令和大 key"。判断一条命令危不危险,看它的复杂度是否和 key 的大小成正比。
- 单线程只修饰命令执行这一步。它给你天然的原子性,也意味着一条慢命令会阻塞所有人。
- 选结构就是选访问模式。先问"我怎么读、怎么改",再决定用 String 还是 Hash。
下一篇聊缓存。起因就是 5.3 那个榜单:Top 100 直接读 Redis,QPS 一上来,热点 key 把节点带宽打满,紧接着商品详情页开始超时。缓存不是加个@Cacheable就完事,它是一条需要设计的链路——穿透、击穿、雪崩,每个都有解法,也都有会失效的前提。
自检三问:
- 你业务里有没有耗时和 key 元素数量成正比的命令?它最大的那个 key 现在有多少元素?
- 所有"先读、判断、再写"的 Redis 操作里,哪些其实需要原子性?两个请求同时执行,结果还对吗?
- 你用的每个 key 是
listpack还是hashtable/skiplist?跑一遍OBJECT ENCODING,看看内存是被数据占的,还是被编码升级占的。