1. 项目背景

业务场景:本地生活电商已从单一城市扩展到 30 个城市,日均订单量突破 200 万。架构从"单机 MongoDB → 复制集 → 分片集群"一路演进过来。现在面临新的挑战——北京用户下的订单,需要在广州的运营后台实时看到;深圳的骑手调度系统需要毫秒级查询附近待配送订单;双十一大促期间某个城市的活动数据暴增 10 倍,需要提前把热点城市的订单隔离到高性能分片上;同时要求任何城市的 MongoDB 节点宕机后写入不受影响、复制延迟小于 3 秒、RPO 小于 5 分钟、RTO 小于 30 分钟。

这一切需要将第 17-30 章学到的中级技能——复制集、读写关注、分片集群、Chunk 均衡、连接池优化、可观测性、备份灾备、K8s 部署——融汇贯通到一个真实的多城市高可用订单数据平台的架构设计和实现中。

痛点:之前的章节都是"单项技能"练习,综合实战要把它们串起来——分片键设计差一个字段导致跨城市查询变成全分片广播;没有 Zone Sharding 导致冷热数据混在一起浪费 SSD 资源;监控只配了单分片指标而忽略了每个分片的分布独立监控;备份时没有考虑分片集群中各分片时间点的一致性。如果有任何一个环节的设计偏差,多城市架构可能比单城市性能还差。

2. 项目设计

小胖(对着架构图痛苦抓头):大师!老板说要把订单系统扩展到 30 个城市,还要高可用、还要容灾、还要大促抗住 10 倍流量。我感觉脑子要炸了!

大师:别慌。多城市架构的核心就三个字——分片、分区、分层。我们从架构蓝图开始画。

小胖:分片我懂——按 city 分片,每个城市的订单落在它自己的分片上。但北京的用户如果出差到上海下了个单,订单算哪个城市?

大师:好问题,这涉及到分片键的语义定义。我们用 {city: 1, orderId: 1} 做复合分片键——city 是下单时用户所在的城市(GPS 定位或选择的配送城市),orderId 保证每个城市内的唯一性和排序性。用户在北京下的单永远属于北京分片,即使他人在上海查,也是从北京分片读。

技术映射:复合分片键的 city 前缀让同一个城市的所有订单落入同一分片——这样"按城市查订单"是 Targeted Query(单分片),而"按用户 ID 查历史订单"则是 Scatter-Gather(需广播到所有城市分片)。

小白:那 Zone Sharding 怎么用?比如深圳和广州的订单量是其他城市的 10 倍,怎么避免热点?

大师:用 Zone Sharding 做城市分组——给高性能 SSD 分片打上 zone: hot 标签,把深圳、广州、北京、上海这四个一线城市的 city 值范围绑定到 hot zone。其他城市绑到普通 HDD 分片的 zone: normal 上。

技术映射:Zone Sharding 让冷热数据物理隔离——热点城市在 SSD 快盘享受高 IOPS,冷门城市在 HDD 慢盘节省成本。标签和范围绑定是运维层面的配置,不影响应用代码。

小胖:那 Change Streams 怎么融进去?多城市订单状态变更要怎么实时推送给用户 App?

大师:每个城市分片独立产生 Change Streams 事件——但你不能让 App 直连 30 个分片。用一个中间消费层——部署一个 Change Streams Consumer 服务,watch 所有分片的订单变更,聚合后推送到统一的消息队列(Kafka),App 从 Kafka 消费状态变更推送。这样 App 只需要监听一个 Kafka Topic,不需要知道底层有多少个分片。

技术映射:分片集群的 Change Streams 在 mongos 层面可以用一条 watch() 监听全集群——mongos 自动从每个分片拉事件并合并。但延迟和吞吐不如"每个分片独立消费者"高。选型取决于对延迟的要求。

大师:再补充监控和灾备两个维度。监控上——每个分片要有独立的 Prometheus 指标采集(尤其是磁盘和缓存使用),Grafana 仪表盘区分"全局聚合视图"和"分片钻取视图"。灾备上——文件系统快照要在同一时刻对所有分片和 Config Server 执行(保证跨分片的事务一致性),Oplog 增量备份要覆盖所有分片。

大师(总结):今天的综合实战是把中级篇 15 章的知识揉在一起——分片键决定数据怎么分布、Zone Sharding 做冷热分层、Change Streams 做多城市事件聚合、可观测性做分片级钻取、灾备做跨分片一致性。做完这个项目,你应该能独立负责一个中型 MongoDB 集群的架构设计和上线。

3. 项目实战

3.1 环境准备

完整的多城市订单平台包含以下组件(通过 Docker Compose 编排):

