## 5.6 文档数据库的结构设计

本节将主要介绍文档数据库的结构设计。

### 5.6.1 文档结构设计规则

文档数据库的逻辑结构设计其实是将概念设计阶段设计好的基于ERD的概念模型转换成文档模型，它的要点是如何将ERD的实体、实体的属性和实体之间的联系转换成文档模式。所谓的文档模式是对文档集的描述。


文档数据库支持嵌套文档、数组以及文档数组等多种结构，因此基于ERD概念模型生成的文档模型也是多种多样的。目前，没有一套文档数据库结构设计的统一范式。我们这里只介绍ERD转换成文档模型的常用规则。

* 实体转换规则：
  * 若文档模型中不存在嵌套文档，则一个实体转换为一个文档集。例如，图5-1-1中的员工实体转换为员工文档集（employee），项目实体转换为项目文档集（project）；
  * 若文档模型中存在嵌套文档，则多个实体转换为一个文档集。例如，图5-1-1中的员工实体和项目实体可以转换为一个文档集（emplyee-project）。

* 属性转换规则：
  * 实体的唯一属性、单值属性、多值属性转换成文档模式的属性，其中多值属性以数组的形式表示。假设文档模型中不存在嵌套文档，图5-1-1中员工实体和项目实体的文档结构如下：

    ```SQL
    员工employee的文档结构
    {
      "sno": " ",   //工号
      "name": " ", 	//名字
      "skill": [" "," ", ""]  //技能
    }
    
    项目project的文档结构
    {
      "pno": " ",   //项目号
      "funds": " " 	//经费
    }
    ```
  
* 联系转换规则：
  * 若文档模型中不存在嵌套文档，则实体之间的1:1联系可以与任意一端的文档模式合并，合并端的文档模式中加入另一端实体的唯一性属性和联系本身的属性；实体之间的1:n联系与n端的文档模式合并，在n端的文档模式中加入1端实体的唯一性属性和联系本身的属性；实体之间的m:n联系转换为一个独立的文档模式，联系两端实体型的唯一性属性和联系本身的属性构成该文档模式的属性。例如，图5-1-1中的“参与”联系是m:n联系，该联系转换成一个单独的文档集（work），work文档集中的文档结构如下：
      ```SQL
    work的文档结构
    {
      "sno": " ",   //员工工号
      "pno": " ", 	//项目号
      "working-hours": " " //工时
    }
    ```
  * 若文档模型中存在嵌套文档，嵌套文档的形式能自然地存储实体之间的一对一、一对多、多对多联系。如果实体之间是1:1联系，那么任意一端的实体嵌入另一端文档模式；如果实体之间是1:n联系，那么n端的实体嵌入1端的文档模式，以文档数组的形式表示；如果实体之间是m:n联系，那么任意一端的实体嵌入另一端文档模式，以文档数组的形式表示。例如，图5-1-1中的员工和项目之间“参与”联系，将项目嵌入员工的文档结构如下：
      ```SQL
    employee-project的文档结构
    {
      "sno": " ",   //工号
      "name": " ", 	//名字
      "skill": [" "," ", ""]  //技能
      "project":[
        {
          "pno": " ",   //项目号
      	  "funds": " " ,	//经费
      	  "working-hours": " " //工时
        },
        ...
      ]
    }
    ```

### 5.6.2 文档结构设计

基于以上的规则，我们能够进行文档数据库的逻辑结构设计，确定某一特定应用数据库中的数据应该如何表示和组织。以下以图5-1-3(b)购物网站的ERD概念模型为例，展示文档数据库的逻辑结构设计过程。

如果按“文档模型中不存在嵌套文档”的规则，可以得到以下的文档模式设计一：

```SQL
购物网站的文档模式设计一：
用户文档集：User{Uid, Uname, Uadd, Tel, Pref[]};
商品文档集：Product{Pid, Pname, Category, Price, Padd};
订单文档集：Order{Oid, Uid, Date}；
订单详情文档集：OrderLine{Oid, Pid, Quantity}；
```
* 首先，图5-1-2中的用户、商品、订单三个实体分别转换成User、Product、Order三个文档集，实体的唯一性属性、单值属性和多值属性构成文档模式的属性。因此，生成用户文档集User{Uid, Uname, Uadd, Tel, Pref[]}，其中多值属性“爱好”用数组形式表示，商品文档集Product{Pid, Pname, Category, Price, Padd}，订单文档集Order{Oid, Date}；

* 然后，用户和订单之间的一对多联系通过将用户的用户号加入订单文档集中实现转换，实现联系转换的订单文档集模式为Order{Oid, Uid, Date}；

* 最后，商品和订单之间的多对多联系转换成订单详细文档集OrderLine，订单的唯一属性、商品的唯一属性以及联系的自身属性构成该文档集的属性，因此，订单详细文档集模式为OrderLine{Oid, Pid, Quantity}。

值得注意的是，除了文档模式设计中的属性之外，文档数据库会为每一个文档自动分配一个唯一标识的“\_id”属性。

如果按“文档模型中存在嵌套文档”的规则，可以得到以下三种文档模式设计：

```SQL
购物网站的文档模式设计二：
用户文档集：User{Uid, Uname, Uadd, Tel, Pref[]};
商品文档集：Product{Pid, Pname, Category, Price, Padd};
订单文档集：Order{Oid, Uid, Date,OrderLine[{Pid, Quantity}]}；

购物网站的文档模式设计三：
用户文档集：User{Uid, Uname, Uadd, Tel, Pref[], 
				Order[{
				  Oid, Date,OrderLine[{Pid,Quantity}]
				}]
			};
商品文档集：Product{Pid, Pname, Category, Price, Padd};

购物网站的文档模式设计四：
用户文档集：User{Uid, Uname, Uadd, Tel, Pref[], 
				Order[{
				  Oid, Date,
				  OrderLine[{
					  Product{Pid, Pname, Category, Price, Padd},
					  Quantity}]
				}]
			};
```

