系统设计:服务治理
系统设计:服务治理
一、微服务架构
如果对一个系统做一个简单的分解,那么,我们可以将它分为存储和计算两部分。前面讲了存储,现在我们来讲计算,计算也可以称为服务。对于服务来说,现在最流行的架构就是微服务架构,也就是我们这一单元要讲解的内容。针对微服务架构,我们重点讲解微服务架构中的服务治理,这也是最有技术挑战的一部分,这其中就包括:RPC框架、服务注册与发现、配置中心、API网关、鉴权、限流、降级、熔断、链路追踪等。
这节课我们先对微服务架构做一个整体的介绍!
1. 架构演进
跟微服务架构相对应的是单体架构(也可以叫做单体服务或者单体应用)。
所谓单体架构指的是,将所有的业务逻辑都塞到一个系统中,一起开发、部署、运行的架构模式。其实,这种单体架构在项目开发初期或者业务复杂度比较低的项目中,非常常用。不管是订单功能、用户功能、商品功能、库存功能,还是其他业务功能,统统在一个项目中开发,并且一起打包(比如,Java中的war包或者jar包)部署、运行。
所谓微服务架构指的是,将整个系统按照业务功能,拆分成多个独立的服务,每个服务都可以独立开发、打包、部署和运行。比如,将订单相关的所有业务逻辑拆分出来,作为一个独立的微服务,其他业务领域,比如用户、商品、库存等等也是如此。
在讲解《设计模式之美》时,我们提到,设计模式是应对复杂代码的,如果代码简单,怎么弄都可以,一个简单的业务逻辑,放到一个类中或者一个函数中来实现,肯定比使用复杂的策略模式、状态模式等要来的简单容易。那么,架构设计也是如此。如果业务并不复杂,代码量不多,那么,单体架构在开发和运维方面,优势更大。
在讲解《设计模式之美》时,我们还提到,重构是避免过度设计的有效方法。其实,架构设计也是如此。特别是对于项目初期,系统性能压力不大,为了快速开发,验证产品,使用单体架构也是非常常见的做法。我们只需要将代码的模块化做好,比如使用Gradle Module将不同的业务逻辑代码组织在不同的Module中,后续随着业务功能的增多,代码的增多,性能压力的增大,再做单体架构到微服务架构的演进,也是没问题的。
说了这么多,到底什么时候该用微服务架构呢?实际上,当单体架构遇到下面这些问题的时候,我们就可以考虑做微服务的拆分了。
- 代码库变得极其臃肿庞大,维护成本高,编译一次要十几分钟甚至更长;
- 一个项目的研发团队过大,沟通成本高,代码提交冲突多,研发效率低;
- 一个小小的线上 Bug 可能就需要整个应用重新打包、测试、部署,风险高、耗时长;
- 即使只有某个模块(比如商品)流量暴增,我们也得对整个系统做扩展,浪费资源;
- 技术栈的更新举步维艰,没人敢大规模重构代码,生怕牵一发动全身。
2. 拆分原则
当你的项目出现了上述问题的时候,就要准备将单体架构往微服务架构迁移了,那么,具体如何将单体架构拆分成微服务架构呢?微服务的拆分,核心依据是业务领域本身,目标是识别出那些功能高度内聚、彼此耦合度低的业务边界,并遵循单一职责原则进行划分。
就像我们在《设计模式之美》中讨论单一职责原则时提到的:“职责如何才算单一”有时确实难以界定,微服务的拆分同样面临这个挑战。比如“用户服务”听起来职责够单一了,但随着业务发展,我们可能发现需要将其进一步拆解为“用户登录认证服务”和“用户基本信息服务”。
因此,除了业务领域这个核心依据,微服务拆分还需要综合权衡几个关键因素:
一是服务粒度的把控。 判断服务是否“足够单一”,不仅要看业务边界,还要结合其实际复杂度和代码规模。例如,当用户登录逻辑膨胀(支持微信、手机、邮箱等多种方式,并集成单点登录、统一认证、黑名单管理等丰富功能),将其独立出来成为专门的服务就显得必要且合理。
二是服务数量的平衡。 虽然拆分能降低单个服务的复杂度,但过度拆分(导致微服务数量过多)会显著提升整个系统的复杂度。这就像代码设计,类太大难以维护,但拆分成无数个零碎的小类同样会让代码变得难以理解和维护。
三是团队结构的匹配。 服务的拆分往往对应着团队的划分。在一个大型团队中,如果所有人挤在同一个巨型项目里协作,效率必然低下。更优的做法是根据团队规模,将其拆分为多个3-5人的小团队,每个小团队专注负责少数几个微服务的全生命周期(开发、测试、运维),这样能最大化协作效率。
四是渐进式拆分的策略。 实践中,我们很少会一次性将整个单体彻底拆散。更明智的做法是在遇到性能瓶颈或管理困难时,优先将那些核心度高、性能压力大或变化频繁的业务模块拆出来独立成微服务,然后,随着项目演进,再逐步拆分其他部分。这种渐进的方式能有效避免过度拆分带来的服务治理负担。
需要特别注意的是,微服务之间的调用必须遵循一定规则,首先不允许存在循环依赖。
为了进一步理清调用关系,我们可以引入分层结构。例如,基础服务(如用户服务)位于底层,被众多上层服务(如订单服务、支付服务)所依赖;业务核心服务(如订单服务、支付服务)位于中层;而聚合服务(如交易服务)则位于顶层。上层服务可以调用下层服务,但禁止反向调用;同层服务之间,优先通过消息中间件进行异步通信,尽量减少直接的同步调用。
这种基于业务拆分为基础、辅以合理分层和调用约束的设计,能有效避免服务间形成错综复杂的网状依赖,使整个系统的调用链路保持简洁清晰,极大地方便后期的维护和问题排查。
3. 收益与挑战
从单体架构迁移到微服务架构之后,我们可以在多个方面获益:
- 技术异构性: 我们不再被单一技术栈束缚,可以根据每个服务的特性(如性能要求、开发效率)选择最合适的语言和框架,新服务可以尝试新技术,老服务也能按计划重构。
- 弹性与容错:单个服务的故障通常不会导致整个系统瘫痪。通过熔断、降级、限流等机制(后续文章会详细讲解),我们能有效隔离故障,确保核心业务链路的高可用性。
- 独立扩展性:哪个服务面临压力(比如商品搜索扛不住高并发),就单独为它增加计算资源(如实例数),而压力不大的服务(如用户登录)则维持现状,避免了单体架构下“一锅端”扩容的资源浪费,成本控制更优。
- 快速部署交付:修改特定服务的功能(例如用户服务的头像上传逻辑),只需对该服务进行测试和部署,上线过程更快,影响范围更小,风险更低,从而加速了产品迭代。
- 清晰的业务边界:微服务之间往往具有清晰的业务边界,团队围绕微服务做开发,职责清晰,减少了代码冲突和频繁的跨团队沟通。
- 隔离的数据存储:微服务架构倡导每个服务拥有自己专属的数据库,用户服务用自己的用户数据库,订单服务用自己的订单数据库,这解决了单体架构下数据库的性能瓶颈问题。
不过,天下没有免费的午餐,微服务带来了诸多收益的同时,也带来了很多技术问题,我们需要花费大量的时间精力来解决,比如:
- 服务之间的调用走网络,也带来了很多分布式系统的共性问题,比如分布式事务。
- 服务的调用错综复杂,调用链路变长,定位线上问题跨系统跨团队,效率低。
- 拆分成很多小的微服务之后,服务运维成本变高,比如部署、监控、日志收集等。
- 系统架构复杂熵变高,调用关系复杂,若缺乏服务治理,一个服务的故障可能引起雪崩效应。
4. 服务治理
微服务架构通过分解复杂性,为我们构建大型、复杂的系统提供了一条可行的路径,但它绝非简单的“把单体拆小”,而是一整套应对分布式系统挑战的架构理念、设计原则和工程实践的集合。它要求我们在获得敏捷性、弹性和技术自由度的同时,也必须正视并解决由此带来的系统复杂性等新挑战。
因此,要驾驭好微服务架构,就离不开一系列关键基础设施和组件的支撑,来完成服务的治理工作,这也是我们后续课程将要逐一深入探讨的核心技术:
- RPC框架:这是服务间高效通信的基石(如 gRPC, Dubbo, Thrift),它封装了网络通信、序列化等底层细节,让远程调用像本地调用一样简单、高效。
- 服务注册与发现:服务实例动态上线、下线,调用方如何知道去哪里找它们?服务注册中心就是微服务世界的“通信录”,动态实时更新和推送在线服务实例地址。
- 配置中心:每一个微服务实例都依赖一定的配置,如果将配置存储在实例本地,那么,修改配置将会是一个大工程。配置中心可以统一、动态地管理成百上千个服务的配置项,并可以避免修改配置时重启服务!
- API网关: 它是系统对外的统一入口(或叫“门面”),负责路由转发、API聚合、协议转换、认证鉴权、限流熔断、日志监控等。
- 服务鉴权: 在分布式环境下,如何确保服务间调用的安全可信?服务的鉴权就是必不可少的功能!
- 限流、降级与熔断: 面对流量洪峰、服务故障,如何保护系统不被打垮?限流控制流量入口;熔断快速失败,防止雪崩;降级提供有损但可用的服务。
- 链路追踪: 一个用户请求可能穿越十几个服务,如何快速定位性能瓶颈或故障点?分布式链路追踪系统(如Zipkin, SkyWalking)通过唯一TraceID串联整条调用链,让Debug不再是大海捞针。
其实,从上面的讲解,你有没有发现,微服务架构在服务治理方面的复杂度,要远超单体架构。对于微服务架构来说,最难的并不是服务的拆分,毕竟即便拆分的不合理,在长期的开发过程中,也会发现并纠正(拆分过细的就合并,拆分粒度不够的就继续拆分,拆分边界不合理的就做调整等),但是,如果基础设施不够完善,服务治理做不好,微服务架构就会埋下很大的稳定性隐患,让项目的开发运维陷入不可控的境地!
5. 最后总结
不要为了微服务而微服务。 对于初创项目或业务逻辑相对简单稳定的系统,单体架构往往是更简单高效的选择,在这个阶段,我们只需要把代码的模块化做好,就能解决你的所有问题。微服务架构的价值,通常是在系统复杂度(业务复杂度、团队规模、性能要求)增长到单体架构难以承受时,才真正显现出来。它更像是一个演进的结果。
二、RPC框架
在微服务架构中,一个完整的业务请求,比如用户下单,往往需要跨越多个服务协同完成,用户服务验证身份、商品服务扣减库存、订单服务创建记录、支付服务处理交易。这就引出了一个核心问题:这些散布在网络各处的服务,如何高效、可靠地通信?RPC(Remote Procedure Call,远程过程调用)框架就该登场了。
1. 基本原理
RPC框架是微服务架构下,服务间进行通信的基础设施。简单来说,RPC框架的目标是:让开发者调用一个远程服务的方法,感觉就像调用本地方法一样简单直观。 我们写代码调用远程服务时,直接使用类似 orderService.createOrder(userId, items)这样编写即可,至于orderService的这个方法是在同一个进程里,还是在千里之外的另一台服务器上,RPC框架帮我们屏蔽掉了这些底层细节。
想象一下本地方法调用:调用方和被调用方在同一个进程、同一块内存空间里。参数传递直接压栈,方法执行结果瞬间返回,整个过程高效、直接、可靠。但当服务被拆分成微服务,部署到不同的主机甚至不同的数据中心时,情况就变得复杂了: 服务间通信必须通过网络,而网络具有不可靠性,超时、抖动、丢包、中断都是常态。一次调用可能成功、可能超时、可能根本到达不了对方。不同进程(甚至不同机器)拥有独立的内存空间。调用方内存中的参数对象(比如 Order 对象),无法直接传递给被调用方。
RPC框架的核心价值,就在于将这些令人头疼的分布式通信的复杂性给封装起来,为开发者提供一个简单易用的编程模式,即试图在分布式环境中,模拟出本地调用的“简单”体验。那么,接下来,让我们揭开RPC框架的面纱,看看一次看似简单的远程调用背后都发生了什么。
(1)动态代理
作为开发者,你只需要定义一个接口(比如 UserService)。RPC 框架在客户端会使用动态代理技术,在运行时动态生成这个接口的一个实现类(即代理类)。当调用 userService.getUser(id) 时,实际上调用的是这个代理类的方法,它负责数据的序列化和反序列化,以及跟服务提供方之间的通信。
其实,除了上面提到的动态代理、网络通信、序列化,客户端还需要处理一些重要事情:
- 负载均衡: 如果同一个服务有多个实例,客户端需要决定调用哪一个。常见策略有随机、轮询、加权轮询、一致性哈希等。RPC 框架通常内置负载均衡功能。
- 超时控制: 设置合理的超时时间,防止因网络延迟或服务端卡顿导致客户端线程被无限阻塞,最终耗尽资源(线程池)。调用超时后框架应快速失败(Fail Fast)或触发重试。
- 重试机制: 对于可重试的故障(如网络瞬时抖动、服务端短暂不可用),框架可自动进行有限次数的重试。但重试必须谨慎,避免大量失败请求同时重试压垮服务端。
(2)序列化协议
当客户端通过发起调用时,框架需要将方法的标识符(比如方法名)、以及传递给该方法的参数对象,从内存中的结构化数据(Java对象、Go Struct等)转换成适合在网络中传输的字节流(二进制或文本格式如JSON)。这个过程就是序列化。字节流通过网络传输到服务提供方后,RPC框架需要将这些字节流转换回服务端内存中可理解的结构化数据(Java对象、Go Struct等),还原出方法标识符和参数值。这个过程就是反序列化。
对于RPC框架来说,序列化协议的选择至关重要,它直接影响编解码速度、数据大小、传输效率,以及跨语言能力(Java调用Go服务),间接影响到RPC框架的性能。常见的序列化协议有:
- 语言原生: 如Java的 Serializable ,性能差、跨语言差,不推荐。
- 文本类型: 如JSON, XML等,可读性好,但性能较低,体积较大。
- 二进制类型:常用的有Protobuf (Google公司开发, 高性能、高压缩、强类型、需IDL定义、跨语言好), Thrift (Facebook公司开发, 类似Protobuf), Hessian 等。
(3)通信协议
序列化后的字节流需要通过网络从客户端传输到服务提供方。选择哪种底层传输协议来传输数据,会直接影响RPC调用的性能和可靠性。常用的有TCP、UDP和HTTP协议,它们各有特点:
- TCP协议 是最主流的选择,因为它提供了可靠的、面向连接的传输保障。它能确保数据包按顺序到达、不丢失(通过重传机制)、不重复。不过,TCP的连接建立(三次握手)和断开(四次挥手)有一定开销,在高频调用场景下,RPC框架通常会使用连接池来复用TCP连接,避免频繁创建和关闭连接的性能损耗。另外,RPC框架需要在TCP协议之上定义自己的应用层协议来区分不同的请求/响应消息(比如Dubbo协议)。
- UDP协议则是无连接、不可靠的传输协议。它的优势在于极低的延迟和开销(没有连接建立过程,并且头部开销小),特别适合对实时性要求极高且能容忍少量丢包的应用(如音视频流、在线游戏)。但在RPC场景下,直接使用原生UDP风险很大,数据可能丢失、乱序、重复。因此,如果要在RPC中使用UDP,框架必须在应用层自行实现丢失重传、数据包排序、流量控制等复杂的可靠性机制,这大大增加了实现的难度和复杂度。纯粹的UDP在通用RPC框架中较少直接使用。
- HTTP协议是七层网络协议,它定义了自己的数据传输格式。HTTP协议一般是基于文本传输,而非二进制。使用HTTP作为RPC的传输层有其优势,即具有高度兼容性,但其缺点也比较明显,基于文本格式,传输同样大小的数据,消息大小要比基于TCP协议的消息要大,传输效率低、耗时更长。不过,后面我们讲到gRPC的时候,你会发现,gRPC是基于HTTP协议实现的,为什么gRPC会选择使用Http协议呢?我们留在后面讲解。
(4)网络模型
客户端与服务提供方通信时,会建立网络连接。但如果每次远程调用都创建新连接,三次握手的开销会让高频调用变得难以承受。因此,RPC框架的客户端普遍采用连接池技术来管理和复用TCP连接。连接池预先建立并维护一定数量的活跃连接,当需要发起调用时,直接从池中获取可用连接;调用结束后,连接归还池中等待复用。这大大减少了连接建立/断开的开销,提升了吞吐量。
而对于服务提供方,高效处理海量并发请求是其重要挑战。 想象一下,成百上千的客户端同时发来请求,服务端如何处理读写和连接?这就涉及到 I/O模型的选择。常见I/O模型有以下几种(其实,在《Java编程之美》中我们都有详细讲到,这里稍微总结介绍一下):
- BIO (Blocking I/O,阻塞式): 这是最朴素的模型。每个客户端连接到来,服务端就分配一个专用线程处理业务逻辑。这种方式简单,但当并发连接数暴涨时,线程数量会急剧膨胀。每个线程都需要内存(栈空间)和CPU上下文切换开销,最终导致资源耗尽、性能急剧下降。因此,它只适合连接数非常少的简单场景。
- NIO (Non-blocking I/O,非阻塞式) / 多路复用: 这是现代高性能RPC框架的主流选择,核心思想是一个或少量线程就能高效管理大量连接。它利用操作系统提供的epoll(Linux)或kqueue(BSD)等机制,非阻塞地轮询多个连接。当某个连接有数据可读(新请求到来)时,I/O线程才去处理它。 I/O线程快速读取请求数据,将解码后的业务请求分发到专门的业务线程池去执行实际逻辑。这样,I/O线程只负责高速的I/O操作,避免了被慢业务阻塞;业务线程池则专注于执行代码逻辑。
- AIO (Asynchronous I/O,异步I/O): 它更进一步,应用程序发起I/O操作后立刻返回,并注册回调,操作系统内核会在操作真正完成时再通过回调来通知应用程序。理论上这是最高效的模型,但在实际应用中(尤其是在Linux平台),其成熟度、性能优势相对于成熟的NIO方案并不显著,且编程模型更复杂,因此在主流RPC框架中应用相对较少,一般都是用NIO。
总结来说,高性能RPC服务的基石在于:客户端善用连接池复用连接,服务端采用基于非阻塞I/O的多路复用模型,并清晰分离I/O线程与业务线程的职责,以实现高性能、高并发的通信模型。
(5)业务线程
前面提到,NIO模型下,I/O线程需要保持高速运转,不能被耗时的业务逻辑阻塞。因此,必须将解码后的业务请求任务(通常是一个 Runnable 或 Callable)提交到一个独立的、大小可控的业务线程池中排队执行。这保证了I/O线程的高响应性,同时业务逻辑也能得到并发执行。
线程池的大小(核心线程数、最大线程数)、任务队列类型(有界/无界)和大小、拒绝策略(当队列满且线程达上限时如何处理新任务)都需要根据业务特性谨慎配置。
(6)服务发现
服务提供方可能有多个实例,且实例会动态上下线。客户端如何找到可用的目标实例?早期采用硬编码的方式,在客户端直接配置服务方的IP和端口。这种方式缺乏灵活性,难以应对动态变化,基本已被淘汰。现在,服务注册和发现已经成为RPC框架的标配。服务注册与发现我们会在下一节中详细讲解,这里只是简单介绍一下。
服务提供方在部署时,需要启动一个服务注册中心(如Nacos, Consul, Zookeeper, Eureka)。服务提供方启动时向注册中心注册自己的地址信息(服务名、IP、端口等信息)。客户端发起请求前,会向注册中心查询目标服务名对应的可用实例列表,并从中选择一个实例进行调用。
(7)执行调用
服务提供方接收到网络请求后,反序列化字节流,还原出方法标识符和参数。通过反射调用对应类上方法(比如 OrderServiceImpl.createOrder(...)),获取执行结果(或异常),将结果(或异常信息)序列化成字节流,通过网络传回客户端。客户端接收到响应字节流后,反序列化还原出结果对象或异常,最终将结果返回给本地调用者代码,或者抛出异常。至此,一次完整的RPC调用完成。框架在背后默默处理了网络通信、数据转换、服务定位等复杂工作,开发者只需关注业务接口的定义和实现。
2. 常用框架
市面上成熟的 RPC 框架有不少,且各有侧重。做技术选型时,要结合具体需求:是用单语言环境还是多语言环境?追求极致性能还是开发便捷?需要深度集成 Spring Cloud 生态吗?了解RPC框架的核心特点,能帮你更合理的做技术选型。下面我们简单聊聊几个广泛使用的RPC框架。
(1)Dubbo
Java生态的经典高性能RPC框架。优势在于功能丰富全面(服务治理能力强)、性能优异、社区活跃、中文文档和案例丰富,是许多大型互联网公司Java技术栈的首选。
Dubbo 是阿里巴巴开源的高性能 Java RPC 框架,在大型互联网公司里应用非常广泛。它最大的特点之一就是高度灵活的可扩展性和丰富的内置功能。
- 多协议支持: Dubbo 没有把自己绑死在某一种通信协议上。它原生支持 Dubbo 协议(基于 TCP 的高效二进制私有协议)、HTTP 协议、gRPC 协议,甚至可以通过扩展支持更多。你可以根据场景灵活选择:追求极致性能用 Dubbo 协议,需要对接异构系统就用 HTTP 或 gRPC。
- 多序列化方案: Dubbo 也不强制序列化方式。它支持 Hessian 2 (默认且跨语言能力较好)、JSON、Java 原生序列化,还能方便地集成高性能序列化库如 Kryo 。这种灵活性让你可以根据性能、兼容性需求自由搭配。
- 开箱即用的服务治理: Dubbo 的核心优势在于它把微服务治理的很多难题都内置解决了。服务发现与注册(对接多种注册中心如 Nacos/Zookeeper)、负载均衡、集群容错(Failover/Failfast 等策略)、路由规则、动态配置、限流熔断(集成 Sentinel)、监控等一应俱全,大大降低了构建生产级微服务的门槛。
- 生态成熟: 拥有活跃的中文社区、详尽的文档和大量生产实践案例,对于 Java 技术栈团队来说,学习和运维成本相对较低。
(2)Thrift
Thrift 由 Facebook 开发并贡献给 Apache,其核心设计哲学是“接口定义先行” 和强大的跨语言能力。Thrift 要求你先用其专属的 接口定义语言 (IDL) 来严格描述你的服务接口(方法、参数、返回值)和数据结构。这个 .thrift 文件就是服务提供方和调用方之间的契约。“一次定义,到处生成” ,写好 IDL 文件后,使用 Thrift 的编译器 (thrift -gen) 可以自动生成多种目标语言(如 Java, C++, Python, Go, PHP, JavaScript 等)的客户端和服务端骨架代码,由此实现跨语言 RPC 调用。
(3)gRPC
gRPC 是 Google 主导的高性能、开源、通用的 RPC 框架,它建立在两个强大的技术基础之上:HTTP/2 和 Protocol Buffers (Protobuf)。Protobuf类似Thrift,是跨语言的序列化协议,同样是基于IDL来实现。
前面我们讲到,HTTP协议相对于TCP协议,存在性能问题,因此一般RPC框架不会选择使用HTTP协议作为通信协议,但是,HTTP/2是对HTTP协议的重大革新,它使用二进制替代文本格式、以及多路复用和头部压缩等特性,解决了 HTTP/1.1 的诸多性能问题。基于HTTP/2协议传输数据不逊于基于TCP传输,而且还具有高度的兼容性(只要支持HTTP协议的客户端都可以调用其接口),因此,gRPC选择使用HTTP/2作为其网络通信协议。可以看我们的计算机网络课程,里面有详细降到各个版本的HTTP协议的特点。
3. 最后总结
RPC框架是微服务架构中连接各个独立服务的高效通信桥梁,它通过封装网络、序列化、服务发现等底层细节,提供了接近本地调用的开发体验,是微服务得以协同工作的基础保障。在接下来的课程中,我们将继续深入微服务落地的关键支撑技术,如服务发现、配置中心、API网关、鉴权、限流、熔断、链路追踪等,这些组件将与RPC框架紧密协作,共同构建起稳定运行的微服务。
三、服务发现
对于单体架构来说,服务之间的调用相对简单、固定、改动少,服务方地址大多是在调用方中硬编码或者写在配置文件里,但一进入微服务架构,情况就大不一样了。
服务数量爆炸式增长,每个服务为了高可用和高性能,还得部署多个实例,这些实例还会动态变化:部署、重启、扩缩容、故障迁移。如果仍然将服务地址硬编码到调用方的代码或者写入调用方的配置文件,那么,服务实例的动态变化就会触发所有调用方更新代码或配置文件,并全部需要重启,这显然给运维带来了很大工作量,非常繁杂而且容易出错。于是,我们就需要有一个集中的地址管理中心,起到服务注册和发现的作用,也就是今天要讲的服务注册中心。
1. 基本原理
不知道你是否还记得,我们前面讲到过负载均衡,在多个后端服务实例的前面,部署一个负载均衡器(比如,Nginx、HaProxy、LVS、F5等),这样服务调用者(也叫做客户端)只需要记录负载均衡器的地址,无须记录后端服务实例的地址,服务实例的上下线等动态变更是由负载均衡器维护的,对于服务调用者来说是透明的。你看,这里的负载均衡器也可以起到服务注册中心的作用。
不过,对于微服务架构来说,服务注册和发现功能一般是集成在RPC框架中,而非使用已经存在的现成的负载均衡器来实现,主要原因还是考虑到性能。
我们前面讲过,对于某些负载均衡器(比如F5、LVS),经过配置之后,可以支持后端服务的响应请求直接返回给客户端,而不经过负载均衡器,但是,为了精确地分流流量,起到负载均衡的作用,所有的请求都要经过负载均衡器。
请求经过负载均衡器的转发,多了一步网络通信,显然会影响性能,对于追求高性能的RPC调用来说,显然需要更优的方案。因此,一般RPC框架会自己实现服务的注册和发现功能,让服务注册中心只起到管理服务地址的作用,客户端和服务方之间的RPC通信直接进行,不经过服务注册中心。这样没有流量的转发,性能肯定更高一些!
为了实现服务的注册和发现,我们需要有一个地方集中存储服务的地址信息(各个实例的地址列表)。
前面我们讲到很多存储系统,比如Redis、MySQL、其他NoSQL数据库,当然,使用它们也没有太大问题,比如,Redis因为其完善的高可用架构Redis Cluster和消息通知pub/sub功能,其实也经常用作注册中心,但是,RPC框架更倾向于选择专用组件:Zookeeper、Nacos、Etcd等,它们天然解决了数据的一致性、高可用性和可靠性,并且提供了友好的编程接口,编程实现简单。每种RPC框架支持的注册中心类型不同,像Dubbo RPC框架支持多种注册中心(Zookeeper、Nacos、Redis),使用Dubbo时,我们可以灵活的选择注册中心类型(一般选运维和开发最熟悉的)。
有了服务注册中心存储服务地址之后,我们来看下基本的服务注册和发现流程。
当一个服务实例启动时,它会主动向注册中心(比如Nacos等)请求登记地址信息(也就是调用Nacos的接口存储数据)。当客户端要调用服务接口时,会先向注册中心查询服务的实例地址列表,获取实例地址列表之后,通过一定的负载均衡算法,选择一个服务实例进行接口调用。
当然,这是最基本的处理流程,其实还有很多性能问题和不完备的地方。比如,每次接口调用都要先查询注册中心获取实例地址列表,这是比较影响接口调用性能的。解决这个问题的方法也比较简单,可以在客户端加一个缓存,缓存实例地址列表。这样就避免了每次接口调用都查询注册中心。
当然,这样做又带来了新的问题:缓存和注册中心的数据一致性问题。也就是,当注册中心中的实例地址更新了(有实例下线了或者新的实例启动了),怎么快速通知各个客户端的缓存进行更新?一般情况下,客户端会跟注册中心维持一种pub/sub的长连接,当注册中心有数据更新时,客户端会监听到数据更新并更新本地的缓存。
以上便是服务注册和发现的基本工作流程,当然,对于业务开发工程师来说,这些底层处理流程并不需要我们去开发,RPC框架的client sdk和server sdk中都已经实现了这些功能。我们只需要启动一个注册中心(比如Nacos),然后,将注册中心的地址配置到RPC服务端和客户端,RPC框架自动会根据注册中心地址进行服务的注册、发现、缓存、负载均衡等工作。
2. 心跳检测
以上我们只讲了服务实例如何上线注册,但没有讲到如果服务实例主动下线或者崩溃,该如何通知注册中心删除实例地址?
心跳检测是非常常用的可用性检测方案,比如前面讲到的负载均衡器(Nginx、LVS等)的健康检查、Netty长连接的有效性检查都是基于心跳检测。这里我们也可以使用心跳检测,来实现服务实例下线或崩溃的发现。
服务实例定期(比如每隔5秒)向注册中心发送一个心跳包,证明自己存活着。如果注册中心在连续一段时间内(超时时间,比如20秒)没有收到任何来自该实例的心跳包,则判定该实例下线或者崩溃,就将其从注册中心删除,基于pub/sub消息通知机制,所有监听此服务地址列表的客户端,都会受到地址更新消息,并更新自己的缓存数据。
注意,因为网络抖动问题,心跳有可能发送失败,因此,为了避免注册中心错误地判定实例下线,超时时间一定要比心跳间隔大,比如上面举例中提到的超时时间为20秒,心跳间隔是5秒,起码要3~4次连续的心跳都没有收到,注册中心才会判定实例下线,这样就可以大大降低因为网络抖动而导致的误判。
当然,细心的你肯定会发现,超时时间设置过长,虽然降低了误判率,但是,也会带来其他副作用,原本已经下线的实例,需要等待20秒钟才会被判定为下线并通知客户端,在此期间,客户端仍然会调用这个下线的实例,导致请求失败,这个问题怎么解决呢?我们留在下一小节「容错机制」中讲解。
对心跳检测有了一定了解之后,我们来看,如何实现心跳检测?
前面提到注册中心有很多类型,比如Nacos、Zookeeper、Redis等,其中Nacos、Zookeeper本身就具有会话的心跳检测功能,Redis可以使用Key的过期时间(如30秒)模拟心跳,服务实例需定期续期(使用 EXPIRE 命令)自己存在Redis中的地址,否则过期后被清理。这也是我们一般使用Nacos、Zookeeper这些专用的分布式协调系统、以及Redis实现注册中心,而非使用MySQL、其他NoSQL数据库(比如MongoDB)实现注册中心(基于它们实现心跳机制较为复杂)的其中一个原因。
3. 容错机制
注册中心作为核心基础设施,其高可用至关重要,但即使做了集群高可用部署,极端情况下仍可能完全不可用(如机房故障)。此时需确保服务调用不受致命影响,该怎么办呢?
- 客户端缓存持久化:在注册中心不可用时,客户端使用最后一次有效的服务地址缓存继续提供服务。虽然此时无法感知新实例上线或实例下线,但大部分存量服务仍可用。
- 服务静态列表降级:在配置文件中预设关键服务的地址列表,当注册中心不可用时切换到静态配置,保证核心链路可用。
- 注册中心集群容灾:生产环境必须部署注册中心集群(如Nacos集群跨机房部署),即使一个机房的全部节点都宕机,另一个机房的注册中心仍然可以提供服务。
前面我们反复提到一个问题,客户端缓存服务地址和注册中心的一致性问题,也就是当注册中心检测到服务实例下线并推送更新时,客户端可能因为网络延迟或处理不及时,本地缓存中仍保留着已下线的实例地址。如果调用方此时使用这个“僵尸地址”发起请求,必然导致调用失败,这个问题该怎么解决呢?
- 本地缓存自动过期:客户端缓存的地址列表设置一个较短的本地有效期(比如30秒),即使未收到注册中心的更新通知,过期后也会主动去注册中心拉取最新列表。这样避免因推送丢失导致长期使用脏缓存。
- 调用失败快速剔除:在发起RPC调用时,如果某个实例连续调用失败(如连接超时、服务不可用),客户端会临时标记该实例为不可用,并在一段时间内(如10秒)不再向其发送请求或者降低该实例的权重减少调用。
当然,即使地址列表是最新的,调用过程中仍可能因网络闪断、服务瞬时过载等问题导致失败。这时不能简单放弃这个服务实例,而需通过调用策略容错:
- Failover(失败自动切换):当调用失败时,自动切换到其他实例重试。这是最常用的策略,特别适合读操作。注意:写操作需谨慎使用,避免重复提交(需服务端幂等)。
- Failfast(快速失败):只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
- Failsafe(失败安全):调用失败后仅打印日志,不抛异常,适用于可降级的非核心功能(如日志上报)。
- Failback(失败自动恢复):后台记录失败请求,定时重发,通常用于消息通知操作。
- Forking(并行调用):同时调用多个实例,只要有一个成功就返回结果,适合对实时性要求极高的场景。
- Broadcast(广播):调用所有服务实例,逐个调用,任意一台报错则报错 ,通常用于通知所有服务实例更新缓存等本地资源信息。
这些策略由RPC框架内置,开发人员只需通过配置选择即可。比如Dubbo RPC的容错配置:
<!-- 全局默认策略(所有服务生效) -->
<dubbo:consumer cluster="failfast" retries="0"/>
<!-- 单个服务指定策略 -->
<dubbo:reference interface="com.example.UserService" cluster="failover" retries="3">
<!-- 方法级重试(覆盖服务级策略) -->
<dubbo:method name="getUser" retries="2"/>
</dubbo:reference>4. 负载均衡
前面的章节中,我们讲到,负载均衡可以分为两类:代理类负载均衡和客户端负载均衡。前面讲到的Nginx、LVS等是代理类负载均衡,而PRC框架使用的是客户端负载均衡,由RPC框架在客户端SDK中实现,客户端根据负载均衡算法,从服务实例地址列表中选择一个实例进行调用。这种设计避免了代理转发带来的性能损耗。
一般情况下,RPC框架支持多种负载均衡策略。
- 随机调用策略:随机选择服务器节点,该策略可以对不同服务器实例设置不同的权重,权重越大分配流量越高。
- 轮询调用策略:均匀地将请求分配到各个机器上。如果各个机器的性能不一样,容易导致性能差的机器负载过高,所以此时需要调整权重,让性能差的机器承载权重小一些,流量少一些。
- 最少活跃数策略:根据服务器的运行状态去选择服务,如果某个机器性能越差,那么,单位时间内接受并处理完成的请求就越少,即表示越不活跃,就分配更少的请求。
- 一致性哈希算法:相同参数的请求一定会被分发到固定的服务器节点。当某个服务器节点挂掉的时候,会基于虚拟节点均匀分配剩余的流量,抖动不会太大。
我们拿Dubbo举例,Dubbo 内置了以上多种负载均衡策略,通过简单配置即可启用:
<!-- 服务消费者全局配置 -->
<dubbo:consumer loadbalance="leastactive"/>
<!-- 单个服务指定策略 -->
<dubbo:reference interface="com.example.UserService" loadbalance="roundrobin">
<!-- 方法级特殊配置 -->
<dubbo:method name="getUser" loadbalance="consistenthash"/>
</dubbo:reference>5. 最后总结
本节课系统地讲解了微服务架构中服务注册与发现。面对服务实例动态变化的挑战,服务注册中心作为集中的地址管理者应运而生,它解决了调用方硬编码地址的繁琐与脆弱。服务注册与发现,配合心跳、容错和负载均衡,共同构成了微服务间动态寻址、健康管理、故障应对和流量调度的基石,使大规模微服务系统能够稳定、高效地运行。
四、配置中心
在单体架构时代,配置通常散落在应用的 properties、yml 或 xml 配置文件中,改个数据库地址,调整个线程池大小都需要重启应用。这样做虽然比较麻烦,但因为部署的实例不多故还能忍受。然而,当进入微服务架构时代,系统拆分成数十个微服务后,这种原始的配置管理方式,瞬间变成了运维的噩梦。想象一下,当你需要紧急调整所有服务的超时阈值以应对突发的网络抖动时,你需要要逐个登录服务器、修改文件、重启服务,这显然是一场大工程,而且人工操作还容易出错。这个时候配置中心就应运而生了。
1. 核心功能需求
常用的配置中心有很多,比较主流的有Nacos、Apollo、Spring Cloud Config。其中,Nacos是阿里开源的配置中心,同时,它还具有服务注册和发现的功能(上一节课我们讲过),使用Dubbo RPC框架首选Nacos。Apollo是携程开源的配置中心。Spring Cloud Config是Spring Cloud生态的配置中心,与Spring Boot应用集成简单。
配置中心的核心价值,远不止于集中存储配置这么简单,在微服务架构中,它还起到了很多其他作用:
- 动态更新: 修改一个配置项(如某个降级开关、日志级别),能够接近实时地推送到所有相关的服务实例,并且无需重启。这在故障排查、容量调整、功能灰度发布时至关重要。
- 配置隔离: 当数十个项目共享同一个配置中心时,不同的项目、不同的环境(开发、测试、预发布、生产等)需要完全隔离的配置集,避免人为误操作导致的配置污染。
- 版本回滚:配置中心会像代码仓库管理源代码那样管理配置变更,记录每次修改的完整快照。通过可视化的版本对比功能,运维人员可以清晰看到不同版本间的差异,一键触发回滚操作。
- 加密存储: 对于敏感信息,比如数据库密码、API密钥等,需要安全的存储、传输和访问控制机制,不能像普通配置一样明文展示。
- 权限控制:防止一个项目访问另一个项目的配置,同时,同一个项目内,不同身份的参与人员具有不同的访问权限,杜绝"实习生误删或修改重要配置"这类灾难事故。
- 审计追踪: 谁在什么时候修改了什么配置?修改前后的值是什么?配置中心会提供完整的审计追踪,避免误操作或者恶意操作,方便追责与快速恢复。
你看,配置中心看起来简单(就是存储配置),实际上并不简单。在面试中,我们完全可以出一道题目“如何设计一个配置中心”或者“配置中心应该具备哪些基本功能”,来考察你对配置中心的了解程度。
接下来,我们结合Nacos,来详细讲解配置中心的核心实现原理。
2. 配置存储模型
配置中心一般需要依赖外部存储系统来存储配置数据,通常采用高可用的数据库(如 MySQL, TiDB)或分布式协调服务(如 etcd, Zookeeper)来持久化存储所有配置数据,包括不同环境、不同应用、不同版本的配置。数据存储模型设计是关键,需要高效支持按应用、环境、Key 等信息查询。
像Nacos支持多种数据库(MySQL、TiDB、Derby),TiDB适合亿级海量配置的存储,Derby为内嵌数据库,更适合开发、测试环境,而MySQL最为常用。我们拿MySQL举例,看下它的数据存储模型,具体如下所示。
CREATE TABLE config_info (
id BIGINT NOT NULL AUTO_INCREMENT, -- 全局唯一ID
data_id VARCHAR(256) NOT NULL, -- 配置ID (如: order-service.redis.url)
group VARCHAR(128) NOT NULL, -- 分组 (如: DEFAULT_GROUP)
tenant_id VARCHAR(128) DEFAULT '', -- 租户ID (命名空间)
content LONGTEXT NOT NULL, -- 配置内容 (支持AES/SM4加密存储)
type VARCHAR(32) DEFAULT 'text', -- 类型 (text/json/xml/yaml)
encrypted BOOLEAN DEFAULT 0, -- 是否加密
md5 VARCHAR(32) NOT NULL, -- 内容MD5,用于快速校验变更
PRIMARY KEY (id),
UNIQUE KEY uk_dataid_group_tenant (data_id, group, tenant_id) -- 唯一索引
);在多租户系统中,tenant_id为租户ID,区分不同的租户。在Nacos中,tenant_id表示命名空间;group表示分组,data_id为配置项。data_id的命名方式一般为:
# 格式:{应用名}.{配置类别}.{后缀}
order-service.redis.cache_timeout # 订单服务的Redis缓存超时
user-service.db.master_url # 用户服务的数据库主库URLtenant_id、group、data_id三者配合可以起到隔离环境、业务线、团队、项目、微服务等的效果,比如通过tenant_id区分不同的环境,通过group区分不同的项目,通过data_id的前缀区分不同的微服务。
暂时无法在飞书文档外展示此内容
然后再配合权限表,就可以实现用户操作权限的隔离。
-- 权限表
CREATE TABLE permissions (
user VARCHAR(64), -- 用户账号
tenant_id VARCHAR(128),
group VARCHAR(128),
data_id_pattern VARCHAR(256) -- 通配符
);
-- 示例数据:
-- 允许张三在dev环境操作支付组所有应用
('zhang3', 'dev', 'PAYMENT_GROUP', '*')
-- 允许李四在prod环境操作用户服务的DB配置
('li4', 'prod', 'USER_GROUP', 'user-service.db.*')除此之外,通过历史版本表his_config_info存储配置项的历史版本,可以快速实现历史版本的回滚。至此,通过以上存储模型,便简单实现了配置隔离、历史回滚、权限控制等功能。
3. 多级存储架构
Nacos 通过三级存储架构实现了高效的读写:内存缓存、本地磁盘快照(RocksDB)、数据库。
内存缓存:顶层的 ConcurrentHashMap 提供微秒级响应,承载日常绝大部分的读取请求,内部采用 LRU 策略淘汰冷数据。
本地磁盘快照:基于 RocksDB 实现。RocksDB 是一个 LSM 树结构的存储引擎,它的写入性能极强(所有写入都是顺序追加),非常适合 Nacos 这种需要快速持久化配置变更的场景。你可能担心 LSM 树的读取会慢一些,但在 Nacos 的架构中,读请求几乎全被内存缓存拦截,所以读性能不是瓶颈。有了 RocksDB,即使数据库完全宕机,Nacos 仍能提供配置读取服务。
数据库:最终的持久层。数据库写入采用批量提交,避免频繁单条写入的性能问题。
除此之外,Nacos还会实时运行热点探测,识别高频配置项,并将其预先存入内存缓存中。针对超过1KB的大型XML/JSON配置,系统智能启用GZIP压缩算法,大大缩减存储和网络传输数据量。
4. 配置实时推送
配置中心可以简单划分为客户端和服务端,服务端存储配置,客户端拉取配置并使用。那么,如何实现客户端迅速感知并获取配置变更呢?当然,最简单的实现方法是基于消息中间件的pub/sub模式,但是,对于业务开发者来说,使用配置中心还要引入消息中间件,使用成本是比较高的,更好的方法应该是all in one,开箱即用!
其实,如果追求实现简单,我们可以采用客户端轮训的方式,对于关心的配置,间隔固定时间(比如1秒)拉取一次服务端的配置,当然,为了减少数据传输量,在拉取配置时,客户端传递上一次拉取的时间,基于此时间进行过滤,拉取在此时间之后有更新的配置。
不过,基于轮询的方式,虽然实现比较简单(服务端只需要开放接口即可),但是,大量客户端频繁的轮询(建立连接、释放连接)会对服务器有较大压力,而且,大部分情况下,配置可能都没有更新,大部分轮询都是在做无用功。你可能会说,那把轮训的时间间隔设置大一点,10秒请求一次不就行了吗?但是,这样又会带来新的问题,那就是:服务器变更了配置,但客户端要过很久(最长10秒)才能感知到配置变更。
轮询相当于pull通信方式,既然pull不行,那么我们就改成 push。push的通信方式需要客户端和服务端保持长连接,当有配置更新时,服务端通过长连接实时地将配置更新推送给客户端。但是,这种方式需要维护长连接(例如需要心跳检测以进行连接有效性检查),编程实现就复杂了。除此之外,大部分情况下,配置都是没有更新的,维持长连接似乎也有点大材小用!
因此,像Nacos、Apollo一般都采用了长轮询的通信方式。客户端发起一个请求到服务端,询问“我关心的配置是否有更新?”如果此时没有更新,服务端会 hold 住这个连接不立即返回,直到发生客户端关心的配置变更(立即返回变更数据)或达到一个较长的超时时间(如 30s、60s),此时返回空响应。客户端收到响应(无论是否有数据)后,立即发起下一次长轮询请求。这种方式相比短轮询(如每 5s 请求一次)大大减少了无效请求次数,同时保证了变更通知的实时性(通常在秒级)。
相比于长连接(需要基于TCP或WebSocket实现),长轮询(可以基于HTTP实现)编程实现要简单得多,即便因为网络抖动等原因,导致单次连接的断开,客户端也会主动发起下一次连接,连接的管理比长连接要简单许多,但实现的效果跟长连接相差无几。
5. 最后总结
配置中心绝不是一个简单的 Key-Value 存储,它还需要支持动态更新、配置隔离、版本回滚、加密存储、审计追踪等功能,是保障微服务架构下大规模应用配置管理高效、可靠、安全的关键基础设施。通过分析其存储模型、多级存储架构以及长轮询等实时推送机制,我们可以看到,一个优秀的配置中心实现起来并不容易。理解这些核心原理,不仅有助于我们更好地选型和运用配置中心,也是面试中考察对微服务架构理解深度的常见问题。
五、API网关
在微服务架构中,一个系统可能被拆分成很多个微服务,微服务属于内部服务,如果直接暴露接口给前端应用(web、app、小程序、IoT设备等)调用,会存在诸多问题,比如调用关系可能会比较混乱(前端应用需要接入很多后端微服务),也会遇到协议不兼容的问题(比如,前端可能不适合直接调用Dubbo接口),此时,我们就需要在前端和微服务之间构建一个中间层,提供集中的、统一的接口给前端调用,这个中间层就是API网关。
1. 产生背景
刚刚已经提到了一些需要API网关的原因,这里我们再更加详细的阐述一下。
想象一下,在一个经过微服务拆分的项目中,我们可能有十几个甚至几十个独立运行的微服务,每个微服务都暴露着自己的接口,如果前端应用直接与这些服务通信,就会存在以下一些问题:
- 前端与后端强耦合
对于前端开发工程师来说,他需要知道后端业务的划分方式,每个微服务的职责分工和业务边界,一旦后端重构了微服务之间的划分,比如合并了某两个微服务或者拆分了某一个微服务,又或者将部分业务从一个微服务迁移到另一个,这些知识都要同步给前端开发团队,并且需要做相应的前端代码修改!
- 通信协议难以兼容
我们知道,微服务一般采用RPC框架暴露接口,为了性能,RPC框架往往采用长连接、二进制数据格式来进行通信,并且,不同的微服务还可能采用不同的RPC框架来开发,而对于很多前端应用来说,如果去兼容适配每个微服务的通信协议,增加了前端应用的开发成本,这也是不应该的!
- 通用功能重复开发
有一些通用的非业务功能,也就是前面文章中提到的服务治理,比如用户鉴权、接口限流、降级、熔断、访问日志记录、监控等,如果每个微服务都重复开发,不仅效率低下,开发成本高,而且容易出错,更难以保证一致性!
API网关就是为了解决以上问题而产生的,API网关介于前端和微服务之间,为前端提供一个统一的接入点。所有的前端应用都只与API网关直接交互,API网关接收到前端的接口请求之后,经过服务治理(鉴权、限流、日志记录等),协议的转换(HTTP-》RPC),再根据路由规则,将接口请求路由给对应的微服务处理,处理之后的结果,再经由API网关返回给前端应用。你看,其实,API网关就是一个反向代理!
2. 核心功能
接下来,我们具体讲讲,一般来说,一个API网关都需要具备哪些核心的功能:
- 路由转发:
这是网关最基础、也是最核心的能力。它根据HTTP请求的路径(Path)、方法(Method)、Header、甚至请求内容(Body)等特征,将请求精准地路由到后端对应的微服务。API网关(如Spring Cloud Gateway、Zuul)通常支持与服务注册中心(如Nacos)无缝集成,能够自动感知微服务的实例变化,实现动态的负载均衡路由,无需人工维护微服务实例列表。
- 负载均衡:
API网关通过服务注册中心获取到微服务实例的列表并调用某个微服务实例,因此,API网关相当于微服务的客户端,前面我们讲过,有别于代理负载均衡,微服务一般使用客户端负载均衡,所以,API网关还会有负载均衡的作用。当然,如果使用RPC框架的client sdk来调用微服务接口,那么,sdk中一般已经集成了各种负载均衡算法的实现,API网关只需要指定使用的负载均衡算法即可。当然,API网关也可以自己去实现。
- 配置中心:
待会我们会讲到,API网关为了实现高性能、高可用,需要集群部署,跟微服务的配置相似,API网关的配置(主要是路由配置、服务治理的各种策略)也需要集中化存储,也就是需要配置中心。这样,配置的更新不需要重启API网关,并且便于统一管理。比如当支付服务上线新版本,需要将/v1/pay切换至/v2/pay时,运维人员只需在配置中心(如Nacos)修改一条规则,几秒内所有的网关实例就生效,无需重启网关实例,更不必中断线上服务。
- 协议转化:
微服务内部可能采用高性能但对外不友好的协议(如 gRPC、Dubbo、Thrift)。网关可以承担协议转换的重任,对外暴露标准的、易于理解的HTTP API,对内则使用最适合的协议与后端服务通信。这大大简化了客户端的集成难度,也保护了内部技术选型的灵活性。
- 服务治理:
微服务主要负责业务逻辑的实现,我们可以将非业务的功能,也就是鉴权、限流、降级、熔断、日志、监控、部分调用链追踪等服务治理工作,从微服务中剥离出来,统一放到API网关中的实现。这样也就实现了业务功能和非业务功能的解耦合,业务的频繁更新只需要更新单个微服务,并不需要更新API网关,系统更加稳定。
3. 技术实现
前面我们讲了API网关要解决的微服务的痛点,以及API网关需要具备的核心功能,相当于API网关的设计部分,接下来,我们聊聊实现部分,也就是,具体如何来实现一个API网关呢?
其实,我们并不需要从零自己去实现以上罗列的所有API网关的功能,因为市面上有很多API网关框架,我们可以直接使用,比如,Spring Cloud Gateway、Netflix Zuul、Kong等。
其中,Spring Cloud Gateway本质是一个 Spring Boot Starter(spring-cloud-starter-gateway),无法独立运行,需要作为 JAR 包集成在 Spring Boot 应用中。
Zuul1作为 Servlet 过滤器运行,需嵌入 Spring Boot 或 Web 应用服务器(Tomcat/Jetty)。Zuul2改善了Zuul1的性能,使用Netty非阻塞异步模型,支持独立部署运行。
Spring Cloud Zuul是Spring Cloud对Zuul1的集成,类似Spring Cloud Gateway,需要嵌入Spring Boot应用部署,官方并没提供Zuul2与Spring集成的Spring Boot Starter。Spring Cloud大力发展自家的Spring Cloud Gateway,因此,Spring Cloud Zuul进入维护模式,已经被Spring Cloud Gateway取代!
Kong 是一个完全独立的部署运行的网关服务。它基于 OpenResty(Nginx的升级版) 构建,通过 Lua 插件扩展功能,不依赖任何外部应用框架。
这些框架都提供了上述所讲的路由转发、负载均衡、配置中心的基本功能,至于协议转换和服务治理,需求不固定且繁杂,多数都需要通过引入插件、类库、二次开发等方式来实现。
高扩展性是现代API网关框架非常强大的特性,这既保证了网关的轻量,又能灵活应对个性化的需求。例如,在Spring Cloud Gateway中,如果要实现gRPC和HTTP之间的协议转换,我们需要引入grpc-spring-boot-starter,如果要实现Dubbo和HTTP之间的协议转换,需要引入dubbo-spring-boot-starter。实现限流、熔断等服务治理,我们需要引入Sentinel或者Hystrix等组件。
4. BFF服务
前面我们讲到从单体架构拆分成微服务架构会面临的一些问题,其实,还有一个问题没有讲,原因是这个问题并非API网关要解决的问题,但是,很多人会误认为可以在API网关中解决,因此,这里我们也一并讲一下,那就是:API聚合。
一般来讲,微服务为了保证接口的通用性,往往会就暴露细粒度的接口,即一个接口包含简单单一的业务。这样,对于复杂的业务,需要调用多个细粒度的微服务接口才能完成。内网通信比较快,不同层级的微服务之间互相调用,问题不大。但是,对于前端应用来说,与后端服务之间的通信往往需要经过公网甚至是移动网络,通信成本往往很高,甚至高于业务逻辑执行的成本(时间)。如果实现一个稍复杂的业务逻辑,需要调用多个后端接口,多次往返的网络通信,势必增加前端应用的响应时间,影响用户体验。
为了解决这个问题,于是就有了Backend For Frontend(BFF),也就是为前端服务的后端。BFF是在微服务的上层,它根据前端的数据需求,调用下游多个微服务(如用户服务、订单服务、商品服务),获取所需数据,并将其聚合、裁剪、转换成一个符合该前端要求的数据,然后一次性返回给前端。这大大减少了前端的请求次数和数据处理复杂度。
实际上,现在很多项目的前端形态都趋于多样化(Web、iOS、Android、小程序等),每个前端应用的功能和页面可能都不尽相同,对接口的需求也不同。基于此,我们甚至可以为每个不同的前端应用构建独立的BFF,这样不管是接口性能还是前端开发的体验都会更好!
有些同学可能会说,是不是可以将API聚合的工作放到API网关中实现呢?答案是可以但不推荐。比如,从技术实现角度上,前面讲到,Spring Cloud Gateway需要集成到一个Spring Boot应用中运行,那么,我们在这个Spring Boot中就可以实现接口聚合的逻辑。这样做的好处是运维简单(不用维护两套东西:API网关和BFF),以及减少网络传输耗时,原本请求要经过BFF再到微服务,现在直接从API网关到达微服务,减少了一次网络通信。
但是,这样做也会带来很多其他问题,一方面,有些API网关就不支持API聚合业务逻辑的编写(比如Kong),另一方面,修改BFF业务逻辑需要重启全部的网关实例,可能要中断全部的服务,还有BFF和API网关柔和在一起,互相竞争系统资源,且无法针对性的做伸缩扩容。从架构清晰、解耦合、演进的角度来说,将BFF和API网关独立开发和部署,是更加合理的选择。
理解了API网关和BFF,我们从全局的角度来看下整个微服务架构。其中,为了高性能和高可用性,API网关、BFF、微服务均采用集群部署。

