在软件开发中,我们选择的技术栈往往带有一些固有的设计边界。对于 MongoDB 而言,其最著名的边界之一便是 BSON 文档最大 16MB 的大小限制。在大多数场景下,这个限制是绰绰有余的,它鼓励开发者设计更为精简和规范的数据模型。然而,在某些特定业务场景下,单个逻辑实体的数据量自然增长并最终触及这个上限,就成了一个棘手的问题。本文将分享一次处理此类问题的完整过程,从一个有隐患的初始设计,到一个健壮、可扩展的最终方案。
最初的困境:一个“定时炸弹”式的数据模型
问题的起源是一个看似合理的数据模型。系统中存在一个核心实体,其文档结构中包含了一个用于聚合数据的集合字段。例如,一个用于记录和合并分析结果的文档,其结构可以被简化为如下形式:
// 初始数据模型
public class ParentDocument {private ObjectId id;private String name;// 该字段用于持续聚合数据,是问题的根源private List<DataObject> aggregatedData;
}
其业务逻辑是,当新的数据片段产生时,系统会读取整个 ParentDocument
,将新的数据片段与 aggregatedData
列表在内存中合并,然后将包含完整新列表的 ParentDocument
整个保存回数据库。在系统初期,数据量不大,这种“读取-修改-写回”的模式运行良好,逻辑清晰且易于实现。
然而,这个方案的弊端是致命的。随着业务的持续运行,aggregatedData
列表不断膨胀。每一次合并都使得文档的体积越来越大,像一个被不断吹气的气球,最终必然会达到其物理极限。当某次合并后的文档总体积超过 16MB 时,MongoDB 的驱动程序在尝试保存时会立即抛出 BsonMaximumSizeExceededException
异常,导致整个业务流程中断。这个问题就像一颗预先埋下的定时炸弹,它的爆炸只是时间问题。
报错:
error=Payload document size is larger than maximum of 16777216.
org.bson.BsonMaximumSizeExceededException: Payload document size is larger than maximum of 16777216.at com.mongodb.internal.connection.BsonWriterHelper.writePayload(BsonWriterHelper.java:68)at com.mongodb.internal.connection.CommandMessage.encodeMessageBodyWithMetadata(CommandMessage.java:162)at com.mongodb.internal.connection.RequestMessage.encode(RequestMessage.java:138)at com.mongodb.internal.connection.CommandMessage.encode(CommandMessage.java:59)at com.mongodb.internal.connection.InternalStreamConnection.sendAndReceive(InternalStreamConnection.java:268)at com.mongodb.internal.connection.UsageTrackingInternalConnection.sendAndReceive(UsageTrackingInternalConnection.java:100)at com.mongodb.internal.connection.DefaultConnectionPool$PooledConnection.sendAndReceive(DefaultConnectionPool.java:490)at com.mongodb.internal.connection.CommandProtocolImpl.execute(CommandProtocolImpl.java:71)at com.mongodb.internal.connection.DefaultServer$DefaultServerProtocolExecutor.execute(DefaultServer.java:253)at com.mongodb.internal.connection.DefaultServerConnection.executeProtocol(DefaultServerConnection.java:202)at com.mongodb.internal.connection.DefaultServerConnection.command(DefaultServerConnection.java:118)at com.mongodb.internal.operation.MixedBulkWriteOperation.executeCommand(MixedBulkWriteOperation.java:431)at com.mongodb.internal.operation.MixedBulkWriteOperation.executeBulkWriteBatch(MixedBulkWriteOperation.java:251)at com.mongodb.internal.operation.MixedBulkWriteOperation.access$700(MixedBulkWriteOperation.java:76)at com.mongodb.internal.operation.MixedBulkWriteOperation$1.call(MixedBulkWriteOperation.java:194)at com.mongodb.internal.operation.MixedBulkWriteOperation$1.call(MixedBulkWriteOperation.java:185)at com.mongodb.internal.operation.OperationHelper.withReleasableConnection(OperationHelper.java:621)at com.mongodb.internal.operation.MixedBulkWriteOperation.execute(MixedBulkWriteOperation.java:185)at com.mongodb.internal.operation.MixedBulkWriteOperation.execute(MixedBulkWriteOperation.java:76)at com.mongodb.client.internal.MongoClientDelegate$DelegateOperationExecutor.execute(MongoClientDelegate.java:187)at com.mongodb.client.internal.MongoCollectionImpl.executeSingleWriteRequest(MongoCollectionImpl.java:1009)at com.mongodb.client.internal.MongoCollectionImpl.executeInsertOne(MongoCollectionImpl.java:470)at com.mongodb.client.internal.MongoCollectionImpl.insertOne(MongoCollectionImpl.java:453)at com.mongodb.client.internal.MongoCollectionImpl.insertOne(MongoCollectionImpl.java:447)
解决方案的演进:从内嵌聚合到引用分块
要解决这个问题,核心思想必须从“如何将一个大文档存进去”转变为“如何将一个逻辑上的大实体拆分成多个物理上的小文档”。我们最终采用的方案是“文档引用分块”。
这个方案的第一步是重构数据模型。我们将聚合数据从主文档中剥离出来,存放到一个专门的“数据块”集合中。主文档仅保留对这些数据块的引用。
// 重构后的主文档模型
public class ParentDocument {private ObjectId id;private String name;// 存储指向数据块文档的ID列表private List<ObjectId> chunkIds;
}// 新增的数据块文档模型
@Document(collection = "dataChunks")
public class ChunkDocument {private ObjectId id;// 每个块包含一部分数据private List<DataObject> dataSlice;
}
伴随着数据模型的演进,核心的存取逻辑也需要重构。
在写入数据时,我们在服务层引入了分块逻辑。当需要保存一个聚合了大量数据的逻辑实体时,代码不再直接构建一个巨大的 ParentDocument
。取而代之的是:
判断大小:评估待保存的总数据量是否超过预设的安全阈值。
执行分块:如果超过阈值,则在内存中将庞大的数据列表分割成多个较小的列表。
持久化块:遍历这些小列表,将每一个都包装成一个
ChunkDocument
并存入dataChunks
集合。保存引用:收集所有新创建的
ChunkDocument
的_id
,将这个ObjectId
列表存入ParentDocument
的chunkIds
字段中,同时清空其内嵌的数据字段。更新清理:在更新一个已存在的
ParentDocument
时,先根据其旧的chunkIds
删除所有关联的旧数据块,避免产生孤立的垃圾数据。
读取数据的逻辑则确保了这种底层变化对上层业务的透明性。当业务需要获取一个完整的 ParentDocument
实体时,数据服务层的逻辑如下:
获取主文档:首先根据ID获取
ParentDocument
。检查分块:判断其
chunkIds
字段是否有效。按需重组:如果
chunkIds
存在,则根据ID列表到dataChunks
集合中查询出所有的关联数据块。随后,在内存中将所有dataSlice
合并成一个完整的数据列表。返回完整视图:将重组后的完整数据列表设置到
ParentDocument
实例的对应字段上(通常是一个非持久化的瞬态字段),再将其转换为业务DTO(数据传输对象)返回。
通过这种方式,无论底层数据是否被分块,上层业务逻辑得到的永远是一个数据完整的、与原始设计中一致的逻辑实体。这不仅解决了16MB的限制,也保证了方案的向后兼容性和对其他业务模块的最小侵入性。
替代方案的思考:为何不是 GridFS?
在探讨解决方案时,我们自然会想到 MongoDB 官方提供的大文件存储方案——GridFS。GridFS 能将任意大小的文件分割成默认255KB的块进行存储,非常适合存放图片、视频或大型二进制文件。
然而,经过审慎评估,我们认为 GridFS 并不适用于我们当前的业务场景。主要原因在于我们的数据特性和操作模式:
首先,我们的“大文件”并非一个不可分割的二进制“大对象”(BLOB),而是一个由成千上万个独立结构化对象(DataObject
)组成的集合。我们需要对这个集合进行频繁的、增量式的合并操作。
若采用 GridFS,每次合并都需要将整个几十甚至上百兆的逻辑对象从 GridFS 下载到内存,反序列化成一个巨大的列表,与新数据合并后,再将这个全新的、更大的对象序列化并重新上传到 GridFS,最后删除旧的 GridFS 文件。这种“整体读-整体写”的操作模式对于我们增量更新的场景而言,性能开销和资源消耗是无法接受的。
其次,GridFS 的设计初衷是文件存储,我们无法对存储在其中的内容进行查询。而我们的“引用分块”方案,每一个数据块本身仍然是一个标准的 MongoDB 文档,保留了未来对部分数据集进行直接查询的可能性。
最后,引入 GridFS 会导致数据访问模式的根本性改变,从操作文档对象变为操作文件流(InputStream
),这将对现有的数据服务层、业务逻辑乃至DTO产生巨大的冲击,与我们期望的“最小化、高内聚”的重构目标背道而驰。
总结
面对看似难以逾越的技术边界,选择最“官方”或最“强大”的工具未必是最佳答案。通过深入分析业务数据的结构特性和操作模式,我们最终选择的“文档引用分块”方案,虽然需要自行实现分块和重组逻辑,但它以一种高度兼容且对业务侵入最小的方式,优雅地解决了16MB的文档大小限制。这个过程也再次印证了一个朴素的道理:最合适的方案,永远源于对问题本质的深刻理解。