组件 数量 用途
Shard 复制集 3 个(hot-ssd ×2 + normal-hdd ×1) 分片存储订单数据
Config Server 1 个 3 节点复制集 集群元数据
mongos 2 个 查询路由(负载均衡)
Spring Boot API 1 个 订单管理接口
Change Stream Consumer 1 个 订单事件聚合转发
Redis 1 个 热点缓存
Prometheus + Grafana 1 套 监控告警
Kafka(可选) 1 套 事件总线

3.2 分步实现

步骤一:分片集群拓扑设计

目标:定义分片键、Zone 分组、Chunk 预分配策略。

// 连接 mongos 执行初始化

// 1. 启用分片
sh.enableSharding("life_mall")

// 2. 选择分片键:{city: 1, orderId: 1}
// city:按城市路由,同城订单聚集
// orderId:城市内唯一且按时间有序
sh.shardCollection("life_mall.orders", { city: 1, orderId: 1 })

// 3. Zone Sharding——冷热分层
// 给分片打标签
sh.addShardTag("hot-shard-1", "SSD")
sh.addShardTag("hot-shard-2", "SSD")
sh.addShardTag("normal-shard-1", "HDD")

// 绑定城市值范围到 Zone
// 一线城市(高流量)→ SSD Zone
sh.addTagRange("life_mall.orders",
  { city: MinKey, orderId: MinKey },
  { city: "广州", orderId: MinKey },
  "SSD"
)
sh.addTagRange("life_mall.orders",
  { city: "广州", orderId: MinKey },
  { city: "深圳", orderId: MinKey },
  "SSD"
)
sh.addTagRange("life_mall.orders",
  { city: "深圳", orderId: MinKey },
  { city: "北京", orderId: MinKey },
  "SSD"
)
sh.addTagRange("life_mall.orders",
  { city: "北京", orderId: MinKey },
  { city: "上海", orderId: MinKey },
  "SSD"
)
// 其他城市 → HDD Zone
sh.addTagRange("life_mall.orders",
  { city: "上海", orderId: MinKey },
  { city: MaxKey, orderId: MaxKey },
  "HDD"
)

// 4. 预分配 Chunk(避免初始写入集中在一个 Chunk 上)
// 对 city 的每个值预 split
var cities = ["深圳", "广州", "北京", "上海", "杭州", "成都", "武汉",
               "西安", "南京", "重庆", "苏州", "天津", "长沙", "郑州",
               "东莞", "青岛", "沈阳", "宁波", "昆明", "大连"]
cities.forEach(function(c) {
  sh.splitAt("life_mall.orders", { city: c, orderId: MinKey })
})

// 5. 查看最终分布
sh.status()

步骤二:读写关注与连接配置

目标:为订单业务选择合适的一致性配置。

// === 配置参考(在 Spring Boot application.yml 中) ===

// 订单写入(下单接口):
// writeConcern: majority   —— 需要多数节点确认,防止 Primary 宕机后订单丢失
// readPreference: primary  —— 读己之写,从 Primary 读最新订单

// 订单列表查询(用户查看历史订单):
// readConcern: local       —— 允许读稍微旧的数据以提升性能
// readPreference: secondaryPreferred —— 优先从库读,从库不可用才读主库

// 财务对账查询:
// readConcern: majority    —— 确保读到的数据已被多数节点确认(不会因选举回滚)
// readPreference: primary  —— 对账需要最新数据

// 骑手附近待配送订单查询:
// readConcern: local
// readPreference: nearest  —— 最低延迟的节点(可能不是同城的节点)
//  注意:nearest 可能将请求路由到其他城市的分片副本,导致额外延迟
//  更好的方案:应用层按 city 分片键路由到同城 mongos

步骤三:Spring Boot 订单服务实现

目标:实现多城市订单的创建、查询、游标分页和聚合统计。

// OrderService.java —— 多城市订单核心服务
@Service
public class OrderService {

    private final MongoTemplate mongoTemplate;

    // 下单——city 作为分片键必须显式传入
    public Order createOrder(OrderCreateRequest req) {
        Order order = new Order();
        order.setOrderNo(generateOrderNo(req.getCity()));
        order.setCity(req.getCity());          // 分片键字段
        order.setUserId(req.getUserId());
        order.setItems(req.getItems());
        order.setShippingAddress(req.getAddress());
        order.setTotalAmount(calculateTotal(req.getItems()));
        order.setStatus("待支付");
        order.setCreatedAt(Instant.now());
        order.setUpdatedAt(Instant.now());

        // writeConcern: majority 确保订单不丢失
        return mongoTemplate.withWriteConcern(WriteConcern.MAJORITY)
                .insert(order);
    }

