☰
Spring Boot 集成 MongoDB 分库分表实战:动态数据源与路由策略
2026/10/15 6:22:52 网站建设 项目流程

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

1.1 从一个真实的性能瓶颈说起

先说一个我亲身经历的场景。某内容社区平台早期用单机 MongoDB 存用户行为日志和内容元数据,前半年跑得挺舒服,数据量在千万级以内,查询响应基本在 20ms 上下。但到了第八个月,集合文档数突破 8000 万,磁盘占用接近 1.2TB,问题开始集中爆发:带时间范围的聚合查询从几十毫秒涨到 3 到 5 秒,索引重建一次要停写将近四十分钟,最要命的是单节点磁盘水位频繁告警,扩容只能垂直加盘,成本高且治标不治本。

这个阶段很多人第一反应是"上分片集群",但分片集群对中小团队来说运维成本不低,需要配置服务器、路由节点、多个分片副本集,机器数量直接翻几倍。而另一种更轻量的思路是在应用层做分库分表——也就是通过 Spring Boot 的数据源路由能力,把数据按规则分散到多个独立的 MongoDB 实例或库中,让每个库的数据量和压力都控制在一个舒适区间。这就是本文要聊的核心:Spring Boot 集成 MongoDB 并实现分库分表。

需要先明确一点,MongoDB 本身没有"表"的概念,对应的是集合(Collection),所以严格来说应该是"分库分集合"。但行业内习惯沿用关系型数据库的说法,叫分库分表,理解上不必纠结字面。

1.2 这套方案到底解决了什么问题

把话说直白一点,应用层分库分表主要解决三类痛点。第一类是单库容量瓶颈,当单实例数据量过大导致磁盘、内存、IO 都吃紧时,把数据拆到多个库能线性分摊压力。第二类是写入热点,比如按用户 ID 取模分片后,不同用户的写入会落到不同库,避免单点写竞争。第三类是隔离性需求,比如把冷热数据分到不同库,热库用高配机器,冷库用低配存储,成本结构更合理。

它适合谁来参考?我认为是那些数据量在千万到十亿级别、团队规模不大、不想过早引入重型分片集群、但又确实遇到了单库瓶颈的团队。如果你现在数据量还在百万级,说实话没必要上这套,先把索引和查询优化做好更划算。技术选型永远要看阶段,过早分片是另一种形式的负债。

1.3 和 MongoDB 原生分片集群的区别

这里必须澄清一个常见误区。MongoDB 官方提供的 Sharded Cluster 是在数据库层面做分片,由 mongos 路由、config server 存元数据、shard 存实际数据,对应用基本透明。而本文讲的应用层分库分表,是在 Spring Boot 这一层自己控制数据往哪个库写、从哪个库读,MongoDB 实例之间互相独立、互不感知。

两者各有取舍。原生分片集群的优势是透明、支持自动再平衡、能水平扩展到很大规模;劣势是组件多、运维复杂、对网络和配置要求高。应用层分库分表的优势是架构简单、每个库都是普通单机或副本集、排查问题直观;劣势是分片逻辑要自己写、跨库查询和聚合要自己处理、扩容时需要手动迁移数据。选哪个,取决于团队运维能力和业务对跨库查询的依赖程度。

2. 整体架构设计与分片策略选型

2.1 核心架构长什么样

整套方案的骨架其实不复杂,核心是三个部分:多数据源配置、路由规则、动态数据源切换。Spring Boot 通过配置多个 MongoTemplate 实例,每个实例指向一个独立的 MongoDB 库;然后在业务代码执行前,根据分片键(比如 userId)计算出目标库,把当前线程的数据源上下文切换到对应实例。

我习惯用 ThreadLocal 保存当前线程要用的数据源标识,配合 AOP 切面在方法执行前解析分片键、设置上下文,方法结束后清理。这样业务代码基本无感知,只需要在需要分片的 Repository 或 Service 方法上加一个注解即可。整体数据流是这样的:请求进来 → 切面拦截 → 解析分片键 → 计算目标库 → 绑定到 ThreadLocal → MongoTemplate 从动态数据源取对应实例 → 执行操作 → 清理上下文。

2.2 分片键怎么选才不踩坑

分片键的选择直接决定这套方案能不能用得住。我的经验是遵循三个原则:高基数、分布均匀、查询必带。

