Spring Boot整合Redis实战:序列化、缓存穿透与高并发踩坑复盘
写这篇文章之前先说个我最近帮一个同事排查问题的场景。他负责的接口加上了 Redis 缓存但并发一高数据库还是被冲垮了。我登上去一看缓存 key 是一串 \xAC\xED 开头的二进制乱码value 也是 JDK 序列化对象接口逻辑里没有空值缓存热点 key 过期的那一秒所有请求全部穿透到了数据库。这不怪他网上一搜“Spring Boot 整合 Redis”大多只告诉你怎么加依赖、怎么写配置类真正上线之后的隐蔽问题很少讲。所以我打算用一篇更完整的实战记录把 Spring Boot 整合 Redis 这件事从头到尾拆开讲先想清楚 Redis 在你项目里干什么再谈依赖、序列化、缓存三兄弟最后给一个能直接参考的商品详情缓存 Demo。这不是一篇只贴代码的教程更多是我在实际项目里踩过坑之后的复盘。无论你正准备第一次往 Spring Boot 项目里加 Redis还是已经加上了但线上偶发异常说不清楚原因这篇文章应该都能帮你少走几步弯路。1. 动手加依赖之前先搞清楚 Redis 在项目里到底承担什么角色1.1 缓存之外的高频角色很多人把 Redis 默认等同于“缓存”这没有错但会限制你的思路。我在项目里见过它被用来干这些事缓存读多写少、实时性要求不高的数据比如商品详情、配置项、用户画像摘要。这是最典型的使用方式。分布式锁多实例部署时同一个资源只能被一个节点处理。Redis 的 SETNX 加上过期时间可以做一个轻量级互斥锁。计数器/限流使用 INCR、INCRBY 统计浏览量、点击数再用 EXPIRE 做时间窗口限流实现成本比数据库计数低很多。会话或临时数据共享集群环境下把 Session 或一次性验证码放到 Redis多个服务实例都能读到。轻量级消息队列用 List 的阻塞读取实现简单的任务队列用 ZSet 做延迟队列。适合内部小规模异步任务但如果消息不能丢、需要事务性投递还是建议用专业的消息中间件。把这些角色想清楚再动手你才不会在第一版设计阶段就把方向走偏。比如有人非要把 Redis 当成主数据库存核心订单那后续的一致性和持久化问题会非常痛苦。1.2 不建议硬塞给 Redis 的数据Redis 虽然快但内存是稀缺资源并不是所有数据都适合往里面放。强事务、强一致的数据缓存本质是牺牲一致性换性能账务、库存扣减这类核心写操作不应该只依赖 Redis 来保证最终正确性。超大对象一个 value 存几 MB 的 JSON初期看着没毛病等量上来之后内存占用、网络带宽、序列化耗时都会成为瓶颈。长期不读的冷数据如果一批数据写入后一个月都不会有人访问占着内存只会挤压热点数据的空间。我在做缓存设计时会先给数据分类哪些可以容忍一段时间不一致哪些是绝对核心数据。缓存永远只是加速层不是数据本身的保险箱。1.3 Lettuce 还是 Jedis默认选择背后的逻辑Spring Boot 从 2.x 开始默认使用 Lettuce 作为 Redis 客户端。Lettuce 基于 Netty一个连接可以被多个线程共享天生支持异步和响应式而 Jedis 更老派每个实例对应一个物理连接使用方式直接但连接管理成本高。这也是 Spring Boot 默认选 Lettuce 的原因连接复用带来的资源节约。不过这里有个常见误解Lettuce 默认共享连接不代表你不需要连接池。连接池体现的是“获取连接”的排队和超时控制不是简单堆物理连接。至于具体怎么配下一节会展开。2. 依赖引入与自动配置Spring Boot 替你做了多少还留了哪些坑2.1 一个 starter 到底带来了什么在 pom.xml 里加上这两个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency第二个依赖很容易被忽略。Spring Boot 的 Redis 自动配置只有检测到 commons-pool2 在 classpath 中才会真正创建连接池对象。也就是说你配置了lettuce.pool.max-active但没引入 commons-pool2这些参数基本不生效应用仍会工作在直连模式下。加上依赖后Spring Boot 的自动配置会做这些事根据RedisProperties创建连接工厂默认实现是LettuceConnectionFactory。创建RedisTemplateString, Object。创建StringRedisTemplate。这里有个很容易被忽略的点自动配置创建的RedisTemplate默认使用JdkSerializationRedisSerializer。它能跑通但你会遇到可读性极差、跨语言困难、类结构变更后反序列化失败等一系列问题。所以很多项目都会自定义一个 RedisTemplate这部分我会在专门章节里讲。2.2 连接参数配置与常见坑不同 Spring Boot 版本的配置前缀不一样这一点最容易翻车。Spring Boot 2.x 使用spring.redis.*Spring Boot 3.x 改成了spring.data.redis.*。假如你在 Boot 3.x 里写的是spring.redis.host启动不会报错但配置不会被读取Redis 会默认连到localhost:6379。我见过几次线上连错 Redis 的事故最后发现都是前缀写错。以 Spring Boot 3.x 为例一份比较稳妥的配置长这样spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpass database: 0 timeout: 3s lettuce: pool: enabled: true max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3s几个参数的含义我解释一下max-active连接池中最大的连接数。不是说一开始就创建 16 个连接而是使用高峰期最多能同时存在的连接数量。max-idle空闲状态下最多保留多少个连接。超过这个数量的空闲连接会被释放。min-idle池中最少保持多少个空闲连接。这个值不宜设太大Redis 服务端不会因为你开了 20 个空闲连接就更快。max-wait从池中获取连接的最大等待时间。如果池已经满了请求在这里排队超过时间就抛出异常而不是无限挂起。timeout在 Spring Data Redis 中既影响连接超时也影响读写超时。设置成 3 秒比较合理如果 Redis 异常导致所有业务请求卡住至少超时后能快速失败而不是让用户一直转圈。2.3 启动成功不等于 Redis 连通Spring Boot 启动时并不会主动连接 Redis只有第一次执行 Redis 操作时才真正建立连接。也就是说你看到一个应用正常启动Redis 配置可能是错的也可能 Redis 服务已经挂了。我习惯在服务里加一个健康检查接口用StringRedisTemplate写一个测试 key 再删掉RestController public class RedisHealthController { private final StringRedisTemplate stringRedisTemplate; public RedisHealthController(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } GetMapping(/redis/ping) public String ping() { stringRedisTemplate.opsForValue().set(health:test, ok, Duration.ofSeconds(5)); return stringRedisTemplate.opsForValue().get(health:test); } }这样至少能在部署后快速确认连接配置是通的。别把“应用能启动”当成“Redis 整合成功”。3. 序列化方案乱码、类型不匹配、和你看不懂的 \xAC\xED3.1 默认序列化器的乱码问题如果你什么都不配置直接用自动创建的RedisTemplate写入字符串然后用 redis-cli 看会看到这样的 key127.0.0.1:6379 keys * 1) \xac\xed\x00\x05t\x00\x03foo这个前缀正是 Java 序列化对象的魔数。JdkSerializationRedisSerializer会把 key 和 value 都用 Java 原生序列化处理对 Java 技术栈来说自洽但对运维不友好其他语言也无法直接解析一旦实体类结构变化历史数据反序列化直接失败。所以序列化方案一定要在写代码初期就定好不要等上了生产再来换。3.2 三种序列化方案怎么选我在项目里主要对比过三种方案StringRedisSerializerkey 和 value 都按 UTF-8 字符串处理适合纯字符串数据也能和 redis-cli 直接交互。Jackson2JsonRedisSerializerObject把对象序列化成 JSON但不保存类型信息反序列化时往往会得到一个LinkedHashMap要自己再转成目标 DTO比较麻烦。GenericJackson2JsonRedisSerializer序列化成 JSON同时会在 JSON 里写入class类型信息反序列化时能直接还原为原来的类型。从维护角度我更推荐这样组合key 用StringRedisSerializervalue 用GenericJackson2JsonRedisSerializer这样 redis-cli 能看到可读的 key缓存对象也能自动还原。但如果你的项目规约要求所有缓存都以字符串形式读写那么直接用StringRedisTemplate加手动 JSON 序列化也很省心。下面这张表是我实际选择时会参考的方案redis-cli 可读性跨语言友好度对象自动还原典型场景JdkSerializationRedisSerializer差差可以但依赖类结构不建议用于生产StringRedisSerializer 手动 JSON较好好需要手动转换通用缓存、接口返回对象GenericJackson2JsonRedisSerializer一般一般自动带 class追求省事的内部缓存3.3 一份可直接落地的 RedisConfig下面这个配置类是我在项目里常用的版本Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }有几个细节必须提醒setHashKeySerializer和setHashValueSerializer很容易漏。如果漏掉Hash 结构的 key 仍然会走默认 JDK 序列化你在 redis-cli 里看到的 Hash 字段也是乱码。自定义 RedisTemplate 之后记得调用afterPropertiesSet()否则序列化器可能没有被正确初始化。GenericJackson2JsonRedisSerializer写入的 JSON 会带class字段查看的时候会看到类似[java.util.HashMap,{id:1}]的内容这是正常的别奇怪。如果你的线上环境已经用默认 JDK 序列化写入了一些旧数据切换序列化器后旧的 key 会读不出来。不要硬写兼容代码更建议在维护窗口期直接清理这些脏数据再用新序列化器重建缓存。清理大批量 key 时生产环境慎用KEYS *它会阻塞 Redis 服务。用 SCAN 增量遍历更安全redis-cli --scan --pattern product:* | xargs redis-cli del如果担心一次管道删除太多可以把 SCAN 的 count 调小分批处理。3.4 StringRedisTemplate 与自定义 RedisTemplate 的取舍如果你的缓存对象结构简单、字段不复杂我建议优先用StringRedisTemplate把对象手动转成 JSON 字符串后写入。理由很朴素手动 JSON 的数据在 redis-cli 里完全可读排查问题方便也不会出现class类型信息把数据撑大的问题。如果每个业务模块都要存各种对象手动 JSON 的代码会很啰嗦这时自定义 RedisTemplate 更划算。但代价是缓存内容里会夹杂类型信息对纯运维查看来说稍微有点噪音。我的习惯是把两种方式分开面向接口响应的缓存用 StringRedisTemplate 加手动 JSON内部临时状态用 RedisTemplate 自动序列化。这样兼顾可读性和开发效率。4. 缓存穿透、缓存击穿、缓存雪崩整合 Redis 之后绕不开的三个坎4.1 三个问题的差异先分清再动手这三个问题经常被混在一起说但它们的成因和应对方式完全不同。缓存穿透查询一个缓存和数据库里都不存在的 key。比如恶意请求随机生成大量不存在的商品 ID每次都直接打到数据库缓存形同虚设。缓存击穿某个热点 key 刚好过期大量并发请求同时发现缓存没了一起回源数据库数据库瞬间压力飙升。缓存雪崩大量 key 在几乎同一时刻过期。比如定时任务给所有商品设置了完全相同的过期时间零点一到全部失效数据库被一波流量打挂。穿透是针对“不存在的 key”击穿是针对“一个热点 key”雪崩是针对“一批 key”。看清区别之后方案会清晰很多。4.2 空值占位与布隆过滤器穿透问题的层层防线对付穿透成本最低的方案是缓存空值。逻辑不复杂查数据库发现结果为空时依然往 Redis 写一个空字符串并设置较短的过期时间比如 60 秒。String value redisTemplate.opsForValue().get(key); if (value null) { Product product productRepository.selectById(id); if (product null) { redisTemplate.opsForValue().set(key, , Duration.ofSeconds(60)); return null; } // 正常写入缓存 }注意空值缓存的时间不能太长否则数据真正写入后用户要等过期才能看到最新数据。通常 30 到 60 秒就够。如果业务对随机非法 ID 的防护要求更高再考虑布隆过滤器。布隆过滤器能高效判断“一定不存在”把不存在的 ID 挡在数据库查询之前。但它的误判率、可删除性、容量扩容都会增加系统复杂度小项目用空值缓存基本就能解决问题不必为了追求技术方案而盲目引组件。4.3 热点 key 过期瞬间用互斥锁重建缓存解决击穿问题我常用互斥锁方案。核心思想是当缓存失效时只允许一个请求去回源数据库其他请求等待这个请求重建缓存后再读取。public String getProductDetailWithLock(Long id) { String key product:detail: id; String cache redisTemplate.opsForValue().get(key); if (cache ! null) { return cache; } String lockKey lock:product: id; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { // 双重检查可能在等待时其他线程已经重建了缓存 String again redisTemplate.opsForValue().get(key); if (again ! null) { return again; } Product product productRepository.selectById(id); String json product null ? : JSON.toJSONString(product); Duration expire product null ? Duration.ofSeconds(60) : Duration.ofMinutes(30); redisTemplate.opsForValue().set(key, json, expire); return json; } finally { // 用 requestId 防止误删其他线程的锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } else { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 递归次数有限实际项目里建议改 while 循环控制重试次数 return getProductDetailWithLock(id); } }这里有几个关键点setIfAbsent(lockKey, requestId, Duration.ofSeconds(5))是 SET NX EX 的原子操作不会出现“加锁成功但过期时间没设置”的情况。释放锁之前要判断 value 是否等于自己的requestId。因为锁可能因业务执行时间过长而自动过期如果不判断旧请求可能删掉新请求已经持有的锁。锁的粒度要尽可能细。这里锁 key 里带了商品 ID只有同一个商品的请求才会互相等待不同商品不受影响。4.4 随机过期时间与多级缓存化解雪崩雪崩最常见的原因是过期时间设置得太“整齐”。解决办法很简单在基础过期时间上叠加一个随机值。long baseExpire Duration.ofMinutes(30).getSeconds(); long expire baseExpire ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(expire));这样同一批 key 的过期时间被均匀打散不会出现集体失效的波峰。缓存预热任务里也建议做同样的处理。更进一步的做法是引入本地缓存作为二级缓存比如应用内放一层 CaffeineRedis 兜底。当 Redis 中的热点 key 失效时本地缓存还能顶住一部分流量。这个方案效果明显但会引入缓存一致性、内存占用管理等问题属于团队需要权衡后再上的复杂方案。5. 完整落一个 Demo商品详情缓存5.1 需求与目录结构为了不纸上谈兵我基于一个最常见的“商品详情查询”场景给出一个最小可运行示例。接口要求是根据商品 ID 返回详情数据量较大查询成本较高适合做缓存。简化后的工程结构如下src/main/java ├── Application.java ├── controller │ └── ProductController.java ├── service │ └── ProductService.java ├── repository │ └── ProductRepository.java └── model └── Product.java为了聚焦 Redis 相关代码数据访问层用ProductRepository抽象实际项目中你可以用 JPA、MyBatis 或任何持久化框架实现。5.2 缓存读服务实现ProductService 的核心逻辑如下Service public class ProductService { public static final String PRODUCT_KEY_PREFIX product:detail:; public static final Duration EMPTY_CACHE_EXPIRE Duration.ofSeconds(60); public static final Duration NORMAL_CACHE_EXPIRE Duration.ofMinutes(30); private final StringRedisTemplate stringRedisTemplate; private final ProductRepository productRepository; public ProductService(StringRedisTemplate stringRedisTemplate, ProductRepository productRepository) { this.stringRedisTemplate stringRedisTemplate; this.productRepository productRepository; } public Product getProduct(Long id) { String key PRODUCT_KEY_PREFIX id; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { if (json.isEmpty()) { return null; } return JSON.parseObject(json, Product.class); } Product product productRepository.selectById(id); if (product null) { stringRedisTemplate.opsForValue().set(key, , EMPTY_CACHE_EXPIRE); return null; } stringRedisTemplate.opsForValue().set( key, JSON.toJSONString(product), NORMAL_CACHE_EXPIRE); return product; } }我特意用StringRedisTemplate配手动 JSON是因为商品详情这类接口返回的数据结构稳定手动 JSON 序列化出的字符串在 redis-cli 里一眼就能看懂。如果出现线上问题任何人都能直接GET product:detail:10001看内容排查成本很低。5.3 数据更新后的缓存一致性缓存不是只读的后端数据更新后缓存怎么办我常用的策略是“更新数据库后删除缓存”由下一次读请求去重建。Transactional public void updateProduct(Product product) { productRepository.updateById(product); stringRedisTemplate.delete(PRODUCT_KEY_PREFIX product.getId()); }更新数据库成功后删除缓存下一次查询会从数据库读取最新数据并重建缓存。这种方式适合缓存重建成本不高的场景。如果缓存重建特别耗时可以在事务提交后异步重建而不是直接删除。还要注意一个顺序问题尽量先更新数据库再删缓存。反过来会出现窗口期旧缓存被读到的风险虽然时间很短但核心场景下依然要避免。5.4 验证缓存是否真的生效很多人写完缓存逻辑只看接口响应快不快不够严谨。我习惯在数据访问层加一个查询计数器验证第二次请求到底有没有查库。Repository public class ProductRepository { private final AtomicLong queryCount new AtomicLong(); public Product selectById(Long id) { queryCount.incrementAndGet(); // 真实查询逻辑 return new Product(id, 测试商品, 100); } public long getQueryCount() { return queryCount.get(); } }然后连续两次调用接口观察第二次请求后queryCount是否没有增加。如果没增加说明缓存真正拦截了查询而不是只把代码写出来就算完事。另外生产环境排查缓存 key 时用 SCAN 而不是 KEYSredis-cli --scan --pattern product:detail:* | wc -l这样可以统计出当前线上到底有哪些商品缓存而不会让 Redis 进入阻塞状态。6. 从踩坑里挤出来的几条经验6.1 连接池大小别拍脑袋决定很多人看到并发高就无脑调大 max-active结果 Redis 服务端连接数暴涨性能反而下降。Lettuce 的连接是支持线程共享的不像 Jedis 那样每个请求都需要独占连接所以连接池更多是在控制“获取连接”的排队行为而不是为每个线程准备一个物理连接。我通常从max-active16、max-idle8、min-idle2起步然后压测观察 Redis 连接数、CPU 和业务接口响应时间再调整。如果请求超过 1000 QPS 就调大到 64反而要怀疑代码里是不是有连接未释放或者其他资源问题。6.2 事务和 Pipeline 的隐蔽坑Transactional注解只能管理 Spring 管理的事务默认条件下 Redis 操作并不会自动加入到这个事务里。你想让 RedisTemplate 配合 Spring 事务必须显式开启redisTemplate.setEnableTransactionSupport(true);开启之后事务期间的操作会被暂存事务提交时再统一发送到 Redis。但要注意这意味着 Redis 命令不会立即执行如果事务很大内存中暂存的命令也会积压。Pipeline 也是一个容易出问题的地方。批量写入几千个 key 时Pipeline 确实快但如果你在 Pipeline 里混入了单个执行也会出错的命令异常可能不会立刻抛出来而是在 flush 阶段才体现。我一般建议只在纯批量写入场景用 Pipeline并且用 try-finally 确保连接资源被释放。6.3 大 Key 与删除方式我在一个订单系统里遇到过价值 2MB 的 JSON 缓存单个 key 占用过大之后内存和带宽压力都非常明显。后来把数据按维度拆成了多个 key问题才缓解。如果你发现某个 key 确实很难避免变大至少要做到两点给缓存设置合理的过期时间避免长期占内存。删除大 key 时用 unlink 代替 delRedis 4.0 开始支持异步删除不会阻塞主线程。Spring Data Redis 高版本中可以直接使用unlink方法或者通过底层的connection执行。删除单个大 key 的性能差异在数据上到几百 MB 时非常明显。6.4 淘汰策略noeviction 是隐藏事故源如果 Redis 没有配置内存上限系统内存耗尽时进程可能被 OOM Killer 干掉。如果配置了 maxmemory但没有配置淘汰策略默认noeviction会让写入直接报错。我习惯在 redis.conf 里这样设置maxmemory 512mb maxmemory-policy allkeys-lruallkeys-lru表示内存满了之后按 LRU 策略淘汰任意 key。如果你的业务有大量必须保留的 key可以用volatile-lru但前提是所有 key 都设置了过期时间。坦白说在缓存场景下我更倾向于allkeys-lru因为总有粗心的人忘记给 key 设置过期时间这时至少还有兜底策略不会让整个 Redis 拒绝写入。6.5 一点个人习惯最后分享一个我自己的实践习惯每个新项目引入 Redis 时我都会先封装一个简单的缓存工具类统一管理 key 前缀、过期时间、空值占位和 JSON 序列化而不是让各个业务直接乱用 RedisTemplate。这样做的最大好处不是代码优雅而是线上出问题时你知道所有缓存行为都遵循同一套规则排查起来会省非常多的时间。缓存这个东西写起来容易压测和运维才是真正见功夫的地方。希望这篇文章能帮你把整合 Spring Boot 和 Redis 时那些“看不见的坑”提前填平。