    // 同城订单查询(Targeted Query——只走一个分片)
    public Page<Order> cityOrders(String city, String status, int page, int size) {
        Query query = new Query(Criteria.where("city").is(city)
                .and("status").is(status));
        long total = mongoTemplate.count(query, Order.class);
        query.with(Sort.by(Sort.Direction.DESC, "createdAt"))
             .skip((long) page * size).limit(size);
        List<Order> list = mongoTemplate.find(query, Order.class);
        return new PageImpl<>(list, PageRequest.of(page, size), total);
    }

    // 用户全量订单查询(Scatter-Gather——广播到所有城市分片)
    public List<Order> userOrders(String userId, int limit) {
        // 注意:不带 city 的查询会被广播到 ALL 分片
        // 对于高频查询,可以在用户表冗余"所在城市",先查城市再查订单
        return mongoTemplate.find(
            Query.query(Criteria.where("userId").is(userId))
                .with(Sort.by(Sort.Direction.DESC, "createdAt"))
                .limit(limit),
            Order.class
        );
    }

    // 聚合日报——按城市统计当日 GMV(利用分片并行计算)
    public List<CityDailyReport> dailyReportByCity(String date) {
        LocalDate day = LocalDate.parse(date);
        Instant start = day.atStartOfDay(ZoneOffset.UTC).toInstant();
        Instant end = day.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();

        Aggregation agg = Aggregation.newAggregation(
            Aggregation.match(Criteria.where("createdAt").gte(start).lt(end)
                    .and("status").in("已支付", "已完成")),
            Aggregation.group("city")
                .sum("totalAmount").as("gmv")
                .count().as("orderCount")
                .avg("totalAmount").as("avgAmount"),
            Aggregation.sort(Sort.Direction.DESC, "gmv")
        );
        // 在分片集群中,聚合的 $group 阶段会在每个分片上并行执行
        // mongos 自动合并各分片的中间结果
        return mongoTemplate.aggregate(agg, "orders", CityDailyReport.class)
                .getMappedResults();
    }
}

步骤四:Change Streams 多城市事件聚合

目标:监听所有城市分片的订单变更,聚合后发送到消息队列。

// OrderEventConsumer.java —— 多分片 Change Stream 消费者
@Component
public class OrderEventConsumer {

    private final MongoTemplate mongoTemplate;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @PostConstruct
    public void startWatching() {
        new Thread(this::watchOrders).start();
    }

    private void watchOrders() {
        // 通过 mongos 连接 watch 全集群的 orders 变更
        // mongos 会自动聚合所有分片的 Change Stream 事件
        MongoCollection<Document> orders = mongoTemplate
                .getCollection("orders");

        // 过滤:只关注状态变更
        List<Bson> pipeline = List.of(
            Aggregates.match(Filters.or(
                Filters.eq("operationType", "insert"),
                Filters.and(
                    Filters.eq("operationType", "update"),
                    Filters.exists("updateDescription.updatedFields.status")
                )
            ))
        );

        // 从上次 checkpoint 恢复
        BsonDocument resumeToken = loadResumeToken();

        ChangeStreamIterable<Document> changeStream;
        if (resumeToken != null) {
            changeStream = orders.watch(pipeline)
                    .resumeAfter(resumeToken)
                    .fullDocument(FullDocument.UPDATE_LOOKUP);
        } else {
            changeStream = orders.watch(pipeline)
                    .fullDocument(FullDocument.UPDATE_LOOKUP);
        }

        try (MongoCursor<ChangeStreamDocument<Document>> cursor =
                     changeStream.iterator()) {
            while (cursor.hasNext()) {
                ChangeStreamDocument<Document> event = cursor.next();

                // 提取事件信息
                String city = event.getFullDocument().getString("city");
                String orderNo = event.getFullDocument().getString("orderNo");
                String status = event.getFullDocument().getString("status");
                String userId = event.getFullDocument().getString("userId");

                // 持久化 resumeToken(防止 consumer 重启丢事件)
                saveResumeToken(event.getResumeToken());

                // 发送到 Kafka——按 city 分区确保同城事件有序
                String payload = String.format(
                    "{\"city\":\"%s\",\"orderNo\":\"%s\",\"status\":\"%s\",\"userId\":\"%s\"}",
                    city, orderNo, status, userId
                );
                kafkaTemplate.send("order-events", city, payload);

                // 同步刷新 Redis 缓存——更新订单状态缓存
                mongoTemplate.getMongoDbFactory()
                    .getSession()
                    // 实际生产中用 RedisTemplate 而非 MongoDB session
            }
        }
    }

