
坦白说我刚带项目的那几年对 Redis 的用法基本是“一个缓存解决不了的问题就再加一个中间件”。这个思路后来坑了我一次很惨的线上事故才让我把 Redis 在 Java 项目里的定位彻底想清楚它不是用来堆炫技代码的而是用来解决特定业务场景的。缓存读写、分布式锁、排行榜、点赞关系、限流、异步解耦这些场景各有各的套路。这篇文章我会把平时在项目里真正用过、可以直接抄走的 Java 代码模板整理出来每套模板后面都会讲清楚为什么这么写、什么时候会翻车、踩了哪些坑之后才形成这个版本。如果你是刚接触 Redis 的 Java 开发这套模板可以直接当成项目里的工具类骨架如果你已经有几年经验重点可以看每章最后那部分“实测中容易翻车”的内容那部分是我认为比代码本身更值钱的东西。1. 动手写模板之前先把这三件基建定死1.1 连接方式Lettuce 和 Jedis 怎么选Spring Boot 系列默认集成的 Redis 客户端是 Lettuce这个基本没什么选择的余地。但很多新手忽略了一个前提Lettuce 底层是 Netty 多路复用一个连接可以被多个线程共享性能上限高可一旦某个命令长时间阻塞同一连接上的其他命令都会跟着遭殃这正是线上常见的RedisCommandTimeoutException的来源之一。而 Jedis 是传统的阻塞式连接一个连接同一时间只能跑一个命令线程安全完全靠连接池来保证模型更简单出问题时更好排查。我自己的选型建议比较务实如果项目是 Spring Boot 且团队对 Lettuce 的调优参数不熟就老老实实用默认的 Lettuce但超时和连接池必须显式配置别依赖默认值如果已经踩过 Lettuce 的“共享连接被慢命令卡死”的坑想追求更可控的行为切到 Jedis 也很方便。切换只需要排除掉 lettuce 依赖、引入 jedis 即可dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId exclusions exclusion groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /exclusion /exclusions /dependency dependency groupIdredis.clients/groupId artifactIdjedis/artifactId /dependency说实话在单机 Redis 场景下两者的吞吐差距没到决定性的程度真正的差别在并发模型和排障方式上。Jedis 的连接池耗尽一眼就能从堆栈看出来Lettuce 的连接被阻塞则要结合全链路的命令耗时去推。1.2 序列化器不换掉默认 JDK 序列化后面全是坑很多项目 Redis 里出现一堆类似\xac\xed\x00\x05t\x00的可读性极差的 key十有八九是没配序列化器。RedisTemplate 默认用的是 JdkSerializationRedisSerializer它不仅让 key 变成乱码还有两个更大的问题一是序列化后的体积偏大空间浪费明显二是如果数据源不可信JDK 反序列化本身就有安全风险。所以在项目第一天就应该把序列化方案定下来这个决定会影响后面所有模板的可读性和排障效率。我的惯例是key 和 Hash 的 field 统一用 StringRedisSerializervalue 用 GenericJackson2JsonRedisSerializer。这样 key 在 redis-cli 里能直接看懂value 是 JSON出了问题一眼能定位。配置代码很固定Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里要说明一个容易被忽略的细节GenericJackson2JsonRedisSerializer默认会在 JSON 里写入类型信息也就是那个class字段。好处是反序列化时能还原成原来的对象类型坏处是 JSON 体积会膨胀而且 DTO 结构调整时老数据里可能残留已经不存在的类路径。所以一旦上了这个序列化器DTO 的字段改动要谨慎删除字段前先考虑缓存里有没有老数据。1.3 连接池参数maxActive、maxIdle 各管什么事很多项目即使接了 Spring Boot Redis连接池也没有真正生效。以 Lettuce 为例如果不主动配置 pool它默认使用一个共享连接高并发下连接数会自己膨胀但连接池的等待、复用机制完全没有。我建议在配置文件里显式打开连接池参数控制在合理范围spring: data: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 2s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3smax-active是最大活跃连接数max-idle是空闲连接上限max-wait是拿不到连接时的最大等待时间。这几个参数我的经验值是普通业务系统 16 到 32 足够千万别开成几百。连接数越大客户端线程调度和服务端上下文切换的开销都会叠加性能反而往下掉。如果你的应用确实有高频访问 Redis 的场景优先考虑用 pipeline 合并命令或减少网络往返而不是无脑堆连接数。max-wait设置成 3 秒的意义在于让调用方快速失败并及时告警而不是无限等下去把线程都拖死。2. 缓存读写的三类经典事故穿透、击穿、雪崩2.1 最基础的 Cache Aside 模板业务里最常见的缓存模式是旁路缓存读缓存缓存没有就查数据库再回填写的时候更新数据库并删除缓存。很多人在“写后更新缓存”还是“写后删除缓存”之间纠结我的结论是大多数项目直接用删除缓存。原因是更新缓存需要构造对象一旦构造逻辑有偏差或者多个线程同时写很容易产生脏数据删除缓存则让下次读取时自然回填逻辑上更收敛。通用读方法的模板我会用SupplierT做数据源回调让业务方只关心怎么加载数据public T T get(String key, ClassT type, SupplierT loader, Duration ttl) { Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return (T) cached; } T result loader.get(); if (result ! null) { redisTemplate.opsForValue().set(key, result, ttl); } return result; }这个基础模板只能在低流量、数据基本都存在的场景用。如果loader.get()返回 null说明数据库里也没有这条数据那么它就永远不会被缓存下一次同样的请求还是会打到数据库。低流量问题不大但要是恶意脚本随机请求一堆不存在的 ID数据库压力会被放大很多倍这就引出了缓存穿透。2.2 缓存穿透空值缓存与布隆过滤器双保险应对穿透最便宜、最通用的做法是缓存空值。查出 null 时往 Redis 里写一个占位对象设置一个较短的过期时间比如 60 秒。这样同一个不存在数据的 key 至少一分钟内不会被重复打到数据库public T T getWithEmptyFlag(String key, ClassT type, SupplierT loader, Duration ttl, Duration emptyTtl) { Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return (T) cached; } T result loader.get(); if (result ! null) { redisTemplate.opsForValue().set(key, result, ttl); } else { redisTemplate.opsForValue().set(key, , emptyTtl); } return result; }空值缓存能挡住大多数场景但挡不住“每次都用不一样的不存在 key”这种极端扫描。这种情况要上布隆过滤器。布隆过滤器的思路是用少量内存记录哪些 key 一定不存在查询前先走过滤器如果过滤器说这个 key 不在直接返回不再碰缓存和数据库。Java 里最简单的方式是 Guava 的BloomFilter单机部署可以直接用但分布式环境下多个实例的内存过滤器要保持同步否则每个实例都要预热一遍反而麻烦。这种情况下可以用 Redisson 提供的 RBloomFilter它把布隆过滤器的位数组存在 Redis 里多个实例共享一份数据。我在一个高并发商品详情接口上用的组合是布隆过滤器做第一层拦截空值缓存做第二层兜底两层都过了才让请求打到数据库。实测下来数据库的重复查询量能降到原来的百分之一以下效果非常明显。2.3 缓存击穿互斥锁加二次查的模板击穿和穿透是两回事。穿透是“缓存和数据库都没有”击穿是“缓存有但刚好过期了一瞬间大量请求同时回源”。解决方式之一是互斥锁只有一个线程能回源其他线程让出一点时间后重新读缓存。代码模板里有两个关键点一是加锁时要用带过期时间的setIfAbsent二是拿到锁之后不要直接查库要再查一次缓存做 double check。public T T getWithMutex(String key, ClassT type, SupplierT loader, Duration ttl) { Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return (T) cached; } String lockKey lock: key; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { Object again redisTemplate.opsForValue().get(key); if (again ! null) { return (T) again; } T result loader.get(); if (result ! null) { redisTemplate.opsForValue().set(key, result, ttl); } else { redisTemplate.opsForValue().set(key, , ttl); } return result; } finally { releaseLock(lockKey, requestId); } } try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithMutex(key, type, loader, ttl); }这里用递归来重新查缓存写法上不算最优雅但对大多数业务足够清晰。如果担心递归深度可以改成 while 循环思路完全一样。还有个细节很多人会忽略锁的过期时间要比预期回源时间长最好留一倍余量。因为如果业务里的慢 SQL 导致回源时间超过了锁的过期时间锁先过期其他线程又会涌进来回源互斥就失效了。这段模板里的releaseLock方法在第 3 章会完整给出那段 Lua 脚本就是防止误删锁的关键。2.4 缓存雪崩过期时间要加随机偏移雪崩是“大量 key 同时过期”。比如零点定时上架一批商品给每个商品缓存设置了相同的 TTL那到同一个时间点所有 key 一起失效下一秒全部请求一起回源数据库很容易被打挂。预防手段很朴素给过期时间加随机偏移量让过期点分散开。比如基础 TTL 是 1 小时实际设置时加一个 0 到 300 秒的随机数Duration actualTtl ttl.plusSeconds( ThreadLocalRandom.current().nextLong(0, 300L)); redisTemplate.opsForValue().set(key, result, actualTtl);随机范围并不是越大越好。范围太小起不到分散效果范围太大缓存命中率会有明显波动。我一般取基础 TTL 的 5% 到 10% 作为随机区间比如 3600 秒的 TTL随机 180 秒。另外一个容易被忽略的点是批量初始化缓存的任务比如定时任务启动后重建一批热点 key要人为错峰执行不要在一个循环里一次性全部 set。只要把这条写进项目的代码规范缓存雪崩的概率就会降很多。3. 分布式锁从 setnx 到 Redisson一次性讲透3.1 为什么到处都在说 setnx 不行了网上有大量文章在讲“用 setnx 做分布式锁”但真正落地的项目里裸写 setnx 很容易翻车。早期版本的错误写法是SETNX key value成功后再执行EXPIRE key seconds这两条命令之间如果客户端进程挂了锁就永远没有过期时间整个业务被一把死锁卡住。所以官方后来把原子写法定义为SET key value NX EX seconds在 Spring Data Redis 里就是setIfAbsent(key, value, Duration)把“加锁”和“设置过期时间”合并成一步。这还没完锁释放同样有坑。如果释放时只是简单调用delete(key)很可能把别人加的锁删掉。流程是这样的线程 A 拿到锁执行时间太长导致锁到期线程 B 接着拿到锁这时 A 终于执行完了直接删 key就把 B 的锁删掉了。所以释放前必须先判断 value 是不是自己当初写入的那串唯一标识而且“判断”和“删除”两个动作必须是原子的不能先 get 再 del否则判断完和删除前之间可能插入别人的操作。3.2 自己封装一把锁加锁、释放锁、带过期如果不想引额外的框架自己封装一把最小可用的分布式锁是必须会的。加锁用setIfAbsent释放锁用一段 Lua 脚本保证“判断 value 删除”是原子的public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds))); } public void releaseLock(String lockKey, String requestId) { 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(lockKey), requestId); }这里要提醒一个实践细节如果用的StringRedisTemplatevalue 必须是字符串requestId 传字符串没有问题如果用RedisTemplateString, Object就要保证序列化器统一否则可能出现写入的值和 Lua 里取到的值对不上的情况。requestId最好是 UUID 或者“业务描述随机数”目标是每个线程持有唯一标识。锁的过期时间也不是越长越好太短业务没执行完锁就提前释放并发会进来太长其他线程等待时间增加。一般 5 到 30 秒是一个常见区间具体要结合业务执行耗时来定。自己封装的锁虽然能用但存在三个原生问题不可重入、没有看门狗自动续期、没有公平排队。如果业务里要嵌套加锁比如同一个线程在 A 方法里加锁后调 B 方法B 又对同一把锁加锁自己写的 setnx 版本就死锁了这时候就该考虑 Redisson。3.3 引入 RedissonRLock 的用法与看门狗Redisson 是 Java 生态里把 Redis 分布式锁封装得最成熟的客户端。RLock 实现了 JDK 的 Lock 接口使用习惯几乎零成本。它的核心价值有两个一是可重入同一个线程可以重复加锁二是看门狗机制锁的默认过期时间是 30 秒如果业务没执行完Redisson 的后台线程会自动续期避免锁提前过期导致并发冲突。代码模板如下RLock lock redissonClient.getLock(lock:order: orderId); try { boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock(0, 30, TimeUnit.SECONDS)的意思是拿不到锁时不等待拿到锁后 leaseTime 为 30 秒。有一个很多人不知道的细节如果传了显式的 leaseTimeRedisson 不会启动看门狗自动续期只有不传 leaseTime、使用默认锁过期时间时看门狗才生效。所以如果业务执行时间不稳定可以只传 waitTimeboolean locked lock.tryLock(3, TimeUnit.SECONDS);这种情况下 Redisson 会用默认 30 秒过期并自动续期。finally里那句isHeldByCurrentThread()判断也很重要它防止因为等待超时后线程已经不再持有锁却误调用 unlock 把别人的锁释放掉。3.4 主从与哨兵架构下锁依然有坑Redisson 也不是银弹。在主从架构下如果线程 A 在主节点拿到了锁主节点还没来得及把数据同步到从节点就挂掉了哨兵把从节点提升为主节点线程 B 在新主节点上可能再次拿到同一把锁这就是经典的“故障转移时分布式锁失效”问题。Redisson 提供了红锁RedLock方案要求在多个独立 Redis 节点上同时加锁但红锁本身有争议工程上很多团队并不使用它。我的建议很务实先确认系统对一致性要求到底有多高。如果是秒杀、库存扣减这类强一致场景分布式锁不能只依赖 Redis可以考虑换成数据库唯一约束或 Zookeeper 这类强一致的组件如果只是防止重复提交、限制并发操作这类弱一致场景基于 Redis 的锁加上看门狗已经够用。把“用哪把锁”纳入架构决策而不是单纯堆技术这才是实操里的关键。4. 排行榜、点赞与签到ZSet/Set/位图各自的模板骨架4.1 排行榜ZSet 增量更新与 TopN 查询ZSet 是 Redis 里实现排行榜最顺手的结构它天然维护了“成员到分数”的映射并且能按分数排序。拿游戏积分榜举例玩家每局结束后的积分变化直接调用incrementScore累加要展示 Top 10 时用reverseRangeWithScores取分数最高的前 N 位// 增量更新积分 redisTemplate.opsForZSet().incrementScore(rank:game:202408, userId, addScore); // 查询 Top 10带分数 SetZSetOperations.TypedTupleObject top redisTemplate.opsForZSet() .reverseRangeWithScores(rank:game:202408, 0, 9); // 查询某人的当前排名加 1 是因为排名从 0 开始 Long rank redisTemplate.opsForZSet() .reverseRank(rank:game:202408, userId);这里有几个容易踩的坑第一incrementScore返回的是更新后的分数可以通过返回值判断名次变化第二榜单的 scope 要设计好按天、按周、按赛季拆 key否则一个榜单越滚越大成员到千万级后内存和计算开销都会明显上升第三如果榜单成员需要展示名称头像等信息不要把整个对象塞进 ZSet 的 membermember 应该用用户 ID 或唯一标识展示信息查询后去业务库批处理获取。原因是 ZSet 的 member 是去重的对象序列化后的字符串很容易因为字段顺序或类型信息变化导致同一个用户被看成两个成员。4.2 点赞与关注Set 交并补Set 的天然特性是无重复、支持交并差非常适合点赞、关注、收藏这类二元关系。点赞和取消点赞本质就是 add 和 remove// 给文章点赞 redisTemplate.opsForSet().add(like:article: articleId, userId); // 取消点赞 redisTemplate.opsForSet().remove(like:article: articleId, userId); // 点赞数 Long size redisTemplate.opsForSet().size(like:article: articleId); // 当前用户是否点赞 Boolean liked redisTemplate.opsForSet() .isMember(like:article: articleId, userId);关注关系里最常用的是交集和并集。比如“我的好友里有哪些也关注了这个博主”可以用intersect把两个 set 做交集返回共同关注的人SetObject common redisTemplate.opsForSet() .intersect(follow:user: myId, follow:user: lectureId);但要特别注意Set 的交并差操作在数据量很大时是 O(NM) 的开销如果用在百万粉丝级别的博主场景每个请求都现场做交集Redis 性能会很吃力。更合理的做法是把两个集合中较小的一方作为驱动分批传入 Lua 脚本或直接在应用内存里做交集。我的经验是粉丝百万级以上的场景Set 只适合存近期活跃粉丝精确的交集结果交给异步任务去预计算不要在大流量路径上硬算。4.3 签到日历位图模板连续签到、月度签到这类需求最省内存的方案是位图。一个用户一个月的签到状态用一个 bit 表示31 天只需要 4 个字节左右非常划算。Spring Data Redis 的 RedisTemplate 封装了setBit、getBit和bitCountString signKey sign:user: userId :202408; // 第 15 天签到offset 从 0 开始所以是 14 redisTemplate.opsForValue().setBit(signKey, 14, true); // 查询第 15 天是否签到 Boolean signed redisTemplate.opsForValue().getBit(signKey, 14); // 当月签到总天数 Long total redisTemplate.opsForValue().bitCount(signKey);位图方案算连续签到也很流畅拿到当月的字节数组后直接在 Java 里从当天往回逐位判断遇到 0 就停止连续为 1 的位数就是连续签到天数。要保存的对象跨月时建议按“用户年月”拆 key方便过期和统计。这里提一个位图的底层细节Redis 的字符串位操作是按整字节存储的setBit设置第 31 位时会自动扩展字符串长度所以用strlen查看会发现它按 4 字节对齐这是正常行为不影响功能。5. Redis 计数与限流从商品 PV 到接口防刷5.1 简单计数器INCR 与过期时间的正确打开方式Redis 的INCR最适合做 PV、任务次数这类高频计数它单线程执行天生没有原子性问题。商品详情页的 PV 统计是最典型的场景Long pv redisTemplate.opsForValue().increment(pv:goods: goodsId); if (pv ! null pv 1L) { redisTemplate.expire(pv:goods: goodsId, Duration.ofDays(1)); }这里用了一个小技巧只有在第一次自增返回 1时才设置过期时间。有人会问为什么不直接用expireIfNotSet那个 API 在 Spring Data Redis 2.6 以上才有很多老项目升级成本高。用pv 1L判断能绕开版本问题代价是如果 key 在业务初始化时已经存在这个过期设置就不会执行。另一种更稳的方式是商品发布或任务创建时就把 key 的 TTL 统一设好。这里还要提醒一个概念问题只靠 INCR 做库存扣减是不够的。INCR 可以计数但它减到负数只能说明“看起来没有超卖”在高并发下同一商品多次扣减后的负数并不代表真正售出的数量。库存这种强一致数据要结合数据库事务、分布式锁或 Lua 原子操作做最终校验。计数缓存适合做展示和预警不适合作为强一致的业务口径。5.2 接口限流固定窗口、滑动窗口模板限流最常见的场景是接口防刷。最简单的方案是固定窗口计数一分钟内最多 100 次用 INCR 加 EXPIRE 实现。但固定窗口有个边界问题窗口切换的一瞬间请求量可以翻倍。比如第 59 秒用了 100 次第 61 秒又能用 100 次中间 2 秒内能打进来 200 次。要更平滑就用滑动窗口基于 ZSet 记录每次请求的时间戳统计窗口内的事件数public boolean isAllowed(String userId, int limit, long windowSeconds) { String key rate:user: userId; long now System.currentTimeMillis(); ZSetOperationsString, Object zset redisTemplate.opsForZSet(); // 移除窗口之外的旧记录 zset.removeRangeByScore(key, 0, now - windowSeconds * 1000); Long count zset.zCard(key); if (count ! null count limit) { return false; } // 记录本次请求score 用时间戳member 用唯一值拼接 zset.add(key, now - UUID.randomUUID(), now); redisTemplate.expire(key, Duration.ofSeconds(windowSeconds)); return true; }这段模板能跑但有一个性能问题每次请求都会先移除窗口外的记录再插入一条ZSet 的写入量等于接口 QPS。如果接口 QPS 非常高这个写法会让 Redis 成为瓶颈。所以我在真实项目里的使用原则是滑动窗口用在登录、验证码、下单等低频但需要严格防刷的接口上高频查询接口比如搜索用固定窗口就够性价比更高。5.3 进阶令牌桶的 Lua 模板令牌桶的特点是允许一定的突发流量同时限制长期平均速率是限流算法里比较优雅的一种。实现令牌桶最合适的方式是 Lua 脚本把“读桶状态、按时间补充令牌、扣减令牌、写回状态”聚合在一次原子操作里避免并发下同一桶被多个请求同时修改redis.replicate_commands() local key KEYS[1] local rate tonumber(ARGV[1]) -- 每秒补充的令牌数 local capacity tonumber(ARGV[2]) -- 桶容量 local now tonumber(ARGV[3]) -- 当前时间 local requested tonumber(ARGV[4]) -- 本次要拿的令牌数 local bucket redis.call(hgetall, key) local tokens capacity local lastRefill now if bucket[1] ~ nil then tokens tonumber(bucket[2]) lastRefill tonumber(bucket[4]) end local elapsed math.max(0, now - lastRefill) tokens math.min(capacity, tokens elapsed * rate) if tokens requested then tokens tokens - requested redis.call(hmset, key, tokens, tokens, lastRefill, now) redis.call(expire, key, math.ceil(capacity / rate) * 2) return 1 else return 0 end这段脚本里用 Hash 存了tokens和lastRefill两个字段分别表示当前令牌数和上次补充时间。脚本开头的replicate_commands()是为了兼容旧版本 Redis 对随机函数在主从复制上的限制Redis 7 之后已经默认开启写上去也不会报错。调用时用DefaultRedisScript传入参数即可。限流的 key 一定要按业务维度拆比如用户维度、IP 维度、接口维度不要把所有人的请求都放在同一个桶里否则一个高频调用方会把所有人的配额都吃掉。6. 用 Redis 做轻量异步队列List 和 Stream 两种模板6.1 List最简异步任务队列不是所有系统都需要上 MQ。如果只是简单的异步解耦比如发送邮件、生成流水记录、清理临时文件用 Redis 的 List 加 BRPOP 就够了。生产者往队列右侧推入任务消费者阻塞从左侧弹出// 生产者 stringRedisTemplate.opsForList() .rightPush(queue:mail, emailPayloadJson); // 消费者常驻线程 while (!Thread.currentThread().isInterrupted()) { String payload stringRedisTemplate.opsForList() .leftPop(queue:mail, 5, TimeUnit.SECONDS); if (payload null) { continue; } try { // 执行业务 } catch (Exception e) { // 记录失败或者重新推回队列 } }这套模板最大的坑是“任务处理失败后的重复消费和丢失问题”。简单的leftPop取出即删除如果消费者拿到任务后 JVM 突然宕机任务就丢了。避免丢失的经典做法是BRPOPLPUSH语义从 A 队列取出同时推入 B 队列任务处理成功后从 B 队列删除处理失败则留在 B 队列等待补偿。Spring Data Redis 里对应的简化 API 是rightPopAndLeftPush但阻塞版本通常需要你在连接层面调用bRPopLPush才能实现。我的个人建议是如果任务真的重要到不能丢直接上 MQ别用 Redis 队列硬顶。6.2 Stream消费组与消息确认Stream 是 Redis 5.0 引入的日志式消息结构功能比 List 强很多支持消费组、消息 ID、确认机制。在简单的任务分配场景里Stream 可以替代一部分 MQ 的工作。核心命令是 XADD 生产、XREADGROUP 消费、XAACK 确认。Spring Data Redis 的模板里这样写// 生产消息 ObjectRecordString, String record ObjectRecord.create(stream:order, orderJson); redisTemplate.opsForStream().add(record); // 创建消费组重复创建会抛异常 try { redisTemplate.opsForStream() .createGroup(stream:order, group-order); } catch (RedisSystemException e) { // 消费组已存在忽略 } // 消费消息XREADGROUP 手动确认 ListMapRecordString, Object, Object messages redisTemplate.opsForStream().read( Consumer.from(group-order, consumer-1), StreamReadOptions.empty().count(10) .block(Duration.ofSeconds(5)), StreamOffset.create(stream:order, ReadOffset.lastConsumed())); for (MapRecordString, Object, Object message : messages) { try { // 处理消息 redisTemplate.opsForStream().acknowledge( stream:order, group-order, message.getId()); } catch (Exception e) { // 消息留在 pending 列表等待后续处理 } }这段代码里最关键的是ReadOffset.lastConsumed()它表示只读取当前消费者已经消费过之后的新消息正常流程下每次调用都应该有数据或者阻塞到超时。一旦业务处理抛异常消息就没有 ACK会留在 Pending Entries List 里后续可以用XPENDING查看和补偿。Redis Stream 最适合做“低延迟、允许一定重复、需要简单顺序保证”的场景不依赖额外中间件复杂度比 MQ 低不少。6.3 什么时候不要把 Redis 当消息中间件Redis Stream 再方便也有边界。首先Redis 是内存为主的数据库虽然 Stream 可以通过 AOF/RDB 落盘但它是快照式或追加式的持久化极端故障下可能丢失近秒级别的数据而消息中间件通常具备磁盘日志和发布确认机制能承诺不丢消息。其次Redis Stream 没有 MQ 那种开箱即用的死信队列、延迟队列、重试策略这些都靠业务自己造。最后Redis 本身还承担着缓存职责如果把大量高吞吐的异步消息也塞进来一个热 key 的抖动就可能同时影响缓存和消息两条链路。我的判断标准很简单消息量不大、失败后能接受人工介入补偿用 Redis Stream 是香的如果业务承诺是“这条消息绝不能丢”还是把 RocketMQ、Kafka 之类的独立消息系统架进来。用 Redis 做队列负责“快”和“轻”不负责“万无一失”。7. 线上最常见的三个硬伤大 Key、热 Key、RedisCommandTimeoutException7.1 大 Key影响到底在哪怎么拆分大 Key 是单个 key 的 value 特别大比如几百 KB、几 MB 甚至更大的字符串或者一个 Set/Hash 里有几百万成员。大 Key 本身不一定会让 Redis 挂掉但它会带来三个连锁反应读写这个 key 时单次命令执行时间飙升其他命令排队删除大 key 时释放内存可能造成主线程阻塞集群模式下大 key 会把数据集中到某个分片造成分片倾斜。我见过最典型的案例是把一整条用户行为轨迹用 JSON 塞进一个 key平时读写问题不大到月底清理时一条 DEL 命令把 Redis 主线程卡了几秒所有业务瞬间超时。发现大 key 最快的方式是用redis-cli --bigkeys扫描它会统计每个类型中最大的 key。定位之后的拆分策略通常有两种大字符串拆成多个小 key用 Hash 按字段组织大 Set 按业务维度拆 key并统一过期时间。如果你的业务确实需要一个大聚合结构考虑用 pipeline 分批读取避免一次性全量取值到客户端。7.2 热 Key本地缓存兜底与读写分离热 Key 是某个 key 在短时间内被极其频繁地访问比如一个热点商品详情、一封被误转发的公告链接。热 Key 会让单个分片的 CPU 打高即使 Redis 集群有 32 个分片也只有那一个分片在扛。解决思路一般分两层最有效的是本地缓存兜底在 JVM 内用 Caffeine 或 ConcurrentHashMap 做短期缓存挡住一部分访问第二层是集群里把热 key 复制到多个分片比如在 key 末尾拼随机后缀让不同请求访问不同的物理 key以此分散读压力。这里有一个很容易踩的坑本地缓存会把“更新数据库后删 Redis”的缓存失效策略变复杂。如果更新数据库后只删 Redis本地缓存不知道很容易出现脏读。所以加了本地缓存的项目必须配套统一的失效通知机制比如 Redis 发布订阅或者 MQ 广播一条“xx key 失效”的消息各实例收到后清本地缓存。没有这个机制不要轻易加本地缓存。7.3 RedisCommandTimeoutException排查链路redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这个报错我在排查线上问题时看过很多次。它字面的意思是某一条 Redis 命令超过了客户端配置的超时时间但没有告诉你根因。遇到这类异常我的第一反应不是去调大 timeout 参数而是按下面这条链路逐层排查第一看 Redis 服务端是否发生阻塞。登录 redis-cli 执行slowlog get 50如果看到大量耗时超过 100 毫秒的命令基本可以断定是慢命令拖垮了后面的请求。常见的慢命令包括大 key 的 HGETALL、SMEMBERS、DEL、KEYS。第二看连接池是否被打满。如果业务量突增而max-active设置太小应用侧会大量排队等待连接同样表现为 command timed out。第三也是容易被忽略的一点Lettuce 的共享连接模型下如果某个线程长时间占用底层连接执行一个耗时命令比如上面说的大 key 读取同一连接上的其他线程全部会被阻塞这就解释了一次慢命令引发大面积超时的现象。处理顺序也有讲究先用slowlog和INFO commandstats确认服务端问题再检查应用侧连接池指标观察哪个环节先饱和最后才是调整timeout。我最不推荐的做法是一上来就把timeout从 2 秒调大那只会掩盖问题让调用方在慢链路里卡得更久。正确做法是定位并消除慢命令控制连接池使用必要时对高频命令做 pipeline 合并。我把这些模板沉淀成了项目里的一个工具类之后最大的体会是写业务时尽量不要直接操作 RedisTemplate统一走封装的 RedisService这样任何场景出问题都能在统一入口快速埋点和排查。最后再分享一个自己的习惯无论模板写得多顺手上线前一定要做一次“故障注入”演练模拟大 key 读取、锁超时这些异常场景看看日志和告警能不能第一时间顶上来。工具是死的出问题时的应对方式才是真正的核心竞争力。