要点:
- 数据一致性
- 在任意时间、任意位置看到的同一个事物是完全一致的。
- 对不同级别的一致性汇总概述如下:
- 共识
- 一致性问题是结果,共识是为达到这个结果所要经过的过程,或者说一种手段。
- 想要更严谨的一致性,那么就需要增加相互通讯确认的次数,但是这会导致性能低下,正如PBFT和Paxos一样。但是分布式系统就是这样,到处都需要Balance,找到最适合的才是最重要的。
- 事务
- 任何我们认为应该是这样的事情,去确保它达到预期的过程就是「事务」
- 「事物都具有两面性」,所以,在选择走向分布式之前,慎重考虑下是否有必要,以免给自己徒增麻烦。
- 高可用
- 「高可用」指的是通过尽量缩短因日常维护操作(计划)和突发的系统崩溃(非计划)所导致的停机时间,以提高系统和应用的可用性。
- 分布式系统的关键是做冗余,但是冗余的最大敌人却是「数据一致性」。我们通过冗余打破了原先的瓶颈,打开了一些新的通道。如,可以去争取更高的可用性、更高的性能等等。但是这其中,属「高可用」最重要。
- 更快的发现故障,更快的隔离故障。
- 负载均衡
- 由一个独立的统一入口来收敛流量,再做二次分发的过程就是「负载均衡」,它的本质和「分布式系统」一样,是「分治」。
- 常用策略
- 轮询
- 加权轮询
- 最少连接数
- 最快响应
- Hash法
- 常用「负载均衡」策略优缺点和适用场景
- 用「健康探测」来保障高可用
- HTTP
- TCP
- UDP
- 本质:将请求或者说流量,以期望的规则分摊到多个操作单元上进行执行。
- 实施:
- 硬件负载均衡:F5
- 软件负载均衡(L7):Nginx、HAProxy
- 软件负载均衡(L4):LVS
- 比较
- Session
- session丢失:用户一旦由于某种原因从原先访问服务器A变成访问服务器B,就会出现“登陆状态丢失”、“缓存穿透”等问题
- nginx 使用cookie保存session
- Session保持的其它方案:
- Session复制
- Session共享
- 比较
- 分别用一句话概括一下这3个方案:
- Session 保持。原来在哪还是去哪。
- Session 复制。不管在哪都有一样的数据。
- Session 共享。所有节点共用一份数据。
- 严格来说「Session保持」本质上是破坏了做「负载均衡」的初衷
- 熔断
- 熔断本质上是一个过载保护机制。这一概念来源于电子工程中的断路器,可能你曾经被这个东西的“跳闸”保护过。
- 一个中心思想,分四步走
- 量力而行。因为软件和人不同,没有奇迹会发生,什么样的性能撑多少流量是固定的。这是根本
- 定义一个识别是否处于“不可用”状态的策略
- 切断联系
- 定义一个识别是否处于“可用”状态的策略,并尝试探测
- 重新恢复正常
- 什么场景最适合做熔断
- 一个事物在不同的场景里会发挥出不同的效果。以下是我能想到最适合熔断发挥更大优势的几个场景:
- 所依赖的系统本身是一个共享系统,当前客户端只是其中的一个客户端。这是因为,如果其它客户端进行胡乱调用也会影响到你的调用。
- 所以依赖的系统被部署在一个共享环境中(资源未做隔离),并不独占使用。比如,和某个高负荷的数据库在同一台服务器上。
- 所依赖的系统是一个经常会迭代更新的服务。这点也意味着,越“敏捷”的系统越需要“熔断”。
- 当前所在的系统流量大小是不确定的。比如,一个电商网站的流量波动会很大,你能抗住突增的流量不代表所依赖的后端系统也能抗住。这点也反映出了我们在软件设计中带着“面向怀疑”的心态的重要性。
- 一个事物在不同的场景里会发挥出不同的效果。以下是我能想到最适合熔断发挥更大优势的几个场景:
- 做熔断时还要注意的一些地方
- 与所有事物一样,熔断也不是一个完美的事物,我们特别需要注意2个问题。
- 首先,如果所依赖的系统是多副本或者做了分区的,那么要注意其中个别节点的异常并不等于所有节点都存在异常,所以需要区别对待。
- 其次,熔断往往应作为最后的选择,我们应优先使用一些「降级」或者「限流」方案。因为“部分胜于无”,虽然无法提供完整的服务,但尽可能的降低影响是要持续去努力的。比如,抛弃非核心业务、给出友好提示等等,这部分内容我们会在后续的文章中展开。
- 与所有事物一样,熔断也不是一个完美的事物,我们特别需要注意2个问题。
- 限流
- 作用:只要系统没宕机,系统只是因为资源不够,而无法应对大量的请求,为了保证有限的系统资源能够提供最大化的服务能力,因而对系统按照预设的规则进行流量(输出或输入)限制的一种方法,确保被接收的流量不会超过系统所能承载的上限。
- 通过「压力测试」等方式获得系统的能力上限在哪个水平是第一步。
- 一般我们做压测为了获得2个结果,「速率」和「并发数」。前者表示在一个时间单位内能够处理的请求数量,比如xxx次请求/秒。后者表示系统在同一时刻能处理的最大请求数量,比如xxx次的并发。从指标上需要获得「最大值」、「平均值」或者「中位数」。后续限流策略需要设定的具体标准数值就是从这些指标中来的。
- 其次,就是制定干预流量的策略。比如标准该怎么定、是否只注重结果还是也要注重过程的平滑性等。
- 常用的策略就4种,我给它起了一个简单的定义——「两窗两桶」。两窗就是:固定窗口、滑动窗口,两桶就是:漏桶、令牌桶。
- 固定窗口就是定义一个“固定”的统计周期,比如1分钟或者30秒、10秒这样。然后在每个周期统计当前周期中被接收到的请求数量,经过计数器累加后如果达到设定的阈值就触发「流量干预」。直到进入下一个周期后,计数器清零,流量接收恢复正常状态。
- 假如请求的进入非常集中,那么所设定的「限流阈值」等同于你需要承受的最大并发数。
- 缺点是:由于流量的进入往往都不是一个恒定的值,所以一旦流量进入速度有所波动,要么计数器会被提前计满,导致这个周期内剩下时间段的请求被“限制”。要么就是计数器计不满,也就是「限流阈值」设定的过大,导致资源无法充分利用。
- 滑动窗口其实就是对固定窗口做了进一步的细分,将原先的粒度切的更细,比如1分钟的固定窗口切分为60个1秒的滑动窗口。然后统计的时间范围随着时间的推移同步后移。
- 如果固定窗口的「固定周期」已经很小了,那么使用滑动窗口的意义也就没有了
- 漏桶模式的核心是固定“出口”的速率,不管进来多少量,出去的速率一直是这么多。如果涌入的量多到桶都装不下了,那么就进行「流量干预」。
- 通过一个缓冲区将不平滑的流量“整形”成平滑的(高于均值的流量暂存下来补足到低于均值的时期),以此最大化计算处理资源的利用率。
- 令牌桶模式的核心是固定“进口”速率。先拿到令牌,再处理请求,拿不到令牌就被「流量干预」。因此,当大量的流量进入时,只要令牌的生成速度大于等于请求被处理的速度,那么此刻的程序处理能力就是极限。
- 令牌桶的容量大小理论上就是程序需要支撑的最大并发数
- 固定窗口就是定义一个“固定”的统计周期,比如1分钟或者30秒、10秒这样。然后在每个周期统计当前周期中被接收到的请求数量,经过计数器累加后如果达到设定的阈值就触发「流量干预」。直到进入下一个周期后,计数器清零,流量接收恢复正常状态。
- 常用的策略就4种,我给它起了一个简单的定义——「两窗两桶」。两窗就是:固定窗口、滑动窗口,两桶就是:漏桶、令牌桶。
- 最后,就是处理“被干预掉”的流量。能不能直接丢弃?不能的话该如何处理?
- 四种策略该如何选择?
- 首先,固定窗口。一般来说,如非时间紧迫,不建议选择这个方案,太过生硬。但是,为了能快速止损眼前的问题可以作为临时应急的方案。
- 其次,滑动窗口。这个方案适用于对异常结果「高容忍」的场景,毕竟相比“两窗”少了一个缓冲区。但是,胜在实现简单。
- 然后,漏桶。z哥觉得这个方案最适合作为一个通用方案。虽说资源的利用率上不是极致,但是「宽进严出」的思路在保护系统的同时还留有一些余地,使得它的适用场景更广。
- 最后,令牌桶。当你需要尽可能的压榨程序的性能(此时桶的最大容量必然会大于等于程序的最大并发能力),并且所处的场景流量进入波动不是很大(不至于一瞬间取完令牌,压垮后端系统)。
- 降级
- 将有限的资源效益最大化
- 牺牲功能完整性
- 牺牲时效性
- 实现主要分为两个环节:定级定序和降级实现
- 某个程序所依赖的下游程序的级别不能低于该程序的级别。
- 补偿
- 就是一旦某个操作发生了异常,如何通过内部机制将这个异常产生的「不一致」状态消除掉。
- 做补偿的核心要点是:宁可慢,不可错。
- 做「补偿」的主流方式就前面提到的「事务补偿」和「重试」
- 无状态
- 无状态」意味着每次“加工”的所需的“原料”全部由外界提供,服务端内部不做任何的「暂存区」。并且请求可以提交到服务端的任意副本节点上,处理结果都是完全一样的。
- 任何事物都是有两面性的,正如前面提到的,我们并不是要所有的业务处理都改造成「无状态」,而只是挑其中的一部分。最终还是看“价值”,看“性价比”
- 高内聚低耦合
- 做好高内聚低耦合,思路也很简单:定职责、做归类、划边界
- 模块对外暴露的接口部分,数据类型的选择上尽量做到宽进严出
- 写操作接口,接收参数尽可能少;读操作接口,返回参数尽可能多
- 弹性架构
- 事件驱动架构
- 中心化
- 去中心化
- 它的优点是:
- 通过「队列」进行解耦,使得面对快速变化的需求可以即时上线,而不影响上游系统。
- 由于「事件」是一个独立存在的“标准化”沟通载体,可以利用这个特点衔接各种跨平台、多语言的程序。如果再进行额外的持久化,还能便于后续的问题排查。同时也可以对「事件」进行反复的「重放」,对处理者的吞吐量进行更真实的压力测试。
- 更“动态”、容错性好。可以很容易,低成本地集成、再集成、再配置新的和已经存在的事件处理者,也可以很容易的移除事件处理者。轻松的做扩容和缩容。
- 在“上帝”模式下,对业务能有一个“可见”的掌控,更容易发现流程不合理或者被忽略的问题。同时能标准化一些技术细节,如「数据一致性」的实现方式等。
- 它的缺点是:
- 面对不稳定的网络问题、各种异常,想要处理好这些以确保一致性,需要比同步调用花费很大的精力和成本。
- 无法像同步调用一般,操作成功后即代表可以看到最新的数据,需要容忍延迟或者对延迟做一些用户体验上的额外处理。
- 那么,它所适用的场景就是:
- 对实时性要求不高的场景。
- 系统中存在大量的跨平台、多语言的异构环境。
- 以尽可能提高程序复用度为目的的场景。
- 业务灵活多变的场景。
- 需要经常扩容缩容的场景。
- 微内核架构
- 它的优点是:
- 为递进设计和增量开发提供了方便。可以先实现一个稳固的核心系统,然后逐渐地增加功能和特性。
- 和事件驱动架构一样,也可避免单一组件失效,而造成整个系统崩溃,容错性好。内核只需要重新启动这个组件,不致于影响其他功能。
- 它的缺点是:
- 由于主要的微内核很小,所以无法对整体进行优化。每个插件都各自管各自的,甚至可能是由不同团队负责维护。
- 一般来说,为了避免在单个应用程序中的复杂度爆炸,很少会启用插件嵌套插件的模式,所以插件中的代码复用度会差一些。
- 那么,它所适用的场景就是:
- 可以嵌入或者作为其它架构模式的一部分。例如事件驱动架构中,“上帝”的「事件转换」就可以使用微内核架构实现。
- 业务逻辑虽然不同,但是运行逻辑相同的场景。比如,定期任务和作业调度类应用。
- 具有清晰的增量开发预期的场景。
- 它的优点是:
- 事件驱动架构
- 拆库
- 垂直切分
- 「垂直切分」的优点是:
- 高内聚,拆分规则清晰。相比「水平切分」数据冗余度更低。
- 与应用程序是1:1的关系,方便维护和定位问题。一旦某个数据库中发现异常数据,排查这个数据库的关联程序就行了。
- 缺点:对于访问极其频繁或者数据量超大的表仍然存在性能瓶颈。
- 「垂直切分」的优点是:
- 水平切分
- 先找到“最高频“的「读」字段。
- 再看这个字段的实际使用中有什么特点(批量查询多还是单个查询多,是否同时是其它表的关联字段等等)。
- 再根据这个特点选择合适的切分方案。
- 范围切分:单个表的大小可控,扩展的时候无需数据迁移,压力主要集中在新的库中,而历史越久的库,越空闲
- Hash切分:新数据被分散到了各个节点中,避免了压力集中在少数节点上,一旦进行二次扩展,必然会涉及到数据迁移。因为Hash算法是固定的,算法一变,数据分布就变了。
- 全局表:将用作切分依据的分区Key与对应的每一条具体数据的id保存到一个单独的库或者表中
- 如果热点数据不是特别集中的场景,建议先用「范围切分」,否则选择另外2种。
- 数据量越大越倾向选择Hash切分
- 能不切分尽量不要切分,可以先使用「读写分离」之类的方案先来应对面临的问题。
- 如果实在要进行切分的话,务必先「垂直切分」,再考虑「水平切分」。
- 垂直切分
- 缓存
- 在内存中存储访被问过的数据供后续访问时使用,以此来达到提速的效果
- 预读取就是预先读取将要载入的数据,也可以称作「缓存预热」。就是在系统对外提供服务之前,先将硬盘中的一部分数据加载到内存中,然后再对外提供服务
- 通过缓存机制来加速“写”的过程就可以称作「延迟写」。就是预先将需要写入到磁盘或者数据库的数据,先暂时写入到内存,然后就返回成功。再定时将内存中的数据批量写入到磁盘,「延迟写」一般仅用于对数据完整性要求不是那么苛刻的场景
- 热点数据、静态数据可以缓存
- 运用场景
- 浏览器缓存
- CDN缓存
- 网关(代理)缓存:Varnish,Squid,Ngnix
- 进程内缓存
- 进程外缓存:redis、memcached
- 数据库缓存
- 先DB再缓存
- 数据库操作成功,缓存操作的失败的情况该怎么解:在操作数据库的时候带一个事务,如果缓存操作失败则事务回滚。
- write cache的时候做delete操作,而不是set操作
- 用多一次cache miss的代价来换rollback db失败的问题。
- 哪怕rollback失败了,通过一次cache miss重新从db中载入旧值。
- 高可用数据库(主从):
- 如果在数据还未同步到「从库」的时候,由于cache miss去「从库」取到了未同步前的旧值:就是定时去「从库」读数据,发现数据和缓存不一样了就set到缓存里去,
- 这个方式有点“治标不治本”。不断的从数据库定时读取,对资源的消耗大不说,这个间隔频率也不好定义一个比较合适的统一标准,太短吧,会导致重复读取的次数加大,太长吧,又会导致缓存和数据库不一致的时间变长
- 在产生数据库写入动作后的一小段时间内强制读「主库」来加载缓存。
- 先缓存再DB
- 一般不建议选择「先缓存再DB」的方案,因为内存是易失性的
- 哪怕用delete cache的方式,要么带lock(多客户端情况下还得上分布式锁),要么必然出现数据不一致
- 对写入速度有极致要求,而对数据准确性没那么高要求的场景下就非常好使
- 建议你使用「先DB再缓存」的方式,并且缓存操作用delete而不是set
- 先DB再缓存
- 本地缓存
- 不经常变更的数据。(比如一天甚至好几天更新一次的那种)
- 需要支撑非常高的并发。(比如秒杀)
- 对数据准确性能容忍的场景。(比如浏览量,评论数等)
- 除了第二种场景,否则还是尽量不要引入本地缓存
- 本地缓存、分布式缓存、db之间的数据一致性
- 一般系统规模小的时候可以考虑由接收修改的节点通知其它节点变更(通过rpc或者mq皆可),而规模越大越会选择借助一致性hash让同一个来源的请求固定落到一个节点上
- 一件事情的精细化所带来的复杂度需要更加的精细化去解决,但是又会带来新的复杂度。所以作为技术人的你,需要无时无刻考虑该怎么权衡,而不是人云亦云
- 缓存雪崩
- 「缓存雪崩」的根本问题是:缓存由于某些原因未起到预期的缓冲效果,导致请求全部流转到数据库,造成数据库压力过重
- 解决办法:
- 加锁排队:通过加锁或者排队机制来限制读数据库写缓存的线程数量
- 缓存时间增加随机值:这个主要针对的是「缓存定时过期」机制下的取巧方案。它的目的是避免多个缓存key在同一时间失效,导致压力更加集中
- 解决办法:
- 「缓存穿透」有时也叫做「缓存击穿」
- 「缓存穿透」和「缓存雪崩」最终产生的效果是一样的,就是因为大量请求流到DB后,把DB拖垮。
- 「缓存雪崩」问题只要数据从db中找到并放入缓存就能恢复正常,而「缓存穿透」指的是所需的数据在DB中一直不存在的情况
- 解决方式
- 布隆过滤器(bloomfilter):布隆过滤器就是由一个很长的二进制向量和一系列随机映射函数组成,将确定不存在的数据构建到过滤器中,用它来过滤请求,布隆过滤器有一个最大的缺点,也是其为了高效利用内存而付出的代价,就是无法确保100%的准确率
- 缓存空对象:其实就是哪怕从db中取出的数据是“空(null)”,也把它丢失到缓存中
- 「缓存雪崩」的根本问题是:缓存由于某些原因未起到预期的缓冲效果,导致请求全部流转到数据库,造成数据库压力过重
- 异步
- 只有将「异步」运用于「等待处理的时间」>「创建、销毁、切换线程的时间」的场景下才有价值
- 在使用「异步」的时候,有两点特别容易被忽略。
- 发起请求的线程往往和接收响应的线程不是同一个,所以「线程上下文」是不连续的。
- 虽然请求的顺序是由客户端控制的,但是回调的时候可能就不一定是按照请求时的顺序进行的,像下图这样。
- 需要引入一个全局唯一标识将整个异步的请求链路“串“起来,否则排查问题的时候够你头疼的,完全分不清楚哪是哪
- 如果条件允许,可以再引入一个日志聚合系统。比如ELK全家桶,让你可以更高效的筛选日志信息。
- 阻塞&非阻塞
- 分布式的测试和监控
- 易测试
- 第一点,分层:分层其实除了之前聊到的「易扩展」之外,对于测试工作的进行也是有很大帮助,规模越大的系统越是如此。做好分层只要记住一个概念就行,「高内聚低耦合」
- 第二点,无状态
- 第三点,避免硬编码,尽量配置化
- 第四点,依赖注入
- 第五点,打日志
- 第六点,接口版本化,并且向前兼容
- 监控
- 监控可以分为三个层次。分别是「环境指标」、「程序指标」、「业务指标」。
- 无脑用的话,就Zabbix吧
- 想二次开发的话可以使用小米开源的open-falcon
- 易测试