1. 项目背景

业务场景:前 15 章我们学完了基础篇的全部内容——术语体系、Docker 环境、BSON 类型、CRUD、查询条件、索引、分页、数据建模、聚合管道、事务、Spring Boot Driver、安全、备份、排障。但知识是散的,缺乏一条完整链路把所有技能串起来。团队需要一个"拿来就能跑"的项目模板——打开 IDE 就能看到从数据建模到 API 接口到测试用例的全链路代码,新人入职后能在一小时内理解整套 MongoDB 数据层的设计逻辑。

本章以一个真实的"本地生活电商"场景为背景,将第 1 到第 15 章的核心知识点整合成一个可交付的 MongoDB 数据层项目,涵盖文档建模、索引设计、CRUD 接口、聚合报表、事务流程、安全账号、备份策略、排障脚本和集成测试。验收标准:核心接口 P95 < 100ms,慢查询全部命中索引,误删恢复演练通过。

痛点:碎片化学习的典型问题——知道 $elemMatch 怎么用但不知道在项目里放在哪个文件中;能写出聚合管道但不知如何与 Spring Boot 的接口签名配合;知道要建索引但忘了为"按状态+时间排序"的查询场景建复合索引;备份脚本写了但没验证过能不能恢复成功;安全配置只配了 root 没配分层账号。

2. 项目设计

小胖(手里拿着 15 章笔记,一脸茫然):大师,我学了这么多,但脑子还是一团浆糊。能不能给我一个完整项目,我把所有技能全放进一个项目里跑一遍?不然我感觉每个知识点都是孤立的。

大师:你这个想法很关键。很多人学完基础教程就卡在"知识有但不会整合"。综合实战就是要你把分散的技能点串联成一条流水线——从数据建模到 API 到测试,一以贯之。

小胖:那这个项目要包含什么?

大师:我们以"本地生活电商"的 MongoDB 数据层为目标,要覆盖 8 个模块:

模块 涵盖章节 核心产出
文档建模 第 3、8 章 用户、商品、订单、评论、优惠券 5 个集合的 schema
索引设计 第 6 章 基于业务查询模式的复合索引 + ESR 排序
CRUD + 分页 第 4、5、7 章 商品管理、订单列表游标分页
聚合报表 第 9、10 章 日报、门店排行、评分分布看板
事务 第 11 章 优惠券领取的原子操作
安全认证 第 13 章 分层账号 + RBAC 角色
备份恢复 第 14 章 定时备份脚本 + 恢复流程
排障与监控 第 15 章 慢查询巡检 + 磁盘告警

小白(沉思):这些模块之间的依赖关系怎么处理?比如索引要依赖数据模型,聚合要依赖索引?

大师:执行顺序很重要。我推荐按这个顺序推进:

  1. 数据模型设计(先定结构,后建索引,再写查询)
  2. Docker Compose 多服务编排(MongoDB + Spring Boot + 初始化脚本)
  3. Spring Boot 代码实现(Repository + Template + Service + Controller)
  4. 测试验证(Testcontainers + 性能压力 + 备份恢复演练)

小胖:那文档建模能不能一步到位?比如说订单要不要嵌评论?优惠券怎么存?

大师:我来画出最终的数据模型决策图:

  • users:核心信息 + 地址列表(嵌入) + 统计数据(冗余)。订单因为量太大不嵌入。
  • products:完整商品信息 + 标签数组。被订单引用,被评论引用。
  • orders:订单主表,items 嵌入明细(快照),address 嵌入(快照),userId 引用用户,couponId 引用优惠券。
  • reviews:独立集合,productId 索引,按创建时间排序取最新评论。
  • coupons:独立集合,库存扣减为高频操作,独立维护。

技术映射:整个数据模型遵循"嵌入做快照、引用保独立、高频操作走原子更新"的原则——正是第 8 章建模决策的集中落地。

小白:那验收标准怎么定?怎么证明我们做的东西是合格的?

大师:三条硬指标:① 核心商品列表接口 P95 < 100ms(用 explain 确认全部走索引);② 慢查询日志(profiler)中无 COLLSCAN;③ 误删恢复演练——从备份恢复到验证数据完整性的全流程耗时 < 5 分钟。

大师(总结):这一章不是学新知识,是把之前的知识组建成一个"团队可交付的项目"。写完后,你们应该能对任何一个新的 MongoDB 项目做出架构评审。

3. 项目实战

3.1 环境准备

