13. 数据系统的未来
如何构建现代数据系统
1. 构建现代系统
前面我们讨论当前流行的技术,接下来我们综合之前所说的所有知识,来谈谈未来的系统应该是什么样子。
1.1 数据集成
首先每个软件,即使所谓的通用数据库,也是针对特定的使用模式而设计,第一个挑战就是弄清楚软件产品与他们适合运行环境之间的关系。
其次在复杂的应用中,数据通常以多种不同的方式使用,不太可能存在适用于所有不同环境的软件,因此我们需要数据继承。
当同样的数据的多个副本需要保存在不同的存储系统,以满足不同的访问模式需求时。我们需要弄清楚数据的输入和输出。通过变更数据捕获我们可以保证异构数据系统的一致性。
允许应用程序同时向搜索索引和数据库写入会带来问题。两个客户端同时发送冲突的写操作,两个存储系统以不同的顺序执行它们。
如果可以通过单个系统来决定所有输入的写入顺序,那么以相同的顺序处理写操作就可以更容易的派生出数据的其他形式。无论是使用变更数据捕获还是事件获取日志,都不如简化总体顺序的原则重要。
根据事件日志来更新一个派生系统通常会比较好实现,并且可以实现确定性和幂等性,也因此使系统很容易从故障中恢复。
1.1 派生数据与分布式事务
保证异构数据系统的一致性,经典的方法是通过分布式事务。与分布式事务相比,上面描述的派生数据系统怎么样呢?
抽象点说,它们通过不同的方式达到类似的目标:
- 分布式事务
- 通过使用锁机制进行互斥来决定写操作的顺序
- 使用原子提交保证更改只生效一次
- CDC 和事件溯源
- 使用日志进行排序
- 基于确定性重试和幂等保证更改只生效一次
最大的不同在于事务系统通常提供线性化,这意味着它可以保证读自己的写等一致性。派生系统通常是异步封信的,所以默认情况下无法提供类似级别的保证。
分布式事务的容错性和性能不尽如意,考虑到好的分布式事务协议未得到广泛支持,基于日志的派生数据是集成不同数据系统最有前途的方法。但是一些保证仍是非常有用的,后面我们会讨论在异步派生系统之上实现更强保证的一些方法。
1.2 全序的局限
非常小的系统,构建一个完全有序的事件日志是完全可行的,但是当负载增加时,瓶颈就会出现:
- 大多数情况下,构建一个完全有序的日志需要所有事件都通过一个主节点来决定排序。如果事件吞吐量大于单节点处理上限,则需要将其分区到多台节点上,这样就是的不同分区的事件顺序变得不明确。
- 如果服务部署在多个地理位置不同的数据中心,为了高可用,每个数据中心都有自己的主节点。这意味着来自两个不同数据中心的事件顺序不确定
- 微服务的设计目的是将每个服务与其持久化状态作为独立单元部署,服务之间不共享持久化状态。当两个事件来自不同服务时,事件顺序不确定
从形式上讲,决定事件的全序关系被称为全序关系广播(性能瓶颈),它等价于共识。大多数共识算法是针对当节点吞吐量足以处理整个事件流而设计的,这些算法不提供支持多节点共享事件排序的机制。
1.3 排序事件以捕获因果关系
排序事件是为了捕获因果关系,但是这个问题没有简单的答案。
2. 围绕数据流设计应用程序
2.1 应用程序代码与状态分离
现在大多数Web 应用程序都被部署为无状态服务,状态势必保存在某个地方,通常是数据库。在这种 Web应用程序模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用程序如果想知道数据的内容是否变化唯一的选择就是轮询。
从数据流的角度思考应用意味着重新协调应用代码和状态管理之间的关系。我们不是简单将数据库视为被应用程序所操纵的被动变量,而是更多的考虑状态、状态变化以及处理代码之间的相互作用和协调关系。应用程序代码在某个地方会触发状态变化,而在另一个地方有队状态变化做出响应。变更数据捕获和基于日志的事件流可以让我们做到这一点。
维护派生数据与异步作业执行不同:
- 当维护派生数据时,状态更改的顺序通常很重要
- 容错性是派生数据的关键,丢失单个消息都将导致派生数据集与数据源不同步。消息传递和派生状态更新都必须可靠。
对稳定的消息排序和可容错的消息处理的要求都非常严格,但是它们比分布式事务代价要小得多,并且在操作上更加稳健。现代流式处理可以对大规模环境提供排序和可靠性保证,并允许应用代码作为stream operator 运行。
订阅变化的流,而不是在需要时去查询状态,使得我们更接近电子表格那样的计算模型: 当某些数据发生更改时,依赖于此的所有派生数据都可以快速更新。
2.2 观察派生状态
从数据源到生成派生数据系统,我们把这个过程称为写路径。当用户访问时,所做的数据读取,我们成为读路径。写路径和读路径涵盖了数据的整个过程: 从数据收集到数据使用。读写路径在派生数据上交会,某种程度上,我们所做的系统架构设计就是在写入时需要完成的工作量和读取时需要完成的工作量之间进行的一种权衡。从这个角度看,缓存、索引、实体化视图的角色主要是调整读、写路径之间的边界。

