大数据量的业务做到中期MongoDB 单机常常会先报警。磁盘空间、CPU、IOPS 总有一个先扛不住。我最近刚帮某团队把核心订单数据从单节点迁移到 Linux 环境下的 MongoDB 分片集群顺带把 Spring Boot 服务的访问链路也彻底理了一遍。这篇就把分片集群从架构设计、安装部署到 Spring Boot 接入的完整过程记录成文——配置服务器、路由、分片节点各自怎么搭分片键怎么选连接串怎么写数据怎么验证分布均匀。如果你正准备给 MongoDB 做水平扩展或者正在为 Spring Boot 应用接入分片集群发愁这篇操作记录可以当作一份能直接抄作业的参考。我会尽量把每一步背后的原因讲清楚而不只是丢一堆命令出来因为理解了为什么遇到问题时你才有排查的方向。1. 动手前的架构梳理分片集群到底在解决什么问题1.1 单节点和大副本集的瓶颈在哪很多人一开始都会问我明明已经搭了副本集主节点挂了能自动切换为什么还要分片副本集解决的是高可用问题它解决不了性能和容量问题。主节点承担全部读写从节点大部分时候只是备份。一旦磁盘写满、CPU 打满、或者单次查询要扫描的数据量太大副本集一点忙都帮不上——你加再多的从节点主节点的压力也不会减轻磁盘空间更是被每个节点全量复制吃掉了。分片集群的思路是让多台机器各管一部分数据。所有分片节点合在一起才构成逻辑上的完整数据集。这样存储容量可以横向扩展写入压力也可以分散到多个分片节点上。说得直白点副本集是“一份数据多机器备份”分片是“一堆数据多机器分摊”。那为什么不用 MySQL 的分库分表MongoDB 分片集群核心优势是应用层基本无感知。Spring Boot 应用通过 mongos 路由访问整个集群不需要在业务代码里手动路由到某个分片。数据库层面自动完成数据分布和请求转发对开发同学来说省掉了大量分库分表中间件的维护成本。1.2 分片集群的组件与角色分工一套 MongoDB 分片集群由三个角色组成。mongos 是路由节点也是应用访问的唯一入口。它本身不存储业务数据启动后从配置服务器加载数据分布信息然后把读写请求路由到对应的分片节点。生产环境至少部署两个 mongos前面再挂一层负载均衡避免路由节点成为新的单点。config server 是配置服务器存储整个集群的元数据。包括有哪些分片、每个数据库集合的 chunk 分布在哪个分片、chunk 的范围边界等。config server 挂掉整个集群都无法对外服务所以生产环境必须用副本集而且强烈建议三节点。shard 是真正存储业务数据的节点。生产环境每个 shard 也应该是副本集既可以保证数据高可用又可以提供读写分离能力。我们这次演示环境里每个 shard 用了三节点仲裁形态机器资源有限时这是个省钱又安全的做法。这里要有一个基本概念所有业务请求都必须经过 mongos绝对不能直连某个 shard 节点。直连 shard 虽然能查到部分数据但会出现数据不完整、连接串混乱、绕过路由导致统计和查询结果错乱等一系列问题。1.3 分片键的类型选择原理分片键是 MongoDB 决定数据落到哪个 chunk 的依据。mongos 拿到一条写入请求先看这条数据的分片键落在哪个 chunk 范围然后把数据路由到对应分片。分片键有两种主流类型。range 分片键适合有明显范围查询的场景比如按时间字段分片某个月的数据会集中在几个 chunk 上范围扫描效率很高。缺点也明显写入热点永远落在最新的时间范围数据倾斜严重单个分片容易成为瓶颈。hashed 分片键则对字段值做哈希散列写入请求被均匀打散到各个 chunk特别适合订单表、日志表这类写多读少、按业务主键查询的场景。缺点是范围查询无法利用分片键定位mongos 只能把查询广播到所有分片再合并结果。我这次给订单表选择的是 userId 字段做 hashed 分片理由很直接订单数据量大写入必须均匀分散大部分查询是按用户维度检索userId 作为查询条件可以直接定位到分片不会出现全集群扫描。2. 环境准备与基础安装2.1 服务器拓扑与端口规划任何分布式系统动手前一定要先做端口和目录规划。这决定了后面所有配置文件、监控脚本、防火墙规则如果规划不清晰后面很容易出现端口冲突或者连错实例的问题。我这里使用三台 Linux 服务器搭建演示环境系统为 Ubuntu 20.04。三台机器的 IP 分别为 192.168.1.11、192.168.1.12、192.168.1.13。完整的拓扑规划如下。角色IP 地址端口副本集名称config server 节点1192.168.1.1127019configrsconfig server 节点2192.168.1.1227019configrsconfig server 节点3192.168.1.1327019configrsshard1 副本集主节点192.168.1.1127018shard1rsshard1 副本集从节点192.168.1.1227018shard1rsshard1 副本集仲裁节点192.168.1.1327018shard1rsshard2 副本集仲裁节点192.168.1.1127020shard2rsshard2 副本集主节点192.168.1.1227020shard2rsshard2 副本集从节点192.168.1.1327020shard2rsmongos 路由节点1192.168.1.1127017-mongos 路由节点2192.168.1.1227017-端口规划上27017 是 MongoDB 默认端口给 mongos 用27019 是 config server 惯例端口27018 和 27020 分配给 shard1 和 shard2 使用。每个实例必须有独立的数据目录同一台机器上多个 mongod 进程互不干扰。2.2 MongoDB 安装与基础目录配置安装 MongoDB 社区版 6.0直接用官方 apt 源即可。apt-get install -y gnupg curl curl -fsSL https://pgp.mongodb.com/server-6.0.asc | gpg -o /usr/share/keyrings/mongodb-server-6.0.gpg --dearmor echo deb [ signed-by/usr/share/keyrings/mongodb-server-6.0.gpg ] http://repo.mongodb.org/apt/ubuntu focal/mongodb-org/6.0 multiverse | tee /etc/apt/sources.list.d/mongodb-org-6.0.list apt-get update apt-get install -y mongodb-org这里我踩过一个小坑系统时间不同步会导致副本集选举异常。所以在装完 MongoDB 后建议先同步系统时间。apt-get install -y chrony systemctl enable chrony --now创建数据目录。因为我三台机器上会跑多个 MongoDB 实例所以目录必须按角色区分开mkdir -p /data/config mkdir -p /data/shard1 mkdir -p /data/shard2 mkdir -p /data/log同时关闭透明大页内存。MongoDB 官方明确建议关闭 THP否则高负载下可能出现明显的性能抖动echo never /sys/kernel/mm/transparent_hugepage/enabled echo never /sys/kernel/mm/transparent_hugepage/defrag值得说明的是生产环境还要调整 ulimit 参数。我一般会把进程的 nofile 设置为 64000nproc 设置为 64000避免连接数上来之后出现文件句柄不够的问题。3. 一步步搭建分片集群3.1 初始化配置服务器副本集先写 config server 的配置文件。三台机器都一样只是 dbPath 和日志路径保持一致即可。storage: dbPath: /data/config journal: enabled: true net: bindIp: 0.0.0.0 port: 27019 replication: replSetName: configrs sharding: clusterRole: configsvr systemLog: destination: file path: /data/log/config.log logAppend: true processManagement: fork: true配置里的关键点sharding.clusterRole: configsvr标明这个实例是配置服务器replication.replSetName: configrs指定副本集名称。注意 config server 的副本集名称不能和其他副本集重复。三台机器都启动后任意连一台执行初始化mongod -f /etc/mongod-config.conf mongo --host 192.168.1.11 --port 27019rs.initiate({ _id: configrs, configsvr: true, members: [ { _id: 0, host: 192.168.1.11:27019 }, { _id: 1, host: 192.168.1.12:27019 }, { _id: 2, host: 192.168.1.13:27019 } ] })执行后用rs.status()查看集群状态看到 PRIMARY、SECONDARY 都已就位就说明 config server 副本集初始化成功。这里周期性地强调一次config server 是整个分片集群的关键如果这里没有搭好后面所有 shard 操作都会报错。3.2 初始化分片副本集shard1 和 shard2 的配置文件结构相同只是 replSetName、端口和数据目录不同。shard1 的配置如下。storage: dbPath: /data/shard1 journal: enabled: true net: bindIp: 0.0.0.0 port: 27018 replication: replSetName: shard1rs sharding: clusterRole: shardsvr systemLog: destination: file path: /data/log/shard1.log logAppend: true processManagement: fork: trueshard2 的配置文件类似端口改为 27020replSetName 改为 shard2rsdbPath 改为 /data/shard2。三台机器都启动对应分片实例后先初始化 shard1 副本集。我选择了把 192.168.1.13 上的节点设置为仲裁节点因为仲裁节点不存业务数据只参与选举投票适合资源有限的环境。rs.initiate({ _id: shard1rs, members: [ { _id: 0, host: 192.168.1.11:27018 }, { _id: 1, host: 192.168.1.12:27018 }, { _id: 2, host: 192.168.1.13:27018, arbiterOnly: true } ] })shard2 同理只是把主从位置换了一下仲裁放在 192.168.1.11主节点放在 192.168.1.12。这里要提醒一下如果你确认使用三节点的完整数据节点形态主、从、从可以不用仲裁者。但一旦数据节点只剩两个脑裂风险高所以仲裁者在两节点场景下是必需的。生产环境建议每个 shard 至少三个数据节点不要省这点磁盘空间。3.3 启动 mongos 路由并添加分片mongos 的启动方式与 mongod 不同。它不需要指定 dbPath也不需要副本集配置但必须指定配置服务器地址。用配置文件方式启动 mongosnet: bindIp: 0.0.0.0 port: 27017 sharding: configDB: configrs/192.168.1.11:27019,192.168.1.12:27019,192.168.1.13:27019 systemLog: destination: file path: /data/log/mongos.log logAppend: true processManagement: fork: true启动命令mongos -f /etc/mongos.conf注意sharding.configDB的格式是“副本集名称/节点地址列表”节点地址之间用逗号分隔。如果这里只写了一个节点地址mongos 启动时会去查这个节点其他 config server 挂掉后就会出问题。两个 mongos 节点都启动后登录其中一个 mongos 添加分片mongo --host 192.168.1.11 --port 27017sh.addShard(shard1rs/192.168.1.11:27018,192.168.1.12:27018,192.168.1.13:27018) sh.addShard(shard2rs/192.168.1.11:27020,192.168.1.12:27020,192.168.1.13:27020)执行sh.status()能看到两个分片都已经加入状态正常。这里有个很重要的规范sh.addShard()的参数必须带副本集名称且地址列表要写全。如果只写单个节点地址MongoDB 会提示“host cannot be found”或者把单节点当作独立 shard 添加这会影响后面的数据分布。3.4 启用数据库分片与集合分片分片是基于集合级别的。也就是说并不是库内所有集合都必须分片只有数据量大、写入压力高的集合才需要分片。通过 mongos 执行启用操作sh.enableSharding(appdb)启用数据库分片后再对具体集合执行分片。先创建分片键索引再执行 shardCollection。很多人在这里容易搞反先执行 shardCollection 再建索引结果直接报错。db.orders.createIndex({ userId: hashed }) sh.shardCollection(appdb.orders, { userId: hashed })shardCollection的第二个参数就是分片键。这里必须是{ userId: hashed }的哈希形式与创建的哈希索引对应。分片完成后我习惯立即向集合写入几条测试数据确认 mongos 能正常路由。之后查看sh.status()应该能看到 orders 集合的两个 chunk 被分配到了 shard1rs 和 shard2rs。4. Spring Boot 接入分片集群4.1 Maven 依赖与版本选择Spring Boot 接入分片集群本质上和接入普通 MongoDB 没有区别因为驱动层面只需要和 mongos 通信不需要感知分片细节。我用的是 Spring Boot 2.7.18搭配内置的 MongoDB Driver 4.x稳定性和兼容性都经过了验证。如果你的项目已经升级到 Spring Boot 3.x那么需要 Java 17 及以上驱动版本也变成了 5.x但配置方式大同小异。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency不需要额外引入 MongoDB Driver 依赖因为 starter 会自动传递依赖。这种设计很方便但版本统一由 Spring Boot 管理你也就不需要关心驱动版本的冲突问题了。4.2 连接串配置与高可用说明这是最容易出问题的一步。很多新手会把分片集群的连接串写成副本集的格式加replicaSetshard1rs之类的参数结果驱动去连接 shard 节点绕过了 mongos导致数据分布路由完全失效。正确的连接串只需要写入所有 mongos 的地址逗号分隔不写 replicaSet 参数spring: data: mongodb: uri: mongodb://192.168.1.11:27017,192.168.1.12:27017/appdb?connectTimeoutMS3000serverSelectionTimeoutMS5000驱动拿到这样的连接串后会把它当作一个整体服务列表从中选取可用的 mongos 发起请求。某个 mongos 挂了驱动会自动切换到另一个实现路由层的高可用。连接串格式上主机后面可以用逗号拼接多个地址然后跟数据库名。connectTimeoutMS是建立连接的超时时间serverSelectionTimeoutMS是选择可用服务器的最长等待时间。生产环境建议把这两个参数都显式配置上不然默认值太长服务器故障后应用会有比较久的等待窗口。4.3 实体与仓库代码实现Spring Data MongoDB 的代码模式和平时操作普通 MongoDB 完全一致。定义实体类Document(orders) public class Order { Id private String id; private Long userId; private BigDecimal amount; private LocalDateTime createTime; public Order(Long userId, BigDecimal amount) { this.userId userId; this.amount amount; this.createTime LocalDateTime.now(); } public Long getUserId() { return userId; } public BigDecimal getAmount() { return amount; } }仓库接口public interface OrderRepository extends MongoRepositoryOrder, String { ListOrder findByUserId(Long userId); }写入数据时我建议显式带上分片键字段。用 MongoTemplate 可以更直观地控制写入参数Service public class OrderService { Autowired private MongoTemplate mongoTemplate; public void insertOrder(Long userId, BigDecimal amount) { Order order new Order(userId, amount); mongoTemplate.insert(order); } public ListOrder findByUserId(Long userId) { Query query new Query(); query.addCriteria(Criteria.where(userId).is(userId)); return mongoTemplate.find(query, Order.class); } }这里的核心原则是写入数据时 O 一定要有 userId查询时尽量带上 userId 条件。因为 mongos 可以凭借分片键直接定位到对应的分片如果查询条件里没有分片键mongos 只能向所有分片广播查询再汇总结果。数据量和分片数变大之后广播查询的延迟会明显上升。4.4 分片集群下的事务与一致性配置Spring Boot 中使用分片集群事务行为与单副本集略有不同。MongoDB 从 4.2 版本开始支持分片集群上的多文档事务在 Spring 中可以通过Transactional直接使用。但要注意跨分片事务需要协调多个分片节点性能开销比单分片事务大不少。业务上如果只是简单插入操作尽量不要把事务范围跨到多个分片。读写关注级别上分片集群默认 writeConcern 是 majority保证写入数据在多数节点确认后才返回。readConcern 默认为 local从节点复制延迟可能导致读取到稍旧的数据。如果业务对实时性要求高可以在连接串中加readPreferenceprimary保证读操作都发到主节点。这里分享一个我实际遇到的场景某团队上线分片集群后发现偶尔有查询查不到刚写入的数据。排查下来是连接串里配置了readPreferencesecondaryPreferred请求被路由到了从节点而从节点复制有延迟。对于订单类业务建议默认使用readPreferenceprimaryPreferred既能保持强一致读又可以在主节点故障时自动切换到从节点。5. 压测验证与线上问题排查5.1 数据分布验证与均衡性检查分片集群搭好后不能只看sh.status()显示两个 shard 就完事。必须实际写入一批数据验证分布是否均匀。我写了一个压测程序并发插入 100 万条订单数据每条数据 userId 在 1 到 10000 之间随机分布。插入完成后通过 mongos 执行db.orders.getShardDistribution()输出大概是这样Shard shard1rs at 192.168.1.11:27018 data: 522348 docs 622.3MB Shard shard2rs at 192.168.1.12:27020 data: 477652 docs 568.1MB两个分片的数据量偏差在 5% 以内说明 hashed 分片键的分布效果符合预期。如果出现某个分片数据量远超其他分片那就是数据倾斜需要检查分片键的基数以及 chunk 分布情况。除了看最终分布还可以观察 chunk 迁移是否正常。用db.printShardingStatus()可以查看每个 chunk 的分布明细。如果某个 shard 上的 chunk 数过多MongoDB 的 balancer 会自动触发迁移这个过程需要一定时间。生产环境我一般会把 balancer 的迁移窗口设置在业务低峰期避免迁移流量影响在线业务。5.2 常见故障速查表把我在搭建和后续运维中遇到的典型问题整理成了一张速查表方便大家对照排查。故障现象可能原因解决措施mongos 启动报 Could not determine cluster roleconfig server 副本集未初始化成功检查 config server 的 rs.status()确认 PRIMARY 已选举出来sh.addShard 报 host cannot be found分片副本集成员地址不可达检查各实例 bindIp 和端口确认防火墙放行插入数据报 Failed to target upsert by query写入操作未携带完整分片键确保写入文档或更新条件包含分片键字段Spring Boot 连接失败 Connection refused连接串写成了 shard 节点地址连接串必须写 mongos 地址逗号分隔多个 mongos数据分布严重不均分片键基数太低或选用了 range 分片改用 hashed 分片键或检查数据是否存在单点热点集群运行中性能突然下降balancer 正在执行 chunk 迁移设置迁移窗口或在业务低峰期手动触发迁移5.3 踩坑记录与优化建议最后分享几条我实际操作中的心得。第一mongos 不是无状态到可以随便重启的。它虽然不存业务数据但每次启动时都要从 config server 拉取元数据。频繁重启 mongos 会带来不必要的元数据加载开销生产环境保持稳定的 mongos 实例数和配置很重要。第二分片键的选择必须结合业务查询形态。hashed 分片键写入均匀但范围查询低效。假如你的业务有大量按创建时间范围查询的场景可以考虑创建复合索引来缓解。比如{ userId: hashed, createTime: 1 }的复合索引既支持分片键定位又能在分片内部高效处理时间范围内的排序。但要注意复合索引的字段顺序不能随意调整第一个字段必须包含分片键。第三连接串里尽量配置连接池参数。Spring Data MongoDB 默认连接池足够用但高并发下我会额外加maxPoolSize200minPoolSize10和maxIdleTimeMS60000。这些参数直接影响驱动维护连接的方式合理的连接池配置能明显降低高频请求下的建立连接开销。第四关于 chunk size。默认 128MB 是通用配置但如果你的分片键是 hashed 的且写入量巨大chunk 迁移会比较频繁。遇到这种情况可以先用use config修改默认 chunk size把_id: chunksize对应文档的值调整为 64 甚至 32减少单 chunk 数据量从而降低迁移时长。这个操作要谨慎改小了 chunk 数量变多元数据变多查询路由开销也会增大。第五一个几乎所有人都忽略的点关闭透明大页后要重启 mongod 进程才生效。我遇到过修改了参数但不重启结果压测时延迟依然忽高忽低排查了半天才意识到是这个问题。Ubuntu 下可以通过重启服务或者手动 echo 后立即重启 mongod 来确认状态。还有一个优化点是如果业务允许可以通过 Spring Data MongoDB 的Query注解把分片键字段和查询条件固定在方法里从代码层面强制约束开发人员必须带分片键查询。这样即使以后有人改代码也很难写出全集群广播查询。这套分片集群上线后订单数据的写入能力提升非常明显。单节点 MongoDB 在同样压测条件下写入到 8000 TPS 左右时主节点 CPU 已经接近饱和分片集群在两个 shard 的配置下写入 TPS 能到 14000 以上而且后续数据量增长时再添加新 shard 就可以平滑扩容不需要改造业务代码。我个人在实际操作中最深刻的体会是分片集群不是银弹单机 Mongo 能扛住的业务没必要上分片复杂度是实打实增加的。但一旦数据量和写入压力到了需要水平扩展的临界点分片集群的架构价值就会完全体现出来。动手之前先把分片键、访问路径、mongos 数量这些设计问题想清楚然后再去搭环境整个过程会顺畅很多。最后再分享一个小技巧生产环境启动 mongos 之前可以在配置文件的net.serviceExecutor设置为adaptive这个配置让 mongos 根据实际流量动态调整线程池大小对高并发场景有一定帮助。这个参数在 MongoDB 4.4 及以上版本才支持如果你用的版本更老就忽略这条。