《数据密集型应用系统设计》(DDIA)阅读笔记
《数据密集型应用系统设计》(DDIA)阅读笔记
数据的储存结构
对于如何在磁盘上组织数据,有两个主要的数据结构:B-tree和lsm-tree。
lsm-tree
这是一张描述lsm-tree存储结构的图,出自https://mp.weixin.qq.com/s/s8s6VtqwdyjthR6EtuhnUA
上述文章的作者的mini-bitcask项目基于lsm-tree实现了一个简单的数据库,非常适合新手学习。
lsm-tree比较简单,它的核心思想是以追加日志的形式组织数据,在内存中维护索引,索引保存的是一条数据在日志文件中的偏移量。
如果你修改了一条数据,并不会在原有位置更新,而是把新数据追加在文件末尾。
为了避免前面的无用数据浪费太多磁盘空间,可以择机启动后台压缩进程,整理出一份新的数据文件并替换老数据文件。
因为磁盘顺序写入的速度远高于随机写入,所以lsm-tree的写入性能很好。
mysql的redoLog其实就借鉴了lsm-tree的思想,为了加快写入速度,数据可以不立即写入b+树中,先写进内存,为了防止数据丢失,数据还是要以追加日志的形式在磁盘上写一份,以便崩溃时恢复数据。虽然还是要写磁盘,但是顺序写入redoLog要比随机写入b+树快多了。
b-tree
对于b-tree,背过面试知识点的同学应该很熟悉,可以理解为一个为了适应磁盘读写而改造的二叉树。
由于b-tree树的高度可以做到很矮且平衡,而且不需要像lsm-tree那样进行昂贵的压缩操作,b-tree的读性能优秀且稳定。
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接附加到树上。
上述两个优点使得b-tree成为了关系型数据组织数据的主流选择。
内存型数据库
反直觉的是,内存数据库的性能优势并不是因为它们不需要从硬盘读取。因为只要有足够的内存,即使是基于硬盘的存储引擎也可能永远不需要从硬盘读取,因为操作系统在内存中缓存了最近使用的硬盘块。内存型数据库更快的原因在于省去了将内存数据结构编码为硬盘数据结构的开销。
内存中的数据结构可以非常灵活,但是一旦放进磁盘里就要受到诸多限制,比如一条数据的大小可能远小于一个磁盘块的大小、随机读写性能差等。如果压根就不考虑以磁盘为主要存储介质,内存数据库无论是设计还是性能优化上都要轻松不少。
olap和列式存储
oltp(On-Line Transaction Processing)数据库为在线系统而生,更注重事务的实时性,olap(On-Line Analytical Processing)数据库为离线分析而生,更注重聚合计算的吞吐量。
在分析数据时,我们操作的对象不再是一行一行的对象,而是大量行的某几列,比如计算所有男性用户的订单金额平均数。为了适应这个场景,列式存储诞生了。
在列式存储数据库中,一行数据不再存放在一起,而是按列拆开存储,比如第20行数据的3个字段被分别存储在3个文件中的第20行。读取时,只读取需要的列。
由于同一列的数据一般结构相似且重复多,这就非常适合压缩,比如订单表有1亿行数据,但是其商品id列去重后只有几百种,压缩可以大大缩小存储空间,使得列式存储数据库可以处理大量的数据。
类似的压缩思想在influxdb中也有应用,influxdb中的列分为两种:tag和field。field就是数据部分(比如温度),tag是元数据(比如国家、城市),可以理解为联合唯一索引,其组合应该数量有限,以便压缩。我曾经出于某种需求在tag里加了一列uuid,这导致针对tag的压缩效率降低。
序列化和反序列化
内存中的一个对象,如果要持久化保存到硬盘,或者把它通过网络传输到别处,直接把内存里的0和1照搬过去是不行的,因为内存里的指针一旦脱离本进程就失去了意义。这就要求有一种方法能把内存中的对象序列化为一串二进制数据,在需要的时候反序列化进内存使用。
序列化方案有很多种,包括:
- 语言自带的序列化协议:java的Serializable、python的pickle等
- 文本形式:json、xml、csv等
- 语言中立的二进制编码:thrift、protobuf等
在微服务的浪潮下,语言自带的序列化方案为不同语言编写的服务之间的沟通制造了障碍,现在已经式微。
json等文本形式的序列化方案在可读性和语言中立性上很好,但是和thrift这类方案相比,传输效率不够高。
我们重点关注设计更考究、且不是那么为人熟知的语言中立的二进制编码方案。
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
上面是一个thrift的idl(Interface Definition Language),定义了一个Person结构。
这和大部分语言定义结构的写法差不多,但是最明显的区别就是idl每个字段前面有个数字(标签)。
一个Person对象序列化后产生的二进制数据如下图所示:
在一个对象序列化后产生的二进制数据中,标签被用于区分不同的字段,这就比json那种靠字段名区分字段要更节省空间(毕竟整数比字符串小多了),数据更紧凑,传输效率更高。至于idl里的字段名,那不重要,只是为了可读性而已,给它改名也无所谓,但是绝对不可以改标签。
除此之外,标签更重要的作用是保持模式的向前和向后兼容,以适应微服务时代的滚动升级。
- 向后兼容 (backward compatibility):新的代码可以读取由旧的代码写入的数据。
- 向前兼容 (forward compatibility):旧的代码可以读取由新的代码写入的数据。
在微服务时代,业务系统是由多个服务组成的,而为了保证发布期间服务不中断,并且新代码的bug造成的损失可控,要滚动升级,这就意味着同一时间可能同时存在着新版本和旧版本代码的实例,新代码要能读取由旧的代码写入的数据,旧代码也要能读取由新的代码写入的数据。
你可以添加新的字段到架构,只要你给每个字段一个新的标签号码。如果旧的代码(不知道你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的字段,你不能设置为必需。如果你要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入你添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 添加的每个字段必须是可选的或具有默认值。
删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性。这意味着你只能删除可选的字段(必需字段永远不能删除),而且你不能再次使用相同的标签号码(因为你可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
事务
事务的意义是简化应用层的处理逻辑,大量的错误(进程崩溃、网络中断、断电等)可以转化为简单的事务中止和应用层重试。应用层不需要关心只写入了一半的脏数据。
区间锁
如果一个事务希望自己即将修改的数据不被其他事务修改,可以使用select for update给即将写的目标行加锁,这样其他事务对被锁住行的写操作都会被阻塞,直到加锁的事务提交或回滚。
但是如果要写的数据并不存在,没有可加锁的对象,可以选择针对某一列加区间锁。
比如一个预定会议室的场景:
select * from booking where room_id = 111 and start_time > '2025-04-11 15:00:00' and end_time < '2025-04-11 16:00:00' for update;
# 应用层判断该区间没有存在的预定记录,决定写入一个新的预定记录。在该事务提交或回滚之前,其他事务无法插入这个时间段这个会议室的预定记录
insert ...
需要注意的是,区间锁是加在索引上的(在这个例子里就是room_id、start_time、end_time),如果列没有索引,就会导致全表扫描,给表里所有记录加锁,即使其他事务写的数据并没有落在你想限制的区间,也会被阻塞。所以使用区间锁的时候应该保证对应的列上存在索引。
数据复制
主从模型
一个数据库节点作为主节点,接受写请求和读请求。其余节点作为从节点,接受读请求。通过数据复制与主节点保持数据一致。
这种模型能够通过拓展从节点提高数据库的读性能。还可以把从节点放置在距离用户更近的机房,以降低附近地区用户的读延迟。
同步复制和异步复制
同步复制:主节点接收到写请求后,不仅要修改自己的数据,还要等待从节点同步完成,都写入成功才认为是写入成功。
异步复制:主节点完成自己数据的修改后,就可以返回。数据复制异步完成,这会导致从节点的数据和主节点出现短暂的不一致。
在实践中,往往是一主多从的结构,完全的同步复制会严重拖慢写入效率。而完全的异步复制可能导致主节点故障时,新的主节点缺少一部分尚未复制的数据。
一个折中的做法是选取一个从节点进行同步复制,保证有一个从节点一直有完整的数据,其余从节点异步复制。如下图:
多主模型
一主多从的模式下,所有的写请求必须经过主节点,对于那些远离主节点的用户来说,写延迟仍然很高。
进而产生了多主模型,多个主节点都可以接受写请求,通过异步复制把变更同步到其他主节点。这一模式又称“单元化”或者“set化”。如下图所示:
这一模式最大的问题在于多主同时对同一个数据进行写入导致的冲突,解决冲突的办法有很多但是都不太完美,避免冲突成为了最简单的办法。这通常需要从业务角度对数据进行切分,以保证每一个set的写请求都可以在自己的数据范围内完成,而不会出现跨set的写请求。
如果数据切分合理的话,一个set就是一个功能完善的整体,多个set分别部署在多个机房,可以实现机房级别的容灾。
数据分区
数据复制机制在所有节点上存储了全量的数据,数据量的大小仍然受限于单个节点的磁盘大小。为了存储更多数据,可以把数据拆分为多份,每个节点只储存一部分数据。通过水平扩容扩展存储总量。
每个节点都可以视为一个功能完整的小型数据库,具备读写的能力。
一条数据应该放在哪个分区,决定了你在读写时要去找哪些节点。和主从复制模式不同,你不能随便找一个节点,因为这个节点可能并没有你想要的数据。
常用方法是根据key的哈希值决定分区。就像字典一样,你只要知道这个字的读音/部首就知道在哪个范围查找了。
二级索引
对于kv结构的数据来说,key可以定位到唯一的分区。但是你拿到一个二级索引值,没有办法知道应该去哪个分区查。有两个办法解决这个问题:
二级索引存储在数据所在的节点
每个节点维护一份自己数据的二级索引,当处理二级索引查询时,需要查询所有节点,然后汇总查询结果。
举一个例子:如果没有办法集中管理所有班级的人的身高,还想找出最高的10个人,只能让每个班查出本班最高的10个人,然后把每个班的数据汇总起来,再找出所有班级最高的10个人。
这个办法的缺点是“读放大”,如果查询分页过深,需要汇总的数据量就会很大,例如es就限制from + size不能超出10000。
二级索引也分区存储
二级索引也像key一样分区存储,当处理二级索引查询时,根据查询条件就知道对应的索引存储在哪个节点,到对应节点查出key再查具体数据。
这样虽然避免了“读放大”的问题,但是写数据导致的二级索引更新可能要涉及到多个节点,同步更新会拖慢写入效率,实践中都采用异步更新,当然代价是数据和索引短暂不一致。
批处理
与关心响应时效性的在线服务不同,批处理服务更关心数据处理的吞吐量。
以分析一个web服务日志文件的需求为例,每一次请求都会产生以下日志:
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
抽象格式如下:
$remote_addr - $remote_user [$time_local] "$request"
$status $body_bytes_sent "$http_referer" "$http_user_agent"
unix哲学
如果想找出网站上找到五个最受欢迎的网页,可以使用以下shell命令:
cat /var/log/nginx/access.log | #1
awk '{print $7}' | #2
sort | #3
uniq -c | #4
sort -r -n | #5
head -n 5 #6
含义为:
- 读取日志文件
- 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的 URL。在我们的例子中是
/css/typography.css
。 - 按字母顺序排列请求的 URL 列表。如果某个 URL 被请求过 n 次,那么排序后,文件将包含连续重复出现 n 次的该 URL。
uniq
命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。-c
则表示还要输出一个计数器:对于每个不同的 URL,它会报告输入中出现该 URL 的次数。- 第二种排序按每行起始处的数字(
-n
)排序,这是 URL 的请求次数。然后逆序(-r
)返回结果,大的数字在前。 - 最后,只输出前五行(
-n 5
),并丢弃其余的。该系列命令的输出如下所示:
4189 /favicon.ico
3631 /2013/05/24/improving-security-of-ssh-private-keys.html
2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
1369 /
915 /css/typography.css
我们通过“管道”把几个简单的程序组合起来,实现了一个更高级的程序。这是一种unix哲学:
- 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加feature让老程序复杂化。
- **期待每个程序的输出成为另一个程序的输入。**不要将无关信息混入输出。避免使用严格的表格状数据或二进制输入格式。不要坚持交互式输入。
- 尽早地开始设计和构建软件(甚至是操作系统),并且在需要抛弃一些老设计时不要犹豫。
- 优先使用工具来减轻编程任务(即使编写工具也需要花费时间),并且做好用完就扔的心理预期。
这种方法 —— 自动化,快速原型设计,增量式迭代,测试友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和 DevOps 运动。
分布式的unix
在需要处理的数据规模大到单机无法承载时,使用相同思想的hadoop出现了,它基于hdfs,把多台机器通过网络组合成一个巨大的文件系统,并可以组合多个MapReduce任务实现大规模的数据分析。
一个MapReduce任务可以分为以下几步:
- 读取hdfs上指定位置的文件,把文件拆分为多块,分发给多个mapper。
- mapper处理生成多个键值对
- 对上一步产生的键值对根据key排序、分区,分发给多个reducer
- 对于key相同的键值对集合,会被一个reducer处理,输出结果到hdfs指定位置
观察第一步和最后一步,可以发现MapReduce任务可以首尾相接组合起来,一个MapReduce任务的输出可以作为另一个MapReduce任务的输入,他们之间通过hdfs文件衔接。
面向频繁故障的设计
在分布式系统中,出错是不可避免的,网络、硬件等问题的发生是不可预测的。如果一个MapReduce任务出错,它应该能被自动重试解决(除非是逻辑bug)。这就要求MapReduce任务应该是没有副作用的。即原始数据不可变,且重试不会对外界产生影响(比如写入外部数据库)。
除了容错以外,这种设计还带来了两个额外的好处:测试友好、提高资源利用率。
任务可以随心所欲地重新运行,便于使用自动化测试工具,也便于debug,这很好理解。
提高资源利用率这个优点,要结合另一个背景来理解:容器
MapReduce最早由google提出,k8s也是google自家容器编排系统borg的开源版本。google使用容器运行MapReduce任务也是很自然的,容器经常会为了给更高优先级的服务腾出资源而被杀死,这种概率远远大于网络、硬件故障导致的失败概率。
允许被随时杀死,同时也就意味着在资源空闲时可以过度使用资源——反正真要用资源的时候把它赶走就是了。
MapReduce任务牺牲了资源保障的优先级,获得了充分利用空闲资源的权利,以提高吞吐量。这虽然看起来矛盾,但是的确提升了整体资源利用率。
在《凤凰架构》介绍k8s资源与调度的章节中写到了pod的资源要求和优先级的关系,优先级最低的pod就是那种既不声明资源需求量(requests),又不声明资源使用上限(limits)的pod,在调度时他们更容易被分配资源,当资源不足时也会被优先杀死。
流处理
与批处理不同,流处理面向的是无界的、持续增长的数据。
流处理的典型应用是消息队列,而消息队列又分为两种:
- 以RabbitMQ为代表的传统消息队列。在消费者回复确认后会删除消息。
- 以Kafka为代表的基于日志存储的消息队列。消息以追加日志的形式存储,消费者的消费进度体现为日志文件的偏移量,偏移量会随着消息的消费而增加,但不会删除数据(使用循环缓冲区可以覆盖最老的数据,避免磁盘空间耗尽)
主题和消费组
当存在多个消费者时,有两种模式:
- 负载均衡式:一条消息只能被一个消费者消费,增加消费者数量可以提高消费速度。
- 广播式:一条消息会被所有消费者消费,消费者之间是隔离的,不会互相影响。
RabbitMQ为了实现这两种模式,设计了交换机和队列的概念:
为了实现这两种模式,kafka也设计了两个概念:
- 主题:同一种消息的队列。
- 消费组:消费组需要绑定一个主题,同一消费组内的所有消费者采用负载均衡模式消费,不同消费组之间是隔离的。
因为消息是日志式存储的,被消费后并不会被删除,各个消费者只是在顺序地读取同一个日志文件,因此实现广播是很直观的:不同消费组在顺序读取的时候都经过了同一个消息,就都读到了。例如下图中1号消息就被消费组A和消费组B都读到了。
而负载均衡式的消费,就要依靠分区来实现了。同一消费组内,一个消费者消费一个分区。
那为什么同一个消费组内的多个消费者不消费同一个分区呢?因为这使得维护偏移量变得复杂起来。实践中推荐一个分区一个消费者,通过增加分区数量提高并发度。
日志式消息还有一个优点就是可以轻易地从一个历史事件点重新开始消费,因为这只需要修改偏移量。
消费速度跟不上生产速度会发生什么
消息会积压,这在rabbitmq上会导致磁盘空间不足,写入受阻。
但是kafka没有消息积压这一说,只不过消费者的偏移量远远落后于最新日志而已。由于循环缓冲区的设计,最老的日志会被最新的日志覆盖,可能会出现一些消息还没来得及被消费者消费,就被新消息覆盖的情况。这有点像赛车游戏里,跑的慢的赛车被跑的快的赛车从后面超过,落后一圈。
但是这只会影响那些落后特别多的消费者,其他消费者仍然在消费最新的消息。
所以对于kafka自己来说,消费速度跟不上生产速度造成的破坏是有限的。
多系统数据同步
以数据库数据为原始数据,在搜索引擎中构建索引(派生数据)是一个常见的设计,保持这二者数据同步的一个简单方式是双写,即修改数据时同时修改数据库和搜索引擎。抛开一个成功一个失败这样显然的问题以外,还可能发生竞争问题:
两个客户端同时想要更新一个项目 X:客户端 1 想要将值设置为 A,客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端 1 的写入将值设置为 A,然后来自客户端 2 的写入将值设置为 B,因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误,这两个系统现在也永久地不一致了。

既然数据的流向是数据库 -> 搜索引擎,那能不能借鉴主从复制的思想,让数据库作为“主库”,数据复制到搜索引擎这个“从库”呢?这样,搜索引擎的数据最终总能和数据库同步。
通过读取数据库复制日志可以做到这一点,这称之为变更数据捕获(change data capture, CDC),应用实例有 LinkedIn 的 Databus,阿里的canal。基于这种思想,可以方便地维护一个以原始数据为源头的派生数据体系。这种方式的缺点和主从复制一样,是主从延迟,但优点是最终一致性——分布式环境中两害相权取其轻的妥协结果。

使用不可变日志构建数据库
kafka最独特的点就是以不可变日志的形式存储数据,但这其实并非新发明。会计已经运用这种思想构建账本很久了,当一笔交易发生时,将其追加到账本的末尾。如果有一笔账记错了,也不会在原纪录上修改,而是在末尾新增一条修正的记录。
如果运行了破坏数据的错误代码,就地修改数据的传统数据库就难以恢复,而不可变日志构建的数据库能够很轻易地恢复到被破坏前的样子。
数据库新增了一条数据,然后经历了怎样的修改,对于一些场景来说具有分析意义,例如分析用户行为。
数据系统的未来
离线客户端
可以把客户端作为一种派生数据系统,在客户端存储一些数据,使其在离线状态下依然可用。通过基于日志的流处理+订阅变更流的方式在联网时更新客户端数据。
exactly-once
在流处理中,如果对一条数据处理失败,可能发生自动重试,如果没有做好幂等性保障,重试可能导致错误的结果,例如多次扣款。我们需要确保即使操作被重试,也像执行一次一样,不多也不少。
方式之一是在客户端为写操作生成一个请求id,在请求的所有处理环节中传递和检查(直到数据库),确保相同的操作只执行一次。
例如一个转账事务:
ALTER TABLE requests ADD UNIQUE (request_id);
BEGIN TRANSACTION;
INSERT INTO requests
(request_id, from_account, to_account, amount)
VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00);
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;
如果相同请求id已经执行过一次转账操作,第一条sql就会因为唯一索引冲突而失败,导致整个事务失败,避免了重复操作。
在单分区情况下,数据库可以很容易地实现上面这个事务,但是如果这三张表分别属于不同分区呢?你会很自然地联想到分布式事务。
分布式事务很复杂、效率低下。(在分布式环境中达成共识本身就是一件复杂的事情)而且并非所有系统都支持分布式事务协议,所以我不喜欢分布式事务的方案。
可以借助日志流处理的思路实现一个新方案:
- 从账户 A 向账户 B 转账的请求由客户端提供一个唯一的请求 ID,并按请求 ID 追加写入相应日志分区。
- 流处理器读取请求日志。对于每个请求消息,它向输出流发出两条消息:付款人账户 A 的扣款指令(按 A 分区),收款人 B 的收款指令(按 B 分区)。被发出的消息中会带有原始的请求 ID。
- 后续处理器消费扣款 / 收款指令流,按照请求 ID 除重,并将变更应用至账户余额。
第一个操作只写入了一个日志对象,这很容易保证原子性。
第二个操作如果中途失败,可以重试,因为其算法是确定的、参数是一样的,生成的两条消息无论怎么重试都不会变,只不过可能会出现多个重复的消息。
第三个操作借助请求id,在自己的分区可以实现幂等的写入,如果因为请求id冲突以外的其他原因写入失败,还可以重新消费消息。
我们就这样通过日志流处理绕过了分布式事务。
抽象的来讲,使用日志流处理保障多系统之间的数据完整性的方式如下:
- 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入
- 使用确定性的派生函数,从这一消息中衍生出所有其他的状态变更
- 将客户端生成的请求 ID 传递通过所有的处理环节,从而允许端到端的除重,带来幂等性。
- 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易
宽松的约束
当偶尔打破约束的成本可以接受时,也可以不使用上述的复杂方法。例如:
- 如果两个人同时注册了相同的用户名或预订了相同的座位,你可以给其中一个人发消息道歉,并要求他们换一个不同的用户名或座位。这种纠正错误的变化被称为 补偿性事务(compensating transaction)
- 如果客户订购的物品多于仓库中的物品,你可以下单补仓,并为延误向客户道歉,向他们提供折扣。实际上,这么说吧,如果叉车在仓库中轧过了你的货物,剩下的货物比你想象的要少,那么你也是得这么做。因此,既然道歉工作流无论如何已经成为你商业过程中的一部分了,那么对库存物品数目添加线性一致的约束可能就没必要了。
- 与之类似,许多航空公司都会超卖机票,打着一些旅客可能会错过航班的算盘;许多旅馆也会超卖客房,抱着部分客人可能会取消预订的期望。在这些情况下,出于商业原因而故意违反了 “一人一座” 的约束;当需求超过供给的情况出现时,就会进入补偿流程(退款、升级舱位 / 房型、提供隔壁酒店的免费的房间)。即使没有超卖,为了应对由恶劣天气或员工罢工导致的航班取消,你还是需要道歉与补偿流程 —— 从这些问题中恢复仅仅是商业活动的正常组成部分。
是否严格遵循约束是一个需要权衡的选择。
可审计性的设计
“审计”通常出现在财务领域,但是当你对数据系统的正确性要求足够高时,对数据的审计也是必要的,因为足够大规模的系统在足够长时间内,小概率发生的错误也一定会发生。我们需要一种能发现并纠正错误的方法,就是审计。
例如,HDFS 和 Amazon S3 等大规模存储系统并不完全信任磁盘:它们运行后台进程持续回读文件,并将其与其他副本进行比较,并将文件从一个磁盘移动到另一个,以便降低静默损坏的风险。
基于事件日志(例如redis的aof)的数据系统的可审计性就很好,事件日志是不可变的,运行的代码是确定性的,使用相同的代码运行相同的事件日志得到的数据总是相同的。
基于上面的原理,可以很容易地执行数据审计:通过哈希校验确保事件日志存储无误,通过重复运行事件日志并比较结果和现有数据的区别。
即使你并不需要如此严谨的数据正确性,一个可以回放并复现历史场景的数据系统对于故障分析也是很有帮助的。
端到端的测试
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有 Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
检查数据系统的完整性,最好是以端到端的方式进行(从数据的生产端到消费端):我们能在完整性检查中涵盖的系统越多,某些处理中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。
持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地前进。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更快更好地迭代一个应用,使其满足不断变化的需求。
本书前面还提到一个类似的观点:如果变更可以回滚,你有退路可走,那你就能向前走地更安心、更快。
做正确的事
正确地使用技术和数据,认真地对待其对世界和人的影响。
本书末尾探讨了算法决策可能带来的不公、大数据时代对个人隐私自由的剥夺等问题。在技术书籍中看到这些内容令我有些意外,但我认同技术不应被用来做恶,应该推动文明的发展。
那一刻,我理解了那句话所代表的精神:Make the world a better place.