ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis Hash与List实战:选型、底层原理与Spring Boot整合

Redis Hash与List实战:选型、底层原理与Spring Boot整合 Redis系列我写到第三篇了这篇标题是 redis-3-Hash-List按计划把 Hash 和 List 两种数据类型一次讲透。前两篇已经铺垫过基础概念和 String 类型但说实话项目里真正让新人懵的往往不是 String而是 Hash 和 List都是 Spring 里一个 opsForXxx一个用来存对象一个用来做队列到底谁是谁什么时候用哪个底层又是什么结构很多人是一团浆糊的。这篇文章我不打算按官方文档的目录平铺而是按我实际干活的经验来先讲选型判断再逐个拆 Hash 和 List 的底层与命令然后给一套从安装到 Spring Boot 整合的完整实操路径最后把面试题和缓存治理里最容易踩的坑统一收拾一遍。适合三类人刚学 Redis 不知道数据类型怎么用的新手、准备面试想补底层细节的同学、以及在做缓存设计时纠结该用 Hash 还是 List 的后端开发。1. 先把选型想清楚Hash与List在数据类型版图里的位置1.1 Redis数据类型全景速览Redis 对外暴露的数据类型官方口径是 5 种String、Hash、List、Set、ZSet有序集合。后面又陆续加了 Bitmap本质是 String 上的位图操作、HyperLogLog基数估算同样挂在 String 语义下、Geo地理坐标底层基于 ZSet 实现、Stream5.0 开始的消息队列专用结构。所以面试的时候别只说Redis 有五种数据类型能提到 Stream 和 Geo说明你对新版本有跟进这是第一层加分项。我自己的分类习惯是String 解决一个值怎么存Hash 解决一个对象怎么存List 解决一串有序的数据怎么存Set 解决去重和集合运算ZSet 解决带分数的排序场景。这篇只展开 Hash 和 List但你在心里要有这张大图不然很容易出现两个类型都能实现需求但我选错了的情况。1.2 两种结构两种思维模型Hash 和 List 的定位差异我用大白话总结Hash 是一个扁平的小型数据库表。一个 key 对应一张表field 是行主键value 是字段值。List 是一条可以在两端操作的有序队列。可以从左边放右边取也能按区间截取。生活化类比Hash 像一个带抽屉的文件柜。你打开一个柜子key里面有多个抽屉field每个抽屉里放一个小物件value。你只想改其中一个抽屉的时候完全不需要把整个柜子搬出来。List 则像一条传送带工人从左侧放货LPUSH、右侧取货RPOP需要时还能从中间抽查某一节LINDEX/LRANGE。这两个类比几乎能覆盖 90% 的选型场景对象字段经常部分更新 → Hash数据有先后顺序、要按队列消费 → List。1.3 接到需求怎么判断用哪种我一般用下面这张表快速做决策你的需求推荐类型为什么缓存一个对象的多个字段经常只改其中几个Hash字段级更新避免整体读写需要按顺序保存、消费消息或任务List两端操作 O(1)还支持阻塞读取只需要缓存一个字符串或数字String命令最少语义最清晰需要去重、交集并集Set专门的集合运算能力需要按分数排序、取 TopNZSet跳表加分数的天然排序举个例子。产品提了个需求商品详情页展示标题、销量、库存运营要能单独改标题销量要实时递增。如果你图省事用 String 存整个 JSON改标题就得读出来反序列化、改字段、再序列化写回去并发下还容易互相覆盖。换成 Hash 就很自然HSET 改标题HINCRBY 加销量都是单字段操作。这就是 Hash 在缓存场景里最值钱的地方。2. Hash类型底层结构、核心命令与实战用法2.1 底层结构小体积的listpack大字段的hashtableHash 底层不是一上来就上 hashtable 的Redis 为了省内存做了很经典的两级设计。数据量小、字段值也小的时候Hash 用一段连续内存把 field 和 value 依次排布老版本叫 ziplistRedis 7.0 之后换成了 listpack。这两种结构本质上都是紧凑排列的字节数组好处是几乎不浪费内存坏处是查找需要线性扫描字段多了会变慢。当字段数超过阈值或者某个 value 的长度超过阈值Redis 就会把整个 Hash 从紧凑结构转换为真正的 hashtable数组加链表读写变成 O(1)但每个节点都有额外的指针元数据内存开销变大。这里面有一个很多人背错的点转换阈值在不同版本里不一样。以我常用的配置为例Redis 版本小数据量编码字段数阈值配置字段值阈值配置6.x 及以前ziplisthash-max-ziplist-entries默认 512hash-max-ziplist-value默认 647.xlistpackhash-max-listpack-entries默认 128hash-max-listpack-value默认 64 bytes注意7.x 把字段数阈值从 512 改成了 128。很多人拿着旧文档背512到了新版本就露馅。最靠谱的做法是连上你自己的实例执行CONFIG GET hash-max-*看一眼真实值别凭记忆。2.2 核心命令对照表与参数语义Hash 的命令不多但细节坑不少。我做成速查表命令作用注意点HSET key field value [field value ...]设置一个或多个字段一次设多个字段减少 RTTHGET key field获取单个字段字段不存在返回 nilHMSET/HMGET批量设置/获取4.0 后 HSET 已支持多字段HMSET 基本可弃用HGETALL key返回所有 field 和 value字段多时是大坑别在生产大规模用HDEL key field ...删除字段支持一次删多个HEXISTS key field判断字段是否存在常用于幂等判断HINCRBY key field increment字段值自增字段必须是整数HINCRBYFLOAT key field increment浮点自增用于金额、评分等HKEYS/HVALS取所有字段 / 所有值同样注意大 Hash 场景HLEN key取字段数量O(1)HSCAN key cursor增量遍历遍历大 Hash 只推荐这个方法我自己实际开发中最常用的组合是 HSET HGET HINCRBY HSCAN。HSET 和 HGET 解决读写HINCRBY 解决计数器类需求HSCAN 用于排查或者数据迁移。2.3 实战场景对象信息缓存与购物车最典型的场景是用户信息缓存。假设 key 是user:1001里面存 name、age、email、vipLevel 这些字段。用户改昵称的时候HSET user:1001 name zhangsan不用像 String 方案那样先把整个对象读出来再写回去。这里有个容易被忽略的好处StringJSON 的写操作天然是读-改-写三步在高并发下如果没有加锁两个请求同时改不同字段也会互相覆盖Hash 字段级操作是原子的改 name 不影响 email。购物车也是 Hash 的高频场景。key 用cart:{userId}field 用 SKU IDvalue 用数量HSET cart:1001 sku_001 2 HINCRBY cart:1001 sku_001 1加购就是 HINCRBY 加一改数量就是 HSET删一项就是 HDEL。字段天然按商品维度隔离不需要额外设计。还有一类用法容易被忽略把 Hash 当配置中心用。我做过一个活动页配置缓存一个 key 装整个页面的配置每个配置项一个 field。运营改一个按钮文案直接 HSET 改一个字段比整个 JSON 覆盖快得多也安全得多。2.4 内存优势、字段过期与Big Key警告Hash 的内存优势在字段值都很小的时候非常明显。官方博客给过一个经典的对比案例100 万个字段放一个 Hash 里比拆成 100 万个独立的 String key 能省下非常可观的量级具体数值会随字段长度浮动但数量级的差距是真实的。原因就是小 Hash 用 listpack 这种连续内存结构几乎没有额外指针开销而 100 万个 String key 光 key 本身的 dict 结构和元数据就是很大一笔开销。但 Hash 也不是没有坑主要有三个第一没有字段级过期。Redis 7.4 之前Hash 的字段不能单独设 TTL整个 key 过期是全量的。社区一直呼吁字段级过期Redis 7.4 才开始有实验性的 field TTL 能力生产环境不建议依赖。替代方案是把过期时间塞进 value 里读取时自己判断或者老老实实整个 key 过期后重建。第二字段数量膨胀会变成 Big Key。一个 Hash 字段数几万、几十万甚至上百万就是标准的 Big Key。这时候执行 HGETALL 会让 Redis 在事件循环里长时间阻塞其他请求全部排队引发连锁超时。凡是看到 HGETALL、HKEYS、HVALS 这类一次性返回全量数据的命令都要先确认这个 Hash 到底有多大。第三切换了编码之后内存优势会缩水。字段数超过阈值变成 hashtable 后指针开销上来了这时候 Hash 相比独立 String key 的省内存优势会变小但字段级更新的功能优势还在。3. List类型底层原理、命令细节与消息队列玩法3.1 底层结构quicklist的折中设计List 的底层在 Redis 3.2 之后统一为 quicklist。quicklist 是双向链表和压缩列表的混合体整体是一个双向链表但每个节点内部是一小块连续内存老版本是 ziplist7.0 后换成 listpack。为什么这么设计如果只用简单的双向链表每个节点单独 malloc内存碎片和指针开销很大而且节点在内存里分散缓存命中率很低。如果只用数组两端插入删除要整体搬移数据复杂度 O(N)。quicklist 的思路是把元素分成一节一节的车厢每节车厢内部尽量装紧凑车厢之间用指针挂起来。我常打的比方是火车。每节车厢内部是装得整整齐齐的集装箱listpack车厢之间靠挂钩连接。元素少的时候可能整个 List 就一节车厢看起来像一块连续内存元素多了就挂上多节车厢。Redis 还允许你通过配置控制每节车厢的大小比如list-max-listpack-size -2表示每节车厢不超过 8KB。正因为有这层结构List 的两端操作 LPUSH/RPOP 是 O(1)但中间操作 LINSERT、LINDEX 最坏要遍历节点复杂度是 O(N)。很多人以为 List 和编程语言里的数组一样按下标随机访问这是底层认知的误区。3.2 常用命令与常见误用命令作用注意点LPUSH/RPUSH key element [element ...]从左边 / 右边推入返回当前长度可批量推LPOP/RPOP key从左边 / 右边弹出一个空列表返回 nilLRANGE key start stop取区间元素下标是闭区间负索引从尾部算LINDEX key index按下标取元素最坏 O(N)LSET key index element按下标改元素下标越界报错LTRIM key start stop只保留区间内元素经常被误解为删除区间LREM key count element删除匹配元素count 正负控制方向0 删除全部匹配LINSERT key BEFORE/AFTER pivot element在某个值前后插入需要先定位 pivotBLPOP/BRPOP key timeout阻塞弹出timeout 单位秒0 表示永远等最容易出错的是 LTRIM。它的语义是保留 start 到 stop 区间内的元素其余删除和直觉上的删掉这段正好相反。比如要做只保留最近 100 条消息LTRIM news:list 0 99这条执行完列表里就只剩前 100 个元素了。阻塞命令 BLPOP/BRPOP 返回的不是单个值而是一个数组包含 key 和弹出的元素。这在高并发消费场景下要注意解析。3.3 用List实现轻量消息队列List 做消息队列是老传统了经典组合是 LPUSH BRPOP生产者从左边推消费者从右边阻塞弹出。BRPOP 的好处是队列为空时线程挂起等待而不是空轮询打爆 Redis。# 生产者 LPUSH task:queue task-001 # 消费者 BRPOP task:queue 0多个消费者同时 BRPOP 一个 key 时Redis 的弹出操作是原子的每条消息只会被一个消费者取走天然实现了任务分发。但这个方案有个致命细节BRPOP 取到消息之后如果消费者进程崩溃或者处理失败消息就丢了。我早期做任务队列的时候就踩过这个坑一个异步任务取出来还没执行完服务重启任务直接消失。可靠的轻量方案是采用备份队列模式用 BRPOPLPUSH老版本或 LMOVE/BLMOVE新版本把消息从主队列弹出来同时 push 进一个 processing 队列处理成功后再 LREM 删除如果处理失败可以从 processing 队列取回来重试。# 原子地把 task:queue 右边弹出推入 task:processing 左边 BRPOPLPUSH task:queue task:processing 0 # 处理成功 LREM task:processing 0 task-001这个模式比裸 BRPOP 多了一点点复杂度但可靠性和可观测性都上来了。需要强调的是如果消息场景已经复杂到需要消费者组、消息确认、独立消费游标就别再用 List 硬扛了直接上 Redis 5.0 引入的 Stream那是专门为消息队列设计的结构。3.4 和编程语言的List到底差在哪热词里有人问numpy 和 list 比快在哪也有人讨论list 的模拟实现。放在 Redis 语境里我觉得值得把Redis 的 List和编程语言里的 List做一次彻底区分。Redis 的 List 是服务端进程里的数据结构只要连得上 Redis任何语言都能读写同一个 List进程退出数据还在取决于持久化配置。命令执行是单线程的LPUSH 和 BRPOP 天然原子多线程并发读写得心应手不需要像操作本地 LinkedList 那样自己加锁。编程语言里的 ListPython list、Java ArrayList、C vector是进程内的连续数组或者链表。按下标取值 O(1)但跨进程共享、持久化、并发安全这些事全得自己做。复杂度上也要做区分Redis List 的 LPUSH/RPOP 是 O(1)LRANGE 取区间是 O(N)LINDEX 按下标找也是 O(N)而 Java ArrayList 按下标取是 O(1)中间插入是 O(N)。所以拿 Redis List 当随机访问数组用是方向性错误。至于 Numpy 比 Python 原生 list 快核心是 NumPy 的数据在连续内存里做向量化运算少了解释器逐元素解释的开销。这和 Redis 用 listpack / quicklist 追求紧凑内存布局是同一个道理数据结构的效率很大程度上取决于内存怎么排布。4. 实操过程从安装部署到Spring Boot整合4.1 环境准备三分钟跑起一个Redis很多人卡在第一步我先给一条 Linux 上的干净安装路径。从官网下载源码包后用 tar 解压进入源码目录依次执行make make install PREFIX/usr/local/redis编译前确保系统有 gcc 和 make。装完后把 /usr/local/redis/bin 加进 PATH用 redis-server 启动redis-cli ping 验证返回 PONG 就说明通了。想省事就直接用 Docker我日常本地开发都是这么起的docker run -d --name redis -p 6379:6379 redis:7.2 --requirepass yourpassword连接测试docker exec -it redis redis-cli -a yourpassword pingWindows 环境要特别注意Redis 官方不维护 Windows 原生版本。我不建议去跑那些第三方老旧的 exe 移植版更稳妥的是两条路要么用 WSL2 装 Ubuntu 后在 Linux 子系统里跑要么用兼容层方案。用 WSL 时如果遇到wsl --list --online报 0x80072ee7 这种网络错误通常和网络连通性或 WSL 版本太旧有关先把 WSL 更新到最新版再重试别纠结报错本身。4.2 可视化客户端挑一个顺手的别在生产乱点可视化客户端的选择我实际用下来比较推荐这几个Redis 官方出的 RedisInsight 免费且功能全支持内存分析、慢日志、图形化查看 key 的TTL和数据结构适合刚开始学习的时候直观理解 Hash 和 List 长什么样。老牌的 Redis Desktop ManagerRDM老用户多社区版也能用。Another Redis Desktop ManagerARDM界面更现代支持集群和 SSH 隧道团队协作场景比较方便。工具只是辅助真正重要的是红线意识。生产环境我基本只用 redis-cli而且严禁在可视化工具里手滑执行几条危险命令KEYS *在 key 数量大的时候会让 Redis 单线程卡死FLUSHALL更是直接把所有数据清掉。如果你只是排查问题优先用SCAN和HSCAN这类增量命令。4.3 Spring Boot整合Hash与ListSpring Boot 里整合 Redis最简单的方式是直接用 StringRedisTemplate它底层帮你把 key、field、value 都按字符串处理避免了一堆序列化问题。代码示例Autowired private StringRedisTemplate redisTemplate; // Hash保存用户对象字段 public void saveUser(User u) { String key user: u.getId(); MapString, String fields new HashMap(); fields.put(name, u.getName()); fields.put(age, String.valueOf(u.getAge())); redisTemplate.opsForHash().putAll(key, fields); } // Hash读取单个字段 public String getUserName(Long userId) { return (String) redisTemplate.opsForHash().get(user: userId, name); } // List往队列左侧推任务 public void pushTask(String taskId) { redisTemplate.opsForList().leftPush(task:queue, taskId); } // List从右侧阻塞弹出任务 public String popTask() { return redisTemplate.opsForList().rightPop(task:queue, 5, TimeUnit.SECONDS); }注意rightPop(key, timeout, unit)这个方法对应的是 Redis 的 BLPOP/BRPOP 阻塞语义队列为空时会阻塞等待最多 5 秒超时返回 null。如果你在消费端循环调用要处理好 null 的情况别把超时当成正常业务结果。4.4 序列化问题乱码Key是怎么来的很多人在 Redis 客户端里看到自己的 Hash key 或者 field 前面有一串类似\xAC\xED\x00\x05t的乱码第一反应是 Redis 坏了其实这是 Spring 默认序列化器的问题。Spring Data Redis 的 RedisTemplate 默认用的是 JdkSerializationRedisSerializer会把 Java 对象序列化成二进制字节流所以 key、field、value 看起来全是乱码。这样带来的实际问题不止是难看key 占用内存变大、跨语言无法读取、排查问题费劲。解决办法有两个方向。最简单的是直接用 StringRedisTemplatekey 和 value 都是字符串你自己在业务层完成对象和 JSON 的互转。如果需要自动序列化就自定义 RedisTemplate把 key 和 hash key 设成 StringRedisSerializer把 value 和 hash value 设成 JSON 序列化器Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有个我踩过的坑很多人只改了 key 和 value 的序列化器忘记 setHashKeySerializer 和 setHashValueSerializer结果 Hash 的 key 和 field 是正常的value 里却出现一串乱码。配置序列化器一定要四个都设置缺一不可。5. 问题排查、缓存治理与进阶认知5.1 高频面试题里的Hash和List考点面试里关于这两类数据结构的高频题我整理一下参考答案的要点为什么 Redis 快核心是内存操作加单线程 IO 多路复用加高效数据结构。单线程的好处是不用考虑锁竞争IO 多路复用让网络事件处理很高效数据结构的精心设计让内存和 CPU 开销都可控。Hash 的底层结构是什么要能答出小数据量用 ziplist 或 listpack大数据量用 hashtable以及版本间的阈值差异。主动带出7.x 默认 128 个字段或 64 字节 value这种细节面试官会知道你真的看过配置。List 的底层结构是什么quicklist双向链表套压缩节点。7.0 后的核心是 listpack 节点配置项是 list-max-listpack-size。Hash 和 Set 的区别Hash 是一个 key 下的 field-value 映射field 不能去重但 value 任意Set 是无序去重的集合适合交集并集运算。两者名字容易混但语义差很远。HGETALL和LRANGE 0 -1有什么问题都是 O(N) 的大范围读取在 key 很大时会阻塞单线程 Redis应该用 HSCAN 和分段 LRANGE 替代。5.2 分布式锁和Hash的关系热词里有人问redis 分布式锁和后端 hash 和 linkhash这里必须把分布式锁和 Hash 的关系说清楚。最基础的分布式锁实现是这条命令SET lock:order:1001 client-001 NX PX 30000NX保证只有 key 不存在时才能写入PX设置过期时间防死锁value 放客户端唯一标识以便释放时校验归属。释放锁要小心翼翼先比较 value 再删除两步之间必须用 Lua 脚本保证原子性否则可能删掉别人刚持有的锁。if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end那 Hash 在这里扮演什么角色简单锁用 String 就够了但讲到可重入锁Hash 就登场了。Redisson 的 RLock 底层用的就是 Hash 结构key 是锁名称field 是客户端标识:线程IDvalue 是重入次数。同一个线程重复 lock就在这个 Hash 上做一次 HINCRBY 计数unlock 时递减减到 0 才 HDEL 删除整个 key。所以答案是分布式锁有两种实现路径裸 SET 用 String可重入锁用 Hash。面试里能把这个细节讲清楚分量很足。5.3 缓存治理中的几个坑缓存治理是一个系统工程围绕 Hash 和 List 我挑几个最值得说的。Key 设计必须有规范。我见过最乱的生产环境key 全是随意拼接的没有任何前缀语义。Redis 官方也建议 key 用业务:对象:ID的层级方式比如user:info:1001、cart:1001:items。Hash 的 field 命名同理要能一眼看出含义。缓存穿透、击穿、雪崩是绕不开的三座山。穿透用空值缓存或布隆过滤器击穿用互斥锁或逻辑过期雪崩最有效的办法是过期时间加随机值避免大量 key 同时失效。这些方案和 Hash/List 没有直接绑定但如果你用 Hash 缓存对象列表批量重建时要注意避免瞬时对后端数据库造成压力。大 key 治理要前置。Hash 的 field 无限膨胀比如把用户会话全塞进去、List 被疯狂 LPUSH 导致长度几十万都是典型的大 key 来源。治理手段无非三种拆 key、换结构、设置上限配合定期清理。比如 List 做时间线时每次写入后用 LTRIM 只保留最近 N 条这是最简单有效的预防手段。5.4 主从与集群环境下的特别提醒热词里有 docker 安装 redis 主从、kubesphere 搭建 redis 这类部署话题。篇幅原因我不展开 K8s只讲两个和 Hash/List 直接相关的部署注意事项。主从复制的时候Hash 和 List 的数据是通过 RDB 快照加增量命令传播来完成同步的从库可以完整读到这些结构也能执行只读命令。但要注意Big Key 的主从同步会放大网络开销一个几十 MB 的 Hash 在初次同步或者断线重连时会让主从之间的网络瞬间打满。所以大 key 治理不只是单机问题直接影响主从架构的稳定性。集群环境下有一个名字特别容易混淆的概念Hash Slot。Redis Cluster 根据CRC16(key) % 16384把 key 分配到不同槽位这里的Hash和 Hash 数据类型没有任何关系。你可能遇到这种需求想把一个用户的所有相关 key 放在同一个节点上做批量操作那就用 Hash Tag 语法把要参与哈希计算的部分放进花括号里比如{user:1001}.base_info和{user:1001}.detailRedis 只会对花括号里的内容做 CRC16从而保证两个 key 落在同一槽位。我自己开发时就是用这个思路给用户维度的 Hash List 组合做集群亲和性设计实测下来对批量读取的延迟改善很明显。主从的最小验证方式用 Docker Compose 写两个服务就能跑起来version: 3.8 services: redis-master: image: redis:7.2 command: [redis-server, --appendonly, yes] redis-slave: image: redis:7.2 command: [redis-server, --replicaof, redis-master, 6379] depends_on: - redis-masterdocker compose up -d起来后在主库写入一个 Hash到从库HGETALL能查到就说明主从复制对 Hash 的同步链路是通的。最后分享一点我自己的体会。写这篇之前我重新翻了一遍当年做活动页缓存时的老代码最开始用 String 存整个 JSON运营改一个按钮文案就把后端折腾够呛后来改成 Hash 一个配置项一个 field改哪就 HSet 哪刷新成本几乎降为零。后来做异步任务队又踩了一次裸 BRPOP 丢消息的坑才老老实实把 LMOVE 加备份队列的套路用起来。数据结构这东西背一百遍文档不如在生产环境被坑一次记得牢。如果你正在学 Redis我建议先把 Hash 和 List 的底层阈值参数、常用命令做成速查卡然后在本地 Docker 里把大 key、阻塞、序列化这些坑一个个亲手踩过去踩完你就不会再纠结选型了。
返回列表