Java实习生必读:Redis核心知识实战,缓存三大问题与分布式锁 带实习生三年多我交出去的第一步任务永远是让他们把公司项目的 Redis key 全部导出来统计前缀、过期时间、大 key 分布。有人觉得枯燥有人却能从一份 key 清单里把缓存的业务模型反推出来。后来观察下来能不能独立处理线上 Redis 问题差距从这一步就开始拉开了。这篇文章不讲源码不聊底层数据结构只聚焦 Java 实习生在真实工程环境里绕不开的 Redis 知识数据类型对应哪种业务模型缓存穿透击穿雪崩到底怎么解分布式锁从简单到可靠经历了什么持久化与内存淘汰如何选型以及 Java 客户端接入时最容易被坑的序列化、超时、连接池问题。看完之后不说让你成为 Redis 专家至少项目里再碰到 Redis 超时、缓存不生效、锁失效这类事你能往正确方向排查而不是乱猜。1. 实习生第一次接触 Redis 时最需要建立的是“它到底在系统里扮演什么角色”很多实习生的第一步是从网上找一份 Redis 命令手册然后启动一个 redis-server敲几个命令陷入沉思“这不就是个 map 吗为什么要单独拿出来用”ConcurrentHashMap 只能在单个进程内部共享数据。一旦服务拆成多个节点或者同一套代码部署了多个副本每个进程的 Map 就各自独立了。而 Redis 是独立进程通过 TCP 对外提供读写接口天然成为多个应用节点之间的“共享状态容器”。这才是它在分布式系统里无法被本地缓存替代的根本原因。工程上Redis 在 Java 系统里主要出现在四个位置缓存层商品详情、用户信息这类读多写少的数据先查 Redis命中就不打到数据库。这是最常见的角色。分布式锁多个应用实例同时抢同一份资源时用 Redis 的原子命令做跨进程互斥。计数器与限流库存预减、点赞数、访问量这类需要原子自增的场景用 INCR/DECR 比数据库行锁成本低得多。临时状态存储验证码、登录 token、短期会话生命周期短且需要自动过期正好匹配 Redis 的 TTL 机制。实习生最容易犯的错误是只把 Redis 当成缓存来背概念却忽略了它的网络模型和单线程执行模型。Redis 的服务端是单线程处理命令所以每个命令都是原子的。这也决定了使用它的方式能一次命令解决的事不要拆成多次能用 Pipeline 批量发的不要循环单发。这两条习惯直接影响线上性能表现。理解了 Redis 是干什么的再往下看具体知识点就不会觉得它们是一堆孤立命令了。2. 数据类型不是八股文每种数据结构都能对上具体的业务模型网上关于 Redis 数据类型的文章大多是“String 是字符串Hash 是哈希List 是列表”这种解释。对实习生来说背五张命令表不如搞清楚一件事这个类型在真实业务里到底解决什么问题。2.1 String不只是存字符串还是计数器String 能存的不仅是字符串数字也是按字符串存的。使用 INCR、DECR、INCRBY 这类命令时Redis 会把字符串解析成数值再原子增减。秒杀预减库存、视频播放量、点赞数都是直接调用然后判断返回值。// 扣减库存返回扣减后的剩余值 Long remain redisTemplate.opsForValue().decrement(stock:sku:1001, 1); if (remain 0) { // 超卖需要回滚并记录 redisTemplate.opsForValue().increment(stock:sku:1001, 1); }这条命令每秒能扛几十万次操作比数据库 update stock stock - 1 再判断 affected rows 轻量太多。但要注意Redis 计数器只能做前置判断真正的订单扣减还是以数据库事务为准。另外 String 还承担了分布式锁的载体SETNX、SETEX 都在这个类型上实现。后面专门展开。2.2 Hash对象的最佳形态Java 里一个对象有多个字段在 Redis 里最直观的落法就是 Hash。key 存对象 IDfield 是属性名value 是属性值。商品详情、用户资料、购物车都能用 Hash 表达。HashOperationsString, String, Object ops redisTemplate.opsForHash(); ops.put(cart:user:123, sku_1001, 2); ops.put(cart:user:123, sku_1002, 1); MapString, Object cart ops.entries(cart:user:123);Hash 跟 String JSON 的最重要区别在于Hash 支持单独修改某个字段而不需要反序列化整个对象。比如修改用户头像字段Hash 只需要 hset 一个 fieldString JSON 则要先 get再反序列化改字段再序列化再 set高并发下容易产生覆盖问题。不过 Hash 也不是没有缺点。如果字段数量膨胀到几千个hgetall 会一次性拉大量数据容易产生大 key。工程上建议字段少、访问频繁的对象用 Hash字段多的对象还是 String JSON 更适合。2.3 List可以是队列也可以是栈List 的 LPUSH/RPOP 组合就是典型的生产者消费者队列。在 Redis 还没引入消息队列模块之前很多老项目就是用 List 做异步任务的。现在虽然更推荐 Stream但 List 依然有它的价值实现简单天然阻塞版本 BLPOP 谁都会用。// 生产者 redisTemplate.opsForList().leftPush(task:queue, taskId); // 消费者阻塞获取 String taskId redisTemplate.opsForList().rightPop(task:queue, 10, TimeUnit.SECONDS);实习生要知道的坑在于使用 List 做队列消费端宕机时数据会丢。没有 ACK 机制LPOP 之后数据就没了。如果业务要求不丢数据至少得用 Stream或者干脆上 MQ。2.4 Set去重与集合运算Set 无序、元素唯一适合做标签系统、好友关系、抽奖池。两个 Set 之间做交集、并集、差集一条命令就能完成。比如“共同关注”功能就是用 sinter 同时传入两个用户的关注集合。SetString common redisTemplate.opsForSet().intersect(follow:user:1001, follow:user:1002);另外 SETNX 的去重能力还常用于“用户是否已参与活动”这类判断虽然严格来说用 String 也能做但 Set 天然支持多个活动一个 key 内的去重写起来更整洁。2.5 ZSet排行榜和延时队列的底层基础ZSet 每个成员附带一个 score按 score 排序。排行榜是最直接的应用score 是分数member 是用户 IDZREVRANGE 取前 N 名。redisTemplate.opsForZSet().incrementScore(rank:week:2024W45, userId, 1); SetZSetOperations.TypedTupleString top redisTemplate.opsForZSet().reverseRangeWithScores(rank:week:2024W45, 0, 9);ZSet 还有一个冷门但实用的玩法延时队列。score 存执行时间戳轮询 zrangebyscore 取出 score 小于当前时间的数据执行。很多简单场景的延迟任务用 Redis ZSet 就够不需要引入消息中间件。2.6 扩展类型Bitmap、HyperLogLog、Geo这三个类型是实习生的加分项。Bitmap 适合签到、在线状态的位图存储HyperLogLog 适合 UV 统计牺牲极小精度换来极低内存Geo 适合附近的人、门店距离排序。它们各自命令不多但能体现你对 Redis 的了解不是停留在五种基础类型上。类型选型没有绝对标准但有原则先想清楚业务操作是什么再选数据形态。用错类型最常见的后果就是代码里出现大量 get、set、delete 轮询把一个本该用一条命令完成的操作拆成了几十次网络请求。3. 缓存三大问题穿透、击穿、雪崩面试会问上线也会踩这是实习生面试必问、工作也容易碰上的问题组。我把三个问题放在一起讲因为它们的现象和解决方案经常被混淆。3.1 缓存穿透查了个不存在的值用户请求一个数据库中也不存在的数据比如商品 ID 是 -1缓存里自然没有请求直接打到数据库。如果这种请求是恶意的、大量的数据库会被无效查询压垮。核心解法有三种缓存空值查询数据库发现不存在也在 Redis 里放一个空结果过期时间设短比如 30 到 60 秒。代码上要区分“缓存命中空值”和“缓存未命中”。布隆过滤器启动时把存在的 ID 全量加入布隆过滤器请求先过过滤器不存在的直接拒绝。缺点是布隆过滤器有误判率且需要保证数据更新时同步维护。参数校验ID 负数、超长、非法字符在 Controller 层直接拦截。这一步成本最低很多穿透事故的根因就是没做基础校验。我见过最离谱的案例是实习生上线后日志里全是 key null 的空值查询因为拼接缓存 key 时用了 null 字符串。参数校验不是可有可无。3.2 缓存击穿热点 key 过期的一瞬间某个热点 key 过期时大量并发请求同时发现缓存 miss全部涌向数据库。和穿透的区别是穿透是查不存在击穿是查一个存在但刚过期的热点数据。常用解法互斥锁缓存 miss 时先加分布式锁只有一个线程能去查库并回填缓存其他线程等待后重新查缓存。实现简单代价是首次 miss 有短暂阻塞。逻辑过期缓存里不设置 TTL而是存一个过期时间戳。读取时发现逻辑过期异步去刷新缓存旧数据继续返回。适合允许短暂脏读、但必须扛住高并发的场景。// 互斥锁思路 String lockKey lock:hot: id; String lockValue UUID.randomUUID().toString(); try { Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 查数据库、回填缓存 } else { Thread.sleep(50); return redisTemplate.opsForValue().get(key); } } finally { if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } }互斥锁代码写起来不难难在释放锁的判断后面分布式锁那节会具体讲为什么不能直接 delete。3.3 缓存雪崩大量 key 同一时间过期雪崩覆盖面更广现象是大量 key 集中过期导致缓存命中率骤降数据库瞬时压力飙升。常见原因有设置了相同的过期时间、Redis 实例宕机。解法对应为过期时间加随机值比如 30 分钟加 0 到 300 秒随机数避免集中失效。多级缓存本地缓存Caffeine再兜一层Redis miss 时先查本地缓存减少直击数据库的请求量。高可用Redis Sentinel 或 Cluster避免单点故障导致全局雪崩。三种问题经常一起出现一个接口数据量大、存在脏数据、热点集中过期三个条件同时满足才是最头疼的。排查时先看监控里缓存命中率断崖下跌发生在哪个时间点再对 key 列表判断是哪一类问题。4. 分布式锁从“能用”到“可靠”差的不仅是几行代码很多实习生花了很多时间研究 synchronized到写分布式锁时会用 SETNX 就觉得自己会了。但线上锁失效导致的超卖、重复执行、死锁问题恰恰都在“以为会了”的细节里。4.1 最基础版本SETNX EXPIREBoolean ok redisTemplate.opsForValue().setIfAbsent(lockKey, value); if (ok) { redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { redisTemplate.delete(lockKey); } }这个版本的问题一眼就能看出来如果 set 成功之后、expire 之前进程崩了锁就没有过期时间直接死锁。虽然概率低但不能靠概率写生产代码。4.2 改进SET 命令带上过期时间Redis 2.6.12 之后SET 命令支持 NX 和 EX 参数同时设置一条命令保证原子性。Spring Data Redis 里的 setIfAbsent(key, value, timeout, unit) 就是干这个的。Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);锁的问题是加锁和解锁不是一对天然原子操作。如果线程 A 执行业务超过 30 秒锁自动过期了线程 B 拿到锁开始执行。这时 A 执行完finally 里 delete 锁会把自己加的锁释放掉——实际上释放的是 B 的锁。这就是为什么要给锁一个唯一标识释放前先比较。我上面缓存击穿示例里已经写了雏形if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); }比较和删除之间仍然有窗口期最简单的做法是用 Lua 脚本让“比较删除”原子执行。Spring Data Redis 支持直接执行 Lua 脚本但大多数时候你不需要手动写——Redisson 已经把整套逻辑封装好了。4.3 实用方案Redisson 的看门狗机制Redisson 是 Java 生态里最主流的 Redis 客户端之一它的分布式锁解决了两个核心问题自动续期默认锁超时 30 秒拿到锁的线程如果还没执行完后台会每 10 秒自动续期一次。业务崩了续期逻辑也会停止锁最终自动释放。释放安全内部通过 Lua 脚本比较持有者标识再删除不会误删别人的锁。使用方式非常简单RLock lock redissonClient.getLock(lock:order: orderId); try { if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }实习生要学会的是Redisson 也不是银弹。如果 Redis 主节点宕机锁数据还没有同步到从节点另一个节点可能仍然拿到锁。这种极端情况下需要 Redlock 算法但工程里大多数业务对“锁丢失”的容忍度没那么低。理解 Redisson 解决的是什么、没解决什么比背 Redlock 七步流程更重要。另外要知道同一把锁在不同线程里不是可重入的除非用 Redisson 的 getLock。用 synchronized 的习惯带入分布式锁最容易踩的坑就是两个不同方法里用了同一个 key导致互相把自己的锁释放掉。5. 持久化和内存淘汰Redis 作为缓存和存储的底线实习生最容易忽略的知识点就是持久化。因为本地开发时 Redis 崩了重启数据丢了也就丢了但线上 redis 里存着用户 session、库存数据宕机恢复后的数据一致性是大事。5.1 RDB定期快照RDB 是把内存数据定期保存为二进制快照文件文件小、加载快适合做备份和灾难恢复。缺点是两次快照之间的数据会丢。默认配置里900 秒内有 1 次写操作、300 秒内有 10 次写操作、60 秒内有 10000 次写操作就触发快照。RDB 生成时用 fork 子进程内存充足时问题不大但大内存服务器 写频繁时fork 开销不能忽略。线上遇到 Redis 突然卡顿有时候就是 RDB 触发了。5.2 AOF追加写日志AOF 记录每一条写命令按执行顺序追加到文件。数据安全性高可以通过 appendfsync 策略控制同步频率always 每次写都同步磁盘最安全但性能最差everysec 每秒同步最多丢一秒数据工程里最常用。AOF 文件会越来越大需要定期 rewrite 压缩。Redis 4.0 之后默认开启混合持久化RDB 作为基线快照AOF 记录增量命令兼顾加载速度和安全性。实习生需要掌握的实际操作是正确配置和排障时的判断思路# redis.conf 核心配置 appendonly yes appendfsync everysec auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb如果线上同时开启 RDB 和 AOF重启时 Redis 会优先加载 AOF。记住这个顺序你排障时会快很多。5.3 内存淘汰策略数据满了怎么办Redis 默认配置 maxmemory 不设上限但生产环境必须设置否则内存被撑爆操作系统会杀掉 Redis 进程。设置之后数据达到上限时按照驱逐策略处理。常用策略noeviction达到上限后写请求直接报错防止数据意外丢失适合不能丢数据的业务。allkeys-lru从所有 key 里淘汰最近最少使用的最常用。volatile-lru只从设置过过期时间的 key 里淘汰适合冷热数据分离明显的场景。allkeys-lfu / volatile-lfu按访问频率淘汰适合有稳定访问特征的数据。选策略的思路是先问这些数据丢不丢得起。缓存数据丢得起选 allkeys-lru有状态数据丢不起选 noeviction同时做好内存监控和预警。maxmemory 4gb maxmemory-policy allkeys-lru我经常让实习生做的事是在压测环境把 maxmemory 调小观察驱逐发生时的日志和命中率变化。只有亲眼看到 key 被淘汰、缓存命中率掉下去才会真正明白驱逐策略为什么重要。6. Java 客户端与 RedisTemplate序列化、连接池、超时三个隐形坑Java 接入 Redis大部分项目用的是 Spring Data Redis底层默认客户端是 Lettuce。实习生写代码最容易出现的问题不在 Redis 端而在客户端封装和序列化环节。6.1 序列化器决定你能不能用肉眼读懂缓存Spring Data Redis 的 RedisTemplate 默认使用 JdkSerializationRedisSerializer直接把对象序列化成二进制。问题在于存进去的是二进制乱码redis-cli 里看不出来。Redis 里存的 key 会带一串反序列化前缀导致用 StringRedisTemplate 去 get 时查不到。对象结构变更后反序列化容易报错。工程里最普遍的做法是统一使用 JSON 序列化。推荐配置用 GenericJackson2JsonRedisSerializer或者更精细化的 Fastjson2RedisSerializer / Jackson 自定义序列化器。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializerObject serializer new Jackson2JsonRedisSerializer(Object.class); template.setKeySerializer(RedisSerializer.string()); template.setValueSerializer(serializer); template.setHashKeySerializer(RedisSerializer.string()); template.setHashValueSerializer(serializer); return template; }换序列化器最麻烦的是老数据兼容问题。上线前要先评估存量 key 的可读性必要时通过临时脚本把数据迁移一轮。除非是全新项目不然不要轻易切换序列化方式。6.2 RedisTemplate 和 StringRedisTemplate 别混用StringRedisTemplate 的序列化器默认是 String存进去的是纯字符串。RedisTemplate 如果没配置存进去是二进制。同一个 key用 StringRedisTemplate set再用 RedisTemplate get会直接 miss。实习生经常在同一个项目里手滑混用两个模板。排查起来很费劲因为代码里看起来都是 set 和 get。我的建议是项目基准统一用 StringRedisTemplate 存字符串特殊对象序列化为 JSON 字符串后也走 StringRedisTemplateRedisTemplate 只在需要 HashOperations 等类型化操作时用。6.3 Lettuce 连接池和超时问题Lettuce 默认是基于 Netty 的连接数可以很少但它不默认开启连接池。如果项目里用 Lettuce 默认配置高并发下可能出现连接创建频繁、命令超时。常见的报错Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这类报错在连接池未生效时最容易出现。需要在配置文件里显式开启连接池spring: data: redis: timeout: 3s lettuce: pool: enabled: true max-active: 16 max-idle: 8 min-idle: 4 max-wait: 3s配对要记清楚max-active 给高并发系统预留多少连接max-wait 是连接池耗尽时调用方的等待时间。max-wait 设太短会直接抛异常设太长又拖慢请求。一般 3 到 5 秒是常见取值。Lettuce 还有一个特性连接是不可变共享的底层命令通过不同 channel 并发发送。这带来吞吐上的好处但也造成排障时不容易直观对应连接。所以出现超时问题时不要只盯着连接数先看慢查询日志和大 key。7. 实习期必做的 Redis 自查清单慢查询、大 Key、热 Key最后这部分是带实习生时我最常给的一句话项目里 Redis 出问题百分之八十不是命令用错了而是数据形态有问题。慢查询、大 key、热 key这三样排查清楚大部分线上问题都能找到源头。7.1 慢查询日志怎么看Redis 会把超过慢查询阈值的命令记录到日志默认阈值 10000 微秒生产环境可以调到 1000 微秒也就是 1 毫秒。CONFIG SET slowlog-log-slower-than 1000 CONFIG SET slowlog-max-len 128 SLOWLOG GET 20看到慢查询后不用急着背命令参数先判断命令本身是不是 O(N) 操作。像 KEYS、HGETALL、LRANGE 一个大范围的 List都有可能是慢查询根源。KEYS 在线上要替换成 SCANLRANGE biglist 0 -1 就是典型的坑。7.2 大 Key 的危害和定位Redis 里单个 key 的 value 过大比如一个 Hash 有几万字段或者一个 Set 有几百万成员会带来三个问题删除或过期时主线程需要释放大量内存阻塞其他命令。读写操作整体耗时飙升。网络传输占用带宽。定位大 key 用 redis-cli 的 bigkeys 扫描redis-cli --bigkeys它会统计每种类型里最大的 key 和最大的元素。要注意bigkeys 是逐个 key 扫描线上注意低峰执行。处理方式就几种压缩对象、拆分成多个 key、Hash 改成多个小 Hash、大 Set 拆成多个子 Set。核心思路就是不要让一个 key 承担过多数据。7.3 热 Key单点高并发导致的“假死”某个 key 的访问频率特别高比如热点商品的缓存 key单台 Redis 实例可能都无法支撑出现延迟飘高或超时。常见的解决思路本地缓存放一层Caffeine 缓存热点数据Redis 只作为兜底。多副本复制同一个 key 散列到多个节点读写时随机选择一个副本。热点 key 拆分把同一个 key 变成 key_1 到 key_N每个副本只承载一部分流量。排查热 key 可以用 redis-cli 的 hotkeys 参数前提是开启了 LFU 淘汰策略maxmemory-policy 为 allkeys-lfu 或 volatile-lfu。7.4 上线前过一遍这份清单我让每个实习生在做完 Redis 相关功能后对照下面几项自查通过之后才允许提测缓存 key 是否带业务模块前缀例如user:info:{userId}避免跟其他模块冲突。有没有设置过期时间过期时间是否加了随机值。有没有在循环里逐条 set/get 同一类 key能否改成 Pipeline。写接口有没有考虑缓存更新策略是基于更新的主动写还是过期后的被动回填。锁是否用到 Redisson还是手写的 SETNX 方案释放锁时是否校验持有者。线上数据是否经过序列化后仍能在 redis-cli 里读懂。这份清单我用了三年每次 review 都能筛出一堆低级问题。实习生如果能主动照着自查成长速度会明显快过别人。毕竟工程能力这种东西不是背会几个命令就有的而是在一次次排查和反思里堆出来的。