一、先搞懂两个核心思维的区别:关系思维和NoSQL思维

很多刚接触NoSQL的开发者,都会犯一个通病:把关系型数据库的设计思路直接套过来。比如之前用MySQL时,一个订单拆成订单表、商品表、用户表,用外键关联,到了NoSQL里也想这么干,结果要么查得慢,要么写得麻烦。这背后就是两种思维的本质差异,得先掰明白。

1.1 关系思维是“拆分关联”

关系思维的核心是“拆分再关联”,就像你整理家里的东西,把衣服、鞋子、电器分别放不同柜子,找的时候再从不同柜子里取出来拼。比如电商里的订单场景,关系型数据库会这么设计:

  • 用户表:存用户ID、姓名、手机号
  • 商品表:存商品ID、名称、价格
  • 订单表:存订单ID、用户ID、商品ID、下单时间、金额

要查一个用户的所有订单,就得先查用户表拿到用户ID,再查订单表关联用户ID,再查商品表关联商品ID,把三个表的结果拼起来。这种设计的好处是数据不重复,改的时候只改一个地方,但坏处是查的时候要“连表”,数据量大的时候会很慢。

1.2 NoSQL思维是“聚合冗余”

NoSQL思维的核心是“聚合冗余”,就是把经常要一起用的数据,尽量放在一起,避免多次查询。还是刚才的电商场景,NoSQL里的订单数据,会把用户的基本信息、商品的基本信息都塞进订单的记录里,不用再去别的地方找。

比如一个订单的NoSQL记录可能是这样的:

{
  "订单ID": "ORD001",
  "用户ID": "U001",
  "用户姓名": "张三",
  "用户手机号": "13800138000",
  "商品ID": "P001",
  "商品名称": "纯棉T恤",
  "商品价格": 99,
  "下单时间": "2024-05-20T12:00:00Z",
  "金额": 99
}

这种设计的好处是查一个订单的所有信息,只需要一次查询,速度特别快;坏处是如果用户改了手机号,所有关联的订单都要改,但实际业务里,订单一旦生成,用户的手机号、商品的价格这些信息一般不会变,所以冗余的影响很小,换来的性能提升却很大。

二、DynamoDB的核心:单表设计

DynamoDB是AWS推出的NoSQL数据库,它的最佳实践就是“单表设计”——就是把整个系统的所有数据,都放在一个表里。这和关系型数据库的“多表设计”完全相反,很多人刚听到会觉得不可思议,但其实这是NoSQL思维的极致体现。

2.1 单表设计的核心概念

要理解单表设计,得先搞懂DynamoDB的两个核心概念:分区键(Partition Key)和排序键(Sort Key)。

  • 分区键:用来决定数据存在哪个物理分区里,就像图书馆里的书架编号,相同分区键的数据会放在同一个分区。DynamoDB会根据分区键自动分配分区,所以分区键的选择直接影响数据的均匀分布,避免某个分区数据太多(热点问题)。
  • 排序键:用来给同一个分区里的数据排序,就像同一个书架里的书按书名排序。排序键可以是任意类型的字符串,比如时间、ID、状态等。

这两个键组合起来,就是一条数据的唯一标识(主键)。

2.2 为什么要单表设计?

举个例子,假设你要做一个电商系统,需要支持以下查询:

  1. 查某个用户的所有订单
  2. 查某个商品的所有订单
  3. 查某个用户在某个时间段内的订单
  4. 查某个订单的详细信息

如果用多表设计,可能要做订单表、用户订单表、商品订单表,每个表都存一份订单数据,冗余更多,维护也麻烦。而单表设计可以把所有查询的需求,都通过合理设计分区键和排序键来满足,不用多表。

三、合理设计分区键和排序键

分区键和排序键的设计是单表设计的灵魂,设计得好,查询快、性能稳;设计得不好,要么查不出来,要么性能暴跌。

3.1 分区键的设计原则:热点规避

分区键的核心是“让数据均匀分布”,不能让某个分区的数据特别多。比如如果把“用户ID”作为分区键,每个用户的订单都会存在同一个分区,如果某个大V用户有10万条订单,这个分区就会特别大,查询这个用户的订单时,性能会下降。

