【CS神书笔记】《数据密集型应用系统设计》(三)—— 数据系统基础(第4章)

开篇语:为什么我们要重读经典?

最近很忙,但还是在抽时间看这本神书——《数据密集型应用系统设计》。在技术迭代如此迅速的今天,我们为什么还要花时间研读一本出版于2017年的技术书籍?答案很简单:底层原理从未改变。

《数据密集型应用系统设计》(DDIA)就像分布式系统领域的《算法导论》,它不教你具体工具的API使用,而是揭示那些五年后、十年后依然适用的核心原则。当我读这本书时,最深的感受是:"原来大浪淘沙后的系统设计,在8年前就被这本书预言了。"

这本书适合所有后台开发工程师、大数据工程师,也很适合面试前复习系统设计的同学,或者是想要提升计算机底层认知的同学。



一、本系列的特殊视角

这不是普通的读书笔记,而是一个后端开发的深度批注版。你将看到:

  1. 理论到实践的映射:比如"为什么MongoDB的默认隔离级别恰好对应书中提到的read uncommitted?"
  2. 行业演进验证:书中2017年的预言(如Stream Processing的兴起)如何被近年的Flink、Kafka Streams等工具验证
  3. 反常识洞见:“原来用JSON做数据交换格式在某些场景下比Protocol Buffers更合适?”

二、本篇核心路线

这篇博客将从编码层面,继续介绍数据密集型应用系统的第一部分——数据系统基础

编码的哲学:Json / Xml / Avro / Thrift / Protobuf 背后隐藏的系统演化思维


三、编码的哲学

一切都在改变, 一刻都没有停止。—— Heraclitus ,如柏拉图在Cratylus 引用的那样(公元前360年)

1. 引言

在技术飞速迭代与数据爆炸性增长的今天,我们会发现,光靠优雅的存储引擎(第二章)高效的检索策略(第三章)还不足以支撑整个系统的运行。数据从产生到消费,必然要经历一个“编码—传输—解码”的过程。就像《数据密集型应用系统设计》在系统各层优化上给我们带来深远的启示一样,本章将带你走进数据编码的世界。

  1. 为何编码如此重要?
    当数据离开内存,存入磁盘或通过网络传输时,必须转换为适合存储与传输的格式。高效、紧凑且灵活的编码方式不仅能减少存储空间、降低网络带宽消耗,更能为跨语言、跨平台通信提供坚实基础。同时,由于需求与技术的更迭,需要或多或少对过去编码的"模式"做兼容。正如书中提到的那样:“数据不仅仅是数字和字符串,它还携带着对未来兼容性的期盼。”

  2. 《DDIA》中的思考
    《数据密集型应用系统设计》详细论述了系统设计的各个环节,编码技术则是其中连接数据存储、缓存、中间件及外部服务的“润滑剂”。通过对 Avro、Thrift 和 Protobuf 的深度剖析,我们可以看到,不同的编码方案如何在效率、灵活性和向前/向后兼容性上做出不同的权衡。

  3. 本章目标

  1. 介绍数据编码与序列化的基本原理,解释为什么在现代系统中,如何对数据进行编码是至关重要的一环。
  2. 深入讨论主流编码方案(Avro、Thrift、Protobuf)的设计思想与内部机制。
  3. 揭示模式演化背后的“哲学”:如何在系统长期运营过程中既能保证性能,又能应对不断变化的业务需求。

2.数据编码基础

在系统中,数据编码就像是我们与世界交流的通用语言,无论是持久化存储、网络传输,还是跨语言协作,都离不开它。下面我们一起解构数据编码及其在现代系统中的角色。