5. 最后总结
这节课我们讲了API网关。API网关是微服务架构中不可或缺的统一接入层,它将前端繁杂的调用收敛至单一入口,解决了协议兼容性挑战与服务治理重复建设的问题。通过路由转发、负载均衡、协议转换等核心能力,网关让微服务得以专注业务逻辑,同时将限流、熔断、鉴权等治理能力集中管控,大幅提升系统稳定性和可维护性。网关专注非业务功能,BFF解决业务适配与数据聚合。这种分层设计既保障了架构清晰度,也为系统演进留下弹性空间。
六、认证鉴权
上一节课,我们讲到,在API网关中,我们可以实现多种服务治理工作,比如认证鉴权、限流、降级熔断,接下来的几节课,我们就依次展开讲解这几个服务治理。今天这节课我们讲认证鉴权。
1. 认证鉴权
认证就是解决“你是谁”问题,一般是登陆之后获取访问凭证,后续通过凭证来访问服务。鉴权是解决“你能干啥”问题,一般是认证之后获取用户身份,然后根据用户身份获取用户授权的访问范围,以此来限制用户对某些服务的访问。接下来,我们会侧重讲解认证。
实际上,在微服务架构中,包含两类认证鉴权。一类是用户跟服务之间的认证鉴权,另一类是服务跟服务之间的认证鉴权,比如在大公司中,往往有中台的概念,中台提供众多通用业务的微服务,供各个不同业务线调用,尽管业务线对中台的调用是内部调用,相对于外部调用还算安全,但是大公司人员杂乱,为了保证中台系统的安全、可用性,以及方便做容量规划,各个业务系统在调用中台微服务也应该有认证鉴权逻辑。
接下来,我们先来看一些常用的经典的认证鉴权的实现方式,包含:Session、Token、JWT、AccessKey,然后再来看,它们如何集成到微服务架构中。
2. Session
我们先从早期的Cookie-Session的认证方式讲起。其实现逻辑可以大致分为三部分:
(1)会话创建
当用户登录成功后,服务器会创建一个对应的 Session 会话对象,用于存储本次会话的状态信息(包含session ID和用户ID等用户身份标识)。随后,服务器需要将此 Session 对象持久化到某个存储介质中。在早期单机部署的场景下,像 Tomcat 这类 Web 容器默认会将 Session 存储在单个实例的内存中。这种方式的弊端非常明显:一旦服务器重启,所有会话状态都会丢失;同时,若采用多实例部署,由于会话数据无法在各个实例间共享,必须依赖会话黏滞(Session Sticky)策略,才能确保同一用户的后续请求始终被路由到登录时的那个实例上。
随着分布式架构和集群部署成为主流,为了解决会话共享的问题,常见的做法是将 Session 集中存储到外部分布式缓存(例如 Memcached 或 Redis)中。这种架构演进,使得多个服务实例,可以共享同一份会话数据,从而打破了会话黏滞的限制,提升了系统的可扩展性和可靠性。
(2)凭证下发
创建好 Session 后,服务器必须让浏览器把这个 Session ID 保存起来,并在后续请求中带回。这是通过 HTTP 响应头 Set-Cookie 实现的。服务器会在登录成功的 HTTP 响应中加上这样一个头:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=D3B8F5E0A12C4...; Path=/; HttpOnly; Secure- JSESSIONID:Cookie 的名称。JSESSIONID是Java EE 的标准名称,其他技术栈有不同的默认名,比如PHP使用PHPSESSID。
- D3B8F5E0A12C4...:Cookie 的值,即 Session ID的值。
- Path=/:指定此 Cookie 对网站下的哪些路径有效。/ 表示全站有效。
- HttpOnly:告知浏览器此 Cookie 无法通过 JavaScript 的 document.cookie API 读取,有效防御 XSS(跨站脚本)攻击窃取 Cookie。
- Secure:告知浏览器仅在 HTTPS 加密连接下才发送此 Cookie,防止在明文HTTP下被嗅探。
浏览器收到这个响应后,就会在本地磁盘的指定位置保存下这个 名称=值 对。
(3)访问验证
此后,浏览器向该网站发起的每一个请求,都会自动在请求头 Cookie 中带上之前存储的所有 Cookie。
GET /api/user/profile HTTP/1.1
Host: www.example.com
Cookie: JSESSIONID=D3B8F5E0A12C4...; other_cookie=value服务器接收到请求后从 Cookie 头中提取出 JSESSIONID 的值,拿着这个 Session ID,去它存储 Session 的地方查找对应的 Session。如果找到了且未过期,就将这个 Session 附加到当前请求(request)的属性中。你的业务代码就可以直接从请求中获取用户信息了。
3. Token
其实,基于Session这种认证方式,在网站应用中非常完美,但是,这套机制的实现深度耦合了浏览器,浏览器被设计为自动管理、自动携带 Cookie。认证的处理过程严重依赖 HTTP 协议头(Set-Cookie, Cookie)。
随着互联网技术的发展,前端形态逐渐多样化,除了网站之外,还有APP、小程序、桌面应用、IoT设备等等。基于Session的认证机制是一套为浏览器量身定制的、高度封装的解决方案。它简单,但被浏览器生态绑定。如果把它强行用于非浏览器环境,会非常别扭(需要手动模拟浏览器的Cookie)。
不过,它的处理思想完全可以借鉴,只需要对其实现方式稍作调整,于是,就有了适合多端应用的基于Token的认证方式。
Token是一种显式的(不像Cookie,浏览器会在发送请求时自动附带)、协议无关(不绑定HTTP)的认证方式。前端(不一定是浏览器)需要多做一些事情,主动管理Token(存到本地存储、内存等),并在每次请求时主动附加。这虽然比自动带上 Cookie 多了一步,但换来了巨大的灵活性和清晰的控制权。
我们对基于Token的认证过程稍作介绍。
用户调用登陆API成功登陆之后,后端会生成一个唯一Token(比如使用MD5对随机字符串和时间戳加密得到)发送给前端,并将Token与用户标识(如UserID)一并存储到Redis中。
前端收到Token之后,需要主动存储这个Token,不同类型的前端存储方式也不同,APP通常需要借助原生提供的安全存储机制,例如 iOS 的 Keychain 或 Android 的 Keystore,来安全地存储Token;现代浏览器则可以存储在 localStorage、sessionStorage 中,或依旧使用Cookie;而微信小程序这类平台化应用,则需使用其框架提供的异步存储API,如 wx.setStorageSync。
前端后续调用后端的API,都会将这个值一并传递给后端,对于HTTP请求来说,Token一般放到HTTP header中来传递,格式为:Authorization: Bearer 。后端收到这个Token之后,会搜索Redis,验证这个token是否是自己派发的合法token,然后才能执行API对应的后端逻辑。
大概的逻辑已经走通了,但是,还是不够全面。
Token没有过期时间吗?当然不行,不安全啊,万一泄露了,可能发生重放攻击,也就是黑客拿泄露的Token去冒充用户访问后端API接口。虽然可能性不大,但是也要考虑的。所以,Token需要设置一个过期时间,一并存储在数据库中,校验Token的同时,校验过期时间,如果过期,前端就引导用户去重新登录。
但是,仍然存在问题,那就是Token的过期时间太长,又会增加安全隐患,过期时间太短,又会导致用户频繁重新登陆。这个问题该怎么解决呢?
于是,我们又增加了一个refreshToken,原来的Token改名为accessToken。refreshToken的过期时间比较长,比如可以是7天,accessToken的过期时间比较短,比如可以是2个小时。
当用户登陆成功之后,生成accessToken、refreshToken,并将对应的过期时间,以及uid存储到数据库中,然后将accessToken和refreshToken一并传递给前端,前端找个地方存储这两个Token。平时,前端只是用accessToken,不使用refreshToken,这样就能尽量保证refreshToken的安全性。当accessToken过期之后,前端再将refreshToken传递到后端请求新的accessToken。如果refreshToken也过期了,那么只能引导用户重新登陆了。
问题又来了,为什么将refreshToken给到前端,不给不行吗?每次accessToken过期后,后端就自动生成一个新的accessToken,不行吗?不行!如果这样的话,泄露的accessToken可以用这种方法持续的换取新的accessToken,那么,refreshToken就起不到作用了。
除此之外,为了保证refreshToken足够安全,我们可以实现refreshToken轮转机制,每次使用refreshToken获取新的accessToken后,就让此refreshToken立刻失效,并同时生成一个新的refreshToken返回给前端。这进一步增加了攻击者盗取refreshToken的难度。
4. JWT
基于Token的认证方式,Token需要存储到Redis中,后续用于比对校验以及根据Token获取用户标识。这种方式依赖Redis,每次接口调用都要访问一次Redis,对性能有所影响,而且还需要部署高可用的Redis集群,增加了运维的复杂度,并且,理论上,每引入一个组件(Redis),系统的可用性也会相应的有所下降。
那么,怎么才能去掉Redis呢?要达到这个目的,Token需要具备两个特性:自包含和自验证。自包含的意思是,从Token中就可以解析出用户标识信息,不需要额外存储和查询。自验证的意思是,通过Token本身就能校验是否是后端合法签发的,不需要去Redis中比对。基于这两个优化思想,于是就有了新的认证方式:JWT(JSON Web Token)认证。接下来,我们具体看下JWT的实现原理。
(1)Token生成
一个 JWT 看起来是一长串看似乱码的字符串,用两个点.分隔成三部分:Header.Payload.Signature,举例如下所示:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQHeader
头部是一个 JSON 对象,通常由两部分组成:
- typ (Type):令牌类型,就是 JWT。
- alg (Algorithm):签名算法,比如 HS256 (HMAC SHA-256) 或 RS256 (RSA SHA-256)。
{
"alg": "HS256",
"typ": "JWT"
}这个 JSON 对象会通过 Base64Url 编码(一种适合URL的Base64编码)形成 JWT 的第一部分。
Payload
负载部分也是一个 JSON 对象,用来存放实际需要传递的“声明(Claims)”,它是实现自包含的关键。Payload中的声明分三种类型:
- 注册声明(Registered Claims):预定义的一些标准字段,非强制但推荐使用。例如:
- iss (Issuer):签发者
- sub (Subject):主题(用户ID)
- aud (Audience):接收方
- exp (Expiration Time):过期时间(这是最重要的声明之一)
- iat (Issued At):签发时间
- nbf (Not Before):生效时间
- 公共声明(Public Claims):可以自定义的字段
- 私有声明(Private Claims):签发方和接收方共同定义的、用于共享信息的自定义字段。例如 username, roles, department。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}同样,这个 JSON 对象也会通过 Base64Url 编码形成 JWT 的第二部分。
Signature
签名是 JWT 最精妙的部分,它是实现自验证的关键。签名的生成方式如下:
Signature = Algorithm(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret)我们以 HS256 算法为例,它需要一个密钥(secret):
// 伪代码
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
'your-256-bit-secret' // 一个只有签发方知道的密钥
)计算出的结果(一个二进制哈希值)再经过 Base64Url 编码,就得到了 JWT 的第三部分。
(2)认证流程
用户向后端服务器提供凭证(如用户名密码)。服务器验证凭证无误后,用一个安全存储的密钥生成 JWT,然后将完整的 JWT 返回给客户端。客户端(如浏览器或手机App)收到 JWT,需要妥善存储。此后,客户端在请求需要认证的 API 时,必须在 Authorization 请求头中以 Bearer 模式携带该令牌:Authorization: Bearer <your.jwt.token>。
后端服务器接收到请求,并从请求中提取出 JWT。验证流程是完全本地化的、无状态的,这是 JWT 性能优势的关键:首先,用点.分割字符串,得到三部分。服务器使用预配置的密钥,对收到的前两部分(Header.Payload)重新计算签名,并与收到的第三部分(Signature)进行比对。如果不一致,立即拒绝请求。签名验证通过后,解码 Payload,逐一检查声明(Claims),比如当前时间是否小于过期时间(令牌是否已过期?),当前时间是否大于生效时间?签发者是否是我信任的?等。如果任何一项检查失败,则返回拒绝请求。
(3)优势劣势
相对于Session认证方式,服务端不需要存储会话状态,这使得应用可以轻松地水平扩展,添加再多实例也无须担心会话同步问题。相对于Token认证方式,它避免了每次请求都查询 Redis 来校验Token,本地计算验证签名的速度极快。但JWT认证方式也存在一些问题。
无法主动失效是 JWT 最大的问题,一个签发出去的 JWT 在自然过期之前始终有效。无法像 Session 和 Token一样在服务端直接“踢掉”一个用户(通过删除对应的session和token的方法)。
5. AccessKey
前面我们讲到了三种认证方式:Session、Token、JWT,这三种认证方式更多的是用户和服务之间的认证。前面我们也提到,在微服务架构中,还存在一种服务与服务之间的认证,这种认证怎么实现呢?
AccessKey便是解决这类认证问题的方案,也是阿里云普遍使用的云服务(比如OSS等)访问的认证方式。我们具体来看下它的实现原理。
AccessKey包含两部分:AccessKey ID 和 AccessKey Secret。服务方给每个服务调用方均颁发一个AccessKey ID 和一个 AccessKey Secret。其中,AccessKey ID 用来唯一标识调用方,AccessKey Secret是用来加密生成签名的密钥,本身永远不会在网络上传输,绝对不能泄露。像阿里云建议定期更换AccessKey Secret以提高安全性。
调用方对服务方的每一次请求,都要附带签名,签名的生成算法并不复杂,利用加密算法(比如HMAC-SHA1)使用AccessKey Secret对待签名字符串进行加密,然后就得到了签名。待加密字符串通常由以下几个部分按特定顺序拼接而成,例如StringToSign = HTTPMethod + "&" + URL + "&" + Timestamp。
- HTTP 方法(如 GET、PUT)
- 请求的路径和参数
- 时间戳(防止重放攻击,服务器会验证时间戳的有效性,例如允许15分钟内的偏差)
- 其他需要验证的信息
服务方收到请求之后,根据请求中的AccessKey ID找到对应的AccessKey Secret,然后用相同的加密算法和规则重新生成签名,并与调用方传递过来的签名进行对比,如果一致,表明身份合法且请求未被篡改,否则拒绝调用服务。
6. 接入网关
以上讲解了各种认证方案,我们来看如何将其集成到API网关中,具体有如下两种方案:
方案一:API 网关完全处理认证与鉴权
在这种方案下,网关不仅负责路由、限流,还直接负责用户密码验证、Token 的生成与校验。处理流程如下:
- 用户登录请求 POST /login 直接到达 API 网关。
- 网关提取请求中的用户名和密码。
- 网关直接调用用户服务来验证凭据。
- 验证通过后,网关自己生成 JWT 或 Token。
- 网关将 Token 返回给客户端,并可能将其与用户信息的映射关系存入 Redis(非JWT的情况)。
- 对于后续请求,网关自己验证 Token 签名、过期时间,并查询 Redis 获取用户信息(非JWT的情况)。
方案二:API 网关集成 + 独立认证服务器
API 网关负责认证的“接入”, 用户的身份确认(用户名和密码验证)、Token的签发、验证、管理等工作交给专业的认证服务器去做。具体流程如下:
1. 登录认证(认证服务器职责):
- 用户向网关发送 POST /login 请求。
- 网关不做认证处理,只是根据路由规则,将请求透传给后端的认证服务器。
- 认证服务器验证用户名/密码。
- 验证通过后,认证服务器生成 Token/JWT,并将 Token 和用户信息存入 Redis(非JWT的情况),最后将 Token 返回给客户端。至此,网关没有处理任何认证逻辑。
2. 请求鉴权(网关与认证服务器协作):
- 客户端访问业务 API GET /api/orders,在 Header 中携带 Authorization: Bearer 。
- 请求到达 API 网关。
- 网关拦截请求,提取 Token,并调用认证服务器提供的验证接口来验证 Token 的有效性。
- 认证服务器确认 Token 有效且未过期,然后将对应的用户信息(如用户ID、权限范围等)返回给网关。
- 网关将用户信息(如用户ID)注入到请求中,然后将请求转发给后端的微服务。
- 微服务收到请求,它信任网关注入的用户信息,直接使用其中的用户ID进行业务逻辑处理,无需再次验证 Token。
两种方式各有优缺点,第一种方案不需要独立开发部署认证服务,对于小型项目来说够用,第二种方案职责划分更加清晰,API网关功能更加聚焦,避免过度臃肿。
7. 最后总结
今天这节课,我们主要围绕认证鉴权展开,从传统的Session方式,到更适合多端的Token机制,再到无状态、高性能的JWT,以及服务间调用的AccessKey方案,逐步剖析了不同场景下的身份验证实现思路。最后,我们还探讨了如何将这些认证机制集成到API网关中,既可以选择网关全权处理,也可以结合独立的认证服务,根据实际架构灵活选用。接下来,我们会继续讲解限流、降级、熔断等其他治理策略。
七、接口限流
在日常生活中,限流很常见,比如热门景区为了保证游客的旅游体验,往往会限制每天的入园人数。对我们的系统来说,对于一些预期之外的突发流量,比如突发新闻,秒杀活动,双十一,预期有10万QPS,结果来了15万QPS,那么多出来的5万QPS就是预期之外的流量。预期范围内的流量增长,我们可以通过扩容来解决。对于这种预期之外的流量,超过了后端服务的处理能力,就会将整个后端系统压垮(频繁GC、硬件资源耗尽、请求排队变慢超时等恶性循环导致系统卡死)。
为了保证系统的可用性,我们就需要为系统设计限流的功能,限制预期之外的流量,虽然这会拒绝部分访问,损坏部分用户的使用体验,但却保证了大部分用户的可用性和系统的稳定性。
1. 接口限流的场景
对于一些中台微服务,其接口请求可能来自很多内部系统,例如用户服务的接口,会被很多内部系统调用,比如 CRM, 促销系统等(这些内部系统可以看做上一节课中讲到的BFF)。对于服务于众多调用系统的微服务来说,接口限流除了应对上面提到的一些大促秒杀场景之外,在下面一些场景中也发挥着很大的作用。
作为服务提供方的微服务,我们是无法限制调用方(BFF)如何来使用我们的接口的,我们曾经就遇到过有一些调用方多线程并发跑 job 来请求我们的接口,也遇到到一些因为调用方的代码 bug 或者业务上面的突发流量,导致来自这个调用方的接口请求数量突增,过度争用服务线程资源,而来自其他调用方的接口请求因此来不及响应而排队等待,微服务整体的请求响应时间变长甚至超时。所以为了防止接口被某个调用方过度调用,中台微服务需要对每个调用方进行细粒度的访问限流。
除了对调用者的访问频率进行限制外,我们有的时候还需要对某些接口的访问频率做限制。比如一些慢接口,可能因为逻辑复杂,处理时间会比较长,如果对慢接口的访问频率不加限制,过多的慢接口请求会一直占用服务的线程资源不释放,导致无法响应其他接口请求,影响微服务系统整体的吞吐量和接口响应时间,甚至引起大量的接口超时。除了慢接口,有些核心接口,因为一旦异常访问对业务的影响比较大,除了做调用鉴权之外,还需要做非预期异常流量的限流。
综上所述,我们不仅仅需要针对大促秒杀场景的粗粒度的微服务接口限流功能:比如限制微服务集群每秒请求次数,我们还需要针对不同调用方甚至不同接口进行更加细粒度限流:比如限制 A 调用方对某个服务的某个的接口的每秒最大请求次数。
2. 限流中“流”的定义
限流中的“流”字该如何解读呢?要限制的指标到底是什么?不同的场景对“流”的定义也是不同的,可以是网络流量,带宽,每秒处理的事务数 (TPS),每秒请求数 (hits per second),并发请求数,甚至还可能是业务上的某个指标,比如用户在某段时间内允许的最多请求短信验证码次数。
从保证系统稳定可用的角度考量,对于微服务系统来说,最好的一个限流指标是:并发请求数。通过限制并发处理的请求数目,可以限制任何时刻都不会有过多的请求在消耗资源,比如:我们通过配置 web 容器中 servlet worker 线程数目为 200,则任何时刻最多都只有 200 个请求在处理,超过的请求都会被阻塞排队。
上一小节讲到,我们为了解决调用方对服务资源的过度争用问题,还需要针对不同调用方甚至不同接口做细粒度限流,所以,我们除了需要对系统整体的并发请求数做限制之外,还需要对每个调用方甚至不同接口的并发请求数做限制。但是,要想合理的设置某个调用方的最大允许并发数是比较困难的,这个值很难通过监控统计来获取,太小容易误杀,太大又起不了作用。所以我们还需要其他限流指标。
对比 TPS 和 hits per second ,我们选择使用 hits per second 作为限流指标。因为,对 TPS 的限流实际上是无法做的,TPS 表示每秒处理事务数,事务的开始是接收到接口请求,事务的结束是处理完成返回,所以有一定的时间跨度,如果事务开始限流计数器加一,事务结束限流计数器减一,则就等同于并发限流。而如果把事务请求接收作为计数时间点,则就退化为按照 hits per second 来做限流,而如果把事务结束作为计数时间点,则计数器的数值并不能代表系统当下以及接下来的系统访问压力。
我们再来看,hits per second 是否是一个有效的限流指标呢?答案是肯定的,这个值是可观察可统计的,方便配置限流规则,而且这个值在一定程度上反应系统当前和接下来的性能压力,对于这一指标的限流,确实可以限制对系统资源的使用。
3. 四种经典限流算法
有了流的定义之后,我们接下来看几种常用的限流算法:固定时间窗口,滑动时间窗口,令牌桶算法,漏桶算法。
(1)固定时间窗口限流算法
基于固定时间窗口的限流算法是非常简单的。首先需要选定一个时间起点,之后每次接口请求到来都累加计数器,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值,则拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。
这种基于固定时间窗口的限流算法的缺点在于:限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。我们举一个例子:假设我们限流规则为每秒钟不超过 100 次接口请求,第一个 1s 时间窗口内,100 次接口请求都集中在最后的 10ms 内,在第二个 1s 的时间窗口内,100 次接口请求都集中在最开始的 10ms 内,虽然两个时间窗口内流量都符合限流要求 (<=100 个请求),但在两个时间窗口临界的 20ms 内会集中有 200 次接口请求,如果不做限流,集中在这 20ms 内的 200 次请求就有可能压垮系统。

