做Java后端这些年,MySQL和Redis基本是标配,但去年我在一个数据模型多变、字段经常增删的项目里被关系型数据库的改表流程折磨得够呛,几轮需求迭代后我决定把MongoDB 6.0引入作为主存储之一。当时团队里有不少人质疑:Java项目配MongoDB,事务怎么办、关联查询怎么办、会不会用着用着就性能翻车?老实说这些问题我都遇到过,也在生产环境里逐一解决了。这份指南就是我基于MongoDB 6.0从零上手、到完成Java生态整合、再到线上平稳运行的完整记录,适合正在评估MongoDB或者已经在项目里用它做持久化的Java开发者参考。
1. 为什么做这份Java版MongoDB实战指南
1.1 从一次线上事故说起
先讲个真实案例。之前团队接到一个订单中台项目,上游业务方给过来的订单结构每周都在变:今天加一个promotionInfo,明天加一个riskControl.extraData。按MySQL的做法,每次变更都得走一遍"评估存量数据-写迁移脚本-凌晨发布-回刷数据"的流程,一个字段往往要折腾两三天。后来我们把订单主文档直接落到MongoDB,字段变更从"改表结构"降级成"改代码里的Bean",整个迭代节奏明显不一样了。
但引入MongoDB并不是没有代价。初期我们遇到最典型的问题是:Java对象里的BigDecimal金额字段存到MongoDB后变成了字符串,排序和聚合对不上;LocalDateTime反序列化后格式错乱;最麻烦的是聚合管道里做$lookup关联时,ObjectId和字符串类型匹配不上,结果集直接为空。这些坑在官方文档里都有解释,但文档不会告诉你它们在实际项目里会以什么形态出现。这份指南把这类问题集中记录下来,你遇到时可以直接对照排查。
1.2 MongoDB到底适合什么场景
先泼一盆冷水:MongoDB不是万能钥匙,它有非常明确的适用边界。我自己的判断标准有两条,一是数据有无固定的Schema,二是读写模式是否需要频繁变更字段。电商订单、用户画像、IoT上报数据、内容管理的富文档结构,这类数据天生就是半结构化的,落MongoDB非常舒服。反过来说,如果你的核心业务是强事务的账务系统,每一笔操作都要严格保证ACID并且跨多个文档,那MongoDB虽然支持事务,但用起来的心理成本和运维成本都比MySQL高不少。
另一个很实际的问题是团队技术栈。如果团队里全是写了五六年SQL的老手,短时间内适应文档模型的思维模式会有点痛苦。我的经验是从一个小模块开始试点,比如先用它存储日志、配置、消息流水这类非核心数据,等团队对文档模型建立起感觉后,再逐步扩大使用范围。直接上来就把订单、库存全部迁过去,风险太大了。
2. 环境准备与MongoDB 6.0快速上手
2.1 用Docker 30秒跑起一个6.0实例
本地开发我推荐直接Docker跑,省去安装、配置的麻烦。我常用的是这样一条命令:
docker run -d \ --name mongo6 \ -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAME=admin \ -e MONGO_INITDB_ROOT_PASSWORD=admin123 \ -v mongo_data:/data/db \ mongo:6.0启动后先别急着连,MongoDB 6.0默认开启了认证,要用账号密码才能连。这里有个细节,如果你只是本地调试,不想每次都带用户名密码,可以去掉MONGO_INITDB_ROOT_USERNAME和MONGO_INITDB_ROOT_PASSWORD这两个环境变量,MongoDB会以trust模式启动,本地连接不需要认证。但线上环境一定别这么干,默认配置只有本地回环地址能连,一旦你绑定了0.0.0.0又没有认证,等于把数据裸奔在公网上。
连接串我一般这么写:
mongodb://admin:admin123@localhost:27017/admin?authSource=adminauthSource=admin表示认证库指向admin库,这是初学者最容易漏的参数。你单独创建了一个业务账号,认证库通常还是admin,漏了它就会一直报认证失败。
2.2 基础概念:用MySQL的思路理解MongoDB
文档模型和关系模型本质上是两种不同的世界观。MySQL是先设计表结构,再往表里塞数据;MongoDB是先想好一个文档长什么样,然后把整个JSON结构直接塞进集合。对应关系大概是这样的:
| MySQL | MongoDB | 说明 |
|---|---|---|
| 数据库Database | 数据库Database | 概念一致 |
| 表Table | 集合Collection | 集合不强制Schema,可以混合存放不同结构的文档 |
| 行Row | 文档Document | BSON格式,本质上是JSON的二进制扩展 |
| 列Column | 字段Field | 文档里每个键值对就是一个字段 |
| 主键Primary Key | _id | 自动生成,默认是ObjectId |
| Join关联查询 | $lookup或嵌入式文档 | 设计阶段就要提前规划 |
| 索引Index | 索引Index | 用法基本一致,支持唯一、复合、TTL等 |
建库建表在MongoDB里基本不需要"提前声明",你往一个不存在的集合里插入第一条文档时,数据库和集合会被自动创建。这个特性对快速迭代非常友好,但也容易埋坑:你写错了集合名,比如把order写成orders,代码不会报错,而是悄悄创建了一个新集合,线上的数据就分家了。我的建议是启动时加一个集合校验逻辑,或者在代码上线前用脚本确认集合名与设计文档完全一致。
2.3 第一个Java连接:驱动选型与配置
Java连接MongoDB有两条主流路线:官方同步驱动(mongodb-driver-sync)和Spring Data MongoDB。底层其实都是同一个驱动,Spring Data只是包了一层更高级的抽象。
先看官方驱动的Maven依赖:
<dependency> <groupId>org.mongodb</groupId> <artifactId>mongodb-driver-sync</artifactId> <version>4.11.0</version> </dependency>注意版本要和MongoDB 6.0匹配。MongoDB 3.6之后的版本,驱动兼容性是向后兼容的,但也不要随便用太老的驱动连新版本服务端,很多新特性(如聚合管道新运算符、可查询加密)在旧驱动里根本没有对应的API。
建立连接我习惯这样写:
String connectionString = "mongodb://admin:admin123@localhost:27017/admin?authSource=admin"; MongoClientSettings settings = MongoClientSettings.builder() .applyConnectionString(new ConnectionString(connectionString)) .applyToConnectionPoolSettings(builder -> builder .maxSize(50) .minSize(10) .maxWaitTime(Duration.ofSeconds(5))) .applyToSocketSettings(builder -> builder .connectTimeout(Duration.ofSeconds(3)) .readTimeout(Duration.ofSeconds(10))) .build(); MongoClient client = MongoClients.create(settings); MongoDatabase db = client.getDatabase("demo"); MongoCollection<Document> orderCollection = db.getCollection("order");这里重点解释连接池参数。maxSize=50是连接池的上限,这个值不是越大越好,每个连接占一个线程,太多反而浪费资源,一般和应用的QPS、数据库副本数相关。maxWaitTime=5s的意思是,连接耗尽时请求最多等5秒,超过就抛异常,这是防止雪崩的关键参数。另外我建议连接池的minSize不要设为0,否则突发流量下连接池要重建一堆连接,首次请求延迟会很高。
3. 数据建模与CRUD:从写对到写优雅
3.1 文档建模的核心决策:嵌入还是引用
这是MongoDB设计中最关键的决策,在我看来没有之一。嵌入文档就是把关联数据直接塞在父文档里;引用文档则是只存对方的主键_id,查询时再做关联。
什么场景适合嵌入?一对一的强关联、子数据几乎总是随父数据一起读出的场景。比如订单里的收货地址,你查订单时几乎永远需要地址,没必要单独建一张address表再关联。把地址直接作为订单文档的一个子对象,一次IO就能拿到全部数据,性能是最好的。
什么场景适合引用?一对多并且子集合数据独立更新的场景。比如订单与订单明细,明细可能有几十上百条,而且明细经常被单独查询和修改。如果全塞进父文档,每次更新整条文档的代价会很高,MongoDB的文档有16MB大小限制,明细数量一旦膨胀就会顶到天花板。
我踩过的一个坑是"过度嵌入"。曾经把用户的所有操作日志直接嵌在用户文档里,一个月后单个用户文档超过20MB,直接写入报错。后续拆出来单独建了user_action_log集合,用userId做引用,才算解决。现在我的建模原则是:先画出一张查询路线图,列出所有核心查询需要的字段和频率,然后选择嵌入或引用,而不是凭感觉。
3.2 完整CRUD代码:Driver与Spring Data两种写法
先看纯Driver的写法,逻辑最清晰,也适合理解MongoDB的工作机制。
插入一条订单:
Document order = new Document("orderNo", "NO20240601001") .append("userId", 1001L) .append("amount", new BigDecimal("199.90")) .append("status", 0) .append("items", Arrays.asList( new Document("skuId", "A001").append("name", "手机").append("price", 199.90), new Document("skuId", "A002").append("name", "壳膜").append("price", 0.00) )) .append("createTime", LocalDateTime.now()); orderCollection.insertOne(order);查询时用Filters工具类,这是官方驱动里最常用的一组静态方法:
// 精确匹配 Document doc = orderCollection.find(Filters.eq("orderNo", "NO20240601001")).first(); // 复合条件 List<Document> list = orderCollection.find( Filters.and( Filters.eq("userId", 1001L), Filters.gte("createTime", LocalDateTime.now().minusDays(7)) ) ).sort(Sorts.descending("createTime")).limit(20).into(new ArrayList<>()); // 更新 orderCollection.updateOne( Filters.eq("orderNo", "NO20240601001"), Updates.combine( Updates.set("status", 1), Updates.currentDate("updateTime") ) );Updates.currentDate是官方推荐的更新时间方式,它直接把服务端当前时间写进去,避免了Java应用服务器时间和数据库时间不一致的问题。分布式系统里两台应用服务器时钟差个几秒很常见,能用数据库时间就用数据库时间。
Spring Data MongoDB的写法更符合Java开发者的日常习惯。定义一个实体类:
@Document("order") public class OrderEntity { @Id private String id; private String orderNo; private Long userId; private BigDecimal amount; private Integer status; private List<OrderItem> items; private LocalDateTime createTime; }注意@Document("order")注解里的集合名,一定要和Driver里用的集合名一致,大小写敏感。这个我吃过亏,Win环境开发、Linux环境部署,代码里写的ORDER和order在不同系统下表现不一样,最后统一成小写。
Repository接口:
public interface OrderRepository extends MongoRepository<OrderEntity, String> { List<OrderEntity> findByUserIdAndStatus(Long userId, Integer status); @Query("{ 'orderNo': ?0 }") Optional<OrderEntity> findByOrderNo(String orderNo); }方法名自动解析是Spring Data的招牌能力,findByUserIdAndStatus会被解析成{ userId: ?0, status: ?1 }的查询,不需要手写查询语句。但复杂查询我建议直接上@Query注解,方法名解析出来的语句可读性差,而且还容易因为嵌套对象字段名写错排查半天。
3.3 写入与查询的细节坑
开发阶段最容易遇到的是BigDecimal的问题。官方Java驱动默认把BigDecimal序列化成字符串,这样做的原因是BSON的double类型精度不够,存金额会有误差。但问题是,存成字符串之后,聚合管道里做$sum求和就会失败,因为$sum不识别字符串。我的做法是对金额字段统一使用Decimal128类型,MongoDB是原生支持这个类型的,定义字段时用:
.append("amount", new org.bson.types.Decimal128(new BigDecimal("199.90")))Spring Data里面可以在字段上加@Field(targetType = FieldType.DECIMAL128)注解。这样既保证了精度,聚合时也能直接运算。
另一个常见的坑是LocalDateTime序列化后变成数组。MongoDB Java驱动会把Java 8时间类型序列化为一个数组结构,比如{ "$date": 1717228800000 },这在文档里看起来很丑,而且直接查库时不容易看懂。解决方法有几种:一种是在实体类里用@Field(targetType = FieldType.DATE_TIME)强制转成BSON Date,另一种是自己在Service层做时间戳转换。我推荐前者,能保持实体类字段类型不变,查询结果也方便阅读。
4. 索引与聚合管道:让查询在数据量大时依然能打
4.1 索引设计与explain分析
没有索引的MongoDB在数据量小时很好用,但到了几百万条数据,全表扫描的代价就直接暴露出来了。MongoDB查询优化器的工作原理和MySQL类似,也是根据统计信息选择执行计划。我的经验是,给每个支撑线上查询字段建索引,但不要过度建索引,因为每次写入都要维护索引,索引太多写入性能会明显下滑。
最常用的是单字段索引和复合索引。比如订单查询总是带userId和status,我会建一个复合索引:
db.order.createIndex({ userId: 1, status: 1, createTime: -1 })复合索引的顺序很关键。MongoDB走索引时最左前缀原则和MySQL差不多:{ userId: 1, status: 1 }这个索引,可以支撑userId单独查询,但不能支撑status单独查询。所以在设计索引时,要把等值查询的字段放在前面,范围查询的字段放在后面。createTime用-1是因为大多数场景下我们要查最近的数据,倒序索引能直接避免一次反向扫描。
判断一个查询有没有走索引,最直观的办法是执行explain:
db.order.find({ userId: 1001, status: 0 }).explain("executionStats")看executionStats.totalDocsExamined和totalKeysExamined,如果totalDocsExamined远大于实际返回条数,说明走的是全表扫描。正常情况下totalDocsExamined应该和返回条数基本一致。这个命令要养成习惯,每条新查询上线前先跑一遍。
4.2 聚合管道:一个订单统计的真实案例
聚合管道是MongoDB最强大的功能之一,对应到SQL里就是GROUP BY、JOIN、子查询等操作的集合。Java里可以通过Aggregates工具类和Bson对象组合管道,也可以直接传入JSON字符串。我以"统计最近30天每个用户的订单数和订单总金额"为例,演示一下Elasticsearch级别分组统计的写法。
先看mongosh里的原生写法:
db.order.aggregate([ { $match: { createTime: { $gte: ISODate("2024-05-01T00:00:00Z") } } }, { $group: { _id: "$userId", orderCount: { $sum: 1 }, totalAmount: { $sum: "$amount" } } }, { $sort: { totalAmount: -1 } }, { $limit: 10 } ])这里_id: "$userId"表示按userId分组,$sum: 1是计数,$sum: "$amount"是对金额求和。注意$sum对Decimal128和double都有效,但如果amount是你之前存的字符串,这里就会报错或者得0,这也是我强调金额字段必须用Decimal128的原因。
Java版本对应的代码:
List<Bson> pipeline = Arrays.asList( Aggregates.match(Filters.gte("createTime", startTime)), Aggregates.group("$userId", Accumulators.sum("orderCount", 1), Accumulators.sum("totalAmount", "$amount")), Aggregates.sort(Sorts.descending("totalAmount")), Aggregates.limit(10) ); List<Document> result = orderCollection.aggregate(pipeline).into(new ArrayList<>());$lookup对应SQL里的LEFT JOIN。因为MongoDB不推荐跨集合关联,所以$lookup的性能通常不如MySQL的Join——它本质上是在内存里做hash Join,大集合关联大集合时内存消耗会非常夸张。我建议尽量通过建模规避$lookup,如果实在避免不了,务必在关联字段上建索引,并且用小集合驱动大集合。
4.3 性能优化的常用手段
第一个是慢查询日志。MongoDB 6.0默认会记录超过100ms的查询,日志里会标出COMMAND和planSummary。我排查线上性能问题时总是先去看日志里有没有Slow query字样,然后针对慢查询做索引优化。如果索引加了之后还慢,就可能是查询条件写得太宽,或者数据模型本身设计不合理。
第二个是查询只返回需要的字段。很多开发者写查询时习惯直接把整个文档查出来,在Java里再挑几个字段用。这样做白白浪费了网络带宽和序列化性能。MongoDB支持投影,只返回需要的字段:
orderCollection.find(Filters.eq("orderNo", "NO20240601001")) .projection(Projections.fields( Projections.include("orderNo", "amount", "status"), Projections.excludeId())) .first();第三个是合理使用分页。经典的分页方案是skip+limit,但数据量大时,skip会从头扫描到偏移位置,开销极大。我处理深分页问题时改用"查询条件 + 上一次的游标值"方案。比如按createTime排序,每次查的时候带上createTime小于上一页最后一条记录的过滤条件,这样每次查询都是走索引的,性能非常稳定。
5. 事务、一致性与应用层策略
5.1 Mongo事务的正确打开方式
MongoDB 4.0开始支持多文档事务,6.0版本已经相当成熟了。但先强调一个前提:事务只在副本集和分片集群上支持,单机实例不支持。很多人本地用Docker跑了一个单节点,测试时一调withTransaction就报错,原因就在这里。解决办法是Docker启动时指定副本集名字,比如加--replSet rs0,然后手动执行rs.initiate()。
Java驱动里开启事务的官方姿势是:
try (ClientSession session = client.startSession()) { session.startTransaction(TransactionOptions.builder() .writeConcern(WriteConcern.MAJORITY) .readPreference(ReadPreference.primary()) .build()); try { orderRepository.insert(session, order); stockRepository.decrease(session, skuId, 1); session.commitTransaction(); } catch (Exception e) { session.abortTransaction(); throw e; } }这里有两个细节。第一,事务里所有操作必须挂在同一个ClientSession上,包括insert、update和查询;第二,Spring Data MongoDB中,MongoTemplate有withSession或者MongoOperations的重载方法,但要注意Repository的方法需要额外传入Session参数。我使用过程中最常犯的错就是忘记把session传给第二个操作,结果第二个操作在事务外面执行,事务控制的语义就完全失效了。
事务并不是免费午餐。分布式事务在MongoDB里跨文档操作时,锁粒度是文档级别的,多个事务同时操作同一个文档时会出现写冲突,其中一个会抛出TransientTransactionError。代码里要有重试机制,我一般用Guava的Retryer或者Spring重试模板包一层,遇到该异常时重试2-3次。
5.2 读偏好与写关注:一致性由你决定
MySQL中,主从复制的数据延迟问题很常见,MongoDB同理。MongoDB提供了三个可配置维度来调节一致性级别:写关注(Write Concern)、读偏好(Read Preference)和读关注(Read Concern)。
写关注决定了一次写操作在什么情况下算成功。默认是w:1,主节点写完就算成功;w:majority要求大多数副本节点都写入才算成功,数据安全性更高,但写入延迟会略增。对于核心的订单数据,我建议用w:majority。你可以修改全局默认值,也可以在单个写入请求里指定。
读偏好决定读主节点还是从节点。默认是primary,所有读都走主节点,保证强一致。为了分担主节点压力,可以把一些非核心读(如报表查询)设置secondary,但要接受最多几十到几百毫秒的从节点延迟。在我的项目里,普通用户查询订单走primary,后台管理报表走secondaryPreferred,效果还行。
读关注是在读操作时指定读取数据的隔离级别。比如readConcern: majority表示只读已经被大多数节点确认过的数据,防止读到回滚数据。在这个维度上的配置要结合业务,不是所有查询都需要最高级别。
Java驱动中统一设置:
MongoClientSettings settings = MongoClientSettings.builder() .writeConcern(WriteConcern.MAJORITY) .readConcern(ReadConcern.MAJORITY) .readPreference(ReadPreference.primaryPreferred()) .build();6. 真枪实弹排障:常见问题与排查技巧实录
6.1 连接池耗尽与慢查询
线上最常见的故障是"连接池耗尽"。表现是应用日志里出现大量MongoTimeoutException: Timed out after 5000 ms waiting for a connection from pool。产生原因无非两类:一类是某类请求或任务把连接占住不放(通常是一次查询返回了大量数据,传输超时);另一类是请求量突增,超过了maxSize的上限。
我排查这类问题时,第一步看MongoDB的db.serverStatus().connections,确认服务端连接数是否达到上限;第二步看慢日志,找出哪些查询占用了大量时间;第三步把连接池从默认的maxSize=100按场景调低并设置合理的maxWaitTime。有一次我们把一个导出功能改成异步任务,把一个大查询放到线程池里跑,结果所有线程同时去拿连接,直接把连接池打爆了。改成限制并发数后问题消失。
慢查询优化方面,最简单高效的一招就是看执行计划。我救过最典型的案例是:一个status字段的查询,数据分布是99.9%的文档status=0,只有0.1%是status=1,开发者在status上建了索引,但查询status=0时优化器认为走全表扫描更快,因为绝大部分文档都要读出来。这种情况下我直接把查询需求改成"只查status=1"并加上联合条件,或者用部分索引,问题立刻解决。经验是:建立索引前一定先看基数分布,而不是见到查询就加索引。
6.2 序列化与类型相关的坑
Java开发者整合MongoDB时,至少会遇到以下几个类型的坑。
第一个是ObjectId反序列化。MongoDB默认主键_id是ObjectId类型,映射到Java实体类时,如果你用String id接收,Spring Data可以自动转换,但如果你在Web层直接把这个_id作为JSON输出,有的框架会把ObjectId序列化成{"timestamp": ..., "date": ...}这种结构,给前端造成困扰。解决方案是加一个@JsonSerialize(using = ToStringSerializer.class)注解,或者在DTO里直接定义为String。
第二个是集合名和字段名大小写问题。MongoDB的集合名和字段名是大小写敏感的,Java的实体属性命名是驼峰风格。Spring Data默认会把orderNo映射到orderNo,但如果你手写了@Query("{ 'order_no': ?0 }")而实体字段是orderNo,那就对不上了。我统一用Belongs to的命名规范:Java字段保持驼峰,数据库字段也用驼峰,避免JSON和Java之间的映射混乱。
第三个是嵌套对象的更新。很多人想更新文档里某个子字段,直接updateOne传整个子文档,结果把其他字段覆盖了。正确的做法是使用点路径:
Updates.set("items.$.price", 99.00)配合数组过滤条件Filters.elemMatch("items", Filters.eq("skuId", "A001"))可以精确定位到要更新的数组元素,但这属于进阶用法,新手容易踩坑写错路径。
6.3 集群健康与磁盘告警
MongoDB 6.0继续沿用了WiredTiger存储引擎,磁盘占用和内存管理有一套独立的机制。有一个老生常谈但很多人不知道的点:删除文档不会立刻释放磁盘空间,只有在compact或者数据重写后空间才能回收。所以线上经常看到数据库文件越来越大,用db.stats()一看就是dataSize不大但fileSize巨大。遇到这种情况,如果确认是大量删除数据造成的碎片,可以安排维护窗口执行:
db.runCommand({ compact: "order" })或者使用db.collection.drop()重建集合。compact会阻塞当前集合的读写操作,生产环境谨慎使用。
监控磁盘和内存是MongoDB运维的基本功。我最常用的是Prometheus + Grafana,采集mongod进程指标和MongoDB Exporter指标。核心关注三个指标:mongod内存的resident内存大小、磁盘IO利用率、复制延迟(replSet lag)。复制延迟一旦持续上升,说明从节点同步跟不上,可能导致读从节点的查询数据过期。
还有一个容易被忽略的问题:mongod进程使用的最大文件描述符限制。在高并发场景下,连接数多、打开文件多,如果系统ulimit设置太低,MongoDB会直接拒绝新连接。线上务必把ulimit -n调到65535以上,否则半夜扩容时会出现"明明没到连接池上限但连不上了"的怪象。
7. 最后的体悟与建议
从引入MongoDB 6.0到现在,我的感受是它非常适合Java生态里的半结构化数据存储场景,但使用前一定要想清楚几个问题:团队是否有意愿学习文档模型的思维、业务数据是否真的需要频繁变更字段、事务一致性级别是否可以接受取舍。我一直觉得,技术选型不是选最火的,而是选最适合当前问题域的。
如果几天内想快速上手,我的建议路径是:先自己从零搭一个Spring Boot项目,接入Spring Data MongoDB,写一遍完整的CRUD和聚合查询;然后给它接上事务,体会一次ClientSession串来串去的感觉;最后构造50万条模拟数据,设计索引,跑几轮慢查询优化。这个流程走完,你对MongoDB的认知会比看十篇博客都深。
最后一个私货分享:在Java项目里流传一种做法——把MongoDB当成"缓存数据库",只存热数据,冷数据扔MySQL。这种做法技术上可行,但会增加系统复杂度和链路,数据一致性也更难保证。不如在项目初期就明确一个数据域只用一种主存储,清晰简单,后面维护成本低得多。每做一个技术决策前,问自己一句"这能让系统更简单还是更复杂",答案就会清晰很多。