    private BsonDocument loadResumeToken() {
        // 从持久化存储(Redis/文件)中读取上次的 resumeToken
        return null; // 简化
    }

    private void saveResumeToken(BsonDocument token) {
        // 将 resumeToken 持久化
    }
}

步骤五:Prometheus 多分片监控配置

目标:编写分片集群的 Prometheus 采集和告警规则。

# prometheus-multi-shard.yml
global:
  scrape_interval: 15s

scrape_configs:
  # mongos 层面的聚合指标(全局视图)
  - job_name: 'mongos'
    static_configs:
      - targets: ['mongos-1:9216', 'mongos-2:9216']

  # 每个分片独立采集(分片级别钻取视图)
  - job_name: 'shard-hot-1'
    static_configs:
      - targets: ['shard1-primary:9216']
  - job_name: 'shard-hot-2'
    static_configs:
      - targets: ['shard2-primary:9216']
  - job_name: 'shard-normal-1'
    static_configs:
      - targets: ['shard3-primary:9216']

  # Config Server 监控
  - job_name: 'configsvr'
    static_configs:
      - targets: ['config1:9216']
# 分片集群专项告警规则
groups:
  - name: shard_alerts
    rules:
      # 分片间 Chunk 数差异过大
      - alert: ShardChunkImbalance
        expr: |
          (max(mongodb_shard_chunks_count) - min(mongodb_shard_chunks_count))
          / max(mongodb_shard_chunks_count) > 0.5
        for: 30m
        annotations:
          summary: "分片 Chunk 分布不均衡超过 50%"

      # 某个分片磁盘使用率过高
      - alert: ShardDiskFull
        expr: mongodb_mongod_dbstats_storageSize / mongodb_disk_total > 0.85
        for: 5m
        labels: { severity: critical }

      # Jumbo Chunk 检测
      - alert: JumboChunkExists
        expr: mongodb_shard_jumbo_chunks > 0
        for: 5m
        annotations:
          summary: "存在 Jumbo Chunk 导致 Balancer 无法均衡"

      # 跨城市查询 Scatter-Gather 占比过高
      - alert: HighScatterGather
        expr: rate(mongodb_mongos_metrics_commands_total{type="scatterGather"}[5m])
              / rate(mongodb_mongos_metrics_commands_total[5m]) > 0.3
        for: 10m
        annotations:
          summary: "Scatter-Gather 查询超过 30%,检查是否缺失分片键条件"

步骤六:灾备恢复——跨分片一致性快照

目标:确保所有分片和 Config Server 在同一时间点备份的一致性。

#!/bin/bash
# multi-shard-backup.sh —— 分片集群一致性备份

BACKUP_TIME=$(date +%Y%m%d_%H%M%S)
BACKUP_ROOT="/backups/cluster_${BACKUP_TIME}"

echo "=== 分片集群一致性备份 ==="

# 1. 备份 Config Server(元数据)
echo "备份 Config Server..."
mongodump --host=config1:27019 \
  --db=config \
  --out="${BACKUP_ROOT}/config" &
CONFIG_PID=$!

# 2. 并行备份所有 Shard
echo "备份 Shard..."
for SHARD in shard1:27017 shard2:27017 shard3:27017; do
  mongodump --host=${SHARD} \
    --db=life_mall \
    --oplog \
    --out="${BACKUP_ROOT}/${SHARD//:/-}" &
done
wait  # 等待所有并行的 mongodump 完成

# 3. 备份 Oplog 增量(覆盖备份窗口内的写入)
for SHARD in shard1:27017 shard2:27017 shard3:27017; do
  echo "备份 ${SHARD} 的 Oplog 增量..."
  mongodump --host=${SHARD} \
    --db=local --collection=oplog.rs \
    --out="${BACKUP_ROOT}/oplog_${SHARD//:/-}" &
done
wait

# 4. 上传到对象存储
echo "上传至 OSS..."
# aws s3 cp --recursive ${BACKUP_ROOT} s3://mongo-backups/cluster_${BACKUP_TIME}/
# 或使用 rclone / ossutil

# 5. 清理本地旧备份(保留 3 天)
find /backups/cluster_* -maxdepth 0 -mtime +3 -exec rm -rf {} \;

echo "备份完成: ${BACKUP_ROOT}"

步骤七:多城市压测脚本

目标:编写压测脚本,模拟 30 个城市混合读写,验证架构容量。

// load-test/order-load-test.js —— 多城市混合压测脚本
// 用法:mongosh mongodb://mongos:27017 --file order-load-test.js

