中间件常用组件的原理和设计
07 RPC:如何在面试中展现出“造轮子”的能力?
我们知道,很多应用系统发展到一定规模之后,都会向着服务化方向演进,演进后的单体系统就变成了由一个个微服务组成的服务化系统,各个微服务系统之间通过远程 RPC 调用的方式通信。
可以说,RPC 是微服务架构的基础,从事互联网系统开发,就离不开 RPC 框架,所以这一讲,我们就立足面试场景下对 RPC 技术的考察,来讲解你要掌握的技术点和面试思路。
案例背景
主流的 RPC 框架很多,比如 Dubbo、Thrift、gRPC 等,非主流的框架你在 GitHub 上搜索也有很多结果。框架资源多,很多同学在工作中的选择也多,基本上都是拿来就用,停留在基础概念和使用上,不会深究技术实现。
所以很多候选人对于 RPC 有关的面试问题存在一个误区,认为面试官只会问这样几个问题:
- RPC 的一次调用过程是怎样的?
- RPC 的服务发现是如何实现的?
- RPC 的负载均衡有哪些?
- ……
这些问题看似专业,却很容易搜索到答案,如果作为面试题很难区分候选人的技术能力。所以针对 RPC 的技术考察,目前大多数面试官会从“实践操作 + 原理掌握”两个角度出发,递进地考察候选人。
具体怎么考察呢?我们接着往下看。
RPC 实践操作
面试官通常会从线上的实际案例出发,考察候选人对“实践操作”的掌握程度。举个例子:在电商 App 商品详情页中,用户每次刷新页面时,App 都会请求业务网关系统,并由网关系统远程调用多个下游服务(比如商品服务、促销服务、广告服务等)。
针对这个场景,面试官会问“对于整条 RPC 调用链路(从 App 到网关再到各个服务系统),怎么设置 RPC 的超时时间,要考虑哪些问题?”
App 商品详情页服务调用
一些初中级研发会觉得问题很简单,不用想也知道:App 远程调用网关系统的超时时间要大于网关系统调用后端各服务的超时时间之和。这样至少能保证在网关与下游服务的每个 PRC 调用执行完成之前不超时。
如果你这么回答,从“实践”的角度上看,基本是不合格的。
因为 PRC 接口的超时设置看似简单,但其中却涉及了很多技术层面的问题。比如 RPC 都有超时重传的机制,如果后端服务触发超时重传,这时对 App 来说,也会存在请求等待超时的风险,就会出现后端服务还没来得及做降级处理,商品详情页就已经等待超时了。
并且在 RPC 调用的过程中也还会涉及其他的技术点,比如:
- 即使考虑到整个调用链的平均响应时长会受到所有依赖服务的耗时和重传次数影响,那么依据什么来设置 RPC 超时时间和重试次数呢?
- 如果发生超时重传,怎么区分哪些 RPC 服务可重传,哪些不可重传呢?
- 如果请求超过了 PRC 的重传次数,一般会触发服务降级,这又会对商品详情页造成什么影响?
- ……
总的来说,任何一个微服务出现性能问题,都会影响网关系统的平均响应时长,最终对 App 产生影响。所以从 RPC 接口的超时问题上,面试官会考察候选人很多深层次的开发实践能力。
那具体要怎么回答呢?我建议你参考以下解题思路。
- 结合 TP99 请求耗时:首先如果你要回答“超时时间设置和重传次数问题”,需要根据每一个微服务 TP99 的请求耗时,以及业务场景进行综合衡量。
- RPC 调用方式:你要站在业务场景下,讲清楚网关调用各下游服务的串并行方式,服务之间是否存在上下服务依赖。
- 分析核心服务:分析出哪些是核心服务,哪些是非核心服务,核心服务是否有备用方案,非核心服务是否有降级策略。
总的来讲,解答“实践操作类面试题”,一定要结合理论和落地实践,要做到即有理也有据,有理表示要有分析问题的能力,有据表示具备落地实战的经验。很多同学的通病是:回答问题只有方案,没有落地细节,这会让面试官认为你技术不扎实。
进一步,如果面试官觉得你“实践问题”答得不错,会深入考察你对 RPC 的原理性知识的掌握情况。
RPC 原理掌握
以刚刚的“电商 App”场景为例:
App 商品详情页服务调用
此时,商品详情页的 QPS 已达到了 2 万次/s,在做了服务化拆分之后,此时完成一次请求需要调用 3 次 RPC 服务,计算下来,RPC 服务需要承载大概 6 万次/s 的请求。那么你怎么设计 RPC 框架才能承载 6 万次/s 请求量呢?
能否答好这个问题,很考验候选人对 RPC 原理掌握的深度,我建议你从两个角度分析。
- 优化 RPC 的网络通信性能: 高并发下选择高性能的网络编程 I/O 模型。
- 选型合适的 RPC 序列化方式: 选择合适的序列化方式,进而提升封包和解包的性能。
然而我在面试候选人时发现,一些同学虽然做了准备,但只能说出个别 RPC 框架的大致流程,不能深刻理解每个环节的工作原理,所以整体给我的感觉就是:应用层面通过,原理深度不够。
而我对你的要求是:对于中间件等技术工具和框架,虽然在实际工作中不推荐重复“造轮子”,但在面试中要证明自己具备“造轮子”的能力,因为要评价一个程序员是否对技术栈有全面的认识,考察其“造轮子”的能力是一个不错的切入点。
接下来我们先理解一下完整的 RPC 会涉及哪些步骤,然后再解析其中的重要环节,搞懂 RPC 原理的考察点。
一次完整的 RPC 流程
因为 RPC 是远程调用,首先会涉及网络通信, 又因为 RPC 用于业务系统之间的数据交互,要保证数据传输的可靠性,所以它一般默认采用 TCP 来实现网络数据传输。
网络传输的数据必须是二进制数据,可是在 RPC 框架中,调用方请求的出入参数都是对象,对象不能直接在网络中传输,所以需要提前把对象转成可传输的二进制数据,转换算法还要可逆,这个过程就叫“序列化”和“反序列化”。
另外,在网络传输中,RPC 不会把请求参数的所有二进制数据一起发送到服务提供方机器上,而是拆分成好几个数据包(或者把好几个数据包封装成一个数据包),所以服务提供方可能一次获取多个或半个数据包,这也就是网络传输中的粘包和半包问题。为了解决这个问题,需要提前约定传输数据的格式,即“RPC 协议”。 大多数的协议会分成数据头和消息体:
- 数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;
- 消息体主要是请求的业务参数信息和扩展属性等。
在确定好“ RPC 协议”后,一次完整的 RPC 调用会经过这样几个步骤:
- 调用方持续把请求参数对象序列化成二进制数据,经过 TCP 传输到服务提供方;
- 服务提供方从 TCP 通道里面接收到二进制数据;
- 根据 RPC 协议,服务提供方将二进制数据分割出不同的请求数据,经过反序列化将二进制数据逆向还原出请求对象,找到对应的实现类,完成真正的方法调用;
- 然后服务提供方再把执行结果序列化后,回写到对应的 TCP 通道里面;
- 调用方获取到应答的数据包后,再反序列化成应答对象。
这样调用方就完成了一次 RPC 调用。
RPC 通信流程
你应该能发现, RPC 通信流程中的核心组成部分包括了协议、序列化与反序列化,以及网络通信。在了解了 RPC 的调用流程后,我们回到“电商 App”的案例中,先来解答序列化的问题。
如何选型序列化方式
RPC 的调用过程会涉及网络数据(二进制数据)的传输,从中延伸的问题是:如何选型序列化和反序列化方式?
要想回答这一点,你需要先明确序列化方式,常见的方式有以下几种。
- JSON:Key-Value 结构的文本序列化框架,易用且应用最广泛,基于 HTTP 协议的 RPC 框架都会选择 JSON 序列化方式,但它的空间开销很大,在通信时需要更多的内存。
- Hessian:一种紧凑的二进制序列化框架,在性能和体积上表现比较好。
- Protobuf:Google 公司的序列化标准,序列化后体积相比 JSON、Hessian 还要小,兼容性也做得不错。
明确“常见的序列化方式”后,你就可以组织回答问题的逻辑了:考虑时间与空间开销,切勿忽略兼容性。
在大量并发请求下,如果序列化的速度慢,势必会增加请求和响应的时间(时间开销)。另外,如果序列化后的传输数据体积较大,也会使网络吞吐量下降(空间开销)。所以,你要先考虑上述两点才能保证 RPC 框架的整体性能。除此之外,在 RPC 迭代中,常常会因为序列化协议的兼容性问题使 RPC 框架不稳定,比如某个类型为集合类的入参服务调用者不能解析,某个类的一个属性不能正常调用……
当然还有安全性、易用性等指标,不过并不是 RPC 的关键指标。总的来说,在面试时,你要综合考虑上述因素,总结出常用序列化协议的选型标准,比如首选 Hessian 与 Protobuf,因为它们在时间开销、空间开销、兼容性等关键指标上表现良好。
如何提升网络通信性能
如何提升 RPC 的网络通信性能,这句话翻译一下就是:一个 RPC 框架如何选择高性能的网络编程 I/O 模型?这样一来,和 I/O 模型相关的知识点就是你需要掌握的了。
对于 RPC 网络通信问题,你首先要掌握网络编程中的五个 I/O 模型:
- 同步阻塞 I/O(BIO)
- 同步非阻塞 I/O
- I/O 多路复用(NIO)
- 信号驱动
- 以及异步 I/O(AIO)
但在实际开发工作,最为常用的是 BIO 和 NIO(这两个 I/O 模型也是面试中面试官最常考察候选人的)。为了让你更好地理解编程模型中,这两个 I/O 模型典型的技术实现,我以 Java 程序例,编程写了一个简单的网络程序:
public class BIOSever {ServerSocket ss = new ServerSocket();// 绑定端口 9090ss.bind(new InetSocketAddress("localhost", 9090));System.out.println("server started listening " + PORT);try {Socket s = null;while (true) {// 阻塞等待客户端发送连接请求s = ss.accept();new Thread(new ServerTaskThread(s)).start();}} catch (Exception e) {// 省略代码...} finally {if (ss != null) {ss.close();ss = null;}
}public class ServerTaskThread implements Runnable {// 省略代码...while (true) {// 阻塞等待客户端发请求过来String readLine = in.readLine();if (readLine == null) {break;}// 省略代码...}// 省略代码...
}
这段代码的主要逻辑是: 在服务端创建一个 ServerSocket 对象,绑定 9090 端口,然后启动运行,阻塞等待客户端发起连接请求,直到有客户端的连接发送过来后,accept() 方法返回。当有客户端的连接请求后,服务端会启动一个新线程 ServerTaskThread,用新创建的线程去处理当前用户的读写操作。
BIO 网络模型
所以,BIO 的网络模型中,每当客户端发送一个连接请求给服务端,服务端都会启动一个新的线程去处理客户端连接的读写操作,即每个 Socket 都对应一个独立的线程,客户端 Socket 和服务端工作线程的数量是 1 比 1,这会导致服务器的资源不够用,无法实现高并发下的网络开发。所以 BIO 的网络模型只适用于 Socket 连接不多的场景,无法支撑几十甚至上百万的连接场景。
另外,BIO 模型有两处阻塞的地方。
- 服务端阻塞等待客户端发起连接。在第 11 行代码中,通过 serverSocket.accept() 方法服务端等待用户发连接请求过来。
- 连接成功后,工作线程阻塞读取客户端 Socket 发送数据。在第 27 行代码中,通过 in.readLine() 服务端从网络中读客户端发送过来的数据,这个地方也会阻塞。如果客户端已经和服务端建立了一个连接,但客户端迟迟不发送数据,那么服务端的 readLine() 操作会一直阻塞,造成资源浪费。
以上这些就是 BIO 网络模型的问题所在,总结下来就两点:
- Socket 连接数量受限,不适用于高并发场景;
- 有两处阻塞,分别是等待用户发起连接,和等待用户发送数据。
那怎么解决这个问题呢? 答案是 NIO 网络模型,操作上是用一个线程处理多个连接,使得每一个工作线程都可以处理多个客户端的 Socket 请求,这样工作线程的利用率就能得到提升,所需的工作线程数量也随之减少。此时 NIO 的线程模型就变为 1 个工作线程对应多个客户端 Socket 的请求,这就是所谓的 I/O多路复用。
NIO 网络模型
顺着这个思路,我们继续深入思考:既然服务端的工作线程可以服务于多个客户端的连接请求,那么具体由哪个工作线程服务于哪个客户端请求呢?
这时就需要一个调度者去监控所有的客户端连接,比如当图中的客户端 A 的输入已经准备好后,就由这个调度者去通知服务端的工作线程,告诉它们由工作线程 1 去服务于客户端 A 的请求。这种思路就是 NIO 编程模型的基本原理,调度者就是 Selector 选择器。
由此可见,NIO 比 BIO 提高了服务端工作线程的利用率,并增加了一个调度者,来实现 Socket 连接与 Socket 数据读写之间的分离。
在目前主流的 RPC 框架中,广泛使用的也是 I/O 多路复用模型,Linux 系统中的 select、poll、epoll等系统调用都是 I/O 多路复用的机制。
在面试中,对于高级研发工程师的考察,还会有两个技术扩展考核点。
- Reactor 模型(即反应堆模式),以及 Reactor 的 3 种线程模型,分别是单线程 Reactor 线程模型、多线程 Reactor 线程模型,以及主从 Reactor 线程模型。
- Java 中的高性能网络编程框架 Netty。
可以这么说,在高性能网络编程中,大多数都是基于 Reactor 模式,其中最为典型的是 Java 的 Netty 框架,而 Reactor 模式是基于 I/O 多路复用的,所以,对于 Reactor 和 Netty 的考察也是避免不了的。因为相关资料很多,我就不展开了,你可以在课下补充这部分的知识,并在留言区与我交流。
总结
这一讲,我带你了解了面试官考察 RPC 技术的套路,无论是初中级还是高级研发工程师,都需要掌握这一讲的内容。
- 在“实践操作”中,我带你通过“如何设置 RPC 超时时间”的场景,学习了在微服务系统中,系统整体的平均响应时长,会受到所有依赖服务接口的耗时和重传次数影响。
- 在“原理掌握”中,我通过“商品详情页”的案例,引出 RPC 框架的原理与核心功能,如网络通信模型的选型、序列化和反序列化框架的选型等。
最后,我还是要强调一下,程序员一定要具备造轮子的能力,目的是突破技术栈瓶颈,因为技术只有动手实践过,才能有更加全面和深入的思考。学完这一讲后,我建议你阅读一些成熟的 RPC 框架的源代码,比如阿里开源的 Dubbo,或 Google 的 gRPC。
当然在实际工作中,一个产品级别的 RPC 框架的开发,除了要具备网络通信、序列化和反序列化、协议等基础的功能之外,还要具备如连接管理、负载均衡、请求路由、熔断降级、优雅关闭等高级功能的设计,虽然这些内容在面试中不要求你掌握,但是如果你了解是可以作为加分项的,例如连接管理就会涉及连接数的维护与服务心跳检测。
08 MQ:如何回答消息队列的丢失、重复与积压问题
这一讲,我们将围绕 MQ 消息中间件,讨论你经常被问到的高频设计问题。
面试官在面试候选人时,如果发现候选人的简历中写了在项目中使用了 MQ 技术(如 Kafka、RabbitMQ、RocketMQ),基本都会抛出一个问题:在使用 MQ 的时候,怎么确保消息 100% 不丢失?
这个问题在实际工作中很常见,既能考察候选者对于 MQ 中间件技术的掌握程度,又能很好地区分候选人的能力水平。接下来,我们就从这个问题出发,探讨你应该掌握的基础知识和答题思路,以及延伸的面试考点。
案例背景
以京东系统为例,用户在购买商品时,通常会选择用京豆抵扣一部分的金额,在这个过程中,交易服务和京豆服务通过 MQ 消息队列进行通信。在下单时,交易服务发送“扣减账户 X 100 个京豆”的消息给 MQ 消息队列,而京豆服务则在消费端消费这条命令,实现真正的扣减操作。
那在这个过程中你会遇到什么问题呢?
案例分析
要知道,在互联网面试中,引入 MQ 消息中间件最直接的目的是:做系统解耦合流量控制,追其根源还是为了解决互联网系统的高可用和高性能问题。
- 系统解耦:用 MQ 消息队列,可以隔离系统上下游环境变化带来的不稳定因素,比如京豆服务的系统需求无论如何变化,交易服务不用做任何改变,即使当京豆服务出现故障,主交易流程也可以将京豆服务降级,实现交易服务和京豆服务的解耦,做到了系统的高可用。
- 流量控制:遇到秒杀等流量突增的场景,通过 MQ 还可以实现流量的“削峰填谷”的作用,可以根据下游的处理能力自动调节流量。
不过引入 MQ 虽然实现了系统解耦合流量控制,也会带来其他问题。
引入 MQ 消息中间件实现系统解耦,会影响系统之间数据传输的一致性。 我们在 04 讲提到过,在分布式系统中,如果两个节点之间存在数据同步,就会带来数据一致性的问题。同理,在这一讲你要解决的就是:消息生产端和消息消费端的消息数据一致性问题(也就是如何确保消息不丢失)。
而引入 MQ 消息中间件解决流量控制, 会使消费端处理能力不足从而导致消息积压,这也是你要解决的问题。
所以你能发现,问题与问题之间往往是环环相扣的,面试官会借机考察你解决问题思路的连贯性和知识体系的掌握程度。
那面对“在使用 MQ 消息队列时,如何确保消息不丢失”这个问题时,你要怎么回答呢?首先,你要分析其中有几个考点,比如:
- 如何知道有消息丢失?
- 哪些环节可能丢消息?
- 如何确保消息不丢失?
候选人在回答时,要先让面试官知道你的分析思路,然后再提供解决方案: 网络中的数据传输不可靠,想要解决如何不丢消息的问题,首先要知道哪些环节可能丢消息,以及我们如何知道消息是否丢失了,最后才是解决方案(而不是上来就直接说自己的解决方案)。就好比“架构设计”“架构”体现了架构师的思考过程,而“设计”才是最后的解决方案,两者缺一不可。
案例解答
我们首先来看消息丢失的环节,一条消息从生产到消费完成这个过程,可以划分三个阶段,分别为消息生产阶段,消息存储阶段和消息消费阶段。
消息的生产、存储与消费
- 消息生产阶段: 从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 MQ Broker 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,这个阶段是不会出现消息丢失的。
- 消息存储阶段: 这个阶段一般会直接交给 MQ 消息中间件来保证,但是你要了解它的原理,比如 Broker 会做副本,保证一条消息至少同步两个节点再返回 ack(这里涉及数据一致性原理,我在 04 讲中已经讲过,在面试中,你可以灵活延伸)。
- 消息消费阶段: 消费端从 Broker 上拉取消息,只要消费端在收到消息后,不立即发送消费确认给 Broker,而是等到执行完业务逻辑后,再发送消费确认,也能保证消息的不丢失。
方案看似万无一失,每个阶段都能保证消息的不丢失,但在分布式系统中,故障不可避免,作为消费生产端,你并不能保证 MQ 是不是弄丢了你的消息,消费者是否消费了你的消息,所以,本着 Design for Failure 的设计原则,你还是需要一种机制,来 Check 消息是否丢失了。
紧接着,你还可以向面试官阐述怎么进行消息检测? 总体方案解决思路为:在消息生产端,给每个发出的消息都指定一个全局唯一 ID,或者附加一个连续递增的版本号,然后在消费端做对应的版本校验。
具体怎么落地实现呢?你可以利用拦截器机制。 在生产端发送消息之前,通过拦截器将消息版本号注入消息中(版本号可以采用连续递增的 ID 生成,也可以通过分布式全局唯一 ID生成)。然后在消费端收到消息后,再通过拦截器检测版本号的连续性或消费状态,这样实现的好处是消息检测的代码不会侵入到业务代码中,可以通过单独的任务来定位丢失的消息,做进一步的排查。
这里需要你注意:如果同时存在多个消息生产端和消息消费端,通过版本号递增的方式就很难实现了,因为不能保证版本号的唯一性,此时只能通过全局唯一 ID 的方案来进行消息检测,具体的实现原理和版本号递增的方式一致。
现在,你已经知道了哪些环节(消息存储阶段、消息消费阶段)可能会出问题,并有了如何检测消息丢失的方案,然后就要给出解决防止消息丢失的设计方案。解决方案你可以参考 05 讲中的 “基于 MQ 的可靠消息投递”的机制,我这里就不再赘述。
回答完“如何确保消息不会丢失?”之后,面试官通常会追问“怎么解决消息被重复消费的问题?” 比如:在消息消费的过程中,如果出现失败的情况,通过补偿的机制发送方会执行重试,重试的过程就有可能产生重复的消息,那么如何解决这个问题?
这个问题其实可以换一种说法,就是如何解决消费端幂等性问题(幂等性,就是一条命令,任意多次执行所产生的影响均与一次执行的影响相同),只要消费端具备了幂等性,那么重复消费消息的问题也就解决了。
我们还是来看扣减京豆的例子,将账户 X 的金豆个数扣减 100 个,在这个例子中,我们可以通过改造业务逻辑,让它具备幂等性。
扣减京豆
最简单的实现方案,就是在数据库中建一张消息日志表, 这个表有两个字段:消息 ID 和消息执行状态。这样,我们消费消息的逻辑可以变为:在消息日志表中增加一条消息记录,然后再根据消息记录,异步操作更新用户京豆余额。
因为我们每次都会在插入之前检查是否消息已存在,所以就不会出现一条消息被执行多次的情况,这样就实现了一个幂等的操作。当然,基于这个思路,不仅可以使用关系型数据库,也可以通过 Redis 来代替数据库实现唯一约束的方案。
在这里我多说一句,想要解决“消息丢失”和“消息重复消费”的问题,有一个前提条件就是要实现一个全局唯一 ID 生成的技术方案。这也是面试官喜欢考察的问题,你也要掌握。
在分布式系统中,全局唯一 ID 生成的实现方法有数据库自增主键、UUID、Redis,Twitter-Snowflake 算法,我总结了几种方案的特点,你可以参考下。
我提醒你注意,无论哪种方法,如果你想同时满足简单、高可用和高性能,就要有取舍,所以你要站在实际的业务中,说明你的选型所考虑的平衡点是什么。我个人在业务中比较倾向于选择 Snowflake 算法,在项目中也进行了一定的改造,主要是让算法中的 ID 生成规则更加符合业务特点,以及优化诸如时钟回拨等问题。
当然,除了“怎么解决消息被重复消费的问题?”之外,面试官还会问到你“消息积压”。 原因在于消息积压反映的是性能问题,解决消息积压问题,可以说明候选者有能力处理高并发场景下的消费能力问题。
你在解答这个问题时,依旧要传递给面试官一个这样的思考过程: 如果出现积压,那一定是性能问题,想要解决消息从生产到消费上的性能问题,就首先要知道哪些环节可能出现消息积压,然后在考虑如何解决。
因为消息发送之后才会出现积压的问题,所以和消息生产端没有关系,又因为绝大部分的消息队列单节点都能达到每秒钟几万的处理能力,相对于业务逻辑来说,性能不会出现在中间件的消息存储上面。毫无疑问,出问题的肯定是消息消费阶段,那么从消费端入手,如何回答呢?
如果是线上突发问题,要临时扩容,增加消费端的数量,与此同时,降级一些非核心的业务。通过扩容和降级承担流量,这是为了表明你对应急问题的处理能力。
其次,才是排查解决异常问题,如通过监控,日志等手段分析是否消费端的业务逻辑代码出现了问题,优化消费端的业务处理逻辑。
最后,如果是消费端的处理能力不足,可以通过水平扩容来提供消费端的并发处理能力,但这里有一个考点需要特别注意, 那就是在扩容消费者的实例数的同时,必须同步扩容主题 Topic 的分区数量,确保消费者的实例数和分区数相等。如果消费者的实例数超过了分区数,由于分区是单线程消费,所以这样的扩容就没有效果。
比如在 Kafka 中,一个 Topic 可以配置多个 Partition(分区),数据会被写入到多个分区中,但在消费的时候,Kafka 约定一个分区只能被一个消费者消费,Topic 的分区数量决定了消费的能力,所以,可以通过增加分区来提高消费者的处理能力。
总结
至此,我们讲解了 MQ 消息队列的热门问题的解决方案,无论是初中级还是高级研发工程师,本讲都是你需要掌握的,你都可以从这几点出发,与面试官进行友好的交流。我来总结一下今天的重点内容。
- 如何确保消息不会丢失? 你要知道一条消息从发送到消费的每个阶段,是否存在丢消息,以及如何监控消息是否丢失,最后才是如何解决问题,方案可以基于“ MQ 的可靠消息投递 ”的方式。
- 如何保证消息不被重复消费? 在进行消息补偿的时候,一定会存在重复消息的情况,那么如何实现消费端的幂等性就这道题的考点。
- 如何处理消息积压问题? 这道题的考点就是如何通过 MQ 实现真正的高性能,回答的思路是,本着解决线上异常为最高优先级,然后通过监控和日志进行排查并优化业务逻辑,最后是扩容消费端和分片的数量。
在回答问题的时候,你需要特别注意的是,让面试官了解到你的思维过程,这种解决问题的能力是面试官更为看中的,比你直接回答一道面试题更有价值。
另外,如果你应聘的部门是基础架构部,那么除了要掌握本讲中的常见问题的主线知识以外,还要掌握消息中间件的其他知识体系,如:
- 如何选型消息中间件?
- 消息中间件中的队列模型与发布订阅模型的区别?
- 为什么消息队列能实现高吞吐?
- 序列化、传输协议,以及内存管理等问题
- ……
08 案例串联 如何让系统抗住双十一的预约抢购活动?
到目前为止,我们讨论了很多的面试思路,比如 02 讲中关于架构设计的“四步回答法”,不过大部分内容都是比较独立的知识点(比如分布式事务、分布式锁……)为了让你更深入掌握前几讲内容,把相对独立的知识串联起来,我们今天就来回顾、梳理近期学习的内容,通过“电商预约抢购”的场景,用前几讲内容,做一道完整的架构设计题。
案例背景
在大促活动期间,“预约抢购”已经是各大电商平台的主要促销手段,京东自然也会和一些大的供应商合作,推出一些低价的爆款产品,比如 2019 年的 “1499 元抢购飞天茅台”活动,就让很多人每天准时准点拿着手机拼人品。
那这类电商领域的大促抢购场景涉及专栏的哪些内容呢?它们是怎么通过架构设计的方式组合在一起,实现一个完整的需求流程呢?这就是今天要讨论的话题。
我们先把需求梳理一下,总的来说,实现一个抢购系统大概可以分为四个阶段。
- 商品预约:用户进入商品详情页面,获取购买资格,并等待商品抢购倒计时。
- 等待抢购:等待商品抢购倒计时,直到商品开放抢购。
- 商品抢购:商品抢购倒计时结束,用户提交抢购订单,排队等待抢购结果,抢购成功后,扣减系统库存,生成抢购订单。
- 订单支付:等待用户支付成功后,系统更新订单状态,通知用户购买成功。
接下来,我们就针对各阶段容易出现的问题,来分析其中的技术考点和解决方案。
商品预约阶段
这几年,很多电商平台为了方便流量运营,改造了传统秒杀场景,通过先预约再抢购的方式预热商品,并根据预约量调整运营策略。而且在预约抢购的活动中,为了增加商品售卖量,会允许抢购前,预约资格超过实际的库存数量。
那么问题来了:如何在高并发量的情况下,让每个用户都能得到抢购资格呢?这是预约抢购场景第一个技术考察点。 那你可以基于“06 | 分布式系统中,如何回答锁的实现原理?”来控制抢购资格的发放。
我们基于 Redis 实现分布式锁(这是最常用的方式),在加锁的过程中,实际上是给 Key 键设置一个值,为避免死锁,还要给 Key 键设置一个过期时间。
SET lock_key unique_value NX PX 10000
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除,但不能乱删,要保证执行操作的客户端就是加锁的客户端。而这个时候, unique_value 的作用就体现出来,你可以通过 Lua 脚本判断 unique_value 是否为加锁客户端。
选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。但你要注意,此方案是基于单节点的 Redis 实例实现的,如果此时 Redis 实例发生故障宕机,那么锁变量就没有了,客户端也就无法进行锁操作,就会影响到业务的正常执行。 所以,基于 Redis 实现分布式锁时,你还要掌握如何保证锁的可靠性,也就是怎么基于多个 Redis 节点实现分布式锁(这部分也可以参考 06 讲中的内容)。
等待抢购阶段
用户预约成功之后,在商品详情页面中,会存在一个抢购倒计时,这个倒计时的初始时间是从服务端获取的,用户点击购买按钮时,系统还会去服务端验证是否已经到了抢购时间。
在等待抢购阶段,流量突增,因为在抢购商品之前(尤其是临近开始抢购之前的一分钟内),大部分用户会频繁刷新商品详情页,商品详情页面的读请求量剧增, 如果商品详情页面没有做好流量控制,就容易成为整个预约抢购系统中的性能瓶颈点。
那么问题来了:如何解决等待抢购时间内的流量突增问题呢?有两个解决思路。
- 页面静态化:提前对抢购商品的详情页面做静态化,生成一个静态页面,再把页面放到距离用户最近的 CDN 节点中,这样一来,当浏览器访问页面时,就会自动缓存该页面的静态资源文件(对于静态化技术,很多页面端的模板引擎都支持这样的功能,我就不展开讲了)。
- 服务端限流:对商品详情页中的动态请求接口设置最大并发访问数量(具体的数量根据上线前的性能压测为准),防止超出预期的请求集中访问系统,造成系统压力过载。操作上,你可以在商品详情页的后端系统入口层(如网关系统)中进行接口限流,如果使用 Nginx 来做反向代理,可以直接基于 Nginx 配置限流算法,比如 Nginx 的 ngx_http_limit_req_module(限制单位时间内所有 IP 的请求数量)和 ngx_stream_limit_conn_module(限制单位时间内单个 IP 的请求数量)两个模块就提供了限流控制的功能,所以你还要提前掌握限流策略的原理,如令牌桶算法的原理。
商品抢购阶段
在商品抢购阶段,用户会点击提交订单,这时,抢购系统会先校验库存,当库存足够时,系统会先扣减库存,然后再生成订单。在这个过程中,短时间之内提交订单的写流量非常高,所以为了做流量削峰,会将提单请求暂存在消息队列中,并提示用户“抢购排队中……”然后再由后端服务异步处理用户的请求。
而你可以基于数据库和缓存两种方式,来实现校验库存和扣减库存的操作。
但因为抢购场景的瞬时流量极高,一般不会直接基于数据库来实现(因为每次操作数据库,即使通过消息队列做了流量削峰,对数据库来说压力也很大,会产生性能瓶颈)。如果非要基于数据库的话,你要通过分布式锁来优化扣减库存的并发操作,但此阶段的分布式锁对可靠性的要求会极高(因为在大促抢购阶段,小的可用性故障,都可能造成大的线上事故),所以基于单节点 Redis 实现的分布式锁不合适,你要选择多节点 Redis 实现分布式锁,或者选型 ZooKeeper。
为了避免上述问题,我们一般基于缓存来存储库存,实现扣减库存的操作。这样在提交订单时,库存的查询和锁定就不会给数据库带来性能瓶颈。不过你仍要注意,基于缓存(如 Redis)的库存扣减操作,仍要考虑缓存系统的单点问题,就算是多节点存储库存,也要引入锁的策略,保证 Redis 实现库存的一致性。
实现了校验库存和扣减库存之后,最后一步是生成抢购订单。由于数据库表会承载订单数据,一旦出现瞬时流量,磁盘 I/O、数据库请求连接数等资源都会出现性能瓶颈,你可以考虑对订单表分库分表,通过对用户 ID 字段进行 Hash 取模,实现分库分表,提高系统的并发能力。
从“商品抢购阶段的架构设计”中我们可以总结出三个技术考点:流量削峰、扣减库存、分库分表。
- “流量削峰”的面试考点
流量削峰是由于正式抢购场景下,短时间内的提单请求非常高,所以引入消息队列做异步化,然后在抢购系统的后端服务中,启动若干个队列处理消息队列中的提单请求,再执行校验库存、下单等逻辑。
那么如何快速处理消息队列中的提单请求,避免出现大量的消息积压,就是本阶段的考点之一了,方案可以参考“08 | MQ:如何回答消息队列的丢失、重复与积压问题?”
- “扣减库存”的面试考点
我刚刚提到,当基于 Redis 实现库存的扣减时,要考虑怎么解决 Redis 的单点问题。而如果基于 Redis 集群来实现扣减库存,还要解决 Redis 在哨兵模式部署的情况下,因为主从切换带来的数据不一致的问题。这就涉及“06 | 分布式系统中,如何回答锁的实现原理?”中的内容。
- “分库分表”的面试考点
生成订单后如何实现分库分表?你可以参考“04 | 亿级商品存储下,如何深度回答分布式系统的原理性问题?”中的解决方案。
当然还有一个容易忽略的问题:带宽的影响。由于抢购入口的请求量会非常大,可能会占用大量带宽,为了不影响提交订单的请求,有时会从网络工程的角度解决,通过单独的子域名绑定独立的网络服务器,这里就会涉及 DNS 的设计与优化手段。
订单支付阶段
在用户支付订单完成之后,一般会由支付平台回调系统接口,更新订单状态。在支付回调成功之后,抢购系统还会通过异步通知的方式,实现订单更新之外的非核心业务处理,比如积分累计、短信通知等,此阶段可以基于 MQ 实现业务的异步操作。
订单支付后操作
不过针对服务的异常(如宕机),会存在请求数据丢失的可能,比如当支付回调系统后,修改订单状态成功了,但是在异步通知积分系统,更新用户累计积分时,订单系统挂掉了,此时 MQ 还没有收到这条消息,那么这条消息数据就无法还原了。
订单支付后操作(异常)
所以你还要考虑“05 | 海量并发场景下,如何回答分布式事务一致性问题?”中,可靠消息投递机制:先做消息的本地存储,再通过异步重试机制,来实现消息的补偿。比如当支付平台回调订单系统,然后在更新状态的同时,插入一个消息,之后再返回第三方支付操作成功的结果。最后,通过数据库中的这条消息,再异步推送其他系统,完成后续的工作。
订单支付后操作(新方案)
总结
今天,我们用前几讲的内容实现了一个完整的预约抢购的系统设计,为了加深你的理解,我总结了每个阶段的注意点。
- 商品预约阶段:要掌握如何在高并发的场景下通过锁的方式,让每一个用户都获取到抢购资格,结合业务场景对于并发控制的需求诉求和成本的考虑,在商品预约阶段,你可以基于 Redis 来实现分布式锁。
- 等待抢购阶段:此阶段对页面的查询请求会很高,尤其是临近抢购倒计时的流量突增,解决方案是做页面静态化和服务端限流。
- 商品抢购阶段:商品抢购是整个流程中涉及技术点最多的阶段,瞬时流量会带来极大的压力,所以通过 MQ 做了同步转异步,实现对流量的削峰,从而让请求排队等待,然后有序且有限地进入到后端服务,而你必须掌握消息队列的丢失、重复和积压问题的解决方案;另外在扣减库存的时候,为了解决扣减存储不超售的问题,同样还需要引入锁的机制。
- 订单支付阶段:在用户支付完成后,系统通常还需要处理一些非核心操作,你可以通过 MQ 通知的方式来实现系统间的解耦和异步通信,但依旧要保证消息的可靠性(当然也可以通过 RPC 同步调用的方式来实现),所以你也要掌握 RPC 和 MQ 的相关知识点。
总的来说,互联网中大数据里的存储设计(如商品与订单数据的存储设计),你可以参考 04 讲;关于秒杀或抢购场景下的库存扣减设计,你可以参考 06 讲;分布式系统之间的事务一致性的架构设计,你可以参考 05 讲;关于架构设计中的服务强依赖的设计,一般会通过 RPC 远程同步调用的方式实现,你可以参考07讲;系统解耦,流量削峰的设计问题,你可以参考 08讲。