存储和数据库系统:构建一个简单的分布式数据库系统 | 豆包MarsCode AI 刷题

99 阅读9分钟

存储和数据库系统

数据产生

数据产生的过程通常涉及多种设备、传感器或用户输入。数据从原始来源进入存储系统,可能会进行预处理、清洗和格式化。

数据流动

数据流动涉及在多个系统或组件之间传递数据。流动的方式可能是同步的或异步的。数据流动的效率和可靠性直接影响系统的整体性能。

数据持久化

数据持久化是指将数据从内存存储到持久存储介质(如硬盘或 SSD)上,以便在系统重启或崩溃后恢复数据。持久化通常包括以下几个步骤:

  1. 校验数据的合法性(实体完整性)

    • 确保数据的一致性和准确性,避免不完整或错误的数据被持久化。
    • 通过事务机制(例如数据库中的 ACID 特性)保证数据的完整性。
  2. 修改内存中的数据

    • 使用高效的数据结构(例如 B+树、哈希表、红黑树等)对内存中的数据进行组织和优化,确保在处理时的效率。
  3. 写入存储介质

    • 将数据从内存写入磁盘或 SSD,可能涉及到缓冲、写入策略(如延迟写入或立即写入)和 I/O 调度策略的选择。

存储&数据库简介

RAID技术

RAID是一种将多个物理硬盘驱动器结合成一个逻辑硬盘的技术,用于提高数据存储的性能、冗余性和可靠性。

  • RAID 0(条带化)

    • 特点:将数据分割成小块,并交替写入到多个磁盘中,提供高性能。
    • 优点:提高了读写速度。
    • 缺点:没有冗余机制,若一个硬盘损坏,所有数据丢失。
  • RAID 1(镜像)

    • 特点:数据会被镜像写入两块硬盘,每块硬盘保存数据的完整副本。
    • 优点:数据冗余,容错能力强。
    • 缺点:磁盘利用率只有 50%,存储成本高。
  • RAID 0+1(镜像+条带化)

    • 特点:结合了 RAID 0 和 RAID 1 的优点。首先对数据进行条带化,然后将这些条带进行镜像。
    • 优点:提供较好的性能和冗余。
    • 缺点:需要至少四个硬盘,磁盘利用率较低。

数据库系统

关系数据库

关系数据库是基于关系模型的数据库,数据存储为表格形式,表之间通过外键等方式关联。常见的关系数据库包括 MySQL、PostgreSQL、Oracle 等。

结构化数据

结构化数据是指可以通过固定字段、表格、关系等方式表示的数据,通常使用 SQL(结构化查询语言)来查询和管理。

事务的四大特性(ACID)

  • 原子性

    • 事务中的操作要么全部成功,要么全部失败,不能部分执行。
  • 一致性

    • 事务开始前和结束后,数据库都必须处于一致的状态,确保数据库的完整性。
  • 隔离性

    • 每个事务在执行时是独立的,互不干扰。事务之间的中间状态对其他事务不可见。
  • 持久性

    • 一旦事务提交,数据的变更是持久的,即使系统崩溃也不会丢失。

通过 SQL 支持复杂查询,如 SELECT, JOIN, GROUP BY 等操作。


单机存储系统

单机存储系统指的是所有存储设备和计算资源都位于单个物理设备中。这种存储方式通常有较好的性能,但在扩展性和容错性方面较差。

典型单机存储系统:

  • 本地硬盘:如机械硬盘(HDD)或固态硬盘(SSD)。
  • 磁带:用于大规模备份。

分布式存储系统

分布式存储系统将数据分散存储在多台机器上,通过网络实现访问和管理。

HDFS

  • 特点:HDFS 是一种高度容错的分布式文件系统,适用于大规模数据存储,通常与 Hadoop 框架结合使用。
  • 优点:具备高吞吐量和良好的容错能力,支持大文件存储。

Ceph

  • 特点:Ceph 是一个分布式对象存储系统,提供高性能、高可扩展性的存储解决方案,支持块存储、对象存储和文件系统接口。
  • 优点:支持动态扩展和自动修复,能够处理大规模数据。

单机数据库系统