高基数是指这个字段的取值要足够多,比如 userId、orderId 这种,几百万上千万个不同值,取模后能均匀散开。如果选了个性别、状态这种只有几个值的字段,那分片就失去意义了,数据全挤在一两个库里。

分布均匀是指取值不能有明显倾斜。比如按用户注册时间分片,早期用户少后期用户多,就会导致某些库特别大。取模法(hash 分片)天然能缓解这个问题,因为它把任意输入映射到固定范围。

查询必带是最容易被忽视的一点。分片键必须是高频查询条件,否则你查数据时不知道该去哪个库找,只能全库扫描,性能反而更差。比如你按 userId 分片,那所有查询都得带上 userId;如果业务里有大量按内容 ID 查询的场景,那 userId 就不是好选择,得考虑用内容 ID 或者做二级索引表。

2.3 常见分片算法对比

算法原理优点缺点适用场景
取模法hash(key) % N分布均匀、实现简单扩容需重新分布库数量固定、数据量可预估
范围法按 key 区间划分范围查询友好、扩容方便易热点、分布不均时间序列、有序 ID
一致性哈希哈希环 + 虚拟节点扩容影响小实现复杂、需维护环库数量动态变化
预分片提前划分固定槽位扩容平滑需规划槽位映射长期演进、规模较大

我个人的建议是:如果库数量在短期内不会变,直接用取模法,简单可靠;如果预期会持续扩容,用预分片(比如固定 1024 个槽,槽到库的映射存配置),扩容时只迁移部分槽,比一致性哈希更好理解和维护。一致性哈希虽然理论优雅,但实际运维中虚拟节点的调优挺费劲,中小团队不一定吃得消。

2.4 库数量定多少合适

这个问题没有标准答案,但有个估算思路。先算单库能舒适承载的数据量,假设单库 MongoDB 在 500GB 以内、文档数 5000 万以内查询性能比较稳,那你总数据量除以这个数就是最少库数量。比如预估三年后 2TB 数据,那至少 4 个库,留点余量定 6 到 8 个比较稳妥。

另一个维度是写入 QPS。单库 MongoDB 副本集在普通配置下能扛几千 QPS 的写入,如果你的峰值写入是 2 万 QPS,那至少 4 到 6 个库分摊。两个维度取较大值,再向上取整到 2 的幂次(方便取模和扩容),基本就靠谱了。别一上来就搞 32 个库,库太多连接池管理、监控、备份都是负担,够用就好。

3. Spring Boot 多数据源配置实操

3.1 依赖与基础配置

先看依赖,核心就是 spring-boot-starter-data-mongodb,版本跟着 Spring Boot 走即可。如果你用的是 Spring Boot 3.x,注意 MongoDB 驱动版本要匹配,避免出现连接协议不兼容的问题。

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

配置文件里,我习惯把多个库的配置写成列表形式,方便动态加载。用 YAML 的话大概长这样:

mongodb: shards: - name: shard0 uri: mongodb://user:pass@host0:27017/db_shard0?maxPoolSize=100 - name: shard1 uri: mongodb://user:pass@host1:27017/db_shard1?maxPoolSize=100 - name: shard2 uri: mongodb://user:pass@host2:27017/db_shard2?maxPoolSize=100 - name: shard3 uri: mongodb://user:pass@host3:27017/db_shard3?maxPoolSize=100

这里有个细节值得说:每个库的连接池大小要单独算。假设总连接预算 400,4 个库就每个 100,别每个都配 200 导致总连接数爆掉数据库的 maxConnections。maxPoolSize 的估算公式大致是:峰值并发线程数 × 单请求平均占用连接时长系数,一般取并发数的 1.2 到 1.5 倍比较稳。

3.2 动态数据源上下文

ThreadLocal 上下文是整套方案的中枢,我把它设计成一个简单的持有类:

public class MongoDataSourceContext { private static final ThreadLocal<String> HOLDER = new ThreadLocal<>(); public static void set(String shardName) { HOLDER.set(shardName); } public static String get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

注意:clear 一定要在 finally 里调用。线程池复用线程时,如果上下文没清理,下一个请求可能误用上一个请求的数据源,这种 bug 极其隐蔽,我踩过一次,排查了大半天。

3.3 多 MongoTemplate 注册

接下来把每个库的 MongoTemplate 都注册成 Bean,用一个 Map 存起来,key 是库名:

@Configuration public class MongoConfig { @Bean public Map<String, MongoTemplate> mongoTemplateMap( MongoProperties props) { Map<String, MongoTemplate> map = new HashMap<>(); for (ShardConfig shard : props.getShards()) { MongoClient client = MongoClients.create(shard.getUri()); MongoTemplate template = new MongoTemplate( client, shard.getName()); map.put(shard.getName(), template); } return map; } }

这里用 MongoClients.create 直接建客户端,而不是依赖 Spring 自动配置,是因为自动配置默认只认一个 URI。手动建的好处是每个库的连接参数可以独立调优,比如热库连接池大一点、冷库小一点。

3.4 动态路由 MongoTemplate

业务代码不应该直接依赖上面那个 Map,而是依赖一个路由门面。我写了一个 DynamicMongoTemplate,内部根据上下文选真正的 template:

@Component public class DynamicMongoTemplate { @Resource private Map<String, MongoTemplate> mongoTemplateMap; public MongoTemplate current() { String shard = MongoDataSourceContext.get(); if (shard == null) { throw new IllegalStateException("未指定分片,请检查切面配置"); } MongoTemplate template = mongoTemplateMap.get(shard); if (template == null) { throw new IllegalArgumentException("未知分片: " + shard); } return template; } }

业务层调用dynamicMongoTemplate.current().save(entity)就能自动落到正确的库。这种设计的好处是路由逻辑集中在一处,将来要加分片算法、加读写分离都在这里改,不会散落到各个业务类里。

4. 分片切面与路由规则实现

4.1 自定义分片注解

为了让业务代码干净,我定义了一个注解,标在需要分片的方法上,指定分片键在参数中的位置:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Sharding { int keyIndex() default 0; String keyField() default "id"; }

keyIndex 表示分片键是第几个参数,keyField 表示如果是对象参数,从哪个字段取。这样设计能覆盖大部分场景:传 userId 字符串、传整个 User 对象、传包含 userId 的 DTO 都能处理。

4.2 切面解析与路由

切面的核心逻辑是:拦截带 @Sharding 的方法 → 取出分片键 → 计算目标库 → 设置上下文 → 执行 → 清理。

@Aspect @Component public class ShardingAspect { @Around("@annotation(sharding)") public Object around(ProceedingJoinPoint pjp, Sharding sharding) throws Throwable { Object[] args = pjp.getArgs(); Object keyObj = args[sharding.keyIndex()]; String key = extractKey(keyObj, sharding.keyField()); String shard = ShardRouter.route(key); try { MongoDataSourceContext.set(shard); return pjp.proceed(); } finally { MongoDataSourceContext.clear(); } } private String extractKey(Object obj, String field) { if (obj instanceof String) { return (String) obj; } // 反射取字段,省略异常处理 try { Field f = obj.getClass().getDeclaredField(field); f.setAccessible(true); return String.valueOf(f.get(obj)); } catch (Exception e) { throw new IllegalArgumentException("无法提取分片键", e); } } }

4.3 路由算法实现

路由算法我用的是取模法,配合一个可配置的库列表:

public class ShardRouter { private static final List<String> SHARDS = Arrays.asList( "shard0", "shard1", "shard2", "shard3"); public static String route(String key) { int hash = Math.abs(key.hashCode()); int index = hash % SHARDS.size(); return SHARDS.get(index); } }

提示:这里用 hashCode 而不是简单取数字,是为了兼容字符串类型的 key。但要注意 hashCode 在不同 JVM 上对同一字符串是一致的(String 的 hashCode 有规范定义),所以不用担心跨节点路由不一致的问题。如果你用的是自定义对象做 key,务必重写 hashCode 并保证稳定。

4.4 业务层怎么用

业务代码用起来就很清爽了:

@Service public class UserLogService { @Resource private DynamicMongoTemplate dynamicMongoTemplate; @Sharding(keyIndex = 0) public void saveLog(String userId, UserLog log) { dynamicMongoTemplate.current().save(log, "user_log"); } @Sharding(keyIndex = 0) public List<UserLog> queryByUser(String userId) { return dynamicMongoTemplate.current() .find(Query.query(Criteria.where("userId").is(userId)), UserLog.class, "user_log"); } }

注意查询方法也必须带分片键,否则切面拿不到 key 就没法路由。这也是前面强调"查询必带分片键"的原因,它不只是性能问题,更是这套架构能否运转的前提。

5. 跨库查询与聚合的处理方案

5.1 为什么跨库查询是个难题

分库之后,原本一条db.user_log.aggregate(...)能搞定的统计,现在数据散在 4 个库里,MongoDB 之间互相不知道对方存在,没法直接 join 或聚合。这是应用层分库分表最大的代价,必须提前想清楚业务里有多少跨库场景。

我的经验是,能通过分片键规避的跨库查询,尽量规避。比如统计某用户的行为,带上 userId 就能定位到单库;如果非要统计全平台总量,那就得并行查所有库再合并。

5.2 并行查询 + 内存合并

对于必须跨库的查询,我写了一个工具方法,用线程池并行查所有库,然后合并结果:

public <T> List<T> queryAllShards( Function<MongoTemplate, List<T>> queryFn) { List<CompletableFuture<List<T>>> futures = new ArrayList<>(); for (Map.Entry<String, MongoTemplate> e : mongoTemplateMap.entrySet()) { futures.add(CompletableFuture.supplyAsync( () -> queryFn.apply(e.getValue()), queryExecutor)); } return futures.stream() .map(CompletableFuture::join) .flatMap(List::stream) .collect(Collectors.toList()); }

这里的关键是并行,如果串行查 4 个库,耗时就是 4 倍;并行的话基本等于最慢那个库的耗时。线程池大小建议等于库数量,避免线程过多反而增加上下文切换开销。

5.3 分页与排序的坑

跨库分页是最容易出问题的地方。假设你要查第 3 页、每页 20 条,正确做法是每个库都查前 60 条(pageNum × pageSize),然后在内存里合并排序后取第 41 到 60 条。如果你图省事每个库只查 20 条,那合并后的结果一定是错的,因为全局第 41 条可能来自任何一个库。

// 每个库都取 pageNum * pageSize 条 int fetchSize = pageNum * pageSize; List<T> merged = queryAllShards(t -> t.find(query).limit(fetchSize).toList()); merged.sort(comparator); int from = (pageNum - 1) * pageSize; int to = Math.min(from + pageSize, merged.size()); return merged.subList(from, to);

注意:这种方案在深分页时性能会急剧下降,因为每个库都要取大量数据。如果业务有深分页需求,建议改用游标分页(基于上一页最后一条的排序字段继续查),或者干脆把统计结果预计算到一张汇总表里。

5.4 聚合统计的替代思路

对于 count、sum 这类聚合,如果实时性要求不高,我强烈建议用定时任务预聚合。比如每小时跑一次,把各库的统计结果汇总写入一张 summary 集合,查询时直接读汇总表。这样既避免了实时跨库聚合的开销,又能保证数据基本准确。

如果非要实时,那就并行查各库的聚合结果再相加。MongoDB 的 countDocuments 和 aggregate 都能在单库内高效执行,合并阶段只是简单的数值相加,成本可控。

6. 常见问题与排查技巧实录

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

数据倾斜是分库后最常见的问题,表现为某些库特别大、某些库很空。发现方法很简单,写个脚本定期统计各库的文档数和磁盘占用:

// 在每个库执行 db.stats().dataSize db.user_log.countDocuments({})

如果发现最大库和最小库差距超过 30%,就要警惕了。原因通常是分片键分布不均,比如某些 userId 是连续数字,hashCode 后仍然聚集。解决办法有两个:一是换分片键,选分布更均匀的字段;二是加盐,在 key 前面拼一个随机前缀再 hash,牺牲一点查询便利性换取均匀分布。

6.2 连接池耗尽与超时

多库场景下连接池管理容易出问题。典型症状是高峰期报 "Wait queue full" 或 "Connection timeout"。排查步骤:先看每个库的 maxPoolSize 配置,算一下总连接数是否超过数据库的 maxConnections;再看是否有慢查询长时间占用连接。

症状可能原因排查方法解决方向
Wait queue full连接池太小或泄漏看 pool 监控指标调大 maxPoolSize 或查泄漏
Connection timeout网络或库负载高ping 库、看慢日志优化查询、加索引
连接数超限总配置超数据库上限累加各库 maxPoolSize下调单库配置

我的经验是给每个库的连接池加监控埋点,把 active、idle、waitQueueSize 打到监控系统,出问题时一眼就能看出是哪个库的池子满了。

6.3 分片键变更的迁移方案

业务演进中分片键可能要换,比如从 userId 换成 orderId。这时候不能直接改,得做数据迁移。我的做法是双写过渡:新数据按新规则写,同时写一份到旧规则位置;老数据用后台任务慢慢迁移;迁移完成后切读新规则,最后停双写。整个过程对业务基本无感,但要注意双写期间的一致性问题,建议加个对账任务定期校验两边数据。

6.4 事务问题

MongoDB 4.0 之后支持多文档事务,但跨库事务是不支持的,因为事务只在单个副本集或分片集群内生效。所以分库之后,涉及多个库的写操作没法保证原子性。我的建议是尽量避免跨库写事务,如果业务上确实需要,用最终一致性方案:先写主库,再通过消息队列异步同步到其他库,配合补偿任务处理失败情况。

6.5 常见问题速查表

问题根因快速定位处理建议
查询结果缺失查询没带分片键检查切面是否生效补上分片键参数
数据写错库上下文未清理看线程复用日志finally 中 clear
跨库分页错乱每库取数不足核对 fetchSize取 pageNum×pageSize
聚合结果偏小漏查某个库核对库列表遍历所有分片
启动报 Bean 冲突多个 MongoTemplate看启动日志用 @Qualifier 区分

7. 性能优化与生产实践建议

7.1 索引策略要跟着分片走

分库之后,每个库都要单独建索引,而且索引策略要一致。我习惯在应用启动时用代码检查并创建索引,避免漏建:

@PostConstruct public void initIndexes() { for (MongoTemplate t : mongoTemplateMap.values()) { t.indexOps("user_log") .ensureIndex(new Index() .on("userId", Sort.Direction.ASC) .on("createTime", Sort.Direction.DESC)); } }

注意复合索引的字段顺序,分片键相关的查询字段放前面,排序字段放后面,这样能同时命中过滤和排序。

7.2 读写分离的考虑

如果读压力远大于写,可以在每个分片下再挂一个只读副本,读操作走副本、写操作走主库。实现上就是在 MongoTemplate 层面区分,或者用 MongoDB 驱动的 ReadPreference 配置。不过这会增加架构复杂度,建议先压测确认读确实是瓶颈再上。

7.3 监控与容量规划

生产环境必须有的监控指标:每个库的 QPS、慢查询数、连接池使用率、磁盘水位、文档增长速度。我一般用 Prometheus 采集,配 Grafana 看板。容量规划上,磁盘水位超过 70% 就要准备扩容,文档增长速度突然加快要排查是否有异常写入。

7.4 扩容时的平滑迁移

扩容加库时,取模法的 N 变了,几乎所有数据都要重新分布,这是它最大的痛点。平滑迁移的做法是:先加新库,用双写把新数据写到新库,同时后台任务按新规则迁移老数据,迁移期间读操作要兼容新旧两个位置(先查新位置,没有再查老位置),全部迁完后切读新规则。整个过程可能持续几天,要有耐心,别想着一步到位。

8. 我踩过的几个坑和一点心得

第一个坑是切面顺序。我一开始把分片切面和事务切面配在一起,结果事务切面先执行,导致事务开启时数据源还没切换,事务绑定到了错误的库。后来把分片切面的 order 调到事务之前才解决。这个问题的本质是切面执行顺序会影响上下文状态,涉及多切面时一定要理清顺序。

第二个坑是异步方法里的上下文丢失。有次在 @Async 方法里调分片查询,发现总是报"未指定分片"。原因是 @Async 会切换到新线程,ThreadLocal 里的上下文没传过去。解决办法是在提交异步任务前手动把分片名作为参数传进去,在异步方法开头重新 set。ThreadLocal 和线程池的组合一定要小心。

第三个坑是批量写入的路由。批量插入时如果一批数据的分片键不同,会被路由到同一个库,导致数据错位。正确做法是先按分片键分组,再分别批量写入各库。这个逻辑我封装成了一个工具方法,业务层不用关心。

最后分享一个实用技巧:给每个库的集合名加个统一前缀,比如shard0_user_log、shard1_user_log。这样即使误连到某个库,也能一眼看出数据属于哪个分片,排查问题时特别有用。另外建议在文档里加一个_shard字段记录数据实际所在的分片,方便对账和迁移时校验。

这套方案我在两个项目里落地过,数据量分别在 3 亿和 8 亿级别,稳定跑了两年多。核心体会是:分库分表不是银弹,它用架构复杂度换取了单库压力的缓解。上之前一定要想清楚跨库查询有多少、扩容频率多高、团队能不能 hold 住多库运维。如果这些问题的答案都是正向的,那这套方案确实能帮你把 MongoDB 用得更久、更稳。

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

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

立即咨询