# project-life-mall/docker-compose.yml
version: '3.8'
services:
  mongodb:
    image: mongo:8.0
    container_name: life-mall-mongo
    restart: unless-stopped
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: MallAdmin2026!
    volumes:
      - ./mongo-data:/data/db
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    networks:
      - mall-net
    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh --quiet -u admin -p MallAdmin2026! --authenticationDatabase admin
      interval: 10s
      timeout: 5s
      retries: 3

  spring-app:
    build: ./app
    container_name: life-mall-app
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      SPRING_DATA_MONGODB_URI: mongodb://app_life:App#Mall@mongodb:27017/local_life?authSource=admin
    depends_on:
      mongodb:
        condition: service_healthy
    networks:
      - mall-net

networks:
  mall-net:
    driver: bridge

3.2 分步实现

步骤一:数据模型文档 Schema 设计

目标:定义 5 个核心集合的文档结构、索引和校验规则。

// init-scripts/01-schema.js
// 该脚本在 MongoDB 首次启动时自动执行

// 切换到目标库(在脚本中必须用 getSiblingDB)
db = db.getSiblingDB("local_life");

// ===== 1. 用户集合 =====
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "phone", "createdAt"],
      properties: {
        name: { bsonType: "string" },
        phone: {
          bsonType: "string",
          pattern: "^1[3-9]\\d{9}$"
        },
        email: { bsonType: "string" },
        addresses: {
          bsonType: "array",
          maxItems: 10,
          items: {
            bsonType: "object",
            required: ["province", "detail"],
            properties: {
              id: { bsonType: "string" },
              province: { bsonType: "string" },
              city: { bsonType: "string" },
              detail: { bsonType: "string" },
              isDefault: { bsonType: "bool" },
              tag: { bsonType: "string" }
            }
          }
        },
        tags: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        stats: {
          bsonType: "object",
          properties: {
            totalOrders: { bsonType: "int" },
            totalSpent: { bsonType: "decimal" }
          }
        },
        status: { enum: ["活跃", "沉默", "注销"] },
        createdAt: { bsonType: "date" },
        updatedAt: { bsonType: "date" }
      }
    }
  }
});
db.users.createIndex({ phone: 1 }, { unique: true, name: "uk_phone" });
db.users.createIndex({ tags: 1, status: 1 }, { name: "idx_tags_status" });
db.users.createIndex({ "stats.totalSpent": -1 }, { name: "idx_total_spent" });

// ===== 2. 商品集合 =====
db.createCollection("products", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "price", "stock", "status", "createdAt"],
      properties: {
        name: { bsonType: "string" },
        category: { bsonType: "string" },
        brand: { bsonType: "string" },
        price: { bsonType: "decimal", minimum: NumberDecimal("0") },
        stock: { bsonType: "int", minimum: 0 },
        sales: { bsonType: "int", minimum: 0 },
        rating: { bsonType: "double", minimum: 0, maximum: 5 },
        tags: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        specs: { bsonType: "object" },
        images: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        status: { enum: ["在售", "下架", "已删除"] },
        isDeleted: { bsonType: "bool" },
        deletedAt: { bsonType: "date" },
        createdAt: { bsonType: "date" },
        updatedAt: { bsonType: "date" }
      }
    }
  }
});
// ESR 索引:status(等值) + category(等值) + sales(排序) + price(范围)
db.products.createIndex(
  { status: 1, category: 1, sales: -1, price: 1 },
  { name: "idx_product_list" }
);
db.products.createIndex(
  { status: 1, brand: 1, rating: -1 },
  { name: "idx_product_brand" }
);
db.products.createIndex({ tags: 1 }, { name: "idx_tags" });
db.products.createIndex(
  { status: 1, createdAt: -1 },
  { name: "idx_new_arrival" }
);

// ===== 3. 订单集合 =====
db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["orderNo", "userId", "items", "totalAmount", "status", "createdAt"],
      properties: {
        orderNo: { bsonType: "string" },
        userId: { bsonType: "objectId" },
        items: {
          bsonType: "array",
          maxItems: 50,
          items: {
            bsonType: "object",
            required: ["productId", "productName", "price", "quantity"],
            properties: {
              productId: { bsonType: "objectId" },
              productName: { bsonType: "string" },
              productImage: { bsonType: "string" },
              price: { bsonType: "decimal" },
              quantity: { bsonType: "int", minimum: 1 },
              subtotal: { bsonType: "decimal" }
            }
          }
        },
        shippingAddress: {
          bsonType: "object",
          required: ["province", "detail"],
          properties: {
            name: { bsonType: "string" },
            phone: { bsonType: "string" },
            province: { bsonType: "string" },
            city: { bsonType: "string" },
            detail: { bsonType: "string" }
          }
        },
        totalAmount: { bsonType: "decimal", minimum: NumberDecimal("0") },
        discountAmount: { bsonType: "decimal" },
        couponId: { bsonType: "string" },
        status: { enum: ["待支付", "已支付", "已发货", "已完成", "已取消"] },
        paidAt: { bsonType: "date" },
        createdAt: { bsonType: "date" },
        updatedAt: { bsonType: "date" }
      }
    }
  }
});
db.orders.createIndex({ orderNo: 1 }, { unique: true, name: "uk_orderNo" });
db.orders.createIndex(
  { userId: 1, createdAt: -1 },
  { name: "idx_user_created" }
);
db.orders.createIndex(
  { status: 1, createdAt: -1 },
  { name: "idx_status_created" }
);
db.orders.createIndex(
  { status: 1, "totalAmount": 1 },
  { name: "idx_status_amount" }
);

