☰
Spring Boot 中 MongoDB 分库分表实战:路由算法与性能调优
2026/10/15 6:22:49 网站建设 项目流程

1. 为什么要在 Spring Boot 里折腾 MongoDB 分库分表

1.1 先搞清楚一个前提:MongoDB 到底需不需要分库分表

很多人一看到"分库分表"四个字,第一反应就是"这不是 MySQL 才需要干的事吗"。我一开始也是这个想法,直到手上一个物联网项目的数据量从千万级冲到十亿级,单集合的写入开始出现明显抖动,查询延迟从几十毫秒涨到几百毫秒,才真正意识到:MongoDB 虽然天生支持分片集群,但那是运维层面的能力,业务代码层面如果不做适配,照样会踩坑。

先把概念理清楚。MongoDB 原生的分片(Sharding)是通过 mongos 路由、config server 配置、shard 分片节点组成的一套集群方案,它解决的是单机存储容量和整体吞吐的问题。而我们在 Spring Boot 里说的"分库分表",更多是指应用层的逻辑分片——也就是在代码里根据某个分片键,把数据路由到不同的数据库或不同的集合里。

这两者的区别很关键:

维度原生分片集群应用层分库分表
实现位置数据库运维层业务代码层
对代码侵入几乎无感需要改造 DAO 层
扩容难度需要专业运维改配置即可
适用场景大型企业、有专职 DBA中小团队、快速迭代
事务支持跨分片事务受限可控范围内灵活处理

我选择应用层分库分表,核心原因是团队没有专职 DBA,运维一套完整的 MongoDB 分片集群成本太高,而且业务的分片键非常明确(设备 ID),完全可以在代码层做路由。如果你的团队有成熟的运维体系,优先考虑原生分片;如果追求快速落地和可控性,应用层方案更务实。

1.2 分片键选不对,后面全是坑

分片键(Shard Key)的选择直接决定了数据分布是否均匀、查询是否能命中单个分片。我在项目里踩过的最大一个坑,就是一开始用了时间戳做分片键,结果导致写热点——所有新数据都往最后一个分片写,其他分片几乎闲置。

后来改成"设备 ID 哈希 + 月份"的组合键,写入才均匀分布。这里给几条实操经验:

  • 高基数:分片键的取值要足够多,比如设备 ID 有几十万个,而不是状态字段只有几个值。
  • 写分散:新写入的数据要能均匀落到不同分片,避免单调递增的键(如自增 ID、时间戳)。
  • 查询命中:最常用的查询条件要包含分片键,否则会变成广播查询,性能反而更差。
  • 不可变:分片键一旦确定,后续更新会导致数据迁移,尽量选不变的字段。

提示:如果你的业务查询模式是"按用户查"和"按时间查"并存,可以考虑冗余一份数据到两个不同分片键的集合里,用空间换查询效率。这在 MongoDB 里比 MySQL 更容易实现,因为文档模型本身就鼓励冗余。

1.3 整体方案设计思路

我的方案核心是三层结构:

  1. 路由层:一个ShardingRouter组件,根据分片键计算出目标库和集合名。
  2. 数据源层:多个MongoTemplate实例,分别对应不同的物理库。
  3. 业务层:DAO 层调用路由层获取正确的 Template,对业务代码尽量透明。

这样设计的好处是,业务代码不需要关心数据到底存在哪个库,只需要传入分片键即可。后续扩容时,只需要调整路由算法和增加数据源配置,业务逻辑基本不动。

2. 环境搭建与多数据源配置实操

2.1 依赖引入与版本选择

Spring Boot 集成 MongoDB 的核心依赖是spring-boot-starter-data-mongodb。版本选择上,我建议跟 Spring Boot 主版本保持一致,避免出现驱动不兼容的问题。

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

如果你用的是 Spring Boot 2.7.x,对应的 MongoDB 驱动是 4.x 版本;如果是 3.x,驱动是 5.x。驱动版本差异会影响连接池配置和事务 API,升级前一定要看官方兼容性矩阵。

