☰
SpringBoot整合MongoDB实战:选型、连接池、索引与事务边界
2026/10/26 3:27:50 网站建设 项目流程

上个月帮一个做设备巡检系统的朋友翻代码,看到他还在用 SpringBoot 拼 JDBC 手写 JSON 字符串往库里塞,一条巡检记录十几个可变字段,表结构改了三次,每次都要停机加列。这种场景其实就是 MongoDB 的主场——文档模型天然适配"字段不固定、嵌套层级深、读多写少"的数据,而 SpringBoot 生态对它的封装又足够成熟,spring-boot-starter-data-mongodb一个依赖拉进来,配置写三行,MongoTemplate直接用,整合成本低到可以忽略。这篇就围绕 SpringBoot 整合 MongoDB 这条主线,把选型判断、依赖版本对齐、文档映射、连接池估算、批量写入、聚合与事务边界这些实际会碰到的东西一次讲透。适合刚接触文档数据库的后端同学,也适合已经能跑通 Demo、但一到生产就出各种连接超时和类型错乱问题的老手——下面这些坑基本都是我或身边同行真踩过的。

1. 动手之前先把选型逻辑捋顺

1.1 一个真实需求场景的拆解

先说我经手过的一个典型例子。某公司做工业设备巡检,每台设备每次巡检产生的数据结构差异极大:温度传感器上报的是{deviceId, ts, value},而人工巡检单里可能是{deviceId, ts, inspector, items:[{name, result, remark}], photos:[...]},甚至同一个集合里,不同型号设备的字段集合能差出一半。最初用关系库,方案是主表 + 若干张明细表 + 一个ext_json大字段兜底,结果查询时既要 join 又要解析 JSON,索引也建不利索。

换成 MongoDB 之后,整条巡检记录就是一个文档,嵌套的items数组直接存在文档里,一次查询把整条记录捞出来,前端渲染不用再做二次拼装。这个转变的关键不在于"用了 NoSQL 显得先进",而在于数据本身的形状和存储的形状对上了。文档数据库的核心价值就在这里:聚合根内部的从属数据,天然就该跟着主文档一起读写。

不过话说回来,如果当时的需求是"按巡检项跨设备做统计报表、还要和工单系统做多表关联",那我肯定还是建议留在关系库。选型这件事最忌讳的就是拿一个新玩具硬套所有场景,后面几节我会把判断标准写得更具体一些。

1.2 SpringBoot 整合 MongoDB 适合与不适合的判断标准

我一般用三个问题快速判断:

  • 数据是不是以聚合根为单位整体读写?比如订单 + 订单明细、文章 + 评论列表、设备 + 巡检项。是,就适合;如果明细需要独立被其他业务高频引用和关联,就要谨慎。
  • 字段结构会不会频繁变化?硬件上报、第三方回调、运营配置这类数据,字段集合半年变一次很正常,文档模型不用改表结构,加个字段就完事。反过来,如果是财务对账这种字段极其稳定、且强依赖外键约束的场景,关系库更省心。
  • 读写比例和一致性要求如何?读多写少、允许最终一致的场景(内容、日志、画像、配置)很合适。强事务、跨文档多写、需要复杂 join 的场景,要么用副本集事务硬扛,要么干脆别用。

还有一个容易被忽略的点:运维能力。MongoDB 的分片、副本集、备份恢复,和传统关系库的运维习惯差别不小。团队里没人懂,出了问题只能干瞪眼,那还不如老老实实加两张表。技术选型从来不只是代码层面的事。

1.3 版本兼容矩阵:先对齐再写代码

整合踩的第一个坑,往往不是代码写错,而是版本没对齐。Spring Data MongoDB 的版本跟 Spring Boot 绑定,而底层又依赖mongodb-driver-sync,三者错位就会出现各种诡异的NoSuchMethodError。下面这张表是我整理的常见组合,照着选基本不会出问题:

Spring Boot 版本Spring Data MongoDBMongoDB Java Driver服务端建议版本
2.7.x3.4.x4.6 ~ 4.94.4 / 5.0 / 6.0
3.0.x ~ 3.1.x4.0.x ~ 4.1.x4.9 ~ 4.115.0 / 6.0
3.2.x ~ 3.3.x4.2.x ~ 4.3.x4.11 / 5.06.0 / 7.0
3.4.x 及以上4.4.x5.2+7.0

实际操作中最稳妥的做法是:不要手动指定mongodb-driver-sync的版本,交给 Spring Boot 的 BOM 管理。只有当服务端版本特别老(比如还在 4.0)或者特别新、驱动需要显式升级时,才在dependencyManagement里覆盖。我见过有同事为了"用上新特性"手动把 driver 升到 5.x,结果 Boot 2.7 的自动配置类里调用的方法签名变了,启动直接报错,排查了小半天。

注意:如果服务端是 MongoDB 4.0 以下,且业务需要事务,那就别折腾了,事务要求副本集且版本 4.0+,单机模式怎么配都开不起来。

2. 工程骨架:依赖、配置与连接池

2.1 起步依赖引入与排除项

依赖这块非常简单,一行搞定:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency>

如果项目用的是 Maven 多模块,注意别在同一工程里既引spring-boot-starter-data-mongodb又引mongodb-driver-legacy或者旧版mongo-java-driver,这两个包同时存在时,MongoClient的类会冲突,启动报ClassNotFoundException或者更隐蔽的LinkageError。

另外有个常被问到的问题:要不要用响应式的spring-boot-starter-data-mongodb-reactive?我的判断很直接——如果整条调用链已经是 WebFlux + Reactor,那就用响应式;如果是传统 MVC,别为了"高性能"硬上。响应式的收益在 IO 密集、高并发的场景才明显,普通 CRUD 业务里,Mono/Flux带来的调试成本和团队学习成本,往往大于收益。我之前见过一个项目,一半接口同步一半响应式,最后block()到处飞,反而把线程池堵死了。

2.2 application.yml 分环境配置写法

配置有两种主流写法,选一种坚持用,别混。

写法一:拆开写(推荐,参数可控性更好)

spring: data: mongodb: host: 127.0.0.1 port: 27017 database: inspection username: app_user password: ${MONGO_PWD} authentication-database: admin auto-index-creation: true uri: # 留空,走上面的分项配置

写法二:URI 一把梭

spring: data: mongodb: uri: mongodb://app_user:${MONGO_PWD}@127.0.0.1:27017/inspection?authSource=admin&maxPoolSize=80&minPoolSize=10&maxIdleTimeMS=60000

两种写法的差别在于:URI 方式对连接池参数、读写关注(readConcern/writeConcern)、超时时间的控制更细,因为这些参数是 URI 的 query 部分天然支持的;分项配置方式更直观,但部分参数需要额外定义 Bean 才能覆盖。

我个人的习惯是:开发环境用分项配置(改起来方便),生产和预发统一用 URI,密码走环境变量注入,绝不写死在配置文件里。这里提一句authentication-database,如果你的用户建在admin库但业务库是另一个,这个参数不写会直接认证失败——这是新手最容易卡住的地方之一。

2.3 连接池参数怎么估算才不翻车

连接池是生产事故的高发区。默认maxPoolSize是 100,听着够用,但如果一个应用有多个实例、每个实例都开 100,服务端连接数瞬间就上去了,MongoDB 单机默认连接上限大概在两万左右,加上内部连接,很容易打出connection refused。

给一个可以直接套用的估算公式:

所需连接数 ≈ (峰值 QPS × 单次操作平均耗时ms) / 1000 × 冗余系数(1.5 ~ 2)

