资讯动态

SpringBoot整合MongoDB:选型、连接池、映射与索引实践

发布时间:2026/10/26 19:28:42 来源:尧图企业网站定制
上周帮一个做内容社区的朋友看他接手的一个后台系统翻到数据访问层的时候我看到一个挺典型的写法文章正文、评论、标签全塞在一张关系表里评论用一个 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 groupIdorg.springframework.boot/groupId artifactIdspring-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库但连接串里没写authSourceadmin默认会拿业务库去认证必然失败。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%40ss10.0.1.11:27017,10.0.1.12:27017,10.0.1.13:27017/content_db?replicaSetrs0authSourceadminmaxPoolSize50minPoolSize5connectTimeoutMS3000socketTimeoutMS15000serverSelectionTimeoutMS5000readPreferencesecondaryPreferredretryWritestruewmajority auto-index-creation: false注意auto-index-creation这一行它非常关键后面第 4 节会专门讲。默认值是false也就是说你写Indexed注解但不开这个开关索引根本不会创建查询走全表扫描数据量上来之后慢得莫名其妙。3.2 连接池和超时参数怎么定连接池这几个参数我给一个经验区间和判断依据参数默认值我的常用值依据maxPoolSize10020~50单实例连接数上限要乘上应用实例数后低于服务端maxIncomingConnectionsminPoolSize05~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或者显式指定readPreferenceprimary否则用户会看到自己刚提交的内容不翼而飞然后投诉你。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.timezoneAsia/Shanghai并且在容器镜像里也配上别指望默认值。第二次坑更隐蔽是嵌套对象里的 ObjectId 序列化。实体字段如果是String类型的 IDSpring Data 在映射时会转好返回给前端是字符串。但如果某个嵌套文档的字段类型是ObjectId比如手动用MongoTemplate写入的、或者从别的集合引用过来的Jackson 序列化时会把ObjectId当成普通对象展开变成{timestamp:1700000000,machineIdentifier:123456,processIdentifier:1,counter:789}这种结构前端拿到直接懵。解决办法是在字段上加JsonSerialize(using ToStringSerializer.class)或者全局注册一个ObjectId的 Jackson 序列化器。public class ObjectIdSerializer extends JsonSerializerObjectId { 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 ListComment 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 MongoRepositoryArticle, String { PageArticle findByUserIdAndStatus(String userId, Integer status, Pageable pageable); ListArticle findByTagsIn(CollectionString tags); long countByUserId(String userId); Query({ userId: ?0, createTime: { $gte: ?1 } }) ListArticle findRecent(String userId, Date since); }分页参数用PageablePageRequest.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(); ListCriteria 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)是数组包含javawhere(tags).all(java, spring)是同时包含两个如果要对数组元素本身做多条件匹配用elemMatchCriteria.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);这个写法能用但限制明显只能匹配数组里第一个命中的元素而且如果查询条件里出现多个数组字段$指代哪个会不明确。更好的方案是arrayFiltersUpdate 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循环一万次是最差的写法每次一个网络往返耗时按分钟算。批量走BulkOperationsBulkOperations 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 开始只要注入的是MongoDatabaseFactoryMongoTransactionManager会自动配置好直接在方法上加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: true4.2 之后其实默认就是增量构建不再长时间阻塞。生产环境大批量数据建索引建议放到低峰期并且监控构建进度。验证是否用上索引用explaindb.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 只是起点真正要做的是把索引、连接池、并发更新策略这几件事在上线前想清楚用脚本和监控把它们固定下来。我现在接一个新项目第一步是把连接串参数按业务量算一遍第二步是把实体上的索引注解抄成脚本第三步是写一个并发更新的集成测试这三步做完后面基本不会出大问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