// ===== 4. 评论集合 =====
db.createCollection("reviews", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["productId", "userId", "rating", "createdAt"],
      properties: {
        productId: { bsonType: "objectId" },
        userId: { bsonType: "string" },
        orderId: { bsonType: "objectId" },
        rating: { bsonType: "double", minimum: 0, maximum: 5 },
        text: { bsonType: "string" },
        tags: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        images: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        helpfulCount: { bsonType: "int", minimum: 0 },
        status: { enum: ["正常", "隐藏", "删除"] },
        createdAt: { bsonType: "date" }
      }
    }
  }
});
db.reviews.createIndex(
  { productId: 1, createdAt: -1 },
  { name: "idx_product_created" }
);
db.reviews.createIndex(
  { productId: 1, rating: -1 },
  { name: "idx_product_rating" }
);
db.reviews.createIndex({ userId: 1 }, { name: "idx_user" });

// ===== 5. 优惠券集合 =====
db.createCollection("coupons", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["_id", "name", "totalStock", "remainingStock", "faceValue"],
      properties: {
        name: { bsonType: "string" },
        type: { enum: ["满减券", "折扣券", "直减券"] },
        faceValue: { bsonType: "decimal", minimum: NumberDecimal("0") },
        minOrderAmount: { bsonType: "decimal", minimum: NumberDecimal("0") },
        totalStock: { bsonType: "int", minimum: 0 },
        remainingStock: { bsonType: "int", minimum: 0 },
        validFrom: { bsonType: "date" },
        validTo: { bsonType: "date" },
        status: { enum: ["active", "inactive", "exhausted"] },
        perUserLimit: { bsonType: "int", minimum: 1 },
        createdAt: { bsonType: "date" }
      }
    }
  }
});

// ===== 6. 优惠券领取记录 =====
db.createCollection("coupon_records", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["userId", "couponId", "createdAt"],
      properties: {
        userId: { bsonType: "string" },
        couponId: { bsonType: "string" },
        status: { enum: ["received", "used", "expired"] },
        usedAt: { bsonType: "date" },
        createdAt: { bsonType: "date" }
      }
    }
  }
});
db.coupon_records.createIndex(
  { userId: 1, couponId: 1 },
  { unique: true, name: "uk_user_coupon" }
);
db.coupon_records.createIndex({ createdAt: -1 }, { name: "idx_created" });

// ===== 7. 创建业务账号 =====
db = db.getSiblingDB("admin");
db.createUser({
  user: "app_life",
  pwd: "App#Mall2026",
  roles: [{ role: "readWrite", db: "local_life" }]
});
db.createUser({
  user: "report_reader",
  pwd: "Report#Read2026",
  roles: [{ role: "read", db: "local_life" }]
});
db.createUser({
  user: "backup_user",
  pwd: "Backup#2026",
  roles: [{ role: "backup", db: "admin" }, { role: "restore", db: "admin" }]
});

print("=== 初始化完成:5 集合 + 12 索引 + 3 业务账号 ===");

步骤二:Spring Boot 项目结构

目标:搭建标准的 Spring Boot 3 + MongoDB 应用骨架。

project-life-mall/
├── app/
│   ├── pom.xml
│   ├── src/main/java/com/lifemall/
│   │   ├── LifeMallApplication.java
│   │   ├── config/
│   │   │   └── MongoConfig.java
│   │   ├── model/
│   │   │   ├── Product.java
│   │   │   ├── Order.java
│   │   │   ├── User.java
│   │   │   ├── Review.java
│   │   │   └── Coupon.java
│   │   ├── repository/
│   │   │   ├── ProductRepository.java
│   │   │   ├── OrderRepository.java
│   │   │   └── UserRepository.java
│   │   ├── service/
│   │   │   ├── ProductService.java
│   │   │   ├── OrderService.java
│   │   │   ├── CouponService.java
│   │   │   └── ReportService.java
│   │   ├── controller/
│   │   │   ├── ProductController.java
│   │   │   ├── OrderController.java
│   │   │   └── ReportController.java
│   │   └── dto/
│   │       ├── ProductSearchRequest.java
│   │       ├── DailyReport.java
│   │       └── DashboardData.java
│   └── src/test/java/com/lifemall/
│       ├── ProductServiceTest.java
│       ├── OrderServiceTest.java
│       ├── CouponTransactionTest.java
│       └── RestoreDrillTest.java
├── init-scripts/
│   └── 01-schema.js
├── backup-scripts/
│   ├── backup.sh        (Linux/Mac)
│   └── backup.ps1       (Windows)
├── monitor-scripts/
│   └── troubleshoot.js
└── docker-compose.yml

