☰
01-Redis 到底快在哪:从一次 SET 看清数据结构与单线程模型
2026/11/4 2:29:06 网站建设 项目流程

Redis 到底快在哪:从一次SET看清数据结构与单线程模型

一、一次KEYS,让整个实例停住

去年排查商品详情接口超时。日志里到处是Read timed out,数据库慢查询日志却干净得可疑。最后在 Redis 监控面板上看到一条命令的耗时曲线垂直拉升,时间点和告警对得上——有人在大促前敲了:

KEYS cart:*

他的本意是"看看购物车数据长什么样"。而KEYS是全量键空间遍历,执行期间其他客户端的命令全部排队。实例上几十万个 key,所有依赖它的服务一起卡住。

多数人对 Redis 的理解停在一句话上:“它很快,因为它在内存里,因为它是单线程的。”

这句话不能指导任何决策。要回答的是:快在哪个环节?单线程约束了什么?为什么同一个SET,有时瞬间返回,有时能把整条链路拖垮?


二、一条命令的旅程

2.1 从字节到函数调用

SET cart:1001 "[]"在内部经过五步:

  1. TCP 收包:编码成 RESP 数组(*3\r\n$3\r\nSET\r\n...),纯文本协议,解析成本极低。
  2. 协议解析:epoll 发现 socket 可读,把字节流拆成参数数组。
  3. 命令表查找:用SET查处理函数,校验参数个数、ACL、内存上限;Cluster 下判断 slot 是否归自己管,不归就回MOVED。
  4. 单线程执行:在内存哈希表上定位或创建 key,写入值,做过期检查。
  5. 回复缓冲:+OK\r\n进输出缓冲区,下一轮事件循环写回 socket。

[图:一条命令从客户端发出到回复返回的五步路径,标出 I/O 线程与主线程的分界]

2.2 "单线程"只修饰第 4 步

Redis 的"单线程"特指命令执行串行,不是"整个进程只有一个线程"。

环节谁在干是否卡住其他命令
执行命令本身主线程—(它就是那条串行队列)
socket 读写、协议解析主线程;6.0 起可配io-threads交给 I/O 线程否
RDB 快照 / AOF 重写fork出的子进程fork瞬间停顿,子进程干活不阻塞
AOFfsynceverysec下由后台线程做;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整体异步。

三、"快"的三层原因,和它的边界

  1. 数据在内存里,不涉及磁盘寻道;持久化是另一条路径(第 03 篇),不影响命令执行。
  2. 执行模型省掉了协调成本,不需要加锁,也不需要在多核之间同步状态。
  3. 数据结构与协议为"小操作"优化过,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 的编码随数据规模切换的阈值示意图]

结构紧凑编码通用编码触发阈值(官方默认值,非实测)
Stringint(能放进long long的整数)、embstr(≤44 字节)raw超long long范围或超 44 字节
Hashlistpackhashtablehash-max-listpack-entries128、-value64
Listquicklist(节点内是listpack)无(始终 quicklist)list-max-listpack-size控制单节点大小
Setintset(全整数)、7.2 起新增listpackhashtableset-max-intset-entries512、set-max-listpack-entries128、set-max-listpack-value64
ZSetlistpackskiplistzset-max-listpack-entries128、-value64

三点必须知道:

  1. 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。
  2. Set 的listpack编码是 7.2 才有的。7.0 及更早,装字符串的小 Set 直接就是hashtable。
  3. 编码升级不可逆。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,否则 hashtable

ZSet 同理,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])endreturnv
privatestaticfinalRedisScript<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 + JSONHash
改一个字段的读写量O(购物车大小)O(1)
并发改不同商品互相覆盖互不影响
并发改同一商品丢失更新HINCRBY原子
取整个购物车一次GETHGETALL(字段多时是慢命令)

结论不是"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。差别不在"谁更快",而在连接模型:

LettuceJedis
连接模型单连接多路复用、线程安全;也可配池一个连接同时只处理一条命令,必须靠池
阻塞命令(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都要删掉重写。

九、小结

  1. Redis 快在"内存 + 无协调 + 紧凑数据结构",慢在"O(N) 命令和大 key"。判断一条命令危不危险,看它的复杂度是否和 key 的大小成正比。
  2. 单线程只修饰命令执行这一步。它给你天然的原子性,也意味着一条慢命令会阻塞所有人。
  3. 选结构就是选访问模式。先问"我怎么读、怎么改",再决定用 String 还是 Hash。

下一篇聊缓存。起因就是 5.3 那个榜单:Top 100 直接读 Redis,QPS 一上来,热点 key 把节点带宽打满,紧接着商品详情页开始超时。缓存不是加个@Cacheable就完事,它是一条需要设计的链路——穿透、击穿、雪崩,每个都有解法,也都有会失效的前提。

自检三问:

  1. 你业务里有没有耗时和 key 元素数量成正比的命令?它最大的那个 key 现在有多少元素?
  2. 所有"先读、判断、再写"的 Redis 操作里,哪些其实需要原子性?两个请求同时执行,结果还对吗?
  3. 你用的每个 key 是listpack还是hashtable/skiplist?跑一遍OBJECT ENCODING,看看内存是被数据占的,还是被编码升级占的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询