1. 为什么需要编码和解码?

  1. 持久化与传输的必然:当数据从内存转存到磁盘,或在网络间传输时,必须将丰富的内存结构转换为紧凑且高效的二进制格式。编码使得数据能以更小的体积、更高的传输速度被存储和传递。(当我们使用内存时,往往是使用指针访问和操作内存中的对象,但存储为字节码时指针对其他进程是没有意义的,因此和内存中的对象看起来很不一样)
  2. 跨语言互操作:不同编程语言和平台之间的数据结构各异,统一的编码方式提供了跨平台的数据桥梁,使得各系统间无缝对接成为可能。(Java 的 java.io.Serializable / Ruby 的 Marshal / Python 的 Pickle 等,他们互相之间无法互相访问对方的编码文件;这些语言的库只关注快速、便捷地操作编解码,忽略了往前或往后兼容。
  3. 性能优化:高效的编码方案能显著降低 I/O 开销、减少网络带宽消耗,同时提高序列化和反序列化的速度,这对于实时处理和大规模数据流动至关重要。(主要取决于编解码消耗地 CPU ,以及编解码结构的大小)

2. 编码的核心概念

  1. 序列化 (Serialization)
    指将内存中复杂的数据结构转换为可存储或传输的格式的过程,序列化后的数据可以保存在文件中,或在网络进行传输,再由另一端反序列化为原本的数据结构。
  2. 反序列化 (Deserialization)
    将经过编码的数据解析回原本内存中的数据结构,这一过程必须严格按照编码时的格式和规则,保证数据正确还原。

3. 两大类型的编码方式

在实际应用中,编码方案大致可以分为两类,各有优劣:

  1. 文本型格式
  1. 代表:JSON、XML、YAML
  2. 特点
    可读性强:便于人工调试和日志分析。
    灵活性高:无须预先定义严格的模式。
    缺点:体积较大、解析速度相对较慢,可能带来额外的性能开销。
  1. 二进制格式
  1. 代表:Avro、Thrift、Protobuf
  2. 特点
    高效紧凑:数据体积小,传输与存储成本低。
    性能优秀:序列化和反序列化速度快,适用于高吞吐场景。
    依赖模式定义:数据结构需要事先通过 IDL 或 Schema 定义,兼容性需要工程师精心管理。

4. 文本型格式的缺陷

Martin Kleppmann 在文中明确指出了,文本型格式编码在大规模数据系统中的不足。

  1. Xml / Csv不区分数字字符串,Json尽管区分两者,但不区分整数和浮点数,且不指定精度,这导致处理大数字时无法精确表达
  2. Json 和 Xml 对 Unicode字符串支持,但不支持二进制。尽管可以使用Base64将二进制数据编码为文本来解决这个限制,但二进制与其他文本混杂,且数据大小增加了33%。
  3. Xml 和 Json 支持可选的模式,但是他们也需要学习成本,甚至像数字和二进制字符串,想要被正确解释,需要硬编码/解码逻辑。
  4. Csv 没有任何模式,但是应用程序需要自行定义每行和每列的含义。如应用程序要添加新的行和列,必须手动处理这些更改。Csv也是非常模糊格式,例如一个值包含了逗号/换行符,这时候就会被模式错误解析。

正式基于这些文本型格式编码的缺陷,二进制编码呼之欲出。

3. 主流编码方案解读

在二进制编码的浪潮下,Avro、Thrift 与 Protobuf 成为系统设计中的三大主流选择。接下来,我们将深入解剖这三种编码方案,从设计理念、内部机制与实际工程中的使用体验等角度,探讨它们各自的特点和权衡所在。

1. 基准模型 MessagePack

Json 可能是文本型格式编码的最广泛应用技术,我们以 Json 格式的一条记录作为基准模型(来自AI研究生的碎碎念),看看二进制编码能带来多大的收益。

这条 Json 有三个字段,分别是 userName , favoriteNumber , interests,属性分别为字符串、整形数字、字符串列表。这个 Json字段需要使用81字节进行保存。

在这里插入图片描述
基准模型使用 Json 的最为广泛使用的二进制编码技术 MessagePack 为例,这样存储需要66字节。

  1. 第一个字节 83,8x 格式表示一个固定大小的映射(fixmap),x 的值(低 4 位)表示键值对的数量。83 的低 4 位是 0011(二进制),即 3。所以 83 表示这是一个包含 3 个键值对的对象。
  2. 第一个键值对:key:a8,表示后面跟着 8 个字节的字符串数据,这几个字符串编码为 ASCII/UTF-8 编码,代表 “userName”。同理,value:a6,表示后面6个字符串数据,代表"Martin"。
  3. 第二个键值对:key:ae,表示"favoriteNumber"。value:cd,cd 是 MessagePack 中用于表示一个 16 位无符号整数(Unsigned Integer)的类型标记。它表示后面的 2 个字节是一个大端序(Big-Endian)的 16 位整数。05 39表示51616+3*16+9 = 1337。
  4. 第三个键值对:key:a9, 表示"interests"。value:92,9x 格式表示一个固定大小的数组(fixarray)。x 的值(低 4 位)表示数组元素的数量。因此,92表示一个包含两个元组的数组,分别是"daydreaming",hacking"。

在这里插入图片描述

2. Thrift 与 Protocol Buffers

Apache Thrift 最初在 Facebook开发,在2007-2008年开源,既提供一种跨语言的远程过程调用(RPC)框架,也内嵌了高效的二进制序列化机制。它与 Protocol Buffers 基于相同的原理,都需要模式才能编码数据。
Thrift 和 Protocol Buffers 都可以使用接口定义语言( IDL )来描述模式,定义也十分相似。

// 定义用户配置结构体
struct UserProfile {
    1: required string userName, 
    2: optional i64 favoriteNumber, 
    3: optional list<string> interests
}
// 定义用户配置结构体
message UserProfile {
  required string user_name = 1; 
  optional uint32 favorite_number = 2;
  repeated string interests = 3;
}

Thrift 有两种编码方式,分别是BinaryProtocol 和 CompactProtocol。(实际上,还有一种,名为DenseProtocol,由于其只能使用C++实现,不算跨语言,因此不多赘述)

我们先来看Thrift BinaryProtocol。

  1. 与基准模型相比,它同样具有一个类型注释(字符串、整数等),并且指定了长度(字符串的长度、列表的项数),字符串也被编码为了ASCII码。
  2. 最大的区别在于,其没有字段名(userName等),取而代之的是字段的标签(1,2,3)。这是因为Thrift BinaryProtocol定义了其中的模式,用于指示当前字段,但是更为紧凑。
  3. 这种编码格式只需要59字节,相比于MessagePack的Json,减少了10%左右的开销。
    在这里插入图片描述
    再来看Thrift CompactProtocol,它只需要34字节的存储空间。
  4. 它与 BinaryProtocol 语义完全相同。
  5. 不同的是,它将字段类型和标签号打包到了单个字节中,并使用可变长度整数实现。
  6. 对于数字,不使用全部8字节,而是使用两个字节进行编码,最高位用于指示是否还有更多字节。-64-63编码为1字节,-8192-8191编码为2字节,以此类推。
    在这里插入图片描述

再来看 Protocol Buffers,它是 Google 开发的,也是在2007-2008年开源。它实际上与 Thrift CompactProtocol 十分类似,它可以做到使用 33 字节表示相同的记录。

在这里插入图片描述

4. Avro

Apache Avro 是另一种二进制编码格式,它的诞生来源于 Thrift 不适配 Hadoop 的用例,因此 Avro 在2009年作为 Hadoop 的子项目启动。它也使用了模式来指定比编码的数据结构,它有两种模式语言,一种是人工编辑的 Avro IDL, 一种是利于机器读取的基于Json的模式语言。

Avro IDL示例如下:

record Person{
        string userName;
        union { null, long } favoriteNumber = null;
        array<string> interests;
    }

该模式等价 Json 如下:
在这里插入图片描述
相比与之前的编码而言,我们可以发现如下几点变化:

  1. 首先,这个模式中没有标签编号。通过这样的编码方式,可以将二进制编码压缩到 32 字节长,这也是我们现在所见编码中最为紧凑的了。
  2. 实际上,这个编码甚至没有任何标识字段或数据类型的存在,编码只是连在一起的列值组成。字符串只是一个前缀长度,后面跟着的是UTF-8字节流。
  3. 为了解析这样的编码模式,只有读取和写入的数据模式完全相同,才能正确解析,如果有任何不适配的地方都无法解码数据。
    在这里插入图片描述

5.三种主流技术比对

在这里插入图片描述

4. 模式演化的挑战与策略

在实际工程中,系统往往需要经历不断的演化和迭代。数据模式(Schema)也在不断更新,新增、删除或修改字段是常态。然而,数据一旦部署后,立刻就会面临读写双方版本不一致的问题。如果设计不当,模式的变更就可能引发数据丢失或应用崩溃。借鉴《数据密集型应用系统设计》的深刻见解,本节将讨论模式演化过程中的常见挑战以及各主流编码方案如何通过不同的设计策略来缓解这些问题。

1. 什么是模式(Schema)?

我们已经阐述了各种编码方案实现的细节,现在我们回头来看,到底什么是模式?
在我看来,模式(Schema)就是对数据结构、字段、数据类型的约束,它可以是开发者们对于对象、文档、图等数据结构在存储、通信时约定好的表或对象,遵守着某种约定。
那么, 二进制模式相比与传统的 Json / Xml有何优点?
在我看来主要有如下优点:

  1. 他们比传统模式,乃至于他们的二进制变体更加紧凑,可以省略数据中的字段名称。
  2. 模式是最新的内容,这是由于解码必须使用模式,因此可以保证是最新的,而不是手动维护的,手动维护可能会导致文档不一致的情况。
  3. 模式允许在部署前进行检查,主要是向前兼容和向后兼容性(后文会详细阐述)
  4. 静态类型编程语言的用户更青睐模式,因为他们可以在编译时进行检查。

2.模式演化与向前向后兼容

  1. 向前兼容:向前兼容要求旧版应用在面对新增或变更的新版数据时,不至于因无法解析不认识的字段而出错。
  2. 向后兼容:向后兼容要求新版应用能够正确解析和处理旧版数据。

3. 主流编码方案的兼容性处理

  1. Thrift 与字段编号(Tag)
  1. 字段编号(Tag):Thrift 采用专门的接口描述语言(IDL),每个字段在定义时会被赋予一个唯一且固定的编号。这一编号成为序列化和反序列化过程中检索字段的依据。 在新增字段时,必须分配未被占用的新编号,并可以采用 optional 修饰符;
  2. 向后兼容:当业务需求变化而新增字段时,新字段应当设置为 optional(可选),并配置合适的默认值。这样,新版本的应用在解析旧数据时,若旧数据中没有这些字段,则通过默认值予以补充。
  3. 向前兼容:对于旧版应用,如果遇到新版数据中存在额外字段,由于 Thrift 协议解析时主要关注固定编号的字段,未知的字段会被自动忽略,从而不会导致解析失败。
  1. Protobuf 数字标签和固定顺序
  1. 数字标签:Protobuf 使用 .proto 文件对数据结构进行描述,在其中每个字段都分配有一个固定的数字标签。正是这些数字标签保证了不同版本之间数据的一致性。一旦设定,这些标签就不应该更改。新增字段时,通过使用未分配的标签和相应默认值,确保老版本可以忽略新字段。
  2. 向后兼容:新增加的字段只要设置了默认值,旧版应用在反序列化过程中遇到缺失的字段时,会使用默认值进行替代,确保数据完整性。
  3. 向前兼容:当 Protobuf 应用的新版包含额外字段时,旧版应用在解析时会自动忽略那些不认识的标签,而不会报错。这种设计确保了即使旧版代码看到包含新内容的数据流时,也能正常运行。
  1. Avro 动态解析与Schema Registry
  1. Schema:Avro 通常在数据中嵌入写入时使用的 Schema (write schema)或其引用,解释数据时再由读端提供新的 Schema(reader schema)。只要两个 Schema 之间满足兼容性规则,就能实现数据解析。
  2. 动态解析:与 Protobuf 和 Thrift 这两种依赖于代码生成的编码技术相比,Avro对 JavaScipt / Ruby / Python等语言更为友好。因为解释型语言没有编译的步骤,代码生成反而时不必要的障碍。而Avro 对这两者都进行了可选的支持,它生成的模式也是动态解析,这使得开发者不需要手动更新数据库列名到字段标签的映射。
  3. 向后兼容:当新版 Schema 中新增字段时,只要为这些字段定义默认值,读取旧数据(没有这些字段的信息)的新应用可以通过默认值来补全,从而实现向后兼容。
  4. 向前兼容:如果新加入的数据包含了旧系统所不认识的字段,旧版 Schema 在解析时可以“自动忽略”这些未知字段,不会因为多出来的数据而导致解析异常。
  5. Schema Registry:Avro 通常结合 Schema Registry 使用,将所有版本的 Schema 进行集中管理。这样不仅降低了数据交换时的版本不匹配风险,也便于自动校验新旧 Schema 之间的兼容性。

在这里插入图片描述

4. 实践的最佳策略

上述三种主流方案对于兼容性的设计,启发着我们做系统设计与方案设计时,对下面几点进行考量与探究:

  1. 预留扩展空间:在初期设计时就考虑未来扩展,预留空白字段或扩展槽。
  2. 制定详尽的兼容性规则:新增字段需设置合理默认值;字段编号一旦定义后,不允许轻易修改;对于数据类型的变更,严格评估风险并提前进行兼容性测试。
  3. 辅助工具与自动校验:利用 Schema Registry 和自动化测试工具,对不同版本之间的兼容性进行持续校验,确保数据在跨版本交互时能够正确解析。

5. 数据流模式

在构建数据密集型应用系统时,数据在各个组件间的传输方式尤为关键。书中详细论述了三种常见的数据流模式,数据库,服务,消息传递,每种方式都有其适用场景和权衡。本节将逐一解析这三种数据流模式。

1. 基于数据库的数据流

在这种模式下,写入数据库的进程负责对数据进行编码,而读取数据库的进程负责解码。可以把写入数据到数据库看作“给未来的自己发送消息”。这种设计要求非常高的兼容性,因为往往会出现同一数据库由不同版本代码访问的情况。

  1. 向后兼容性:当数据库只由单个进程访问时,新版读取自己写入的旧数据没有问题。但在多个进程同时访问(比如不同服务或同一服务的多个实例并存)时,新代码写入的新字段可能会被旧代码读取后更新,再写回数据库。如果读取过程丢失了这些未知字段(即旧代码不理解新字段),数据就会出现意外丢失。
  2. 向前兼容性:在升级过程中,新代码写入了新字段,而旧版应用不认识这些字段时,必须保证旧代码在读取数据后再写回时能保留新字段的原始值。这种机制要求数据库解码/编码过程能够保存未知字段,防止数据的意外丢失。
  3. 许多关系数据库允许在不重写已有数据的情况下添加新列。读取旧行时,系统自动为缺失的新字段填充为空值,从而在逻辑上使整个数据库看起来采用单一模式编码。
  4. LinkedIn 的文档数据库 Espresso 就利用 Avro 存储数据,并支持模式横化规则,使得存储中虽然包含不同时代的数据记录,但外部访问时统一为一种模式。

在这里插入图片描述

2. 基于服务的数据流(REST / RPC)

在现代系统架构中,服务通常通过网络进行通信。客户端和服务器之间的数据交互构成了基于服务的数据流。

  1. 客户端(如 Web 浏览器、移动应用或其他服务)通过网络请求连接服务器。服务器提供的 API(RESTful 或 SOAP 接口)定义了输入和输出的具体格式。由于这些 API 往往事先在服务端约定好,因此客户端和服务器均须遵循这一共同的协议。

  2. 然而,这就带来了兼容性的挑战。同一服务可能存在新旧版本同时运行的情况(例如,新版本已经部署,而部分客户端仍调用旧API)。因此,服务器在响应时必须向前兼容,即使旧版客户端不认识新字段,也不会破坏数据的正确性;反之,新版客户端在读取旧版数据时,也必须由默认值等手段保证向后兼容。

  3. 在这一点上,REST 和 远程过程调用(RPC)产生了鲜明的对比。RESTful API 倾向于简单、公开标准:请求通常为 JSON 或 URI 编码的表单数据,响应中增加新字段一般不会造成问题。RPC(如 Thrift、gRPC 或基于 Avro 的 RPC)则更注重消息的二进制编码,在新旧版本的升级过程中,通常要求服务器先更新,再逐步升级客户端,从而只需要保证请求上向后兼容,而响应上要求向前兼容。

  4. Martin Kleppmann 特别提及了 RPC,这是一种使得向远程服务发出请求看起来类似于本地函数调用的技术思想。它具有以下几个特点。(RPC是一项非常重要且泛用的技术,后期博主将会单独出一个系列讲述其特点并实现一个简易的RPC框架)

  1. 本地函数调用是可预测的,而网络请求存在延迟和丢失地可能
  2. 本地调用种,指针引用几乎是瞬时地,在RPC中,所有参数必须编码成字节流后再传输;跨语言调用中,各种编程语言支持的数据类型并不完全一致,比如 Javascript 的数字精度问题。
  3. RPC调用接口可能会丢失,因此必须提供重试机制,但是多次执行会导致业务不幂等,需要单独处理。
  4. RPC与普通的REST开发不同点在于,RPC一般都是先更新服务器,再更新客户端,因此,通常只需要在RPC上保证向后兼容,即新服务器能正确解析旧版请求。

3. 基于消息传递的数据流

基于消息传递的数据流模式利用消息代理(也称为消息队列或面向消息的中间件)实现进程间的数据传输。

  1. 发送方将消息发布到队列或主题,消息代理负责确保消息传递给一个或多个消费者,而发送方无需等待响应。这种异步方式能显著提升系统的伸缩性与容错能力。这样的数据流有一个好处,那就是如果消费者不可用,消息可以在代理中暂存,待系统恢复后继续传输。同时,代理通常支持重试机制,防止消息因网络故障而丢失。

  2. 同基于数据库的数据流类似,当消息在传递过程中经过重新编码时,必须确保未知字段(即新版本的扩展字段)能被保留。如果消费者将消息重新编码后丢失了这些数据,可能会在后续流转中产生问题。
    因此,消息传递系统设计时也需考虑向前和向后兼容性,特别是在多版本服务共存、Actor模型或跨节点系统中,各节点可能运行不同版本代码,必须确保编码数据在各个版本间正确无损地转换。

  3. Martin Kleppmann 特别地介绍了分布式 Actor 框架,部分框架(如 Akka、Orleans 或 Erlang OTP)将 Actor 编程模型与消息代理结合,提供了在跨节点环境下处理并发和消息传递的解决方案。这些框架也面临着新版与旧版之间兼容性的问题,需要能适应消息从新版本节点发送到旧版本节点、以及相反的情况。

6.总结

在这一篇博文中,我们研究了内存数据结构转换为网络或磁盘上字节流的多种方法,正如我们看到的,编码不仅影响效率,更影响着应用系统的体系结构。我们讨论了Json / Xml / Csv 等传统技术,也讨论了 Thrift / Protocol Buffers / Avro 等海量数据系统中主流框架。最后,我们讨论了几种数据流的模型,阐明了编码在不同场景下的重要性。


下节预告:
数据分身 —— 让数据拥有永续生命,确保系统高可用的奥义

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值