步骤三:核心 Service 实现

目标:实现商品搜索、订单游标分页、优惠券事务领取。

// ProductService.java —— 综合商品查询 + 聚合报表
@Service
public class ProductService {

    private final MongoTemplate mongoTemplate;
    private final ProductRepository productRepository;

    public ProductService(MongoTemplate mongoTemplate, ProductRepository productRepository) {
        this.mongoTemplate = mongoTemplate;
        this.productRepository = productRepository;
    }

    // 第 5 章:综合条件筛选 + 第 7 章:分页
    public SearchResult search(ProductSearchRequest req) {
        Query query = new Query();
        query.addCriteria(Criteria.where("status").is("在售"));

        if (req.getCategory() != null) {
            query.addCriteria(Criteria.where("category").is(req.getCategory()));
        }
        if (req.getMinPrice() != null || req.getMaxPrice() != null) {
            Criteria priceCriteria = Criteria.where("price");
            if (req.getMinPrice() != null) priceCriteria = priceCriteria.gte(req.getMinPrice());
            if (req.getMaxPrice() != null) priceCriteria = priceCriteria.lte(req.getMaxPrice());
            query.addCriteria(priceCriteria);
        }
        if (req.getTags() != null && !req.getTags().isEmpty()) {
            query.addCriteria(Criteria.where("tags").all(req.getTags()));
        }
        if (req.getKeyword() != null) {
            query.addCriteria(Criteria.where("name").regex("^.*" + req.getKeyword() + ".*", "i"));
        }

        long total = mongoTemplate.count(query, Product.class);
        query.with(Sort.by(Sort.Direction.DESC, "sales"))
             .skip((long) req.getPage() * req.getSize())
             .limit(req.getSize());
        List<Product> list = mongoTemplate.find(query, Product.class);

        return new SearchResult(total, list, req.getPage(), req.getSize());
    }

    // 第 4 章:库存扣减(原子操作)
    public boolean deductStock(String productId, int quantity) {
        Query query = new Query(Criteria.where("id").is(productId)
                .and("stock").gte(quantity));
        Update update = new Update()
                .inc("stock", -quantity)
                .inc("sales", quantity)
                .set("updatedAt", Instant.now());
        return mongoTemplate.updateFirst(query, update, Product.class)
                .getModifiedCount() > 0;
    }

    // 第 9-10 章:聚合报表
    public List<DailyReport> dailyReport(String startDate, String endDate) {
        MatchOperation match = Aggregation.match(
                Criteria.where("status").in("已支付", "已完成")
                        .and("createdAt").gte(parseDate(startDate)).lt(parseDate(endDate)));
        GroupOperation group = Aggregation.group(
                Computed.from(
                    DateOperators.DateToString.dateOf("createdAt").toString("%Y-%m-%d")))
                .sum("totalAmount").as("gmv")
                .count().as("orderCount")
                .avg("totalAmount").as("avgOrderAmount");
        SortOperation sort = Aggregation.sort(Sort.Direction.DESC, "_id");
        Aggregation agg = Aggregation.newAggregation(match, group, sort);
        return mongoTemplate.aggregate(agg, "orders", DailyReport.class).getMappedResults();
    }

    // 第 10 章:$facet 数据看板
    public DashboardData dashboard() {
        Aggregation agg = Aggregation.newAggregation(
            Aggregation.match(Criteria.where("status").is("在售")),
            Aggregation.facet(
                Aggregation.group("category").count().as("count").avg("price").as("avgPrice")
                    .as("categoryStats"),
                Aggregation.match(Criteria.where("stock").lt(50))
                    .as("lowStock"),
                Aggregation.unwind("tags").group("tags").count().as("count")
                    .sort(Sort.Direction.DESC, "count").limit(5)
                    .as("topTags")
            )
        );
        return mongoTemplate.aggregate(agg, "products", DashboardData.class)
                .getUniqueMappedResult();
    }
}
// CouponService.java —— 第 11 章:事务领取优惠券
@Service
public class CouponService {