关系型数据库

  • MySQL:开源、轻量级的关系型数据库,广泛应用于 Web 开发。
  • PostgreSQL:高级开源关系型数据库,支持事务和复杂查询。

非关系型数据库(NoSQL)

  • MongoDB:面向文档的数据库,支持 JSON 格式存储数据,适用于大规模数据存储。
  • Redis:内存数据存储系统,支持键值对存储,广泛用于缓存。

分布式数据库

  • 分布式存储节点池化:分布式数据库通过池化多个存储节点,可以实现更高的存储容量和性能,并支持动态扩容。
  • 分布式事务:通过分布式事务保证跨节点数据的一致性,常见的技术有两阶段提交(2PC)和三阶段提交(3PC)。

新技术演进

SPDK(Storage Performance Development Kit)

  • 特点:SPDK 是一个高性能的存储开发工具包,旨在通过减少内核交互,提升存储系统的性能。

  • 关键技术

    • 用户态操作:避免内核与用户态的上下文切换,提高性能。
    • 中断转为轮询:减少中断处理的开销,改为通过轮询处理 I/O 请求。
    • 无锁数据结构:通过无锁数据结构减少多线程访问冲突,提高系统的并发性。

分布式数据库示例

客户端模拟请求: 使用 Go 的 http 包来模拟客户端发送 HTTP 请求。

网关代理服务器: 作为中介,网关服务器会根据请求中的 key 选择一个存储节点,然后将请求转发给相应的存储节点。

存储节点: 存储节点处理实际的 PUT 和 GET 请求,存储数据或返回存储的数据。

