分布式系统设计

高可用分布式系统应该要考虑以下几个大点和一些小点,大点与小点之间其实有包含、有交集,只是为了方便分点论述才分类。

四个大点:

  • 容错能力(服务隔离、异步调用、请求幂等性、分布式锁)
  • 可伸缩性(有 / 无状态的服务)
  • 一致性(补偿事务、重试)
  • 应对大流量的能力(熔断、降级)

两个小点:

  • 解耦(MQ)
  • 线程池

四个大点论述:

1、容错能力(服务隔离、异步调用、请求幂等性、分布式锁)

何为容错能力:系统在不健康、不顺,甚至出错的情况下有能力 hold 得住,挺得住,还有能在这种逆境下力挽狂澜的能力;我们都知道,故障是必然会发生的,是正常的,是常见的,我们应该把处理故障的代码当成正常的功能做在架构里写在代码里。

  • 请求幂等:幂等并不是每次请求的结果都一样,而是一次和多次请求某一个资源应该具有同样的副作用,f(x) = f(f(x)),要做到幂等性的交易接口,需要有一个唯一的标识,来标志交易是同一笔交易,这个标识要能做到全局唯一。幂等还是比较容易实现的,比如对于转账交易(支付系统一定要保证幂等),最简单的就是每次请求都带有一个唯一标识id,比如一个请求(id,money),此id标识在数据库中能唯一确定一条记录;若因为网络故障客户端多次请求同一个转账交易(id,money),服务端必须保证只能有一次记录成功,对于其他重复请求应该给客户端明确的解析语义。我们一般保证幂等的判断是从数据库查询有没有相同id的记录,但是在分布式系统环境下,可能有问题,主从问题:两个相同的请求request1、request2时间间隔很短,request1请求过来的时候,查询从库发现没有对应记录,则request1开始操作插入主库record1,但是还没有同步到从库;此时request2查询从库(主从还未同步)也发现没有相同id的记录,准备插入有相同id的记录record2,这个时候request1成功插入record1,request2开始插入record2,数据库报错:唯一约束被破坏相关的异常日志;解决这个问题有两种方法:1、读写都强制走主库;2、采用分布式锁,考虑性能问题,一般都选2
  • 分布式锁:分布式系统一般都有多台机器,常规的多线程锁已经无法解决问题;最简单用redis实现:思路很简单,主要用到的redis函数是setnx()。首先是将某一任务标识名UniqueKey(能唯一识别一个请求的标识)作为键存到redis里,并为其设个过期时间,如果是同样的请求过来,先是通过setnx()看看是否能将UniqueKey插入到redis里,可以的话就返回true,不可以就返回false。
    • 分布式锁设计原则:
    •    互斥性,同一时间只有一个线程持有锁
    •    容错性,即使某一个持有锁的线程,异常退出,其他线程仍可获得锁
    •    隔离性,线程只能解自己的锁,不能解其他线程的锁
  • 服务隔离:是为了在系统发生故障时能限定传播范围和影响范围,即发生故障后不会出现滚雪球效应,从而保证只有出问题的服务不可用,其他服务还是可用的。
  • 异步调用:发送方发送请求后,接收方直接返回正在处理,通过轮询或者回调的方式返回结果;异步调用相比于同步调用的最大好处是可以快速相应客户端的请求,至于具体的请求结果,通过异步回调的方式发送给客户端,这种方式在服务端平均处理请求时间过长的业务场景下很好用,避免在高请求流量下的超时、阻塞等问题;

2、可伸缩性(有 / 无状态的服务)

  • 无状态服务在程序 Bug 上和水平扩展上有非常优秀的表现,但是在一致性上却有劣势,事物总是相对的(个人此点能力不足,不足以论述)

3、一致性(补偿事务、重试)

  • ACID :大家在买同一本书的过程中,每个用户的购买请求都需要把库存锁住,等减完库存后,把锁释放出来,后续的人才能进行购买。同一时间不可能有多个用户下单,订单流程需要有排队的情况,这样一来,我们就不可能做出性能比较高的系统来。
  • BASE :大家都可以同时下单,这个时候不需要去真正地分配库存,然后系统异步地处理订单,而且是批量的处理。因为下单的时候没有真正去扣减库存,所以,有可能会有超卖的情况。而后台的系统会异步地处理订单时,发现库存没有了,于是才会告诉用户你没有购买成功。
  • 强一致性(ACID)和高可用性(BASE)是对立,顾此失彼;因此,为了可用性,我们要讲业务中需要强一致性的动作和不需要强一致性的动作剥离开,对于非强一致性需求的动作,可以做补偿事务;我们应尽量设计更多非强一致性的业务
  • 由于网络等问题,一些请求无法确定是否成功,这个时候需要重试,即在此发送请求给服务端,希望服务端能确定的交易结果,重试一般通过定时任务扫表,将不是终态的记录查询出在此发请求。