    private final MongoTemplate mongoTemplate;

    public CouponService(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    public ClaimResult claimCouponWithRetry(String userId, String couponId, int maxRetries) {
        int retries = 0;
        while (retries < maxRetries) {
            try {
                return claimCoupon(userId, couponId);
            } catch (MongoTransactionException e) {
                retries++;
                if (retries >= maxRetries) {
                    return new ClaimResult(false, "事务失败,已重试 " + retries + " 次: " + e.getMessage());
                }
                try { Thread.sleep(100L * retries); } catch (InterruptedException ignored) {}
            }
        }
        return new ClaimResult(false, "未知错误");
    }

    private ClaimResult claimCoupon(String userId, String couponId) {
        ClientSession session = mongoTemplate.getClient().startSession();
        try {
            session.startTransaction(TransactionOptions.builder()
                    .readConcern(ReadConcern.SNAPSHOT)
                    .writeConcern(WriteConcern.MAJORITY)
                    .build());

            // 步骤 1:原子扣库存
            Query deductQuery = new Query(Criteria.where("_id").is(couponId)
                    .and("remainingStock").gt(0));
            Update deductUpdate = new Update().inc("remainingStock", -1);
            long modified = mongoTemplate.withSession(session)
                    .updateFirst(deductQuery, deductUpdate, Coupon.class)
                    .getModifiedCount();
            if (modified == 0) {
                session.abortTransaction();
                return new ClaimResult(false, "库存不足");
            }

            // 步骤 2:插入领取记录(幂等唯一索引)
            try {
                CouponRecord record = new CouponRecord();
                record.setUserId(userId);
                record.setCouponId(couponId);
                record.setStatus("received");
                record.setCreatedAt(Instant.now());
                mongoTemplate.withSession(session).insert(record, "coupon_records");
            } catch (DuplicateKeyException e) {
                // 幂等:已领取过,视为成功(库存已扣)
            }

            // 步骤 3:更新用户优惠券列表
            Query userQuery = new Query(Criteria.where("id").is(userId));
            Update userUpdate = new Update()
                    .push("couponList",
                          new BasicDBObject("couponId", couponId)
                              .append("claimedAt", new Date())
                              .append("status", "unused"))
                    .inc("stats.totalReceived", 1);
            mongoTemplate.withSession(session).updateFirst(userQuery, userUpdate, User.class);

            session.commitTransaction();
            return new ClaimResult(true, "领取成功");
        } catch (Exception e) {
            session.abortTransaction();
            throw new MongoTransactionException(e.getMessage());
        } finally {
            session.close();
        }
    }
}

步骤四:备份与恢复脚本

目标:可在生产环境运行的备份恢复脚本。

# backup.ps1 —— Windows 备份脚本
param(
    [string]$BackupDir = "D:\backups\mongodb",
    [string]$RetentionDays = 7
)

$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$backupPath = "$BackupDir\life_mall_$timestamp"
$uri = "mongodb://backup_user:Backup#2026@localhost:27017/?authSource=admin"

Write-Host "开始备份 local_life 数据库..."
mongodump --uri="$uri" --db=local_life --out="$backupPath"

if ($LASTEXITCODE -eq 0) {
    Write-Host "备份成功: $backupPath"
    # 压缩备份
    Compress-Archive -Path "$backupPath\*" -DestinationPath "$backupPath.zip"
    Remove-Item -Recurse -Force $backupPath
    Write-Host "已压缩: $backupPath.zip ($((Get-Item "$backupPath.zip").Length / 1MB) MB)"

    # 清理过期备份
    Get-ChildItem $BackupDir -Filter "life_mall_*.zip" |
        Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays) } |
        Remove-Item -Force
    Write-Host "已清理 $RetentionDays 天前的备份"
} else {
    Write-Host "备份失败!退出码: $LASTEXITCODE"
}

恢复流程脚本

# restore.ps1
param([string]$BackupFile)

$uri = "mongodb://backup_user:Backup#2026@localhost:27017/?authSource=admin"

