☰
SpringBoot整合MongoDB:选型、连接池、映射与索引实践
2026/10/26 19:28:37 网站建设 项目流程

上周帮一个做内容社区的朋友看他接手的一个后台系统,翻到数据访问层的时候,我看到一个挺典型的写法:文章正文、评论、标签全塞在一张关系表里,评论用一个 TEXT 字段存 JSON 字符串,标签用逗号拼接,查“某个用户最近点赞过的带某标签的文章”这种需求,代码里套了三层循环加内存过滤。他问我要不要改成文档库,我说这个结构确实适合,但整合的过程中有几个地方能让你的项目启动就报错,或者跑两个月之后连接数打满。这篇就把 SpringBoot 整合 MongoDB 这条路上的选型判断、配置参数、映射约定和实际踩到的报错,按我自己的操作顺序完整捋一遍,从“要不要用”一直讲到“上线前还要确认什么”。

1. 先判断业务是否适合文档库,再谈整合

1.1 三个让我倾向 MongoDB 的信号

第一个信号是字段结构会反复变。比如内容类系统的文章,今天加个“封面视频”,明天加个“关联商品列表”,后天又要给一部分文章加“付费章节”。关系型数据库里每加一次就要改表、写迁移脚本、同步改 ORM 映射,改三次之后表里躺着一堆没人用的 NULL 字段。文档库这边,新增字段对老文档没有任何影响,读出来是 null 而已,代码里该判空判空就行。

第二个信号是天生一对多的嵌套数据。评论、点赞用户 ID 列表、规格参数、操作日志,这些数据的特点是“整体读写、很少单独跨父级聚合”。文章详情页要一次拿到正文加前 20 条评论,文档库里一个findById就返回了,关系库要 join 两次或者查三次再在内存里拼。

第三个信号是读写比例严重偏向读,且查询条件相对固定。内容展示、商品详情、配置中心、埋点上报这类场景,写少读多,查询条件基本围绕“某个主体 ID + 时间范围 + 状态”打转,正好是文档库加复合索引最舒服的形态。

这三个信号同时出现两条以上,我才会认真考虑换库。只出现一条,通常加个 JSON 字段或者拆张从表就够了,没必要引入一个新组件——多一个组件就多一套监控、备份、权限和值班责任。

1.2 提前劝退:这些场景不要硬上

反过来讲,有几种需求我见过硬套文档库最后很痛苦的。一是强事务、多文档一致性要求高的,比如账户余额、库存扣减、订单状态机,MongoDB 从 4.0 开始支持多文档事务,但它要求副本集部署,而且事务里能做事的时长和操作数量都有约束,把它当关系库用,性能优势没了,复杂度反而上去了。

二是需要大量多表关联和聚合报表的。后台运营要出“按地区、按品类、按渠道交叉统计”的报表,文档库要么写一堆$lookup,要么把数据同步到别处算,维护成本不低。这类需求留在关系库或者丢给专门的分析存储更合适。

三是数据结构其实非常稳定的。有些项目业务模型三年不变,字段固定,关联清晰,这种用关系库加索引就够了,换成文档库只是把 SQL 换成了另一套语法,收益接近于零。我在评审时经常问一句:你换库是为了解决什么具体问题?如果答案是“听说它快”,那基本可以打回去了。

2. 依赖怎么选:starter、原生驱动与版本对齐

2.1 三种引入方式的取舍

SpringBoot 项目接 MongoDB,主流有三条路。第一条是spring-boot-starter-data-mongodb,它把 Spring Data MongoDB 和官方 Java 驱动一起带进来,提供MongoTemplate、MongoRepository、注解映射、审计、事务等一整套能力,绝大多数项目走这条。第二条是只引mongodb-driver-sync原生驱动,自己管MongoClient和 BSON 文档,好处是没有任何映射层开销,坏处是所有 CRUD 都得手写,字段名靠字符串常量,改个字段名全靠全局搜索。第三条是响应式的spring-boot-starter-data-mongodb-reactive,配 WebFlux 用,返回Mono/Flux,但如果你的项目是传统 Servlet 栈,引它进来只会让代码风格割裂。

