【Redis】Redis 数据结构与 Spring Boot 集成
第 1 期VibeLoop 的数据基石 — Redis 数据结构与 Spring Boot 集成VibeLoop 是一个虚构的轻量级内容互动平台用于本系列统一演示。本文从零开始带你理解 Redis 五种核心数据结构的底层机制并集成到 Spring Boot 中实战——为后续的缓存策略、分布式锁、高可用架构打下基础。1. 开篇场景2. 五大数据结构全景速览3. String不只存字符串3.1 VibeLoop 实战Session 共享与接口限流3.2 内部编码44 字节的临界点4. Hash用户资料的理想容器4.1 VibeLoop 实战用户资料字段独立更新4.2 内部编码ziplist → hashtable 的转换秘密5. List时间线背后的双向链表5.1 VibeLoop 实战动态 Timeline 与异步消息队列5.2 阻塞命令BRPOP 实现可靠消费6. Set去重与集合运算6.1 VibeLoop 实战共同关注与标签聚合7. ZSet排行榜的灵魂数据结构7.1 跳表为什么 O(logN) 却比红黑树更优7.2 VibeLoop 实战24h/7d/30d 三维热榜8. 单线程模型深度拆解8.1 IO 多路复用epoll 三件套8.2 6.0 的 IO 多线程别被名字骗了9. Spring Boot 集成从配置到实战9.1 依赖与配置9.2 StringRedisTemplate 五种操作速查9.3 Lettuce 连接池调优10. 源码走读Lettuce 连接池 borrowObject11. 面试 8 连问12. 必背速查表1. 开篇场景假设你正在搭建 VibeLoop一个轻量级内容互动平台。用户 Alice 登录后首页需要展示她的个人信息、关注列表、最新动态时间线、以及当前的热门帖子排行。这些数据每一次请求都去 MySQL 查那张用户关注表动辄百万行每次 JOIN 查询需要 200ms——再加推荐算法、权限校验用户可能还没刷出首页就已经划走了。这就是 Redis 的价值它把「读多写少」的热数据放在内存中用精心设计的数据结构匹配对应的业务场景把响应时间从 200ms 压缩到1ms 以内。你会怎么用 Redis 的数据结构来承载 VibeLoop 的这些需求先别急着翻文档。咱们从五种基本数据类型逐一切入趁热把内部编码、单线程模型、Spring Boot 集成和连接池源码一并打通。2. 五大数据结构全景速览在深入代码之前先把五大数据结构与 VibeLoop 的业务场景做一次全景映射数据结构VibeLoop 场景核心命令时间复杂度StringSession 共享、帖子阅读计数、接口限流SET/GET/INCR/EXPIREO(1)Hash用户资料昵称/头像/简介独立字段HSET/HGET/HDELO(1)List用户动态 Timeline、异步消息队列LPUSH/LRANGE/BRPOPO(1) 两端Set共同关注、内容标签聚合、点赞去重SADD/SINTER/SDIFFO(1)ZSet热门帖子排行24h/7d/30dZADD/ZRANGE/ZREVRANKO(logN)同一个 Redis key 背后Redis 会根据数据的大小和元素数量自动选择不同的内部编码来实现。这也是面试中的重灾区——我们会在每个类型章节展开。3. String不只存字符串Redis 的 String 本质上是一个二进制安全的字节数组最大 512MB。你用它存 JSON 序列化后的对象、整型计数器、二进制图片都行。3.1 VibeLoop 实战Session 共享与接口限流场景一Session 共享。VibeLoop 部署了 3 台 Web 节点用户登录后 Session 需要跨节点共享。// 用户登录成功后将 Session 信息存入 RedisStringsessionKeyvibeloop:session:sessionId;stringRedisTemplate.opsForValue().set(sessionKey,JsonUtil.toJson(userSession),Duration.ofMinutes(30));不用 sticky session不用 Spring Session 的额外依赖。任何一个节点收到请求直接读vibeloop:session:id就行。场景二接口限流。VibeLoop 的帖子发布接口被脚本刷了需要限制同一用户每分钟最多发 3 条。publicbooleanallowPublish(StringuserId){StringrateKeyvibeloop:rate:publish:userId;LongcountstringRedisTemplate.opsForValue().increment(rateKey);if(count1){// 第一次请求设置窗口stringRedisTemplate.expire(rateKey,Duration.ofMinutes(1));}returncount3;}用INCR而非GET SET——一个是需要两步操作有并发窗口问题一个是单条原子命令。面试官大概率会追问「为什么用 INCR 而不是 GET 后 1 再 SET」答不上来就危险了。3.2 内部编码44 字节的临界点Redis 并非只用一个结构来存 String。它有三种内部编码通过 OBJECT ENCODING 可以看到编码条件结构int值可用 long 表示且 20 位数字直接存为long无额外开销embstr值 44 字节Redis 5.0一次 malloc元数据和值连续存储raw值 44 字节两次 mallocredisObject与sds分离44 字节的由来jemalloc 分配 64 字节内存块redisObject占 16 字节sdshdr8占 3 字节\0占 1 字节。64 - 16 - 3 - 1 44。超过 44 字节触发embstr→raw转换多一次内存分配。重要行为embstr是只读的。一旦对embstr执行APPEND或SETRANGERedis 会无条件升级到raw即使新值仍 44 字节。这是面试中的经典陷阱——「embstr 的 key 做了 APPEND 后会怎样」4. Hash用户资料的理想容器4.1 VibeLoop 实战用户资料字段独立更新VibeLoop 用户资料包含昵称、头像、简介、粉丝数、关注数。如果用 JSON 字符串存在 String 里每次改昵称都需要全量序列化反序列化。用 Hash 就很舒服每个字段独立一个 key-value 对更新昵称只影响一个字段。// 写入用户资料StringuserKeyvibeloop:user:profile:userId;MapString,StringprofileMap.of(nickname,Alice_in_Wonderland,avatar,https://cdn.vibeloop.com/avatars/alice.jpg,bio,摄影爱好者 · 旅行博主,followerCount,1280,followingCount,365);stringRedisTemplate.opsForHash().putAll(userKey,profile);// 修改昵称——只改一个 fieldstringRedisTemplate.opsForHash().put(userKey,nickname,Alice_V2);对比 String 方案你拿到整个 JSON → 反序列化 → 找到 nickname 字段 → 修改 → 序列化 → 写回。Hash 只需要一次HSET时间复杂度 O(1)。但小心Hash 不适合存字段数量巨大的对象。当元素超过hash-max-ziplist-entries默认 512或单个 value 超过hash-max-ziplist-value默认 64 字节内部编码从ziplist切换到hashtable内存占用会大幅上升。4.2 内部编码ziplist → hashtable 的转换秘密ziplist压缩列表是一个紧凑的连续内存块所有 field-value 对紧密排列。它省内存但每次读写需要遍历。hashtable是标准哈希表通过数组 链表解决冲突读 O(1) 但每个节点有指针开销在 64 位系统上是 8 字节/指针。配置建议对于 VibeLoop 这种 field 数较少10 个以内的用户资料保持默认配置即可让 ziplist 生效。如果你存的是电商 SKU 属性表动辄上百字段适当调高hash-max-ziplist-entries或让它自然切换到 hashtable。5. List时间线背后的双向链表5.1 VibeLoop 实战动态 Timeline 与异步消息队列VibeLoop 的首页需要展示用户关注的好友动态——谁发了新帖、谁点了赞。// 用户 Alice 发帖后推送到所有粉丝的 TimelineStringtimelineKeyvibeloop:timeline:followerId;StringentryJsonUtil.toJson(newTimelineEntry(postId,authorId,timestamp));stringRedisTemplate.opsForList().leftPush(timelineKey,entry);// 只保留最近 200 条stringRedisTemplate.opsForList().trim(timelineKey,0,199);LPUSH把新动态插入链表头部最新LTRIM裁剪到 200 条。粉丝刷新首页时用LRANGE 0 19拉取最新 20 条时间复杂度 O(SN)S 是偏移量、N 是返回数量。消息队列List 支持的BRPOP阻塞右弹出天然适合做消费者。// 审核队列消费者while(true){StringpostIdstringRedisTemplate.opsForList().rightPop(vibeloop:queue:post:audit,Duration.ofSeconds(30));if(postId!null){auditService.audit(postId);}}5.2 阻塞命令BRPOP 实现可靠消费BRPOP key timeout的行为如果 key 有数据 → 立即弹出返回如果 key 为空 → 阻塞直到有数据或超时多个客户端同时BRPOP同一个 key → 先阻塞的客户端先拿到公平队列注意BRPOP超时返回null不代表出错你需要while(true)循环持续取而不是抛异常退出。可靠性提醒BRPOP弹出后消费者挂了这条消息就丢了。Redis 5.0 引入的 Stream 类型才是生产级消息队列方案有 ACK 机制和消费者组List 适用于对丢失容忍度较高的场景如 Timeline 推送、简单的异步任务。6. Set去重与集合运算6.1 VibeLoop 实战共同关注与标签聚合共同关注Alice 关注了 {Bob, Charlie, David, Eve}Bob 关注了 {Alice, Charlie, Frank, Grace}。StringaliceKeyvibeloop:following:aliceId;// Set: Bob, Charlie, David, EveStringbobKeyvibeloop:following:bobId;// Set: Alice, Charlie, Frank, Grace// 共同关注SetStringcommonstringRedisTemplate.opsForSet().intersect(aliceKey,bobKey);// 结果: {Charlie}sinter的时间复杂度是 O(N * M)N 是最小集合的元素数M 是集合数。对于关注列表这种场景大多数人关注几百到几千人性能完全够用。点赞去重VibeLoop 每篇帖子有一个vibeloop:post:liked:postIdSet存所有点赞用户 ID。用户点赞前先SISMEMBER判断是否已点赞SADD后SCARD获取总数。内部编码元素全是整数时用intset紧凑有序数组一旦有非整数字符串元素立刻切换到hashtable。7. ZSet排行榜的灵魂数据结构这是五大数据类型中面试浓度最高的一个也是 VibeLoop 热榜功能的核心。ZSet 的每个元素由一个member和一个score构成按 score 排序。它不像 Set 只管「有没有」而是多了一层「排第几」的维度。7.1 跳表为什么 O(logN) 却比红黑树更优ZSet 的双编码ziplist元素数 128 且所有元素长度 64 字节skiplist dict超过阈值后切换skiplist跳表是一个多层链表每一层都是下一层的快速通道。查找时从最高层开始每次决定「往下走」还是「往右跳」最终落到目标附近。为什么不用红黑树面试标准答案范围查询跳表找到起点后直接往后遍历O(logN M)红黑树需要中序遍历实现复杂度跳表的插入/删除只需修改相邻节点的指针无需旋转和重新染色空间换时间跳表每层平均有 1/2 的节点总空间 O(N)实际约 1.33N 个节点7.2 VibeLoop 实战24h/7d/30d 三维热榜// 帖子被点赞增加热度分StringhotKey24hvibeloop:hot:posts:24h;stringRedisTemplate.opsForZSet().incrementScore(hotKey24h,postId,1);// 获取 24h 热榜 Top 20分数从高到低SetZSetOperations.TypedTupleStringtopPostsstringRedisTemplate.opsForZSet().reverseRangeWithScores(hotKey24h,0,19);// 定时任务每小时清理 24 小时前的过期数据longcutoffSystem.currentTimeMillis()-24*3600*1000;stringRedisTemplate.opsForZSet().removeRangeByScore(hotKey24h,0,cutoff);三个 ZSet keyvibeloop:hot:posts:24h、7d、30d各维护一个榜。点赞 1 分评论 3 分分享 5 分。定时任务清理过期数据确保不会无限膨胀。延迟队列也是 ZSet 的经典场景把任务执行时间作为 scoreZRANGEBYSCORE 0 now取到期任务。8. 单线程模型深度拆解面试中 80% 的人能答出「Redis 是单线程的」。但接下来的 20% 追问就能筛掉 80%——「单线程为什么还这么快」8.1 IO 多路复用epoll 三件套Redis 使用 epoll 实现 IO 多路复用。核心三件套函数作用epoll_create()创建 epoll 实例内核分配红黑树 就绪链表epoll_ctl()向 epoll 实例注册/修改/删除需要监听的 Socket fdepoll_wait()阻塞等待直到有 Socket 就绪O(1) 返回就绪事件列表对比select/pollepoll 用红黑树管理所有 fd就绪事件放在链表里。epoll_wait不需要遍历全部 fd直接返回就绪链表——这正是「O(1) 获取就绪事件」的由来。Redis 的事件循环核心逻辑while (true) { // 1. 计算最近时间事件的到期时间 // 2. epoll_wait 阻塞等待文件事件超时 最近时间事件 // 3. 处理就绪的文件事件读/写网络数据 // 4. 处理到期的时间事件serverCron、过期键清理等 }快的原因总结全部内存操作无磁盘 IOepoll O(1) 拿到就绪 Socket单线程避免锁竞争和上下文切换内部数据结构经过精心选择和优化慢的场景O(N) 命令KEYS *、SMEMBERS、HGETALL会阻塞整个事件循环。生产环境严禁KEYS *用SCAN代替。8.2 6.0 的 IO 多线程别被名字骗了Redis 6.0 引入的「IO 多线程」只用于网络数据的读写——Socket 数据从内核读到用户空间以及从用户空间写到内核可以由多个 IO 线程并行处理。但命令的解析和执行仍然在主线程中串行完成。这意味着单个命令不会被多线程并发执行不存在并发安全问题耗时命令如KEYS *仍然会阻塞整个服务IO 多线程默认关闭io-threads 1高并发场景才需要手动开启9. Spring Boot 集成从配置到实战9.1 依赖与配置Spring Boot 3.x 默认使用Lettuce作为 Redis 客户端。Jedis 虽然也很流行但 Spring Data Redis 从 2.x 起已将 Lettuce 设为默认Netty 异步驱动线程安全连接天然共享。Maven 依赖dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependencyapplication.ymlVibeLoop 开发环境配置spring:data:redis:host:127.0.0.1port:6379password:${REDIS_PASSWORD:}timeout:3000mslettuce:pool:max-active:16# 最大活跃连接数max-idle:8# 最大空闲连接数min-idle:2# 最小空闲连接数max-wait:2000ms# 获取连接最大等待时间9.2 StringRedisTemplate 五种操作速查Spring Data Redis 提供StringRedisTemplatekey 和 value 都是 String 序列化日常开发 90% 的场景足够。RestControllerRequestMapping(/api/redis-demo)publicclassRedisDemoController{AutowiredprivateStringRedisTemplateredis;// String GetMapping(/string)publicvoidstringOps(){redis.opsForValue().set(vibeloop:counter:post:10001,42);redis.opsForValue().increment(vibeloop:counter:post:10001);redis.opsForValue().get(vibeloop:counter:post:10001);// 43}// Hash GetMapping(/hash)publicvoidhashOps(){redis.opsForHash().put(vibeloop:user:profile:alice,nickname,Alice_V2);redis.opsForHash().get(vibeloop:user:profile:alice,nickname);redis.opsForHash().hasKey(vibeloop:user:profile:alice,avatar);redis.opsForHash().delete(vibeloop:user:profile:alice,bio);}// List GetMapping(/list)publicvoidlistOps(){redis.opsForList().leftPush(vibeloop:timeline:bob,post:1024);redis.opsForList().leftPushAll(vibeloop:timeline:bob,post:1025,post:1026);redis.opsForList().range(vibeloop:timeline:bob,0,9);// 最近10条redis.opsForList().trim(vibeloop:timeline:bob,0,199);// 保留200条}// Set GetMapping(/set)publicvoidsetOps(){redis.opsForSet().add(vibeloop:post:tags:10001,美食,旅行,摄影);redis.opsForSet().add(vibeloop:post:tags:10002,美食,科技);redis.opsForSet().intersect(vibeloop:post:tags:10001,vibeloop:post:tags:10002);// [美食]}// ZSet GetMapping(/zset)publicvoidzsetOps(){redis.opsForZSet().add(vibeloop:hot:posts:24h,post:1024,50);redis.opsForZSet().incrementScore(vibeloop:hot:posts:24h,post:1024,3);redis.opsForZSet().reverseRange(vibeloop:hot:posts:24h,0,9);// Top 10redis.opsForZSet().rank(vibeloop:hot:posts:24h,post:1024);}}9.3 Lettuce 连接池调优参数默认值VibeLoop 建议说明max-active816并发请求数 业务线程数适当调大max-idle88空闲时保留的连接避免频繁创建销毁min-idle02预创建 2 个连接应对突发流量max-wait-1无限2000ms等待超时后抛异常避免线程堆积10. 源码走读Lettuce 连接池 borrowObject当 VibeLoop 的 Web 线程执行redis.opsForValue().get(key)时底层发生了什么核心调用链StringRedisTemplate.getConnection()→ 委托给RedisConnectionFactoryLettuceConnectionFactory.getConnection()→ 调用GenericObjectPool.borrowObject()borrowObject()先检查idleObjects链表是否为空有 idle从链表头部取出执行testOnBorrow验证默认关闭验证通过则返回无 idle触发makeObject()创建新连接makeObject()→RedisClient.connectAsync()→ Netty 建立 TCP 连接 →AUTH认证 → 包装为StatefulRedisConnection回到borrowObject调用factory.activateObject()订阅连接事件应用拿到连接执行 Redis 命令如GET vibeloop:user:profile:10001命令执行完毕后returnObject(conn)归还连接池关键点Lettuce 的StatefulRedisConnection本身是线程安全的。连接池的作用不是解决线程安全问题而是限制并发连接数、复用 TCP 连接以减少建连开销。11. 面试 8 连问Q1Redis 的 String 最大能存多大A512MB。超出会报错ERR value is out of range。实际生产建议控制在 10KB 以内——单 key 过大影响网络传输、阻塞主线程、触发raw编码浪费内存。Q2ZSet 底层用了什么数据结构为什么不用红黑树Aziplist小数据或 skiplist dict大数据。跳表比红黑树更适合范围查询O(logN) 直接向后遍历实现更简单无需旋转染色且红黑树的树形结构在范围查询时需要中序遍历不如跳表直接。Q3embstr 和 raw 的区别什么情况下 embstr 会变成 rawAembstr 是 44 字节时的一次性分配redisObject 和 sds 连续存储raw 是 44 字节时的两次分配。任何修改操作APPEND、SETRANGE都会触发 embstr → raw 的不可逆转换。Q4Hash 和 String 存对象哪个更好A字段少且需要独立更新的场景Hash 更好HSET 单字段 O(1)String 需要全量序列化。字段多且很少单独更新的场景String 可能更简单。Hash 内部编码切换ziplist → hashtable可能导致内存陡增需关注配置阈值。Q5Redis 为什么用单线程单线程为什么还这么快ARedis 的性能瓶颈从来不在 CPU而是内存和网络带宽。单线程简化了实现无锁、无上下文切换。快的原因纯内存操作 epoll IO 多路复用 精心设计的数据结构。6.0 引入的 IO 多线程只处理网络读写命令执行仍为单线程。Q6List 做消息队列有什么问题ABRPOP弹出后消费者崩溃会导致消息丢失无 ACK 机制不支持消费者组和消息回溯。简单异步任务可用 List生产级消息队列建议用 Redis Stream5.0或 RabbitMQ/Kafka。Q7KEYS *为什么被禁用替代方案是什么AKEYS *遍历整个 keyspace时间复杂度 O(N)执行期间整个 Redis 阻塞。生产环境用SCAN游标式渐进遍历每次只返回少量 key对业务无感。SCAN不保证不重复不遗漏需在业务层做去重。Q8Lettuce 和 Jedis 的区别Spring Boot 为什么选 LettuceAJedis 是同步客户端连接非线程安全需配合 JedisPool 使用。Lettuce 基于 Netty 异步驱动StatefulRedisConnection本身线程安全连接天然可共享。Spring Data Redis 2.x 起将 Lettuce 设为默认因为它在高并发下连接管理更优、更适配响应式编程模型。12. 必背速查表数据类型时间复杂度命令时间复杂度注意SET/GET/INCR/DECRO(1)String 核心操作HSET/HGET/HDELO(1)Hash 单字段操作HGETALLO(N)全部 field-value大 Hash 禁用LPUSH/RPUSH/LPOP/RPOPO(1)List 两端操作LRANGE key 0 9O(SN)S偏移量N返回数量SADD/SREM/SISMEMBERO(1)Set 基础操作SINTERO(N*M)N最小集合大小M集合数ZADD/ZREM/ZSCOREO(logN)ZSet 单元素操作ZRANGE key 0 9O(logNM)M返回数量KEYS patternO(N)生产禁用用 SCAN内部编码决策表类型编码触发条件Stringint值可转为 long 且 20 位Stringembstr值 44 字节Stringraw值 44 字节 或 embstr 被修改Hashziplistfield ≤ 512 且 value ≤ 64BHashhashtable超过 ziplist 任一阈值ZSetziplist元素 ≤ 128 且 member ≤ 64BZSetskiplist超过 ziplist 任一阈值Setintset全部元素为整数Sethashtable出现非整数元素ListquicklistLinkedList ziplist 混合所有场景第 1 期到这里。五种数据结构、内部编码、单线程模型、Spring Boot 集成、Lettuce 连接池源码——这些是 Redis 面试的「地基」。下一期我们进入 VibeLoop 的流量护盾缓存策略、穿透/雪崩/击穿的彻底解决以及双写一致性这个面试修罗场。