系统隔离:如何应对高并发流量冲击?

在一次活动中,系统出现了大规模崩溃情况。活动开始时,约有五万学员同时进行操作,大量请求瞬间涌向服务器,使得服务端出现大量请求堆积,最终系统资源耗尽而停止响应。我们不得不重启服务,并对接口实施限流措施,服务才得以恢复正常。

究其原因,我们习惯将公用功能和数据做成内网服务。这种方式虽能提高服务的复用性,却也使我们的服务高度依赖内网服务。当外网受到流量冲击时,内网会受到放大流量的冲击,过高的流量极易导致内网服务崩溃,进而致使整个网站无法响应。

事故发生后,我们经过详细复盘,最终一致认为此次系统大规模崩溃的核心问题在于系统隔离性不佳,业务之间极易相互影响。

如果系统隔离性做得好,在受到大流量冲击时,只会影响被冲击的应用服务,即使某个业务因此崩溃,也不会影响到其他业务的正常运转。这就要求我们的架构要有能力隔离多个应用,并且能够隔离内外网流量,只有如此才能够保证系统的稳定。

拆分部署和物理隔离

为了提高系统的稳定性,我们决定对系统做隔离改造,具体如下图:

也就是说,每个内、外网服务都会部署在独立的集群内,同时每个项目都拥有自己的网关和数据库。而外网服务和内网必须通过网关才能访问,外网向内网同步数据是用 Kafka 来实现的。

网关隔离和随时熔断

在这个改造方案中存在两种网关,即外网网关和内网网关。每个业务都具备独立的外网网关(可依据实际需求进行调整),用于对外网流量进行限流。当瞬时流量超出系统承受能力时,网关会使超编的请求排队阻塞一段时间,待服务器 QPS 高峰过后再予以放行。与直接拒绝客户端请求相比,这种方式能够为用户带来更好的体验。外网若要调用内网的接口,必须经过内网网关。当外网请求内网接口时,内网网关会对请求的来源系统和目标接口进行鉴权。经过注册授权的外网服务只能访问对其授权过的内网接口,如此一来,便可以严格管理系统之间的接口调用。

同时,在开发期间我们必须时刻留意,当内网网关处流量增大时要进行熔断操作。这样能够避免外网服务过度依赖内网接口,保证外网服务的独立性,确保内网不受外网流量的冲击。并且,外网服务要确保在内网网关断开后,依旧能够正常独立运转一小时以上。然而,你应该也察觉到了,这样的隔离方式无法实时调用内网接口,会给研发工作带来很大的困扰。要知道,常见的外网业务往往需要频繁调用内网服务以获取基础数据才能正常运行,而且当内网、外网同时针对同一份数据进行决策时,很容易出现混乱局面。

减少内网 API 互动

为避免共享的数据被多个系统同时修改,我们会在活动期间对参与活动的数据和库存进行推送,随后自动锁定。如此一来,便能防止其他业务和后台对数据进行修改。若要实行禁售,可以通过后台直接调用前台业务接口来进行操作。在活动期间,也可以向外网业务中添加新的商品,但只能增加而不能减少。

这样的实现方式既能够确保在一段时间内数据决策的唯一性,又可以保证内外网的隔离性。不过需要注意的是,这里的锁定操作只是为了保证数据同步不出问题,在活动高峰过后,数据不能一直处于锁定状态,否则会使我们的业务变得很不灵活。因为我们需要将活动交易结果同步回内网,而在同步期间外网依旧能够继续进行交易。如果不保持锁定,数据的流向可能会不小心变成双向同步,这种双向同步很容易引发混乱,一旦系统因此出现问题,就会很难进行修复,如下图所示。

从图中我们可以看到,两个系统由于没有实时互动的接口,数据是完全独立的。然而,在回传外网数据到内网时,如果库存在两个系统之间来回传递,就很容易出现同步冲突,进而导致混乱。那么,如何避免类似问题呢?实际上,只有确保数据同步是单向的,才能够取消相互锁定操作。我们可以规定所有库存决策由外网业务服务决定,后台在对库存进行操作时,必须经过外网业务决策后才能继续操作。

这样的方式比锁定数据更加灵活。而外网在交易后要向内网同步交易结果,只能通过队列方式推送到内网。事实上,使用队列同步数据并非易事,其中有很多流程和细节需要我们去精心打磨,以减少不同步的情况。好在我们所使用的队列很成熟,提供了很多方便的特性,能够帮助我们降低同步风险。

