尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SpringBoot整合MyBatis与Redis的实践笔记

SpringBoot整合MyBatis与Redis的实践笔记 代码不会说谎但配置能让你怀疑人生。当Spring Boot遇上MyBatis和Redis表面看是三个框架的联姻实则是一场关于数据读写路径的重新缔约。MyBatis负责把SQL从业务代码里剥离出来Redis则把热数据从数据库的磁盘IO中劫持走。但真正动手整合时你会发现自己踩的坑远比收获的多缓存穿透、序列化乱码、事务边界模糊、连接池耗尽……这篇文章不打算给你一份“照着抄就能跑”的教程而是把我在生产环境里摸爬滚打出来的实践细节连同那些让人抓狂的异常堆栈一并摊开给你看。第一块基石依赖与版本别小看这里整合的第一步就藏着暗礁。Spring Boot 2.x默认使用Lettuce作为Redis客户端而Spring Boot 1.x用的是Jedis。如果你还在用老项目升级那么Lettuce连接池配置与Jedis并不兼容这是第一个坑。我的建议是直接采用Spring Boot 2.7搭配MyBatis Spring Boot Starter 2.3和Jedis 3.7如果你偏好连接池语义清晰或者干脆拥抱Lettuce的异步特性。但记住依赖版本不一致导致的NoSuchMethodError往往比业务逻辑bug更难排查。pom.xml里还需要特别注意spring-boot-starter-data-redis和mybatis-spring-boot-starter的作用范围。后者会自动配置SqlSessionFactory但如果你想在润色MyBatis的XML映射文件时使用通配符typeAliasesPackage请确保它指向的包路径不会误扫到Redis配置类。数据源与连接池高并发下的一次真实崩溃一个常见误区是既然用了Redis数据库压力就小了连接池可以随意设置。现实抽过耳光之后才会懂Redis命中的概率再高也架不住缓存失效瞬间的洪峰流量。我有一次生产事故就是因为把HikariCP的最大连接数调成20结果一次热点活动缓存过期600个并发请求直接打到数据库连接池立刻耗尽报错信息堆积如山。HikariCP确实快但请合理设置maximum-pool-size至少参考你数据库的max_connections的1/2到2/3。MyBatis的配置同样要跟上map-underscore-to-camel-casetrue是必须的否则你会在resultType映射中浪费两个小时。还有cache-enabled这个属性默认是true意味着MyBatis的二级缓存是开启的。如果你同时用了Redis做业务缓存请把MyBatis的二级缓存关掉否则会出现数据陈旧和缓存序列化混乱的双重暴击。做法是在application.yml中设置mybatis.configuration.cache-enabledfalse或者干脆不用默认缓存只依赖Redis做你的缓存层。Redis的序列化器选择JSON还是JDK一次关于乱码的抉择整合中最容易出怪问题的就是RedisTemplate的序列化方式。默认的JdkSerializationRedisSerializer会将对象序列化成二进制存到Redis里是一堆\xAC\xED\x00\x05t\x00开头的乱码。这不但可读性差而且跨语言时完全没法用。如果你只是给Java服务自己用图省事可以接受但凡有数据清洗工具、PySpark或者前端直接读Redis的场景这套序列化方案就是噩梦。我的做法是自定义RedisTemplatekey使用StringRedisSerializervalue使用GenericJackson2JsonRedisSerializer。同时要注意GenericJackson2JsonRedisSerializer会序列化对象类型信息如果对象里有LocalDateTime这类Java时间类需要额外注册JavaTimeModule否则反序列化时会抛出InvalidDefinitionException。记住一条铁律存入Redis的对象必须有无参构造函数且不要使用内部非静态类。否则你会在下午三点看见生产环境报出一堆奇怪的BeanInstantiationException而这时候你只想骂人。缓存穿透与击穿别再写低端解决方案了很多人一谈缓存穿透就习惯性加redisTemplate.opsForValue().setIfAbsent。但真正实践里你需要区分“穿透”和“击穿”是两种完全不同的野兽。穿透是查询一个不存在的id每次都绕过Redis直击数据库击穿是热点key失效瞬间大量请求同时打到数据库。对于穿透最有效的策略是布隆过滤器或者缓存空值注意设置较短过期时间比如60秒。对于击穿互斥锁SetNX是标准解法但互斥锁代码写不好会造成死锁或线程等待风暴。我推荐一个简易的互斥锁实现使用redisTemplate.opsForValue().setIfAbsent(key:lock, 1, Duration.ofSeconds(5))失败则自旋等待并重试获取缓存。但注意自旋重试必须设置最大等待时间且超过阈值后直接放行查库否则宁可牺牲一致性也要保护数据库。这里我踩过的坑是——用单机Redis做锁当锁过期业务还没执行完另一个线程拿到锁同时查库导致数据不一致。后续升级到Redisson的看门狗机制才彻底解决。高并发场景下请直接使用Redisson别手写分布式锁。事务与缓存一致性先写库还是先删缓存这是所有缓存实践中最折磨人的问题。先更新数据库再删除缓存——这是抖机灵的标准答案但实际工程里删缓存失败怎么办先删缓存再更新数据库——那并发读会把旧数据重新写进缓存同样不一致。我实践下来的可靠方案是先更新数据库再延迟双删即删除缓存等待500ms再次删除缓存。这虽然有点土但在没有可靠消息队列的场景下它能顶住大部分并发倾斜。更优雅的做法是引入Binlog监听Canal或事务消息异步删缓存。但如果你连Redis都还没整合明白别急着上Canal成本太高。先从最朴素的双删开始配合重试机制删除失败则放进本地内存队列定时重试。注意延迟双删中的“延迟”不是随意的必须大于一次写操作从库到主库的同步时间这需要你自己量测。缓存预热的姿势别让第一个请求去扛启动时把热点数据加载进Redis看起来简单但如果你用PostConstruct去同步加载那会阻塞服务启动。我的实践经验是使用ApplicationRunner 线程池异步预热或者干脆在Redis启动后由专门的Job基于访问日志统计TopN进行刷新。但这里有个更细的坑如果预热的数据量很大比如10万个key一次性pipeline塞入也会导致Redis主线程阻塞被监控系统报警。务必分批每批500个key间隔100ms。而且预热时要监控Redis内存和命中率。预热不是越多越好内存是昂贵的缓存命中率才是衡量的唯一指标。建议预热时先查一下数据库的慢查询日志把耗时TOP20的查询作为预热的候选集。MyBatis与Redis的融合二级缓存到底怎么选如前所述你大概率关掉了MyBatis自带的二级缓存。但如果你真的需要跨Session共享查询结果且数据变更不频繁那么使用Redis做MyBatis二级缓存实现是一个可选方案。这时你需要实现Cache接口重写putObject和getObject方法调用RedisTemplate存储。但我劝你慎重因为MyBatis二级缓存是粗粒度的它没有基于key的失效机制一旦对某张表执行了更新整个缓存的namespace都会被清空。如果你的表行数巨大这种清空操作会带来严重的缓存雪崩。实践中的折中方法是保留一级缓存默认开启二级缓存只针对极少数的、数据量小且几乎不更新的字典表比如地区表、状态枚举表。其他业务数据一律使用自主的Redis缓存Key模式做到精确失效。记住一句话缓存设计的本质是失效策略的设计。如果失效策略模糊那么缓存就是一颗定时炸弹。Redis连接池配置Lettuce的坑你避开了吗如果你用了Spring Boot 2.x默认的Lettuce当并发量上来时经常会遇到Cannot get Jedis connection那是Jedis的报错Lettuce则是RedisConnectionFailureException或io.lettuce.core.RedisCommandTimeoutException。Lettuce默认的超时时间是60秒看似很长但一旦Redis节点发生阻塞所有线程都会卡在命令上拖垮整个应用。一定要设置spring.redis.timeout3000ms并且开启连接池校验。注意Lettuce的连接池配置需要在commons-pool2依赖存在时才生效。很多新手以为配了max-active就行结果发现没生效就是因为少了org.apache.commons:commons-pool2这个依赖。另外Lettuce在Redis Sentinel模式下如果主从切换它可能会重连失败需要设置spring.redis.sentinel.master等参数并在代码里加一层重试切熔断。实践告诉我生产环境最好加上Hystrix或Resilience4j保护Redis调用否则一次Redis GC停顿就会让整个服务雪崩。字符串与Hash存储结构的选择决定性能边界存对象是用String存JSON还是用Hash存字段这在低并发下没什么区别一旦数据量大差异就显现了。String存JSON的好处是读取快、写入快但修改一个字段也要全量覆盖。Hash则可以只更新某个字段节省网络IO。但Hash有个隐患field超过512个时Redis内部编码从ziplist转为hashtable内存占用会暴涨。如果你用Hash存储用户的10000个积分流水那将是一场内存灾难。我的建议是小型对象字段少于100用Hash大中型对象整体JSON小于1KB用String而对于列表数据考虑用SortedSet按时间排序。另外所有key的命名规范必须是业务前缀:模块:ID。比如user:profile:12345这样在排查问题时可以直接用redis-cli --scan --pattern user:profile:定位。监控与排查的工具链别等出事了才想起来整合完成后你需要一套可视化的监控手段。Redis自带的INFO命令能提供内存、命中率、连接数但它的历史趋势是缺失的。用Prometheus redis_exporter采集指标配合Grafana面板才能直观看到缓存命中率、内存碎片率、阻塞客户端数。MyBatis侧可以通过mybatis-plus的SQL日志插件或p6spy打印慢SQL但生产环境别一直开着SQL输出IO开销不小。我习惯在关键缓存方法里埋点用Micrometer记录缓存命中/未命中次数、Redis执行耗时、锁等待时间。这些度量数据比日志堆栈更有价值因为它们能告诉你系统的“呼吸节奏”。记住当你看到缓存命中率低于80%时不要急着加缓存先检查你的key是否合理、过期时间是否太短。一段能直接上生产的自定义RedisTemplate配置理论说了这么多上硬菜。以下这段配置是我在多个项目里打磨过的可以直接参考。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key使用String序列化 StringRedisSerializer stringRedisSerializer new StringRedisSerializer(); // value使用JSON序列化 GenericJackson2JsonRedisSerializer jsonRedisSerializer new GenericJackson2JsonRedisSerializer( objectMapper()); template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); template.afterPropertiesSet(); return template; } private ObjectMapper objectMapper() { ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); om.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); return om; } }注意ObjectMapper一定要定义成单例否则序列化器每次new一个ObjectMapper会带来不小的性能损耗。另外如果你用Jackson2HashMapper替代GenericJackson2JsonRedisSerializer写Hash时字段类型会丢失反序列化时可能变成LinkedHashMap导致强转失败。我在实践中吃过这个亏所以这里强烈推荐GenericJackson2JsonRedisSerializer并确保所有实体类有无参构造。缓存管理封装统一一个工具类别到处乱写当你同时使用多个缓存场景时最恐怖的是每个Service都自己写redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)。这会导致过期时间不一致、key命名混乱、后续难以维护。我建议封装一个CacheService提供泛型的getOrSet方法并通过接口回调加载数据库数据。public T T getOrSet(String key, ClassT type, SupplierT dbLoader, long timeout, TimeUnit unit) { T result redisTemplate.opsForValue().get(key); if (result ! null) { return result; } // 加锁防止击穿 String lockKey key :lock; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (locked) { try { result redisTemplate.opsForValue().get(key); if (result ! null) { return result; } result dbLoader.get(); if (result ! null) { redisTemplate.opsForValue().set(key, result, timeout, unit); } else { redisTemplate.opsForValue().set(key, , timeout, unit); // 缓存空值 } return result; } finally { redisTemplate.delete(lockKey); } } else { // 等待后递归调用但需要限次 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getOrSet(key, type, dbLoader, timeout, unit); } }这段代码融合了防击穿和防穿透但注意递归可能导致栈溢出。优化方案是改用循环或者设置最大等待时间。另外在缓存空值时一定要设置比正常值短的过期时间比如300秒否则“穿透”会变成“永久穿透”。那些你一定会遇见的坑整理成救命清单1. Redis连接泄露——如果你使用redisTemplate.execute拿到连接后没归还连接池会耗尽。注意setEnableTransactionSupport会对连接绑定线程务必在事务结束后清空线程绑定。2. Spring Cache与Redis的注解乱用——Cacheable默认key是方法参数CacheEvict会清空整个缓存分区。混用时小心互相覆盖。建议团队内部统一使用自主封装的CacheService少用注解缓存。3. MyBatis懒加载与Redis序列化冲突——如果你的实体类有MyBatis懒加载代理比如association配置了fetchTypelazy当该对象被Redis序列化时代理对象无法被Jackson正确序列化。解决办法是禁用MyBatis懒加载或者使用DTO对象进行缓存。4. 大key问题——一个key里的value超过1MB读写都会阻塞Redis超过毫秒级。排查手段是使用redis-cli --bigkeys扫描对发现的超大hash或list进行拆分。我在实践里将一个存储用户完整资料的超大JSON拆成了几个小key性能提升立竿见影。5. Redis集群模式下Multi-key操作失效——如果你用RedisClustermset、mget、pipeline跨slot会报错。在设计key时需要用哈希标签{}把相关key放在同一个slot比如user:{123}:info和user:{123}:orders。实践后的最终心得整合Spring Boot、MyBatis和Redis本质上是在数据库这张大网旁边织一张更轻快的缓存网。但这张网编织得不好反而会拖累整个系统。不要迷信Redis的速度要敬畏网络IO的损耗不要滥用缓存每一次缓存都带来一致性代价。多想想你的数据生命周期多久变一次允许多旧并发集中在哪里这些问题想透了技术方案自然水到渠成。最后啰嗦一句把这份笔记中的配置和代码抄下来用可以解决80%的问题。但另外20%的问题需要你亲自动手打印堆栈、查看INFO命令输出、用SCAN怼着生产环境查key才能学会。实践笔记的意义不在于告诉你答案而在于让你在排错时有一个可参照的坐标系——如果你遇到了和我相同的问题至少你可以确定我不是一个人。
返回列表