举个具体例子:某个查询接口峰值 QPS 400,单次find平均耗时 6ms,那么并发占用约400 × 6 / 1000 = 2.4个连接,乘 1.5 冗余,理论上 4 个就够。但实际业务里存在慢查询、批量写入、聚合统计这些耗时长的操作,会长时间占用连接,所以我把maxPoolSize设成 50,minPoolSize设 10,maxIdleTimeMS设 60000,maxLifeTimeMS设 1800000。

再看整体:应用部署 4 个实例,4 × 50 = 200个连接,加上运维工具、备份任务,服务端压力完全可控。对比之前有个团队直接用默认 100,8 个实例就是 800 连接,一到高峰期就开始报超时,把maxPoolSize降到 40 之后问题消失。

参数建议值说明
maxPoolSize30 ~ 80按实例数和 QPS 估算,别用默认 100
minPoolSize5 ~ 20保持一定预热连接,避免突发建连
maxIdleTimeMS60000空闲连接回收时间
maxLifeTimeMS1800000连接最长存活时间,防止服务端侧断连
connectTimeoutMS5000建连超时,别设太大
serverSelectionTimeoutMS3000 ~ 5000选节点超时,直接影响故障响应速度
socketTimeoutMS20000 ~ 60000读写超时,按最慢查询的 2 倍设
waitQueueTimeoutMS5000拿不到连接时的等待上限,防止线程无限堆积

提示:waitQueueTimeoutMS这个参数特别值得设。不设的话,池子满了之后请求会无限期等待,线程池被慢慢耗干,最后整个服务假死,日志里却什么明显错误都没有——这种故障排查起来极其痛苦。

3. 文档映射:把集合结构翻译成Java类

3.1 @Document 注解与字段命名策略

实体类是最先要落地的。一个标准的映射类长这样:

import lombok.Data; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; import java.time.Instant; import java.util.List; @Data @Document(collection = "inspection_record") public class InspectionRecord { @Id private String id; @Field("device_id") private String deviceId; private String inspector; private Instant inspectTime; private List<InspectionItem> items; private Integer status; }

几个细节值得展开说。第一,@Id对应的字段类型建议用String,MongoDB 的_id本质是ObjectId,但驱动会自动做转换,用String在前后端传输时最省事;如果你确实需要按ObjectId的时间戳做排序,那就在插入后取id做一次转换,或者干脆额外存一个Instant字段,不要试图在_id上做业务逻辑。

第二,@Field的作用是把 Java 的驼峰命名映射成库里的下划线命名。不加也能跑,字段名就变成驼峰,但这样和已有数据的字段风格不一致,后续用命令行工具查数据时会很难受。这里有个更省事的做法——在配置里全局指定命名策略:

spring: data: mongodb: field-naming-strategy: org.springframework.data.mapping.model.SnakeCaseFieldNamingStrategy

配上之后所有驼峰字段自动转下划线,实体类里就不用一个一个加@Field了。不过这属于"一次性决定",中途切换会导致已有数据读不出来,上线前一定要定死。

第三,Instant和LocalDateTime的选择。我强烈建议用Instant,理由在 5.2 节详细说。

3.2 嵌套对象、数组与Map类型的落库形态

嵌套是文档数据库的核心优势,但写法上有讲究。看这个InspectionItem:

@Data public class InspectionItem { private String name; private String result; private String remark; private List<String> photoUrls; }

落库之后就是一个子文档数组。这里要注意两点:数组内的子文档可以单独被索引和查询(用items.name这样的路径),但不能单独更新数组内某个元素的单个字段——你要么用$set配合位置操作符改整个元素,要么用$push/$pull做增删。这一点和关系库的"明细表按主键更新"思维完全不同,设计时要想清楚更新模式。

还有一个常见需求是"动态字段",比如不同设备型号有不同指标。这时候可以用Map<String, Object>:

private Map<String, Object> metrics;