那怎么设计分区键?常用的方法是“前缀拆分”或者“哈希后缀”。比如把分区键设计成“用户ID#商品ID”,这样同一个用户的订单会分散到不同的分区(因为商品ID不同),同一个商品的订单也会分散到不同的分区(因为用户ID不同),数据就均匀了。

3.2 排序键的设计原则:查询灵活

排序键的核心是“支持你需要的所有查询”。排序键可以是任意字符串,所以我们可以用“组合排序键”,把多个字段拼在一起,用分隔符(比如#、_)分开。

比如排序键设计成“下单时间#订单ID#订单状态”,这样就可以支持:

  • 查某个用户在某个时间段内的订单:比如要查U001在2024年5月的订单,就可以用“U001”作为分区键,排序键大于等于“2024-05-01T00:00:00Z”,小于“2024-06-01T00:00:00Z”,就能快速查到。
  • 查某个用户的所有已完成订单:排序键可以用“2024-05-20T12:00:00Z#ORD001#已完成”,可以筛选状态。

3.3 完整示例:电商订单的单表设计

下面是一个完整的DynamoDB单表设计示例,技术栈为DynamoDB(JSON格式表示表结构和数据)。

首先,定义表的结构:

{
  "表名": "电商系统单表",
  "主键": {
    "分区键": "PK",
    "排序键": "SK"
  }
}

然后,我们把不同类型的数据,通过设计PK和SK来区分:

  1. 用户数据:PK为“USER#用户ID”,SK为“USER#用户ID” 示例数据:

    {
      "PK": "USER#U001",
      "SK": "USER#U001",
      "类型": "用户",
      "姓名": "张三",
      "手机号": "13800138000",
      "注册时间": "2024-01-01T10:00:00Z"
    }
    
  2. 商品数据:PK为“PROD#商品ID”,SK为“PROD#商品ID” 示例数据:

    {
      "PK": "PROD#P001",
      "SK": "PROD#P001",
      "类型": "商品",
      "名称": "纯棉T恤",
      "价格": 99,
      "库存": 100
    }
    
  3. 订单数据:PK为“ORDER#订单ID”,SK为“ORDER#订单ID” 示例数据:

    {
      "PK": "ORDER#ORD001",
      "SK": "ORDER#ORD001",
      "类型": "订单",
      "用户ID": "U001",
      "商品ID": "P001",
      "下单时间": "2024-05-20T12:00:00Z",
      "金额": 99,
      "状态": "已完成"
    }
    

现在,我们来看看怎么用这个设计满足之前的四个查询需求:

  1. 查某个用户的所有订单:可以通过二级索引来实现(后面会讲)。
  2. 查某个商品的所有订单:同样用二级索引。
  3. 查某个用户在某个时间段内的订单:二级索引的SK设计成“下单时间#订单ID”,就可以按时间范围查询。
  4. 查某个订单的详细信息:直接用PK“ORDER#ORD001”查询,一次搞定。

四、二级索引:扩展查询能力

DynamoDB的二级索引(Secondary Index),相当于给表加了一个新的“视图”,可以用不同的分区键和排序键来组织数据,支持更多的查询方式。

4.1 二级索引的类型

DynamoDB有两种二级索引:

  • 全局二级索引(GSI):可以用任意字段作为分区键和排序键,相当于一个新的表,数据会自动同步到GSI里。
  • 本地二级索引(LSI):只能用原表的分区键作为自己的分区键,排序键可以是任意字段,适合同一个分区内的不同排序需求。

4.2 示例:给订单数据加GSI

还是刚才的电商订单场景,我们需要查某个用户的所有订单,就可以加一个GSI:

{
  "索引名": "GSI_User_Orders",
  "索引的分区键": "GSI_PK",
  "索引的排序键": "GSI_SK"
}

然后,修改订单数据,加上GSI的分区键和排序键:

{
  "PK": "ORDER#ORD001",
  "SK": "ORDER#ORD001",
  "类型": "订单",
  "用户ID": "U001",
  "商品ID": "P001",
  "下单时间": "2024-05-20T12:00:00Z",
  "金额": 99,
  "状态": "已完成",
  "GSI_PK": "USER#U001", // GSI的分区键是用户ID
  "GSI_SK": "2024-05-20T12:00:00Z#ORD001" // GSI的排序键是下单时间#订单ID
}

现在,要查用户U001的所有订单,就可以直接查GSI:

{
  "索引名": "GSI_User_Orders",
  "键条件表达式": "GSI_PK = :user_id AND GSI_SK > :start_time",
  "表达式属性值": {
    ":user_id": "USER#U001",
    ":start_time": "2024-01-01T00:00:00Z"
  }
}

要查用户U001在2024年5月的订单,只需要修改GSI_SK的范围:

{
  "索引名": "GSI_User_Orders",
  "键条件表达式": "GSI_PK = :user_id AND GSI_SK BETWEEN :start_time AND :end_time",
  "表达式属性值": {
    ":user_id": "USER#U001",
    ":start_time": "2024-05-01T00:00:00Z",
    ":end_time": "2024-05-31T23:59:59Z"
  }
}

同样的,要查某个商品的所有订单,可以再加一个GSI:

{
  "索引名": "GSI_Product_Orders",
  "索引的分区键": "GSI_PK_Prod",
  "索引的排序键": "GSI_SK_Prod"
}

订单数据里加上:

"GSI_PK_Prod": "PROD#P001",
"GSI_SK_Prod": "2024-05-20T12:00:00Z#ORD001"

查商品P001的所有订单,就可以用这个GSI查询。

五、应用场景、优缺点和注意事项

5.1 应用场景

这种设计特别适合以下场景:

  • 读多写少的系统:比如电商的订单查询、用户行为分析、内容管理系统(CMS)等,大部分操作是查询,写操作相对较少。
  • 数据量巨大的系统:比如社交平台的用户动态、物联网(IoT)的设备数据,需要处理百万甚至亿级的数据,DynamoDB的单表设计可以保证高性能。
  • 对查询延迟要求高的系统:比如实时推荐、实时监控,需要毫秒级的查询响应时间。

5.2 技术优缺点

优点

  1. 性能高:单表设计避免了连表查询,一次查询就能拿到需要的数据,延迟低。
  2. 成本低:DynamoDB的计费方式是按读/写容量单位收费,单表设计减少了查询次数,也就降低了成本。
  3. 扩展性好:DynamoDB是分布式数据库,单表设计可以自动扩展到任意大小,不用担心数据量增长带来的性能问题。

缺点

  1. 设计难度大:需要提前梳理所有的查询需求,合理设计分区键、排序键和二级索引,对开发者的要求比较高。
  2. 数据冗余:为了支持查询,会有一定的数据冗余,比如同一个订单会出现在多个GSI里。
  3. 不适合复杂事务:DynamoDB的事务支持比较有限,不适合需要多表事务的场景(比如银行转账、订单支付的强一致性事务)。

5.3 注意事项

  1. 避免热点分区:分区键的设计一定要保证数据均匀分布,不能让某个分区的请求量过大。如果发现热点问题,可以调整分区键的前缀或后缀。
  2. 二级索引不要太多:GSI的数量是有限制的(最多20个),而且每个GSI都会占用存储和计算资源,所以不要为了每个查询都建一个GSI,尽量合并查询需求。
  3. 排序键的格式要统一:比如时间格式要统一用ISO 8601,分隔符要一致,否则排序和范围查询会出错。
  4. 测试查询性能:上线前一定要测试各种查询的性能,比如大量数据下的范围查询、分页查询,确保满足业务需求。

六、文章总结

从关系思维到NoSQL思维的转变,核心是从“拆分关联”到“聚合冗余”的认知升级。DynamoDB的单表设计,正是这种思维的最佳实践,通过合理设计分区键、排序键和二级索引,可以用一个表满足大部分业务的查询需求,实现高性能、高扩展的系统。

这种设计方法不是万能的,它适合读多写少、数据量大、对延迟要求高的场景,但不适合复杂事务的场景。开发者需要根据自己的业务需求,权衡利弊,选择合适的设计方案。

最后要强调的是,NoSQL的设计没有绝对的“标准答案”,关键是理解背后的思维逻辑,结合业务场景灵活调整,才能设计出高效、稳定的系统。