我的默认选择是第一条。只有在两类情况下我会考虑原生驱动:一是要极致控制序列化行为,比如做通用数据同步工具,字段完全动态;二是项目本身已经有一套自己的数据访问抽象,只想借用连接管理。

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

提示:不要手动指定mongodb-driver-sync的版本号。Spring Boot 的依赖管理里已经把驱动版本锁死了,你强行覆盖版本,很容易出现驱动和服务端、或者驱动和 Spring Data 之间的方法签名不匹配,表现为启动时的NoSuchMethodError或NoClassDefFoundError。

2.2 版本对应关系与升级注意点

版本这块我整理了一张大致对应表,方便排查问题时快速定位。具体小版本以官方依赖管理为准,但大方向是这样的:

Spring BootSpring Data MongoDBMongoDB Java Driver建议服务端版本
2.7.x3.4.x4.6.x4.0 及以上
3.0.x4.0.x4.8.x4.4 及以上
3.2.x4.1.x4.11.x4.4 及以上
3.3.x4.3.x5.1.x4.4 及以上

从 Spring Boot 2.7 升到 3.x 是个坎,因为 Jakarta 命名空间整体替换,同时驱动从 4.x 跨到 5.x,一些老的转换器、自定义Converter实现、MongoClientOptions构造方式都会失效。我做过一次这样的升级,最麻烦的不是编译错误——编译错误反而是好事,改完就行——而是那些编译能过、运行到某个冷门分支才炸的地方,比如自定义的WritingConverter在新版本里泛型约束变了,序列化出来的 BSON 类型悄悄从字符串变成了对象,前端解析直接崩。

升级时的做法是:先把依赖升到目标版本,跑一遍全量单元测试和集成测试,然后重点检查三类代码——自定义Converter、手写的BasicDBObject拼装、以及任何直接new MongoClient(...)的地方。这三处是版本迁移的高发区。

2.3 启动报错的快速定位思路

整合阶段最常见的启动异常按出现频率排一下,基本就这几种:

  • MongoSocketOpenException: Exception opening socket:根本连不上,先确认服务端地址端口通不通,容器化部署时特别注意容器内的localhost不是宿主机的localhost。
  • MongoCommandException: Authentication failed:认证库填错。用户建在admin库,但连接串里没写authSource=admin,默认会拿业务库去认证,必然失败。
  • NoSuchMethodError/NoClassDefFoundError:版本冲突,用mvn dependency:tree找谁把驱动版本带偏了。
  • BeanCreationException指向MongoTemplate:一般是配置项写错,比如 URI 里少了协议头,或者密码含特殊字符没做 URL 编码。

最后这条是最容易忽略的。密码里带@、:、/、#这些字符时,必须做 URL 编码,@编成%40,#编成%23。我见过一个项目因为密码里有个@,连接串被解析成两段,报的错是“未知主机名”,排查了大半天才反应过来。

3. 连接配置:一条 URI 与几个真正影响稳定性的参数

3.1 分散配置与 URI 配置的选择

SpringBoot 支持两种写法,一种是拆开写 host、port、database、username、password,另一种是直接给一条uri。我强烈建议用后者,原因有三个。

第一,副本集和多节点需要列多个地址,拆开写的host只能填一个,虽然可以用逗号塞进去但可读性极差。第二,大量调优参数只能写在 URI 里,比如maxPoolSize、readPreference、writeConcern,这些属性在早期版本的spring.data.mongodb.*下没有对应配置项。第三,URI 和运维脚本、容器环境变量保持一致,你从运维那边拿到的连接串可以直接贴进配置,不用再翻译一遍,减少出错概率。

spring: data: mongodb: uri: mongodb://app_user:pa%40ss@10.0.1.11:27017,10.0.1.12:27017,10.0.1.13:27017/content_db?replicaSet=rs0&authSource=admin&maxPoolSize=50&minPoolSize=5&connectTimeoutMS=3000&socketTimeoutMS=15000&serverSelectionTimeoutMS=5000&readPreference=secondaryPreferred&retryWrites=true&w=majority auto-index-creation: false

