《Akka应用模式:分布式应用程序设计实践指南》读书笔记6
一致性和可扩展性
一致性是系统内比较复杂的属性,它会随着系统的变化而变化。简单来说,一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。一旦系统具有并行性(分布式只是并行的一种表现),保持一致性就变得困难了,毕竟需要协调全局状态。
事务和一致性
一致性意义上的事务是指单一的原子操作。也就意味着一系列操作要么全成功,要么全不成功,不涉及中间状态。比如A转账给B,不能存在A余额已经扣除但B余额没有增加的情况。
强一致性和最终一致性
数据在任一时刻都是被原子性的事务操作所更新的,这种情况称为强一致性,即系统的所有节点可能会从一个完全一致的状态转移到另一个完全一致的状态。最终一致性意味着在一段时间内发生的更新不管跨度多短,系统的不同节点在这段时间内都会有不同的状态,但在一段时间后,节点间的状态最终会达到一致。在分布式环境中,最终一致性通常比强一致性更容易实现。不知道作者为啥很奇怪,这不是很显然的么?哈哈。世界本来就是异步的,异步的就意味着,数据的更新总是有先后的嘛,就看你能不能丈量得到了。
并发性与并行性
并发性是指两个进程从某一个时刻开始,同时存在,但他们不一定在同一时刻运行,也可以在重叠的时间段内运行。并行性引入了两个进程同时执行的意思。支持并行性意味着也支持并发性,但反过来就不一定了。因为并行性的时间要求更高。分布式系统是支持并行性的,单核心单处理器系统是支持并发性的。
全局一致的分布式状态影响可扩展性
全局一致的分布式状态违反了现实世界的物理法则,也很难实现。毕竟它在更新状态时,要“暂停整个世界”,以确保系统的每个模块更新到正确的状态,然后所有节点继续工作。其实吧,这也可以实现,但这样做还有神马意义呢。它严重限制了并行处理的能力,降低了可扩展性。
位置透明性
位置透明也是系统的一个特征,通过它可以执行计算而不关心计算发生的位置。其实吧,我觉得这是Akka提供的非常优秀的特性之一。我们居然可以像写单机系统那样写分布式系统!我们在单机系统OOP中,往往通过this指针引用其他变量,然而在Akka中使用ActorRef引用其他actor,而ActorRef屏蔽了actor的位置。
交付保证
交付保证是建立分布式系统时经常被忽略的一部分,就是如何确保消息消息被送达、处理后返回响应的结果。看看TCP三次握手协议就知道这有多麻烦了。
最多投递一次
最多投递一次的交付机制最容易实现。因为它就是简单的发送一次消息,然后继续其他工作。这是Akka默认的交付机制,也就是 fire and forget
最少一次
跟最多一次相反,它允许重复发送数据,以确保被正确处理。但这往往需要存储消息,以便没有收到接收方的确认消息时重发数据。这其实有点麻烦,因为数据存内存吧,可能把内存撑爆,毕竟现在的内存还比较贵,存硬盘吧,存取又比较慢。特别是在海量数据的场景下,就更麻烦了。引入redis等第三方分布式缓存虽然可以折中的解决效率的问题,但也引入了系统的复杂性、不稳定性。
书籍的原作者,用一个“死循环”Ask模式实现了最少一次,但我非常不喜欢这个方式,对于我来说,死循环就是在找死,因为它总是能给系统带来“惊喜”。
我们还可以引入数据库或磁盘存储来解决消息被交付之前的存储问题,Akka Persistence内置了所有逻辑,并提供了AtLeastOnceDelivery特性。它只是实现了这一方案的框架,具体存在哪里还是需要我们自己来实现的。
恰好一次交付
这往往不可能,特别是在分布式系统中,但可以近似做到。恰好一次意味着消息从发送者到接受者只需要恰好一次可靠的传递,没有多余的重复。想想都不可能,其实也没有必要。其实我们经常谈论的“恰好一次”,是对恰好一次的变种:消息从发送者到接受者只经过一次正确的处理!其实就是重复的发消息给接受者,接受者消息只进行了一次业务上的处理,对多余的消息只是简单的进行了应答。
集群单例
有时候系统中一些区域必须是唯一的。比如全局唯一ID的生成器,这个生成器需要跟踪以往使用的ID或者可能使用单调递增的数值。简单来说就是需要一个全局协调器,一旦不唯一可能造成灾难性的后果。其实我更喜欢局部协调器,这虽然没有解决问题的复杂性,但至少解决了问题影响的范围。幸运的是Akka帮我们实现了这一机制,可以确保整个集群系统中只有一个actor实例可用,也就是集群单例。它运行在集群中存在时间最长的可用节点上。这有时候也是合理的,运行时间最长意味着最稳定,毕竟活了那么久居然没有挂,非常可靠。要不然像Windows那样,天天重启就不好了。幸运的是Akka的gossip机制可以确定哪个节点是运行最久的节点。
单例就意味着没有并行,往往也意味着性能瓶颈与可用性问题。毕竟单例不可用期间,发送到集群单例中的任何消息都将被缓存,也就意味着消息不能被及时处理。另外,当集群单例进行节点漂移时,全局状态如何进行漂移也是一件棘手的事情。分布式缓存和单例数据库可以保存这个状态,但也无法避免的增加系统的复杂性和不确定性。因为它没有彻底解决问题,只是暂时转移了问题。使用Akka Persistence可以维持状态,在重新创建实例时,可以恢复先前的状态。
作者建议除非绝对必要,不然尽量避免使用。其实我们可以改变一下思路,把“集群单例”转换成“逻辑单例”,以缩小“单例”不可用时影响的系统的范围。
在Akka中,集群单例是通过其代理接收消息的,毕竟单例会发生漂移,其ActorRef会发生变化,用代理来消除这一变化是非常合理的。也就是用不变来封装变化,以减少系统的复杂性。
可扩展性
具备可扩展的系统在处理更高的负载时不会出现故障或性能急剧下降的问题。它与性能的关系微乎其微,优化性能不能保证可扩展性。其实这句话说的有点歧义,因为我觉得这亮点还是有点关系的。毕竟可扩展性是可以用来提高系统高负载时的性能问题的。系统的可扩展性可以分为垂直扩展和水平扩展。垂直扩展意味着增加更多的资源,例如CPU/内存/带宽;水平扩展意味着增加系统的节点数量。在处理某个问题时,如果原来的人忙不过来了,那就有两种方法来解决。一是提高工人的技术熟练程度或用高技能工人替换低产能工人,另外一种就是增加工人数量了。两种方案各有优劣,都需要付出一定的代价。
一个和可扩展性高度相关的概念是弹性。具有弹性是指具备在运行的系统中增加资源的能力,能够通过增加资源来扩展系统,而不影响客户端。其实就是水平扩展的能力。比如,某件工作需要用到一个限定数量的资源,那么提高工人的数量,并一定能提高工作的效率。使系统具有弹性就是消除这个关键资源,以增加水平扩展的能力,那么怎么增加弹性呢?
避免全局状态
其实也就是避免关键资源。如果必需,可以用集群单例实现。
避免共享状态
全局状态其实是共享状态的极端情况,她是集群所有节点之间的共享状态。
遵循Actor模型
这一点其实不太好理解,需要综合考虑Actor模型和反应式编程的相关规则和概念。具体可参考我的开源组件,其中的思想大家可以慢慢领悟。
避免顺序操作
顺序也是一种状态。而且需要考虑时间因素,是一个双重风险。
隔离阻塞性操作
这一点也很有帮助,之前我一直以为这种方法仅仅是转义了系统的问题,并没有很好的解决系统的弹性。但其实细细研究我还是错了。这不仅仅是转义,而是划分了系统的优先级,可以对阻塞性操作单独进行优化(可参考我另一篇博文)。将这些操作隔离到另一个线程池是保持用用程序高响应的关键。具体可参考我的开源组件。
监控和调优
分布式系统本质上就是不确定性的,监控是我们理解系统很重要的途径,这是一门值得研究的技术,可以帮助我们获得显著的可扩展性。
集群分片和一致性
在很多系统中,一致性和可扩展性一般是对立的。一致性要求集群中的节点之间共享信息,这往往降低了系统的可扩展性。Akka提供了平衡两者的方法,其实也就是分而治之。细细想来,“分而治之”的思想真是万能钥匙。
分片
分片在数据库系统中应用了很长时间。它通过分片键使所有记录分布在整个集群环境的各个节点上。通过这种方式分发记录,竞争共享资源的情况可以减少,能够在集群中的节点之间均衡负载,而不需要把所有请求都转发到同一个节点上。那么为了实现分片,需要使用一个可靠的方法来确定数据所在的分片。这个问题其实还比较复杂,可以是静态的,准动态的,完全动态的。Akka中可以使用特殊节点来完成,它可以将流传递到适当的分片上,其实就是一个路由器。只不过这个路由器功能非常简单,它只需要跟踪分片,并根据需要传递即可。可靠性比较好,即使故障,恢复也会很快。但他仍有可能是系统的一个瓶颈。
其实分片也是“分而治之”的一种思想!
Akka中的分片
分片往往是指数据,但Akka中对这个概念进行了扩展,它对整个集群中活跃的actor进行分片!每个actor都分配了一个实体ID,该ID全局唯一。它通常表示actor建模代表的域实体的标识符。Akka提供了从消息提取实体ID的方法,和计算actor所驻留的分片的ID。把actor进行分片,不知道是不是合适,因为它同时包含了状态和行为。如果架构分层设计的不好,可能会比较乱。
Akka可以确保集群中实体ID对应唯一的actor,如果目前不存在对应的actor,则会创建actor!这一点想的还是非常周到的!如果一个ID对应多个actor,就比较混乱了。
集群中分片所在的区域成为分片区域,这些区域作为分片的托管,参与分片的每个节点将为每种类型的分片actor承载一个单独的分片区域。“分片区域”其实是区域内分片actor的路由或者代理,也是一个actor。分片协调器用于管理分片所在的位置,它通知分片区域各个分片的位置,协调器是作为一个集群单例实现的,在实际的消息流中的交互被最小化了,只有在分片的位置未知的情况下,才参与这个消息的交互,所以基本不会有性能的问题。
分片键的生成
这是一个技术活,需要跟具体的业务场景结合来设置。最简单的就是用实体ID作散列,但这样会产生非常大量的分片。还可以根据预先设置的分片数量进行取模,达到控制分片数量的目的。当然也可以采用一致性HASH对数据进行分片。方法还是有很多种的。但不管怎么样,最终的目的都是尽可能的使分片均匀分布,减少数据倾斜,分散节点压力。
分片的分布
分片太少,可能无法将其均匀分布在集群中;分片太多对系统造成比较大的维护负担。作者建议把分片数量设置为集群输了最大值的10倍以上。不过这个就要根据自己的业务场景和数据量重新考虑了,还要考虑集群的重新平衡带来的负担。其实看到这里机会发现,分布式系统遇到的问题还是比较类似的,分片在缓存系统中还是比较经典的问题的。
其实“分而治之”的另外一种延伸,就是“均分而治之”,如果把数据分成两部分,其中一部分数据量是另外一种数据量的10倍,也就没有“分”的必要了。
一致性边界
分片机制通过在集群中用单一一致的actor提供一致性边界,与特定ID的实体完成完成的所有通信都通过该actor!这句话非常重要!由于actor一次只能处理一个消息,那么就可以确保actor范围内的消息顺序和一致性状态。也就是同一个actor内的状态是全局一致的(因为集群中只存在一个),且消息是顺序处理的(actor基本特性)!
那会不会成为系统瓶颈呢?的确可能。如果某个actor待处理的消息过多,会积压在actor的邮箱中,如果该actor处理能力跟不上当然是瓶颈。不过它只会影响到该actor关联的业务,其他ID对应的业务不会遇到问题。当然了,如果所有actor的压力都很大,那只能提高actor的处理能力了。毕竟,如果负载超出了系统的处理上限,只能对系统进行进一步的优化了。
但鱼和熊掌不可兼得,保持了一致性就牺牲了可用性。因为托管分片的节点丢失或重新调整平衡的操作,会导致分片漂移的过程中,实体actor变得不可用。从发现故障、决定迁移、执行迁移、迁移完成,整个迁移过程可能会需要很长的时间。
可扩展性边界
一致性和可扩展性往往是对立的,但这是有前提的:他们是全局一致性和全局可扩展性。其实我们可以围绕一致性创建边界,将其隔离为单个实体,使用该边界实现可扩展性。即一致性限于单个实体,将系统扩展到多个实体。其实就是将一致性的范围尽可能的缩小,以提高系统的可扩展性。
分片聚合根
分片机制很大程度上依赖于我们正确选择适合分片的实体的能力。其实简单点来说,就是如何正确、合适的拆分actor。作者建议用系统的聚合根来研究如何分片,聚合根提供了一个域内自然的一致性边界。这个就要根据自己的业务场景来做了,我也没有用过这玩意儿,就不发表意见了。
持久化
以持久化方式分布actor表示分片系统必须偶尔执行再平衡的调整操作,来应对actor漂移。akka persistence为事件源提供了内置支持,可以很方便的重建actor。但也不一定非得这样做,考虑到每个actor都有一个唯一ID,把actor的状态放到redis里面似乎也是非常合理的,因为重建时一定有一个key,只要能在redis里面找到对应的value就可以了。当然了把状态存到任何一个数据库也是可以的。
钝化
期望一个系统始终在内存中保持所有的actor是不合理的,毕竟有些actor随着时间的流逝会逐渐衰亡,或者说长时间没有收到消息,为了节约内存可以暂时将其从内存中卸载掉。所以Akka的分片机制引入了钝化的概念,它会给分片区域发送一条消息,通知特定actor开始钝化,也会通知该分片区域开始缓存素有发送给该actor的新消息。同时actor将发送一个自定义的消息,然后放置在邮箱中。最后该actor会接收到这条自定义的消息,然后关闭自己。如果收到新消息,则会在该actor关闭后重新创建actor,并发送新消息。
通常这是通过在actor中使用setReceiveTimeout操作来实现的。关于这一点可以看下官方的文档,简单点来说就是,系统会发送一个Passivate
消息给actor对应的父actor,也就是Shard。父actor会将Passivate
消息包装一下,发送给该actor,然后actor收到消息后关闭自己。其实这有点类似于缓存机制中的超时设置。
说实话这个功能,我还是很感到意外的。毕竟,开发者也是可以轻松实现的,但有可能想不到或者忽略到这个技术细节。Akka居然集成到框架里面了!
使用集群分片保证一致性
这一章节作者只是简单描述了如何使用集群分片的功能。但实现的代码过于简单,我觉得有些细节处理的不是太完美。比如Person这个actor在收到timeout消息之后就简单粗暴的给父actor发送Passivate(PoisonPill)消息这会导致该actor在固定的时间间隔内反复重启。最好是用Passivate携带特定消息,由子actor来做响应的操作:继续存活还是死亡。
使用集群分片时的常见做法是创建一个信封消息以包含分片所需的各种信息。信封信息可以采取最适合业务需求的任何形式。如果用例需要额外的信息存储在信封消息中才能计算分片键,那么当然可以添加这些额外的信息。其实也就是如何计算分片ID。
关于Akka不得不承认,有时候它的确就是面向消息编程的。因为消息的字段内容会影响actor的行为或框架的设计,消息协议设计的好,那么系统就成功了一半。
结论
本章节作者讨论了一致性和可扩展性之间的平衡,并给出了简单的例子。高度分布式的系统比单节点系统更有可能发生部分功能失效的情况,毕竟子系统越多出错的概率越大。其实一个正确构建的分布式系统因为故障导致整个系统完全失败的可能性实际上很低,但在设计系统的初期还是需要考虑各种失败的情况的。对失败进行分类,哪些是可以自动处理的,哪些是遇到之后必须人工干预的,哪些是可以忽略的,哪些是可以通过重启解决的。谋定而后动大概就是这个道理吧。
其实我喜欢Akka的原因,不简单因为它是一个构建高并发、分布式的工具,还因为它基于actor集成了布式中常见问题的解决方案,这对于学习、了解分布式框架的概念、难点非常有帮助。即使后面不适用Akka来构建系统,但其技术和设计哲学仍然值得借鉴。希望你也和我一样喜爱Akka。