4、应对大流量的能力(熔断、降级、限流)

  • 熔断(慎用):如果系统中,某个目标服务调用慢或者有大量超时,此时,熔断该服务的调用,对于后续调用请求,不在继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用;熔断主要是应对流量引起的问题,弃卒保车,使服务处于关闭、半关闭状态,以保证部分业务成功,或者舍弃次要业务使主业务运行通畅;
  • 限流:熔断的一种,半开状态,只允许少部分的请求,其他的都拒绝,如果设计得当,被拒绝的请求,客户端会通过重试、补偿操作来完成;

  • 降级:暂时牺牲掉一些服务,保障整个系统的服务。比如:如果服务器已经高负载,这个时候可以将一些不重要的操作给关闭,比如重试操作,因为服务器已经承受不住,这个时候再重试也没有多大效果,反而更加重服务端的压力,这个时候可以设置一个开关,将重试功能关闭;

两个小点:

1、解耦(MQ)

在软件工程中,对象之间的耦合度就是对象之间的依赖性。对象之间的耦合越高,维护成本越高,因此对象的设计应使模块之间的耦合度尽量小。在软件架构设计中,模块之间的解耦或者说松耦合有两种,假设有两个模块A、B,A依赖B:

  1. 第一种是,模块A和模块B只通过接口交互,只要接口设计不变,那么模块B内部细节的变化不影响模块A对模块B服务能力的消费。 
    • 面向接口设计下真正实现了将接口契约的定义和接口的实现彻底分离,实现变化不影响到接口契约,自然不影响到基于接口的交互。
    • 模块A和B之间的松耦合,主要通过合理的模块划分、接口设计来完成。如果出现循环依赖,可以将模块A、B共同依赖的部分移除到另一个模块C中,将A、B之间的相互依赖,转换为A、B同时对C的依赖。
  2. 第二种是,将同步调用转换成异步消息交互。 
    • 比如在买机票系统中,机票支付完成后需要通知出票系统出票、代金券系统发券。如果使用同步调用,那么出票系统、代金券系统宕机是会影响到机票支付系统,如果另一个系统比如专车系统也想要在机票支付完成后向用户推荐专车服务,那么同步调用模式下机票支付系统就需要为此而改动,容易影响核心支付业务的可靠性。
    • 如果我们将同步调用替换成异步消息,机票支付系统发送机票支付成功的消息到消息中间件,出票系统、代金券系统从消息中间件订阅消息。这样一来,出票系统、代金券系统的宕机也就不会对机票支付系统造成任何影响了。专车系统想要知道机票支付完成这一事件,也只需要从消息中间件订阅消息即可,机票支付系统完全不需要做任何改动。
    • 异步消息解耦,适合那些信息流单向流动(类似发布-订阅这样的),实时性要求不高的系统。常见的开源消息队列框架有:Kafka、RabbitMQ、RocketMQ。

2、线程池

  • 线程池:如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间,且创建过多的线程会耗费系统资源,频繁的上下文切换也影响系统的性能。

实战分析:

1、降级

分析项目一:三个系统,A、B、C;A负责下单菜品:aaa,B负责接受A的下单请求,比根据A传递过来的aaa查询C得到菜品的详情,然后在继续下单;我们可以看出如果C系统挂了,整个下单流程就挂了,无法下单了;如何降级?B将C的菜品数据也存一份,正常情况下用C的菜品查询(C的菜品数据实时性更好一点),如果C挂了,B就用自己存的菜品,作为一种临时方案,达到可继续下单的目的,毕竟不能因为一个查询系统挂了导致整个下单功能不能用,这就是一种降级处理~

分析项目二:关闭重试机制;很多项目都有超时重试的机制,这种机制在服务已经负载过大、无法及时处理时,应当将重试逻辑关闭,避免因重试导致更严重的服务负担。

分析项目三:控制入口访问权限,白名单可用,当服务负载过大,可以通过在入口(比如拦截器中进行控制)控制流量,比如只允许vip客户访问,通过添加白名单方式来实现。

降级的手段总结:
a. 拒绝部分请求:如下三种方式

  • 拒绝部分老请求:优先处理新请求
  • 优先级请求方式:下单的请求咱继续收,退款、查询等先拒绝吧
  • 随机丢弃

2、高可用设计注意事项

分析项目一:缓存,不要放在业务侧,要在数据源头设置,业务方只负责增删改;否则可能造成多个业务方的数据不一致;

发布了142 篇原创文章 · 获赞 345 · 访问量 45万+

猜你喜欢

转载自blog.csdn.net/zhengchao1991/article/details/81071725