Spring Boot整合MongoDB与Redis实战:非关系型数据库交互指南

发布时间:2026/10/13 7:42:40
Spring Boot整合MongoDB与Redis实战:非关系型数据库交互指南
1. 项目背景与选型思考做 Java Web 开发的人尤其是用了几年 Spring Boot 的心里大多有个默认公式业务数据就是丢 MySQL、Oracle 这类关系型库用 JPA 或 MyBatis 操作。这套组合拳打得很顺能覆盖八成场景。但等你真的遇到那种几千台服务器同时上报日志、一个用户画像文档嵌套几十个字段、或者同一份热点商品数据被几万人并发读的场景关系型数据库就会开始露出疲态性能瓶颈、建模僵化、扩展困难接踵而至。我这次要聊的是 Spring Boot 系列第 15 篇的主角与 Mongo、Redis 这类非关系型数据库交互。标题说“交互”实际上就是把 Spring Boot 应用和 NoSQL 存储之间的通信通道彻底打通包括依赖引入、连接配置、数据建模、Repository 编程、事务与缓存实践以及绕不开的序列化、一致性、索引调优等细节。这篇文章适合谁已经能熟练用 Spring Boot 操作 MySQL、但对 NoSQL 只是听说过的后端开发工作里即将接触用户行为分析、推荐系统、消息缓存、会话管理等任务的可以参考里头的思路直接落地。就算你所在团队暂时没引入 Mongo 或 Redis我建议你也把这篇看完因为 Spring Boot 官方对 NoSQL 的支持设计得非常典型理解这套模型之后再去接 ES、Cassandra 之类的存储你会发现套路都差不多。在动手写代码之前我想先把“为什么非要用非关系型库”这件事聊透。很多人一上来就纠结具体 API却忽略了一个根本问题你的业务到底哪里痛如果只是把关系型库当缓存用那 Redis 反而不是最优解如果数据明明是结构化强关联的强行拍扁成 JSON 文档后面查起来想哭都来不及。所以这篇我先把选型思路放在最前面再一步步带你过实操。1.1 什么时候该非关系型数据库出手我经历过一个典型的业务场景。某平台要做一个用户行为轨迹分析模块客户端每操作一次就向后端上报一条事件内容包括用户 ID、页面路径、停留时长、设备信息、来源渠道等。峰值时每秒要写入大几千条而且这批数据的价值在于“全量留存”要支持按用户和时间维度做聚合分析而不是频繁更新单条记录。这种场景丢给 MySQL写压力大、表结构要反复加字段、历史数据膨胀后查询越来越慢。换 Mongo 之后文档结构直接对应一次事件上报字段天然是半结构化的写吞吐也高出一大截。Redis 则是另一种思路。它不追求“存住所有数据”而是追求“热数据访问极快”。比如用户登录后维护一个 Session、首页展示的 Top10 商品、接口层要防抖的并发计数器这些数据如果每次都要查数据库能把库拖垮。把它们扔进 Redis就是以内存换速度以过期策略换内存空间。我个人的判断标准很简单数据量大、写入频繁、结构松散变化快 —— 优先考虑文档型 Stock如 MongoDB数据要求极低延迟访问、有自然过期逻辑、需要计数器或排行 —— 用键值型 Redis数据强一致、多表关联复杂 —— 老老实实留在关系型库。1.2 Spring Boot 官方对 NoSQL 的适配思路Spring Boot 之所以适合整合非关系型数据库不单纯因为它提供了 starter 依赖更关键的是它把两套编程模型都打包得很顺手。对 MongoDBSpring Boot 给出的是 spring-boot-starter-data-mongodb上层是 Spring Data MongoDB基本照搬了 JPA 那套“实体类 Repository 接口”的写法对 Redis给出的是 spring-boot-starter-data-redis核心是 RedisTemplate 的模板操作以及基于注解的缓存抽象。这种设计对 Java 开发者的友好之处在于你不需要重新学一套操作数据库的语言Mongo 的查询条件在 Spring Data 里被封装成 Criteria 对象Redis 的命令被拆成了 opsForValue、opsForHash、opsForZSet 等方法代码写起来很像是在操作一个 Map 或 List。但友好归友好深水区的东西依然得自己摸清楚比如 Mongo 的事务是需要副本集环境的Redis 的分布式缓存有穿透、击穿、雪崩三兄弟等着你还有序列化器的选择不当会直接让你在 Redis 里存出一堆乱码。提示官方适配层做得再好用也只是给你搭了个桥。桥对面是 NoSQL 自己的运行机制不理解这些机制桥搭得再好你也会在对面迷路。2. 环境准备与依赖引入2.1 本地开发环境的快速搭建我建议你直接用 Docker 搭环境干净又省事。如果你机器上没装 Docker这一步骤也可以手动下载对应版本的压缩包安装不过管理和切换版本会麻烦一些。MongoDB 我这边用的是 6.x 版本直接执行docker run -d \ --name mongo-local \ -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAMEadmin \ -e MONGO_INITDB_ROOT_PASSWORDadmin123 \ mongo:6这条命令启动了一个带认证的 Mongo 实例用户名密码都预设好了。现在很多镜像默认无认证跑起来虽然方便但生产环境免不了要开密码所以我建议从一开始就养成带认证的习惯。Redis 更简单没有那么多认证逻辑docker run -d \ --name redis-local \ -p 6379:6379 \ redis:7 \ redis-server --requirepass redis123注意这里把访问密码通过启动参数传进去了后面 Spring Boot 配置里要带上。当然Redis 本身也有 daemonize、save 等一堆配置Docker 启动时可以通过--appendonly yes开启 AOF 持久化这样可以防止重启后内存数据全部丢失。我在本地把这两个容器跑起来之后习惯先用客户端敲几个命令验证一下。Mongo 这边用 mongosh 或者其他 GUI 工具连一下Redis 直接redis-cli -a redis123 ping看能不能返回 PONG。环境通了再进代码别上来就写配置出了问题反而分不清是哪一层的锅。2.2 在 Spring Boot 项目中引入依赖项目基础是用 Spring Boot 3.xJava 版本 17 以上。pom.xml 里加入这两段dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency第一次接触的同学可能会问不是还要引入 MongoDB 驱动包吗其实 starter-data-mongodb 里已经通过依赖传递把官方 Java 驱动带进来了不需要额外加。Redis 也是同理starter-data-redis 里打包了 Lettuce 客户端。有一点值得多说几句Spring Boot 的自动配置会扫描 classpath 里有没有对应的驱动类然后自动创建 MongoTemplate、RedisTemplate 等核心 Bean。所以你只要引了依赖、写了链接配置不需要自己写new MongoClient()或new JedisPool()这些样板代码。这也是 Spring Boot “约定优于配置”的核心体现。2.3 核心连接配置与参数含义Spring Boot 的配置非常简单在 application.yml 里加上spring: data: mongodb: uri: mongodb://admin:admin123localhost:27017/user_trace authentication-database: admin data: redis: host: localhost port: 6379 password: redis123 timeout: 3s lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0Mongo 的连接串要说明一下格式mongodb://用户名:密码主机:端口/数据库名。注意如果账号是在 admin 库创建的必须加authentication-database: admin否则应用会拿当前库去认证直接报认证失败。Redis 端这里用了 starter-data-redis 默认的 Lettuce 连接池配置我故意把 timeout 设成 3 秒避免某个 Redis 操作卡死时整个请求线程一起挂掉。这个 3 秒是“等待获取连接的最大时间”不是操作 Redis 命令本身的超时。生产环境我一般还会再单独配spring.data.redis.lettuce.pool.max-active这个参数决定并发高峰期能同时拿多少连接设太小会请求阻塞设太大又费资源。注意连接超时的默认值在不同版本间不太一样有的版本默认 60 秒对于高并发实时接口来说太长。我习惯主动写死一个更小的值宁可超时失败重试也别让用户无脑等待。3. Spring Data MongoDB 交互实战3.1 数据模型设计——从 JSON 到 Java 文档进入代码阶段第一步是把业务文档映射成 Java 类。我拿用户行为事件来当例子。一个事件包含Document(user_trace_event) public class UserTraceEvent { Id private String id; private String userId; private String pagePath; private Integer staySeconds; private MapString, Object extraInfo; private LocalDateTime occurredAt; Indexed private LocalDateTime createdAt; }注意几个关键注解。Document告诉 Spring Data 这个类对应 MongoDB 中的哪个集合collection不写的话默认用类名小写下划线命名。Id映射 _id 字段可以 String 类型也可以 ObjectId 类型如果用 StringSpring Data 会自动把 ObjectId 转成字符串。我在occurredAt上没用索引但有业务查询经常按“创建时间范围 用户 ID”来筛所以给createdAt加了Indexed。这个注解非常有用Spring Boot 启动时会自动扫描实体类中的索引注解并去数据库里创建省得手动敲命令。嵌套文档怎么处理Mongo 的强项就是文档中间可以嵌对象。比如设备信息可以直接嵌成一个子对象private DeviceInfo device;DeviceInfo不需要标Document它只是普通 POJO。Spring Data 会把它序列化为 Mongo 文档里的子文档。对应原来的关系型思维这里不需要建第二张表、不搞外键数据直接贴在主文档上。查询时也能直接按内嵌字段去过滤甚至建联合索引。3.2 Repository 接口编程与复杂查询封装Spring Data MongoDB 提供了MongoRepository接口用法和 JPA 的 Repository 几乎一致。我定义一个查询接口public interface UserTraceEventRepository extends MongoRepositoryUserTraceEvent, String { ListUserTraceEvent findByUserIdOrderByOccurredAtDesc(String userId); ListUserTraceEvent findByPagePathAndUserId(String pagePath, String userId); long countByUserIdAndOccurredAtBetween(String userId, LocalDateTime start, LocalDateTime end); }方法名的解析规则和 JPA 那套一样Spring Data 会在启动时拼接对应的查询条件。不过我自己实际用下来方法名越长越容易踩雷比如关键字大小写不敏感导致拼错、方法名过长难读这类问题排查起来比 SQL 更隐性。所以简单的条件我推荐用方法名复杂查询一定要切到Query注解。用Query结合 Mongo 的 JSON 查询语法Query({ userId: ?0, occurredAt: { $gte: ?1, $lte: ?2 } }) ListUserTraceEvent searchUserEvents(String userId, LocalDateTime start, LocalDateTime end);这里的?0、?1对应方法参数按顺序替换。最直观的作用是你可以把复杂的嵌套条件、数组操作、正则等用 Mongo 原生语法写清楚不用担心方法名解析器的能力边界。3.3 MongoTemplate 的灵活操作Repository 能覆盖八成需求但有些动态拼接条件的场景会很别扭。比如前端传了一堆过滤条件可选字段有十几个你没法为每种组合都写一个方法。这时候我直接用MongoTemplate它是 Spring Data MongoDB 底层的操作入口像 JdbcTemplate 之于 JDBC。Service public class TraceQueryService { private final MongoTemplate mongoTemplate; public TraceQueryService(MongoTemplate mongoTemplate) { this.mongoTemplate mongoTemplate; } public ListUserTraceEvent search(TraceQueryParam param) { Criteria criteria new Criteria(); if (StringUtils.hasText(param.getUserId())) { criteria.and(userId).is(param.getUserId()); } if (StringUtils.hasText(param.getPagePath())) { criteria.and(pagePath).regex(param.getPagePath()); } if (param.getStart() ! null param.getEnd() ! null) { criteria.and(occurredAt).gte(param.getStart()).lte(param.getEnd()); } Query query new Query(criteria); query.with(Sort.by(Sort.Direction.DESC, occurredAt)); query.skip(0L); query.limit(20); return mongoTemplate.find(query, UserTraceEvent.class); } }这种写法的好处是“条件全是运行时拼的”随时增删都不会影响其他查询。但使用时要克制不要把大量业务逻辑放进这种动态查询里否则到后期条件组合爆炸性能也无法预估。3.4 事务机制与副本集的坑很多人会惯性认为Mongo 是 NoSQL就不能谈事务。这其实是老黄历了。MongoDB 4.0 之后支持多文档事务Spring Data MongoDB 也通过Transactional把事务管理抽象了出来。但有个硬性前提必须运行在副本集或分片集群环境下不是独立节点就能用的。我本地用 Docker 跑的是单节点想测试事务就得先搭副本集。做法是用 docker-compose 启动一个单副本集 Mongo或者在 Mongo 客户端里执行rs.initiate()把一个独立节点初始化为副本集。这个步骤不复杂但容易卡在主机配置上尤其是 IP 地址写不对导致副本集选举不成功。写事务的代码本身和 JPA 没什么区别Transactional public void recordEventAndUpdateSummary(String userId, UserTraceEvent event) { userTraceEventRepository.save(event); userSummaryRepository.incrementEventCount(userId); }但要注意MongoDB 事务默认有 60 秒空闲超时事务中如果有耗时操作记得保证整体在超时时间内完成。另外一个隐藏的坑是在事务里调用 MongoRepository 的 save 方法时Spring Data 会做对象版本检查如果遇到乐观锁冲突会直接抛异常这在你高并发测试时经常遇到。3.5 索引理解与查询性能定位NoSQL 的性能表象很容易让人迷惑以为它天生就快。其实写快是底层存储引擎的事查询快不快完全看你有没有建索引。Mongo 查询慢的排查方式我之前会直接在 GUI 工具里执行同名查询看 explain 的输出。现在更多的是在业务代码里关于慢查询的日志里找到查询语句复制到客户端里跑一遍观察 winningPlan 是什么。如果 winningPlan 是 COLLSCAN说明全集合扫描大概率是要建索引如果是 IXSCAN说明走了索引再观察有没有 SORT 阶段如果有 sorting 又没对应的索引还会有内存排序上限的问题默认 100MB 内存限制一超就报错。注意Mongo 的联合索引也要遵循“等值字段在前范围字段在后”的规则顺序反了索引效果大打折扣。4. Spring Data Redis 交互实战4.1 序列化器的选择是成败关键Redis 数据是键值形式键和值都需要经过序列化才能存进去。Spring 默认用的是 JdkSerializationRedisSerializer这个选择我只想用四个字评价坑到极致。它会把对象转成一段带着类信息的二进制流你在 redis-cli 里看到的就是\xAC\xED\x00\x05t...这种乱码而且反序列化要求客户端必须有对应的类定义一旦改了包名或类结构老数据全部废掉。我的建议是键用StringRedisSerializer保证人能看懂值用Jackson2JsonRedisSerializer或者直接简单粗暴用GenericJackson2JsonRedisSerializer。配一个自定义 RedisTemplateConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }很多新人会漏掉HashKeySerializer和HashValueSerializer导致 set key、value 都对了但操作 Hash 类型时依然写入乱码。我在项目里踩过一次这个坑花了大半天排查后来发现就是忘了配 Hash 的序列化器。4.2 业务中高频操作的封装技巧RedisTemplate 的操作入口是各种opsForXxx()方法opsForValue对应 StringopsForHash对应 HashopsForZSet对应有序集合opsForList对应 List。用起来的直观感受是它把 Redis 命令“Java 化”了。以我之前做的首页热点数据缓存来举例Component public class HotRankCache { private final RedisTemplateString, Object redisTemplate; private static final String HOT_KEY hot:rank; public void recordView(String productId) { redisTemplate.opsForZSet().incrementScore(HOT_KEY, productId, 1); } public SetObject getTopProducts(long topN) { return redisTemplate.opsForZSet().reverseRange(HOT_KEY, 0, topN - 1); } }用 ZSet 的 score 存浏览次数每次访问 1取 TopN 时直接从尾部倒序取出性能和开发效率都很高。这种数据结构思维是关系型数据库时代很难有的。4.3 Cacheable 注解的快速接入Spring 的缓存抽象最大的价值是“业务代码零侵入”。你只需要在启动类或配置类上加EnableCaching然后在需要缓存的方法上标注注解Service public class ProductService { Cacheable(cacheNames product, key #productId) public Product getProductById(String productId) { // 走数据库查询 return productMapper.selectById(productId); } CacheEvict(cacheNames product, key #productId) public void updateProduct(Product product) { productMapper.updateById(product); } }这里默认的 CacheManager 会使用我们刚才配置的 RedisTemplate 来存取数据。Cacheable先查缓存命中直接返回不命中则执行方法体再写入缓存CacheEvict在更新后主动淘汰缓存避免脏数据。有一个细节值得注意Cacheable默认在 key 对应的值不存在时会走方法体但如果你查询数据库返回了 nullSpring 默认不缓存 null这会导致频繁打穿缓存到数据库。解决方式是在注解里加unless #result null或者缓存空值并设置极短过期时间。下一章我会专门聊聊这个三兄弟问题。4.4 Redis 分布式锁的正确打开方式在单机多线程场景下Java 自带的 synchronized、Lock 够用。但一旦部署了多实例共享资源互斥就得用分布式锁。Redis 分布式锁最经典的方式是 SETNX 过期时间 Lua 脚本释放。一种简洁的写法是用 RedisTemplate 模拟public boolean tryLock(String key, String requestId, long expireSeconds) { Boolean success redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } public boolean releaseLock(String key, String requestId) { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong script new DefaultRedisScript(luaScript, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), requestId); return Long.valueOf(1).equals(result); }注意几点锁的 value 必须唯一比如 UUID不能随便用一个固定字符串否则可能出现误删别人锁的问题过期时间必须有避免持有锁的线程宕机导致死锁释放锁必须走 Lua 脚本先比对再删除两步操作带原子性。若是生产级项目我其实更推荐引入 Redisson它自己封装了看门狗续期机制不用操心跳过期的细节。5. 常见问题与排查技巧实录5.1 MongoDB 连接失败对比排查Mongo 连接失败是我见过最高频的错误而且报错信息往往模棱两可。这里有几种典型场景和排查思路认证失败检查连接串的用户名密码以及 authentication-database 是否指向正确的库主机地址解析失败如果用了 localhost 无法解析改成 127.0.0.1 试试防火墙拦截Docker 端口没映射出来或宿主机防火墙没放行 27017认证库写错最常见账号在 admin 建的但 URI 里指向了业务库连接时就会报Authentication failed。我给同事排查过一个问题Spring Boot 日志里报超时但用客户端连得上。最后发现是该环境网络 DNS 解析慢导致底层驱动在建立连接时耗时超过 Java 连接超时时间。解决办法是把 URI 里写 IP 而不是域名并且在应用配置里适当放大 connection-timeout。5.2 Redis 出现乱码和类型转换异常乱码问题几乎都是由序列化器配置不当引起。RedisTemplate 配了 StringRedisSerializer 作为 key 序列化器但 value 走默认 JDK 序列化时存进去的值是全平台不可读的二进制。另一个隐藏问题发生在你同时用了 RedisTemplate 和 StringRedisTemplate 操作同一个 keySpring 自带的 StringRedisTemplate 强制 key、value 都是字符串拿 RedisTemplate 存进去的对象取出来自然报类型转换异常。我的排查建议是先别急着改代码用 redis-cli 查看具体 key 的内容如果看到包裹的可读 JSON说明 value 序列化器是对的如果是\x开头的一串乱码那基本确定走到 JDK 序列化了如果 key 也乱那 key 序列化器大概率也没配对。地处理方式只有一个统一全局的 RedisTemplate 配置不要两个 Bean 混用。在关键业务中必要时我可以只准一个 RedisTemplate 实例手工注入不给 Spring 隐式装配的机会。5.3 缓存穿透、击穿与雪崩的处置方案Redis 当缓存用数据一致性虽然简单但高并发隐患很大。这三个问题我分别处理过穿透请求查询一个不存在的 key每次都打到数据库。解决办法之一是缓存空值设置几十秒过期另一个是使用布隆过滤器在缓存前拦截大多数不存在的 key。这两种方案我会根据实际场景选择。如果数据集合本身不大布隆过滤器更省内存如果不存在的 key 分布稀疏缓存空值更直接。击穿某个热点 key 过期瞬间有大量请求同时访问数据库瞬间被打爆。解决办法是加互斥锁在缓存重建时只让一个线程查库其余线程等待或直接降级。分布式实现就用上一篇提到的 Redis 分布式锁。雪崩大量 key 在同一时间过期导致请求集中打到数据库。解决方式很简单给过期时间加随机偏移量比如基础过期时间 10 分钟再随机加 1~300 秒错峰过期。5.4 双写一致性与缓存淘汰策略Redis 缓存数据库双写是业务里没法回避的问题。我处理这种问题的原则是不做“更新数据库同时更新缓存”这种高风险操作而是删除缓存让下次读取时回源。原因在于并发场景下先更新缓存再写数据库一旦写库失败缓存里已经是新数据但库里还是老的不一致窗口可能很长。而“先淘汰缓存再更新数据库”虽然也有极小概率读请求把旧数据加载到缓存但结合过期时间兜底最终能收敛。如果追求强一致就得考虑消息队列、订阅数据库 binlog 等异步方案但复杂度和成本都高不到万不得已不上。提示缓存一致性没有银弹。先确认你的业务能接受多长时间的短暂不一致再决定用哪种缓存策略。很多尴尬的故障其实不是技术问题而是业务要求没想清。6. 收尾我的一些经验总结回头再看非关系型数据库接入 Spring Boot 这件事技术细节都不难学难的是数据结构思维转换。MySQL 里你下意识设计第三范式把数据拆成一张张表再 joinMongo 里你得反过来想能内嵌就内嵌因为内嵌数据在读取时一次 IO 全拿Redis 里你得想的是这个数据结构用什么类型存最省空间、读写最便捷。这三种思考方式完全不一样硬拿关系型思维套 NoSQL只会写出一堆又慢又别扭的代码。我在团队里给大家定的规矩很简单非关系型数据库不能一开始就引入必须是现有架构有明确痛点了再接入一旦接入了要定义好数据边界不要让 Mongo 存了 MySQL 该存的强关联数据也不要让 Redis 承担持久化主存储的角色配置和序列化这些基础事项必须在开发初期统一不然后期改造成本高到你想重写整个模块。另外我还想提一个小技巧在你本地调试时把 Spring Data MongoDB 和 Redis 的 SQL 日志打开也就是在配置文件里加logging.level.org.springframework.data.mongodb.coreDEBUG以及logging.level.org.springframework.data.redis.coreDEBUG。这样你能清楚看到每次查询实际生成了什么样的 Mongo 查询文档、Redis 执行了哪些命令、有哪些序列化动作发生。比在客户端里猜来猜去高效得多。这个系列到这一篇关系型、非关系型的路都走了一遍。后面如果再往下写我可能会分享怎么给 Spring Boot 应用做多数据源切换以及 NoSQL 和 SQL 数据之间的同步方案。网上讲基础配置的文章很多但实际工程里把两种存储无缝拼在一起才是真正需要花心思的地方。