文档模式设计二在设计一的基础上，以嵌套文档的结构表示订单和商品之间的多对多联系。首先，将订单和商品之间的m:n联系转换成订单详细文档集OrderLine，订单详细文档集只包含商品的唯一属性和联系的自身属性，其模式为OrderLine{Pid,Quantity}，然后将OrderLine以文档数组的方式嵌入订单文档集中，因此订单文档集的模式为Order{Oid, Uid, Date,OrderLine[{Pid, Quantity}]}。

文档模式设计三在设计二的基础上，将订单文档集Order以文档数组的形式嵌入用户文档集。

文档模式设计四在设计三的基础上，将商品文档集Product以文档数组的形式嵌入用户文档集中的OrderLine。

那么，哪一种文档模式更合理呢？这需要根据购物网站的业务流程和应用功能进行判断。如果某种文档组织方式使得实现应用功能更加简单，性能更高，那么该文档组织方式则更优。

通过上述例子，我们发现文档数据库的结构设计相当灵活的，没有一套固定的标准，需要根据应用的实际需求选择最适合的文档模式。

### 5.6.3 合理利用数据冗余

为了能够进一步提高应用的性能，文档结构设计有时需要加入必要的冗余数据 。加入的冗余数据不会消耗过多的存储空间，不会产生额外的更新代价，且经常被查询，有助于提升查询效率。

例如，在购物网站中，商家需要经常统计某个商品的总销售量。基于购物网站的文档模式设计二，MongoDB数据库的查询语句如下所示：

```SQL
基于购物网站的文档模式设计二，统计某个商品的总销售量，其查询语句如下：
db.Order.aggregate( [ 
        {
            $unwind:{"$OrderLine"} /*将OrderLine数组展开为单独的文档*/
        },  
        {
            $match:{"OrderLine.Pid":"某具体商品号 "} /*过滤，确保只处理某商品*/
        },
        {
            $group:{
                "_id":"$OrderLine.Pid", /*按商品号分组*/
                "totalQuantity":{$sum:"$OrderLine.Quantity"} /*计算商品的总销售量*/
             }
        }
] )
```

上述查询需要使用文档数据库的聚合计算，需要经过\$unwind，\$match，\$group等多个阶段的处理才能得到最终的结果。在$unwind阶段，需要对订单文档集Order进行全文档扫描，因此查询效率很低。

为了提高查询效率，我们可以在商品文档集Product中冗余存储商品的总销售量TotalQuantity属性。优化之后的文档模式设计二及其查询语句如下：

```SQL
优化之后的购物网站的文档模式设计二
用户文档集：User{Uid, Uname, Uadd, Tel, Pref[]};
商品文档集：Product{Pid, Pname, Category, Price, Padd, TotalQuantity};
订单文档集：Order{Oid, Uid, Date,OrderLine[{Pid, Quantity}]}；

统计某个商品的总销售量，其查询如下：
db.Prodect.find(
	{"Pid":"具体商品号 "},
    {"TotalQuantity":1}
)
每次产生新订单时，需要更新购买商品的总销售量：
db.Product.updateOne(
    {"Pid":" "},
    {$inc: {"TotalQuantity":"购买数量"}}
)
```

在商品文档集中冗余存储商品的总销售量TotalQuantity，能够避免聚合查询中对Order文档集的全文档扫描，从而使得查询语句的执行效率非常高。而冗余存储带来的代价是每次购买商品时需要同步更新该商品的总销售量。如果更新操作对应用性能的影响比较小，那么数据冗余是可接受的。此外，冗余存储商品的总销售量，也便于计算商品的总销售额，否则计算商品的总销售额需要将Order文档集和Product商品文档集进行连接操作。

### 练习题

**1**. 在数据库设计中加入冗余属性可以有什么好处？

 <ol type="A">
  <li>有利于提升数据查询的性能</li>
  <li>有利于提升数据更新的性能</li>
  <li>有利于数据库的维护</li>
  <li>有利于降低软件开发的复杂度</li>
 </ol>

**2**. 我们在描述人的文档中记录他以导演、演员或编剧的身份参与过的影片。一种方式是按照身份对影片进行分类 {导演的影片[{...}], 演员的影片[{...}], 编剧的影片[{...}]}。另一种方式是在每一部影片中对参与方式进行标注 {影片[{..., 参与方式:(导演/演员/编剧)...}]}。对这两种方式的优缺点描述正确的是：

 <ol type="A">
  <li>前一种方式便于统计一个人参与影片的个数</li>
  <li>前一种方式便于查找一个人是否参与过某部影片</li>
  <li>前一种方式便于统计一个人导演过影片的个数</li>
  <li>前一种方式比后一种方式几乎没有优势</li>
 </ol>

**3**. 什么时候适合将一种对象嵌入到另一种对象中存储？

<ol type="A">
  <li>子对象对父对象有明确的依附关系，比如一个书中的章节或者一个账户中的优惠券。</li>
  <li>应用总是通过父对象去访问子对象，比如一篇文章的评论</li>
  <li>一个子对象只属于唯一一个父对象，否则子对象会被多次存储</li>
  <li>以上皆是</li>
 </ol>

[**上一页<<**](chapter5.5-R.md) | [**>>下一页**](chapter5.7-D.md)