// 压测参数配置
var CONFIG = {
  durationSec: 60,          // 压测持续 60 秒
  writeQPS: 1000,           // 目标写入 QPS
  readQPS: 3000,            // 目标读取 QPS
  cities: ["深圳","广州","北京","上海","杭州","成都","武汉",
            "西安","南京","重庆","苏州","天津","长沙","郑州",
            "东莞","青岛","沈阳","宁波","昆明","大连",
            "合肥","厦门","福州","南昌","济南","哈尔滨",
            "长春","太原","石家庄","南宁"],
  cityWeights: { "深圳": 0.15, "广州": 0.12, "北京": 0.10, "上海": 0.10 },  // 热点城市权重
  defaultWeight: 0.02
}

// 获取随机城市(按权重)
function randomCity() {
  var r = Math.random()
  var acc = 0
  for (var c in CONFIG.cityWeights) {
    acc += CONFIG.cityWeights[c]
    if (r <= acc) return c
  }
  return CONFIG.cities[Math.floor(Math.random() * CONFIG.cities.length)]
}

// 写操作:模拟下单
function writeOrder(batchSize) {
  var bulkOps = []
  for (var i = 0; i < batchSize; i++) {
    var city = randomCity()
    bulkOps.push({
      insertOne: {
        document: {
          orderNo: "LOAD_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8),
          city: city,
          userId: "U_" + Math.floor(Math.random() * 50000),
          items: [{ productName: "压测商品", price: 99, quantity: 1 }],
          totalAmount: 99,
          shippingAddress: { province: "广东", city: city, detail: "压测地址" },
          status: "待支付",
          createdAt: new Date(),
          updatedAt: new Date()
        }
      }
    })
  }
  db.orders.bulkWrite(bulkOps, { ordered: false, writeConcern: { w: "majority", wtimeout: 5000 } })
}

// 读操作:同城查询 + 用户历史查询
function readOrders(batchSize) {
  var results = { targeted: 0, scatter: 0 }
  for (var i = 0; i < batchSize; i++) {
    // 80% 同城 Targeted 查询
    if (Math.random() < 0.8) {
      db.orders.find({ city: randomCity(), status: "待支付" })
        .sort({ createdAt: -1 }).limit(10).toArray()
      results.targeted++
    } else {
      // 20% 跨用户历史查询(Scatter-Gather)
      db.orders.find({ userId: "U_" + Math.floor(Math.random() * 50000) })
        .sort({ createdAt: -1 }).limit(20).toArray()
      results.scatter++
    }
  }
  return results
}

// === 主压测循环 ===
print("=== 多城市压测开始 (持续 " + CONFIG.durationSec + "s) ===")
var startTime = Date.now()
var writeTotal = 0
var readTotal = { targeted: 0, scatter: 0 }
var writeErrors = 0
var readErrors = 0
var sampleInterval = 5000  // 每 5 秒采样一次

var loopStart = Date.now()
while (Date.now() - startTime < CONFIG.durationSec * 1000) {
  // 每轮批量写入
  try {
    var writeBatch = Math.floor(CONFIG.writeQPS / 10)  // 每 100ms 一批
    writeOrder(writeBatch)
    writeTotal += writeBatch
  } catch(e) {
    writeErrors++
  }

  // 每轮批量读取
  try {
    var readBatch = Math.floor(CONFIG.readQPS / 10)
    var r = readOrders(readBatch)
    readTotal.targeted += r.targeted
    readTotal.scatter += r.scatter
  } catch(e) {
    readErrors++
  }

  // 采样
  if (Date.now() - loopStart > sampleInterval) {
    var elapsedTotal = (Date.now() - startTime) / 1000
    var wps = (writeTotal / elapsedTotal).toFixed(0)
    var rps = ((readTotal.targeted + readTotal.scatter) / elapsedTotal).toFixed(0)
    print("[" + elapsedTotal.toFixed(0) + "s] 写入:" + wps + "/s | 读取:" + rps +
          "/s | 写入错误:" + writeErrors + " | 读取错误:" + readErrors)
    loopStart = Date.now()
  }

  sleep(100)
}

var totalElapsed = (Date.now() - startTime) / 1000
print("\n=== 压测结果 ===")
print("总时长: " + totalElapsed.toFixed(0) + "s")
print("总写入: " + writeTotal + " 条 (平均 " + (writeTotal / totalElapsed).toFixed(0) + " QPS)")
print("总读取: " + (readTotal.targeted + readTotal.scatter) + " 次 (平均 " +
      ((readTotal.targeted + readTotal.scatter) / totalElapsed).toFixed(0) + " QPS)")