注意auto-index-creation这一行,它非常关键,后面第 4 节会专门讲。默认值是false,也就是说你写@Indexed注解但不开这个开关,索引根本不会创建,查询走全表扫描,数据量上来之后慢得莫名其妙。

3.2 连接池和超时参数怎么定

连接池这几个参数,我给一个经验区间和判断依据:

参数默认值我的常用值依据
maxPoolSize10020~50单实例连接数上限,要乘上应用实例数后低于服务端maxIncomingConnections
minPoolSize05~10保持一定预热连接,避免流量突增时集中建连
maxIdleTimeMS0(不回收)60000空闲连接回收,避免长时间挂着被中间设备断开
connectTimeoutMS100003000建立 TCP 连接的等待时间,内网可以更短
socketTimeoutMS0(不超时)15000单次读写等待时间,必须设,否则一个慢查询会挂住线程
serverSelectionTimeoutMS300005000选不到可用节点时的等待时间,设短一点让故障快速失败

maxPoolSize是最容易出事的一个。假设服务端maxIncomingConnections默认是 65536,看起来很多,但如果你有 20 个应用实例,每个配 100,峰值就是 2000 个连接,还没算上监控、备份、其他人连的。更隐蔽的问题是每个连接在服务端都占内存,连接数上去之后服务端内存压力会明显增加。

我一般的算法是:预估单实例 QPS 峰值 ÷ 单连接能承载的 QPS,再乘 1.5 的余量。单连接能承载多少,取决于你的平均查询耗时——平均 5ms 的简单查询,单个连接理论上限是 200 QPS,实际按 100 算,那峰值 500 QPS 的实例配 8~10 个连接就够。当然这是理论估算,真实情况还要看是否有长查询、是否有大结果集遍历。

socketTimeoutMS设成 0 是灾难。它是“永不超时”,一旦服务端某个查询卡住,客户端线程就跟着卡住,线程池慢慢被耗光,最后整个应用无响应。这个参数我从来不留默认。

3.3 多数据源与读写分离

项目里如果需要连多个库,比如业务库一个、日志库一个,SpringBoot 的自动配置只支持一个。做法是自己定义主MongoTemplate(加@Primary),另外几个用MongoDatabaseFactory手动构造:

@Configuration public class MongoConfig { @Bean @Primary public MongoTemplate primaryMongoTemplate(MongoDatabaseFactory factory) { return new MongoTemplate(factory); } @Bean("logMongoTemplate") public MongoTemplate logMongoTemplate( @Value("${app.mongo.log-uri}") String uri) { ConnectionString cs = new ConnectionString(uri); MongoClientSettings settings = MongoClientSettings.builder() .applyConnectionString(cs) .build(); return new MongoTemplate(MongoClients.create(settings), cs.getDatabase()); } }

读写分离不用自己写,readPreference交给驱动就行。secondaryPreferred是读优先从节点、从节点不可用时回落主节点,适合报表、列表页这类对实时性要求不高的查询。但要注意一个坑:写完之后立刻读,可能读不到,因为主从同步有延迟。对“写完立即跳详情页”的场景,必须用primary或者显式指定readPreference=primary,否则用户会看到自己刚提交的内容不翼而飞,然后投诉你。

4. 实体映射:注解背后的默认约定

4.1 集合名、字段名与主键

@Document不加参数时,集合名默认是类名首字母小写,Article对应article。这个默认值我通常不依赖,而是显式写@Document(collection = "article"),因为类名重构一下,集合名就跟着变了,数据还在老集合里,查出来全是空。

字段名默认就是 Java 属性名,驼峰原样存。这意味着createTime存进去就是createTime,不是create_time。如果团队规范要求下划线,可以用@Field("create_time")逐个标,或者全局配置一个FieldNamingStrategy。我个人倾向前者,虽然啰嗦,但每个字段对应什么都写在代码里,看代码就知道数据结构,不用去猜全局策略。

@Id标注的字段,类型可以是String也可以是ObjectId。用String的好处是前端传来传去方便,Spring Data 会自动把 BSON 的 ObjectId 和 String 互转;用ObjectId的好处是能拿到时间戳信息(objectId.getTimestamp()),某些场景下能省一次字段。我一般用String,除非有明确的取时间戳需求。