我实测下来,Spring Boot 3.2 + MongoDB 驱动 5.1 的组合最稳定,连接池的默认参数也比较合理,基本不需要大改。

2.2 多数据源配置的两种方式

配置多个 MongoDB 数据源,常见有两种做法:

方式一:手动创建多个 MongoTemplate Bean

@Configuration public class MongoConfig { @Bean("mongoTemplate0") public MongoTemplate mongoTemplate0() { MongoClientFactoryBean factory = new MongoClientFactoryBean(); factory.setHost("127.0.0.1"); factory.setPort(27017); return new MongoTemplate(factory.getObject(), "db_0"); } @Bean("mongoTemplate1") public MongoTemplate mongoTemplate1() { MongoClientFactoryBean factory = new MongoClientFactoryBean(); factory.setHost("127.0.0.1"); factory.setPort(27018); return new MongoTemplate(factory.getObject(), "db_1"); } }

方式二:基于 AbstractMongoClientConfiguration 抽象配置

这种方式更适合数据源数量动态变化的场景,可以通过配置文件读取库列表,循环创建 Template。

我最终选了方式二,因为项目后期从 2 个库扩到了 4 个库,方式一只需要改代码,方式二改配置文件就行。如果你预判未来会扩容,一开始就用抽象配置,省得后面重构。

2.3 连接池参数怎么调

MongoDB 驱动的连接池参数直接影响并发性能,默认值在高并发下往往不够用。关键参数如下:

参数默认值建议值说明
maxPoolSize100200-500最大连接数,按 QPS 估算
minPoolSize020-50最小空闲连接,避免频繁创建
maxIdleTimeMS060000空闲连接回收时间
maxLifeTimeMS01800000连接最大存活时间
connectTimeoutMS100005000连接超时,内网可调小
serverSelectionTimeoutMS300005000选主超时,快速失败

maxPoolSize 的估算公式:峰值 QPS × 平均查询耗时(秒)× 冗余系数(1.5-2)。比如峰值 2000 QPS,平均耗时 20ms,那么 2000 × 0.02 × 2 = 80,考虑到突发流量,设 200 比较稳妥。

注意:连接池不是越大越好。连接数过多会导致 MongoDB 服务端线程切换开销增大,反而降低吞吐。我试过把 maxPoolSize 从 200 调到 1000,QPS 不升反降了 15%。

3. 分片路由算法的设计与实现

3.1 哈希取模 vs 一致性哈希

路由算法是分库分表的心脏。最常见的两种:

  • 哈希取模:hash(key) % N,实现简单,但扩容时需要重新分布所有数据。
  • 一致性哈希:扩容时只影响相邻节点,数据迁移量小,但实现复杂,且容易产生数据倾斜。

我的项目初期用哈希取模,因为库数量固定为 4,短期内不会变。后来做了一次扩容演练,发现从 4 库扩到 8 库时,约 75% 的数据需要迁移,耗时超过 12 小时。如果你的业务增长快,一开始就上一致性哈希,或者用预分片(比如先分 1024 个逻辑槽,再映射到物理库)。

预分片方案我后来在另一个项目里用过,效果很好:逻辑槽固定 1024 个,物理库从 4 扩到 8 时,只需要把部分槽重新映射,迁移量可控在 50% 以内。

3.2 路由组件的代码实现

核心路由类大概长这样:

@Component public class ShardingRouter { private static final int SHARD_COUNT = 4; public String getDatabase(String shardKey) { int hash = Math.abs(shardKey.hashCode()); int index = hash % SHARD_COUNT; return "db_" + index; } public String getCollection(String baseName, String shardKey) { int hash = Math.abs(shardKey.hashCode()); int index = hash % SHARD_COUNT; return baseName + "_" + index; } }

这里有个细节:String.hashCode()在极端情况下可能返回Integer.MIN_VALUE,直接取绝对值还是负数,所以要用Math.abs后再取模。我见过有同事漏了这一步,导致偶发的路由到不存在的库,排查了半天。

3.3 分片键的提取与传递

分片键怎么传到路由层?我的做法是在实体类里加一个@ShardKey注解,DAO 层通过反射提取。这样业务代码只需要正常保存实体,路由自动完成。