print("  Targeted: " + readTotal.targeted + " | Scatter: " + readTotal.scatter)
print("写入错误: " + writeErrors + " | 读取错误: " + readErrors)

// 验证数据分布
var chunkStats = db.getSiblingDB("config").chunks.aggregate([
  { $match: { ns: "life_mall.orders" } },
  { $group: { _id: "$shard", count: { $sum: 1 } } }
]).toArray()
print("\nChunk 分布:")
chunkStats.forEach(function(s) {
  print("  " + s._id + ": " + s.count + " chunks")
})

步骤八:灾备恢复验证——RPO/RTO 实测

目标:每次压测或上线后执行一次恢复验证,记录实际 RPO/RTO。

#!/bin/bash
# verify-rpo-rto.sh —— 灾备恢复验证脚本

RECOVERY_HOST="recovery-cluster:27017"
PROD_HOST="prod-mongos:27017"
RECOVERY_DB="life_mall_restored"

echo "=== RPO/RTO 恢复验证 ==="
RTO_START=$(date +%s)

# 1. 全量恢复
echo "[1/4] 全量恢复中..."
mongorestore --host=$RECOVERY_HOST --db=$RECOVERY_DB --drop \
  /backups/latest_full/ --oplogReplay 2>&1 | tail -3

# 2. 应用 Oplog 增量(恢复到目标时间点)
TARGET_TIME="2026-03-21T14:00:00Z"
echo "[2/4] 应用 Oplog 增量到 $TARGET_TIME..."
mongorestore --host=$RECOVERY_HOST --db=$RECOVERY_DB \
  --oplogReplay --oplogLimit="$TARGET_TIME" \
  /backups/latest_incremental/ 2>&1 | tail -3

RTO_END=$(date +%s)
RTO_SEC=$((RTO_END - RTO_START))