落库就是{metrics: {temp: 36.5, vibration: 0.12}},灵活是灵活,但代价是无法用 Spring Data 的派生查询方法直接查,只能走MongoTemplate手写 Criteria,而且字段类型不受约束,容易出现同一个 key 一会儿是数字一会儿是字符串的情况。我的经验是:能用固定字段就用固定字段,Map只在真正不可预知的场景用,并且约定好 value 只允许String/Number/Boolean三种基础类型。

3.3 索引声明与自动创建开关

索引不建,查询就是全表扫,数据量一过百万直接卡死。Spring Data 提供了注解声明方式:

@Document(collection = "inspection_record") @CompoundIndex(name = "idx_device_time", def = "{'device_id': 1, 'inspect_time': -1}") public class InspectionRecord { // ... @Indexed private Integer status; }

这里有个大坑:从 Spring Data MongoDB 3.0 开始,自动建索引默认关闭了,你光加注解不管用,必须在配置里显式打开:

spring: data: mongodb: auto-index-creation: true

打开之后,应用启动时会扫描注解并创建索引(已存在且定义一致则跳过)。但生产环境我通常建议关掉自动创建,改用脚本或迁移工具统一管理索引。原因很实际:自动创建在多实例同时启动时可能产生并发冲突;更麻烦的是,如果索引定义改了,旧索引不会自动删除,会留下一个"僵尸索引"占着写入性能,你还得手工去 drop。索引是数据库层面的资产,就该像表结构一样纳入版本管理。

复合索引的字段顺序也有讲究:{'device_id': 1, 'inspect_time': -1}能支持"按设备查 + 按时间倒序"的查询,但如果业务里更多是"按时间范围 + 按状态过滤",那索引就该建成{'status': 1, 'inspect_time': -1}。这条原则跟关系库完全一致:等值条件在前,范围/排序条件在后。

4. 数据访问实操:MongoTemplate 与 Repository 两条路

4.1 MongoTemplate 的增删改查标准写法

Spring Data 给了两套 API:MongoTemplate和MongoRepository。我的建议是两者都用,但分工明确——简单 CRUD 走 Repository,复杂查询、聚合、批量操作走 Template。

MongoTemplate的基本用法:

@Service @RequiredArgsConstructor public class InspectionService { private final MongoTemplate mongoTemplate; // 插入,返回带 id 的对象 public InspectionRecord save(InspectionRecord record) { return mongoTemplate.insert(record); } // 条件查询 + 排序 + 限制条数 public List<InspectionRecord> recent(String deviceId, int limit) { Query query = new Query( Criteria.where("device_id").is(deviceId) .and("inspect_time").gte(Instant.now().minusSeconds(86400)) ).with(Sort.by(Sort.Direction.DESC, "inspect_time")) .limit(limit); return mongoTemplate.find(query, InspectionRecord.class); } // 局部更新,只改指定字段 public void updateStatus(String id, Integer status) { Query query = new Query(Criteria.where("_id").is(id)); Update update = new Update() .set("status", status) .currentDate("update_time"); mongoTemplate.updateFirst(query, update, InspectionRecord.class); } }

几个实操要点:

  • insert和save的区别在于:insert遇到已存在的_id会抛DuplicateKeyException,save则是 upsert 语义(有 id 就整体替换)。在需要防止重复插入的场景(比如幂等消费消息),用insert配合唯一索引更安全。
  • updateFirst只更新第一条,updateMulti更新所有匹配项,upsert系列方法在无匹配时插入。别用错。
  • Query对象是可以复用的,但limit、skip、with会修改原对象,如果多处复用同一个 Query,记得Query.query(criteria)重新构造,不然会踩"查询条件莫名其妙被改"的坑。这个坑我调了两小时才定位到。

4.2 Repository 派生查询与方法命名规则

仓库接口的写法非常省心:

public interface InspectionRecordRepository extends MongoRepository<InspectionRecord, String> { List<InspectionRecord> findByDeviceIdAndInspectTimeAfter( String deviceId, Instant time); Page<InspectionRecord> findByStatus(Integer status, Pageable pageable); long deleteByDeviceId(String deviceId); boolean existsByDeviceIdAndStatus(String deviceId, Integer status); }

方法名解析规则和 JPA 基本一致,And/Or/GreaterThan/In/Regex这些都能用。但有两个坑必须提:

坑一:字段名映射不一致。如果实体上加了@Field("device_id"),findByDeviceId是能正确识别的,因为 Spring Data 会做属性到字段的映射;但如果你手写@Query,里面必须写库里真实的字段名device_id,写驼峰会查不到数据。

坑二:deleteBy会先查后删。派生删除方法是"查询出所有匹配文档 → 逐个删除",数据量大时性能极差,而且返回的计数是删除条数。批量删除请用mongoTemplate.remove(query, Class),一次调用就完事。

还有一点,MongoRepository和MongoTemplate混用时,事务不会自动传递(除非配了MongoTransactionManager),这点在第 6 节细说。

4.3 分页、排序与字段投影

分页用Pageable:

Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "inspect_time")); Page<InspectionRecord> page = repository.findByStatus(1, pageable);

看起来很美,但要注意**Page会额外执行一次count查询**。如果集合里有几百万文档,每次翻页都 count 一遍,成本极高。有两种应对方式:一是改用Slice(只查是否还有下一页,不查总数),二是前端改成游标分页,用上一页最后一条的时间戳作为inspect_time < lastTs的条件,性能最好。

字段投影是另一个实用技巧。列表页往往只需要几个字段,没必要把带 photos 数组的完整文档拉回来:

Query query = new Query(Criteria.where("status").is(1)) .with(Sort.by(Sort.Direction.DESC, "inspect_time")) .limit(20) .fields().include("device_id", "inspector", "inspect_time", "status"); List<InspectionRecord> list = mongoTemplate.find(query, InspectionRecord.class);

投影能显著减少网络传输量和反序列化开销,列表接口响应时间从 200ms 降到 60ms 是常有的事。

4.4 批量写入:BulkOperations 的正确用法

批量场景千万别在 for 循环里一条一条 insert,一次网络往返就是一次开销,几千条能把接口拖到超时。正确做法是BulkOperations:

public void batchSave(List<InspectionRecord> list) { if (CollectionUtils.isEmpty(list)) { return; } int batchSize = 1000; for (int i = 0; i < list.size(); i += batchSize) { List<InspectionRecord> sub = list.subList(i, Math.min(i + batchSize, list.size())); BulkOperations ops = mongoTemplate.bulkOps( BulkOperations.BulkMode.UNORDERED, InspectionRecord.class); ops.insert(sub); ops.execute(); } }

三个关键点:批次大小控制在 500 ~ 1000 条,太大容易触发 16MB 的文档大小限制或者驱动内部缓冲区溢出;用UNORDERED模式,让服务端并行执行,单条失败不影响其他条;分批执行而不是一次性塞,方便中途记录进度和失败重试。

实测数据:在本地环境,逐条插入 5000 条记录耗时约 12 秒,改成 1000 条一批的UNORDERED批量后降到 800 毫秒左右。数量级上的差异,别不当回事。

5. 踩坑实录与问题速查

5.1 连接、超时与认证类故障

这类问题占了实际故障的七成以上,整理成表方便对照:

现象大概率原因处理方式
启动报Authentication failed用户建在 admin 库但没配authSourceURI 加authSource=admin
偶发Connection refused服务端连接数打满降maxPoolSize,核对实例总数
请求长时间挂起无响应池子耗尽且没设waitQueueTimeoutMS补上该参数,加监控告警
No server chosen/ 超时 30s节点故障但serverSelectionTimeoutMS太大调到 3000~5000ms 快速失败
隔夜后首次请求超时空闲连接被中间设备断开设maxLifeTimeMS小于中间设备空闲阈值
批量写入报 16MB 超限单次 bulk 数据过大缩小批次,或拆分文档

