database_tutorial

第9章:NoSQL数据库

章节大纲

9.1 引言

9.2 文档存储原理

9.3 列族存储设计

9.4 图数据库算法

9.5 时序数据库优化

9.6 多模型数据库

9.7 本章小结

9.8 常见陷阱与错误

9.9 练习题


9.1 引言

NoSQL数据库的兴起源于互联网规模应用对传统关系型数据库的挑战。当面对数十亿用户、PB级数据、毫秒级响应要求时,传统的ACID事务模型和严格的模式约束成为了扩展性的瓶颈。本章深入探讨各类NoSQL数据库的设计原理、实现技术和最佳实践,帮助您理解如何为不同的数据模型和访问模式选择合适的存储方案。

NoSQL的演进历程

NoSQL的发展经历了三个关键阶段:

第一代(2000-2005):Memcached、Berkeley DB等简单键值存储,主要解决缓存问题。这一代系统功能单一,主要作为关系型数据库的补充。

第二代(2006-2010):Google BigTable、Amazon Dynamo论文引发革命,Cassandra、HBase、MongoDB等系统涌现。这一代开始探索不同的数据模型和一致性权衡。

第三代(2011-现在):NewSQL和多模型数据库兴起,如CockroachDB、FaunaDB。追求在保持可扩展性的同时提供更强的一致性保证和SQL支持。

NoSQL的核心理念

NoSQL(Not Only SQL)并非完全抛弃SQL,而是针对特定场景优化的数据管理方案。其核心理念包括:

  1. 模式灵活性:无需预定义严格的表结构,支持半结构化和非结构化数据
  2. 水平扩展性:通过增加节点而非升级硬件来提升容量和性能
  3. 最终一致性:放松一致性要求以换取可用性和分区容错性
  4. 针对性优化:为特定数据模型和访问模式深度优化
  5. 开发者友好:提供更贴近应用程序数据结构的API

CAP理论深度解析

CAP理论由Eric Brewer提出,Seth Gilbert和Nancy Lynch给出了正式证明。理解CAP需要明确各项的准确含义:

一致性(Consistency):所有节点在同一时刻看到相同的数据。这里指的是线性一致性(Linearizability),是最强的一致性保证。

可用性(Availability):每个请求都能收到响应,不管成功或失败。注意这要求所有非故障节点都必须响应。

分区容错性(Partition Tolerance):系统在网络分区的情况下继续运行。在分布式系统中,网络分区是不可避免的,因此P实际上是必选项。

        一致性(C)
           /\
          /  \
         /    \
        /  CA  \  ← 单机系统
       /        \
      /    P     \
     /            \
    /      CP  AP  \
   /________________\
可用性(A)        分区容错(P)

实际选择:
CP系统:HBase, MongoDB (主节点模式)
AP系统:Cassandra, DynamoDB
CA系统:单机关系型数据库(不考虑网络分区)

CAP的细微差别

实践中的CAP权衡比理论更复杂:

  1. 可调一致性:许多系统允许per-request选择一致性级别
    • Cassandra: ONE, QUORUM, ALL
    • DynamoDB: Eventually Consistent, Strongly Consistent
  2. 部分可用性:系统可能降级运行而非完全不可用
    • 只读模式
    • 限流保护
    • 优先级队列
  3. 一致性谱系
    强 ← → 弱
    线性一致性 > 顺序一致性 > 因果一致性 > 最终一致性
    

BASE原则的实践含义

BASE(Basically Available, Soft state, Eventual consistency)是NoSQL系统的设计原则:

NoSQL vs RDBMS的技术权衡

维度 RDBMS NoSQL
数据模型 固定模式,规范化 灵活模式,反规范化
查询能力 复杂SQL,JOIN支持 简单查询,应用层JOIN
事务支持 ACID,强一致性 BASE,最终一致性
扩展方式 垂直扩展为主 水平扩展为主
性能特点 复杂查询优化 简单操作高吞吐
运维成本 单机简单,集群复杂 分布式原生,自动化程度高

Rule of Thumb

选择NoSQL数据库的决策框架:

9.2 文档存储原理

文档数据库将数据组织为文档集合,每个文档是自包含的数据单元,通常以JSON、BSON或XML格式存储。MongoDB、CouchDB和Amazon DocumentDB是典型代表。文档模型特别适合处理层次化、半结构化的数据,避免了关系型数据库中复杂的JOIN操作。

文档模型的哲学

文档数据库的设计哲学源于三个关键观察:

  1. 应用数据的自然形态:大多数应用程序处理的是对象或文档,而非扁平的行
  2. 访问局部性原理:相关数据通常一起访问,应该存储在一起
  3. 模式演化的需求:业务需求变化快,数据结构需要灵活演进

文档模型设计

文档模型的核心优势是其嵌套结构能够自然地表达复杂对象:

{
  "_id": "user_123",
  "name": "张三",
  "profile": {
    "age": 28,
    "email": "zhang@example.com",
    "preferences": {
      "language": "zh-CN",
      "timezone": "Asia/Shanghai",
      "notifications": {
        "email": true,
        "sms": false,
        "push": true
      }
    },
    "addresses": [
      {
        "type": "home",
        "city": "北京",
        "street": "中关村大街1号",
        "primary": true,
        "coordinates": {
          "lat": 39.9042,
          "lng": 116.4074
        }
      },
      {
        "type": "work",
        "city": "上海",
        "street": "南京路100号",
        "primary": false
      }
    ]
  },
  "orders": [
    {
      "order_id": "ord_456",
      "date": "2024-01-15",
      "status": "delivered",
      "total": 299.99,
      "items": [
        {
          "product_id": "prod_789",
          "name": "无线耳机",
          "price": 299.99,
          "quantity": 1
        }
      ]
    }
  ],
  "created_at": "2023-01-01T00:00:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

设计模式与反模式

有效的设计模式:

  1. 嵌入模式(Embedding)
    // 好的例子:用户和其设置
    {
      "user_id": "123",
      "settings": {
        "theme": "dark",
        "language": "zh-CN"
      }
    }
    

    适用场景:

    • 一对一关系
    • 一对少关系(<100)
    • 数据总是一起访问
    • 子文档不会无限增长
  2. 引用模式(Referencing)
    // 用户文档
    {
      "user_id": "123",
      "name": "张三",
      "post_ids": ["post_1", "post_2", "post_3"]
    }
    // 独立的帖子文档
    {
      "post_id": "post_1",
      "author_id": "123",
      "content": "..."
    }
    

    适用场景:

    • 一对多关系(>1000)
    • 数据独立访问和更新
    • 避免文档过大
  3. 混合模式(Hybrid)
    // 存储最近N条,完整列表用引用
    {
      "user_id": "123",
      "recent_orders": [
        {"order_id": "o1", "date": "2024-01", "total": 100},
        {"order_id": "o2", "date": "2024-01", "total": 200}
      ],
      "all_order_ids": ["o1", "o2", "o3", "o4", ...]
    }
    
  4. 桶模式(Bucket Pattern)
    // 时序数据分桶存储
    {
      "sensor_id": "sensor_001",
      "start_time": "2024-01-15T00:00:00Z",
      "end_time": "2024-01-15T01:00:00Z",
      "measurements": [
        {"timestamp": "00:00:00", "temp": 25.1},
        {"timestamp": "00:00:10", "temp": 25.2},
        // ... 最多360个测量值(1小时,10秒间隔)
      ],
      "count": 360,
      "sum_temp": 9036,
      "min_temp": 24.8,
      "max_temp": 25.5
    }
    

设计原则:

  1. 嵌入 vs 引用决策树
    数据关系分析
         │
         ├─ 是否一起访问?
         │   ├─ 总是 → 考虑嵌入
         │   └─ 偶尔 → 考虑引用
         │
         ├─ 更新频率?
         │   ├─ 子文档独立更新 → 引用
         │   └─ 一起更新 → 嵌入
         │
         ├─ 数据大小?
         │   ├─ 可能超16MB → 引用
         │   └─ 远小于16MB → 嵌入
         │
         └─ 一对多基数?
             ├─ <100 → 嵌入
             ├─ 100-1000 → 混合
             └─ >1000 → 引用
    
  2. 文档大小限制
    • MongoDB限制单文档16MB
    • 建议控制在1MB以内以优化性能
    • 使用GridFS存储大文件
  3. 原子性边界
    • 文档是原子操作的最小单位
    • 需要事务的数据应在同一文档
    • MongoDB 4.0+支持多文档事务,但有性能开销

存储格式:BSON

BSON(Binary JSON)是JSON的二进制编码,提供了额外的数据类型和高效的遍历:

BSON文档结构:
┌────────────┬────────────┬────────────┬─────┐
│  总长度    │  元素1     │  元素2     │ ... │ 
│  (4字节)   │            │            │     │
└────────────┴────────────┴────────────┴─────┘

元素结构:
┌──────┬───────────┬───────────┬───────┐
│ 类型 │  字段名   │    值     │  \x00 │
│(1字节)│  (字符串) │  (变长)   │       │
└──────┴───────────┴───────────┴───────┘

BSON的优势:

索引策略

文档数据库的索引比关系型数据库更加灵活:

  1. 单字段索引:最基本的索引类型
    db.users.createIndex({"name": 1})
    
  2. 复合索引:多个字段的组合索引,遵循最左前缀原则
    db.users.createIndex({"profile.age": 1, "profile.city": 1})
    
  3. 多键索引:自动为数组字段的每个元素创建索引项
    db.users.createIndex({"profile.addresses.city": 1})
    
  4. 文本索引:支持全文搜索
    db.articles.createIndex({"content": "text"})
    
  5. 地理空间索引:支持地理位置查询
    db.places.createIndex({"location": "2dsphere"})
    

查询处理

文档数据库的查询处理需要处理嵌套结构和数组:

查询计划生成:

查询:db.users.find({"profile.age": {$gt: 25}, "orders.total": {$gt: 1000}})

执行计划:
┌─────────────────┐
│   Collection    │
│     Scan        │
└────────┬────────┘
         │
    ┌────▼────┐
    │ Filter  │ profile.age > 25
    └────┬────┘
         │
    ┌────▼────┐
    │ Filter  │ orders.total > 1000
    └────┬────┘
         │
    ┌────▼────┐
    │ Project │
    └─────────┘

查询优化技术:

  1. 索引选择:基于选择性和覆盖度选择最优索引
  2. 查询重写:将复杂查询分解为简单操作
  3. 管道优化:在聚合管道中尽早过滤和投影

分片与复制

文档数据库通过分片实现水平扩展:

分片架构:
┌──────────────────────────────────────┐
│          应用程序                     │
└────────────┬─────────────────────────┘
             │
    ┌────────▼────────┐
    │   Mongos路由    │
    └────┬───────┬────┘
         │       │
    ┌────▼───┐ ┌▼─────┐
    │ Shard1 │ │Shard2│
    │ RS1    │ │ RS2  │
    └────────┘ └──────┘

分片键选择原则:

  1. 高基数:分片键应有足够多的不同值
  2. 写分散:避免所有写入集中到单个分片
  3. 查询隔离:常见查询应该能够定位到特定分片

复制集机制:

9.3 列族存储设计

列族存储(Wide Column Store)如Cassandra、HBase和Amazon DynamoDB,采用了不同于行存储的数据组织方式,特别适合处理稀疏数据和高吞吐量写入。

数据模型

列族存储的数据模型可以理解为一个多维映射:

(RowKey, ColumnFamily:Column, Timestamp) → Value

逻辑视图:

RowKey: user_123
┌─────────────────────────────────────────────────────┐
│ ColumnFamily: profile                               │
├──────────────┬──────────────┬──────────────────────┤
│ name:        │ age:         │ email:               │
│ "张三"       │ 28           │ "zhang@example.com"  │
├──────────────┴──────────────┴──────────────────────┤
│ ColumnFamily: activity                              │
├──────────────┬──────────────┬──────────────────────┤
│ login:2024:01│ login:2024:02│ purchase:2024:01:15  │
│ 15           │ 8            │ "order_456"          │
└──────────────┴──────────────┴──────────────────────┘

设计特点:

  1. 稀疏性:不存在的列不占用存储空间
  2. 动态列:可以在运行时添加新列,无需改变模式
  3. 列族分组:相关列组织在一起,提高局部性
  4. 版本控制:每个单元格可以保存多个时间戳版本

LSM树与SSTable

列族存储普遍采用LSM(Log-Structured Merge)树结构:

写入路径:
          写入请求
              │
              ▼
         ┌─────────┐
         │ MemTable│ (内存,可变)
         └────┬────┘
              │ 达到阈值
              ▼
         ┌─────────┐
         │SSTable 0│ (磁盘,不可变)
         └────┬────┘
              │ Compaction
              ▼
    ┌─────────────────────┐
    │   SSTable Level 1   │
    └──────────┬──────────┘
               │
         ┌─────▼─────┐
         │  Level 2  │
         └───────────┘

SSTable(Sorted String Table)结构:

┌──────────────────────────────────────┐
│          数据块 (Data Blocks)         │
│  ┌────────┬────────┬────────┐       │
│  │ Block1 │ Block2 │ Block3 │ ...   │
│  └────────┴────────┴────────┘       │
├──────────────────────────────────────┤
│         索引块 (Index Block)          │
│    记录每个数据块的起始key            │
├──────────────────────────────────────┤
│      布隆过滤器 (Bloom Filter)        │
│    快速判断key是否可能存在            │
├──────────────────────────────────────┤
│         元数据 (Metadata)             │
│    文件信息、统计数据等               │
└──────────────────────────────────────┘

Compaction策略

Compaction是LSM树的核心操作,用于合并SSTable和清理过期数据:

  1. Size-Tiered Compaction
    • 相似大小的SSTable合并
    • 写入放大较小,但空间放大较大
    • 适合写密集型工作负载
  2. Leveled Compaction
    • SSTable分层,每层大小呈指数增长
    • 空间放大小,但写入放大大
    • 适合读密集型工作负载
  3. Time-Window Compaction
    • 按时间窗口组织SSTable
    • 适合时序数据,便于过期数据清理

Bloom Filter优化

布隆过滤器用于快速判断key是否可能存在于SSTable中:

布隆过滤器原理:
输入: key = "user_123"
      ↓
   哈希函数
  h1, h2, h3
      ↓
位数组: [0,1,0,1,1,0,1,0,1,0,1,1]
         ↑   ↑     ↑
        h1  h2    h3

判断:所有位置都为1 → 可能存在
     任一位置为0 → 肯定不存在

参数调优:

读写路径优化

写入优化:

  1. 批量写入:将多个写操作合并,减少I/O次数
  2. 预写日志:先写WAL保证持久性,再写MemTable
  3. 异步刷新:后台线程负责将MemTable刷新到磁盘

读取优化:

  1. 多级缓存
    读取请求
        ↓
    MemTable (最新数据)
        ↓ 未命中
    Block Cache (热数据块)
        ↓ 未命中
    SSTable (磁盘文件)
    
  2. 索引优化
    • 稀疏索引减少内存占用
    • 前缀压缩减少索引大小
    • 多级索引支持大文件
  3. 读取合并: 从多个SSTable读取数据并合并,返回最新版本

压缩策略

列族存储的列式组织天然适合压缩:

  1. 前缀压缩:相邻key共享前缀
    原始: user_001_profile, user_001_activity, user_002_profile
    压缩: user_001_profile, [8]activity, user_002_profile
    
  2. 字典编码:高重复值用字典替换
    原始: Beijing, Shanghai, Beijing, Beijing, Shanghai
    字典: {0: Beijing, 1: Shanghai}
    编码: 0, 1, 0, 0, 1
    
  3. 游程编码:连续相同值压缩
    原始: 0,0,0,0,1,1,1,2,2,2,2,2
    编码: (0,4), (1,3), (2,5)
    

Rule of Thumb

9.4 图数据库算法

图数据库如Neo4j、Amazon Neptune和ArangoDB专门用于存储和查询高度互联的数据。社交网络、知识图谱、推荐系统等场景中,实体间的关系往往比实体本身更重要。

图存储模型

图数据库主要有两种存储模型:

1. 原生图存储(Native Graph Storage)

节点存储:
┌────────┬────────┬──────────┬────────────┐
│ NodeID │ Labels │Properties│ Relations  │
├────────┼────────┼──────────┼────────────┤
│   1    │ Person │ {name:   │ →[2,3,4]   │
│        │        │  "张三"} │            │
└────────┴────────┴──────────┴────────────┘

边存储:
┌────────┬────────┬────────┬──────────┬─────────┐
│ EdgeID │  Type  │ Source │  Target  │Properties│
├────────┼────────┼────────┼──────────┼─────────┤
│   101  │ KNOWS  │   1    │    2     │{since:  │
│        │        │        │          │ 2020}   │
└────────┴────────┴────────┴──────────┴─────────┘

2. 索引邻接列表(Index-Free Adjacency)

节点记录:
┌──────────────────────────────────┐
│ NodeID: 1                        │
│ Properties: {name: "张三"}       │
│ FirstOutEdge: →Edge_101          │
│ FirstInEdge: →Edge_205           │
└──────────────────────────────────┘
          │
          ▼
边记录(双向链表):
┌──────────────────────────────────┐
│ EdgeID: 101                      │
│ Type: KNOWS                      │
│ StartNode: →Node_1               │
│ EndNode: →Node_2                 │
│ NextOutEdge: →Edge_102           │
│ NextInEdge: NULL                 │
│ Properties: {since: 2020}        │
└──────────────────────────────────┘

图遍历算法

深度优先搜索(DFS)

DFS(start_node, visited=∅):
    visited.add(start_node)
    for neighbor in start_node.neighbors:
        if neighbor ∉ visited:
            DFS(neighbor, visited)
    
栈实现:
┌─────┐
│  A  │ ← 当前节点
├─────┤
│  B  │
├─────┤
│  C  │
└─────┘

广度优先搜索(BFS)

BFS(start_node):
    queue = [start_node]
    visited = {start_node}
    while queue ≠ ∅:
        node = queue.dequeue()
        for neighbor in node.neighbors:
            if neighbor ∉ visited:
                visited.add(neighbor)
                queue.enqueue(neighbor)

队列实现:
← [D, E, F] ← 出队方向
     ↑
   入队方向

双向搜索优化 从起点和终点同时搜索,相遇即找到路径:

起点搜索空间     终点搜索空间
    ○─○─●           ●─○─○
   /│\  │           │  /│\
  ○─○─○ ●           ● ○─○─○
        ↑           ↑
        └───相遇点──┘
搜索空间:O(b^(d/2)) vs O(b^d)

最短路径算法

Dijkstra算法 适用于非负权重图:

优先队列状态:
┌──────┬──────┬────────┐
│ Node │ Dist │ Parent │
├──────┼──────┼────────┤
│  A   │  0   │  NULL  │
│  B   │  5   │   A    │
│  C   │  8   │   B    │
│  D   │  ∞   │  NULL  │
└──────┴──────┴────────┘

松弛操作:
if dist[u] + weight(u,v) < dist[v]:
    dist[v] = dist[u] + weight(u,v)
    parent[v] = u

A*算法 使用启发式函数加速搜索:

f(n) = g(n) + h(n)
- g(n): 起点到n的实际距离
- h(n): n到终点的估计距离(启发式)

常用启发式函数:
- 欧几里得距离: √((x₂-x₁)² + (y₂-y₁)²)
- 曼哈顿距离: |x₂-x₁| + |y₂-y₁|

Floyd-Warshall算法 计算所有点对最短路径:

for k in 1..n:
    for i in 1..n:
        for j in 1..n:
            dist[i][j] = min(dist[i][j], 
                           dist[i][k] + dist[k][j])

动态规划思想:考虑是否经过中间节点k

社区检测算法

标签传播算法(Label Propagation)

初始化:每个节点唯一标签
迭代:
1. 随机排序节点
2. 每个节点采用邻居中最频繁的标签
3. 重复直到收敛

示例迭代:
迭代0: [1][2][3][4][5]
迭代1: [1][1][3][3][5]
迭代2: [1][1][3][3][3]
收敛:  社区1:{1,2}, 社区2:{3,4,5}

Louvain算法 基于模块度优化的社区检测:

模块度 Q = 1/2m ∑ᵢⱼ[Aᵢⱼ - kᵢkⱼ/2m]δ(cᵢ,cⱼ)

两阶段迭代:
1. 局部移动:节点移动到能最大化模块度的社区
2. 社区聚合:将社区压缩为超节点,构建新图

┌───────┐     ┌───────┐     ┌───┐
│细粒度 │ --> │中粒度 │ --> │粗粒度│
│  社区 │     │  社区 │     │社区│
└───────┘     └───────┘     └───┘

分布式图处理

Pregel模型(Bulk Synchronous Parallel)

超步(Superstep)执行:
┌─────────────────────────────┐
│   Compute (本地计算)         │
├─────────────────────────────┤
│   Send Messages (发送消息)   │
├─────────────────────────────┤
│   Barrier (全局同步)         │
└─────────────────────────────┘
        ↓ 下一超步

顶点状态机:
Active → Compute → Active/Inactive
         ↑              │
         └──收到消息────┘

图分区策略

  1. 边分区(Edge-Cut)
    节点分配到不同分区,边可能跨分区
    分区1: {A, B}  分区2: {C, D}
    跨分区边: B→C
    
  2. 点分区(Vertex-Cut)
    边分配到分区,高度节点可能复制
    分区1: A→B, A→C
    分区2: A→D, B→D
    节点A在两个分区都有副本
    

图查询优化

模式匹配优化

查询模式:
MATCH (a:Person)-[:KNOWS]->(b:Person)-[:LIKES]->(c:Product)
WHERE a.age > 25

执行计划:
1. 索引扫描: Person.age > 25
2. 扩展KNOWS关系
3. 过滤Person标签
4. 扩展LIKES关系
5. 过滤Product标签

优化技术:
- 选择性估计:先处理选择性高的条件
- 关系方向:利用出入度信息
- 标签索引:快速定位特定类型节点

Rule of Thumb

9.5 时序数据库优化

时序数据库(TSDB)如InfluxDB、TimescaleDB、Prometheus专门优化了时间序列数据的存储和查询。物联网、监控系统、金融市场等场景产生的数据具有时间戳、高写入量、查询模式可预测等特点。

时序数据特点

时序数据具有独特的特征,需要专门的优化策略:

  1. 写多读少:写入查询比通常为9:1或更高
  2. 顺序写入:新数据时间戳递增,很少更新历史数据
  3. 批量到达:数据点通常批量产生
  4. 查询模式固定:主要是时间范围查询和聚合操作
  5. 数据生命周期:旧数据价值递减,需要降采样或删除
典型时序数据结构:
┌────────────┬────────────┬────────────┬────────┐
│ Timestamp  │  Metric    │    Tags    │ Value  │
├────────────┼────────────┼────────────┼────────┤
│2024-01-15  │cpu.usage   │host=srv1   │ 78.5   │
│10:30:00    │            │region=east │        │
└────────────┴────────────┴────────────┴────────┘

存储架构

分段存储(Segmented Storage)

时间分段:
├── 2024-01-15/
│   ├── 00-06/ (活跃段,可写)
│   ├── 06-12/ (只读段)
│   └── 12-18/ (只读段)
├── 2024-01-14/
│   └── ... (压缩存储)
└── 2024-01-13/
    └── ... (降采样)

段内结构:
┌─────────────────────────────┐
│    索引文件 (Index)          │
│  metric → series_id映射      │
├─────────────────────────────┤
│    数据文件 (Data)           │
│  时间戳数组 | 值数组         │
├─────────────────────────────┤
│    元数据 (Metadata)         │
│  统计信息、压缩参数         │
└─────────────────────────────┘

列式存储优化

行式存储(传统):
┌──────┬──────┬──────┬──────┐
│ t1   │ m1   │ tag1 │ v1   │
├──────┼──────┼──────┼──────┤
│ t2   │ m1   │ tag1 │ v2   │
└──────┴──────┴──────┴──────┘

列式存储(TSDB):
时间戳列: [t1, t2, t3, ...]
值列:     [v1, v2, v3, ...]
标签索引: tag1 → [series_1, series_2]

优势:
- 同类数据连续,压缩率高
- 批量聚合操作效率高
- 可以独立压缩每列

压缩算法

时序数据的规律性使其特别适合压缩:

1. Delta编码 存储相邻值的差值:

原始时间戳: 1705294200, 1705294260, 1705294320
Delta编码:  1705294200, 60, 60
进一步压缩: 1705294200, [60] × 2

2. Gorilla压缩(Facebook) 利用浮点数的位模式相似性:

XOR压缩:
值1: 12.5 = 0x41480000
值2: 12.6 = 0x41499999
XOR: 0x00019999 (大量前导零)

压缩格式:
┌───┬────────┬─────────┐
│控制│前导零数│有效位   │
│位  │(5 bits)│(variable)│
└───┴────────┴─────────┘

3. 字典压缩 对重复的标签值使用字典:

原始: host=server1, host=server1, host=server2
字典: {0: "server1", 1: "server2"}
编码: host=0, host=0, host=1

降采样与聚合

降采样策略

原始数据(1秒精度):
10:00:00 → 100
10:00:01 → 102
10:00:02 → 98
10:00:03 → 101
...

1分钟聚合:
10:00:00 → avg:100.25, min:98, max:102, count:60

1小时聚合:
10:00:00 → avg:99.8, min:85, max:115, p95:110

保留策略:
- 原始数据:保留7天
- 1分钟聚合:保留30天
- 1小时聚合:保留1年

连续聚合(Continuous Aggregation)

实时聚合管道:
         原始数据流
              │
      ┌───────┴───────┐
      ▼               ▼
   实时表          聚合任务
  (最近数据)          │
      │               ▼
      │          物化视图
      │         (历史聚合)
      └───────┬───────┘
              ▼
          查询合并

索引优化

倒排索引(Inverted Index)

Series索引:
metric_name + tags → series_id

倒排索引:
tag_key=tag_value → [series_id1, series_id2, ...]

示例:
region=east → [1, 3, 5, 7]
type=cpu → [1, 2, 3, 4]

查询 region=east AND type=cpu:
result = [1, 3, 5, 7] ∩ [1, 2, 3, 4] = [1, 3]

时间索引

分层时间索引:
年 → 月 → 日 → 小时 → 分片

稀疏索引(每N个点一个索引项):
┌──────────┬─────────┐
│Timestamp │ Offset  │
├──────────┼─────────┤
│10:00:00  │ 0       │
│10:01:00  │ 1024    │
│10:02:00  │ 2048    │
└──────────┴─────────┘

查询优化

向下采样查询(Downsampling Query)

查询:1年的CPU使用率趋势

优化路径:
if 时间范围 > 30天:
    使用1小时聚合表
elif 时间范围 > 7天:
    使用1分钟聚合表
else:
    使用原始数据

自动选择合适粒度,平衡精度和性能

时间范围分区裁剪

查询:WHERE time >= '2024-01-10' AND time <= '2024-01-15'

分区裁剪:
├── 2024-01-08/ [跳过]
├── 2024-01-09/ [跳过]
├── 2024-01-10/ [扫描]
├── 2024-01-11/ [扫描]
...
├── 2024-01-15/ [扫描]
├── 2024-01-16/ [跳过]

高可用与扩展

复制策略

写入路径(多副本):
      Writer
         │
    ┌────┴────┐
    ▼         ▼
  Node1     Node2
  (主)      (从)
    │         │
    └────┬────┘
         ▼
    一致性检查

分片策略

1. 基于度量名分片:
   cpu.* → Shard1
   memory.* → Shard2
   disk.* → Shard3

2. 基于时间分片:
   [00:00-08:00) → Shard1
   [08:00-16:00) → Shard2
   [16:00-24:00) → Shard3

3. 基于标签哈希分片:
   hash(tags) % N → ShardID

Rule of Thumb

9.6 多模型数据库

多模型数据库如ArangoDB、CosmosDB、OrientDB支持在单一系统中使用多种数据模型。这种设计避免了多种专用数据库带来的运维复杂性和数据一致性问题。

统一数据模型

多模型数据库的核心是找到不同数据模型的共同抽象:

通用数据表示

基础元素:
┌─────────────────────────────────┐
│         Entity/Document         │
│  ┌────────────────────────┐     │
│  │ _id: unique_identifier │     │
│  │ _type: model_type      │     │
│  │ properties: {...}      │     │
│  │ edges: [...]          │     │
│  └────────────────────────┘     │
└─────────────────────────────────┘

模型映射:
- 文档模型: Entity = Document
- 图模型: Entity = Vertex, Edge = Relationship
- 键值模型: Entity = {_id: key, value: ...}
- 关系模型: Entity = Row, Collection = Table

多模型视图

同一数据的不同视图:
原始数据:
{
  "_id": "user_123",
  "_type": "vertex",
  "name": "张三",
  "age": 28,
  "_edges": [
    {"_to": "user_456", "type": "friend"},
    {"_to": "product_789", "type": "purchased"}
  ]
}

文档视图:普通JSON文档
图视图:顶点with边关系
关系视图:User表的一行

查询语言设计

多模型数据库需要统一的查询语言支持不同模型:

AQL (ArangoDB Query Language)示例

// 文档查询
FOR doc IN users
  FILTER doc.age > 25
  RETURN doc

// 图遍历
FOR v, e, p IN 1..3 OUTBOUND 'users/123' friends
  RETURN p

// 连接查询
FOR u IN users
  FOR o IN orders
    FILTER u._id == o.user_id
    RETURN {user: u.name, order: o.total}

// 聚合查询
FOR u IN users
  COLLECT city = u.address.city INTO groups
  RETURN {city: city, count: LENGTH(groups)}

查询优化器架构

查询解析流程:
    查询字符串
         │
    ┌────▼────┐
    │  Parser │
    └────┬────┘
         │ AST
    ┌────▼────┐
    │Optimizer│
    └────┬────┘
         │
    ┌────▼─────────────┐
    │  Model Detector  │
    └──┬──────┬────┬──┘
       │      │    │
   文档计划 图计划 关系计划
       │      │    │
       └──┬───┴────┘
          │
    ┌─────▼─────┐
    │  Executor │
    └───────────┘

存储引擎架构

分层存储设计

逻辑层:
┌──────────────────────────────┐
│    统一API接口               │
├──────┬──────┬──────┬────────┤
│ Doc  │Graph │ KV   │ Column │
│ API  │ API  │ API  │  API   │
└──────┴──────┴──────┴────────┘
           │
存储层:
┌──────────────────────────────┐
│    通用存储引擎              │
├──────────────────────────────┤
│  文档存储 │ 图索引 │ 列存储  │
├──────────────────────────────┤
│      LSM-Tree / B-Tree       │
└──────────────────────────────┘

索引管理

多类型索引:
┌─────────────────────────────┐
│     索引管理器              │
├─────────────────────────────┤
│ • Hash索引 (键值查询)        │
│ • B-Tree索引 (范围查询)      │
│ • 全文索引 (文本搜索)        │
│ • 地理索引 (空间查询)        │
│ • 图索引 (关系遍历)          │
└─────────────────────────────┘

索引选择策略:
- 基于查询模式自动推荐索引
- 支持跨模型的复合索引
- 索引维护成本评估

事务处理

多模型数据库的事务需要跨越不同数据模型:

ACID保证

事务示例(跨模型):
BEGIN TRANSACTION
  // 文档操作
  INSERT INTO users VALUES {...}
  
  // 图操作
  CREATE EDGE friend FROM users/123 TO users/456
  
  // 键值操作
  SET cache:user:123 = {...}
  
COMMIT

事务日志:
┌──────────────────────────────┐
│ TxID: 1001                   │
│ Operations:                  │
│  1. DOC_INSERT users/789     │
│  2. EDGE_CREATE friend/101   │
│  3. KV_SET cache:user:123    │
│ Status: COMMITTED            │
└──────────────────────────────┘

隔离级别实现

MVCC实现多版本:
Document版本链:
v1 (t=100) → v2 (t=200) → v3 (t=300)
     ↑            ↑            ↑
   Tx_A读      Tx_B读      当前版本

不同模型的版本管理:
- 文档:完整文档版本
- 图:节点/边属性版本
- 键值:值版本

性能优化

查询路由

智能路由决策:
查询分析 → 模型识别 → 最优执行路径

示例:
// 查询:找出张三的所有朋友购买的产品
分析结果:
1. 文档查询找到张三 (文档模型)
2. 图遍历找朋友 (图模型)
3. 连接查询找产品 (关系模型)

执行计划:
┌─────────┐     ┌─────────┐     ┌─────────┐
│Doc Query│ --> │Graph BFS│ --> │Join Op  │
└─────────┘     └─────────┘     └─────────┘

缓存策略

多级缓存:
┌─────────────────────────────┐
│   查询结果缓存              │
├─────────────────────────────┤
│   模型特定缓存              │
│ • 文档缓存                  │
│ • 图遍历缓存                │
│ • 聚合结果缓存              │
├─────────────────────────────┤
│   页面缓存(Buffer Pool)    │
└─────────────────────────────┘

缓存失效策略:
- 基于TTL的过期
- 基于LRU的淘汰
- 事务提交时的主动失效

使用场景与权衡

适用场景

  1. 微服务架构:单一数据库支持多种服务的不同数据需求
  2. 复杂应用:同时需要文档灵活性、图关系和事务保证
  3. 数据湖:统一存储和查询异构数据
  4. 原型开发:快速迭代,数据模型可能变化

性能权衡

专用数据库 vs 多模型数据库:

性能对比:
┌──────────────┬─────────┬──────────┐
│   操作类型    │ 专用DB  │ 多模型DB │
├──────────────┼─────────┼──────────┤
│ 简单KV查询   │  ★★★★★  │  ★★★★    │
│ 复杂图遍历   │  ★★★★★  │  ★★★★    │
│ 时序聚合     │  ★★★★★  │  ★★★      │
│ 跨模型查询   │  ★★     │  ★★★★★   │
│ 运维复杂度   │  ★★     │  ★★★★★   │
└──────────────┴─────────┴──────────┘

Rule of Thumb

9.7 本章小结

NoSQL数据库的出现并非要取代关系型数据库,而是为特定场景提供更优化的解决方案。本章深入探讨了五种主要的NoSQL数据库类型及其设计原理:

核心概念回顾

  1. 文档存储
    • 灵活的模式,适合半结构化数据
    • 嵌套文档提供自然的数据组织
    • 原子性边界在文档级别
    • 关键技术:BSON编码、多键索引、分片键设计
  2. 列族存储
    • LSM树结构优化写入性能
    • 稀疏数据的高效存储
    • Compaction策略平衡读写性能
    • 关键技术:SSTable、Bloom Filter、压缩算法
  3. 图数据库
    • 关系是一等公民
    • 索引邻接列表实现O(1)遍历
    • 专门的图算法优化
    • 关键技术:图分区、Pregel模型、社区检测
  4. 时序数据库
    • 针对时间序列数据的专门优化
    • 自动降采样和数据保留
    • 高压缩率的存储格式
    • 关键技术:Gorilla压缩、连续聚合、分段存储
  5. 多模型数据库
    • 单一系统支持多种数据模型
    • 降低运维复杂度
    • 跨模型事务支持
    • 关键技术:统一查询语言、模型映射、智能路由

关键公式与算法

  1. Bloom Filter假阳性率: \(p ≈ (1 - e^{-kn/m})^k\) 其中 $m$ 是位数组大小,$n$ 是元素数量,$k$ 是哈希函数个数

  2. LSM树写入放大: \(WA = \frac{层数 × 放大因子}{2}\) 典型值:10层 × 10倍 / 2 = 50倍

  3. 图遍历复杂度
    • BFS/DFS:$O(V + E)$
    • Dijkstra:$O((V + E) \log V)$
    • Floyd-Warshall:$O(V^3)$
  4. 时序数据压缩率: \(压缩率 = \frac{原始大小}{压缩后大小}\) Gorilla通常达到10-20倍

设计决策树

选择NoSQL数据库的决策流程:

数据特征分析
     │
     ├─ 数据是否有固定模式?
     │   ├─ 否 → 文档数据库
     │   └─ 是 ↓
     │
     ├─ 关系是否是核心?
     │   ├─ 是 → 图数据库
     │   └─ 否 ↓
     │
     ├─ 是否时间序列数据?
     │   ├─ 是 → 时序数据库
     │   └─ 否 ↓
     │
     ├─ 是否需要极高写入吞吐?
     │   ├─ 是 → 列族存储
     │   └─ 否 ↓
     │
     └─ 是否需要多种访问模式?
         ├─ 是 → 多模型数据库
         └─ 否 → 关系型数据库

9.8 常见陷阱与错误

文档数据库陷阱

  1. 文档过大
    • 错误:将所有相关数据嵌入单个文档
    • 后果:超过16MB限制,更新效率低
    • 正确:合理划分文档边界,使用引用
  2. N+1查询问题
    • 错误:循环查询引用的文档
    • 后果:大量网络往返,性能差
    • 正确:使用聚合管道或批量查询
  3. 索引滥用
    • 错误:为每个字段创建索引
    • 后果:写入性能下降,存储膨胀
    • 正确:基于查询模式选择性创建索引

列族存储陷阱

  1. 热点Key
    • 错误:使用递增ID或时间戳作为行键前缀
    • 后果:写入集中到单个Region
    • 正确:使用哈希或反转时间戳分散写入
  2. Compaction风暴
    • 错误:大量SSTable同时需要Compaction
    • 后果:I/O饱和,查询延迟激增
    • 正确:合理配置Compaction策略和阈值
  3. 过长的列族名
    • 错误:使用描述性长名称
    • 后果:每个单元格都存储完整名称,空间浪费
    • 正确:使用短名称,文档中说明含义

图数据库陷阱

  1. 超级节点
    • 错误:允许节点有数百万条边
    • 后果:遍历性能急剧下降
    • 正确:使用虚拟节点或扇出限制
  2. 深度遍历
    • 错误:无限制的递归遍历
    • 后果:内存溢出或超时
    • 正确:设置最大深度,使用迭代深化
  3. 全图扫描
    • 错误:不使用索引的模式匹配
    • 后果:需要扫描所有节点
    • 正确:创建标签和属性索引

时序数据库陷阱

  1. 基数爆炸
    • 错误:使用高基数标签(如UUID)
    • 后果:索引膨胀,查询变慢
    • 正确:限制标签基数,使用字段存储高基数值
  2. 乱序写入
    • 错误:大量历史数据补写
    • 后果:破坏顺序写入优化
    • 正确:批量排序后写入,使用专门的补数工具
  3. 过度聚合
    • 错误:保存过多聚合维度
    • 后果:存储爆炸,维护成本高
    • 正确:只预聚合常用维度,其他实时计算

多模型数据库陷阱

  1. 模型混用
    • 错误:在单个查询中混合多种模型
    • 后果:优化器无法生成高效计划
    • 正确:分阶段查询,每阶段使用单一模型
  2. 事务范围过大
    • 错误:跨模型的大事务
    • 后果:锁争用严重,吞吐量下降
    • 正确:缩小事务范围,考虑最终一致性
  3. 性能期望不当
    • 错误:期望达到专用数据库的性能
    • 后果:性能不满足需求
    • 正确:理解权衡,合理设置期望

9.9 练习题

基础题

练习9.1:文档模型设计 设计一个电商平台的用户和订单数据模型。考虑以下需求:

Hint:考虑嵌入vs引用的权衡,原子性边界在哪里?

参考答案 用户文档: ```json { "_id": "user_123", "username": "zhangsan", "email": "zhang@example.com", "addresses": [ { "id": "addr_1", "type": "home", "street": "中关村大街1号", "city": "北京", "default": true } ], "cart": { "items": [ {"product_id": "prod_456", "quantity": 2} ], "updated_at": "2024-01-15T10:00:00Z" } } ``` 订单文档(独立集合): ```json { "_id": "order_789", "user_id": "user_123", "order_date": "2024-01-15T14:30:00Z", "items": [ { "product_id": "prod_456", "product_name": "商品名称", // 冗余存储 "price": 99.99, "quantity": 2 } ], "shipping_address": { // 订单时地址快照,不引用 "street": "中关村大街1号", "city": "北京" }, "status": "delivered" } ``` 设计理由: - 地址嵌入用户文档:经常一起访问 - 购物车嵌入:需要原子更新 - 订单独立文档:独立生命周期,避免用户文档过大 - 订单中冗余商品名称和地址:保持历史快照

练习9.2:LSM树Compaction计算 一个列族存储系统使用Leveled Compaction,每层大小比例为10:1。如果:

计算完成一次从Level 0到Level 1的compaction后,最坏情况下的写入放大。

Hint:考虑Level 1可能已经接近满容量。

参考答案 计算过程: 1. Level 0总大小:10 × 10MB = 100MB 2. Level 1当前假设接近满:约1GB 3. Compaction需要读取:100MB (L0) + 重叠部分L1数据(最坏1GB) 4. Compaction后写回L1:约1.1GB 5. 可能触发L1到L2的连锁compaction 写入放大计算: - 原始写入:100MB - L0→L1 compaction写入:1.1GB - 可能的L1→L2 compaction:最坏10GB - 总写入放大 = (100MB + 1.1GB + 10GB) / 100MB ≈ 112倍 这是最坏情况,实际通常为10-30倍。

练习9.3:图遍历复杂度 在一个社交网络图中,平均每个用户有150个朋友。如果要找出某用户的三度人脉(朋友的朋友的朋友),最坏情况下需要访问多少个节点?如何优化?

Hint:考虑重复访问和双向搜索。

参考答案 最坏情况分析: - 一度:150个节点 - 二度:150 × 150 = 22,500个节点 - 三度:150 × 150 × 150 = 3,375,000个节点 - 总计:约340万个节点 优化策略: 1. **去重**:使用visited集合,避免重复访问 2. **双向BFS**:从起点和终点同时搜索 - 每端只需遍历1.5度 - 节点数:2 × (150 + 150²) ≈ 45,000 3. **采样**:随机采样部分朋友关系 4. **预计算**:缓存常见路径 5. **并行化**:分布式图计算框架

挑战题

练习9.4:时序数据压缩设计 设计一个针对物联网传感器数据的压缩方案。数据特点:

Hint:结合多种压缩技术,考虑分段存储。

参考答案 压缩方案设计: 1. **分段存储**(1小时一个段) - 便于并行压缩/解压 - 支持快速定位时间范围 2. **Delta-of-Delta编码** ``` 原始: 25.1, 25.2, 25.2, 25.3, 25.4 一次Delta: 25.1, +0.1, 0, +0.1, +0.1 二次Delta: 25.1, +0.1, -0.1, +0.1, 0 ``` 3. **变长编码** - 小Delta值(-0.5到+0.5):1字节 - 中等Delta(-5到+5):2字节 - 异常值:4字节+标记 4. **游程编码** - 连续相同Delta值压缩 - 如室温稳定时期 5. **位打包** - 0.1度精度 = 需要1位小数 - 打包多个值到一个字节 压缩率估算: - 原始:4字节/值 × 3600值/小时 = 14.4KB - 压缩后:~1KB(头部) + ~0.3字节/值 × 3600 = 2KB - 压缩率:14.4/2 = 7.2倍 - 加上游程编码可达10-15倍

练习9.5:多模型查询优化 在一个多模型数据库中执行以下查询: “找出张三的所有朋友在最近30天内购买过的,且评分>4星的商品”

设计最优的执行计划,说明每步使用哪种数据模型。

Hint:考虑选择性和数据局部性。

参考答案 最优执行计划: 1. **文档查询**(高选择性) ``` 找到user_id where name = "张三" 预期结果:1个用户ID ``` 2. **图遍历**(关系查询) ``` 从user_id出发,1跳FRIEND关系 预期结果:~150个朋友ID ``` 3. **时序查询**(时间过滤) ``` 查找最近30天的购买记录 WHERE user_id IN (朋友ID列表) AND timestamp > now() - 30d 预期结果:~500条购买记录 ``` 4. **文档查询**(评分过滤) ``` 获取商品详情和评分 WHERE product_id IN (购买的商品) AND rating > 4 预期结果:~50个商品 ``` 优化要点: - 先执行高选择性操作(找张三) - 利用图模型的关系遍历优势 - 时序索引快速过滤时间范围 - 批量查询减少往返

练习9.6:CAP权衡分析 设计一个全球部署的物联网平台的数据存储方案。需求:

为实时和历史数据分别选择合适的NoSQL方案,说明CAP权衡。

Hint:考虑Lambda架构,不同层级不同权衡。

参考答案 Lambda架构设计: **实时层(Speed Layer)** - 选择:AP系统(Cassandra) - 部署:多数据中心,就近写入 - 一致性:最终一致性,允许短暂不一致 - 可用性:网络分区时继续服务 - 分区容错:必须容忍跨洲网络问题 配置: ``` - 复制因子:3(每个数据中心) - 一致性级别:LOCAL_QUORUM写,ONE读 - 数据保留:24小时 ``` **批处理层(Batch Layer)** - 选择:CP系统(HBase on HDFS) - 部署:中心化部署,定期同步 - 一致性:强一致性,准确的历史记录 - 可用性:可以容忍短暂不可用 - 分区容错:主备切换 配置: ``` - HDFS复制因子:3 - HBase WAL开启 - 定期快照和备份 - 压缩:Snappy或LZ4 ``` **服务层(Serving Layer)** - 时序数据库(InfluxDB)缓存最近数据 - 提供统一查询接口 - 智能路由:实时查询→Cassandra,历史查询→HBase CAP权衡理由: 1. 实时数据选AP:设备上报不能中断 2. 历史数据选CP:分析需要准确性 3. 通过分层架构同时满足不同需求

练习9.7:图分区算法设计 设计一个社交网络图的分区算法,目标是最小化跨分区边。已知:

Hint:考虑多目标优化和启发式方法。

参考答案 混合分区算法设计: **第一阶段:社区检测** ```python # 使用Louvain算法检测社区 communities = louvain_detection(graph) # 预期找到~10000个社区 ``` **第二阶段:社区分配** ```python # 贪心分配社区到分区 partitions = [[] for _ in range(100)] for community in sorted(communities, key=size, reverse=True): # 找负载最小的分区 min_partition = min(partitions, key=current_size) # 考虑边界成本 if boundary_edges(community, min_partition) > threshold: # 找边界最少的分区 min_partition = min(partitions, key=lambda p: boundary_edges(community, p)) min_partition.append(community) ``` **第三阶段:动态调整** ```python # 处理超级节点 for super_node in nodes_with_degree > 10000: # 创建影子节点 create_shadow_nodes(super_node, partitions) # 边界优化 for iteration in range(10): for node in boundary_nodes: # 计算移动收益 gain = compute_move_gain(node) if gain > 0: move_node_to_best_partition(node) ``` **优化指标**: - 边切割率:目标<5% - 负载均衡:标准差<10% - 局部性:同城用户尽量同分区 **预期效果**: - 跨分区边:~5-8%(利用社区结构) - 查询本地化率:>90%(大部分查询单分区完成) - 复制因子:1.2(20%热点节点复制)

练习9.8:NoSQL迁移方案 设计从MySQL迁移到NoSQL的方案。现有系统:

Hint:考虑双写、数据一致性校验、回滚方案。

参考答案 分阶段迁移方案: **Phase 1: 数据建模(2周)** ``` 1. 分析查询模式 2. 设计NoSQL模型 - 用户→文档数据库(MongoDB) - 订单→列族存储(Cassandra) - 关系→图数据库(Neo4j) 3. 确定数据冗余策略 ``` **Phase 2: 双写部署(1周)** ``` 应用层改造: ┌─────────────┐ │ Application │ ├──────┬──────┤ │ Write│ Read │ │ ↓↓ │ ↓ │ │MySQL │MySQL │ │NoSQL │ │ └──────┴──────┘ ``` **Phase 3: 数据同步(2周)** ```bash # 历史数据迁移 1. 全量导出MySQL快照 2. ETL转换到NoSQL格式 3. 批量导入NoSQL 4. 增量同步(CDC) ``` **Phase 4: 一致性验证(1周)** ```python # 对比验证脚本 for sample in random_sample(users, 0.1): mysql_data = fetch_from_mysql(sample) nosql_data = fetch_from_nosql(sample) assert deep_equal(mysql_data, nosql_data) ``` **Phase 5: 流量切换(2周)** ``` # 灰度切换 1. 1%读流量→NoSQL 2. 监控性能和正确性 3. 逐步增加:1%→10%→50%→100% 4. 保持双写 ``` **Phase 6: MySQL下线(1周)** ``` 1. 停止双写 2. MySQL设为只读 3. 保留1个月用于回滚 4. 最终下线 ``` **回滚方案**: - Phase 1-3:无需回滚 - Phase 4-5:切回MySQL读 - Phase 6:从备份恢复+增量回放 **风险控制**: - 每阶段设置检查点 - 保持MySQL备份 - 监控关键指标 - 准备降级开关