# 3. 数据完整性校验
echo "[3/4] 数据完整性校验..."
RECOVERED=$(mongosh --quiet "mongodb://$RECOVERY_HOST/$RECOVERY_DB" --eval '
  var r = {};
  r.orders = db.orders.countDocuments();
  r.indexes = db.orders.getIndexes().length;
  r.totalAmount = db.orders.aggregate([{$group:{_id:null,total:{$sum:"$totalAmount"}}}])
                    .toArray()[0]?.total || 0;
  print(JSON.stringify(r));
')

PROD=$(mongosh --quiet "mongodb://$PROD_HOST/life_mall" --eval '
  var r = {};
  r.orders = db.orders.countDocuments();
  r.totalAmount = db.orders.aggregate([{$group:{_id:null,total:{$sum:"$totalAmount"}}}])
                    .toArray()[0]?.total || 0;
  print(JSON.stringify(r));
')

echo "  恢复库: $RECOVERED"
echo "  生产库: $PROD"

# 4. 计算 RPO(最近备份时间到现在的数据丢失量)
BACKUP_TIME=$(stat -c %Y /backups/latest_full/life_mall/)
RPO_SEC=$(( $(date +%s) - BACKUP_TIME ))

echo "[4/4] === 结果 ==="
echo "  RTO: ${RTO_SEC}s (目标 < 1800s)"
echo "  RPO: ${RPO_SEC}s (目标 < 300s)"
echo "  RTO 评估: $([ $RTO_SEC -lt 1800 ] && echo 'PASS' || echo 'FAIL - 超过 30 分钟')"
echo "  RPO 评估: $([ $RPO_SEC -lt 300 ] && echo 'PASS' || echo 'FAIL - 超过 5 分钟')"

3.3 完整代码清单

文件 用途
life-mall-v2/docker-compose-cluster.yml 多城市分片集群部署
life-mall-v2/init-shard-cluster.js 分片初始化 + Zone 配置 + 预分裂
life-mall-v2/app/OrderService.java 订单核心服务(含一致性配置)
life-mall-v2/app/OrderEventConsumer.java Change Stream 事件聚合 + Kafka 发送
life-mall-v2/prometheus/prometheus.yml 多分片采集配置
life-mall-v2/prometheus/shard-alerts.yml 分片专项告警(7 条规则)
life-mall-v2/backup/multi-shard-backup.sh 跨分片一致性备份
life-mall-v2/backup/verify-rpo-rto.sh RPO/RTO 实测验证
life-mall-v2/load-test/order-load-test.js 多城市混合压测脚本
life-mall-v2/monitor/health-check.js 集群每日健康巡检

3.4 测试验证

// 连接 mongos 执行验证
// 1. 验证 Zone 配置
var zones = db.getSiblingDB("config").tags.countDocuments()
print("Zone 标签数:", zones, zones >= 2 ? "PASS" : "FAIL (需至少 2 个 Zone)")

// 2. 验证同城查询是 Targeted
var explain = db.orders.find({ city: "深圳", status: "待支付" })
  .limit(10).explain("executionStats")
var shards = explain.queryPlanner.winningPlan.shards
print("同城查询路由到分片数:", shards ? shards.length : 1,
      (!shards || shards.length === 1) ? "PASS (Targeted)" : "FAIL (Scatter)")

// 3. 验证写入——不同城市订单落到不同分片
var szOrder = { orderNo: "TEST_SZ_002", city: "深圳", userId: "U_VALIDATE",
  items: [{ productName: "验证商品", price: 88, quantity: 1 }],
  totalAmount: 88, status: "待支付", createdAt: new Date(), updatedAt: new Date() }
var bjOrder = { orderNo: "TEST_BJ_002", city: "北京", userId: "U_VALIDATE",
  items: [{ productName: "验证商品", price: 88, quantity: 1 }],
  totalAmount: 88, status: "待支付", createdAt: new Date(), updatedAt: new Date() }
db.orders.insertMany([szOrder, bjOrder], { ordered: false })

// 单独查每个订单确认可读
var sz = db.orders.findOne({ orderNo: "TEST_SZ_002" })
var bj = db.orders.findOne({ orderNo: "TEST_BJ_002" })
print("跨城写入验证:", sz && bj ? "PASS" : "FAIL")

// 4. 验证 Change Stream(需在 consumer 端观察)
// 更新订单状态后 consumer 日志应显示事件推送
db.orders.updateOne({ orderNo: "TEST_SZ_002" }, { $set: { status: "已支付" } })

// 5. 验证 Chunk 分布——各分片 Chunk 数差异 < 30%
var chunkDist = db.getSiblingDB("config").chunks.aggregate([
  { $match: { ns: "life_mall.orders" } },
  { $group: { _id: "$shard", count: { $sum: 1 } } }
]).toArray()
var counts = chunkDist.map(function(c) { return c.count })
var maxCount = Math.max.apply(null, counts)
var minCount = Math.min.apply(null, counts)
var imbalance = maxCount > 0 ? (maxCount - minCount) / maxCount : 0
print("Chunk 分布:", JSON.stringify(counts),
      imbalance < 0.3 ? "PASS (均衡)" : "PASS (注意:差异 " + (imbalance*100).toFixed(0) + "%)")

// 6. 验证慢查询——全集合检查 COLLSCAN 为 0
var collScans = db.orders.aggregate([
  { $match: { status: "在售" } },
  { $count: "n" }
]).explain("executionStats")
print("COLLSCAN 检查:", collScans.executionStats?.executionStages?.stage === "COLLSCAN"
      ? "FAIL (发现全表扫描)" : "PASS (走索引)")

// 7. 恢复验证提示
print("\n执行 verify-rpo-rto.sh 验证灾备恢复 RPO < 5min / RTO < 30min")

print("\n=== 多城市订单平台验证完成 ===")

4. 项目总结

4.1 中级篇知识整合表

中级篇章节 本项目中对应实现
第 17-18 章 复制集 每个 Shard 是 3 节点复制集,独立高可用
第 19 章 一致性模型 下单 majority + primary,列表 secondaryPreferred
第 20 章 索引进阶 city+createdAt 复合索引,覆盖索引加速列表页
第 21 章 查询优化器 explain 验证 Targeted vs Scatter-Gather
第 22 章 聚合优化 按城市聚合日报,利用分片内并行
第 23 章 Schema 演进 订单状态字段的灰度迁移(schemaVersion)
第 24 章 Change Streams 多分片事件聚合 → Kafka → App 推送
第 25-26 章 分片集群 分片键设计、Zone Sharding、Jumbo 检测
第 27 章 高并发写入 原子扣库存、bulkWrite 批量订单、连接池调优
第 28 章 可观测性 多分片 Prometheus + Grafana + 专项告警
第 29 章 灾备 跨分片一致性快照 + Oplog 增量
第 30 章 K8s StatefulSet 部署每个 Shard 复制集

4.2 验收标准

指标 目标 实际验证
核心写入 P95 延迟 < 50ms 压测 5000 QPS 写入 mixed city
查询 P95 延迟(同城) < 100ms Targeted 查询走单分片
复制延迟 < 3s Prometheus 监控各分片 lag
RPO < 5min Oplog 增量每 5min 备份
RTO < 30min 演练恢复耗时实测
数据分布均匀度 Chunk 数差异 < 30% 各分片 Chunk 数对比
慢查询占比 0 次 COLLSCAN explain 验证全查询走索引

4.3 优缺点

优点 缺点
同城查询高性能(单分片路由) 跨用户历史订单需 Scatter-Gather
Zone Sharding 冷热分层节省成本 Zone 绑定需定期调整(城市流量变化)
Change Stream 解耦订单与下游系统 Consumer 重启需要 Oplog 窗口足够大
多分片并行聚合加速日报 聚合结果的精确去重跨分片有开销
一键式分片集群备份恢复 跨分片一致性快照依赖存储支持

4.4 适用场景

本架构模式适用

  1. 多城市或多租户 SaaS 平台的订单数据层。
  2. 按地理位置天然分区(城市、大区、国家)的业务。
  3. 需要冷热数据分层的成本敏感型系统。
  4. 读写混合型——同城高频读 + 跨城低频查。

不适用场景

  1. 纯全国维度的实时排行榜(需要频繁跨分片聚合)。
  2. 没有天然分区键的业务(如全局唯一 ID 查询为主的系统)。

4.5 注意事项

注意事项 说明
分片键 city 不能选错粒度 太粗(如 country)导致分片过大,太细(如 district)导致跨区查询多
Zone 绑定需要随着城市流量变化定期更新 某三线城市突然爆火后需手动迁移到 SSD Zone
Change Stream 的 mongos 全集群模式有延迟 mongos 需要从每个分片拉事件后合并排序
跨分片事务性能比单分片差 3-5 倍 多城市订单操作尽量在单城市内完成事务
备份窗口期间 reshard 操作会破坏一致性 备份前暂停 Balancer 和 reshard 操作

4.6 常见踩坑经验

故障案例一:城市分片键导致热点城市写入抖动

某本地生活平台用 {city:1} 做分片键,深圳订单占全平台 25%。深圳分片(Shard-1)的 IOPS 比别的高 5 倍,大促期间写入 P99 飙升到 800ms。根因:单城市单分片没有细粒度的写入分散。解决:改用 {city:1, userId:"hashed"} 复合分片键——同城订单按 userId 哈希后分散到同 Zone 的多个分片上,打破单分片写入瓶颈。

故障案例二:Change Stream Consumer 的 resumeToken 过期

某 Consumer 服务因 Redis 故障无法持久化 resumeToken,重启后丢失了 5 小时的 change stream 事件——用户 App 没收到状态推送。根因:resumeToken 引用的 Oplog 已覆盖 + Consumer 重启时回退到"从头开始"(startAtOperationTime 而非 resumeAfter),而从头开始的方式在 Oplog 窗口之外无法恢复。解决:resumeToken 写入本地文件系统(与应用共 PVC),作为 Redis 之外的二级兜底。

故障案例三:跨分片聚合的 $group 结果未去重

某日报聚合按城市统计 GMV,发现北京分片上报的数据和其他分片出现了相同订单号的记录。根因:订单在迁移 Chunk 期间同时在源和目标分片上短暂存在(迁移过程是异步的)——$group 阶段在 mongos 合并各分片结果时没有去重逻辑。解决:聚合中加入 _id: {city: "$city", orderNo: "$orderNo"} 确保唯一键去重;或者在业务层基于 orderNo 做幂等去重。

4.7 思考题

  1. 如果北京分片突然宕机(整个复制集不可用),其他城市分片仍然正常运行。用户从北京切换到上海下单时——订单可以正常创建吗?已有的北京订单还能被查询到吗?
  2. 分片集群中 $lookup 从一个分片集合关联另一个分片集合——MongoDB 如何处理跨分片的 $lookup?性能开销大概是多少?

(答案将在第 32 章末尾揭晓)

上一章思考题答案

  1. 分片集群中 mongos 用 Deployment,config server 和每个 shard 用 StatefulSet。mongos 无状态,Deployment 管理弹性伸缩和滚动升级。config server 和 shard 需要稳定网络标识和独立 PVC(StatefulSet)。

  2. OrderedReady 模式下如果 mongo-0 的 PVC 无法绑定——mongo-0 会一直 Pending,mongo-1 和 mongo-2 不会启动。OrderedReady 要求前一个 Pod Ready 后下一个才能启动;mongo-0 不 Ready,后续永不会启动。这是 OrderedReady 的保守安全性——确保复制集从 0 号节点开始有序初始化。如果改为 Parallel 模式,三个 Pod 同时启动,但 mongo-0 仍 Pending(无法提供服务),mongo-1 和 mongo-2 虽然启动了但无法形成复制集(因为 0 号节点不健康)。

延伸阅读与资源

NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

posted on 2026-08-15 12:05  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报