Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 前面几次着重讨论了在分布式系统中出现错误之后该如何处理。虽然长篇累牍谈了很多,但所谈到的情况还是过于乐观,现实则会更加复杂。我们可以根据墨菲定律做一个非常悲观的假定:即所有可能出错的事情最终一定会出错。 作为开发者,我们的核心任务是构建可靠的系统,即使系统面临各种出错的可能,也需要完成预定的工作(确保满足用户期望)。 所以,首先我们要充分认识目前所面临的挑战。 比如:故障可能来自网络问题,以及时钟与时序问题等等…… 在有一段工作经历中,我们的线上系统使用的是公有云,其中用到了不同地区的实例。两地区之间使用的是一条百兆带宽的专线。某个星期天的下午,领导通知我们有个服务出问题了,我查了程序日志之后,看到有许多访问上游服务网络超时的日志,即网络问题。随机运维查看之后告诉我们,上面提到的那条百兆专线被跑满了。至此,服务出问题的罪魁祸首已经找到——网络原因。当然,带宽被占满是由于业务增长还是某些服务出现bug抑或是恶意攻击,这就是另一个话题了。 所以,在我看来,所谓网络的不可靠性并不一定特指网络本身出了什么问题。 故障与部分失效 我们所开发的单点程序,通常会以一种确定的方式运行:要么工作,要么出错。单台节点上的软件通常不应该出现模棱两可的现象。而在分布式系统中,可能会出现系统的一部分正常工作,但其他部分出现难以预测的故障,我们称之为"部分失效"。 问题的难点就在于这种部分失效是不确定的:如果涉及多个节点和网络,几乎肯定会碰到有时网络正常,有时则莫名地失败。 正是由于这种不确定性和部分失效大大提高了分布式系统的复杂性。 不可靠的网络 我们目前关注的主要是分布式无共享系统,即通过网络连接多个节点。所以网络是跨节点通信的唯一途径,并且每台机器都有自己的内存和磁盘,一台机器不能直接访问另一台机器的内存或磁盘除非通过网络向对方发出请求。 诚然,无共享并不是构建集群系统的唯一方式,但却是当下构建互联网服务的主流方式。主要因为:硬件成本低廉,可以采用跨区域多数据中心来实现高可靠性,同时也可以给不同地域的用户提供更高的访问效率。 在我们的网络中一个节点发送数据到另一个节点,但是网络并不能保证他什么时候到达,甚至于,不能保证何时到达。 发送请求等待响应的过程中,有很多错误可能出现: 请求已经丢失(比如有人拔了网线,当然在系统上云之后这种物理层面的小问题,基本可以由底层的虚拟化系统来避免和保障) 请求可能在某个队列中等待,无法马上发送或响应(比如网络发送方或接收方已经超负荷,正如文章开头所提到的例子) 远程接收节点可能已经失效(比如依赖的上游服务崩溃,不过目前基于kubernetes的系统可以在一定程度上保障服务的稳定性) 远程节点可能暂时无法响应(比如正在运行长时间的垃圾回收。可以对服务进行内存的调优,可以在基于kubernetes的系统限制内存大小同时增加实例数等等) 远程接收节点已经完成了请求处理,但回复却在网络中丢失(比如交换机配置错误) 远程接收节点已经完成了请求处理,但回复却被延迟处理(比如网络或发送者的机器超出负荷) 处理类似的问题通常可以采用超市机制:在等待一段时间之后,如果仍然没有收到回复则选择放弃,并认为响应不会到达。 检测故障 许多系统都有自动检测节点失效这种的功能,比如 在ES中节点超过1分钟无响应则踢出集群,而后数据分片在正常的节点上进行重建。 在kubernetes中节点失效后,集群也会自动将失效节点的任务自动负载到其他节点之上。 超时与无限期的延迟 如果超时是故障检测唯一可行的方法,那么超时应该设置多长呢?很不幸,这并没有一个标准的答案。在上面提到的ES的例子是采用了一分钟的时间,延迟1分钟是为了防止偶尔的网络延迟等,因为将某个节点踢出集群后数据分片在集群中重新分配是需要消耗资源的。 设置较长超时则意味着更长时间的等待,才能宣告节点失效(在这期间,用户只能等待或者看到错误信息)。而较短的超时设置可以帮助更快地检测故障,但可能会出现误判,例如实际上节点只是出现短暂的性能波动(由于节点或者网络上的高负载峰值)。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 上一次我们主要从书中学习了主从架构消息同步相关的内容,而书中后面提到了多主节点复制(如多数据中心等)和无主节点复制(书中提到的Riak、Dynamo等并不了解,我认为最近比较火的一些区块链技术也是一些无主节点复制)。这两种模式在实际中(至少在我的认知范围内中小体量的公司基本不会维护一些多数据中心的场景)并不常见,这里不再过多讨论。 在一个单独的主从复制架构中,主节点和所有从节点都需要保存全量的数据。在项目初期,如果对未来的数据增量没有一个相对准确的判断,在业务发展一段时间之后应用就会遇到性能瓶颈,同时也有可能面临扩容困难等一系列问题。因此,分片的机制应运而生。 数据分区与数据复制 分区通常与复制相结合,即每一个分区的复制都是一个完整的主从架构的复制,而每个分区都会在多个节点上拥有相同的(不考虑微观上的延迟)副本,这意味着某条记录属于特定分区,而同样的内容会被保存到不同节点上以提高系统的容错性,这样即使某一个节点失效也不会影响整个集群的运行。 键-值数据的分区 面对海量的数据如何决定哪一条记录该放在哪个分区上呢?分区的主要目标就是将数据和查询负载均匀地分布在所有节点上。 而如果分区不均匀,就会出现某些分区节点比其他分区承担了更多的任务,即为数据倾斜。数据倾斜会导致分区效率严重下降以至于丧失了既定的目标。 避免热点最简单的办法是将数据随机分配到所有节点上。这种方法可以比较均匀地分布数据,但也有一个致命的缺点:如此写入到集群中的数据是无法通过特定key来读取的,因为没有办法知道数据保存在哪个节点上,所以不得不查询所有节点。 简单的改进方法可以通过key来分配分区,比如a-z的单词根据首字母分配到26个节点上。 基于关键字区间分区 假如上述根据单纯根据首字母字来分区时没有26个节点,那就需要将某些临近的字母放到同一个分区中,比如ab放到第一个分区,cd放到第二个分区……依次类推,26个字母需要13个节点即可放完。 但是基于关键字区间的分区也存在缺点,某些访问模式会导致热点。假如使用时间戳作为关键字,每一天的数据写入到一个分区中时,就会使这个分区成为热点。而其他分区始终处于空闲状态。 为了避免上述问题,可以在时间戳以外加入其他内容,比如数据类型等 基于关键字哈希值分区 对于上述数据倾斜与热点问题,许多分布式系统采用了基于关键字哈希函数的方式来分区。 一个好的哈希函数可以处理数据倾斜并使其均匀分布,这样从整体来看可以使数据均匀的分布到所有分区上。 负载倾斜与热点 如上所述,基于哈希的分区方法可以减轻热点,但依然无法完全避免。一个极端情况是所有读写都是针对同一个key进行的,则最终的请求都会被路由到同一个分区中。比如某个明星又离婚了等等… 而最让人困扰的是,数据倾斜的问题不光会出现在这些基础设施(指分布式存储,一些消息中间件等)中,也会出现在我们的应用层中。比如,为了防止数据乱序(有时候乱序的数据会给下游处理带来压力,比如Flink处理乱序数据产生的延迟问题。再者相同key发往不同分区时也会使Flink处理数据时产生大量的Shuffle带来的网络IO压力)从而采用哈希等方法将数据写入kafka的partition中。 即使采用了哈希分区的方法,如果出现某个热点key产生大量数据,就会造成数据倾斜。严重时将导致Kafka集群中某几个节点(主分片和所有副本所在的节点)磁盘被写满,进而导致整个集群不可用引发生产故障。 针对这个特特定的场景,由于同一key的数据可以在较长一段时间后忍受分区发生改变,因此可以在几个小时以后改变一次分区选择规则。诚然,这个办法并不能推广到所有数据倾斜问题的解决中。 分区与二级索引 上面讨论的分区方案都依赖于键值的数据模型(其实我个人认为,多数数据存储莫不如此,即便是回到MySQL也是通过主键查询,要么回表,再要么全表扫描)。键值模型相对简单,即都是通过关键字来访问记录。但是涉及到二级索引,情况就会变得复杂。 考虑到其复杂性,部分存储并不支持二级索引,如HBase作为一个面向列的存储,为了兼顾大数据量写入和OLAP场景的应用,并不支持二级索引。但是二级索引则是ES等一些全文搜索引擎的根本值所在。 而二级索引也是需要存储到不同分区中的,目前主要有两种方法来支持二级索引进行分区: 基于文档的分区 基于此条的分区 基于文档分区的二级索引 基于文档的分区是将所有二级索引在每个分区中都存了一个词条,而每个分区中的二级索引只记录自己分区的数据。 如果需要通过二级索引查询数据,就需要每一个分区的二级索引,再做统一处理。因此会导致读延迟显著放大。 基于此条的二级索引分区 基于词条的二级索引分区即与数据分区类似,二级索引的词条被放入所有分区,每个词条只存在于某一个分区(不考虑副本)。 这种方法对比前者,好处就是读取更为高效,不需要遍历所有分区的二级索引。相反这种方案写入性能相对较低,因为一个文档里面可能涉及到多个二级索引,而二级索引的分区又可能完全不同甚至不在同一节点上,由此势必引入显著的写放大。 而正因如此,实践中对全局二级索引的更新往往都是异步的。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 通常在生产中存储结构化数据最常用的是MySQL,而MySQL底层存储用的数据结构是B+树。当并发量达到一定程度之后通常会将单点的MySQL拆分成主从架构(在这之前可以加入内存型缓存如Redis等,属于不同层级的解决办法,不在此文讨论范畴)。 问题产生 在主从架构中主要问题之一有复制滞后。 这里以MySQL集群为例,主从复制要求所有写请求都经由主节点,而从节点只接收只读的查询请求(这一点在ES/Kafka的多副本分片中也有类似体现,主分片写入,从分片只支持读取)。对于读操作密集的负载(如web),这是一个不错的选择。 在这种扩展体系下,只需增加更多的从节点,就可以提高读请求的吞吐量。但是,这种方法在实际生产中只能用于异步复制,如果试图同步所有的从副本(即强一致性),则单个副本的写入失败将使数据在整个集群中写入失败。并且节点越多,发生故障的概率越高,所以以完全同步来设计系统在现实中反而非常不可靠。 在Kafka集群中为了提高消息吞吐量时与副本同步相关的设置通常会将acks设置为1或者0(1/0的区别在于leader是否落盘),partition的leader收到数据后即代表集群收到消息 说回到MySQL的主从集群,从上文中得到的结论,如果采用异步复制的话,很不幸如果一个应用正好从一个异步的从节点中读取数据,而该副本落后于主节点,这时应用读到的是过期的消息,表现在用户面前就会产生薛定谔的数据,即在同一时刻查询会出现两种截然不同的数据。 不过这个不一致的状态只是暂时的,经过一段时间之后,从节点的数据会更新到与主节点保持一致,即最终一致性。 解决办法 由于网络等原因导致的不一致性,不仅仅是存在于理论中,其是个实实在在的现实问题。下面分析复制滞后可能出现的问题,并找出相应的解决思路。 读自己的写 举个栗子: 当用户提交一些数据,然后刷新页面查看刚刚修改的内容时,例如用户信息,或者是对于一些帖子的评论等。提交新数据必须发送到主节点,但是当用户取数据时,数据可能来自从节点。 当集群是异步复制时就会出现问题,用户在数据写入到主节点而尚未达到从节点时刷新页面,看到的是数据修改之前的状态。这将给用户带来困惑。延伸到一些库存类型的应用,其实并不会导致超卖。如果用户看到是旧状态,误认为操作失败重新走了一遍流程,这时写入请求依然是访问到主节点,而主节点的数据是最新的,会返回失败。而这将进一步给用户带来困扰。 对于这种情况,我们需要"写后读一致性",该机制保证用户重新加载页面,总是能看到自己最新更新的数据。但对于其他用户看这条信息没有任何保证 方案一 总是从主节点读取用户可能会修改的信息,否则在从节点读取。即,从用户访问自己的信息时候从主节点读取,访问其他人的信息时候在从节点读取。 方案二 在客户端记住最近更新的时间戳,并附带在请求中。如果查到的数据不够新,则从其他副本中重新查询,或者直接从主节点中查询。 方案三 如果副本分布在多个数据中心(地理位置上的多个机房)等,就必须把请求路由到主节点所在的数据中心。至少目前还没有接触过这种项目,没有很深的理解,不过多讨论这种情况。 此外,依然存在一些其他问题需要考虑,如用户在多个设备上登录,这样一个设备就无法知道其他设备上进行了什么操作,如果采用方案二的话,依然会出现不一致。 单调读 在上述第二个例子中,出现了用户数据向后回滚的情况。 假设用户从不同副本进行了多次读取,用户刷新了一个网页,该请求可能会被随机路由到某一个从节点。用户2345先后在两个从节点上执行了两次完全相同的查询(先是少量滞后的从节点,然后是滞后很大的从节点),则很有可能出现以下情况。 第一个查询返回了最近用户1234所添加的评论,但第二个查询结果代表了更早时间点的状态。如果第一个查询没有返回任何内容,用户2345并不知道用户1234最近的评论,情况还好。但当用户2345看到了用户1234的评论之后,紧接着评论又消失了,就会感到十分困惑。 阿b(bilibili)的评论系统在使用中出现过类型的现象,但不清楚是否是由于审核等一些其他因素造成的。总之是在一个新视频发布后去刷新评论,第一次看到有人评论了,再次刷新评论又消失了。 单调读一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。即保证用户依次进行多次读取,绝不会看到回滚的现象。 实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读操作(不同的用户当然可以从不同的副本读取)。例如,使用用户ID的哈希来决定去哪个副本读取消息,但如果该副本失效,系统必须要有能力将查询重新路由到其他有效的副本上。 前缀一致读 第三个由于复制滞后导致反常的例子。 比如A和B之间以下的对话: A: 请问B,你能听到吗? B: 你好A,我能听到 这两句话之间存在因果关系,即B听到了A的问题,然后再去回答。 现在如果有第三人在通过从节点上收听上述对话。假设B发的消息先同步了,观察者看到的对话就变成了这样: B: 你好A,我能听到 A: 请问B,你能听到吗? 这逻辑就变得混乱了。 防止这种异常需要引入另一种保证:前缀一致读。该保证是说,对于一系列按照某个顺序发生的写请求,那么读取这些内容时必须要按照当时写入的顺序 小结 上面讨论的是在保证最终一致性异步复制的情况下发生的。当系统决不能忍受这些问题时,那就必须采用强一致性,但随之而来的就是写入性能低下,故障率高,一个节点故障引发整个集群不可用等各种问题。都需要在应用开始进行得失的平衡。 再举个栗子: 在kafka这种对写入性能要求极高的应用中,如果发送的消息不是特别重要,有要求极高吞吐量的时候,比如日志收集等,则可以设置为Leader收到消息即代表成功 而在ES中,则必须要求数据分片的所有副本都写入成功才返回成功,采用了强一致性。而ES采用了健康检查,超过1分钟不活跃的节点就剔除集群等机制,从而保证了数据可以实时地写入。 延伸 结合到实际工作中的项目分析,也存在类似问题。 下面举两个类似的栗子: 例一 在某基础信息管理平台中需要一个模糊搜索的功能,各方面平衡之后采用在应用内存中使用前缀树的方式做缓存。由于应用是多实例的,这时数据的增删改就会在多实例之间存在一个短暂的不一致。 例二 在某数据处理应用中,由于每一条数据中需要有多个(一到十几不等)条目访问缓存。开始的时候将缓存放在Redis里,而应用访问Redis的时间大概需要十几到几十毫秒的时间,这样每一条数据的处理时间就在几十毫秒到几百毫秒之间。而使用多线程处理,则会造成消息的严重乱序。 测试下来,程序每秒只能处理不超过20条数据,大大影响了效率。而后将缓存改到内存中,省掉了访问Redis的时间,再结合Kafka的一些优化策略,极大的提高了应用吞吐量。测试后每秒大概可以处理几千条数据。缓存放到程序内存中之后,也同样会出现缓存不一致的问题。 下面是这两个应用中采用的一个缓存架构图: 在这个架构中,如果某个实例接收Redis消息慢了,就会出现不同实例间的数据不一致 参考链接 《数据密集型应用系统设计》