开始日期:2022-2-24 结束日期:

可靠、可扩展、可维护的应用系统

应用分类:

  1. 计算密集型应用:第一限制因素是 CPU 的处理能力
  2. 数据密集型应用:数据量大、数据复杂度高、数据快速多变

其中数据密集型应用包含模块:

  1. 数据库:用于存储数据,方便再次访问 -> MySQL
  2. 高速缓存:缓存复杂或者操作代价高的数据,加快下次访问 -> Redis
  3. 索引:可以用关键字搜索过滤 -> Elasticsearch
  4. 流式处理:持续将消息发到另外进程,异步处理 -> RocketMQ
  5. 批处理:定期处理大量累计数据 -> DataX

    可靠性

    典型的期望包括:

  6. 应用程序执行用户所期望的功能。

  7. 可以容忍用户出现错误或者不正确的软件使用方法。
  8. 性能可以应对典型场景、合理负载压力和数据量。
  9. 系统可防止任何未经授权的访问和滥用。

    定义

    即使出了某些错误,系统仍可以继续正常工作。

    问题与措施

    硬件故障:磁盘配置 RAID ,服务器配备双电源,甚至热插拔C PU , 数据中心添加备用电惊、发电机等。

软件错误:检查系统与系统之间依赖、全面测试、进程隔离、允许进程崩溃重启

人为失误(运维配置):

  • 最小出错的方式设计:抽象层、API、管理界面
  • 分离易出错出故障的接口:提供沙箱环境(理解的是 UAT 环境)
  • 充分测试
  • 提供快速的恢复机制:代码回滚、配置回滚
  • 提供监控系统,监控性能指标、错误率
  • 标准化流程

    可扩展性

    定义

    描述系统「应对负载增加能力」的术语

    方案

    描述负载:吞吐量「QPS、TPS」、PV(页面访问量)、UV(某站点一天内活跃的用户数)、DAU(日活跃用户量)、MAU(月活跃用户量)

描述性能:

  • 负载增加,硬件资源不变,性能怎么变?
  • 负载增加,性能不变,要增加多少资源?

应对方案:把无状态服务分布然后扩展至多台机器相对比较容易,而有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加。出于这个原因,直到最近通常的做法一直是,将数据库运行在一个节点上(采用垂直扩展策略), 直到高扩展性或高可用性的要求迫使不得不做水平扩展。

水平扩展:增加机器数量 垂直扩展:单个机器性能更好

可维护性

要从软件系统设计之初就要用三个基本的设计原则:

  1. 可运维性(方便运营团队来保持系统平稳运行)
  2. 简单性(简化系统复杂性,使新工程师能够轻松理解系统。)
  3. 可演化性(后续工程师能够轻松地对系统进行改进,井根据需求变化将其适配到非典型场

景,也称为可延伸性、易修改性或可塑性)

可运维性

  • 能够监视系统健康状况,服务异常时可以快速恢复;
  • 能够追踪问题出现原因;能将软件和平台保持在最新状态(补丁);
  • 了解系统之间如何互相影响,避免出问题的操作;
  • 预测可能出现的问题,在出现问题前解决;
  • 有好的 CICD 规范和工具(建立用于部署、配置管理等良好的实践规范和工具包);
  • 可执行复杂维护任务(迁移);当
  • 配置更改时,维护系统的安全稳健;
  • 制定流程来规范操作行为,并保持生产环境稳定;保持相关知识的交接。

    简单性

    简化系统设计并不意味着减少系统功能,而主要意味着消除意外方面的复杂性。

消除意外复杂性最好手段之一是抽象。一个好的设计抽象可以隐藏大量的实现细节,并对外提供干净、易懂的接口。一个好的设计抽象可用于各种不同的应用程序。这样,复用远比多次重复实现更有效率;另一方面,也带来更高质量的软件,而质量过硬的抽象组件所带来的好处,可以使运行其上的所有应用轻松获益。

可演化性:易于改变

在组织流程方面,敏捷开发模式为适应变化提供了很好的参考。敏捷社区还发布了很多技术工具和模式,以帮助在频繁变化的环境中开发软件,例如测试驱动开发(TDD)和重构。

在设计方面,领域驱动设计(Domain Drive Design)可以使用。

数据模型与查询语言

关系模型与文档模型

SQL

数据被组织成关系(relations),在SQL中称为表(table),其中每个关系都是元组(tuples)的无序集合(在SQL中称为行)。

NoSQL

