一、把NoSQL当SQL用的尴尬

很多刚从关系型数据库转过来的同学,特别喜欢在NoSQL里强行搞出表关联。比如在MongoDB里硬生生模仿外键关系,结果查询时疯狂用$lookup操作,性能直接扑街。这就好比拿着菜刀当螺丝刀用,不是不行,但特别费劲。

// 反模式示例:在MongoDB中模仿SQL关联查询
// 用户集合
db.users.insert({
  _id: 1,
  name: "张三",
  orders: [1001, 1002] // 用数组存储关联订单ID
});

// 订单集合
db.orders.insert([
  { _id: 1001, product: "手机", userId: 1 },
  { _id: 1002, product: "耳机", userId: 1 }
]);

// 查询时需要多次join
db.users.aggregate([
  { $match: { _id: 1 } },
  { $lookup: { // 性能杀手!
    from: "orders",
    localField: "orders",
    foreignField: "_id",
    as: "orderDetails"
  }}
]);

修正方案其实很简单——直接内嵌相关数据。NoSQL的优势就是可以灵活设计文档结构,比如把常用查询的订单直接塞进用户文档:

// 优化方案:使用嵌入式文档
db.users.insert({
  _id: 1,
  name: "张三",
  orders: [
    { id: 1001, product: "手机" },
    { id: 1002, product: "耳机" }
  ] // 订单详情直接内嵌
});

// 查询变得极其简单
db.users.find({ _id: 1 }, { "orders": 1 });

二、过度索引引发的血案

在MongoDB里创建索引就像吃自助餐,有人总觉得"越多越好"。曾经见过一个集合创建了20多个索引,写入速度比蜗牛还慢。索引确实能加速查询,但每个索引都会占用空间并降低写入性能。

// 反模式示例:给所有字段都建索引
db.products.createIndex({ name: 1 });
db.products.createIndex({ price: 1 });
db.products.createIndex({ category: 1 });
db.products.createIndex({ tags: 1 });
db.products.createIndex({ createdAt: 1 });
// ...还有15个其他索引

正确的做法是根据实际查询模式创建复合索引。比如我们80%的查询都是按分类筛选后按价格排序:

// 优化方案:创建精准的复合索引
db.products.createIndex({ category: 1, price: 1 });

// 这个索引可以完美支持以下查询
db.products.find({ category: "电子产品" }).sort({ price: -1 });

三、大文档综合症

有些人特别喜欢把整个业务对象都塞进一个文档。比如电商系统里,把用户信息、订单历史、购物车、收货地址全放在一个用户文档里。结果文档大小超过16MB限制,系统直接崩溃。

// 反模式示例:巨型文档结构
db.users.insert({
  _id: 1,
  // 用户基础信息
  name: "李四",
  // 所有收货地址
  addresses: [
    { /* 地址1详情 */ },
    { /* 地址2详情 */ },
    // ...20个地址
  ],
  // 完整的购物车
  cart: {
    items: [
      { /* 商品1 */ },
      // ...50件商品
    ],
    coupons: [/* 10张优惠券 */]
  },
  // 完整的订单历史
  orders: [
    { /* 订单1 */ },
    // ...200个订单
  ]
  // 其他各种信息...
});

解决方案是合理拆分文档,对于可能无限增长的数组(如订单记录),应该单独存放并通过引用关联:

// 优化方案:拆分文档
// 用户主文档
db.users.insert({
  _id: 1,
  name: "李四",
  // 只保留常用地址
  mainAddress: { /* 默认地址 */ }
});

// 订单单独集合
db.orders.insert({
  userId: 1,
  // 订单详情
});

// 购物车单独集合
db.carts.insert({
  userId: 1,
  // 购物车项目
});

四、忽视数据访问模式

很多团队设计NoSQL结构时,完全不考虑实际业务怎么访问数据。比如社交平台的消息系统,设计成需要先查用户再查消息,而实际场景90%都是直接查某个用户的最新消息。

// 反模式示例:不符合访问模式的设计
// 用户集合
db.users.insert({
  _id: 101,
  name: "王五"
});

// 消息单独集合
db.messages.insert({
  _id: 1,
  userId: 101,
  content: "你好啊",
  createdAt: new Date()
});

// 查询时需要先找用户再找消息
const user = db.users.findOne({ name: "王五" });
const messages = db.messages.find({ userId: user._id }).sort({ createdAt: -1 });

应该根据高频访问路径优化结构,比如直接把消息嵌入用户文档:

// 优化方案:按访问模式设计
db.users.insert({
  _id: 101,
  name: "王五",
  messages: [
    {
      content: "你好啊",
      createdAt: new Date()
    }
    // 保留最近100条消息
  ]
});

// 查询变得直接高效
db.users.findOne(
  { name: "王五" },
  { messages: { $slice: -10 } } // 只取最近10条
);

五、事务滥用的陷阱

虽然现代NoSQL如MongoDB已经支持多文档事务,但有人把它当万金油到处用。要知道NoSQL事务的性能开销比SQL大得多,滥用会导致系统吞吐量急剧下降。

// 反模式示例:滥用多文档事务
const session = db.getMongo().startSession();
session.startTransaction();

try {
  const users = session.getDatabase("test").users;
  const orders = session.getDatabase("test").orders;
  
  // 更新用户余额
  users.updateOne({ _id: 1 }, { $inc: { balance: -100 } });
  
  // 创建订单记录
  orders.insertOne({
    userId: 1,
    amount: 100,
    items: ["商品A"]
  });
  
  session.commitTransaction();
} catch (error) {
  session.abortTransaction();
  throw error;
}

对于这种简单的资金操作,完全可以用原子操作+嵌入式设计避免事务:

// 优化方案:使用原子操作
db.users.updateOne(
  { _id: 1, balance: { $gte: 100 } }, // 余额检查
  {
    $inc: { balance: -100 },
    $push: { // 原子性地添加订单记录
      orders: {
        id: new ObjectId(),
        amount: 100,
        items: ["商品A"],
        createdAt: new Date()
      }
    }
  }
);

六、总结与最佳实践

经过这些案例我们可以总结出NoSQL设计的几个黄金法则:

  1. 为读而设计:根据查询模式来规划数据结构,而不是按照传统的关系型思维
  2. 适度冗余:合理的数据冗余可以大幅提升查询性能
  3. 大小控制:单个文档不要超过几MB,对可能无限增长的数组要特别小心
  4. 索引策略:只为高频查询创建必要索引,多用复合索引
  5. 事务节制:能用原子操作解决的问题就不要用多文档事务

记住NoSQL不是银弹,它用空间换时间的设计哲学,要求我们在灵活性和性能之间找到最佳平衡点。下次设计NoSQL结构时,不妨先问问自己:业务最常用的查询路径是什么?这个设计能让我用最少的IO完成这个查询吗?