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 章 | 慢查询巡检 + 磁盘告警 |
小白(沉思):这些模块之间的依赖关系怎么处理?比如索引要依赖数据模型,聚合要依赖索引?
大师:执行顺序很重要。我推荐按这个顺序推进:
- 数据模型设计(先定结构,后建索引,再写查询)
- Docker Compose 多服务编排(MongoDB + Spring Boot + 初始化脚本)
- Spring Boot 代码实现(Repository + Template + Service + Controller)
- 测试验证(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 适用场景
本项目模板适合:
- 中小型电商/本地生活平台的 MongoDB 数据层起点。
- 新人入职培训——提供一套完整的"读代码+跑测试+改功能"的学习路径。
- 技术选型 POC——快速验证 MongoDB 是否能满足团队业务需求。
- 日常开发脚手架——基于模板扩展新功能(如增加退款、物流追踪)。
- 面试作品——体现对 MongoDB 全栈能力的掌握。
不适用场景:
- TB 级数据量——需加入分片集群(第 25 章)。
- 金融级一致性——需加入 change stream + 外部对账(第 24 章)。
- 微服务架构——订单和商品需按领域拆库(第 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 思考题
- 本项目的数据模型中有哪些"跨集合事务"场景?除了第 11 章实现的优惠券领取,还有哪两个跨集合操作需要事务保证?如果不用事务,有什么替代方案?
- 如果要将该项目的索引设计从硬编码在 init-scripts 中升级为"由 Spring Boot 在启动时自动确保索引存在"(类似 Hibernate 的 ddl-auto),有什么优点和风险?
(答案将在第 17 章末尾揭晓)
上一章思考题答案:
find 操作运行 600 秒仍 active 的可能原因:① 无索引的全表扫描在大数据集上执行;② find 的 cursor 一直未被客户端消费完毕(idle cursor 不释放但不算 active);③ 如果状态确实是 active,最可能是它正在等待某个锁(如全局写锁)释放——通过
currentOp中的locks字段查看。不直接 killOp 的原因:killOp 不释放已获取的锁,建索引被 kill 可能导致索引损坏;事务内的操作被 kill 可能导致事务不完整回滚。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 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号