CategoryResourceRepost/极客时间专栏/左耳听风/弹力设计/45 | 弹力设计篇之“服务的状态”.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

122 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="45 | 弹力设计篇之“服务的状态”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/36/4e26d19fe41c2007275862bbd0758236.mp3"></audio>
之前在我们讲的幂等设计中,为了过滤掉已经处理过的请求,其中需要保存处理过的状态,为了把服务做成无状态的,我们引入了第三方的存储。而这一篇中,我们来聊聊服务的状态这个话题。我认为,只有清楚地了解了状态这个事,我们才有可能设计出更好或是更有弹力的系统架构。
所谓“状态”就是为了保留程序的一些数据或是上下文。比如之前幂等性设计中所说的需要保留每一次请求的状态或是像用户登录时的Session我们需要这个Session来判断这个请求的合法性还有一个业务流程中需要让多个服务组合起来形成一个业务逻辑的运行上下文Context。这些都是所谓的状态。
我们的代码中基本上到处都是这样的状态。
# 无状态的服务 Stateless
一直以来无状态的服务都被当作分布式服务设计的最佳实践和铁律。因为无状态的服务对于扩展性和运维实在是太方便了。没有状态的服务可以随意地增加和减少节点同样可以随意地搬迁。而且无状态的服务可以大幅度降低代码的复杂度以及Bug数因为没有状态所以也没有明显的“副作用”。
基本上来说无状态的服务和“函数式编程”的思维方式如出一辙。在函数式编程中一个铁律是函数是无状态的。换句话说函数是immutable不变的所有的函数只描述其逻辑和算法根本不保存数据也不会修改输入的数据而是把计算好的结果返回出去哪怕要把输入的数据重新拷贝一份并只做少量的修改关于函数式编程可以参看我在CoolShell上的文章《[函数式编程](https://coolshell.cn/articles/10822.html)》)。
但是,现实世界是一定会有状态的。这些状态可能表现在如下的几个方面。
- 程序调用的结果。
- 服务组合下的上下文。
- 服务的配置。
为了做出无状态的服务我们通常需要把状态保存到其他的地方。比如不太重要的数据可以放到Redis中重要的数据可以放到MySQL中或是像ZooKeeper/Etcd这样的高可用的强一致性的存储中或是分布式文件系统中。
于是,我们为了做成无状态的服务,会导致这些服务需要耦合第三方有状态的存储服务。一方面是有依赖,另一方面也增加了网络开销,导致服务的响应时间也会变慢。
所以,第三方的这些存储服务也必须要做成高可用高扩展的方式。而且,为了减少网络开销,还需要在无状态的服务中增加缓存机制。然而,下次这个用户的请求并不一定会在同一台机器,所以,这个缓存会在所有的机器上都创建,也算是一种浪费吧。
这种“转移责任”的玩法也催生出了对分布式存储的强烈需求。正如之前在《分布式系统架构的本质》系列文章中谈到的关键技术之一的“[状态/数据调度](https://time.geekbang.org/column/article/1609)”所说的因为数据层的scheme众多所以很难做出一个放之四海皆准的分布式存储系统。
这也是为什么无状态的服务需要依赖于像ZooKeeper/Etcd这样的高可用的有强一致的服务或是依赖于底层的分布式文件系统像开源的Ceph和GlusterFS。而现在分布式数据库也开始将服务和存储分离也是为了让自己的系统更有弹力。
# 有状态的服务 Stateful
在今天看来,有状态的服务在今天看上去的确比较“反动”,但是,我们也需要比较一下它和无状态服务的优劣。
正如上面所说的无状态服务在程序Bug上和水平扩展上有非常优秀的表现但是其需要把状态存放在一个第三方存储上增加了网络开销而在服务内的缓存需要在所有的服务实例上都有因为每次请求不会都落在同一个服务实例上这是比较浪费资源的。
而有状态的服务有这些好处。
<li>
**数据本地化Data Locality**。一方面状态和数据是本机保存,这方面不但有更低的延时,而且对于数据密集型的应用来说,这会更快。
</li>
<li>
**更高的可用性和更强的一致性**。也就是CAP原理中的A和C。
</li>
为什么会这样呢因为对于有状态的服务我们需要对于客户端传来的请求都必需保证其落在同一个实例上这叫Sticky Session或是Sticky Connection。这样一来我们完全不需要考虑数据要被加载到不同的节点上去而且这样的模型更容易理解和实现。
可见最重要的区别就是无状态的服务需要我们把数据同步到不同的节点上而有状态的服务通过Sticky Session做数据分片当然同步有同步的问题分片也有分片的问题这两者没有谁比谁好都有trade-off
这种Sticky Session是怎么实现的呢
最简单的实现就是用持久化的长连接。就算是HTTP协议也要用长连接。或是通过一个简单的哈希hash算法比如通过uid 求模的方式,走一致性哈希的玩法,也可以方便地做水平扩展。
然而,这种方式也会带来问题,那就是,节点的负载和数据并不会很均匀。尤其是长连接的方式,连上了就不断了。所以,玩长连接的玩法一般都会有一种叫“反向压力(Back Pressure)”。也就是说如果服务端成为了热点那么就主动断连接这种玩法也比较危险需要客户端的配合否则容易出Bug。
如果要做到负载和数据均匀的话,我们需要有一个元数据索引来映射后端服务实例和请求的对应关系,还需要一个路由节点,这个路由节点会根据元数据索引来路由,而这个元数据索引表会根据后端服务的压力来重新组织相关的映射。
当然,我们可以把这个路由节点给去掉,让有状态的服务直接路由。要做到这点,一般来说,有两种方式。一种是直接使用配置,在节点启动时把其元数据读到内存中,但是这样一来增加或减少节点都需要更新这个配置,会导致其它节点也一同要重新读入。
另一种比较好的做法是使用到Gossip协议通过这个协议在各个节点之间互相散播消息来同步元数据这样新增或减少节点集群内部可以很容易重新分配听起来要实现好真的好复杂
在有状态的服务上做自动化伸缩的是有一些相关的真实案例的。比如Facebook的Scuba这是一个分布式的内存数据库它使用了静态的方式也就是上面的第一种方式。Uber的Ringpop是一个开源的Node.js的根据地理位置分片的路由请求的库开源地址为[https://github.com/uber-node/ringpop-node](https://github.com/uber-node/ringpop-node) )。
还有微软的OrleansHalo 4就是基于其开发的其使用了Gossip协议一致性哈希和DHT技术相结合的方式。用户通过其ID的一致性哈希算法映射到一个节点上而这个节点保存了这个用户对应的DHT再通过DHT定位到处理用户请求的位置这个项目也是开源的开源地址为 [https://github.com/dotnet/orleans](https://github.com/dotnet/orleans) )。
关于可扩展的有状态服务这里强烈推荐Twitter的美女工程师Caitie McCaffrey的演讲Youtube视频《Building Scalable Stateful Service》(演讲PPT)其文字版是在High Scalability上的这篇文章《Making the Case for Building Scalable Stateful Services in the Modern Era》
# 服务状态的容错设计
在容错设计中,服务状态是一件非常复杂的事。尤其对于运维来说,因为你要调度服务就需要调度服务的状态,迁移服务的状态就需要迁移服务的数据。在数据量比较大的情况下,这一点就变得更为困难了。
虽然上述有状态的服务的调度通过Sticky Session的方式是一种方式但我依然觉得理论上来说虽然可以这么干这实际在运维的过程中这么干还是件挺麻烦的事儿不是很好的玩法。
很多系统的高可用的设计都会采取数据在运行时就复制的方案比如ZooKeeper、Kafka、Redis或是ElasticSearch等等。在运行时进行数据复制就需要考虑一致性的问题所以强一致性的系统一般会使用两阶段提交。
这要求所有的节点都需要有一致的结果这是CAP里的CA系统。而也有的系统采用的是大多数人一致就可以了比如Paxos算法这是CP系统。
但我们需要知道,即使是这样,当一个节点挂掉了以后,在另外一个地方重新恢复这个节点时,这个节点需要把数据同步过来才能提供服务。然而,如果数据量过大,这个过程可能会很漫长,这也会影响我们系统的可用性。
所以,我们需要使用底层的分布式文件系统,对于有状态的数据不但在运行时进行多节点间的复制,同时为了避免挂掉,还需要把数据持久化在硬盘上,这个硬盘可以是挂载到本地硬盘的一个外部分布式的文件卷。
这样当节点挂掉以后,以另外一个宿主机上启动一个新的服务实例时,这个服务可以从远程把之前的文件系统挂载过来。然后,在启动的过程中就装载好了大多数的数据,从而可以从网络其它节点上同步少量的数据,因而可以快速地恢复和提供服务。
这一点,对于有状态的服务来说非常关键。所以,使用一个分布式文件系统是调度有状态服务的关键。
# 小结
好了,我们来总结一下今天分享的主要内容。首先,我讲了无状态的服务。无状态的服务就像一个函数一样,对于给定的输入,它会给出唯一确定的输出。它的好处是很容易运维和伸缩,但需要底层有分布式的数据库支持。
接着我讲了有状态的服务它们通过Sticky Session、一致性Hash和DHT等技术实现状态和请求的关联并将数据同步到分布式数据库中利用分布式文件系统还能在节点挂掉时快速启动新实例。下篇文章中我们讲述补偿事务。希望对你有帮助。
也欢迎你分享一下你所实现的分布式服务是无状态的,还是有状态的?用到了哪些技术?
文末给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。
<li>弹力设计篇
<ul>
- [认识故障和弹力设计](https://time.geekbang.org/column/article/3912)
- [隔离设计Bulkheads](https://time.geekbang.org/column/article/3917)
- [异步通讯设计Asynchronous](https://time.geekbang.org/column/article/3926)
- [幂等性设计Idempotency](https://time.geekbang.org/column/article/4050)
- [服务的状态State](https://time.geekbang.org/column/article/4086)
- [补偿事务Compensating Transaction](https://time.geekbang.org/column/article/4087)
- [重试设计Retry](https://time.geekbang.org/column/article/4121)
- [熔断设计Circuit Breaker](https://time.geekbang.org/column/article/4241)
- [限流设计Throttle](https://time.geekbang.org/column/article/4245)
- [降级设计degradation](https://time.geekbang.org/column/article/4252)
- [弹力设计总结](https://time.geekbang.org/column/article/4253)
- [分布式锁Distributed Lock](https://time.geekbang.org/column/article/5175)
- [配置中心Configuration Management](https://time.geekbang.org/column/article/5819)
- [边车模式Sidecar](https://time.geekbang.org/column/article/5909)
- [服务网格Service Mesh](https://time.geekbang.org/column/article/5920)
- [网关模式Gateway](https://time.geekbang.org/column/article/6086)
- [部署升级策略](https://time.geekbang.org/column/article/6283)
- [缓存Cache](https://time.geekbang.org/column/article/6282)
- [异步处理Asynchronous](https://time.geekbang.org/column/article/7036)
- [数据库扩展](https://time.geekbang.org/column/article/7045)
- [秒杀Flash Sales](https://time.geekbang.org/column/article/7047)
- [边缘计算Edge Computing](https://time.geekbang.org/column/article/7086)