image.png

 // server.go
 package main
 ​
 import (
     "fmt"
     "io"
     "net/http"
 )
 ​
 // 模拟三个节点的地址
 var nodes = []string{
     "http://localhost:8081", // 节点 1
     "http://localhost:8082", // 节点 2
     "http://localhost:8083", // 节点 3
 }
 ​
 // hashNodes 根据给定的 key 计算哈希值并选择一个节点
 func hashNodes(key string) (string, error) {
     hash := 0
     // 基于 key 的每个字符计算简单的哈希值
     for _, char := range key {
         hash += int(char)
     }
     // 使用哈希值对节点数取模,确定目标节点
     return nodes[hash%len(nodes)], nil
 }
 ​
 // proxyServer 代理服务器的请求处理逻辑
 func proxyServer(w http.ResponseWriter, r *http.Request) {
     // 从 URL 中获取 'key' 和 'value' 参数
     key := r.URL.Query().Get("key")
     path := r.URL.Path // 获取请求的路径
     value := r.URL.Query().Get("value")
 ​
     // 计算目标存储节点
     node, err := hashNodes(key)
     if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
     }
 ​
     // 构造转发请求的 URL
     url := fmt.Sprintf("%s/%s?key=%s&value=%s", node, path, key, value)
 ​
     // 向目标节点发起 GET 请求
     resp, err := http.Get(url)
     if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
     }
     defer resp.Body.Close()
 ​
     // 将目标节点的响应内容写入当前响应
     io.Copy(w, resp.Body)
 }
 ​
 // main 函数启动代理服务器,监听 8080 端口
 func main() {
     // 设置代理处理的路径
     http.HandleFunc("/put", proxyServer)
     http.HandleFunc("/get", proxyServer)
 ​
     // 启动 HTTP 服务器监听端口 8080
     fmt.Println("Starting proxy server on :8080")
     err := http.ListenAndServe(":8080", nil)
     if err != nil {
         // 如果服务器启动失败,则 panic
         panic(err)
     }
 }
 ​
 ​
 // storage.go
 package main
 ​
 import (
     "fmt"
     "io/ioutil"
     "net/http"
     "os"
 )
 ​
 // 存储数据的文件路径
 var storagePath = "./storage"
 ​
 // 初始化函数,确保存储目录存在
 func init() {
     // 检查存储目录是否存在,如果不存在则创建
     if _, err := os.Stat(storagePath); err != nil {
         if os.IsNotExist(err) {
             // 如果目录不存在,创建目录
             os.Mkdir(storagePath, 0755)
         } else {
             // 如果其他错误,panic
             panic(err)
         }
     }
 }
 ​
 // putHandler 处理 PUT 请求,保存数据到本地文件
 func putHandler(w http.ResponseWriter, r *http.Request) {
     // 获取 URL 参数中的 key 和 value
     key := r.URL.Query().Get("key")
     value := r.URL.Query().Get("value")
 ​
     // 如果 key 或 value 为空,返回 400 错误
     if key == "" || value == "" {
         http.Error(w, "Key and value are required", http.StatusBadRequest)
         return
     }
 ​
     // 打开文件,如果文件不存在则创建
     file, err := os.OpenFile(storagePath+"/"+key, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
     if err != nil {
         // 如果文件打开失败,返回 500 错误
         http.Error(w, "Failed to save file", http.StatusInternalServerError)
         return
     }
     defer file.Close()
 ​
     // 将 value 写入文件,追加模式
     _, err = file.WriteString(value + "\n")
     if err != nil {
         // 如果写入失败,返回 500 错误
         http.Error(w, "Failed to write to file", http.StatusInternalServerError)
         return
     }
 ​
     // 返回成功响应
     w.WriteHeader(http.StatusOK)
     fmt.Fprintf(w, "Save the file %s", key)
 }
 ​
 // getHandler 处理 GET 请求,返回存储的数据
 func getHandler(w http.ResponseWriter, r *http.Request) {
     // 获取 URL 参数中的 key
     key := r.URL.Query().Get("key")
 ​
     // 如果 key 为空,返回 400 错误
     if key == "" {
         http.Error(w, "Key is required", http.StatusBadRequest)
         return
     }
 ​
     // 从文件系统中读取存储的文件
     data, err := ioutil.ReadFile(storagePath + "/" + key)
     if err != nil {
         // 如果文件读取失败,返回 404 错误
         http.Error(w, "File not found", http.StatusNotFound)
         return
     }
 ​
     // 将文件内容写入响应
     w.Write(data)
 }
 ​
 // start 函数启动存储节点的 HTTP 服务器,监听指定端口
 func start(port string) {
     //http.ListenAndServe是阻塞操作,且在多个 HTTP 服务器上,不能直接共享相同的路由模式
     //(例如 /put)。Go 的 http.HandleFunc 会在全局范围内注册路由模式,要在不同的
     //的端口上多次注册相同的路由模式。使用ServeMux 使他们可以拥有独立的路由注册
     //创建独立的路由器
     mux := http.NewServeMux()
     // 设置请求处理函数
     mux.HandleFunc("/put", putHandler)
     mux.HandleFunc("/get", getHandler)
 ​
     // 启动 HTTP 服务器监听指定端口
     http.ListenAndServe(port, mux)
 }
 ​
 // main 函数启动多个存储节点,监听不同的端口
 func main() {
     // 启动存储节点 1,监听 8081 端口
     go start(":8081")
     // 启动存储节点 2,监听 8082 端口
     go start(":8082")
     // 启动存储节点 3,监听 8083 端口
     go start(":8083")
 ​
     // 阻塞主线程,保持程序运行
     select {}
 }
 ​
 ​

server.go

  • hashNodes:根据给定的 key 计算一个简单的哈希值,选择一个存储节点。此处使用的是简单的哈希算法,通过将每个字符的 ASCII 值相加,然后对节点数量取模来选择节点。
  • proxyServer:该函数用于接收来自客户端的请求,选择合适的存储节点,然后将请求转发到目标节点。它会从 URL 查询参数中提取 keyvalue,然后构造请求发送给存储节点。
  • main:启动代理服务器,监听端口 8080,并设置 /put/get 路由,分别处理 PUT 和 GET 请求。

storage.go

  • init:初始化存储目录,确保存储路径存在,如果不存在则创建。
  • putHandler:处理 PUT 请求,接受 keyvalue,将数据写入本地文件系统中,文件名为 key,内容为 value
  • getHandler:处理 GET 请求,接受 key,从文件系统读取文件并返回内容。
  • start:启动每个存储节点,创建一个 HTTP 服务器,分别处理 PUT 和 GET 请求,监听不同端口(808180828083)。
  • main:通过 goroutines 启动多个存储节点,每个存储节点监听一个不同的端口,并保持运行。