资讯详情 Spring Boot对接MongoDB:CRUD、分页、事务与索引实战全解析
📅 2026/10/22 23:23:47
用 Spring Boot 接 MongoDB网上搜到的教程大多能跑到“接口通了”这一步但真正搁到生产环境里CRUD 能不能扛住并发、分页翻到后面是不是越来越慢、事务到底为什么不起作用、索引加了为什么还是不生效——这些问题很少有人讲透。这篇文章围绕 Spring Boot 整合 MongoDB 的四条主线CRUD、分页、事务、索引讲一讲我这几轮真实项目里反复踩过、验证过的做法。适用范围是那些打算用 Spring Data MongoDB 做业务系统、又不想只停留在“跑通 Demo”阶段的开发者。下面说的每个点我都会给你理由顺便把容易出错的地方一起标出来。1. 依赖与配置先把连接层的地基打牢很多项目出问题都不是出在业务代码而是从依赖选择和生产连接配置那一步就已经埋下了雷。1.1 最小依赖组合和版本选型Spring Boot 整合 MongoDB核心依赖其实只有一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency这个 starter 会把spring-data-mongodb以及配套的 MongoDB 驱动一起拉进来不需要再手动塞一个mongodb-driver-sync。如果你用的是 Spring Boot 3.x默认走的是 Jakarta 命名空间配套的驱动版本也是 4.x 以上。很多人在这一步犹豫要不要加spring-boot-starter-data-mongodb-reactive。如果你没有 WebFlux、没有流式处理的需求别加。同步加响应式两套驱动一起在 classpath 里自动配置会出现一些模棱两可的行为尤其是事务管理和 MongoTemplate 的装配容易绕晕。本着“能少一个变量就少一个变量”的原则非响应式项目就老老实实用同步 starter。1.2 连接串、连接池和“看不见的自动配置”application.yml里最省事的写法是这样spring: data: mongodb: uri: mongodb://user:passlocalhost:27017/appdb但真要到生产我建议把连接池参数写清楚spring: data: mongodb: uri: mongodb://user:pass10.0.0.11:27017,10.0.0.12:27017/appdb?replicaSetrs0maxPoolSize60maxIdleTimeMS150000waitQueueMultiple5maxPoolSize和数据库实例规格、应用并发线程数直接相关。并发高的应用连接池太小会出现获取连接超时开太大又占着不必要的文件描述符。一个粗略的起步值核心业务线程数 × 1.5然后做压测微调。maxIdleTimeMS要小于 MongoDB 服务端的连接空闲回收时间否则你拿到一条被服务端悄悄关掉的连接第一次读写会报连接重置然后靠驱动重试勉强撑过去白白增加延迟。这里还有一个容易忽略的点Spring Boot 的自动配置会注册一个MongoTransactionManager。这个 manager 好不好用取决于你用的是副本集还是单节点。如果连的是单节点事务相关操作会直接抛异常。所以配置阶段就要确认生产环境必须用副本集形态的 MongoDB哪怕是只有一个节点的副本集。后面讲事务的时候还会再展开。2. 从 Document 到 Repository写 CRUD 之前先定这三件事CRUD 是数据库应用的基本盘但 MongoDB 没有表结构约束反而更需要在编码前约定好实体映射、集合边界和更新策略。不然写了半个月集合里的字段名和类型能乱到没法看。2.1 实体映射既要像 Java也要像 BSON实体类上两个注解最重要Document和Id。Document(user_profile) public class UserProfile { Id private String id; private String mobile; private String nickname; Field(created_at) private Instant createdAt; Version private Long version; }几点经验集合名建议显式写。Document(user_profile)比Document默认生成的userProfile更可控。线上改集合名很伤定义时就别把这件事交给框架。Java 字段名和 MongoDB 字段名不是非要一致。通过Field把数据库字段明确映射比如 Java 里叫createdAt库里存created_at这样以后 DBA 看库、写聚合管道都不懵。ObjectId可以不暴露。主键我喜欢用String类型Spring Data 会完成ObjectId的自动转换。前端拿到的 ID 就是一个普通字符串序列化阶段不用额外写JsonSerialize。Version是乐观锁的标志。Spring Data MongoDB 在更新时会自动把版本号拼进更新条件里如果更新影响行数为 0说明数据已被其他线程改过直接抛OptimisticLockingFailureException。业务上做“防并发修改”时这个字段很值钱建议默认加上。2.2 Repository 接口设计别把查询全堆在一个文件里MongoRepository是 Spring Data 提供的便捷入口但很多人用成了“大杂烩”。接口里方法越堆越多最后几十个查询方法谁也不敢动。我的习惯是按聚合根拆Repository public interface UserProfileRepository extends MongoRepositoryUserProfile, String { OptionalUserProfile findFirstByMobile(String mobile); PageUserProfile findByStatus(Integer status, Pageable pageable); long countByStatus(Integer status); }这里有个查询命名的小规律findFirstByMobile返回的是Optional即使库里真有重复数据也只取第一条。手机号这类业务上要求唯一的字段用findFirstBy至少不会因为历史脏数据把接口打挂。涉及多集合关联、聚合管道的查询Repository 的命名方法搞不定这时候就需要MongoTemplate。Repository 和MongoTemplate可以共存这是我非常推荐的组合简单操作用 Repository复杂查询用MongoTemplate不要为了“风格统一”强迫自己在一个接口里造出所有花活。Service public class OrderQueryService { private final MongoTemplate mongoTemplate; public ListOrderEntity searchOrders(String userId, Integer status) { Query query new Query(Criteria.where(userId).is(userId) .and(status).is(status)); query.fields().include(orderNo).include(amount); return mongoTemplate.find(query, OrderEntity.class); } }2.3 更新操作updateFirst、upsert 与并发控制MongoDB 的更新不是“先查后改”而是一条命令直接在服务端完成匹配和修改。Spring Data 里对应三条常用 API方法行为适用场景save有_id就替换没有就插入全量覆盖不建议做部分字段更新updateFirst更新匹配的第一条大部分业务更新upsert匹配不到就插入计数器、状态同步用MongoTemplate做局部更新的典型写法Query query Query.query(Criteria.where(orderNo).is(NO-20240901-001) .and(status).is(1)); Update update new Update() .set(status, 2) .inc(retryCount, 1) .set(updatedAt, Instant.now()); UpdateResult result mongoTemplate.updateFirst(query, update, OrderEntity.class); if (result.getModifiedCount() 0) { // 说明条件没匹配上可能是状态已经变了按业务逻辑处理 }我特别想提醒两点第一save是“全量替换”。如果文档有多个字段一个线程读出来改了一个字段后save回去另一个线程同时改了别的字段后save的线程会把另一个线程的修改覆盖掉。部分字段更新一律用Update配合set/inc别用save兜底。第二upsert和普通update有个隐蔽差异upsert插入新文档时查询条件里不参与$set的字段不会自动写入文档。比如Query里的orderNo如果只在条件是出现、没在Update里set新插入的文档就没有orderNo字段。所以做 upsert 时凡是应该落库的关键条件字段都要在Update里显式set一遍。3. 分页查询从 pageable 到深分页治理“分页”听起来简单但 MogoDB 的分页和 MySQL 有本质区别。MySQL 的LIMIT 100000, 20是等一等就有MongoDB 的skip会把前 100020 条数据全部扫一遍再扔掉——这个成本会随页数增长直线上升。3.1 Repository 分页和 MongoTemplate 分页的差异Spring Data 的 Repository 分页写法是Pageable pageable PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, createdAt)); PageUserProfile page userProfileRepository.findByStatus(1, pageable);Page接口会自动多执行一条count查询来返回总条数。对于后台管理列表这个count可以接受但对于 C 端接口每次翻页都去count全表在高并发下非常浪费。如果你只需要“上一页/下一页”的展示不需要显示总数和总页数用Slice会更好Repository public interface UserProfileRepository extends MongoRepositoryUserProfile, String { SliceUserProfile findByStatus(Integer status, Pageable pageable); }Slice只确认“有没有下一页”不会执行count。列表页用Slice “加载更多”按钮性能能差一个量级。如果用MongoTemplate分页逻辑要自己组装Query query new Query(Criteria.where(status).is(1)); query.with(PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, createdAt))); long total mongoTemplate.count(query.skip(0).limit(0), UserProfile.class); ListUserProfile list mongoTemplate.find(query, UserProfile.class);这里我额外说明一个细节count查询要记得把skip和limit拿掉否则count会跟着分页条件一起算返回的分页总数就不对了。这个坑我见过不止一次。3.2 深分页为什么慢以及“游标式分页”的替代方案当用户翻到第 100 页时Pageable底层生成的是db.user_profile.find({status: 1}).sort({createdAt: -1}).skip(1980).limit(20)skip(1980)不算可怕可怕的是翻到第 10000 页skip(199980)会把前面近 20 万条文档全部遍历一次。这就是深分页性能恶化的根源。替代方案是“基于位置的游标分页”更准确的说法是“基于排序字段值翻页”。思路很简单不告诉数据库“我要从第 N 条开始”而是告诉它“我要从某条记录之后开始”。public ListUserProfile pageByCursor(String lastId, Instant lastCreatedAt, int size) { Query query new Query(Criteria.where(status).is(1) .and(_id).gt(lastId)) .with(Sort.by(Sort.Direction.ASC, _id)) .limit(size); return mongoTemplate.find(query, UserProfile.class); }首屏不用传lastId直接查第一页点“下一页”时把上一页最后一条的_id传回来。这里有几个关键点排序字段必须唯一且稳定。_id天然唯一是最省心的选择。如果想按业务时间排序可以用created_at但必须在排序字段后面再拼一个_id作为第二排序键防止同一时刻有多条数据造成顺序抖动。_id是ObjectId时它本身带有时间戳维度按_id升序能近似按插入时间升序。如果想“按创建时间倒序 游标翻页”就得把createdAt也带在游标条件里条件会复杂一档但依然是 O(1) 性能。游标分页无法直接跳页。用户只能一页一页翻不能一键跳到第 500 页。这是产品需求上的取舍——如果业务方坚持要跳页那只有两条路限制页数上限或者接受skip的成本。再补充一个工程上的细节使用游标分页时前面出现过新插入的数据会导致下一页出现“漏数据”或“重复数据”。一般的处理策略是以_id为游标时可以配合“只允许向前翻”不提供回退或刷新机制。大多数信息流场景都能接受这个约束。4. 事务能力从哪里来副本集、会话与 Transactional如果你之前把 MongoDB 当“能存 JSON 的缓存”用那事务功能可能一直没开过。但到了订单、账户这类资金链路跨文档原子性就躲不掉了。4.1 前置条件没有副本集事务就是空话MongoDB 的分布式事务能力从 4.0 版本开始支持多文档事务但有两个硬前提存储引擎是WiredTiger部署形态是副本集。单节点部署哪怕版本再新执行事务也会直接报错。Spring Boot 里支持事务的配置很简单自动配置会帮你注册MongoTransactionManager。真正要确认的是连接串里有没有replicaSet参数spring: data: mongodb: uri: mongodb://10.0.0.11:27017,10.0.0.12:27017/appdb?replicaSetrs0如果不带replicaSet驱动可能只是把多个地址当成轮流连接而不走副本集发现逻辑事务同样会出问题。4.2 事务边界和失败补偿的实际经验业务方法上打个Transactional就能获得多文档事务能力Transactional(rollbackFor Exception.class) public void transfer(String fromAccount, String toAccount, BigDecimal amount) { Query fromQuery Query.query(Criteria.where(accountId).is(fromAccount) .and(balance).gte(amount)); Update fromUpdate new Update().inc(balance, amount.negate()); mongoTemplate.updateFirst(fromQuery, fromUpdate, AccountEntity.class); Query toQuery Query.query(Criteria.where(accountId).is(toAccount)); Update toUpdate new Update().inc(balance, amount); mongoTemplate.updateFirst(toQuery, toUpdate, AccountEntity.class); }这段代码里有个业务判断藏在查询条件里balance.gte(amount)是扣款方余额要大于等于转账金额。如果余额不够updateFirst的修改数为 0后续就不会执行增加操作事务会正常提交而不是回滚——注意这里不会自动抛异常。所以余额校验逻辑要自己判断UpdateResult.getModifiedCount()为 0 就手动抛一个业务异常让Transactional回滚整个事务。事务这里有几个高频陷阱陷阱一自调用导致事务不生效。Transactional走的是 Spring AOP 代理。你在同一个类里this.methodA()调methodB()方法 B 上的事务注解不会生效。需要把事务方法放到另一个Service里或者注入自身代理对象再调用。陷阱二异步方法里的事务。事务上下文默认不会传递给子线程。你在Async方法里做 MongoDB 事务操作安全起见要在异步方法内部自己把事务逻辑组织好别指望外层Transactional覆盖进去。陷阱三瞬时错误。MongoDB 事务在执行提交阶段可能遇到主节点切换之类的瞬时错误驱动会抛MongoTransientTransactionError。这种错误业务上通常可以整体重试。我在项目里会给事务入口加一个重试模板捕获这个异常后按照固定策略重试几次。Spring 的Retryable在这个场景下很顺手Retryable( retryFor MongoTransientTransactionError.class, maxAttempts 3, backoff Backoff(delay 200)) public void doWithTransaction() { // 事务逻辑 }4.3 事务的性能边界事务有成本别把全站写入都包在事务里。一个事务内操作太多会长时间持有写锁冲突概率上升而且 MongoDB 事务默认使用快照隔离并发冲突时靠“写冲突”来终止事务。我在压测里观察到单事务内超过 20 个文档操作后性能会明显下降。建议事务内只放真正需要原子性的步骤其余非关键写入放到事务外完成。5. 索引设计慢查询往往不是 MongoDB 的锅很多 MongoDB 慢查询的根因不是“数据库不行”而是压根没建索引或者建了索引但查询条件用不上。5.1 注解建索引 vs 手工管理索引Spring Data 提供了一套基于注解的索引声明方式Document(order_entity) CompoundIndex(name idx_user_status_time, def {userId: 1, status: 1, createdAt: -1}) public class OrderEntity { Id private String id; Indexed private String orderNo; Indexed(expireAfterSeconds 86400) private Instant createdAt; }Indexed声明单字段索引CompoundIndex声明复合索引expireAfterSeconds会创建 TTL 索引让 MongoDB 自动清理过期文档。启动时 Spring Data 会根据注解自动ensureIndex。这套机制对开发环境很友好换环境启动后索引就是齐的。但生产环境我建议把索引管理从应用里剥离出去原因有三应用启动时建索引在大集合上可能长时间阻塞写入。不同环境的索引差异靠代码注解不好精细控制。DBA 团队需要统一的索引变更评审。我在生产项目里通常让应用带上“基础索引注解”保证新环境能跑复杂索引、大集合索引则走手工脚本void ensureManualIndexes(MongoTemplate mongoTemplate) { mongoTemplate.indexOps(OrderEntity.class) .ensureIndex(new Index() .on(userId, Sort.Direction.ASC) .on(status, Sort.Direction.ASC) .named(manual_user_status)); }根据实际维护经验手工管理索引时一定要给索引起稳定的名字不然重复执行ensureIndex会因为定义不一致创建出重复索引。索引不是越多越好每多一个索引写入性能就多一份负担。5.2 复合索引的字段顺序和 explain 验证复合索引的设计有个基本规律等值条件的字段放前面范围/排序字段放后面。比如常见查询是db.order_entity.find({userId: u123, status: 1}).sort({createdAt: -1})那复合索引就适合建成{userId: 1, status: 1, createdAt: -1}。userId和status是等值过滤createdAt负责排序。如果反过来建成{createdAt: -1, userId: 1, status: 1}排序字段虽然在最前面但等值过滤没法在这个索引里快速收敛效果会差一大截。索引建完后别信“直觉”用执行计划验证db.order_entity.find({userId: u123, status: 1}) .sort({createdAt: -1}) .explain(executionStats)重点看winningPlan里的stage是不是IXSCAN以及totalDocsExamined和nReturned的比例。理想状态下totalDocsExamined应该约等于nReturned如果你发现扫描的文档数远大于返回条数说明索引还没完全命中。这里有一个代码层面的注意点Spring Data 生成的查询条件如果带了_id范围判断MongoDB 通常会主动走_id索引。这时候你以为自己建的复合索引会生效实际执行计划可能已经变了。排查这种问题一定要以explain实际输出为准别只对着索引定义空想。5.3 TTL 索引和文本索引TTL 索引在日志类、会话类集合上非常实用。比如订单超时未支付自动关闭Indexed(expireAfterSeconds 1800) private Instant createdAt;MongoDB 后台会每分钟扫描一次 TTL 索引把createdAt超过 1800 秒的文档删除。注意expireAfterSeconds是以索引字段的时间为准字段过期时间决定了文档什么时候被清。如果想基于“最后更新时间”过期就把索引建在updatedAt字段而不是createdAt。MongoDB 的文本索引TextIndex适合做简单的标题/摘要搜索。它对中文分词的默认支持比较弱真要做站内搜索一般还是接专门的检索引擎。业务上如果只是模糊匹配用正则表达式也能顶一顶但要注意带前缀的正则可以走索引带后缀的、左右都模糊的正则基本全表扫描。6. 我在实际项目里整理出的“排障笔记”最后这部分是我从多次线上问题里总结的几条经验每条背后都对应过一次半夜被叫醒的记录。6.1 连接串别在代码里拼更别用明文写在日志里MongoDB 连接串包含用户名密码。配置中心管理连接串是基本素质。另外启动日志里别把整个MongoClientSettings打出来驱动默认日志信息量很大稍不注意就把密码带出来了。我一般会把uri的可读部分打印出来密码用掩码处理。6.2 update 和查询的字段类型必须严格一致MongoDB 对类型敏感字符串123和数值123是两个完全不同的值。如果一个字段在历史数据里被写成字符串后来代码里用Integer类型去查索引用不上还会出现“数据明明存在但查不到”的诡异现象。排查这类问题最快的方式是登录 mongo shell用聚合管道$type字段检查类型分布。6.3 线上大集合建索引选对时机创建索引会导致集合上的写操作暂停一段时间集合越大BLOCK 时间越长。如果要给千万级文档的集合加索引挑业务低峰期做或者用background方式建新版本驱动默认已经是后台构建但 DBA 流程还是要确认。这一步踩过坑之后我对“启动时自动同步索引”这类机制就警觉了——开发环境没问题生产环境可能会在大集合上给你一个“惊喜”。6.4 用 embedded MongoDB 做单元测试注意事务支持Spring Boot 测试里可以用de.flapdoodle.embed.mongo启动嵌入式 MongoDB 做集成测试。但嵌入式版本默认可能是单节点事务测试会报错。需要显式配置为副本集模式比较折腾。如果只是测 Repository 的 CRUD用嵌入式没问题要测事务逻辑我建议连一个真实的测试副本集免得被“测试通过但线上报错”的假象欺骗。6.5 主从延迟导致的“写后读”不一致MongoDB 默认读偏好是主节点一般不会出现写入后立刻读不到的情况。但如果你图分担压力把读请求切到了从节点就要接受数据延迟。钱相关、状态相关的查询必须强制走主节点别让一条secondaryPreferred的读配置导致用户看到“支付成功但订单未支付”的诡异状态。6.6 定期清理无用的索引索引是写放大器。线上业务跑半年后会有一些以前为 A 场景设计的索引业务迭代后没人再用。通过db.collection.getIndexes()和慢查询日志交叉分析把超过 30 天没有被IXSCAN命中的索引删掉。我见过一个日志集合因为历史遗留了十几个索引写入性能掉了一半清理后立刻恢复。一些收尾的实话如果你能把这些点全部消化掉基本就可以从“能跑”走向“能扛”。我现在回想这几轮项目感触最深的是两句话第一MongoDB 不是“不用设计表结构所以可以随便存”的数据库它的索引、分页、事务都有边界理解边界才能用好它第二Spring Boot 的自动配置看起来很省心但你得知道每一层自动配置背后选的是什么、约束是什么否则出了问题连日志都看不明白。最后分享一个小技巧每次发布涉及 MongoDB 变更之前我习惯在测试环境里把所有索引定义、事务场景、分页场景全部回归一遍同时在application.yml里把logging.level.org.springframework.data.mongodb.core.MongoTemplate: DEBUG临时打开观察实际生成的查询语句。这样不仅能发现索引没命中的问题还能顺便确认查询条件里有没有多余的字段让索引失效。等线上验证通过再把日志级别调整回去。这套流程成本很低但帮我躲掉过不少问题。