
先交代一下背景我工作这几年前前后后用 Redis 做了缓存、分布式锁、排行榜、接口限流、消息队列踩过的坑比看过的资料还多。说实话网上的教程不少但大多东讲一点西讲一点要么只讲 Jedis 原生 API要么把 Spring Data Redis 封装成一坨“黑盒”出了问题完全不知道从哪里排查。这篇博文我会用最直接的方式把 Java 环境里操作 Redis 的完整链路拆开讲透从基础客户端连接到 Spring 环境下的 RedisTemplate 使用再到序列化方案、五种数据类型的实战场景、分布式锁的写法与演进最后把工作中真正遇到过的坑和排查思路一并交代清楚。适合刚上手 Redis 的 Java 开发者也适合用了 Redis 一阵子但对“为什么这么配置”还不太清楚的兄弟。先把结论放这在 Java 和 Spring 环境下操作 Redis 并没有那么玄乎核心就三件事——搞懂数据结构、选对客户端、把序列化和连接配置做对。这三件事搞明白项目里百分之八十的 Redis 问题都能提前消灭掉。1. 环境准备与两种基础连接方式1.1 先把 Redis 跑起来安装与最常用的命令你想操作 Redis第一步肯定是先有一个 Redis 服务。开发环境推荐直接用 Docker 拉镜像省去编译安装的麻烦。如果是 Mac一条命令的事docker run -d --name redis-dev -p 6379:6379 redis:7.0Windows 环境下官方原版 Redis 不提供 Windows 版本建议同样用 Docker或者用 Memurai、tporadowski/redis 这类社区维护的发行版。这里多说一句开发环境用 Docker 跑 Redis 是最省心的方案别在这上面浪费时间编译源码。服务启动后先用命令行工具确认一下redis-cli 127.0.0.1:6379 PING PONG 127.0.0.1:6379 SET hello world OK 127.0.0.1:6379 GET hello worldRedis 的核心操作说白了就是围绕“键值对”展开的一组命令。你先把这些命令背熟后面再看 Java 代码就非常轻松数据类型常用命令典型场景StringSET / GET / INCR / EXPIRE缓存、计数器、分布式IDHashHSET / HGET / HGETALL对象缓存、购物车ListLPUSH / RPOP / LRANGE消息队列、最新消息列表SetSADD / SISMEMBER / SPOP去重、抽奖、标签ZSetZADD / ZRANGEBYSCORE / ZREVRANGE排行榜、延迟队列通用KEYS / DEL / EXPIRE / TTL / EXISTS键管理、过期策略这里必须提醒一个新手常见误区生产环境严禁直接使用 KEYS 命令。它会对全库做遍历数据量大的时候直接把 Redis 卡死。后面我会单独说这个坑。1.2 Jedis 与 Lettuce 的选型对比Java 里老牌的 Redis 客户端有 JedisSpring Boot 2.x 之后默认用的是 Lettuce。你肯定会问到底选哪个我直接说结论不要纠结。Jedis是直连模式操作直观但线程不安全。什么意思就是说多个线程共享一个 Jedis 实例时需要自己搞连接池去管理一个线程拿一个连接用完了还回去。所以用 Jedis 的时候最佳实践就是配合 JedisPool 使用。Lettuce基于 Netty 实现连接实例是线程安全的多个线程可以共享同一个连接底层通过异步事件循环处理并发请求。Spring Boot 默认集成 Lettuce所以只要你是 Spring Boot 项目基本不用动脑子直接用 spring-boot-starter-data-redis 里自带的 Lettuce 就行。我的实际经验是没有特殊强制要求就用 Lettuce Spring Data Redis。原生 Jedis 我只在某些老项目的改造里见过新项目基本碰不到。1.3 从 Jedis 原生 API 到“手写一个 Redis 工具类”既然标题是“在 Java 中操作 Redis”我就先用原生 Jedis 写一个最简示例帮你把底层逻辑弄清楚。因为后面哪怕你用了 Spring Data Redis它的底层依然离不开这些基本操作。先引入依赖dependency groupIdredis.clients/groupId artifactIdjedis/artifactId version4.4.3/version /dependency然后写一个简单的测试public class JedisDemo { public static void main(String[] args) { // 创建连接池配置 JedisPoolConfig poolConfig new JedisPoolConfig(); poolConfig.setMaxTotal(50); poolConfig.setMaxIdle(20); poolConfig.setMinIdle(5); // 创建连接池 try (JedisPool jedisPool new JedisPool(poolConfig, 127.0.0.1, 6379)) { try (Jedis jedis jedisPool.getResource()) { // 验证密码没有密码可以省略 jedis.auth(yourpassword); // 基本的键值操作 jedis.set(name, geektime); String name jedis.get(name); System.out.println(name name); // 带过期时间的操作 jedis.setex(token:1001, 3600, session-token-value); // 计数器操作 long count jedis.incr(page:view:article:1001); System.out.println(第 count 次访问); // 哈希操作 jedis.hset(user:1001, nickname, 老王); jedis.hset(user:1001, level, VIP5); System.out.println(jedis.hgetAll(user:1001)); } } } }这段代码里有几个点要重点说第一用连接池是必须的。Jedis 实例不是线程安全的不池化的话高并发场景下你会看到各种连接异常。第二try-with-resources 是个好习惯。JedisPool和Jedis都实现了Closeable这样能保证连接正确归还。还有个细节JedisPool也实现了Closeable正常要把池的关闭放在最外层上面代码里两个 try 嵌套内层的Jedis归还外层的JedisPool最后随主流程结束这个写法是对的。第三setex和auth这种命令实际工作中很常用一个是设置值同时给过期时间一个是鉴权网上很多旧代码里是分开写的注意别踩旧教程的坑。不过实话实说真正工作里你不会用原生 Jedis 写业务代码太啰嗦了还容易漏掉连接释放。但理解这一段非常重要因为后面你遇到 Redis 连接池耗尽、连接泄漏这类问题时能顺藤摸瓜找到根因。2. 走进 Spring 环境Spring Data Redis 集成2.1 依赖引入与配置参数到了 Spring Boot 项目里事情就简单很多了。先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency注意Spring Boot 3.x 里面这个依赖会帮你带上 Lettuce 和 Spring Data Redis。如果你想用 Jedis要单独排除掉 Lettuce 再加 Jedis 依赖但日常开发没必要这么折腾。配置文件application.ymlspring: data: redis: host: 127.0.0.1 port: 6379 password: 123456 database: 0 timeout: 3s lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 max-wait: 3s time-between-eviction-runs: 60s这几个配置参数建议每个都弄明白max-active连接池最大连接数。设太小高并发下会导致获取连接等待甚至报错设太大会占用 Redis 服务端太多连接资源。32 是比较保守的起步值高并发项目可以按压测调大。max-idle和min-idle控制空闲连接数量主要影响请求的响应速度和资源占用。min-idle 保持几个空闲连接能避免冷启动时频繁创建新连接。max-wait获取连接的最大等待时间超过就抛异常。time-between-eviction-runs后台线程驱逐空闲连接的频率。关于 timeout 和 max-wait 这两个时间参数很多人容易混淆。timeout是读写 Redis 操作的最大等待时间max-wait是从连接池拿连接时的最大等待时间。这两个可以相等但含义完全不同面试的时候经常被问到。2.2 RedisTemplate 与 StringRedisTemplate 的差别Spring Data Redis 给你封装好了RedisTemplate。默认情况下容器里会有两个现成的 BeanRedisTemplateObject, Object和StringRedisTemplate。这里要专门讲清楚它们的序列化差异因为这是新手踩坑最集中的地方。默认的RedisTemplate使用的键和值的序列化器是JdkSerializationRedisSerializer。什么意思就是它会把你的 Java 对象通过 JDK 原生的ObjectOutputStream序列化成二进制字节数组存的时候前面还会带一串\xAC\xED\x00\x05之类的乱码头。所以你会遇到这种情况用RedisTemplate存进去的键在 redis-cli 里看是一堆\xac\xed\x00\x05t\x00\x04name这样的鬼东西。这就是传说中的“看到乱码”其实数据本身没错只是序列化方式导致键带了前缀。而StringRedisTemplate的键和值默认都是StringRedisSerializer存进去是什么就是什么在可视化工具里看非常清爽。核心结论如果你的键值都是字符串比如业务 key 和 JSON 字符串直接用StringRedisTemplate如果你非要让 Redis 帮你存整个 Java 对象那就得自定义RedisTemplate的序列化器下面的部分给你具体方案。2.3 序列化方案选型别让乱码和冗余数据折腾你实际项目里我最推荐的一套组合是key 用StringRedisSerializer保证 key 的可读性方便排查问题和运维管理。value 用GenericJackson2JsonRedisSerializer直接把对象序列化成 JSON 字符串可读性好适合业务对象。hash 的 key 用StringRedisSerializerhash 的 value 用GenericJackson2JsonRedisSerializer。配置类如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 序列化 StringRedisSerializer keySerializer new StringRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); // value 序列化 GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }有人会问为啥不用 Fastjson我以前也用过后来项目升级出过几回反序列化兼容性问题再加上那个JSONType各种注解带来的线上事故现在基本不碰了。老老实实用 Spring 官方提供的 GenericJackson2JsonRedisSerializer 最稳虽然序列化出来的 JSON 里会多一个class字段记录类类型方便反序列化还原对象但换来的是安全性和兼容性值得。再补充一个坑如果 value 用了 GenericJackson2JsonRedisSerializer存储的字符串值在反序列化时会被转成LinkedHashMap而不是你原来的 POJO。那是因为它在 JSON 里存了类型信息才认得出来。如果你只是存普通字符串建议直接用StringRedisTemplate别让 Object 类型到处横跳省得后面ClassCastException找上门。2.4 封装一个开箱即用的 Redis 工具类配置好序列化器后强烈建议你按自己项目的需求封装一个工具类。不要每次在业务代码里直接注入RedisTemplate然后用原始 API那样代码会很散。我这里给你一个精简版但足够实用的封装思路Component public class RedisCache { private final RedisTemplateString, Object redisTemplate; public RedisCache(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } public void set(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public T T get(String key, ClassT type) { Object value redisTemplate.opsForValue().get(key); if (value null) { return null; } return type.cast(value); } public void delete(String key) { redisTemplate.delete(key); } public boolean expire(String key, long timeout, TimeUnit unit) { return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, unit)); } public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } }注意我上面代码里用了Boolean.TRUE.equals(...)而不是直接返回 Boolean是因为 RedisTemplate 很多方法返回的是 Boolean 对象直接自动拆箱有可能出现 NPE。这个写法虽然多打几个字但能规避空指针风险老手都懂。真正要跨模块复用、带有复杂操作比如 Lua 脚本、分布式锁的工具类还可以进一步拆成多个方法但你得记住“工具类不要干太多事”该交给业务层逻辑去编排的别塞进工具类。3. 五大数据结构在业务里的经典实战3.1 String缓存、计数、分布式 ID、限流String 是最基础也是用得最多的类型。缓存不用多解释就是 set/get 一把梭但要注意缓存穿透和缓存击穿的防护第 5 部分会细说。计数器很经典。比如文章浏览量Long pageView redisCache.incr(article:pageview: articleId);INCR命令在 Redis 内部是原子操作天然适合计数器。多实例部署也不会因为并发覆盖导致计数丢失。分布式 ID业务需要一个不重复的自增 ID可以用INCR配合daily前缀实现String key order:id: LocalDate.now(); Long orderId redisCache.incr(key); // 如果要当天凌晨重置设置过期时间 redisCache.expire(key, 1, TimeUnit.DAYS);这里有个细节如果之前 key 不存在INCR 会从 0 开始第一次执行结果就是 1。如果你希望从某个数起跳可以用INCRBY或者在设置初始值时先SET key 1000再INCR。接口限流经常用 String 过期时间比如“一个用户一分钟最多访问 5 次”String limitKey rate:limit: userId; long times redisCache.incr(limitKey); if (times 1) { redisCache.expire(limitKey, 60, TimeUnit.SECONDS); } if (times 5) { throw new BizException(操作过于频繁请稍后再试); }千万别忽略判times 1后设置过期时间这个步骤不然这个 key 就永远不过期了。3.2 Hash对象缓存与购物车Hash 类型非常契合“一个对象整体存进去频繁修改其中某个字段”的场景。比如用户信息// 存用户对象 redisCache.hset(user:info:1001, nickname, 老王); redisCache.hset(user:info:1001, level, VIP5); redisCache.hset(user:info:1001, balance, 1000); // 修改某个字段 redisCache.hset(user:info:1001, balance, 990);你用 String 类型存整个用户 JSON改余额时就得“取出 - 反序列化 - 改字段 - 再序列化 - 放回”在并发下还容易丢更新。Hash 天然支持字段级操作省心很多。购物车是最常被写进面试题的案例// 商品 ID 为 field数量为 value redisTemplate.opsForHash().put(cart:user:1001, sku:123456, 2); // 增加商品数量 redisTemplate.opsForHash().increment(cart:user:1001, sku:123456, 1); // 获取购物车所有商品及数量 MapObject, Object cartItems redisTemplate.opsForHash().entries(cart:user:1001);Hash 存购物车的优势是一个 key 对应整个购物车一个 field 对应一个商品查询、修改单个商品都不需要动整个结构。不过要注意Hash 的 field 数量如果特别多比如上万HGETALL可能会阻塞 Redis届时要考虑分 key 拆桶。3.3 List消息队列与“最新列表”严格来说 Redis 的 List 可以作为轻量级消息队列使用通过LPUSHBRPOP实现生产者和消费者解耦。生产者redisTemplate.opsForList().leftPush(queue:order, orderJson);消费者阻塞式拿取Object msg redisTemplate.opsForList().rightPop(queue:order, 5, TimeUnit.SECONDS);BRPOP的好处是队列没数据时客户端会阻塞等待而不是空轮询浪费 CPU。这个方案适合轻量级、不需要消息确认、不需要复杂路由的场景。最新消息列表也很好用比如用户的消息盒子每次有新消息就LPUSH然后展示时LRANGE取前 N 条redisTemplate.opsForList().leftPush(msg:inbox: userId, messageJson); ListObject latest redisTemplate.opsForList().range(msg:inbox: userId, 0, 19);注意 List 做“最新列表”要带一个长度上限控制不然这个 key 会无限增长。一般可以用LTRIM截断或者定时清理。3.4 Set去重、抽奖、共同好友Set 去重的特性在业务里很好用。比如“用户已点赞的帖子”redisTemplate.opsForSet().add(post:liked: postId, String.valueOf(userId)); boolean liked Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(post:liked: postId, String.valueOf(userId)));再来个简单抽奖// 把所有参与用户放入集合 redisTemplate.opsForSet().add(lottery:activity:1001, u1, u2, u3); // 随机抽一个用户并移除 Object winner redisTemplate.opsForSet().pop(lottery:activity:1001);共同好友用集合的求交SetObject commonFriends redisTemplate.opsForSet().intersect(user:friends:1001, user:friends:1002);这种场景换个关系型数据库来写SQL 可能得 JOIN 半天Redis 一行就出来了。3.5 ZSet排行榜延迟队列等进阶玩法ZSet有序集合每个元素都带一个分数Redis 按分数排序。最经典的场景就是排行榜// 用户积分加 10 redisTemplate.opsForZSet().incrementScore(rank:global, user:1001, 10); // 取出总分前 10 SetObject topTen redisTemplate.opsForZSet().reverseRange(rank:global, 0, 9);取到的只是用户 ID要展示昵称头像还得回源数据库查。常见做法是先把用户基本信息缓存一份再组装返回不然打榜接口能把数据库查死。ZSet 还有一个进阶用途延迟队列。把任务执行时间戳作为分数任务内容作为值然后起一个定时任务不停查询ZRANGEBYSCORE小于当前时间戳的任务// 延迟 5 分钟执行 long executeAt System.currentTimeMillis() 5 * 60 * 1000; redisTemplate.opsForZSet().add(delay:queue:order, orderId, executeAt); // 消费端轮询 SetObject dueOrders redisTemplate.opsForZSet().rangeByScore(delay:queue:order, 0, System.currentTimeMillis());实现简单、对 Redis 压力小适合“订单超过 30 分钟未支付自动关闭”这类业务。不过要注意消费端的持久化和可靠性进程挂了任务就丢了生产环境一般还得配合数据库状态兜底。4. 分布式锁从手写 SETNX 到 Redisson4.1 为什么需要分布式锁从超卖说起先想一个电商场景库存只有 10 件商品用户同时下了 100 个订单每个订单都要减库存。如果是单机应用你可以在 JVM 层面加锁比如synchronized或者ReentrantLock。但现在的服务基本都是多实例部署负载均衡之后同一段代码可能在 N 台机器上同时执行Java 的锁管不着其他机器上的线程。这时候就需要一个“所有机器都能看见的锁”——Redis 分布式锁。4.2 基础版SET NX EX 的正确姿势网上有些老教程还在教SETNX然后EXPIRE分开写这是有缺陷的。如果SETNX成功之后还没来得及EXPIRE进程就挂了这个锁就永远释放不掉。正确写法是 Redis 官方提供的原子指令// 加锁key 不存在才能设置成功同时带上过期时间 Boolean lock redisTemplate.opsForValue().setIfAbsent(lock:order:pay, thread-1, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(lock)) { try { // 业务逻辑 } finally { releaseLock(lock:order:pay, thread-1); } }释放锁的时候要注意一个经典问题只能删除自己持有的锁。如果 A 线程加的锁因为业务执行超过 30 秒锁自动过期了此时 B 线程成功加锁。结果 A 线程业务跑完执行del lock就把 B 的锁删了。所以删除前要先比较 value 是不是自己的。public void releaseLock(String key, String owner) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); redisTemplate.execute(redisScript, Collections.singletonList(key), owner); }注意这里用了 Lua 脚本。为什么不用 Java 代码“先 get 再 del”因为“比较 value 和删除”这两个步骤不是一个原子操作在判断到删除之间锁可能已经过期别的线程抢到了锁你照样会误删。Lua 脚本在 Redis 中是原子执行的这是分布式锁正确性的关键。4.3 进阶版Redisson 与看门狗机制上面手写版本有个很烦的问题锁的过期时间设短了业务没跑完锁提前过期其他线程就进来了设长了万一持有锁的线程崩溃其他线程要等非常久。怎么办用 Redisson 的RLockRLock lock redissonClient.getLock(lock:order:pay); boolean locked lock.tryLock(1, 30, TimeUnit.SECONDS); if (locked) { try { // 业务 } finally { lock.unlock(); } }Redisson 有“看门狗”机制如果没指定 leaseTime默认加锁成功后会有后台线程每 10 秒给锁续期一次把过期时间一直保持在 30 秒。这样既不会因为业务时间长而提前释放也不用担心线程崩溃后锁永远不释放——因为看门狗线程是跟着持有锁的 JVM 走的一旦持有者挂了看门狗也不跑了锁会在 30 秒后自动过期。生产建议不是很复杂的场景直接用 Redisson 最省心别自己写。但要明白它的原理面试时能讲清楚看门狗和 Lua 脚本还不够最好能说道说道“为什么 Redisson 的锁只能保证 Redis 单节点下的安全”。4.4 分布式锁的坑位提醒锁key的设计要带业务前缀比如lock:order:pay别写太短的通用 key容易跟其他模块冲突。锁的粒度要小。尽量锁“资源 ID”而不是锁全表。比如按订单号、用户 ID 分段锁能大幅提升并发能力。别在 finally 里无条件 unlock先判断是否持锁成功再释放否则没拿到锁的线程也会去 unlock报异常还好更怕的是误删别人锁。主从切换导致的锁丢失Redisson 的普通锁在 Redis 主节点宕机、从节点顶上来的极端场景下可能失效严格场景要用 RedLock但业界对 RedLock 本身也有争论。实际项目里多出于成本考虑直接接受极小概率的极端风险。5. 缓存三大问题的治理方案5.1 缓存穿透查一个不存在的数据什么叫穿透就是请求的数据在 Redis 和数据库里都不存在每次请求都直接打到数据库。攻击者可以构造一批不存在的 ID比如负数 ID 或随机 UUID疯狂请求数据库直接被压垮。常见解决方案缓存空结果查不到的数据也缓存起来过期时间设短一点比如 60 秒。Object value redisCache.get(key); if (value null) { value queryFromDb(key); if (value null) { redisCache.set(key, , 60, TimeUnit.SECONDS); } else { redisCache.set(key, value, 10, TimeUnit.MINUTES); } }布隆过滤器请求进来先过布隆过滤器判断这个 key 是否“一定不存在”如果大概率不存在则直接返回不打库。注意布隆过滤器有误判率判断存在不一定存在但判断“不存在”则一定不存在。参数校验很多穿透其实是因为非法参数接口层做好基础校验能挡掉一部分。5.2 缓存击穿热点 key 过期瞬间缓存击穿和穿透不一样。击穿是某个热点 key 过期的瞬间大量请求同时打到数据库。比如秒杀商品的库存 key在过期的那一刹那所有线程都发现缓存没有全部涌入数据库。解决方案互斥锁缓存没命中时先尝试加分布式锁只有一个线程去查数据库回填缓存其他线程等待后重新查缓存。这个思路简单有效但要注意别把整个系统锁死。Object value redisCache.get(key); if (value null) { String lockKey lock:cache: key; boolean lock redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(lock)) { try { // 双重检查防止并发 value redisCache.get(key); if (value null) { value queryFromDb(key); redisCache.set(key, value, 10, TimeUnit.MINUTES); } } finally { releaseLock(lockKey, 1); } } else { // 没有抢到锁短暂等待后重试 Thread.sleep(50); return getWithLock(key); } }逻辑过期不给 key 设真实过期时间而是把过期时间放在 value 里。读取时发现逻辑上已过期则启动一个线程去更新缓存当前请求还是返回旧数据。这种做法用户体验更好但实现复杂度高一点。5.3 缓存雪崩大面积 key 同时失效雪崩是指大量 key 在同一时间段集体过期或者 Redis 节点宕机导致所有请求落到数据库。应对手段过期时间加随机值避免同一批 key 在同一秒过期给每个 key 的过期时间加一个随机秒数。long timeout 10 * 60 ThreadLocalRandom.current().nextInt(300);热点 key 不设置过期时间改为后台任务定时更新。这种方式能从根本上避免热点 key 过期但更新逻辑要写好别出现缓存跟 DB 不一致。Redis 高可用搭建主从复制 哨兵或 Cluster防止单点故障。多级缓存Redis 之上再加一层本地缓存比如 CaffeineRedis 挂了本地还能扛一阵。5.4 缓存与数据库的双写一致性关于“先更新数据库还是先删缓存”业界是有过很多争论的。这里我给一套偏稳妥的方案。最常用的策略是Cache Aside旁路缓存读先读缓存未命中读数据库回填缓存。写先更新数据库再删除缓存不是更新缓存。为什么写的时候要“删除缓存”而不是“更新缓存”因为更新缓存需要把整个对象的最新值算好再写进去成本高而且并发场景下更新顺序容易错乱删掉让下次读的时候再回填反而更稳。删缓存这个操作有个经典并发问题A 读缓存未命中去 DB 查旧值B 此时更新 DB 并删缓存然后 A 把旧值回填进缓存。最终缓存里还是旧数据。解决方案是延迟双删更新 DB 后先删一次缓存然后过几百毫秒再删一次。也可以依赖消息队列在发出更新操作后异步重试删除保证最终一致性。记住没有绝对一致业务上要允许短暂的旧数据存在能做到最终一致已经能覆盖绝大多数场景。追求强一致性的数据比如余额、支付状态不应该走缓存。6. 生产环境的真实坑位排查记录与高频问题速查6.1 序列化乱码一个 key 引发的现象级事故之前有个同事在新项目里直接用默认RedisTemplate存数据Redis 里全是\xac\xed\x00\x05开头的 key。后来要排查线上数据用可视化工具搜 key怎么都搜不到最后发现 key 的存储和查询不是同一套序列化器导致查出来一堆乱码。排查思路并不复杂先用 redis-cli 看实际存的 key 长什么样然后检查代码里RedisTemplate的 keySerializer 是什么。如果业务代码用了默认RedisTemplate把配置类里的序列化器换掉然后把原来乱码的数据删除重新写入。注意旧的乱码 key 不会自动变好你还得写个清理脚本。6.2 连接池耗尽与连接超时线上遇到过RedisCommandTimeoutException控制台一直刷 “Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException”。常见的诱因有三个连接池max-active设太小高并发时大量线程阻塞在获取连接上。某个业务逻辑耗时长一个线程长期占着连接不释放导致池中连接被占满。Lettuce 默认 timeout 是 60 秒配合超时线程的调度很容易把请求拖住。解决步骤先把spring.data.redis.timeout调小一点比如 3s让超时快速暴露而不是拖死线程。调大连接池参数或者检查是不是哪段代码忘了释放连接。排查有没有大 key 操作因为大 key 的序列化和传输非常耗时。6.3 大 key 与热 key 治理大 key单个 key 的值特别大比如几 MB 的 JSON 字符串或者一个 Set 里有几十万成员。大 key 会导致 Redis 阻塞、网络带宽打满、集群数据倾斜删除的时候还容易阻塞主线程。排查方法用redis-cli --bigkeys扫描它会统计每种类型里最大的 key 是哪些。治理思路拆分纯大字符串拆成多个小 key大 Hash/Set 按业务的某个维度拆桶。压缩把序列化前的对象做字段裁剪或压缩比如压缩成 GZIP。不直接 DEL删除大 key 用UNLINK命令异步释放或者在低峰期分批删除。热 key某个 key 被超高频率访问比如明星微博的点赞数。单台 Redis 可能被它打爆。治理思路有热 key 数据做副本分散到多个 key比如 key#1、key#2读请求随机取一个或者本地缓存兜一层。6.4 慢查询排查Redis 里耗时超过设定阈值的命令会被记录在慢查询日志里。慢查询默认阈值是 10 毫秒slowlog-log-slower-than可以通过SLOWLOG GET查看。慢查询常见的元凶KEYS 命令全库扫描。HGETALL 取超大 Hash。ZRANGEBYSCORE 取超大范围的 ZSet。批量 MGET 传入大量 key。使用 O(N) 复杂度的命令还没控制 N 的量级。排查到慢查询之后重点看命令类型、key 和耗时再针对性地优化数据结构或拆分命令。6.5 高频面试题速查卡问题回答要点Redis 为什么快内存操作 单线程模型 I/O 多路复用缓存穿透 / 击穿 / 雪崩区别穿透是查不存在击穿是热点 key 过期瞬间雪崩是大面积过期/宕机RedisTemplate 的序列化乱码默认 JDK 序列化建议 key 用 StringRedisSerializer、value 用 GenericJackson2JsonRedisSerializer怎么设计分布式锁SET NX EX Lua 删除或直接 RedissonRedis 持久化机制RDB 快照 AOF 日志各自优缺点要背熟为什么 ZSet 能做排行榜跳表结构 分数排序增删改查都是 O(logN) 量级Redis 内存淘汰策略默认 noeviction常用 allkeys-lru / volatile-lru面试前过一遍7. 写在最后我的几点真实建议这篇内容拖了很久正好趁整理的机会把我这两年用 Redis 踩过的坑都说了一遍。最后基于个人经验分享三个小建议不一定适合所有团队但值得你参考。第一把 Redis 当缓存用的时候一定要想过期时间。我见过太多 key 只 set 不设过期时间的代码最后 Redis 内存被打爆服务直接雪崩。就算你确实想“永久保存”也得想清楚数据增长的速度而不是一味放任。第二线上排查问题先把 redis-cli 上的数据读一遍。很多诡异的问题看一眼实际存储的数据形态心里就大概有底了。可视化工具当然方便但命令行能给你最原始、最准确的信息。第三序列化和连接池是关键中的关键。十个 Redis 相关的事故至少有八个能从这两个地方找到原因。这篇文章里反复强调的东西实际工作中真的会一遍又一遍地遇到。最后分享一个小技巧如果你的项目正好是 Spring Boot 3.x配置的写法有些变化但底层原理没有变。真遇到配置不生效、连接失败之类的问题先把spring.data.redis下的配置全部打出来核对一遍再去看异常堆栈。很多时候问题不是代码写得不对而是配置压根没加载进去。