非关系型数据库:

  • 比关系数据库更好的扩展性需求,包括支持超大数据集或超高写入吞吐量。
  • 普遍偏爱免费和开源软件而不是商业数据库产品。
  • 关系模型不能很好地支持一些特定的查询操作。
  • 对关系模式一些限制性感到沮丧,渴望更具动态和表达力的数据模型。

    对象-关系不匹配

    ORM(Object-Relational Mapping,对象关系映射)减少了「如果数据存储在关系表 中 , 那么应用层代码中的对象与表、行和列的数据库模型之间需要一个笨拙的转换层」的样板代码量,但是不能隐藏两个模型的差异。

    多对一

    不管是存 ID 还是文本字符串,都有内容重复的问题。

存储 ID:对人类有意义的信息(例如慈善这个词)只存储在一个地方,引用它的所有内容都使用 ID(ID只在数据库中有意义 ),永远不需要被改变。

存储文本:使用它的每条记录中都保存了一份这样可读信息。

如果这些信息被复制 ,那么所有的冗余副本也都需要更新。这会导致更多写入开销,并且存在数据不一致的风险。

消除上述的重复,正式数据库规范化的思想。而数据库规范话需要表达多对一的关系,此时就不适合文档模型,而在关系型数据库中,可以支持联结操作,能够通过 ID 来引用其他表中的行。在文档数据库中,一对多的梳妆结构不需要联结。

多对多

层级模型:将所有记录表示为嵌套在记录中的记录(树),对多对多关系处理困难,不支持联结(开发人员需要决定是复制「反规范化」多份数据还是手动解析记录之间的引用。

关系模型:定义了所有数据的格式:关系(表)只是元组(行)的集合,仅此而已。没有复杂的嵌套结构,也没有复杂的访问路径。可以读取表中的任何一行或者所有行,支持任意条件查询。可以指定某些列作为键并匹配这些列来读取特定行。可以在任何表中插入新行,而不必担心与其他表之间的外键关系。

网络模型:记录之间的链接不是外键,而更像是指针,访问记录的唯一方法是选择一条始于根记录的路径,并沿着相关路径依次访问。

哪种数据模型的应用代码更简单:

  1. 如果应用数据具有类似文档的结构(即一对多关系树,通常一次加载整个树),那么使用文档模型更为合适。而关系型模型则倾向于某种数据分解,它把文档结构分解为多个表,有可能使得模式更为笨重,以及不必要的应用代码复杂化。局限是不能直接引用文档中的嵌套项。
  2. 如果应用程序确实使用了多对多关系,那么文档模型就变得不太吸引人。可以通过反规范化来减少对联结的需求,但是应用程序代码需要做额外的工作来保持非规范化数据的一致性。通过向数据库发出多个请求,可以在应用程序代码中模拟联结,但是这也将应用程序变得复杂,并且通常比数据库内的专用代码执行的联结慢。在这些情况下,使用文档模型会导致应用程序代码更复杂、性能更差。

    图状数据模型

    图由两种对象组成:顶点(也称为结点或实体)和边(也称为关系或弧),比如:

  3. 社交网络:顶点是人,边指哪些人互相认识

  4. Web 图:顶点是网页,边表示与其他页面的 HTML 链接
  5. 公路或铁路网:顶点是交叉路口,边表示他们之间的公路或铁路线

    属性图

    在属性图模型中,每个顶点包括:
  • 唯一的标识符。
  • 出边的集合。
  • 入边的集合。
  • 属性的集合(键-值对)。

每个边包括:

  • 唯一的标识符。
  • 边开始的顶点(尾部顶点)。
  • 边结束的顶点(头部顶点)。
  • 描述两个顶点间关系类型的标签。
  • 属性的集合(键-值对)。

可以将图存储看作由两个关系 表组成,一个用于顶点,另一个用于边,为每个边存储头部和尾部顶点。

需要注意的地方:

  1. 任何顶点都可以连接到其他任何顶点。没有模式限制哪种事物可以或不可以关联。
  2. 给定某个顶点,可以高效地得到它的所有入边和出边,从而遍历图,即沿着这些顶点链条一直向前或向后)。
  3. 通过对不同类型的关系使用不同的标签,可以在单个图中存储多种不同类型的信息,同时仍然保持整洁的数据模型。

    数据存储与检索

    数据库核心:数据结构

    先说下日志,好多数据库内部都用了日志,日志是一个仅支持追加式更新的数据文件,如果如果日志文件保存了很多内容,这个时候就需要 O(n) 的时间复杂度来查找想要的内容。