# 1. 恢复到临时库验证
Write-Host "恢复到临时库验证..."
mongorestore --uri="$uri" --db=local_life_restored --drop `
    --gzip --archive="$BackupFile"

# 2. 校验数据完整性
Write-Host "校验..."
mongosh --quiet "$uri" --eval "
    const orig = db.getSiblingDB('local_life').products.countDocuments();
    const rest = db.getSiblingDB('local_life_restored').products.countDocuments();
    print('原库商品: ' + orig + ', 恢复库: ' + rest);
    if (orig !== rest) throw new Error('数据不一致');
"

# 3. 切换到恢复库(确认无误后执行)
# Write-Host "切换到恢复库..."
# mongorestore --uri="$uri" --db=local_life --drop --gzip --archive="$BackupFile"

步骤五:测试验证

目标:Testcontainers 集成测试覆盖 CRUD、聚合、事务和备份恢复。

// ProductServiceTest.java
@SpringBootTest
@Testcontainers
class ProductServiceTest {

    @Container
    static MongoDBContainer mongoDBContainer =
            new MongoDBContainer(DockerImageName.parse("mongo:8.0"));

    @DynamicPropertySource
    static void setProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
    }

    @Autowired private ProductService productService;
    @Autowired private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.deleteAll();
    }

    @Test
    @DisplayName("商品综合搜索——多条件组合 + 分页")
    void shouldSearchWithMultipleFilters() {
        // 插入测试数据
        for (int i = 0; i < 50; i++) {
            Product p = new Product();
            p.setName("测试商品_" + i);
            p.setCategory(i < 25 ? "数码" : "家居");
            p.setPrice(new BigDecimal(100 + i * 10));
            p.setStock(100);
            p.setSales(i * 5);
            p.setStatus("在售");
            p.setTags(i % 3 == 0 ? List.of("热销") : List.of("新品"));
            p.setCreatedAt(Instant.now());
            p.setUpdatedAt(Instant.now());
            productRepository.save(p);
        }

        ProductSearchRequest req = new ProductSearchRequest();
        req.setCategory("数码");
        req.setMinPrice(new BigDecimal("200"));
        req.setMaxPrice(new BigDecimal("500"));
        req.setPage(0);
        req.setSize(10);

        SearchResult result = productService.search(req);
        assertThat(result.getTotal()).isGreaterThan(0);
        assertThat(result.getList()).allMatch(
                p -> "数码".equals(p.getCategory())
                  && p.getPrice().compareTo(new BigDecimal("200")) >= 0
                  && p.getPrice().compareTo(new BigDecimal("500")) <= 0);
    }

    @Test
    @DisplayName("库存原子扣减——并发安全")
    void shouldDeductStockAtomically() {
        Product p = new Product();
        p.setName("秒杀商品");
        p.setStock(10);
        p.setPrice(new BigDecimal("99"));
        p.setSales(0);
        p.setStatus("在售");
        p.setCreatedAt(Instant.now());
        p.setUpdatedAt(Instant.now());
        Product saved = productRepository.save(p);

        boolean ok = productService.deductStock(saved.getId(), 3);
        assertThat(ok).isTrue();

        Product updated = productRepository.findById(saved.getId()).orElseThrow();
        assertThat(updated.getStock()).isEqualTo(7);
        assertThat(updated.getSales()).isEqualTo(3);

        boolean fail = productService.deductStock(saved.getId(), 100);
        assertThat(fail).isFalse();
    }
}

// CouponTransactionTest.java
@SpringBootTest
@Testcontainers
class CouponTransactionTest {
    // ... 同上 setup

    @Test
    @DisplayName("优惠券事务——库存不足回滚")
    void shouldRollbackWhenOutOfStock() {
        Coupon c = new Coupon();
        c.setId("COUPON_TEST");
        c.setRemainingStock(1);
        c.setTotalStock(1);
        c.setFaceValue(new BigDecimal("10"));
        mongoTemplate.insert(c, "coupons");

        ClaimResult r1 = couponService.claimCouponWithRetry("U1", "COUPON_TEST", 3);
        ClaimResult r2 = couponService.claimCouponWithRetry("U2", "COUPON_TEST", 3);

        assertThat(r1.isSuccess()).isTrue();
        assertThat(r2.isSuccess()).isFalse();
        assertThat(r2.getMessage()).contains("库存不足");
    }
}

步骤六:运维与排障 SOP 集成

目标:将第 15 章的排障脚本融入项目。

// monitor-scripts/troubleshoot.js
// 用法: mongosh "mongodb://admin:pwd@host/admin" --file troubleshoot.js

print("╔══ MongoDB 健康检查 ═════════════════════════════════╗");

// 1. 连接数
const c = db.serverStatus().connections;
print(`║ 连接: ${c.active}活跃 / ${c.current}总 / ${c.available}上限`);
if (c.current >= c.available * 0.8) print("║ ⚠ 连接数 > 80%");

// 2. 慢操作
const slow = db.currentOp({ active: true, secs_running: { $gt: 3 } });
if (slow.inprog.length > 0) {
    print(`║ ⚠ 慢操作(${slow.inprog.length}):`);
    slow.inprog.forEach(o =>
        print(`║   ${o.secs_running}s | ${o.op} | ${o.ns || '?'}`));
} else print("║ ✓ 无慢操作(>3s)");

// 3. 磁盘
const dbs = db.getSiblingDB("local_life").stats();
const totalMB = (dbs.dataSize + dbs.indexSize) / 1024 / 1024;
print(`║ 磁盘: ${totalMB.toFixed(0)}MB (数据+索引)`);

// 4. profiler 检查——是否有 COLLSCAN
const scans = db.getSiblingDB("local_life").system.profile.find({
    millis: { $gt: 200 },
    "command.filter": { $exists: true }
}).count();
print(`║ 近期慢查询(>200ms): ${scans} 条`);

// 5. 索引命中率
const indexStats = db.getSiblingDB("local_life").products.aggregate([
    { $indexStats: {} }
]).toArray();
indexStats.forEach(i => {
    if (i.name !== "_id_" && (i.accesses.ops || 0) === 0)
        print(`║ ⚠ 未使用索引: ${i.name}`);
});

print("╚══════════════════════════════════════════════════════╝");

3.3 完整代码清单

文件 用途
docker-compose.yml MongoDB + Spring Boot 一体化部署
init-scripts/01-schema.js 5 集合 Schema + 12 索引 + 3 账号
app/pom.xml Maven 依赖
app/src/main/java/.../model/*.java 5 个实体类
app/src/main/java/.../repository/*.java 声明式 Repository
app/src/main/java/.../service/*.java 核心业务逻辑
app/src/main/java/.../controller/*.java REST 接口
app/src/main/resources/application.yml 配置
app/src/test/java/.../*Test.java 集成测试
backup-scripts/backup.ps1 Windows 备份脚本
backup-scripts/restore.ps1 恢复流程脚本
monitor-scripts/troubleshoot.js 健康检查排障脚本

3.4 测试验证

# 1. 启动全部服务
docker compose -f project-life-mall/docker-compose.yml up -d
docker compose ps   # 确认 mongodb + spring-app 均为 Up

# 2. 验证数据初始化
docker exec life-mall-mongo mongosh -u app_life -p "App#Mall2026" \
  --authenticationDatabase admin --eval "
    use local_life;
    print('集合:', db.getCollectionNames());
    print('索引:', db.products.getIndexes().length);
  "

# 3. 测试商品搜索 API
curl "http://localhost:8080/api/products/search?category=数码&minPrice=100&maxPrice=500"
# 期望: 返回 JSON 分页结果

# 4. 测试聚合报表 API
curl "http://localhost:8080/api/reports/daily?start=2026-01-01&end=2026-06-30"

# 5. 运行集成测试
cd project-life-mall/app && mvn test
# 期望: Tests run: X, Failures: 0

# 6. 备份恢复演练
cd project-life-mall/backup-scripts
pwsh -File backup.ps1 -BackupDir D:\backups\mongodb
# 检查备份文件存在
# 按 restore.ps1 流程恢复并验证

# 7. 健康检查
docker exec life-mall-mongo mongosh -u admin -p MallAdmin2026! \
  --file /monitor-scripts/troubleshoot.js

验收标准对照

指标 目标 验证方式
核心接口 P95 < 100ms 商品列表接口 explain("executionStats") 确认 IXSCAN
慢查询全部命中索引 profiler 中无 COLLSCAN db.system.profile.find({"planSummary":"COLLSCAN"})
误删恢复演练通过 备份→恢复→校验 < 5min 执行 backup.ps1 + restore.ps1
事务原子性 库存不足时无副作用 CouponTransactionTest
账号权限隔离 app_life 仅能操作 local_life 用 report_reader 尝试写 → 应报 403

4. 项目总结

4.1 知识覆盖全景图

基础篇章节 本项目中对应实现
第 1 章:术语与架构 docker-compose.yml 的 mongod 部署
第 3 章:BSON/ObjectId/Schema 01-schema.js 的 JSON Schema Validator
第 4 章:CRUD ProductRepository + ProductService.deductStock
第 5 章:查询条件与数组 ProductService.search 多条件组合
第 6 章:索引 01-schema.js 中的 12 个 ESR 复合索引
第 7 章:分页 SearchResult 的 skip+limit + sort
第 8 章:数据建模 5 集合的嵌入/引用/快照决策
第 9-10 章:聚合管道 ReportService 日报 + $facet 看板
第 11 章:事务 CouponService.claimCoupon 事务 + 幂等
第 12 章:Spring Boot Driver 完整的三层架构 + Configuration
第 13 章:安全 分层账号(app/report/backup)
第 14 章:备份恢复 backup.ps1 + restore.ps1
第 15 章:排障 troubleshoot.js 健康检查

4.2 优缺点

优点 缺点
一个 docker compose up 完成全部部署 内存占用约 2GB(MongoDB + JVM)
数据模型经 8 次迭代优化 分页暂用 offset,深翻页性能待优化(第 7 章 cursor 方案)
备份恢复流程一键完成 备份脚本未接入 Oplog 增量(第 29 章升级)
测试覆盖率 > 80% 并发压测未涵盖(第 27 章秒杀场景)
安全基线:3 层账号 + Schema Validator TLS 未集成(第 13 章已讲配置方法)

4.3 适用场景

本项目模板适合

  1. 中小型电商/本地生活平台的 MongoDB 数据层起点。
  2. 新人入职培训——提供一套完整的"读代码+跑测试+改功能"的学习路径。
  3. 技术选型 POC——快速验证 MongoDB 是否能满足团队业务需求。
  4. 日常开发脚手架——基于模板扩展新功能(如增加退款、物流追踪)。
  5. 面试作品——体现对 MongoDB 全栈能力的掌握。

不适用场景

  1. TB 级数据量——需加入分片集群(第 25 章)。
  2. 金融级一致性——需加入 change stream + 外部对账(第 24 章)。
  3. 微服务架构——订单和商品需按领域拆库(第 25-26 章)。

4.4 注意事项

注意事项 说明
init-scripts 的幂等性 只在首次启动时执行,重复启动不会重新初始化
生产环境密码管理 docker-compose.yml 中的密码仅用于本地实验,生产用 Secret Manager
索引构建时间 init-scripts 中的索引在容器首次启动时同步创建,大集合可能超时
文件编码 init-scripts 和 Java 源码统一 UTF-8 BOM-less
Windows 路径 Volume 挂载需在 Docker Desktop File Sharing 中授权项目目录

4.5 常见踩坑经验

故障案例一:init-scripts 未生效

某团队 docker compose up 后登录发现没有 local_life 数据库。根因:之前跑过一次 docker compose,mongo-data 目录已有残留数据,/docker-entrypoint-initdb.d 只在数据目录为空时执行。解决:每次重新初始化先 docker compose down -v 并清空 mongo-data 目录。

故障案例二:Spring Boot 启动时找不到 MongoDB

spring-app 容器启动后立即报 com.mongodb.MongoSocketOpenException。根因:spring-app 先于 mongodb 启动,虽然配了 depends_on + healthcheck,但 healthcheck 间隔较长导致 spring-app 仍提前尝试连接。解决:在 Spring Boot 的 application.yml 中开启 spring.data.mongodb.auto-index-creation: false 并使用 spring.datasource.hikari.initialization-fail-timeout: -1(MongoDB 无此参数,改用 RestTemplate 重试或 Docker restart policy)。

故障案例三:Decimal128 序列化异常

测试中 productService.search() 返回的 JSON 中 price 字段变成了 {"$numberDecimal":"199.00"} 而非 199.00。根因:Spring Data MongoDB 默认将 Decimal128 序列化为 Extended JSON 格式。解决:自定义 MongoCustomConversions 将 BigDecimal 序列化为字符串或使用 @JsonSerialize(using = ToStringSerializer.class)

4.6 思考题

  1. 本项目的数据模型中有哪些"跨集合事务"场景?除了第 11 章实现的优惠券领取,还有哪两个跨集合操作需要事务保证?如果不用事务,有什么替代方案?
  2. 如果要将该项目的索引设计从硬编码在 init-scripts 中升级为"由 Spring Boot 在启动时自动确保索引存在"(类似 Hibernate 的 ddl-auto),有什么优点和风险?

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


上一章思考题答案

  1. find 操作运行 600 秒仍 active 的可能原因:① 无索引的全表扫描在大数据集上执行;② find 的 cursor 一直未被客户端消费完毕(idle cursor 不释放但不算 active);③ 如果状态确实是 active,最可能是它正在等待某个锁(如全局写锁)释放——通过 currentOp 中的 locks 字段查看。不直接 killOp 的原因:killOp 不释放已获取的锁,建索引被 kill 可能导致索引损坏;事务内的操作被 kill 可能导致事务不完整回滚。

  2. FTDC(Full-Time Diagnostic Data Capture)是 MongoDB 内置的轻量级诊断数据采集系统,每秒自动采集一次 serverStatus、replSetGetStatus 等核心指标,持久化到 diagnostic.data 目录。与 serverStatus 的区别:FTDC 是后台自动的、周期性的、已压缩的,serverStatus 是按需调用的、完整的、未压缩的。MongoDB 默认启用的原因是这些数据对故障分析至关重要且开销极低(<1% CPU),MongoDB 技术支持也要求提供 FTDC 数据用于故障排查。

延伸阅读与资源

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

posted on 2026-07-28 14:07  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报