随着 WebSocket 的出现,使得服务器可以主动推送消息至Web应用。就我们的读写路径模型而言,主动推送状态至客户端意味着将写路径一直延伸至终端用户。因此状态变化可以通过端到端的写路径流动: 某个设备上交互行为触发了状态变化,通过事件日志,派生数据系统和流式处理等,一直到另一台设备上用户观察到状态。这种状态变化传播可以做到很低的延迟。
那为什么我们看到的大多数应用都不是这种实现方式呢。这源于目前数据库、开发框架、交互协议对无状态客户端和请求/响应交互根深蒂固的假设。许多数据存储系统的读写操作都是一个请求对应一个响应,很少支持订阅更改。
为了将写路径扩展到最终用户,我们需要从根本上重新思考构建这些系统的方式: 从请求/响应转向发布/订阅数据流。更具响应性的用户界面和更好的离线支持是绝对值得尝试的。
读也是事件
我们讨论了当流式处理将派生数据写入存储时,以及请求查询存储时,存储充当了上述写路径和读路径之间的一种可调边界。对存储的写入是通过事件日志进行的,而读取则是即时的网络请求,查询直接路由到那些存储数据节点。这样的流程是合理的,但不是唯一的设计方案。
也可以将读请求表示为事件流,发送至流处理系统,流处理系统则将读结果发送至输出流来响应读事件。这是一种设想,原书接下来的内容已经看不太懂了。
3. 端到端的正确性
3.1 Exactly-once 执行操作
要想实现呢 exactly-one 语义,最有效的方法之一是使用幂等。但是如果操作本身不是幂等,将其改造成幂等需要一定努力。比如可能需要维护额外的元数据,并确保在节点失效、切换过程中采取必要的 fencing 措施。
3.2 操作标识符
为了实现跨多次网络跳转请求而操作仍然具有幂等性,仅仅依靠数据库提供的事务机制是不够的,需要考虑请求的端到端过程。
例如可以为操作生成一个唯一的标识符(如UUID),并将其作为一个移仓的表单字段包含在客户机应用程序中;或者对所有相关表单字段计算一个哈希值来代表操作ID。如果浏览器两次提交Post请求,则两个请求具有相同的操作ID,然后可以将该操作ID一直传递到数据库,检查确保对一个给定的ID只执行一个操作。
消除重复事务,只是一种更为普遍的端到端论点原则的一例。端到端原则指的是:
只要具备应用程序充分的知识,并且站在通信系统端点的角度的情况下,才能完全正确的实现所关注的功能。因此以通信系统本身的特征来提供这种被质疑的功能是不可能的。
要想实现从Web应用到最终数据的重复消除。单独依赖 TCP 协议在连接层消除重复的包、流处理系统在消息处理级别的 exactly-once 语义是不行的,因为它们都不能防止用户在第一次超时后提交重复的请求。解决这个问题需要一个端到端的解决方案: 从终端用户客户端一致传递到数据库的唯一事务标识符。
端到端的参数也适用于检查数据的完整性: 以太网、TCP 内置的校验码可以检测网络包的损坏情况,但是不能防止收发两端的bug和磁盘的异常。如果想要捕获所有可能的数据源损坏,还是需要端到端的校验和。类似的讨论也适用于加密。底层的功能可以降低更高层次的问题,但不足以确保端到端的正确性。
3.3 在数据系统中采用端到端的思路
长期以来,事务被认为是一个非常好的抽象,它将诸多问题归结为两种可能结果: 提交或中止。
事务处理的代价却很高,特别是异构系统。当我们因此代价而放弃分布式事务,就不得不在应用程序代码中重新实现容错机制时。但是关于并发性和部分错误的推理非常困难而且不直观,所以应用程序的容错机制几乎难以正确工作,最终结果是丢失或损坏数据。
更好的容错是非常必要的: 它能够更容易提供特定应用的端到端正确保证,并在大规模分布式环境中依然具有良好的性能和良好的操作。
4. 强制约束
4.1 唯一性约束
唯一性约束需要达成共识: 多个相同值的并发请求,系统需要决定接受哪个并决绝所有其他的。达成这一共识最简单方法是单一主节点,并负责作出所有决定。如果需要容忍主节点出错,那么又重新回到共识问题上了。采用分区方案可以提高唯一性检查的扩展性,即基于要求唯一性的字段进行分区。但是它无法支持异步的多主节点复制,因为可能会发生不同的主节点同时接受冲突写入,无法保证值唯一。
4.2 基于日志的消息传递唯一性(重要)
日志机制可以保证所有消费者以相同的顺序查看消息,这种保证在形式上被称为全序关系广播,它等价于共识。流处理系统在单线程上严格按照顺序来消费处理日志分区中的所有消息。因此如果根据需要保证唯一性的字段进行日志分区,则流处理系统可以清晰、明确地确定多个冲突操作哪一个先到达。
典型的例子: 多个用户尝试声明相同的用户名:
- 按照用户名哈希值确定分区
- 流处理顺序读取日志中的消息,并在数据库中跟踪已经使用的用户名,用户名未创建则向输出流发送成功消息,否则发送失败消息
- 请求用户名客户端观察输出流,并等待请求是否成功或失败
这种方法可以适用于很多类型的约束,其原理是: 任何可能冲突的写入都被路由到特定分区并按顺序处理
4.3 多分区请求处理(重要)
当涉及多个分区时,确保操作原子执行且同时满足各种约束条件是很有趣的事情。比如可能有三个分区,一个包含请求ID,一个包含收款人账户,一个包含付款人账户。在传统的数据库中,执行一个转账事务需要横跨所有三个分区进行原子提交。这相当于对三个分区上的所有事务执行了全序排列。由于跨分区协调,性能往往很低。
我们可以实现同等的正确性,但不需要原子提交:
- 转账请求首先需要客户端生成唯一的请求ID,并基于请求ID追加到对应的日志分区
- 流处理系统读取请求日志,对每个请求,发送两条输出消息: 到付款人的付款指令,以及到收款人收款指令,请求ID 需要包含在两条消息中
- 后续操作接收上述指令完成更改应用于账户余额,并通过请求ID进行重复数据消除
- 如果想确保付款人不发生透支,可以在步骤一前添加另一个流处理操作符: 维护账户余额并验证转账金额,只有不透支才能放入请求日志中。
上述过程中,我们首先持久化地将请求记录为单条消息,然后从第一条消息派生出收款和支付两条指令。单一对象写入在几乎所有的数据系统中都是原子的。而如果是客户端直接发送收款和付款指令,则需要在两个分区之间进行原子提交以确保两者都成功或者都不成功。
如果步骤二崩溃,从上一个快照检查点,这样做不会跳过任何消息,但可能多次处理消息,步骤3中基于端到端的请求ID可以轻松实现重复消除。
通过将多分区事务划分为不同分区的处理阶段,并使用端到端的请求ID,我们实现了同样的正确性。
5. 时效性和完整性(重要)
事务的一个重要属性是可线性化: 数据写入后立即对所有读者可见。但是跨多个处理阶段的流操作,情况并非如此。日志的消费者模式是基于异步设计的。因此发送者不等待消息处理,完事客户端可能会等待消息出现在输出流中。
概括将,一致性将两个值的分开考虑的不同需求: 时效性和完整性合二为一了:
- 时效性:
- 意味着确保用户观察到系统的最新状态
- CAP提供基于线性化的一致性,这是实时性的强有力保证
- 弱实时性,比如读自己写也很有用
- 完整性
- 意味着避免数据损坏,即没有数据丢失,也没有互相矛盾或错误的数据
总而言之,违反实时性导致最终一致性,违反完整性则是永久不一致,显然完整性比实时性重要的多。
5.1 数据流系统的正确性
ACID 事务通常既提供实时性保证(线性化),也提供完整性保证(原子提交)。因此对于ACID 事务,时效性和完整性之间的区别无关紧要。
基于事件的数据流系统,将时效性和完整性分开了。在异步处理事件流时,除非在返回之前明确创建了等待消息到达的消费者,否则不能保证实时性。完整性是流处理系统的核心。
只执行一次是一种保证完整性的机制。如果事件丢失或者发生两次,数据系统的完整性可能会被破坏,因此容错的消息传递和重复消除对面对故障时保持数据系统的完整性非常重要。
正如我们前面看到的,可靠的流处理系统可以在不需要分布式事务和原子提交协议的情况下保证完整性。我们主要是通过一下机制实现这一完整性的(非常重要):
- 将写入操作的内容表示为单条消息,可以轻松采用原子方式
- 使用确定性派生函数,从这条消息派生所有其他状态的更新操作
- 通过所有这些级别的处理来传递客户端生成的请求ID,实现端到端重复清楚和幂等性
- 消息不可变,并支持多次重新处理派生数据,从而使得错误恢复变得更容易
5.2 宽松的约束(重要)
如前所述,保证唯一性约束需要共识,通常通过单个节点汇集特定分区的所有事件来实现。如果想达到传统的唯一性约束,上述处理限制对于流处理系统必可避免。
但是实际上许多应用程序采用了较弱的唯一性来摆脱该限制。在许多商业环境中,实际上可以接受暂时违反约束,稍后通过道歉流程来修复。不如超售机票,然后道歉。道歉成本是否可接受是一个商业决策。如果可接受,在写入数据库之前检查所有约束的传统模型是不必要的限制,并且不需要线性化的约束。继续写入操作,并在既成事实之后检查约束,可能成为一个合理的选择。你仍然可以在发生恢复代价昂贵的事情之前进行验证,但并不意味着在写入数据之前必须进行验证。
而这些应用程序都需要完整性。
5.3 无需协调的数据系统
现在我们有两个有趣的结论:
- 数据流系统可以保证派生数据的完整性,无需原子提交,线性化或跨分区的同步协调
- 虽然严格的唯一性约束要求时效性和完整性,但是只要整体上保证完整性即使发生暂时约束损坏,也可以事后修复,因此许多应用程序实际采用宽松式的约束并没有问题
总之数据流系统在提供强大完整性的同时,避免了分区之间的协调。与需要执行同步协调的系统相比,可以实现更好的性能和容错能力。在这种情况下:
- 可串行化事务作为维护派生状态的一部分仅在限定的范围内有效
- 异构分布式事务(如XA)并不是必须的
- 同步协调只在需要的地方引入(例如在不可恢复的操作之前执行严格的限制),但是如果只有一小部分应用程序需要,就没有必要所有事务都进行协调。
另一种理解协调和约束的方法是: 它们减少了由于不一致而引发的道歉数量,但是也可能降低系统的性能和可用性,并由此可能增加业务中断和引发的道歉数量。你需要在这之间找到最佳的折中方案: 既不能有太多的不一致,也不能出现太多可用性问题。
6. 信任,但要确认
我们所有关于正确性、完整性、容错性的讨论都是基于某些事情会出错,而其他事情不会出错的假定基础上。我们将这些假设称为我们的系统模型。例如我们假定进程会崩溃、网络会丢包,但我们也假设写入磁盘并且强制同步命令 fsync 执行后不会丢失,内存中的数据结构不会被破坏,CPU 的乘法指令总是返回正确的结果。基于怎样的假设,依赖于事件发生的概览。
如果有足够多的的设备来运行你的软件,即便再不可能的事情也有可能发生。除了由于硬件故障或辐射导致的随机内存损坏之外,某些不合规的存储器访问模式可以在内存完好情况下导致位翻转。(这是极限情况下,才可能发生的)
6.1 不要盲目信任承诺
软硬件并不能总处于理想撞他,因此我们至少需要有办法查明数据是否已经损坏,以便之后修复这些数据。检查数据的完整性也被称为审计。成熟的系统会考虑不太可能的事情出错的可能性,并主动管理这些风险。例如 HDFS 和 Amazon S3 等大型存储系统不完全信任磁盘: 它们运行后台进程,不断读取文件,将其与其他副本进行比较,并将文件从一个磁盘移动到另一个磁盘,以减轻无提示数据损坏的风险。
我们需要这样自我验证和自我审计的系统。
检查数据系统的完整性最好以端到端的方式。
7. 做正确的事
7.1 预测性分析
我们将数据作为一个抽象的东西来讨论,但是许多数据集都是有关人的。我们必须以人性和尊重来对待这些数据。
预测分析系统只是基于过去而推断,如果过去有偏见,它们就会把这种偏见编码下来。如果我们希望未来比过去更好,你妈就需要道德想象力。数据和模型应该只是工具而不是我们的主人。
当预测分析影响人们的生活时,特别是由于自我强化反馈环路而出现一些有害问题。比如信用评分低的人,越受到社会的不信赖,信用评分越低。在我们设计一个系统时,我们需要系统的思考,尝试理解一个数据分析系统是如何响应不同的行为、结构和特征。系统是否强化好饿扩大了人们之前的差异。还试图打击不公平性。
7.2 数据隐私与跟踪
我们所使用的应用无时无刻不在收集有关个人的隐私数据。对于不同意被监视的用户,唯一的选择就是不使用服务。但是这个选择也不是免费的: 如果某一项服务非常受欢迎以至于被大多数人认为是基本的社会参与所需要的,那么指望人们选择退出这项服务是不合理的,使用它编程一种实时性强制约束,比如微信。对于大多数普通人,选择自由没有意义,被监视变得不可避免。
数据隐私和使用
拥有隐私并不意味着一切事情都要保密,它意味着你可以自由选择向谁展示,并展示哪些东西,要公开什么,要保密什么。隐私权是一个决定权:每个人都能够决定在各种情况下如何在保密和透明之间取舍。这事关个人的自由和自主。
通过各种应用从人们身上提取数据,隐私不一定被破坏,而知转移到了数据收集者。作为应用和数据系统的开发者,我们有责任明确的设计程序使得它们尊重人类的需求,否则算法对此没有概念。我们必须前序、乐于接受并为之做好准备。
审视他人但避免自我审查是最重要的权利形式之一,尽管今天的科技公司并没有公开的寻求某些权利,但是它们锁积累的数据和知识给了它们很大的控制权利,而且很多是私下进行,不在公众的监督之内。
作为工程师,我们有责任为我们赖以生存的世界而努力: 一个以人性和尊重来对待人的世界。