FEATURED · 精选文章

Java操作Redis必备:RedisUtils工具类封装与实战解析

发布时间 / 2026/9/18 19:10:45
来源 / 创域科博编辑部
栏目 / 资讯中心
Java操作Redis必备:RedisUtils工具类封装与实战解析 每次看到群里有新人问“Java操作Redis要不要自己写工具类”我就想起自己刚工作那会儿的蠢操作一个订单缓存查询直接在业务代码里塞了十几行redisTemplate.opsForValue().get()加 try-catch后来要加过期时间、要处理空值缓存又得把所有调用点翻出来改一遍。那时候我就意识到一个设计清晰的RedisUtils工具类不是“过度设计”而是 Java 后端项目里真正能帮你省时间的刚需。这篇内容我会把整个工具类从设计思路、依赖配置、序列化处理到 String、Hash、List、Set、ZSet、分布式锁、缓存穿透/击穿/雪崩的应对全部拆开讲清楚还附带我实际踩过的坑。无论你是刚学 Redis 的 Java 初学者还是准备面试被“Redis 八股”折磨的人又或者是项目中想统一 Redis 操作方式的后端开发都能从里面抄到能直接用的东西。先说清楚一个容易让人误解的点Spring Data Redis 本身就提供了RedisTemplate为什么还要费劲包一个RedisUtils答案其实不在于“能不能用”而在于“用起来是否安全、统一、可控”。我接下来就从这里展开。1. 为什么我不直接用 RedisTemplate而是再封装一层 RedisUtils1.1 项目里 Redis 操作的真实痛点先还原一下最真实的场景。订单服务里要查商品信息常规做法是Object cache redisTemplate.opsForValue().get(product: productId); Product product null; if (cache ! null) { product JSON.parseObject(cache.toString(), Product.class); } else { product productMapper.selectById(productId); redisTemplate.opsForValue().set(product: productId, JSON.toJSONString(product)); }这段代码看起来没问题但是当项目里十几个 service 都在写类似的逻辑时问题就来了每个开发对“缓存 key 的命名风格”理解不同有的写product:有的写product_有的干脆写ProductService.getProductById。JSON.parseObject(cache.toString(), Product.class)这种转型操作散落各处一旦 Redis 里的值被别的地方写成了别的格式这里直接报ClassCastException。新增需求要“缓存空值防穿透”你得把所有调用点都找出来改一遍。有人忘记设置过期时间Redis 内存被无界撑爆线上直接告警。这些不是代码能不能跑的问题而是“多人协作下如何保证一致性和可维护性”的问题。工具类的价值就在这里把 key 拼接、序列化、过期时间、异常兜底全部收口到一个类里业务方只关心“存一个对象”“取一个对象”细节不用管。1.2 直接使用 RedisTemplate 的五个常见问题我总结了直接用RedisTemplate最容易踩的五个坑这也是为什么很多团队最终选择再包一层问题具体表现后果序列化混乱默认 JDK 序列化Redis 里看到一串\xAC\xED乱码排查困难无法用命令行直接观察数据key 管理失控各处硬编码字符串前缀风格不统一维护成本高难以做批量清理过期时间遗漏只 set 不设置 expireRedis 内存持续增长空值没有缓存缓存未命中查库但库中无记录反复穿透数据库压力增加类型转换裸奔取出来是 Object强转到处散落类型不一致时异常难定位这些问题的共同根源是“没有统一封装点”。RedisUtils不是要把RedisTemplate的能力藏起来而是要把它变成一个“带规范的入口”让正常操作更简单让危险操作更可控。1.3 RedisUtils 的定位与设计边界在动手写之前先想清楚这个工具类该干什么、不该干什么。我个人的设计原则是它负责缓存读写、分布式锁、通用缓存策略这些高频操作它不负责业务规则比如“订单超过30分钟未支付自动关闭”这种不应该放进工具类它不负责数据一致性兜底定时任务、对账逻辑等仍然是业务层的事它必须暴露最常用的方法给业务方让 90% 的调用一行搞定剩下 10% 的复杂场景才去直接用RedisTemplate。一句话概括RedisUtils是RedisTemplate的“门面”不是“替代品”。它让该简单的简单该灵活的依然灵活。2. 工具类的整体设计从依赖、配置到序列化选型2.1 依赖引入与版本选择现在 Spring Boot 项目绝大多数用的是spring-boot-starter-data-redis,版本跟着 Spring Boot 走就行。另外建议加上 Commons Pool否则连接池相关参数配置了也没用dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency有一个很多新手不知道的点Spring Boot 2.x 之后默认的 Redis 客户端从 Jedis 换成了 Lettuce。Lettuce 基于 Netty支持异步和响应式默认就是线程安全的。区别在于Jedis 是同步阻塞模型同一个连接不能并发使用所以需要连接池管理。Lettuce 是 Netty 多路复用模型单连接即可并发处理多个请求所以就算不配连接池也能跑但生产环境我还是建议配避免单连接成为瓶颈。2.2 RedisConfig 配置类必须自定义的 Bean如果直接用 Spring Boot 默认注入的RedisTemplateObject, Object你会发现存进 Redis 的 key 是\xAC\xED\x00\x05t\x00这种 JDK 序列化产物命令行里完全没法看。所以第一步就是自定义一个RedisTemplateString, Object。核心配置如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 创建 JSON 序列化器 GenericJackson2JsonRedisSerializer jsonRedisSerializer new GenericJackson2JsonRedisSerializer(); // key 使用 String 序列化便于阅读 StringRedisSerializer stringRedisSerializer new StringRedisSerializer(); // key 和 hashKey 都用 String 序列化 template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); // value 和 hashValue 用 JSON 序列化 template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); template.afterPropertiesSet(); return template; } }这里最让人纠结的问题就是value 到底用 JDK 序列化还是 JSON 序列化我的建议很明确能用 JSON 就不用 JDK。JDK 序列化的好处是对象状态恢复无损但缺点非常致命序列化结果含有类全限定名等元数据体积是 JSON 的好几倍肉眼完全不可读线上 debugging 时redis-cli get key直接看到一堆二进制跨语言取数据基本不可能其他系统要读这份缓存只能干瞪眼。JSON 序列化虽然性能略低一点但换来的是可读性和通用性对绝大多数业务缓存来说完全值得。2.3 连接池与资源控制参数Lettuce 的连接池配置在application.yml里我通常这样配spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3s参数含义很简单但有几个经验值得说max-active不是越大越好。Redis 本身是单线程处理命令连接数开再多命令还是在一个线程里排队执行。过大反而增加线程上下文切换和连接维护开销。一般业务场景 16 到 32 足够。max-wait一定要设置否则连接池耗尽时线程会无限等待最后拖垮整个应用。timeout建议设置 2 到 3 秒。线上 Redis 如果出现网络抖动没有超时时间的话一次 get 操作可能卡住几十秒。database默认 0除非有明确的 Redis 多库隔离需求否则别乱动。用不同的 key 前缀做逻辑隔离比用多 database 更靠谱。2.4 RedisUtils 的字段与构造方式工具类内部直接注入RedisTemplateString, Object即可。因为我们已经配置了 String 类型的 key serializer所以工具类里所有 key 操作天然就是 String。Component public class RedisUtils { private final RedisTemplateString, Object redisTemplate; public RedisUtils(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } }注意这里用的是构造器注入这是我一直坚持的习惯。字段注入写起来轻松但测试时替换 mock 麻烦而且容易让人忽略依赖关系。2.5 为什么 value 必须带类型信息上面配置里我用的是GenericJackson2JsonRedisSerializer重点就在 “Generic” 这个词上。普通的Jackson2JsonRedisSerializerObject默认不会把对象的class类型信息写进 JSON。也就是说存的时候是Product对象取出来反序列化时会变成LinkedHashMap你再强转Product就报ClassCastException。而GenericJackson2JsonRedisSerializer会在 JSON 里额外带一个class字段反序列化时就能还原成原始类型。代价是每条数据会多出类型信息体积稍大。但对业务缓存来说这个代价值得。如果你不想所有数据都带类型信息也可以自定义ObjectMapper只对特定包下的对象写入类型信息。这是进阶优化后面踩坑部分我会再提。3. 核心方法逐个拆解CRUD 之外的那些边界情况3.1 String 类型最简单的操作反而最容易写错String 类型是最常用的但新手最容易在“缓存空值”和“过期时间”上翻车。我的工具类里这几个方法基本是标配public void set(String key, Object value) { redisTemplate.opsForValue().set(key, value); } 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 clazz) { Object value redisTemplate.opsForValue().get(key); if (value null) { return null; } if (clazz.isInstance(value)) { return clazz.cast(value); } throw new IllegalStateException(缓存值类型与目标类型不一致, key key); } public void delete(String key) { redisTemplate.delete(key); } public boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit)); }大概率有人会问get方法为什么要多传一个ClassT因为我统一使用了 JSON 序列化泛型在运行时是会被擦除的如果只写get(String key)返回T内部根本不知道要反序列化成什么类只能返回Object最后还是得在业务里强转。传ClassT之后工具类内部可以做一次类型检查业务方拿到的就是干净的Product、User这类对象不需要自己再JSON.parseObject。这个设计细节好多团队的代码里都是缺的。还有几个高频扩展方法public boolean expire(String key, long timeout, TimeUnit unit) { return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, unit)); } public long getExpire(String key, TimeUnit unit) { Long expire redisTemplate.getExpire(key, unit); return expire null ? -1 : expire; } public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } public long increment(String key, long delta) { return redisTemplate.opsForValue().increment(key, delta); }这里有一个很关键但经常被忽视的点setIfAbsent配合过期时间时一定要用带 timeout 参数的重载版本。有些老代码是这样写的Boolean flag redisTemplate.opsForValue().setIfAbsent(key, value); redisTemplate.expire(key, 30, TimeUnit.SECONDS);这一步不是原子操作。如果setIfAbsent成功后、expire执行前进程崩了或网络抖动这个 key 就成了永不过期。别小看这个细节生产环境里 Redis 内存飙升的案例有不少就是这么来的。3.2 Hash 类型适合存对象的封装细节Hash 适合存“一个对象的多个字段”比如用户信息、商品详情。比起把整个对象序列化成一个大 StringHash 的好处是可以单独更新某个字段不用整个对象读出来再写回去。public void hashPut(String key, String hashKey, Object value) { redisTemplate.opsForHash().put(key, hashKey, value); } public Object hashGet(String key, String hashKey) { return redisTemplate.opsForHash().get(key, hashKey); } public MapObject, Object hashEntries(String key) { return redisTemplate.opsForHash().entries(key); } public void hashDelete(String key, Object... hashKeys) { redisTemplate.opsForHash().delete(key, hashKeys); } public boolean hashHasKey(String key, String hashKey) { return redisTemplate.opsForHash().hasKey(key, hashKey); }这里要注意的是hashEntries的返回类型是MapObject, Object里面 hashKey 在序列化配置后一般是 String但 value 可能是任意对象。如果业务方需要强类型最好再加一个泛型方法底层走convertAndCast之类的逻辑。经验之谈Hash 适合字段变化频繁的对象但如果字段不多、且整体读写为主直接用 String 存 JSON 反而更简单还能避免 Hash 结构在大 key 情况下的性能问题。很多团队一开始图新鲜全部用 Hash后来发现一个 key 里有几万字段hgetall直接卡顿就老老实实换回 String 了。3.3 List 队列操作右侧写入左侧读取List 在项目里最常用的场景就是“轻量级队列”。比如日志异步落库、站内信通知等。工具类里提供最基础的三个操作就够了public void listRightPush(String key, Object value) { redisTemplate.opsForList().rightPush(key, value); } public Object listLeftPop(String key) { return redisTemplate.opsForList().leftPop(key); } public long listSize(String key) { Long size redisTemplate.opsForList().size(key); return size null ? 0 : size; }生产级工具类不建议把rightPushAndTrim这种冷门方法都塞进去但有一个方法值得考虑就是leftPop的阻塞版本public Object listLeftPopBlock(String key, long timeout, TimeUnit unit) { return redisTemplate.opsForList().leftPop(key, timeout, unit); }阻塞队列在很多场景下比轮询更优雅。但要注意阻塞leftPop会占着一个 Redis 连接如果并发量很大连接池会吃紧。所以阻塞时间建议设置短一点比如 2 秒配合循环处理而不是一次阻塞 10 秒。3.4 Set 与 ZSet去重、交集与排行榜Set 系列我一般封装这些方法public void setAdd(String key, Object... values) { redisTemplate.opsForSet().add(key, values); } public SetObject setMembers(String key) { return redisTemplate.opsForSet().members(key); } public boolean setIsMember(String key, Object value) { return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, value)); } public long setRemove(String key, Object... values) { Long remove redisTemplate.opsForSet().remove(key, values); return remove null ? 0 : remove; }ZSet 也就是有序集合最经典的应用是排行榜以及“最近活跃用户”“延时任务”等按分数排序的场景public void zSetAdd(String key, Object value, double score) { redisTemplate.opsForZSet().add(key, value, score); } public SetObject zSetRange(String key, long start, long end) { return redisTemplate.opsForZSet().range(key, start, end); } public SetObject zSetReverseRange(String key, long start, long end) { return redisTemplate.opsForZSet().reverseRange(key, start, end); } public Double zSetScore(String key, Object value) { return redisTemplate.opsForZSet().score(key, value); } public long zSetSize(String key) { Long size redisTemplate.opsForZSet().zCard(key); return size null ? 0 : size; }这里有个常见的需求排行榜要展示用户排名。可以用reverseRankpublic long zSetReverseRank(String key, Object value) { Long rank redisTemplate.opsForZSet().reverseRank(key, value); return rank null ? -1 : rank; }注意rank是从 0 开始的业务展示时如果要显示“第几名”记得先减一再加一。这个细节经常被忽略导致排行榜第一名的排名显示成“第0名”。3.5 Key 操作别把生产环境搞成事故现场keys命令在 Redis 里是极其危险的操作。它会在整个键空间中扫描生产环境 key 数量一多直接阻塞 Redis 单线程导致所有命令排队线上服务雪崩。所以工具类里我会提供两个安全的替代品public SetString keys(String pattern) { try (Jedis jedis new Jedis(redisTemplate.getConnectionFactory().getConnection().getNativeConnection().toString())) { // 这里就不展开了实际建议用 scan 命令 } return redisTemplate.keys(pattern); }上面那段其实是我故意留的反面例子——直接在工具类里new Jedis是绝对不推荐的等于绕过了连接池管理。正确的做法是使用scan游标迭代而不是keys。工具类里更保险的封装是这样public SetString scanKeys(String pattern, long count) { SetString result new HashSet(); RedisConnection connection redisTemplate.getConnectionFactory().getConnection(); try { ScanOptions options ScanOptions.scanOptions().match(pattern).count(count).build(); try (Cursorbyte[] cursor connection.scan(options)) { while (cursor.hasNext()) { result.add(new String(cursor.next(), StandardCharsets.UTF_8)); } } } finally { connection.close(); } return result; }这个方法的原理是Redis 的scan会返回一个游标每次遍历一部分 key下一次接着上次的位置继续不会一次性把所有 key 加载出来对阻塞的影响小得多。Search 场景中如果你已经用了 Redis Clusterscan命令同样可用只是会在多个节点上分别执行。3.6 过期时间与空值缓存的约定一个成熟的工具类必须在“过期时间”这个维度上给业务方提供默认值而不是让每个调用者自己传。我通常的做法是public void setWithDefaultExpire(String key, Object value) { redisTemplate.opsForValue().set(key, value, DEFAULT_EXPIRE, TimeUnit.SECONDS); } public void setCacheObject(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); if (timeout 0) { redisTemplate.expire(key, timeout, unit); } }还有一个非常重要的约定缓存空值。这是应对缓存穿透的最简单手段但在工具类里要显式设计好。假设查数据库没查到业务方如果把null直接塞进 Redis取出来再反序列化时就分不清“缓存里存了个 null”和“缓存没有这个 key”。这两个语义是有区别的。所以我一般约定空值缓存时value 存一个预设的占位符比如空字符串或特殊字符串NULL_VALUE。工具类的get方法里会自动识别占位符并返回 nullprivate static final String NULL_PLACEHOLDER NULL_VALUE; public void setNullCache(String key, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, NULL_PLACEHOLDER, timeout, unit); } public T T get(String key, ClassT clazz) { Object value redisTemplate.opsForValue().get(key); if (value null || NULL_PLACEHOLDER.equals(value)) { return null; } ... }这样业务方就不用关心空值到底怎么存的工具类统一把“查不到”表达成null让判断逻辑干干净净。4. 序列化是隐性大坑乱码、类型对不上和那些诡异异常4.1 一次线上 ClassCastException 排查经历我记得有一次上线后客服端反馈某个用户详情接口报错堆栈信息指向缓存读取Exception 是ClassCastException: java.util.LinkedHashMap cannot be cast to com.xxx.UserDetail。我第一反应是序列化配置没生效。查了代码发现RedisConfig确实配置了GenericJackson2JsonRedisSerializer但问题出在一个同事为了图方便在另一个配置类里又注入了一个新的RedisTemplateObject, Object并且没有覆盖默认序列化器。那部分业务代码用的就是那个“漏网之鱼”的 template存进去的 value 没有带类型信息取出来自然就是LinkedHashMap。这个排查过程很枯燥但它揭示了一个非常现实的教训序列化配置没生效或者不统一是 Redis 相关 Bug 里最常见的一类。所以工具类里的RedisTemplate别用默认的并且建议在配置类里把setKeySerializer、setValueSerializer、setHashKeySerializer、setHashValueSerializer四个全部显式设置。少一个都可能在某天晚上给你搞出线上事故。4.2 Jackson2JsonRedisSerializer 与 GenericJackson2JsonRedisSerializer 的区别这两个类名长得很像很多人搞混。简单说Jackson2JsonRedisSerializerT指定一个目标类型适合只存取一种类型的场景。如果用它做 value 序列化器存不同对象时类型信息会丢失。GenericJackson2JsonRedisSerializer内部会保留class类型字段存什么就能还原什么通用性更强。它们的权衡很明显维度Jackson2JsonRedisSerializerGenericJackson2JsonRedisSerializer类型还原固定类型可以还原任意类型都可以还原数据结构体积小不包含类型信息大多了 class 字段安全性类型固定反序列化风险低没做白名单时有可能反序列化任意类适合场景单一类型缓存通用工具类、缓存各种对象工具类既然面向的是“各种业务方缓存各种对象”自然要选GenericJackson2JsonRedisSerializer。但如果你对安全要求很高可以自己定制ObjectMapper通过activateDefaultTyping或自定义PolymorphicTypeValidator来限制允许反序列化的类避免恶意数据传入时触发不安全反序列化。4.3 自定义 ObjectMapper 的细节如果默认的GenericJackson2JsonRedisSerializer不满足需求比如你想把 LocalDateTime 序列化成可读字符串而不是默认的数组那就需要自定义 ObjectMapper 并塞给序列化器ObjectMapper objectMapper new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(objectMapper);这里面有一个很值得注意的点如果你看到JavaTimeModule说明项目里有用LocalDateTime、LocalDate这类时间类型。默认情况下Jackson 会把它们序列化成{year:2025,...,nano:0}这样的结构不仅可读性差而且反序列化时更容易出问题。加了JavaTimeModule并关闭WRITE_DATES_AS_TIMESTAMPS后时间字段就会变成2025-01-01 12:00:00这种可读格式。4.4 为什么我建议在 value 里保留类型信息网上有争议说“JSON 里带类型信息不好体积大”。这个说法有一定道理但要看场景。在工具类这种通用缓存场景里value 可能是 User、Product、Order、PageResult 等等如果不在 JSON 里带类型那反序列化时就必须靠调用方手动指定ClassT并且要求“传入的类”和“实际缓存的数据类”完全一致。但问题是业务方法很多是返回ListProduct这种带泛型的类型。泛型在运行时是被擦除的等取数据反序列化时如果你只给Product.classJackson 处理ListProduct时依然会把元素转成LinkedHashMap最后循环里再炸一次。带类型信息之后这些复杂嵌套结构都能被准确还原。多出来的几十字节体积相比线上排除ClassCastException的时间成本根本不算什么。确实有一种替代方案是使用ParameterizedTypeReference保留泛型。RedisTemplate的execute系列方法支持这个public T T execute(RedisCallbackT action)但工具类里大量方法都要保留泛型代码会变得非常啰嗦。权衡下来GenericJackson2JsonRedisSerializer是投入产出比最高的选择。5. 分布式锁与缓存一致性工具类里真正考验设计能力的地方5.1 从 setnx 到完整加锁方法的演进很多人提到 Redis 分布式锁第一反应就是setnxBoolean flag redisTemplate.opsForValue().setIfAbsent(lockKey, requestId); if (flag) { try { // 执行业务 } finally { redisTemplate.delete(lockKey); } }但仔细看这段代码至少有四个漏洞没有设置过期时间宕机后锁永远不释放死锁锁没有绑定“持有者标识”A 线程可能把 B 线程的锁删掉释放锁不是原子操作判断 删除之间可能出问题Redis 主从切换时可能丢失锁。工具类里的加锁方法至少要解决前三个最稳妥的方案是配合 Lua 脚本。5.2 用 Lua 脚本保证“判断删除”原子性释放锁时常见的错误写法是if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); }“判断”和“删除”是两步操作中间如果线程被暂停锁过期被别的线程抢走然后再继续执行 delete就会把别人的锁删掉。正确做法是用 Lua 脚本把这两步合二为一public boolean releaseLock(String lockKey, String requestId) { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(luaScript, Long.class); Long result redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId); return Long.valueOf(1).equals(result); }RedisCallback之外还有一种实现方式就是用DefaultRedisScript配合redisTemplate.execute。关键在于 Lue 脚本在 Redis 服务端是原子执行的期间不会被其他命令打断。对应的加锁方法也可以直接基于setIfAbsent带过期时间的原子版本public boolean tryLock(String lockKey, String requestId, long timeout, TimeUnit unit) { return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, timeout, unit) ); }这个setIfAbsent带 timeout 的重载底层对应 Redis 的SET key value NX EX seconds是原子命令不用再手动拼两步。5.3 Redisson 与自研分布式锁的取舍看到这里有人会问那直接用 Redisson 不就行了吗为什么还要在工具类里自己写Redisson 确实更完善它的RLock有看门狗自动续期机制有可重入特性处理 Redis 主从切换也有 redlock 相关方案。但是Redisson 是一个较重的依赖引入了大量额外功能非高并发、非强一致场景下自研的简单锁完全够用一旦用了 Redisson 的RLock又脱离了RedisTemplate的统一封装工具类就要再包一层。我的经验是如果项目里已经有 Redisson就统一用 Redisson 的锁如果项目只是偶尔需要加锁自研简单锁可以支撑绝大多数场景。工具类里自研锁的重点不是“锁有多强”而是保证“所有人都用同一个锁工具”别有人用setnx裸写有人用Redisson有人自己拼 Lua。顺便提一句面试官问到 Redis 分布式锁时你如果能从setnx说到 Lua 脚本再说到 Redisson 看门狗最后承认“自研锁在极端情况下有缺陷”这才是一条完整的知识链。5.4 缓存穿透、击穿、雪崩工具类里的对应解法这三个问题几乎是 Java 面试必问的八股但真正在工具类里落地的人不多。我来对应着说缓存穿透查询一个根本不存在的数据请求直接打到数据库。工具类里的解法是“空值缓存”前面已经讲过。缓存击穿某个热点 key 过期瞬间大量并发请求同时打到数据库。工具类里可以加“互斥锁重建缓存”的封装。缓存雪崩大量 key 在同一时间过期或者 Redis 挂了。工具类层面能做的有限主要靠过期时间加随机值和集群高可用。其中“互斥锁重建缓存”比较适合在工具类里提供。我可以这样设计public T T getWithMutex(String key, ClassT clazz, long expire, TimeUnit unit, SupplierT dbLoader) { T value get(key, clazz); if (value ! null) { return value; } String lockKey LOCK: key; String requestId UUID.randomUUID().toString(); boolean locked tryLock(lockKey, requestId, 5, TimeUnit.SECONDS); if (!locked) { // 没抢到锁短暂自旋重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithMutex(key, clazz, expire, unit, dbLoader); } try { // 双检防止上一个线程已经重建了缓存 value get(key, clazz); if (value ! null) { return value; } T loaded dbLoader.get(); if (loaded null) { setNullCache(key, 30, TimeUnit.SECONDS); } else { set(key, loaded, expire, unit); } return loaded; } finally { releaseLock(lockKey, requestId); } }这个方法的精髓在于“先拿锁拿到锁再查一次”。因为第一个人重建缓存期间可能有多个线程同时进来第一个拿到锁的线程在重建第二个线程又没拿到锁如果不做double check第二个线程在获得锁之后会再查一次数据库这就不叫“互斥重建”了。要注意这个方法是工具类里复杂度最高的一个方法。它用到SupplierT函数式接口业务方使用时很直观Product product redisUtils.getWithMutex(product: id, Product.class, 300, TimeUnit.SECONDS, () - productMapper.selectById(id));这种设计让业务方完全不用关心缓存逻辑只需要提供一个“从数据库加载数据”的函数。5.5 双写一致性先删缓存还是先更新数据库工具类解决不了所有一致性场景但可以在缓存更新/删除的封装上帮业务方规避一些常见问题。关于“先删缓存还是先更新数据库”我的结论是先更新数据库再删除缓存。理由很简单更新数据库后删除缓存即使删缓存失败最多导致这次读到旧值但数据库的值已经是最新的下一次读请求过来会重新加载新值。反过来如果先删缓存再更新数据库更新失败的瞬间后续请求会直接打到数据库把旧值又加载回缓存相当于缓存又被污染的更严重了。工具类里可以设计两个方法public void deleteAfterDbUpdate(String key, Runnable dbUpdateAction) { dbUpdateAction.run(); redisTemplate.delete(key); }不过这个封装比较简陋因为数据库更新和缓存删除不在一个事务里还是可能不一致。更完整方案是订阅 binlog 或定时对账那已经超出工具类范畴。工具类能做的是保证“缓存删除操作至少是可靠的、显式调用的”而不是散落在业务代码里的一个redisTemplate.delete。6. 实测踩坑记录与优化建议6.1 坑一重载方法选错导致缓存 TTL 失效RedisTemplate.opsForValue().setIfAbsent有两个重载Boolean setIfAbsent(K key, V value); Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);我之前见过有人把代码写成了这样redisTemplate.opsForValue().setIfAbsent(key, value, 30L, TimeUnit.SECONDS);看起来没问题但接收参数的是long timeout如果传入的变量是Integer或者直接传30而不是30L,编译时可能自动匹配到错误的重载吗其实不会30会自动提升为long并不会出问题。真正的坑是有些人认为“先 setIfAbsent 再 expire 是原子的”于是把expire单独拎出来调用。一旦 set 成功、expire 前出现异常这个 key 就永远不失效。在工具类里如果发现业务方在代码里手动调expire我建议直接在设计上“消灭”这种用法——提供带过期时间的setCacheObject系列方法替代裸set expire。6.2 坑二Hash、List 批量操作时误用事务导致性能劣化我曾经在一个活动秒杀项目里看到团队成员把批量查询全部包在multi事务里redisTemplate.execute(new SessionCallbackObject() { Override public Object execute(RedisOperations operations) throws DataAccessException { operations.multi(); for (String key : keys) { operations.opsForValue().get(key); } return operations.exec(); } });这里有个问题Redis 事务期间命令不会立即执行而是进入队列同时exec之前其他客户端还是可以读到旧值。对于纯读场景事务不仅不能保证一致还多了一次exec开销。纯读批量场景正确做法是mget或管道而不是事务。工具类的做法是把管道封装好用于大量读请求public ListObject pipelineGet(ListString keys) { return redisTemplate.executePipelined((RedisCallbackObject) connection - { for (String key : keys) { connection.get(key.getBytes(StandardCharsets.UTF_8)); } return null; }); }管道和事务有本质区别管道是“把命令打包一起发减少网络 RTT”不是原子性保证事务是“命令入队后一起执行保证不被其他客户端插入命令”。用错场景要么多掏网络开销要么莫名其妙的“不生效”。6.3 坑三工具类用 static 方法没法注
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