public class DeviceData { @ShardKey private String deviceId; private String payload; private long timestamp; }

反射有性能开销,我做了缓存:第一次提取后把字段的Field对象缓存起来,后续直接复用。实测下来,单次路由耗时从 0.5ms 降到 0.05ms,在百万级调用下差别很明显。

提示:如果你的分片键是组合键(比如 deviceId + month),注解可以设计成接收多个字段名,路由时拼接后再哈希。

4. 数据写入与查询的完整实现

4.1 写入流程与批量优化

单条写入很简单,拿到路由后的 Template 直接 save 即可。但物联网场景下,写入往往是批量的,这时候逐条 save 性能很差。

我的优化方案是:按分片键分组,再批量插入。

public void batchSave(List<DeviceData> dataList) { Map<String, List<DeviceData>> grouped = dataList.stream() .collect(Collectors.groupingBy(d -> router.getDatabase(d.getDeviceId()))); grouped.forEach((db, list) -> { MongoTemplate template = templateMap.get(db); template.insert(list, resolveCollection(list.get(0))); }); }

这样每个库只建立一次连接、执行一次批量插入,比逐条快 5-8 倍。实测 1 万条数据,逐条写入约 12 秒,分组批量后降到 1.8 秒。

批量插入还有个坑:单批次数据量不要超过 16MB(MongoDB 的文档大小限制是 16MB,批量请求也有类似约束)。我一般控制在 1000 条或 8MB 以内,超过就拆批。

4.2 查询的路由与聚合

单分片查询很直接,带上分片键就能定位到具体库。麻烦的是跨分片查询,比如"查询所有设备在某个时间段的数据"。

这种场景有两种处理方式:

  1. 并行查询 + 内存聚合:向所有分片发查询,用线程池并行执行,结果在应用层合并。
  2. 冗余存储:把需要跨片查询的维度单独存一份到另一个集合。

我选了方式一,因为数据实时性要求高,冗余存储有一致性风险。并行查询用CompletableFuture实现:

List<CompletableFuture<List<DeviceData>>> futures = templateMap.values().stream() .map(t -> CompletableFuture.supplyAsync(() -> t.find(query, DeviceData.class, collectionName), executor)) .collect(Collectors.toList()); List<DeviceData> result = futures.stream() .flatMap(f -> f.join().stream()) .sorted(Comparator.comparingLong(DeviceData::getTimestamp)) .collect(Collectors.toList());

线程池大小建议设为分片数的 2 倍,避免某个分片慢拖累整体。我设的是 8(4 分片 × 2),实测跨片查询延迟从串行的 400ms 降到 120ms。

4.3 分页查询的处理技巧

跨分片分页是个经典难题。skip + limit在分片场景下会失效,因为每个分片都要 skip,最后合并时数量对不上。

我的做法是基于游标的分页:用上一页最后一条记录的排序字段值作为下一页的查询起点。

query.addCriteria(Criteria.where("timestamp").gt(lastTimestamp)); query.with(Sort.by(Sort.Direction.ASC, "timestamp")).limit(pageSize);

这样每个分片都从游标位置开始查,合并后取前 pageSize 条即可。缺点是不支持跳页,只能上一页下一页。如果业务必须支持跳页,那就只能接受性能损耗,或者用 Elasticsearch 做查询层。

注意:游标分页要求排序字段唯一且单调,如果 timestamp 有重复,需要加一个 tie-breaker 字段(比如 _id)保证顺序稳定。

5. 常见问题排查与避坑经验

5.1 数据倾斜怎么发现和处理

数据倾斜的表现是:某个库的磁盘占用、CPU 使用率明显高于其他库。发现方式有两种:

  • 定期统计:写个定时任务,统计每个集合的文档数和存储大小。
  • 监控告警:接入监控系统,对每个库的指标设阈值。

我遇到过一次严重倾斜,原因是某批设备的 ID 哈希值恰好集中在同一个桶。解决办法是在哈希前加一个随机前缀(比如deviceId + "_" + random(0, 10)),把数据打散。代价是查询时也要带上同样的前缀,需要额外存储这个前缀。

