ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot下Redis实战:序列化、分布式锁与缓存穿透

Spring Boot下Redis实战:序列化、分布式锁与缓存穿透 开头部分从业者口吻引入融入Redis/Java/Spring关键词:搞后端开发这么多年Redis 几乎成了 Java 服务端绕不开的标配组件。无论是做缓存、分布式锁、排行榜还是单纯想要一个高性能的 KV 存储Redis 总是第一个跳进脑子里的方案。尤其是搭了 Spring Boot 之后操作 Redis 的方式从最开始的 Jedis 手写连接池到后来 Spring Data Redis 封装好的 RedisTemplate再到 Redissson 这类功能全面的客户端整个生态已经非常成熟。这篇博文我不打算照搬官方文档而是结合自己在实际项目里踩过的坑、优化过的方案把“在 Java 中操作 Redis”以及“在 Spring/Spring Boot 环境下操作 Redis”这件事从头到尾捋一遍。本文适合谁看刚开始接触 Redis 的 Java 新人在 Spring 项目里用了 RedisTemplate 但总是遇到序列化、乱码、连接池爆掉问题的同学以及准备面试需要重新梳理 Redis 客户端选型和缓存最佳实践的开发者。我会把重点放在实操细节上比如为什么你的 key 在可视化管理工具里是一堆乱码为什么 Redis 缓存雪崩之后服务反而更卡为什么分布式锁用 SETNX 不够还要配合 Lua 脚本。这些都是文档里不会细讲、但生产环境一定会碰到的内容。1. 在 Java 中直接操作 Redis从原生 Jedis 开始理解连接原理1.1 为什么建议先学原生 Jedis而不是一上来就上 Spring 封装很多朋友一进公司接触的就是 Spring Boot 项目直接注入一个 StringRedisTemplate 就开始写业务连 Redis 底层长什么样都没见过。我个人的建议是越是新手越要先学会用原生客户端连一次 Redis哪怕只是在本地写个 main 方法跑通一下。原因很简单Spring Data Redis 帮我们做了太多事情连接管理、序列化、异常翻译全部透明化处理。一旦线上出了问题比如连不上 Redis、key 过期时间不对、数据反序列化失败如果对底层机制没有基本概念排查起来会非常痛苦。我自己带过几个新人排查 Redis 连接超时的时候他们甚至不知道 Redis 默认端口是 6379、不知道 INFO 命令可以看到连接数这就是因为一开始就站在了太高的抽象层。原生客户端我比较推荐先接触 Jedis。Jedis 在 Redis 生态里属于“老牌正统”API 设计直接对应 Redis 命令比如 set、get、hset、expire几乎没有额外包装。你写完 Jedis 代码基本就知道 Redis 命令长什么样。1.2 Jedis 基础用法连接、命令调用、连接池先看一段最基本的 Jedis 操作代码import redis.clients.jedis.Jedis; public class JedisDemo { public static void main(String[] args) { // 连接本机 Redis默认端口 6379 try (Jedis jedis new Jedis(localhost, 6379)) { // 如果 Redis 设置了密码 // jedis.auth(yourpassword); jedis.set(user:1001, 张三); String value jedis.get(user:1001); System.out.println(value); jedis.expire(user:1001, 60); // Hash 操作 jedis.hset(cart:1001, itemId, 20001); jedis.hset(cart:1001, count, 2); String count jedis.hget(cart:1001, count); System.out.println(count); // List 操作 jedis.rpush(queue:order, orderId-001); String orderId jedis.lpop(queue:order); System.out.println(orderId); } } }这段代码核心要注意的是 try-with-resources 的使用。Jedis 实例不是线程安全的所以绝对不能把单个 Jedis 实例放到静态字段里共享给多个线程。正确做法是每次用完就关掉或者从连接池里借一个、用完还回去。实际项目中不会有人像上面这样直接 new Jedis而是使用 JedisPool。原因在于创建 TCP 连接是有开销的如果每个请求都新建连接再关闭在高并发场景下 Redis 端的文件描述符和线程资源会被迅速打满。连接池的本质就是复用连接把网络握手成本降到最低。import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; public class JedisPoolDemo { private static final JedisPool pool; static { JedisPoolConfig config new JedisPoolConfig(); config.setMaxTotal(50); // 最大连接数 config.setMaxIdle(10); // 最大空闲连接数 config.setMinIdle(5); // 最小空闲连接数 config.setMaxWaitMillis(3000); // 获取连接的最大等待时间 config.setTestOnBorrow(true); // 借出连接前验证连接可用 pool new JedisPool(config, localhost, 6379, 2000, yourpassword); } public static void main(String[] args) { try (Jedis jedis pool.getResource()) { String pong jedis.ping(); System.out.println(pong); jedis.set(name, zhangsan); } } }连接池参数里面setTestOnBorrow(true)看似多了一次 PING 命令的往返开销但在生产环境里非常有必要。原因在于Redis 服务端可能因为超时、网络抖动、重启等原因把空闲连接断掉而客户端连接池并不知道连接已经失效拿出来的其实是“半死连接”读写时就会报 Broken pipe 或 Connection reset。开启 TestOnBorrow 之后每次借用连接前会先验证连接是否真的可用不适合的直接丢弃重建。当然这个参数在 QPS 极高的场景下会多出大量 PING 命令所以也有团队选择关闭它并用 TestWhileIdle 替代具体取舍看业务。1.3 Jedis 和 Lettuce 怎么选把 Jedis 理解清楚之后我们再来看现在 Spring Boot 默认集成的 Lettuce。可能有人会问既然 Jedis 这么好用为什么 Spring Boot 2.x 之后默认用 Lettuce 了核心原因是连接模型的不同。Jedis 采用阻塞式 IO一个连接同一时刻只能处理一个命令。想要并发执行命令就必须靠连接池维护多个连接。Lettuce 基于 Netty 实现使用异步事件循环一个连接可以同时处理多个命令天然支持异步和响应式编程模型。在并发量大、连接数受限的场景下Lettuce 的优势非常明显它能让单条连接承载更高的吞吐量。但 Lettuce 也有一个坑共享连接。在 Spring Boot 默认配置下多个线程会共享同一条 Lettuce 连接。如果某个线程执行了一个耗时极长的命令比如 KEYS 全量扫描其他线程的命令会被阻塞在同一个连接上等待。我后面在常见问题章节会再提到这个生产环境需要谨慎处理。从选型角度看我个人的建议是如果你的项目只是做简单缓存读写Spring Data Redis 默认的 Lettuce 完全够用不需要额外折腾连接池如果你的业务对 Redis 操作延迟极其敏感并且是同步阻塞式编程模型为主可以考虑替换为 Jedis如果涉及分布式锁、限流、发布订阅等高级功能直接用 Redisson 更省心。没有绝对最好的客户端只有最适合当前业务场景的客户端。2. 深入 Spring Data RedisRedisTemplate 的配置与序列化问题2.1 引依赖与自动配置的核心机制在 Spring Boot 项目里引入 Redis 操作能力通常只需要一个 starter 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency加完依赖之后Spring Boot 的自动配置会帮我们做以下几件事创建 LettuceConnectionFactory 连接工厂读取 application.yml 里 spring.redis 开头的配置项创建 RedisTemplate 和 StringRedisTemplate 两个 Bean。对于一个只存 String 类型的简单需求甚至一行配置都不用写直接注入 StringRedisTemplate 就能用。这就引出了 Spring Data Redis 的核心组件层级底层是连接工厂ConnectionFactory负责真正建立与 Redis 服务器的连接上层是 RedisTemplate负责把 key 和 value 序列化成字节数组再交给连接工厂发送给服务器。理解这个层级关系之后再看序列化问题就清晰多了。2.2 序列化器配置为什么你的 key 是一堆乱码这是 Spring 环境下操作 Redis 最经典的坑没有之一。默认情况下RedisTemplate 使用 JdkSerializationRedisSerializer 来做 key 和 value 的序列化。这个序列化器会把 Java 对象序列化成二进制字节流在 Redis 客户端比如 Another Redis Desktop Manager里看到的结果就是\xAC\xED\x00\x05t\x00\x04name这样的乱码。更麻烦的是JdkSerializationRedisSerializer 要求被序列化的对象必须实现 Serializable 接口否则直接抛异常。解决思路也很统一自定义 RedisTemplate Bean把 key 的序列化器统一设置为 StringRedisSerializer把 value 的序列化器设置为 Jackson2JsonRedisSerializer这样 key 在 Redis 里是普通字符串value 是 JSON 格式可读性和跨语言性都得到保证。下面是我在项目里常用的配置类import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 使用 String 序列化 StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 使用 Jackson JSON 序列化 Jackson2JsonRedisSerializerObject jsonSerializer new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // 推荐只保存非空的字段减少体积 om.activateDefaultTyping( om.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL ); jsonSerializer.setObjectMapper(om); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有一个细节值得展开ObjectMapper.activateDefaultTyping的作用是让 Jackson 序列化时在 JSON 中写入类型信息反序列化时才能把 JSON 还原成原来的 Java 类型。如果不开启 default typing存一个User对象进去反序列化时会默认反成 LinkedHashMap导致强转会报 ClassCastException。很多新人遇到过这个问题加了一堆JsonTypeInfo注解还是解决不了其实就是这里没配好。但开启 default typing 也有安全风险如果 JSON 数据来源不可信恶意 JSON 可能在反序列化时发起反射攻击。所以生产环境如果对安全要求高更推荐的做法是直接使用 StringRedisTemplate自己在业务层手动序列化和反序列化对象。牺牲一点便捷性换来最直观的存储结果和绝对可控的类型转换。2.3 StringRedisTemplate 和 RedisTemplate 的区别到底该用哪个Spring Data Redis 里默认提供了 StringRedisTemplate它的 key 和 value 序列化器都强制设置为 StringRedisSerializer。也就是说无论存什么最终都会先转成字符串再存进 Redis。那么问题来了业务开发中到底用 RedisTemplate 还是 StringRedisTemplate我的经验判断标准非常直接存简单字符串用 StringRedisTemplate存复杂对象用 RedisTemplate自定义 JSON 序列化。但如果这个复杂对象只是短期存一下比如接口防重用到的一串请求 ID那用 StringRedisTemplate 手动 JSON.toJSONString() 反而更省心。从调优角度说StringRedisTemplate 的优点是存储结果直接可见、跨语言兼容性最好不允许有任何序列化黑魔法也没有反序列化类型丢失问题。缺点是所有对象都要手动序列化代码写起来繁琐而且开发效率低。RedisTemplate 的优点是封装了序列化细节业务代码里可以直接存对象。缺点是需要精心配置序列化器配置不对就会出现乱码和类型转换错误。实际项目里我经常同时注入这两个 Template。比如用 StringRedisTemplate 做分布式锁锁 key 和 value 都是简单字符串用自定义 RedisTemplate 做缓存value 是复杂的查询结果对象。两者各司其职互不干扰。3. Redis 常用数据类型在 Java 项目中的实战用法3.1 String 类型缓存与计数器的正确写法String 是 Redis 中最基础的数据类型在 Java 中的操作也最简单stringRedisTemplate.opsForValue().set(token: userId, token, 30, TimeUnit.MINUTES);这段代码实现了带过期时间的缓存写入30 分钟过期时间通过 TimeUnit 定义确保 Redis 端执行的是 SET key value EX 1800 的原子操作。String 类型的第二个高频场景是计数器。Redis 的 INCR 命令是原子自增操作天然适合做发号器、限流计数和点赞量统计。Long count stringRedisTemplate.opsForValue().increment(article:like: articleId, 1);这里要注意 increment 的返回值。它返回的是自增之后的最新值如果你需要判断“这是第一次自增”可以用increment返回值是否等于 1 来判断。另外一个经验计数器场景往往需要定期回写数据库再从数据库初始化到 Redis所以更新数据库和更新 Redis 的顺序、幂等性都需要提前设计好否则容易出现数据不一致。3.2 Hash 类型对象存储和购物车场景Hash 类型适合存储结构化对象比如商品信息、用户资料、购物车明细。因为 Hash 的底层实现是哈希表读写单个字段的性能极高不需要像 JSON 字符串那样整个读写。用 RedisTemplate 操作 Hash 的示例HashOperationsString, String, Object hashOps redisTemplate.opsForHash(); // 写入用户信息 hashOps.put(user:1001, name, 张三); hashOps.put(user:1001, age, 25); hashOps.put(user:1001, city, 上海); // 读取单个字段 Object name hashOps.get(user:1001, name); System.out.println(name); // 读取全部字段 MapString, Object userMap hashOps.entries(user:1001);购物车场景用 Hash 非常形象key 是用户 IDfield 是商品 IDvalue 是商品数量。用户每次加购只需要 HSET 一次每次修改数量也只更新对应 field。而如果用 String 类型整个购物车序列化成 JSON每次加购都要重新读取完整数据、反序列化、修改、再序列化、再写回性能差距非常明显。Hash 还有一个隐藏优势field 级别可以单独设置过期吗答案是不行。Hash 的过期时间只能设置在 key 层面field 没有独立的 TTL。网上有一些人问 hash 中某个字段能否带过期时间不好意思Redis 不提供这个能力。如果业务上确实需要“部分字段过期”通常的做法是把每个字段拆成独立的 String key或者用定时任务定期清理看具体场景取舍。3.3 List、Set、ZSet 在典型业务中的落地List 类型的典型场景是消息队列、时间线列表、最新公告。Redis 的 LPUSH BRPOP 组合可以实现一个简单的阻塞队列虽然不如 Kafka、RocketMQ 这类专业消息中间件功能强大但在轻量级场景下非常实用。// 左侧写入右侧读取实现 FIFO stringRedisTemplate.opsForList().leftPush(queue:email, emailId); String emailId stringRedisTemplate.opsForList().rightPop(queue:email, 5, TimeUnit.SECONDS);注意看 rightPop 的两个额外参数这是阻塞读取最多等 5 秒。没有数据时线程不会空转而是挂起等待这个能力在做“任务队列”时非常好用。我见过很多同学用 Thread.sleep 轮询的方式消费 List 队列既增加 Redis 压力又造成 CPU 浪费换成 BRPOP 之后清爽很多。Set 类型的核心能力是去重和集合运算。标签系统、用户关注关系、抽奖去重都可以用 Set 实现。求两个用户的共同关注一条 SINTER 命令搞定SetString common stringRedisTemplate.opsForSet().intersect(follow:1001, follow:1002);这个操作如果用数据库实现可能需要 JOIN 或者两次查询后在内存里求交集而 Redis 在服务端直接完成集合运算只把结果返回给客户端。数据量大时性能差距非常显著。ZSet有序集合则是排行榜场景的标配。每个成员有一个分数scoreRedis 根据分数自动排序。实现排行榜只需要两行代码stringRedisTemplate.opsForZSet().add(rank:week, userId, score); // 取前 50 名 SetString top50 stringRedisTemplate.opsForZSet().reverseRange(rank:week, 0, 49);ZSet 的底层实现是跳跃表skip list插入、删除、查询的时间复杂度都是 O(log N)。这意味着百万级用户的排行榜任何操作的耗时时长都在微秒级这是关系型数据库 ORDER BY 很难达到的效果。4. 分布式锁的正确实现SETNX、Lua 脚本与 Redisson4.1 为什么直接 SETNX 不够在只有一个 Java 进程的单机环境里用 synchronized 或者 ReentrantLock 就能解决并发问题。但现在的服务基本都是多实例部署每个实例有自己的 JVM 锁不同实例之间的锁互相独立无法互斥。这时候就需要一个所有实例都能访问到的公共组件来承担“锁”的职责Redis 就是最常见的实现载体。分布式锁最基础的思路是使用 Redis 的 SETNX 命令如果 key 不存在则设置成功如果 key 已存在则设置失败。Java 代码里对应Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, 1, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 执行业务 } finally { stringRedisTemplate.delete(lock:order: orderId); } }这个写法看起来解决了互斥问题但离健壮的分布式锁还差两件事锁超时释放和误删锁。先看超时如果业务执行时间超过设置的过期时间比如 30 秒Redis 会自动删除这个 key锁就失效了。此时第二个线程乘虚而入拿到锁并开始执行同一段业务并发问题就出现了。解决思路是给锁设置合理的过期时间或者使用看门狗机制定时续期。Redisson 内置了这样的机制后面再细说。再看误删锁线程 A 拿到锁后因为 Full GC 暂停了超过 30 秒锁被 Redis 自动释放。线程 B 拿到锁开始执行业务。这时候线程 A 恢复执行完业务之后调用 delete(lock:...直接把线程 B 的锁删掉了。解决思路是删除前先比较锁的 value 是不是自己的标志只有是自己持有的锁才允许删除。这种“先查再删”的操作不是原子的所以必须用 Lua 脚本来保证原子性。4.2 Lua 脚本实现原子性“检查 删除”Redis 的 Lua 脚本可以一次性执行多条命令并且执行过程中不会被其他客户端命令插入天然具备原子性。业界标准的释放锁脚本长这样if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endJava 中调用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); Long result stringRedisTemplate.execute( redisScript, Collections.singletonList(lock:order: orderId), lockValue );这里每个线程在加锁时生成一个全局唯一的 value比如 UUID释放锁时用脚本比较当前 value 是否等于自己的 value相等才删除。整个比较加删除的过程在 Redis 服务端原子完成彻底规避了误删锁的问题。不过到这里锁的超时续期问题依然没有解决。手写续期逻辑非常繁琐涉及一个后台线程每隔一段时间检查锁是否还存在、如果存在就重新设置过期时间而且必须确保线程生命周期正确销毁否则会内存泄漏。4.3 实际项目中我推荐直接用 Redisson如果项目里需要分布式锁我强烈建议直接引入 Redisson 而不是手写 SETNX Lua。Redisson 是 Java 生态中功能最完整的 Redis 客户端之一它的分布式锁内置了看门狗机制获得锁之后如果业务还没执行完后台线程会每 10 秒自动续期到 30 秒确保锁不会因为业务超时而被 Redis 自动释放。同时它还实现了可重入锁、公平锁、读写锁等多种变体。Autowired private RedissonClient redissonClient; public void bizMethod() { RLock lock redissonClient.getLock(lock:order: orderId); boolean locked false; try { // 尝试加锁最多等待 3 秒拿到锁后 30 秒自动过期看门狗会续期 locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { // 执行业务 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } } }Redisson 的使用让分布式锁的代码复杂度几乎降到了和本地锁相同的水平。唯一需要提醒的是Redisson 默认使用 Lua 脚本实现加锁和释放天然保证了原子性不需要我们再单独写脚本。如果只是想让代码尽快跑起来直接用它没有问题。5. 缓存穿透、击穿、雪崩生产环境不可回避的三个问题5.1 问题的本质与现象区分用 Redis 做缓存最怕的不是 Redis 挂掉而是缓存层在最需要保护数据库的时候失效了。三个经典问题业界已经总结得很清楚问题触发场景核心表现系统现象缓存穿透查询一个不存在的数据缓存和数据库都没有每次请求都打到数据库数据库压力异常飙升缓存击穿某个热点 key 过期瞬间大量请求同时访问大量请求在 key 过期的一刻直穿数据库数据库瞬时高并发缓存雪崩大量 key 同一时间过期或者 Redis 宕机多个 key 同时失效请求瞬间全部打到数据库数据库扛不住而宕机很多人容易混淆击穿和雪崩最简单的记忆方式击穿是“单个热点 key 失效”雪崩是“大批 key 同时失效”。击穿的关键是热点数据互斥雪崩的关键是过期时间分散和降级兜底。5.2 结合 Spring 的解决方案组合针对缓存穿透最常用的方案是缓存空值。如果数据库查询结果为空仍然把这个 key 存入 Redisvalue 设为一个特殊标记过期时间设置得短一些比如 60 秒。这样后续相同请求直接命中空值缓存不会继续打数据库。另一种方案是使用布隆过滤器在访问缓存之前先用布隆过滤器判断 key 是否存在不存在直接拦截。布隆过滤器的特点是“判断不存在一定不存在判断存在不一定存在”用它挡掉绝大部分恶意或不合理的穿透请求非常有效。针对缓存击穿核心思路是互斥更新。在缓存失效的一瞬间只允许一个线程去访问数据库并回填缓存其他线程等待或降级。Redisson 的分布式锁正好派上用场public String getDataWithLock(String key) { String value stringRedisTemplate.opsForValue().get(key); if (value ! null) { return value; } // 加锁只有一个线程能进入回填逻辑 RLock lock redissonClient.getLock(lock:cache: key); boolean locked lock.tryLock(); if (locked) { try { // double check防止等待锁的线程重复查询 value stringRedisTemplate.opsForValue().get(key); if (value ! null) { return value; } value queryFromDb(key); stringRedisTemplate.opsForValue().set(key, value, 10, TimeUnit.MINUTES); return value; } finally { lock.unlock(); } } else { // 没抢到锁休眠后重试 Thread.sleep(50); return getDataWithLock(key); } }针对缓存雪崩最简单的方案是让缓存的过期时间不要集中在同一个时间点。比如在原有过期时间基础上加一个随机数5 分钟过期的缓存变成 5 分 10 秒、4 分 50 秒、5 分 30 秒这样的分布。另一个方案是使用多级缓存比如本地 Caffeine 缓存做第一层Redis 做第二层数据库做第三层即使 Redis 层大面积失效本地缓存依然能承担一部分请求。从 Spring 层面还有一个更优雅的方式就是用 Spring Cache 的注解加上 TTL 配置。但这里我要多说一句Spring Cache 注解适合简单场景一旦涉及自定义过期时间、多级缓存、防击穿注解化的方案会变得笨重不如直接用 RedisTemplate 手写缓存逻辑灵活。6. Spring Cache 注解方式操作 RedisCacheable 到底好用吗6.1 注解方式的基本用法Spring Cache 提供了 Cacheable、CachePut、CacheEvict 三个核心注解。Cacheable 先查缓存有就直接返回没有就执行方法并把结果写入缓存。CachePut 每次都执行方法并更新缓存。CacheEvict 删除缓存。一个典型的用户查询服务Service public class UserService { Cacheable(value user, key #userId, unless #result null) public User getUserById(Long userId) { // 查询数据库 User user userMapper.selectById(userId); return user; } CacheEvict(value user, key #userId) public void updateUser(Long userId, User newUser) { userMapper.updateById(newUser); } }这里的 value 是缓存分区名称对应 Redis key 的前缀key 是 SpEL 表达式对应 Redis key 中的业务标识。默认情况下实际 Redis key 是user::1001这种格式。6.2 踩坑记录注解方式的三个局限我对 Spring Cache 注解的态度是“合适场景可以用但不建议万事皆注解”。原因有三第一过期时间配置不够灵活。Cacheable 的默认过期时间需要在全局配置中设置不同缓存分区想用不同的 TTL 比较麻烦。虽然可以通过自定义 CacheManager 做多个缓存管理器但代码复杂度直线上升。第二无法优雅处理缓存穿透和击穿。Cacheable 没有内置缓存同步机制当缓存过期时多个线程会同时进入方法体查询数据库。对于 QPS 很低的内部系统问题不大但对于高并发的对外接口这就是一个隐患。第三返回值序列化策略不好统一。如果方法的返回值是一个泛型或者接口类型反序列化时容易出现类型丢失问题。前文提到过 Jackson default typing 的安全风险在 Spring Cache 中同样存在。如果你决定用注解模式我建议把复杂业务逻辑从 Controller/Service 中拆出来单独建一个 CacheService只把简单的、缓存逻辑相对固定的方法放在里面。复杂的缓存逻辑还是回归 RedisTemplate 手动管理灵活度更高。7. 常见问题与排查技巧实录Redis 实战排坑速查表7.1 六个高频问题及解决方案我把这些年实际遇到过的 Redis 操作问题整理成了一个速查表。这些问题在官方文档里都能找到原理依据但真正在生产环境把它们联系起来却往往需要踩几次坑问题现象根本原因排查思路解决办法RedisTemplate 存入的 key 在客户端看到乱码默认使用 JdkSerializationRedisSerializer检查 key 格式是否以\xAC\xED开头自定义 RedisTemplatekey 改用 StringRedisSerializer存对象后取回来强转报 ClassCastExceptionJackson 反序列化时类型信息丢失检查 JSON 字符串里是否带 class 字段开启 ObjectMapper default typing或手动序列化高并发下 Redis 连接超时Lettuce 共享连接被阻塞查看 Redis 服务端当前连接数、客户端线程堆栈调整 Lettuce 配置或替换为 Jedis 连接池缓存过期瞬间数据库被压垮热点 key 击穿观察数据库 QPS 与缓存命中率曲线加分布式锁互斥回填或使用本地缓存兜底分布式锁释放了别人的锁未判断锁的持有者直接 DELETE查看锁的 value 是否一致使用 Lua 脚本保证“比较 删除”原子性Redis 响应慢但 CPU 不高慢命令阻塞单线程执行 SLOWLOG GET 查看慢命令禁用 KEYS、HGETALL 等 O(N) 命令7.2 几个容易被忽略的实操心得第一个心得是千万不要在线上用 KEYS 命令。Redis 是单线程模型KEYS 命令会遍历所有 key数据量一大就会阻塞整个 Redis 服务影响所有业务。如果需要扫描符合条件的 key应该使用 SCAN 命令配合游标分批次获取。第二个心得是Batch 批量操作要谨慎使用。Redis 的 pipeline 模式能显著减少 RTT往返延迟但如果在 pipeline 里执行了非预期的写操作出问题时排查很困难。建议 pipeline 只封装同一类命令不要混入复杂逻辑。第三个心得是Redis 连接池不是越大越好。连接数过多会消耗 Redis 服务端的内存和线程资源反而降低整体吞吐。一般单实例 Redis 的 maxTotal 设置为 50 到 100 之间足够。如果你的系统需要超过这个量级的连接数说明业务并发已经很高不如先考虑引入 Redis Cluster 集群拆分连接。8. 一个综合案例在 Spring Boot 中实现缓存 分布式锁 数据一致性8.1 案例背景这里分享一个我自己在电商项目中做过的真实案例商品详情页的缓存热点处理。商品详情是典型的“读多写少”场景一个爆款商品可能在秒杀活动期间被几十万用户同时访问。如果把所有流量都放到数据库上数据库必然扛不住所以必须用 Redis 做缓存。但又不能直接用最普通的「先查缓存没有就查库再塞缓存」逻辑因为热点 key 的击穿风险会让数据库在缓存失效瞬间被打爆。8.2 核心实现代码我在项目中最终采用了「本地 Caffeine 缓存 Redis 缓存 数据库」三级架构并把分布式锁放在 Redis 层做互斥回填。简化后的代码如下Service public class ProductDetailService { private static final String PRODUCT_CACHE_KEY product:detail:; Autowired private StringRedisTemplate stringRedisTemplate; Autowired private RedissonClient redissonClient; // 本地 Caffeine 缓存过期时间 10 秒 private final CacheString, String localCache Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .maximumSize(10_000) .build(); public ProductDetail getProductDetail(Long productId) { String cacheKey PRODUCT_CACHE_KEY productId; // 1. 查询本地缓存 String localValue localCache.getIfPresent(cacheKey); if (localValue ! null) { return JSON.parseObject(localValue, ProductDetail.class); } // 2. 查询 Redis 缓存 String redisValue stringRedisTemplate.opsForValue().get(cacheKey); if (redisValue ! null) { // 回填本地缓存 localCache.put(cacheKey, redisValue); return JSON.parseObject(redisValue, ProductDetail.class); } // 3. Redis 未命中加分布式锁互斥回填 RLock lock redissonClient.getLock(lock: cacheKey); boolean locked lock.tryLock(50, 10, TimeUnit.MILLISECONDS); if (!locked) { // 未拿到锁说明别的线程正在回填可以短暂重试 try { TimeUnit.MILLISECONDS.sleep(50); return getProductDetail(productId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } try { // Double Check避免拿到锁后重复查询数据库 redisValue stringRedisTemplate.opsForValue().get(cacheKey); if (redisValue ! null) { localCache.put(cacheKey, redisValue); return JSON.parseObject(redisValue, ProductDetail.class); } // 4. 真正查询数据库 ProductDetail detail productMapper.selectById(productId); if (detail null) { // 缓存空值防止缓存穿透 stringRedisTemplate.opsForValue().set(cacheKey, , 60, TimeUnit.SECONDS); localCache.put(cacheKey, ); return null; } String json JSON.toJSONString(detail); stringRedisTemplate.opsForValue().set(cacheKey, json, 30, TimeUnit.MINUTES); localCache.put(cacheKey, json); return detail; } finally { lock.unlock(); } } }这段代码中的几个设计细节我逐个解释一下。本地 Caffeine 缓存的过期时间设置成了 10 秒远小于 Redis 的 30 分钟。这是故意的因为每个实例的本地缓存只有 10 秒的数据窗口差即使某实例的本地缓存被穿透后续也还有 Redis 可以兜底。Redis 中设置了空值的缓存并且只有 60 秒过期这是为了防穿透的同时不会长期占用内存。加锁的等待时间只有 50 毫秒拿不到锁就快速重试。为什么不让所有线程都傻等因为热点 key 的数据库回填通常很快如果 50 毫秒内拿不到锁说明锁持有者可能已经出了问题与其继续等待不如回到递归重试的路径上等锁被释放后再走缓存命中。8.3 数据一致性问题的处理思路缓存和数据库之间的一致性是分布式系统里永远绕不开的话题。业界常用的套路是先更新数据库再删除缓存。为什么不是先删除缓存再更新数据库因为删除缓存之后、更新数据库之前如果有一个请求读取数据发现缓存为空会把数据库中的旧数据重新回填到缓存导致在很长一段时间内缓存都是旧数据。反过来操作先更新数据库再删除缓存只要删除这一步成功下一次读取就一定能拿到新数据。那么删除缓存失败怎么办我的做法是引入重试机制。删除失败的消息放入本地内存队列后台定时任务每隔一段时间重试删除直到成功。更工程化的方案是使用 RocketMQ 或 RabbitMQ 的延迟消息来做重试。如果团队人手紧张用 Spring 的 Scheduled 定时扫描失败记录也是可以的。还有一点需要提醒很多团队为了加快响应会采用先更新数据库再异步删除缓存的方案。异步操作的顺序要严格保证“数据库更新完成后才发起缓存删除”否则仍然存在并发窗口导致的数据不一致。如果你无法接受任何短暂的数据不一致那就只能上分布式事务或者引入更复杂的 binlog 监听方案比如 Canal 解析 MySQL binlog 后再主动清理缓存。这个方案实施成本较高但能把一致性做到近乎实时的程度。我对上面这些内容的实际操作体验是绝大多数 Redis 使用问题都不是 Redis 本身能力不够而是封装层使用不当。Java 和 Spring 生态给开发者提供了极其便利的 Redis 操作方式但越便利的封装越容易让人忽略底层机制。理解连接池原理、序列化机制、原子性约束你对 Redis 的掌控力会完全不一样。最后再分享一个小技巧遇到 Redis 诡异问题时先用命令行 redis-cli 登录服务器手动敲几个相关命令复现一遍再回去看 Java 代码。多一层对照往往能最快定位到是代码问题、配置问题还是 Redis 本身的问题。
返回列表