注意:@Id用String时,插入时如果传了不合法的 24 位十六进制字符串,转换会抛异常。前端传 ID 一定要做格式校验,别直接透传到查询条件里,否则一个乱码 ID 就能让接口 500。

4.2 时间类型与序列化这两个高频坑

时间字段我踩过两次。第一次是实体里用LocalDateTime,存进去发现比预期少了 8 小时,读出来又对了,看着没问题。但运维直接查库做数据核对时,看到的时间和业务时间对不上,排查半天才明白:驱动把LocalDateTime按 JVM 默认时区转成了 UTC 的Date存储,读的时候再按默认时区转回来。应用层看着正常,但只要 JVM 时区配置和运维那边不一致,数据就错位。

我的做法是统一用Instant或者Date存,业务层需要展示时再转成带时区的格式。如果一定要用LocalDateTime,就在启动参数里把时区固定死(-Duser.timezone=Asia/Shanghai),并且在容器镜像里也配上,别指望默认值。

第二次坑更隐蔽,是嵌套对象里的 ObjectId 序列化。实体字段如果是String类型的 ID,Spring Data 在映射时会转好,返回给前端是字符串。但如果某个嵌套文档的字段类型是ObjectId(比如手动用MongoTemplate写入的、或者从别的集合引用过来的),Jackson 序列化时会把ObjectId当成普通对象展开,变成{"timestamp":1700000000,"machineIdentifier":123456,"processIdentifier":1,"counter":789}这种结构,前端拿到直接懵。解决办法是在字段上加@JsonSerialize(using = ToStringSerializer.class),或者全局注册一个ObjectId的 Jackson 序列化器。

public class ObjectIdSerializer extends JsonSerializer<ObjectId> { @Override public void serialize(ObjectId value, JsonGenerator gen, SerializerProvider provider) throws IOException { gen.writeString(value.toHexString()); } }

4.3 索引注解与那个默认关闭的开关

@Indexed、@CompoundIndex、@TextIndexed这些注解写在实体上很舒服,但它们只是“声明”,是否真正创建索引取决于spring.data.mongodb.auto-index-creation。这个开关默认false,是 Spring Data 团队有意为之——生产环境自动建索引风险太大,一个字段加了唯一索引,启动时如果存量数据有重复,应用直接起不来。

我的处理方式是:开发环境打开自动创建,生产环境关掉,索引通过脚本管理。脚本放在版本库里,跟代码一起走 Code Review,上线前由发布流程执行。这样索引变更可追溯、可回滚,也能提前在预发环境验证。

@Document(collection = "article") @CompoundIndex(name = "idx_user_ctime", def = "{'userId': 1, 'createTime': -1}") public class Article { @Id private String id; @Indexed private String userId; private String title; private List<Comment> comments; private Date createTime; }

复合索引的字段顺序有讲究,遵循 ESR 原则:Equality(等值)、Sort(排序)、Range(范围)。上面这个索引,查询条件如果是userId = ? AND createTime >= ?并按createTime倒序,正好全用上;如果反过来把createTime放前面,等值条件就用不上索引前缀,效果差很多。这个顺序不是随便排的,写注解前先想清楚查询长什么样。

5. Repository 与 MongoTemplate 的边界

5.1 Repository 擅长的那部分

MongoRepository最大的价值是方法名派生查询。findByUserIdAndStatusOrderByCreateTimeDesc这种写法,不用写一行实现,Spring Data 解析方法名自动生成查询。简单的等值、范围、排序、分页,用它效率极高,代码量也少。

public interface ArticleRepository extends MongoRepository<Article, String> { Page<Article> findByUserIdAndStatus(String userId, Integer status, Pageable pageable); List<Article> findByTagsIn(Collection<String> tags); long countByUserId(String userId); @Query("{ 'userId': ?0, 'createTime': { $gte: ?1 } }") List<Article> findRecent(String userId, Date since); }

分页参数用Pageable,PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createTime")),Spring Data 会自动转成skip和limit。这里要提醒一句:skip是有成本的,翻到一万页之后服务端要把前面的文档都扫一遍再丢弃,页码越大越慢。列表页如果可能翻很深,要改成游标分页,这个第 6 节会讲。