(2)滑动时间窗口限流算法
滑动时间窗口算法是对固定时间窗口算法的一种改进,流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。
滑动时间窗口的算法模型如下:
假设滑动时间窗口大小为 1 秒,我们需要记录一个时间窗口[t_start, t_start+1 秒) 内,请求顺序到来的时间点 list = (t_1, t_2, …t_k)。当 t_m 时刻新的请求到来时,我们通过以下步骤来更新滑动时间窗口并判断是否限流:
STEP 1: 检查接口请求的时间 t_m 是否在当前的时间窗口 [t_start, t_start+1 秒) 内。如果是,则跳转到 STEP 3,否则跳转到 STEP 2。
STEP 2: 向后滑动时间窗口,将时间窗口的起点 t_start 更新为 list 中的第二小时间点,并将最小的时间点从 list 中删除。然后,跳转到 STEP 1。
STEP 3: 判断当前时间窗口内的接口请求数是否小于最大允许的接口请求限流值,即判断: list.size < max_hits_limit,如果小于,则说明没有超过限流值,允许接口请求,并将此接口请求的访问时间放入list,否则直接执行限流。

滑动时间窗口限流算法可以部分解决固定时间窗口的临界问题,上面的例子通过滑动时间窗口算法整形之后,第一个 1 秒的时间窗口的 100 次请求都会通过,第二个时间窗口最开始 10ms 内的 100 个请求会被限流熔断。
即便滑动时间窗口限流算法可以保证任意时间窗口内接口请求次数都不会超过最大限流值,但是仍然不能防止在细时间粒度上面访问过于集中的问题,也就是说,基于时间窗口的限流算法,不管是固定时间窗口还是滑动时间窗口,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。
为了应对上面的问题,对于时间窗口限流算法,还有很多改进版本,比如:
多层次限流,我们可以对同一个接口设置多条限流规则,除了 1 秒不超过 100 次之外,我们还可以设置 100ms 不超过 20 次 (这里需要设置的比 10 次大一些),两条规则同时限制,流量会更加平滑。
(3)漏桶限流算法
漏桶限流算法的核心是以固定速率处理请求。我们维护一个“水桶”,水桶大小为capacity,请求以任意速率到来,会存放到水桶中,如果水桶满了,请求就会被拒绝。水桶以固定的速率rate来“流出”(即取出处理)请求,保证了流量非常平滑。当然,具体代码实现巧妙,并不需要显式地维护一个存储请求的“水桶”。
boolean tryAcquire() {
long now = currentTimeMillis(); //获取当前时间
long consumedWater = (now - lastInjectTime) * rate //当前时间减去上次注水的时间 * 流出的速率 = 流出的水量
long leftWater = max(0, leftWater - consumedWater); //之前桶内的水量 - 这段时间流出的水量
if (leffWater + 1 <= capacity) { //水桶内的水量 + 此次注入的一滴水 是否不大于 桶的大小
lastInjectTime = now; //重置注水时间
leftWater++; //水桶水量+ 1
return true;
} else { // 水桶满了
return false;
}
}漏桶限流算法虽然可以让流量非常平滑,但是,也存在一定的问题,就是无法充分利用服务的处理能力。面对突发的流量,服务的处理速度仍然跟平时保持一致,不急不慢,而我们希望的是,在不影响服务稳定性的情况下,尽量快速处理请求,减少请求的响应时间。
当然,漏桶算法也并非无用,如果我们系统调用了一个三方平台的服务,而这个服务要求我们以不超过某种速率的方式调用接口,我们就可以使用漏桶算法来限制请求以一个固定不超过上线的频率来调用。
(4)令牌桶限流算法
令牌桶算法跟漏桶算法正好相反,漏桶算法是定速流出,而令牌桶算法是往桶内定速流入令牌,请求从桶内获取令牌之后才被处理,如果桶内无令牌可取,则拒绝服务。具体的处理逻辑如下所示:
- 接口限制 t 秒内最大访问次数为 n,则每隔 t/n 秒会放一个 token 到桶中;
- 桶中最多可以存放 capacity(即n) 个 token,如果 token 到达时令牌桶已经满了,那么这个 token 会被丢弃;
- 接口请求会先从令牌桶中取 token,拿到 token 则处理接口请求,拿不到 token 则执行限流。
令牌桶算法看似比较复杂,每间隔固定时间都要放 token 到桶中,但并不需要专门起一个线程来做这件事情。每次在取 token 之前,根据上次放入 token 的时间戳和现在的时间戳,计算出这段时间需要放多少 token 进去,一次性放进去,所以在实现上面也并没有太大难度。
boolean tryacquire(String key) {
long now = currentTimeMillis(); //获取当前时间
long generatedToken = (now - lastAcquireTime) * rate // 当前时间减去上次取令牌的时间 * 放入令牌的速率
long leftToken = min(capacity, leftToken + generatedToken) // 之前桶内的令牌数 + 这段时间放入的令牌数
if (leffToken >= 1) {
lastAcquireTime = now; // 重置取令牌时间
leftToken--; //令牌数-1
return true;
} else {
return false;
}
}相较于漏桶算法,令牌桶算法更能应对突发流量。假设桶内有100个令牌,那么,这100令牌可以在突发流量到来时一瞬间被取走,不需要像漏桶算法那样匀速消费。
4. 灵活的限流规则配置
要想实现限流,除了限流算法之外,还需要配置限流规则。限流规则可以配置在本地yaml文件中,也可以配置在配置中心(如果是多机共享相同的限流配置)。
限流应用的场景很多,为了应多各种场景,限流规则需要足够灵活,限流规则一般包含这样几个部分:限流对象、限流接口、限流阈值、处理策略。
(1)限流对象
其中,限流对象又包括全局限流、用户限流、设备限流、IP限流、调用方限流等。全局限流指的是不对任何对象做区分的限流,也就是对所有请求来源的总访问流量的限流;用户限流指的是对单个用户的访问限流;设备限流指的是对访问设备的限流,比如用户可能多设备登录,我们希望限制单个设备而非单个用户的访问;IP限流指的是限制来源IP的访问,避免没有登陆的情况下的恶意访问;调用方限流指的是中台微服务或者开放平台对其他系统(而非直接的普通用户)开放接口使用,在这种场景下,对于每个调用方的请求限流。
(2)限流接口
限流接口指的是对哪个或者哪类接口进行限流。限流接口可以是all,也就是对所有接口的总的访问进行限流,当然,也可以设置细粒度的接口进行限流,比如使用正则表达式或者标签对一组接口总的访问进行限流,当然,也可更细粒度,对单一个接口进行限流。
(3)限流阈值
对于限流时间粒度的选择,我们既可以选择 1 秒钟不超过 1000 次,也可以选择 10 毫秒不超过 10 次,还可以选择 1 分钟不超过 6 万次,虽然看起这几种限流规则都是等价的,但过大的时间粒度会达不到限流的效果,比如限制 1 分钟不超过 6 万次,就有可能 6 万次请求都集中在某一秒内;相反,过小的时间粒度会削足适履导致误杀很多本不应该限流的请求,因为接口访问在细时间粒度上随机性很大。所以,尽管越细的时间粒度限流整形效果越好,流量曲线越平滑,但也并不是越细越合适。
对于访问量巨大的接口限流,比如秒杀,双十一,这些场景下流量可能都集中在几秒内,TPS 会非常大,几万甚至几十万,需要选择相对小的限流时间粒度。相反,如果接口 TPS 很小,建议使用大一点的时间粒度,比如限制 1 分钟内接口的调用次数不超过 1000 次,如果换算成:一秒钟不超过 16 次,这样的限制就有点不合理,即便一秒内超过 16 次,也并没有理由就拒绝接口请求,因为对于我们系统的处理能力来说,16 次 / 秒的请求频率太微不足道了。即便 1000 次请求都集中在 1 分钟内的某一秒内,也并不会影响到系统的稳定性,所以 1 秒钟 16 次的限制意义不大。
对于最大允许访问频率的设置,需要结合性能压测数据、业务预期流量、线上监控数据来综合设置,最大允许访问频率不大于压测 TPS,不小于业务预期流量,并且参考线上监控数据。
(4)处理策略
这里所讲的处理策略,指的是当接口达到限流上限之后,如何来处理接口请求的问题。所谓否决式限流就是超过最大允许访问频率之后就拒绝请求,比如返回 HTTP status code 429 等,所谓阻塞式限流就是超过最大允许访问频率之后就排队请求。除此之外,还有其他一些限流处理策略,比如:记录日志,发送告警,服务降级等等。
同一个系统对于不同的调用方也有可能有不同的限流处理策略,比如对响应时间敏感的调用方,我们可能采用直接拒绝的处理策略,对于像后台 job 这样对响应时间不敏感的调用方,我们可能采用阻塞排队的处理策略。
我们再来看下其他熔断策略的一些应用场景:比如限流功能刚刚上线,为了验证限流算法的有效性及其限流规则的合理性,确保不误杀请求,可以先采用日志记录 + 告警的限流处理策略,通过分析日志判定限流功能正常工作后,再进一步升级为其他限流处理策略。
不同的处理策略对于选择限流算法也是有影响的,比如令牌桶和漏桶算法就比较适合阻塞式限流场景,如果是否决式的限流处理场景,就比较适合选择基于时间窗口的限流算法,避免过于平滑而误杀太多请求。
5. 单机限流和全局限流
按照限流的部署架构,我们可以把限流分为单机限流和全局限流。
单机限流指的是对单台机器的访问进行限流,这个时候,配置可以放置于本地文件中,限流算法依赖的计数器可以在内存中维护。整体来说,不依赖外部系统,实现比较简单,对系统本身的性能和可用性都没有太大影响。
全局限流,也叫做分布式限流。全局限流指的是对提供同一服务的集群进行统一的限流,集群中的多台机器共享流量配额。实现全局限流的方法有很多,一种是依赖API网关,在集群的API网关上做单机限流,就实现了对集群的全局限流。不过,这种方法不能解决所有的场景,比如,如果API网关为了高可用性,多机部署,前置负载均衡(比如Nginx)进行分流,这种架构下,如何实现全局限流呢?当然,聪明的你可能会说,把限流放到负载均衡去做就可以了嘛。理论上可以,但是从代码实现难易程度、灵活性,以及开源限流组件(比如,Google Guava RateLimiter、阿里的Sentinel)的兼容性上来看,并不一定是可落地的合理的选择。
其实,要从根本上解决这个问题,只需要对单机限流做一下改造,将限流规则放置于Redis、Nacos等配置中心,将限流算法中依赖的计数器放置于中心存储器(比如Redis)即可,这样就能实现限流配额的多机共享。
分布式限流算法在引入 Redis 中心计数器这个独立的系统之后,系统的复杂度一下子高了很多,因为要解决一些分布式系统的共性技术问题:
(1)事务问题
接口限流过程包含三步操作:
Step 1:“读”当前的接口访问计数 n;
Step 2:”判断”是否限流;
Step 3:“写”接口计数 n+1, if 接口限流验证通过
在并发情况下,这 3 步 CAS 操作 (compare and swap) 存在 race condition。在多线程环境下,可以通过线程的加锁或者 concurrent 开发包中的 Atomic 原子对象来实现。在分布式情况下,思路也是类似的,可以通过分布式锁,来保证同一时间段只有一个进程在访问,但是引入分布式锁需要引入新的系统和维护锁的代码,代价较大,为了简单,我们选择另一种思路:借助 Redis 单线程工作模式 +Lua 脚本完美的支持了上述操作的原子性。
(2)超时问题
对于 Redis 的各种异常情况,我们处理起来并不是很难,catch 住,封装为统一的 exception,向上抛,或者吞掉。但是如果 Redis 访问超时,会严重影响接口的响应时间甚至导致接口响应超时,这个副作用是不能接受的。所以在我们访问 Redis 时需要设置合理的超时时间,一旦超时,判定为限流失效,继续执行接口逻辑。Redis 访问超时时间的设置既不能太大也不能太小,太大可能会影响到接口的响应时间,太小可能会导致太多的限流失效。我们可以通过压测或者线上监控,获取到 Redis 访问时间分布情况,再结合服务接口可以容忍的限流延迟时间,权衡设置一个较合理的超时时间。
(3)性能问题
分布式限流算法的性能瓶颈主要在中心计数器 Redis,在没有做 Redis sharding 的情况下,基于单实例 Redis 的分布式限流算法的性能,要远远低于基于内存的单机限流算法。所以,在应用分布式限流算法时,一定要考量限流算法的性能是否满足应用场景,如果服务接口的 TPS 已经超过了限流框架本身的 TPS,则限流功能会成为性能瓶颈影响接口本身的性能。除了 TPS 之外,Redis中心计数器与限流服务之间的网络延迟,也会影响服务接口的响应时间。
6. 限流功能有效性验证
这里所说的有效性包含两个方面:限流算法的有效性和限流规则的有效性。在大促,秒杀,或者其他异常流量到来之前,我们需要事先通过实验来验证限流功能的有效性,用数据来证明限流功能确实能够拦截非预期的异常流量。否则,就有可能会因为限流算法的选择不够合适或者限流规则设置不合理,导致真正超预期流量到来的时候,限流不能起到保护服务的作用,超出预期的异常流量压垮系统。
如何测试限流功能正确有效呢?尽管可以通过模拟流量或者线上流量回放等手段来测试,但是最有效的测试方法还是:通过导流的方式将流量集中到一小组机器上做真实场景的测试。对于测试结果,我们至少需要记录每个请求的如下信息:对应接口,请求时间点,限流结果 (通过还是拒绝),然后根据记录的数据绘制成如下图表。从图中,我们可以一目了然的了解限流前与限流后的流量情况,可以清晰的看到限流规则和算法对流量的整形是否合理有效。
除了事先验证之外,我们还需要时刻监控限流的工作情况,实时了解限流功能是否运行正常。一旦发生限流异常,能够在不重启服务的情况下,做到热更新限流配置:包括开启关闭限流功能,调整限流规则,更换限流算法等等。