为了能更快地找到想要的值,所以有了索引这个结构。这个结构的大致思想是:用额外的空间保留一份额外的元数据,将这些元数据作为路标,方便定位想要的数据。但是索引存在以下问题:

  1. 维护额外的结构会有额外的空间开销
  2. 对于写入来说,每次写数据需要更新索引,所以索引会降低写的速度

    哈希索引

    如果数据存储用追加式文件组成,保存 内存中的 ha s h map ,把每个键一一映射到数据文件中特定的字节偏移量 ,这样就可以找到每个值的位置。如下图所示:
    image.png
    此时,会存在一个问题:如果只追加到一个文件,会出现磁盘空间耗尽的问题。

解决方案是:将日志分解成一定大小的段,当文件到一定大小后就不再往此文件追加,并将之后的写入到新的段文件中,并将之前写满的段进行压缩(丢弃重复的键,只保留每个键最近的更新),形成新段,后台完成后还可以将之前的旧段进行删除。

具体实现问题与解决方案

  1. 文件格式:格式用二进制格式,以字节为单位来记录字符串的长度,之后跟上原始字符串
  2. 删除记录:要删除键和对应的值,在数据文件中添加一个特殊删除记录,合并日志段时发现此记录,则丢弃该键的所有值
  3. 崩溃恢复:数据库重新启动,内存中的 hash map 丢失从头到尾读取段文件,记录每个键最新值的偏移量,恢复每个段的 hash map。如果文件大,可以将每个段的 hash map 快照存储在磁盘上,然后更方便加载到内存中,从而加快恢复速度
  4. 部分写入的记录:如果是将记录追加到日志的过程中崩溃,则对文件添加校验值,发现损坏部分并丢弃(暂时没理解)
  5. 并发控制:由于写入以严格的先后顺序追加到日志中,通常的实现选择是只有一个写线程。数据文件段是追加的, 并且是不可变的, 所以他们可以被多个线程同时读取。

    追加式优点

  6. 追加和分段合并主要是顺序写,它通常 比随机写入快得多

  7. 如果段文件是追加的或不可变的,则并发和崩溃恢复要简单得多。例如,不必担心在重写值时发生崩溃的情况,留下一个包含部分旧值和部分新值混杂在一起的文件。
  8. 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题。

    哈希索引缺点

  9. 需要大量的随机访问 I/O,如果哈希变满,处理冲突的逻辑复杂

  10. 区间查询效率不高

    SSTables(排序字符串表)和 LSM-Tree(Log Structured Merge Tree)

    SSTables 优点

  11. 合并段更简单高效,并发读每个文件,比较每个文件的第一个键,将最小的键拷贝到输出文件,如果键相同,保留最新(不同留小的,相同留最新的)

image.png

  1. 在文件中查找特定的键时,不再需要在内存中保存所有键的索引。
  2. 由于读请求往往需要扫描请求范围内的 多个key value对,可以考虑将这些记录保存到一个块中并在写磁盘之前将其压缩。然后稀疏内存索引的每个条目指向压缩块的开头。除了节省磁盘空间,压缩还减少了 I/O 带宽的占用。

image.png

SSTables 构建

  1. 将其保存到可以顺序插入键并以排序后的顺序读取的数据结构中(如红黑树),这个结构可以称为内存表
  2. 内存不够时,将上述的文件写入到磁盘(此时写入磁盘会高效),写盘的同时,同时进行新的内存表写入
  3. 读的时候,先在内存表上查,然后是最新的磁盘文件,然后次新,直至找到
  4. 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值

存在的问题:如果数据库在「已经写入到内存表,但是未写入磁盘」时崩溃,可以在磁盘上保留单独的日志记录每次写入。

SSTables -> LSM-Tree

基于合井和压缩排序文件原理的存储引擎通常都被称为 LSM 存储引擎。
LSM-Tree 的 基本思想:保存在后台合井的一系列的 SSTable

性能优化

  1. 先查内存表,再找到最旧的磁盘文件 -> 查找不存在的键效率很低 -> 可以使用布隆过滤器
  2. 不同的策略会影响甚至决定SSTables压缩和合并时的具体顺序和时机,最常见的方式是大小分级和分层压缩:在大小分级的压缩中,较新的和较小的SSTables被连续合并到较旧和较大的SSTables;在分层压缩中,键的范围分裂成多个更小的SSTables,旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘空间。

    B-trees