5.2 什么时候切到 MongoTemplate

有两类需求 Repository 搞不定,或者说写得很难看。

一是局部字段更新。Repository 的save是整体覆盖语义(实际上 Spring Data 会做 diff,但它判断的范围有限),对于“只更新阅读数 +1”这种需求,save要先查再改再写,两个来回还可能有并发覆盖问题。用MongoTemplate一条$inc就完事:

Query query = Query.query(Criteria.where("_id").is(articleId)); Update update = new Update().inc("viewCount", 1).set("lastViewTime", new Date()); mongoTemplate.updateFirst(query, update, Article.class);

二是复杂条件组合。动态拼查询条件,比如后台筛选器支持标题模糊、状态多选、时间范围、标签任意匹配,用方法名派生要写一堆findByXxxAndYyyOrZzz,没法维护。Criteria链式拼装清晰得多。

Criteria criteria = new Criteria(); List<Criteria> ands = new ArrayList<>(); if (StringUtils.hasText(keyword)) { ands.add(Criteria.where("title").regex(Pattern.quote(keyword), "i")); } if (statusList != null && !statusList.isEmpty()) { ands.add(Criteria.where("status").in(statusList)); } if (start != null) { ands.add(Criteria.where("createTime").gte(start)); } if (!ands.isEmpty()) { criteria.andOperator(ands.toArray(new Criteria[0])); } Query query = new Query(criteria) .with(Sort.by(Sort.Direction.DESC, "createTime")) .skip((long) page * size) .limit(size);