现在我们来看下整体的数据流转,如下图:

后台系统推送数据到 Redis 或数据库中,外网服务通过 Kafka 把结果同步到内网,扣减库存需通知外网服务扣减成功后方可同步操作。

分布式队列控流和离线同步

我们刚才提到,外网和内网进行同步所使用的是 Kafka 分布式队列,主要是因为它具有以下几个优点:队列拥有良好的吞吐量,并且能够进行动态扩容,可应对各种流量冲击的场景;可以通过动态控制内网消费线程数,从而实现内网流量的可控性;内网消费服务在高峰期可以暂时离线,内网服务能够临时进行一些停机升级操作;如果内网服务出现 bug,导致消费数据丢失,可以对队列消息进行回放,实现重新消费;Kafka 是分区消息同步,消息是顺序的,很少会出现乱序情况,可以帮助我们实现顺序同步;消息内容可以保存很长时间,加入 TraceID 后查找方便且透明,有利于排查各种问题。两个系统之间的数据同步是一件非常复杂、繁琐的事情,而使用 Kafka 可以将这个实时过程转变为异步的,再加上消息可回放、流量也可控,整个过程变得轻松了许多。

在“数据同步”中最难的一步就是保证顺序,接下来我具体介绍一下我们当时是怎么做的

当用户在外网业务系统下单购买一个商品时,外网服务会扣减本地缓存中的库存。库存扣减成功后,外网会创建一个订单并发送创建订单消息到消息队列中。当用户在外网业务支付订单后,外网业务订单状态会更新为“已支付”,并给内网发送支付成功的消息到消息队列中,发送消息实现如下:

type ShopOrder struct { 
   TraceId    string `json:trace_id`      // trace id 方便跟踪问题 
   OrderNo    string `json:order_no`      // 订单号 
   ProductId  string `json:"product_id"`  // 课程id 
   Sku        string `json:"sku"`         // 课程规格 sku 
   ClassId    int32  `json:"class_id"`    // 班级id 
   Amount     int32  `json:amount,string` // 金额,分 
   Uid        int64  `json:uid,string`    // 用户uid 
   Action     string `json:"action"`      // 当前动作 create:创建订单、pay:支付订单、refund:退费、close:关闭订单 
   Status     int16  `json:"status"`      // 当前订单状态 0 创建 1 支付 2 退款 3 关闭 
   Version    int32  `json:"version"`     // 版本,会用当前时间加毫秒生成一个时间版本,方便后端对比操作版本,如果收到消息的版本比上次操作的时间还小忽略这个事件 
   UpdateTime int32  `json:"update_time"` // 最后更新时间 
   CreateTime int32  `json:"create_time"` // 订单创建日期 
} 
//发送消息到内网订单系统 
resp, err := sendQueueEvent("order_event", shopOrder{...略}, 消息所在分区) 
if err != nil { 
return nil, err 
} 
return resp, nil

可以看到,我们在发送消息的时候,已经通过某些依据(如订单号、uid)算出这条消息应该投放到哪个分区内。Kafka 同一个分区内的消息是顺序的。

为什么要保证消费顺序呢?核心在于我们的数据操作必须按顺序执行。如果不按顺序,就会出现很多奇怪的场景。比如 “用户执行创建订单、支付订单、退费” 这一系列操作,消费进程很有可能会先收到退费消息,但由于还没收到创建订单和支付订单的消息,退费操作在此时就无法进行。当然,这只是个简单的例子,如果碰到更多步骤乱序的话,数据会更加混乱。所以,如果我们想做好数据同步,就要尽量保证数据是顺序的。

不过,我们在前面讲 Kafka 的优点时也提到了,队列在大部分时间是能够保证顺序性的,但是在极端情况下仍会有乱序发生。为此,我们在业务逻辑上需要做兼容。即使无法自动解决,也要记录好相关日志以方便后续排查问题。不难发现,因为这个 “顺序” 的要求,我们的数据同步存在很大难度。好在 Kafka 是能够长时间保存消息的。如果在同步过程中出现问题,除了通过日志对故障进行修复外,我们还可以将故障期间的流量进行重放(重放要保证同步幂等)。这个特性让我们可以做很多灵活的操作,甚至可以在流量高峰期,暂时停掉内网消费服务,待系统稳定后再开启,落地用户的交易。

除了数据同步外,我们还需要对内网的流量做到掌控。我们可以通过动态控制线程数来实现控制内网流量的速度。

9