5.2 跨库事务怎么办

MongoDB 4.0 之后支持多文档事务,但跨库事务在分片集群里支持有限,应用层分库更是没法用原生事务。

我的处理原则是:能不用事务就不用,用最终一致性替代。

具体做法:

  • 写入时先写主数据,再写日志表,用定时任务补偿。
  • 关键业务用"两阶段提交"的简化版:先写预备状态,确认所有分片成功后改为完成状态。
  • 对一致性要求极高的场景,考虑把相关数据放到同一个分片键下,避免跨片。

5.3 常见问题速查表

问题现象可能原因排查方向解决方案
写入变慢连接池耗尽查看连接池监控调大 maxPoolSize
查询超时广播查询检查查询条件是否含分片键补上分片键或建索引
数据倾斜哈希不均统计各库文档数加随机前缀打散
分页错乱skip 失效检查分页实现改用游标分页
连接泄漏未关闭游标查看连接数增长趋势用 try-with-resources
内存溢出批量过大检查批量插入大小拆批到 1000 条以内

5.4 几个我踩过的坑

坑一:索引没建对。分片后每个集合都要单独建索引,我一开始只在主集合建了,结果其他分片查询全表扫描。后来写了个初始化脚本,启动时自动为所有分片集合建索引。

坑二:时间字段用了字符串。早期为了省事,timestamp 存成了字符串,结果范围查询和排序都慢。改成long或Date类型后,配合索引,查询快了 10 倍。

坑三:忘了处理空分片键。有些历史数据没有 deviceId,路由时直接 NPE。后来加了默认分片键"unknown",保证所有数据都有归属。

坑四:扩容时没做双写。第一次扩容直接停服迁移,停了 6 小时。后来改成双写方案:新旧路由同时写,后台慢慢迁历史数据,实现平滑扩容。

6. 性能压测与调优实录

6.1 压测环境与工具

压测用的是 JMeter,模拟 500 并发持续写入 10 分钟。环境是 4 个 MongoDB 实例(每个 4 核 8G),Spring Boot 应用 2 个实例(每个 4 核 8G)。

压测指标关注三个:TPS、P99 延迟、错误率。

6.2 调优前后的对比

指标调优前调优后提升
TPS32009800206%
P99 延迟480ms85ms82%
错误率2.3%0.01%99.6%
CPU 使用率95%62%-

调优主要做了四件事:

  1. 连接池 maxPoolSize 从 100 调到 300。
  2. 批量写入从逐条改为分组批量。
  3. 为所有分片集合补建了复合索引。
  4. 跨片查询改为并行执行。

6.3 索引设计的经验

分片场景下,索引要在每个分片集合上单独建。我用的复合索引是{deviceId: 1, timestamp: -1},覆盖了最常用的查询模式。

索引不是越多越好。我一开始建了 6 个索引,写入性能掉了 30%。后来精简到 3 个,写入恢复,查询也没受太大影响。每加一个索引,写入就要多维护一棵 B+ 树,权衡很关键。

提示:用explain()分析查询计划,确认是否命中索引。如果看到COLLSCAN,说明走了全表扫描,必须优化。

7. 后续扩展方向

这套方案目前支撑了日均 2 亿条写入、峰值 1.5 万 TPS 的业务,稳定运行了 8 个月。后续我打算从几个方向继续优化:

一是引入冷热分离,把 3 个月前的数据归档到低成本存储,热数据只保留近期,减少单库压力。二是路由层加缓存,把分片键到库的映射缓存到本地,减少哈希计算开销。三是探索 MongoDB 原生分片,等团队运维能力跟上后,把应用层逻辑逐步下沉到数据库层。

如果你也在做类似的事情,我的建议是:先把分片键选对,再把路由做稳,最后才考虑性能调优。顺序反了,后面全是返工。分片键选错,数据迁移能让你怀疑人生;路由不稳,线上偶发问题能查到你头秃。这两步做扎实了,剩下的都是水到渠成的事。

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

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

立即咨询