注意Pattern.quote,用户输入里带正则元字符(比如(、*、.)时,不转义会抛PatternSyntaxException,或者匹配出莫名其妙的结果。我用正则做模糊匹配时一定包一层。

5.3 两者混用的约定

一个项目里两种方式共存很正常,但要有个约定,否则后接手的人会很困惑。我的约定是:简单查询走 Repository,涉及局部更新、批量操作、聚合、复杂动态条件走 MongoTemplate。同一个业务对象的写操作尽量只在一个地方,别一半用 Repository 一半用 Template,不然缓存的失效、审计字段的填充、事件发布这些横切逻辑很容易漏。

另外,如果两者都注入了,记得给主MongoTemplate加@Primary,避免注入歧义。

6. 增删改查的写法细节

6.1 查询构造里容易踩的几个点

Criteria拼装时,多个条件用.and()串联,但如果条件之间有or关系,要显式用orOperator。我见过一个 bug,本意是“状态为 1 或 2,并且属于该用户”,写成了where("status").is(1).and("status").is(2).and("userId").is(uid),结果第二个条件覆盖了第一个,永远查不到状态 1 的数据。

正确写法是:

Criteria criteria = new Criteria().andOperator( new Criteria().orOperator( Criteria.where("status").is(1), Criteria.where("status").is(2) ), Criteria.where("userId").is(userId) );

数组字段的查询要区分“包含某个元素”和“元素满足多个条件”。where("tags").is("java")是数组包含java;where("tags").all("java", "spring")是同时包含两个;如果要对数组元素本身做多条件匹配,用elemMatch:

Criteria.where("comments").elemMatch( Criteria.where("userId").is(uid).and("score").gte(4) );

elemMatch和and混用是另一处高频错误。不加elemMatch直接where("comments.userId").is(uid).and("comments.score").gte(4),语义是“存在一个元素的 userId 是 uid,且存在一个元素的 score ≥ 4”,这两个可能是不同元素,结果比预期宽。这个差异在数据量小的时候不容易发现,上线才发现统计对不上。

6.2 数组元素更新与 arrayFilters

更新嵌套数组里的某个元素,老写法用位置操作符$:

Query query = Query.query( Criteria.where("_id").is(articleId) .and("comments.commentId").is(commentId)); Update update = new Update().set("comments.$.content", newContent); mongoTemplate.updateFirst(query, update, Article.class);

这个写法能用,但限制明显:只能匹配数组里第一个命中的元素,而且如果查询条件里出现多个数组字段,$指代哪个会不明确。更好的方案是arrayFilters:

Update update = new Update() .set("comments.$[c].content", newContent) .set("comments.$[c].updateTime", new Date()) .filterArray(Criteria.where("c.commentId").is(commentId)); mongoTemplate.updateFirst( Query.query(Criteria.where("_id").is(articleId)), update, Article.class);

arrayFilters支持多个过滤器、支持嵌套数组、支持更新所有命中元素,语义明确得多。缺点是服务端版本要求 3.6 以上,不过现在基本都在这个版本之上了。

往数组里追加元素,用$push配合$each批量加,$slice控制数组长度上限。这个组合很实用,比如“最近 100 条操作记录”:

Update update = new Update() .push("recentLogs").each(logEntry) .slice(-100);

一行就能保证数组不会无限膨胀。文档库没有字段长度上限,但一个文档无限长大之后,每次更新都要重写整个文档,性能会明显下降。超过 16MB 还会直接写失败。数组字段一定要有长度控制策略。

6.3 批量写入与 upsert

单条save循环一万次是最差的写法,每次一个网络往返,耗时按分钟算。批量走BulkOperations:

BulkOperations ops = mongoTemplate.bulkOps( BulkOperations.BulkMode.UNORDERED, Article.class); for (Article a : batch) { Query q = Query.query(Criteria.where("_id").is(a.getId())); Update u = new Update() .set("title", a.getTitle()) .set("status", a.getStatus()) .setOnInsert("createTime", new Date()); ops.upsert(q, u); } BulkWriteResult result = ops.execute();

UNORDERED表示并行执行、某条失败不影响其他条,适合数据同步场景;ORDERED是顺序执行、遇到错误就停,适合有依赖关系的场景。选错了不会报错,但行为差异很大。

批量插入用mongoTemplate.insert(list, Article.class),内部会分批走 bulk,比循环save快一个数量级。但要注意,insert是纯插入,如果_id已存在会抛DuplicateKeyException,整个批次可能部分成功。做幂等导入时要用upsert,别用insert。

6.4 深分页与游标方案

skip + limit在数据量大时是性能杀手。翻到第 1000 页,skip(20000),服务端要扫描两万条文档再丢弃,耗时可能是第一页的几十倍。我的判断标准是:总页数可能超过 100 页、或者单页数据量大的列表,一律改成游标分页。

游标分页的核心是“记住上一页最后一条的排序字段值”,下一页从这个值之后取:

Criteria criteria = Criteria.where("status").is(1); if (lastCreateTime != null) { criteria.and("createTime").lt(lastCreateTime); } Query query = new Query(criteria) .with(Sort.by(Sort.Direction.DESC, "createTime")) .limit(size);

配合createTime上的索引,每次查询都是索引区间扫描,耗时稳定,与页码无关。前端不需要知道页码,只需要拿着上一页返回的游标继续请求。

这个方案有个前提:排序字段必须唯一或者组合唯一。如果createTime有重复值,可能漏数据或者重复数据。稳妥做法是用createTime + _id组合排序,游标条件写成(createTime < lastTime) OR (createTime = lastTime AND _id < lastId)。

7. 上线前的三件确认事项

7.1 事务到底需不需要

SpringBoot 从 2.2 开始,只要注入的是MongoDatabaseFactory,MongoTransactionManager会自动配置好,直接在方法上加@Transactional就能用。但有个硬性前提:必须是副本集部署,单机模式不支持事务,调用时会抛Transaction numbers are only allowed on a replica set member or mongos。

本地开发想验证事务,用容器起一个单节点副本集就行,启动参数加--replSet rs0,然后进容器执行一次rs.initiate()。这样本地和服务端行为一致,不会出现“本地好使、线上报错”的情况。

事务的使用范围要克制。我的原则是:一组写操作必须同时成功或同时失败,才用事务;只是几条不相关的写入,用BulkOperations就够了,事务反而增加开销。事务里也不要做远程调用、不要做大量循环写入,事务持有时长上去之后,锁冲突和回滚成本都会上升。

7.2 索引的创建时机与验证方式

索引不能等出问题再补。上线前的检查动作是这样:把实体上的@Indexed、@CompoundIndex全部对照实际查询梳理一遍,确认每个高频查询都有索引覆盖。整理成一张清单,标注索引名、字段、用途,跟代码一起评审。

创建索引有两种方式:createIndex前台创建,会阻塞该集合的读写;createIndexes带background: true(4.2 之后其实默认就是增量构建,不再长时间阻塞)。生产环境大批量数据建索引,建议放到低峰期,并且监控构建进度。

验证是否用上索引,用explain:

db.article.find({userId: "u1", createTime: {$gte: ISODate("2024-01-01")}}) .sort({createTime: -1}) .explain("executionStats")

看winningPlan里的stage,如果是IXSCAN说明走了索引,COLLSCAN就是全表扫描。再看totalKeysExamined和nReturned的比例,接近 1:1 说明索引选择性好,比例悬殊说明索引建得不对或者查询条件不匹配。

提示:定期用db.collection.aggregate([{$indexStats:{}}])看索引的使用次数,长期零使用的索引就是纯负担,每次写入都要维护它,该删就删。

7.3 监控指标要提前接上

MongoDB 出问题往往不是突然崩,而是慢慢劣化。有几个指标我每次上线前都会确认接入了监控:当前连接数(接近上限就是预警)、慢查询数量(超过 100ms 的查询占比)、复制延迟(副本集从节点落后主节点的时间)、队列等待数(请求排队等连接的时长)。

驱动层面,Spring Boot Actuator 可以暴露一些指标,但更细的要看服务端。应用侧我一般在数据访问层加一层耗时打点,把超过阈值的查询语句和耗时记到日志里,方便回溯。这个成本很低,但排查慢查询时非常有用——你能直接看到是哪条语句、传了什么参数、耗时多少,不用去服务端翻 profile 数据。

8. 我实际踩过的几个报错和根因

第一个是集合名不对导致查不到数据。实体类叫Article,默认集合名是article,但历史数据在articles里。应用启动正常,插入也正常(插到新集合里去了),就是查不到老数据。排查时先看db.getCollectionNames(),一眼就看出来了。这个错误的教训是:整合时第一件事就是确认集合名,别依赖默认值。

第二个是索引没生效导致接口超时。查询语句跟索引字段完全对得上,但explain显示COLLSCAN。最后发现是auto-index-creation没开,注解只是写着好看。改成脚本建索引后正常。这件事之后,我养成了一个习惯:新项目接入时先手动跑一遍索引清单,不依赖任何自动机制。

第三个是连接池打满。报错是Wait queue for available connection timeout,接口大面积超时。原因是某个导出功能一次性查出十万条数据,用findAll()全量遍历,每条都占着连接不放,加上其他请求,池子瞬间见底。改法是导出走游标分批读取,每次取 1000 条处理完释放,同时给这个功能单独限流。文档库不是不能查大数据量,但不能用“一次性全拿出来”的方式查。

第四个是时区错位。前面第 4 节提过,LocalDateTime存储时的时区转换问题。现象是运营在管理后台看到的时间比实际早 8 小时。最后的处理是把所有时间字段统一改成Instant,展示层再做时区转换,同时把容器和 JVM 的时区都固定成同一个值。

第五个是嵌套文档并发更新丢数据。两个请求同时更新同一篇文章的不同评论,用整体save的方式,后写的覆盖了先写的。改成arrayFilters做局部更新之后就没问题了。这个坑的本质是:能局部更新就不要整体覆盖,尤其是文档里有数组、有计数字段的时候,整体覆盖的并发风险很高。

这几个问题有个共同点:都不是语法错误,编译和启动都正常,是在特定数据量、特定并发下才暴露出来的。所以整合完跑通 CRUD 只是起点,真正要做的是把索引、连接池、并发更新策略这几件事在上线前想清楚,用脚本和监控把它们固定下来。我现在接一个新项目,第一步是把连接串参数按业务量算一遍,第二步是把实体上的索引注解抄成脚本,第三步是写一个并发更新的集成测试,这三步做完,后面基本不会出大问题。

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

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

立即咨询