7. 最后,总结
本节我们讲解了限流。在应对秒杀、大促、双 11、618 等高性能压力的场景时,限流已经成为了标配技术解决方案,为保证系统的平稳运行起到了关键性的作用。不管应用场景是哪种,限流无非就是针对超过预期的流量,通过预先设定的限流规则选择性的对某些请求进行限流拒绝,以保证大部分请求的正常处理和系统的稳定性。
八、降级熔断
前面我们讲到,在微服务架构中,服务之间的调用关系变得非常复杂,调用链路变长,为了保证整体的稳定运行,我们需要完善的服务治理,这其中就包括限流、降级、熔断,上一节课我们讲了限流,这节课我们讲降级和熔断。降级和熔断是两个比较容易混淆的概念,它们两者之间区别也是面试中的常考的知识点。
1. 服务治理之降级
什么是降级?其实,降级有狭义和广义两种理解方式,这里我们先来看下它的狭义的理解方式。
降级指的是在资源不足或者性能压力过大的情况下,为了保证核心功能的正常运行,暂时主动关闭一些非核心功能的一种策略。降级是一个预防性过载保护策略,因为会影响用户的体验,所以,应该在逼不得已的情况下才能被触发,是一个应急措施。这个前面讲到的限流的应用场景是类似的。
比如对于电商系统,面对双十一等大促活动,请求流量会剧增,团队一般会对预期的可能的流量大小做预估,提前对系统进行扩容(部署更多的机器),并通过压测确保系统能承担预期流量,但是,扩容不是无限的,流量预估也不是准确的,压测也不是100%还原真实情况的,当在大促期间,随着访问的加剧,系统资源出现告急,服务出现响应速度慢甚至超时的时候,我们可以将一些非核心的业务暂时关闭,临时只保留核心业务,尽管用户体验会有所损失,但起码保证核心服务基本是可用的。
对于电商系统来说,商品、下单、交易、支付等是核心的业务,评论、退货、退款、商品推荐、积分换购等是非核心的业务,如果核心业务和非核心业务之间有共享资源或者逻辑耦合,比如某些逻辑会共享数据库锁、分布式锁、消息队列、需要保证分布式事务一致性、核心业务逻辑中嵌套非核心业务逻辑等,这个时候,我们就可以将非核心业务在必要的时候暂时关闭执行,这样锁就不会被争抢、消息队列的压力就减小、分布式事务就不需要处理、复杂逻辑简化,大大减少了业务逻辑和技术实现的复杂性,执行更加高效,保证核心业务的正常运行。
为了实现在突发情况到来时,能够做到快速的响应,我们需要在大促之前,提前规划好降级的路径,找到哪些业务和功能可能降级,并提前编写降级之后的代码逻辑,通过开关来控制是否执行降级,当突发情况到来时,我们可以动态打开开关(通过修改配置中心的开关配置),这样就可以快速地实现降级。
2. 服务治理之熔断
在分布式系统中,服务通常不是孤立存在的。想象一下电商平台的场景:用户下单需要调用订单服务,订单服务又依赖商品服务、库存服务和积分服务。在正常状态下,这个调用链平稳运行,每个服务都能快速响应。
但是,当积分服务因为数据库连接池满或者网络延迟等各种原因导致响应变慢时,问题就开始显现。订单服务中调用积分服务的线程会开始阻塞,等待响应。如果这种情况持续下去,订单服务的线程池逐渐被这些阻塞的线程占满。此时,新的下单请求无法得到处理,用户开始感受到系统变慢甚至超时。
更糟糕的是,这种阻塞效应会继续向上蔓延。依赖订单服务的其他服务,比如前端页面或者移动端应用,也会开始受到影响。最终,整个系统就像多米诺骨牌一样,因为一个非核心服务的故障而引发全面瘫痪。这就是典型的雪崩效应,即局部故障经过层层放大,最终导致全局性后果。
解决这种局部问题引发全局雪崩的有效方法就是熔断。熔断具体是如何实现的呢?
我们将执行熔断逻辑的组件定义为熔断器。熔断器有三种不同的状态:打开、半打开、关闭,针对不同的状态,执行不同的逻辑。
最开始,熔断器处于关闭状态,此时,熔断器监控积分服务的接口调用,如果在某一段时间内(比如5秒),调用异常的请求数量或者比例超过某个预先设定的阈值,这些异常情况包括调用失败、超时、慢调用(即接口响应时间超过某个预先设定的值)。此时,熔断器就进入打开状态,在接下来的一段时间(比如10秒内)不再允许调用请求积分接口,而是执行其他预先设置的逻辑,比如将下单之后增加积分的事件暂时记录日志或者推送消息队列,以便稍后补充执行。
考虑到积分服务可能在一段时间之后会恢复,熔断器在10秒的熔断时间之后,进入半打开状态,尝试少量放行对积分服务接口的调用,如果请求还是有问题,直接再次熔断,即再次进入打开状态;如果少量请求执行没问题,会全量放开访问,即进入关闭状态。根据接口请求的执行情况,熔断器会在三个状态之间不停转换。
跟降级类似,熔断也是迫不得已的应急策略,也需要提前找到可以熔断的业务(例如,上述的积分业务就可以熔断,订单、支付是核心业务可能就不适合),并且需要编写熔断后的备选执行逻辑,当然,上述讲到的熔断器并不需要我们从零实现,市面上有很多成熟的现成的组件,比如阿里开源的Sentinel、Netflix Hystrix、Ressilence4J等。
3. 降级和熔断的区别和联系
降级和熔断都是保护性的应急措施,都需要事先梳理业务,找出可以降级或者熔断的业务,并编写备选执行逻辑,一般来说,可降级或者熔断的都不是核心的业务,或者说是可以接收备选逻辑的。降级和熔断的不同的地方是,降级往往是手动触发执行,熔断往往是自动触发执行。
前面提到,降级有两种理解方式:狭义和广义,实际上,广义上来讲,降级就是程序执行备选业务逻辑的,是一个非常宽泛的技术概念,并不是一个具体的技术实现。
如果按照广义的定义,熔断也算是一种降级,因为熔断之后,需要执行备选逻辑,比如返回空数据,返回临时不可用提示,或者刚刚讲到的记录日志或推送消息队列,这其实也是一种退而求次的降级策略。其实,上一节中讲到的限流,也可以看是一种降级,当流量超过预期之后,降级的策略变为拒绝请求或者阻塞排队。
除此之外,在前面的章节中,我们讲到,限流使用Redis做中心计数器存储,当Redis访问出错或者超时时,我们可以临时将限流功能暂停,保证不会因为限流组件本身的可用性而影响业务系统的可用性,这其实就是一种降级策略;还有,如果系统从配置中心读取配置时,获取失败或者超时,这时可以从本地配置中获取,这也是一种降级策略。
4. 最后总结
限流、降级、熔断是保证系统稳定运行的三板斧,特别是应对大促等突发流量的情况。它们都是不得已而为之的临时应急策略,并非常态的处理方案,不管是限流,还是降级、熔断,都会影响到用户体验,因此,我们应该做好系统监控,及时发现它们的触发原因,在渡过流量突发期之后,进行复盘、优化、扩容,避免下次大促再次触发这些应急方案。
九、链路追踪
在微服务架构取代单体架构成为主流之后,系统的复杂度呈指数级增长。一个外部请求可能需要调用数个甚至数十个服务,其调用路径错综复杂。当某个请求变慢或失败时,如何去定位问题呢?查看海量的日志去查找问题,如同大海捞针,效率低下,因为单纯通过业务ID(比如user ID、order ID),你很难搞清楚当前的请求对应日志文件中的哪些日志,一点一点的去梳理日志,排查问题又慢又费劲。因此,我们就需要一个东西,能将某个请求的所有的调用链路串联起来,当后期debug问题的时候,基于它也能够快速的查找到这个请求所对应的所有调用链路的信息。这个东西就是我们今天要讲的调用链追踪系统。
1. 需求背景
其实,即便是单体架构,也是需要调用链追踪,只不过是服务内部的调用链追踪。
为了方便在请求出错时排查问题,我们在编写代码的时候,会在关键路径上打印日志。某个请求出错之后,我们希望能搜索出这个请求对应的所有日志,以此来查找问题的原因。而实际情况是,在日志文件中,不同请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,我们就无法关联同一个请求的所有日志。
当然,单体架构下的调用链追踪,实现起来是比较简单的,我们可以给每个请求分配一个唯一的Trace ID,并且保存在请求的上下文(Context)中,比如,处理请求的工作线程的局部变量中。在 Java 语言中,我们可以将Trace ID 存储在 Servlet 线程的 ThreadLocal 中,或者利用 Slf4j 日志框架的 MDC(Mapped Diagnostic Contexts)来实现(实际上底层也是基于ThreadLocal实现)。
当服务端接收到请求之后,生成唯一的ID并写入线程的ThreadLocal中。在后续的业务逻辑中,每次打印日志的时候,我们从ThreadLocal中取出请求的TraceID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的Trace ID 信息,我们就可以通过Trace ID 来搜索同一个请求的所有日志了。
不过,微服务架构下,跨多个服务的调用链路追踪,就要复杂一些了,但是,基本原理大同小异。市面上也有很多成熟的调用链路追踪框架,我们可以直接拿来用,比如SkyWalking、Zipkin、Jaeger、Spring cloud Sleuth等。当然,我们不会教你如何去使用它们,这个你可以自己去实践。本节课,我们重点带你剖析一个调用链追踪系统底层是如何实现的,让你知其然知其所以然,也方便你去阅读开源项目的源码和应对技术面试。
2. 链路信息
其实,现在的大多数调用链追踪系统的技术实现方案,都是参考了Google在2010年发表的论文:《Dapper,一个大规模分布式系统的跟踪系统》,它定义了两个核心的概念:
Trace:一条完整的调用链。比如,处理一次用户下单请求,从网关入口,到订单服务、库存服务、支付服务,再返回,这一整条路径就是一个 Trace。每个 Trace 都有一个全局唯一的 Trace ID 来标识,可以由UUID、Snowflake雪花算法等生成,只要保证唯一即可!
Span:表示调用链中的每个节点,当然,这里的节点不仅仅是微服务,也可以是数据库、Redis、消息队列等。每个 Span 都有自己的 Span ID 来唯一标识,并且还包含上一个调用方的Span ID,即Parent Span ID,以及其他一些必要信息。
一个Trace由多个Span组成,它们通过Trace ID关联,并通过Parent Span ID来组成一棵树形结构。你可以联想一下数据结构中的“树”。注意,这里是树形结构而非链表结构,是因为一个服务可能调用多个其他服务。
为了让你有一个更直观的了解,我们对Span做了一个简单的代码实现,如下所示。
public class Span {
// 核心元数据
private String traceId; // 全局唯一的Trace ID
private String spanId; // 当前Span的唯一ID
private String parentSpanId; // 父Span的ID。如果为null,则是根Span。
// 描述信息
private String name; // Span的名称,例如 "GET /order"
private String serviceName; // 服务名
// 时间信息
private Long startTime; // 开始时间戳(微秒)
private Long duration; // 持续时间(微秒)
private Long endTime; // 结束时间戳(微秒)
// 其他标签信息,用于后续分析
private Map<String, String> tags = new HashMap<>();
// 构造器、getter、setter 省略...
// 一个有用的方法:计算并设置持续时间
public void stop() {
this.endTime = System.currentTimeMillis() * 1000; // 转为微秒
this.duration = this.endTime - this.startTime;
}
}我们需要注意的是,调用链信息跟服务内日志是完全两回事的,如果你希望服务内的日志也复用调用链中的Trace ID和Span ID,方便通过调用链找到发现出错服务之后,再通过服务内日志定位问题,那么,我们就可以借鉴Spring Cloud Sleuth的做法,将 TraceId 和 SpanId 集成到 SLF4J 的 MDC (Mapped Diagnostic Context) 中,这样你在业务逻辑中打印日志时就能直接带上TraceId和SpanId这些信息。
3. 上下文传递
在整个调用链路中,很多信息是需要从上往下逐层传递的,核心的有Trace ID(用于串联整个链路)和 Span ID(作为下层服务的Parent Span ID)。那么,这些信息是如何在不同的服务间传递的呢?
当一个请求到达API 网关时,其集成的调用链追踪组件会发起一个Trace,即创建第一个 Span(也叫根 Span),并生成 Trace ID。之后请求每经过一个服务,该服务集成的调用链追踪组件都会完成以下操作:
- 解析上下文:从 incoming request(比如 HTTP 请求头)中尝试提取 Trace ID 和 Parent Span ID。如果有,说明自己不是链路的起点。如果没有,说明自己是链路的起点!
- 创建新 Span:为本服务的处理过程创建一个新的 Span(包含Span ID),其 Trace ID 与上游传来的保持一致,其 Parent Span ID 则指向来源服务的那个 Span ID。
- 记录信息:在这个 Span 的执行期间,记录开始时间、结束时间、以及其他必要信息。
- 传递上下文:在调用下一个服务时,必须将当前的 Trace ID 和自己这个新创建的 Span ID(作为下游服务的 Parent Span ID)通过某种方式携带过去(比如HTTP请求头)。
- 上报数据:在本 Span 结束时,将封装好的 Span 数据异步、批量地上报给调用链追踪后端系统(上报工作待会详细讲解)。
传递上下文的方式通常有两种:
- 通过 HTTP 请求头传递:对于基于HTTP 协议的接口调用,通常会使用约定好的 Header来传递上下文信息,比如 X-B3-TraceId 和 X-B3-SpanId(这是 Zipkin 使用的标准)。A 服务在调用 B 服务时,通过 HTTP Client (比如OpenFeign、OkHttp)将这些信息设置到请求头中。B 服务再从头里解析出来。
- 通过 RPC 上下文传递:对于 RPC 调用(如 gRPC、Dubbo),框架本身通常都有提供了“上下文”的概念,追踪信息可以直接注入到 RPC 的上下文对象中,在RPC调用时传递。
为了让你更加直观的理解上述上下文传递过程,我们基于SpringBoo项目,编写了一个示例代码,如下所示。
- 入口拦截器(接收请求)
我们需要一个 Spring HandlerInterceptor 来处理 incoming request。
@Component
public class TraceWebInterceptor implements HandlerInterceptor {
@Autowired
private Tracer tracer; //Tracer的定义在后面
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 尝试从HTTP头中提取追踪信息
String traceId = request.getHeader("X-Trace-ID");
String parentSpanId = request.getHeader("X-Parent-Span-ID");
// 2. 如果头部没有(说明是链路起点),则生成新的;有则沿用。
Span span;
if (traceId != null) {
// 我是一个子Span,继承上游的TraceId和ParentSpanId
span = tracer.createSpan(request.getRequestURI(), traceId, parentSpanId);
} else {
// 我是根Span,生成全新的TraceId
span = tracer.createRootSpan(request.getRequestURI());
}
// 3. 将当前Span设置到上下文中,以便后续业务逻辑使用,此部分后面讲解!
TraceContext.setCurrentSpan(span);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求处理完毕,结束当前Span并上报
Span currentSpan = TraceContext.getCurrentSpan();
if (currentSpan != null) {
currentSpan.stop();
// 可以记录一些额外信息,比如HTTP状态码
currentSpan.getTags().put("http.status_code", String.valueOf(response.getStatus()));
if (ex != null) {
currentSpan.getTags().put("error", ex.getMessage());
}
// 上报数据
tracer.reportSpan(currentSpan);
// 清理上下文,防止内存泄漏
TraceContext.clear();
}
}
}其中,TraceContext用来保存Span的信息,以供后续使用(如上述代码中的afterCompletion)。
public class TraceContext {
private static final ThreadLocal<Span> currentSpan = new ThreadLocal<>();
public static Span getCurrentSpan() {
return currentSpan.get();
}
public static void setCurrentSpan(Span span) {
currentSpan.set(span);
}
public static void clear() {
currentSpan.remove();
}
}- 出口拦截器(发起请求)
当我们的服务使用 RestTemplate 调用其他服务时,需要自动将当前上下文信息,注入到 HTTP 头中。我们可以通过 ClientHttpRequestInterceptor 来实现。
@Component
public class TraceRestTemplateInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
Span currentSpan = TraceContext.getCurrentSpan();
if (currentSpan != null) {
// 将当前的追踪信息注入到请求头中,传递给下游服务
request.getHeaders().add("X-Trace-ID", currentSpan.getTraceId());
request.getHeaders().add("X-Parent-Span-ID", currentSpan.getSpanId());
}
return execution.execute(request, body);
}
}上面的代码逻辑中使用到Tracer类,它负责创建Span和上报数据,具体如下所示:
@Component
public class Tracer {
@Value("${spring.application.name}")
private String serviceName;
// ID生成器,可以用更复杂的方式,比如Snowflake,这里简单用UUID
public String generateId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
public Span createRootSpan(String name) {
Span span = new Span();
span.setTraceId(generateId());
span.setSpanId(generateId()); // 根Span的SpanId也是新生成的
span.setParentSpanId(null); // 根Span没有父Span
span.setName(name);
span.setServiceName(serviceName);
span.setStartTime(System.currentTimeMillis() * 1000); // 微秒
return span;
}
public Span createSpan(String name, String traceId, String parentSpanId) {
Span span = new Span();
span.setTraceId(traceId); // 继承TraceId
span.setParentSpanId(parentSpanId); // 继承ParentSpanId
span.setSpanId(generateId()); // 自己的SpanId是新生成的
span.setName(name);
span.setServiceName(serviceName);
span.setStartTime(System.currentTimeMillis() * 1000);
return span;
}
public void reportSpan(Span span) {
// 这里是上报逻辑。绝不能同步阻塞主业务线程!
// 我们应该将Span放入一个阻塞队列,然后由一个单独的线程池消费队列,批量上报。
// 此处为演示,简单打印日志。
System.out.println("[Reporting Span]: " + JSON.toJSONString(span));
}
}4. 低侵入性
低侵入性是任何调用链追踪组件能否成功在项目中落地使用的关键。如果组件的侵入性高,引入组件会显著增加开发和维护成本,那么就会遭到开发者的反感。相反,如果组件的侵入性低,引入组件不需要破坏原有的代码逻辑,集成简单,那么,就会受到开发者的欢迎。
像刚刚我们举的示例代码,将链路追踪的逻辑集中放置于Interceptor或者Filter中,就是一种低侵入的实现方式。其实,很多框架都提供了Interceptor或Filter的开发模式,比如,Spring中的HandlerInterceptor、Servlet Filter、RestTemplate的ClientHttpRequestInterceptor、Feign 的 RequestInterceptor、OkHttp 的 Interceptor,以及Dubbo、gRPC等RPC框架,也都提供了Filter或者Interceptor,借助它们,我们可以轻松低侵入地集成链路追踪功能。
比低侵入更进一步,其实,我们还可以实现无侵入的调用链追踪,依赖的核心技术就是字节码增强,SkyWalking就使用了这种技术。借助它,我们可以无需修改任何代码,就能接入调用链追踪。
我们在启动应用时,通过 Java Agent 技术(-javaagent:/path/to/agent.jar)将一个代理 Jar 包挂载到目标 JVM 上。这个代理利用 Javassist 等字节码操作工具,在类加载阶段,动态地修改特定类的字节码。它会识别并注入追踪逻辑到关键方法中(例如所有 Servlet 的 service 方法、所有 JDBC 的 execute 方法、所有 Spring MVC 控制器的方法等),注入的逻辑会帮你完成:创建 Span、记录时间、上报数据等所有工作。
当然,这种方法实现起来要比较复杂,首先要掌握字节码的开发技术,另外还需要对市面上开发工程师可能用到的不同版本的框架、库(比如Servlet、JDBC、Spring MVC、Dubbo、Redis Client、Kafka Client等)去识别增强点(即基于哪个方法去做字节码注入),然后编写增强逻辑。
5. 采样策略
接入调用链追踪系统并不是没有代价的,也要承担性能开销和存储成本。对于一个访问量不大的中小型系统来说,记录每个请求的调用链是没有问题,但是,对于访问量巨大的大型应用来说,日处理上亿甚至百亿的请求,每个请求又要经过数个甚至数十个微服务,如果全量采集、上报、存储追踪数据,其产生的网络流量、性能损耗,可能会成为压垮业务系统的瓶颈,对存储、索引、查询也会带来巨大的技术挑战。
为了解决一个问题而引入更大的技术挑战,显然是不合理的,因此,调用链追踪系统都支持灵活的采样策略,可以按照某种策略选择性(比如按照10%的采样概率去采样或者每秒限制采样1000个请求)的去采集、上报、存储一部分请求的调用链追踪数据。当然,这也会导致当我们查询某些请求对应的调用链路的时候,可能查不到!关于这个问题的解决,我们待会会讲。。
不同的调用链追踪系统,支持不同的采样策略,我们先来看两种主流的,他们分别是头采样和尾采样。
头采样指的是在链路的入口决定是否给这个请求进行采样,如果决定采样,在Span中添加标记sampled=true,并传递给下一个服务继续采样;如果决定不采样,在Span中添加标记sampled=false,并传递下一个服务继续保持不采样。头采样实现简单,但是,是否采样的决策是在最开始做的,这就可能导致一些本应该记录的异常调用链而没有被记录,比如请求在某个服务中处理异常或者很慢。
尾采样指的是在链路全部采集完成之后,都上报到一个聚合节点,系统再根据整个Trace的全局特性(总耗时、是否有错误、是否包含关键服务)来决定是否将其持久化到存储中。我们可以对于一些处理报错、耗时超过阈值、甚至是特定的接口(比如一些重要接口)的调用链,进行百分之百采样,其他调用链按照概率来采样。这样就不会丢失有价值的调用链,方便后续定位问题。
但是,尾采集也有问题,就是可能要做很多无用功,而且还要有集中做裁决的中心器,性能损耗大,实现复杂。因此,我们可以采取这种的采样策略。
- 对于调用链入口服务进行100%采样,但是按照设定的概率,选择部分请求在后续服务中继续采样,而部分请求在后续服务中不再采样。这样做是保证每个链路在搜索的时候都能搜到,并且知道大致的情况。
- 如果后续某个服务处理报错或者耗时超过阈值,又或者是重要接口,即便其sampled设置为false,我们仍然会将其重置为true,并采集剩下的链路信息。当然在此之前的部分链路信息就采集不到了,但这影响不大。毕竟,我们还可以通过Trace ID和Span ID去全量的日志中查询完整的案发现场。
通过这种采样策略,我们可以在可控的成本下,确保那些最重要的、最能帮助我们排查问题的信息得以保留,从而不再惧怕用户反馈问题时“查无此证”的尴尬局面。
6. 异步上报
每个服务采集的信息都会上报到中心存储系统(Collector),上报操作一般都异步进行,这样才能尽量减少对服务本身的性能损耗和稳定性影响。异步上报基于生产者-消费者模型。生产者先将数据放置于一个内存中的队列(比如Java中的BlockingQueue),消费者是一个后台线程,会不断地从队列中取出数据,并批量地通过网络发送到Collector。Collector负责接收数据,并写入持久化存储系统。
当然,为了解耦服务与Collector,很多调用链追踪系统的Agent或SDK,也提供了将数据发送到消息中间件(比如Kafka)的工作模式,Collector再从消息中间件中读取并写入持久化存储系统。这样,Collector 集群的维护、升级、扩容都不会影响 Agent 端的数据上报,而且,数据在消息队列中会持久化一段时间,即使 Collector 短暂宕机,数据也不会丢失,恢复后可以继续消费。
Collector 收到数据后,需要将其持久化到某个存储系统中,以供 UI 查询。存储系统的选择非常重要,需要支持海量数据的存储、方便的扩容策略、对分布式部署的天然支持,以及强大的索引、聚合分析、搜索能力,支持按照Trace ID或者时间段、服务名、关键词等进行搜索。因此,SkyWalking、Zipkin、Jaeger 都首选推荐 Elasticsearch,当然也支持比如MySQL、TiDB等数据库。
很多调用链追踪系统都提供了UI界面,支持对存储系统的各种查询操作。需要说明的是,Spring Cloud Sleuth只是一个采集上报的Agent,Collector、存储、UI界面需要依赖Zipkin等其他链路追踪系统。
7. 最后总结
这节课我们讲到了调用链追踪系统的核心实现原理,包括链路信息的采集、上下文传递、异步上报、存储,以及低侵入性和采样策略,掌握了以上理论知识,再去阅读或者学习特定调用链追踪系统的具体实现原理,就可以更加清晰、更加有全局观。