提示:那个"隔夜超时"的问题特别隐蔽——白天一切正常,第二天早上第一批请求必然超时,之后又好了。本质是长时间空闲的连接被网络中间层静默断开,而客户端还以为是活的。设maxLifeTimeMS让连接主动轮换,就能绕过去。

5.2 日期时区与数值类型的隐性偏差

日期这块我吃过一次大亏。早期实体类用了LocalDateTime,本地开发一切正常,部署到服务器(UTC 时区)之后,页面上的时间整整差了 8 小时。原因是驱动把LocalDateTime当作"无时区的本地时间"序列化成Date,服务端按 UTC 存,读回来再按 UTC 解释,中间就丢了偏移。

结论很干脆:存时间一律用Instant,需要展示时在接口层转成业务时区。如果历史数据已经用了LocalDateTime,可以加一个转换器统一处理:

@Bean public MongoCustomConversions customConversions() { return new MongoCustomConversions(Arrays.asList( new InstantToDateConverter(), new DateToInstantConverter() )); }

数值类型也有坑。JSON 里1和1.0是不同的 BSON 类型(int32 vs double),如果你用Map<String, Object>存指标,某个字段一会儿存整数一会儿存小数,之后用Criteria.where("metrics.temp").is(36.5)查询就可能匹配不上。解决办法是在写入前统一做类型规整,或者干脆定义为Double强转。

5.3 _class 字段引发的序列化问题

默认情况下,Spring Data 会给每个文档加一个_class字段,值是类的全限定名,比如com.example.demo.entity.InspectionRecord。这个字段的作用是反序列化时能还原成具体类型,但它有三个副作用:占空间、暴露包结构、类名改了之后老数据读不出来。

处理方式有两种。轻量的方式是加类型别名:

@Document(collection = "inspection_record") @TypeAlias("inspection") public class InspectionRecord { ... }

这样_class就变成inspection,短且稳定。另一种是直接在配置里关掉:

@Bean public MongoTemplate mongoTemplate(MongoDatabaseFactory factory, MongoMappingContext context) { MappingMongoConverter converter = new MappingMongoConverter(new DefaultDbRefResolver(factory), context); converter.setTypeMapper(new DefaultMongoTypeMapper(null)); // 不写 _class return new MongoTemplate(factory, converter); }

关掉之后文档会干净很多,但代价是不能再用父类/接口接收查询结果做多态反序列化,必须明确指定具体类型。如果业务里没有多态存储的需求,关掉是划算的:几百万文档省下的空间和反序列化时间都不算小。

5.4 性能问题速查表

现象排查方向常用手段
查询随数据量线性变慢缺少索引explain("executionStats")看是否 COLLSCAN
单条查询偶发 1s+慢查询阻塞连接池开慢查询日志,slowms设 100
写入吞吐上不去索引过多 / 写关注过高精简索引,评估writeConcern
分页越翻越慢深分页 skip 成本高改游标分页或加范围条件
内存占用持续上涨大结果集全量加载加 limit、用投影、流式读取
聚合查询超时管道顺序不合理$match前置,尽量在索引上过滤

排查的第一步永远是explain。在 Java 里可以这样打:

Query query = new Query(Criteria.where("device_id").is("D001")); Document explain = mongoTemplate.getDb() .runCommand(new Document("explain", new Document("find", "inspection_record") .append("filter", new Document("device_id", "D001"))) .append("verbosity", "executionStats")); System.out.println(explain.toJson());

看winningPlan.stage是不是COLLSCAN,totalDocsExamined和nReturned的比值是多少。比值接近 1 说明索引有效,比值上百说明扫了大量无关文档,索引该重建或者查询条件该调整。

6. 进阶:聚合管道与事务边界

6.1 聚合查询在 Java 侧的组装方式

聚合是 MongoDB 的强项,Java 侧的写法有两条路。简单管道用链式 API:

Aggregation agg = Aggregation.newAggregation( Aggregation.match(Criteria.where("status").is(1) .and("inspect_time").gte(start)), Aggregation.group("device_id") .count().as("total") .sum("items.size()").as("itemCount") .max("inspect_time").as("lastTime"), Aggregation.sort(Sort.Direction.DESC, "total"), Aggregation.limit(50) ); AggregationResults<DeviceStat> results = mongoTemplate.aggregate(agg, "inspection_record", DeviceStat.class); List<DeviceStat> list = results.getMappedResults();

复杂管道(比如带$lookup、$unwind、$facet)建议直接用Document拼 BSON:

List<Document> pipeline = Arrays.asList( new Document("$match", new Document("status", 1)), new Document("$unwind", "$items"), new Document("$group", new Document("_id", "$items.name") .append("failCount", new Document("$sum", new Document("$cond", Arrays.asList( new Document("$eq", Arrays.asList("$items.result", "NG")), 1, 0))))), new Document("$sort", new Document("failCount", -1)) ); AggregationOperation custom = context -> new Document("$pipeline", pipeline); // 或逐条转 Document

这里的关键原则:$match一定要放在管道最前面,这样才能用上索引;$group和$sort放在后面。如果把$match放到$unwind之后,等于先展开数组再过滤,中间结果集会大出好几倍。

另外提醒一句,聚合不要跑在业务主流程的同步接口里。一次复杂的$group在大集合上跑几秒很正常,把连接占住,高并发下直接拖垮连接池。这类统计要么走异步任务,要么预计算成结果表。

6.2 事务的启用条件与使用边界

先说结论:MongoDB 事务能不用就不用,非用不可时务必清楚它的代价。

启用条件是服务端为副本集(或分片集群),版本 4.0+。配置上需要一个MongoTransactionManager:

@Configuration public class MongoTxConfig { @Bean public MongoTransactionManager transactionManager( MongoDatabaseFactory factory) { return new MongoTransactionManager(factory); } }

然后再照常用@Transactional注解即可。

代价在哪?事务内默认使用快照隔离,加锁会显著影响并发;事务有 60 秒的默认执行上限;事务期间写操作占用的资源不会释放。我见过一个团队把整条批量导入(几万条)包在一个事务里,跑到 40 秒左右开始报TransactionTooLargeForCache,最后拆成单条提交反而稳定。

实际经验是:只把真正需要原子性的那一小段包进事务。比如"扣库存 + 写流水"这两步,而不是"查询 + 校验 + 扣库存 + 写流水 + 发消息"整条链。跨文档的强一致性需求,如果能在建模阶段通过"把强关联数据放进同一个文档"来规避,那才是最优先的方案——文档数据库的设计哲学本来就是"用模型换掉事务"。

注意:事务内的读操作要显式指定读关注为snapshot,否则读到的可能是事务外的最新数据,逻辑上会出现"事务里读到脏数据"的错觉。Spring 的@Transactional默认已经处理了这部分,但如果你手写了MongoTemplate并自定义了ReadConcern,要留意是否会覆盖。

写到这里差不多了。SpringBoot 整合 MongoDB 这件事,跑通 Demo 只需要半小时,但真正让它稳定承载业务,功夫全在版本对齐、连接池预估、索引设计和类型的统一规范上。我个人的体会是,文档数据库用起来最舒服的状态,是你能在设计阶段就明确"哪些数据是同一个聚合、必须一起读写",而不是事后到处用事务去补一致性——后者说明模型本身就没想清楚,换个数据库也救不回来。最后分享一个我沿用很久的习惯:每个 Mongo 集合上线前,先在测试库上灌 50 万条接近真实分布的数据,跑一遍全部查询接口,用explain逐个看执行计划。这个动作花不了两小时,但能提前拦掉八成以上的性能问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询