从单体——微服务

近年来微服务架构的兴起也带动了软件架构设计的热点,是否采用微服务架构,如何设计,都给业务实践中带来了许多需要思考的问题。

单体架构带来的问题是显而易见的,在目前业务使用者不断增多的网络世界,架构设计更多需要考虑应用的健壮性,如何抗住大量应用访问,如何在高并发的业务场景下保持核心应用不崩溃,这些问题都指出了单体应用架构的不足。

但是是否应该直接使用微服务呢?如何从单体架构过渡到微服务架构?微服务架构又会面临哪些问题和不足?如何解决?这是我们本文需要思考和探究的问题。

微服务不可避免会遇到分布式调用,那么面临的:服务器宕机、网络丢包、重试等问题,就需要策略去解决。

单体架构

我们给出这样的一个业务场景:单体架构应用已经部署好了,但是由于业务需要,我们需要临时对该应用做策略优化,并且重新部署。但是由于业务系统的重启会导致用户在访问的时候丢失数据,无法服务,于是考虑在用户量使用比较少的时期去重启。但是由于没有完善的日志监控服务,则只能通过tomcat中间件的日志信息看来判断,于是如何做好系统重构,实现无感更新版本就成为我们需要探讨的问题。

Nginx应用无状态

首次重构,我们考虑使用Nginx做反向代理和负载均衡,新增两组应用服务器,实现负载均衡并解决单点问题。

新问题:已登录用户操作过程中会突然变为未登录状态,因为session信息存放在应用服务器内存中,新增Nginx后负载均衡把信息按照策略发往两台应用服务器,其session信息可能出现未能命中目标服务器的情况。

解决方案:用户登陆后session信息从内存转移到redis缓存,应用层增加拦截器处理session信息,实现多台服务器共享用户的session信息。

这样就可以解决无感更新的问题。

数据读写分离

场景:由于业务访问量增加,MySQL数据库的CPU占用比很快就会打满,通过客户端执行show full processlist查询命令,查询出大量慢查询SQL命令(可以参照我们上篇博文做一下优化),但是数据库仍旧会是整个单体应用的瓶颈。

MySQL单机模式调整为主从模式,用MyBatis进行多数据源配置,由业务端通过拦截器来控制查询或者写入的目标数据库。

新问题:引出了由主从数据库同步延时导致数据暂不可见的问题,需要等主从同步完成后,才能显示用户数据。

解决方案:1、加硬件;2、读/写业务开关:通过写入时对redis写入标志,查询时对redis先查询是否写入,如有则可能出现主从同步延时,直接查询主数据库;3:强制读主数据——但是这部分需要对业务切分,仅让核心业务有这个操作就行。

分库分表

随着业务量的急剧上升,相当多的用户数据还需要保存,则会造成一定的I/O问题,需要考虑分而治之的思路,实现分库分表。

一般建议根据业务情况,使用数据库水平切分的方式,但是重构时,需要将MyBatis的配置文件中大量的多表join查询语句,在短时间内全部改为单表查询,所以重构火葬场不是说说而已。

当现有单体架构无法满足产品快速迭代,快速上线,且通过增加研发人员数量、提高服务器硬件等方式均无法解决产品在上线、更新的问题时,就需要考虑微服务架构了。

选型

这里采用Dubbo来做示范,当然市面上还有大量其他的技术架构,仅供参考,选择框架时需要考虑以下技术点:

  • 服务发布订阅:是自动发现注册,还是手动在线注册
  • 服务路由形式:框架中支持的服务路由(如随机路由)是否满足业务需求,是否支持自定义路由
  • 集群容错:集群容错支持的方式,比如快速失败、失败自动切换等常用的容错方式
  • 调用方式:服务调用方式是否支持同步、异步以及并行调用
  • 通信协议:是否满足业务需求,是否支持自定义协议
  • 序列化方式:是二进制序列化还是以文本方式序列化

Dubbo三大核心能力:

  1. 面向接口的远程方法调用
  2. 提供容错和多种负载均衡策略
  3. 服务自动注册和发现功能。

其利用Spring Boot简化了分布式系统基础设施的开发:服务发现注册、配置中心、消息总线、负载均衡、熔断器、数据监控等特性可做到一键部署和启动。

重构需要注意的问题:

  1. 接口参数全靠猜:接口的入参和出参存在多种参数类型,具体的参数传入需要做好接口文档。
  2. PO对象透传:VO(值对象)、PO(持久层对象)、DTO(数据传输对象)概念混淆
  3. API依赖包太大,因为提供了大量的第三方包,容易出现jar包冲突
  4. 服务拆分错误:按照业务实际进行拆分,不要按照接口级,不然会过于复杂,有些应用需要聚合。
  5. 存在打量的join查询:即便表面上提供了API接口,但是在实现具体业务逻辑时仍旧使用了打量的SQL join查询语句
  6. 不清楚如何交付API:系统学习Maven,使用多模块的方式构建项目。

服务拆分与工程拆分

在微服务架构下进行开发和设计尤其要注意:

  • 一份基准代码多分部署:程序部署做到和环境无关
  • 显式声明依赖关系:通过依赖清单,明确声明所有依赖项。
  • 在环境中存储配置:代码和配置严格分离,配置可以完全不一样,但是代码必须一致
  • 后端服务(程序运行需要通过网络屌用的服务:数据库、MQ、缓存等)视为资源,将其作为第三方服务。
  • 分离构建和运行:构建为可执行包,发布和配置结合投入使用,回滚考虑选定版本启动进程。
  • 应用程序无状态运行:任何需要持久化的数据需要存储到后端服务中,而应用程序采用无状态形式运行。

标准化工程

将重复的事物和概念都标准化,比如:创建工程、代码命名方式、工程启动方式等标准化。

例如:用工程名-service格式表示具体服务名称,用TableName(Read/Write)Service格式定义类名,从而达到见名知意的效果。

自动化部署:从传统手动到写Shell脚本命令批量自动发布服务,到最后自动化部署的过程。

微服务优势:降低复杂度、独立部署、容错、可扩展。

服务拆分角度和原则

服务若拆分过细,服务数量数量爆炸,导致开发复杂度和运维复杂度都会增加。

服务若拆分过粗,会因为服务提供的接口频繁变更,导致一次需求迭代影响众多服务,使故障范围变大。

拆分原则:高内聚与低耦合、业务模型、按读/写模式拆分、演进式拆分、阶段性合并。

高内聚与低耦合:高内聚指的是每个服务应尽可能只完成一件事,低耦合指的是减少外部服务依赖,避免一个服务因实现某个功能而调用多个服务。例如每个服务都需要调用数据库服务,则需要通过接口来调用,杜绝外部服务通过直连数据库的方式获取数据。

业务模型:将相关业务聚合在同一个服务中,避免跨数据库引发的数据一致性问题,减少调用外部服务次数。

按读/写模式拆分:业务量上升后,将同一类型服务的读和写操作拆分到不同服务上,保障服务稳定性。

演进式拆分:业务发展后频繁变更的个性化业务拆分服务。

阶段性合并:接上述,如果不需要频繁变更或者很少调用的服务可以考虑合并。

模块通过多表联合查询方式的,需要更改为每个服务都配置独立的库、独立的表进行拆分,同步将内部接口调用换位RPC调用,将SQL的join查询拆分为单表查询,在业务系统中做数据合并操作。

框架自动化

工程采用Maven的多模块方式创建:

  • api:接口层。外部服务依赖该接口提供的服务,该接口层依赖model模块
  • service:接口实现层,实现API,同时包含DTO和PO互转的方法。其依赖api、business和facade模块
  • business:涉及与数据相关的处理,包含MyBatis的sqlMap的配置文件和PO对象。
  • model:定义DTO对象,根据数据库表字段的描述自动增加注释
  • facade:如果需要调用外部服务,则统一在此调用。

数据请求模型

将请求模型拆分为两层:聚合层、原子服务层

  • 聚合层:为客户端提供统一接口的服务层,用于编排该接口所需依赖的原子服务:web、business、facade、model
  • 原子服务层:屏蔽数据库操作,提供RPC接口:单表原则——禁止对两张以上的表做join操作,不要包含任何业务系统逻辑,对外屏蔽分库分表操作,关注接口性能。

日志收集和控制

由于微服务的原子服务调用情况复杂,但是可以形成调用链,寻常情况下如果请求接口异常,逐个登录服务器查询日志分析问题会很麻烦,需要考虑聚合日志进行分析。

一般规范日志格式、使用统一记录日志工具,或者开源的ELK日志收集平台,如果对个性化业务报警和预警有需求,可以考虑自定义日志处理平台——Flume+Kafka+流计算+规则引擎。

微服务模式开发

分布式系统之间通过RPC方式进行通信,我们如何解决RPC框架中存在的问题?

核心概念

分布式系统:使用多台计算机去协同解决单台计算机所无法解决的计算、存储等问题,分布式系统中的每台机器都负责解决原问题中的一个子集。

横向拆分:在无状态系统中多部署几个实例,通过负载均衡方式协调每个实例负载的计算量。

纵向拆分:将一个大应用拆分为多个小应用,每个小应用负责处理一部分业务。

分布式技术会遇到更多的一些问题:网络异常、数据一致性、分布式系统性能等。

常见异常:

  • 服务器宕机——注意单点问题和无感切换
  • 网络异常——通信问题引起的网络分化、消息乱序等
  • 分布系统的三态——设计架构时考虑成功、失败、超时(未知)这三种状态的处理方式
  • 存储的数据丢失——状态丢失时从其他节点读取、恢复该存储数据的状态。

分布式系统的副本分类:为数据或者服务体提供的冗余,分为服务副本和数据副本两种类型。

服务副本:多节点提供某种相同的服务,不依赖本地节点的存储状态,是无状态服务。

数据副本:在不同节点上持久化同一份数据,但是由于数据分散不同机器,如何保证数据一致性成为难题。

数据一致性:

  • 强一致性:任何时刻用户或节点都可以读到最近一次成功更新的副本数据,最难实现。
  • 弱一致性:系统不保证进程或者线程在任何时刻访问数据都会返回最新的更新过的值。
  • 最终一致性:数据一旦更新成功,各个副本上数据最终将达到完全一致的状态,但需要一定时间。

设计原则:异构性、透明性、并发性、可扩展性、故障独立性、数据一致性、负载均衡。

RPC框架工作原理:向服务调用方和服务提供方屏蔽各类复杂性操作:负载均衡、序列化和反序列化、网络重试、超时等。主要由:客户端、服务器端、注册中心三种角色构成。

注册中心:统一管理每个服务所对应的URL地址的系统。优势在于解耦了服务提供者和服务消费者之间的关系,并且支持弹性扩容和缩容。

服务治理:解决服务注册、服务发现、负载均衡、流量削峰、版本兼容、服务熔断、服务降级、服务限流等由服务拆分引发的问题。

服务注册与发布:服务实例在启动时被加载到容器中,并且使用心跳机制定期刷新当前服务在注册中心的状态,确认服务状态。一般分为:自注册模式和第三方注册模式。

  • 自注册模式:服务实例负责在服务注册表中注册和注销服务实例,缺点是每种编程语言和框架内部需要实现注册代码
  • 第三方注册模式:通过集中化管理的服务进行查询订阅追踪运行服务的变化,缺点是需要一个高可用系统来支撑。

服务发现:使用注册中心记录分布式系统中全部服务信息,便于其他服务快速找到这些已注册的服务。分为:客户端发现模式和服务器端发现模式两种。

客户端发现模式:客户端从服务注册服务中查询可用服务实例地址,然后负载均衡选择一个,发起请求。可自定义负载均衡策略,并且压力都在客户端。

服务端发现模式:通过负载均衡器去实现,客户端无需关注发现细节,但是服务端需要配置一个高可用的负载均衡器。

流量削峰:使用一些技术手段来削弱瞬时的请求高峰,让系统吞吐量在高峰请求下可控,也可用于消除毛细,是服务器资源的利用更加均衡、充分。策略:队列、限额、分层过滤、多级缓存等。

版本兼容:更新后新的数据结构是否可理解和解析旧数据,新协议是否可理解旧协议并且给出预期处理。

服务熔断:当服务出现不可用或响应超时,已经达到系统设定阈值,暂停对该服务的调用。

服务降级:在服务器压力剧增情况下,根据当前业务和流量对一些服务页面有策略的降级,释放服务器资源,保证核心任务的运行。

注册中心

针对Dubbo框架,注册中心可用考虑选择使用ZooKeeper或者Nacos。

ZooKeeper是一个开源的分布式协调服务,可用于实现分布式系统中常见的发布/订阅、负载均衡、命令服务、分布式协调/通知、集群管理、Master选举、分布式锁和分布式队列等功能。运行模式:单机模式和集群模式(要求服务器奇数个)。

ZooKeeper将数据全量存储在内存中,以此来实现自身的高吞吐量,减少访问延迟。

四大特性:原子性、单一视图、可靠性、实时性。

有事件监听器机制:允许用户在指定节点上针对事件注册监听器,事件触发监听器后将事件信息推送到客户端。

使用场景:

  • 数据发布/订阅:将ZooKeeper包装为分布式配置中心,发布者将数据发布到ZooKeeper的一个或一系列节点上,供订阅者进行数据订阅,达到动态获取数据目的。
  • 分布式协调/通知:基于临时节点特性,不同机器在ZooKeeper指定节点下创建临时子节点,根据该节点判断机器存活状态。
  • 分布式锁:控制分布式系统之间同步访问共享资源的一种方式,互斥防止不同系统或者同系统不同主机之间共享资源的数据一致性问题。

编程方式:使用Curator封装好底层后,集成可调用API框架使用。

Nacos是用于构建云原生应用的动态服务发现、配置管理和服务管理平台,提供控制台帮助运维管理所有服务和应用的配置。具备可视化操作界面、集成动态配置和服务管理的平台。具有:单机模式、集群模式和多集群模式。

集群模式下,需要做好配置;且对MySQL数据库也要导入配合的数据,同时修改Nginx的配置文件,让其负载均衡可用保证Nacos的高可用。

这里不展示具体的Provider配置与发布、Consumer的配置等具体的代码配置工作。

负载均衡策略选择

Dubb提供了4种均衡策略,分别是:随机策略、轮询策略、最少活跃调用数策略、一致性Hash策略,默认设置是随机策略。这里不提供源码分析,浅谈一下这四种策略优缺点和使用场景。

随机策略:从给定的服务提供者列表种随机挑选一个服务进行负载操作,强调随机性。分为没有可控性的普通随机策略和可用调整权重改变随机性的加权随机策略(基于不同机器的硬件性能不同改变权重)。

轮询策略:服务器轮流处理请求,尽可能使得每个服务器处理的请求数都相同。分为普通轮询策略和加权动态轮询策略。因为受到网络问题或者服务器响应问题,普通轮询策略容易受到影响,可用在每次接收请求时都更新动态权重,重新选择负载服务器的加权动态轮询策略。

最少活跃调用数策略:服务提供者当前正在处理的请求数(一个请求对应一条连接)最少,表明该服务提供者的效率高,并且在单位时间内可处理更多的请求。此时应优先将请求分配给该服务提供者。通过活跃数判定请求提供。

一致性Hash策略:为保障相同参数的请求总是被发到同一提供者,通过引入虚节点来保证在节点的数量变化时,原来请求分配到哪个节点,现在仍应该分配到那个节点。

Dubbo常用特性

Dubbo提供的RPC框架提供了很多特性:服务多版本管理、上下文信息等。

服务多版本管理涉及服务开发、部署、在线升级和服务治理等环节,发布服务的时候可用指定版本。

上下文存放的是当前调用过程种所需的环境信息,所有配置信息都将被转换为URL的参数。

SPI是一种服务发现机制,本质上是将接口实现类的全限定名配置在文件中,并由服务夹再器读取配置文件。加载实现类一般用于启用框架扩展或替换组件。

使用Java SPI机制的优势是实现解耦:使得第三方服务模块的加载配置逻辑与调用者的业务代码分离,可根据业务情况来扩展框架或替换框架组件。缺点是:只能通过遍历方式全部获取所有的实现类,且接口的实现类会全部加载且实例化一遍,无法根据条件来加载实现类,造成浪费。另外获取某个实现类的方式也不够领获,无法根据某个参数来获取对应的实现类。

所以Dubbo不选用原生的SPI来实现,而是改造后可根据名称获取某个所需的扩展实现,同时增加了对扩展点IOC和AOP的支持。

Dubbo SPI可用通过键值对的方式进行配置,按需加载指定的实现类。其源码实现这里不赘述,主要利用了反射机制。

Dubbo官方还提供了大量的Filter,其应用场景为:traceID追踪、权限认证、黑白名单、性能监控等,通过Filter的扩展可有效将场景和业务分离。

  1. 调用链追踪:为方便排查聚合层将请求拆分到多个服务器的问题,网关会给每次请求分配唯一的traceID,但是实际上这个业务需要分离出来,可用使用自定义Filter结合日志的MDC(日志跟踪工具)方式来解决问题。
  2. 权限认证:通过颁发密钥,设置可访问的时间范围等策略来控制核心服务的调用,设置ProviderAuthFiler来拦截并验证请求是否符合安全策略要求。

实施全过程

架构是为了实现业务需求产生的,微服务架构的使用考虑是什么?微服务架构具有:可独立部署、高扩展与伸缩、资源利用高效、故障隔离等优点,如何体现?如何把庞大的单体应用切割为独立的小服务?一般需要经过:前后端分离改造、服务无状态化、统一认证服务,如何实施这些改造?

前后端分离

传统MVC模式需要后端兼顾前后端工作,导致开发进度变慢而且编码复杂,后其优化更难。前后端分离:将前端代码和后端代码分离,前端开发人员负责——HTML页面编写、页面跳转(路由)和UI交互工作;后端开发人员负责业务数据处理,提供数据接口给到前端。

这里采用VUE框架和Node.js作为前端,前后端通信采用Rest接口方式,并以JSON数据格式进行通信的方式来设定后续前后端框架。

服务无状态化

无论哪种负载均衡策略,都无法保证相同的请求每次都被同一个服务处理,但是要求最终返回结果是一致的。因此,分布式架构的核心之一就是执行无状态,执行结果有状态

无状态服务:指的是该服务运行的实例不会再本地存储业务执行后的状态,例如,不保存需要持久化的数据,不存储业务的上下文信息。

有状态服务:该服务的实例可用将运行时产生的数据随时备份。通常来说,数据库、缓存、消息中间件等一般都被定义为有状态服务。

业务架构按照上述划分后,无状态业务部分很容易横向扩展,而后端的中间件是有状态的,则需要考虑扩容及状态的迁移、复制、同步等机制。

在分布式架构中,首先要将有状态的业务服务改变为无状态的计算类服务,其次再把有状态数据迁移到对应的有状态数据服务种,最终服务具备无状态特性后就具备了横向扩展能力。

统一认证服务

微服务架构中,统一认证和授权是实施服务化的基础条件。单体应用中基于拦截器和session实现用户的登录和鉴权。但是在微服务架构中,由于服务都被设计为无状态的,上述单体应用的技术无法实施,需要由统一认证服务完成登录校验和用户鉴权。具体包括:身份认证、鉴权与第三方联合登录的功能。目前常用的统一鉴权方式有令牌、JWT、OAuth、CAS等认证方式。

令牌方式:令牌认证服务在完成调用方的鉴权请求后,会生成token返回给调用方,业务调用方后续请求业务系统时需要携带该token。

token值写入redis,并且设置TTL时间,避免僵尸数据,如果接口请求量大,则使用Redis的主从复制模式来横向扩展Redis只读实例。若压力过大,可用双token刷新续期来解决token到期问题。注销时候,从分布式缓存中删除即可。

JWT方式:JWT是自包含的JSON声明规范,因其分散存储的特点而归属于客户端授权模式,JWT信息是经过签名的,可用确保发送方真实性。由于加密算法是公开的,所以在生产环境中,需要使用RSA256方式借助公钥、私钥生成token保证其安全性。过期时,需要应用端主动调用认证服务来重新获取token,并覆盖本地token,达到续期效果。

微服务设计模式

经典设计模式有:业务功能分解模式、子域分解模式、隔舱模式、数据库模式、事件驱动模式、绞杀者模式。

对单体应用做微服务重构时,首先需要找到核心业务点,厘清模块之间的限界上下文,保证服务具备高内聚、低耦合,重构的功能要相对独立。

具体执行方法:只读先行、功能独立、需求频繁作为切入点、新功能重新开发。

线上问题

这里赘述一下可能遇到的线上问题和解决方案。

微服务核心点——数据去中心化,也即每个服务都有自己私有的业务数据库,且只能访问业务自己的数据库;如需访问外部数据库,则必须通过外部服务提供的接口访问。

服务线程池满:线程池很快打满,但是服务器的CPU和内存占用率都较低,是由于数据库有大量的慢查询SQL语句,导致数据库的性能出现问题,服务请求响应时间变长,当有高并发请求时会把Dubbo线程池打满。解决方法就是优化慢SQL语句。

当然最后还是要完成数据的去中心化,每个基础服务都对应一个数据库才行。不然如果数据库一旦有性能问题,就会导致应用宕机。

微服务实施初期,重点以服务化为主,实施服务的拆分,以及聚合层、原子服务层的划分,每个原子服务都可用通过配置来连接数据库。这个阶段不建议做真正的分库操作。

等待到微服务实施后期,可用通过数据同步、订阅binLog等方式做真正的分库操作。

通过“冗余法”对老系统所依赖的数据库的表不做任何变更,而对于每个服务所需的表在新的独立数据库中都重新构建。通常才用的方法有:增量同步和binLog日志同步。

数据库的CPU占有率飙高:用户访问量不大,但是出现全表查询。这种情况容易发生在传入空对象的全表查询上,可用采用在数据访问层执行拦截判断,若SQL语句没有where条件,则由中间件直接抛出异常。

无止境的循环依赖:由于微服务架构设计要求服务之间只能通过接口调用获取数据,容易发生A调用B,B调用A这种情况,最终导致RPC服务出现循环依赖调用的情况。需要在设计的时候就做好服务调用约定:

  • 原子服务层:主要做数据库的操作和一些简单的业务逻辑,不允许调用其他任何服务。
  • 聚合层:允许调用基础服务层,完成复杂业务逻辑聚合操作,聚合层之间不允许互相调用。

如果出现上述AB互相调用的情况,可把互相依赖的数据再次抽离为独立的服务C,

微服务进阶

上述将内部接口调用改为RPC调用,然后将数据库设计为每个服务对应一个数据库的方式固然重要,但是还有进阶的方法,细致打磨服务,从而令服务运行更快更稳定。例如使用:多级缓存、串行转并行、熔断与降级、服务限流、接口的幂等性、分布式事务、解耦及流量削峰等技术。

缓存分类

缓存的需求是为了提高访问的速度,让用户对延迟时间减少感知。缓存一般分为客户端缓存、浏览器缓存、CDN缓存、Nginx缓存、本地缓存、分布式缓存。

CDN内容分发缓存,主要是将用户访问指向距离最近的服务器直接响应,以减少网络拥塞和提高系统访问速度。

本地缓存:将服务器本地的物理内存划分出一部分来缓存响应给客户端的数据。最大优点是应用和缓存都在同一个进程内,没有网络开销延迟。但是这类缓存一般都在JVM堆空间,由于容量受限,会影响到GC。一般用来缓存一些并发访问量特别高且能在短时间内容忍与数据库不一致的数据。典型代表为:Guava、Ehcache、Caffeine。

本地缓存一般不做持久化,遇到服务器宕机、重启、进程崩溃等异常情况时,数据无法同步到磁盘上,且写入缓存中的内容容易丢失,所以应用中一般会结合使用本地缓存和分布式缓存。

分布式缓存:通常指与业务应用相分离且部署在集群服务器上的缓存服务。用的最多的是Redis,该类缓存最大优势:自身为独立应用服务,除了具备普通缓存特性,还与业务应用相互隔离,多个应用服务可直接共享缓存内容。

缓存优化

单级缓存:对象序列化后存入Redis,并且缓存失效后,等下次调用再击中数据库,然后获取最新数据,将聚合数据再放入缓存。

多级缓存:大流量击中导致缓存过期后流量抖动频繁,可结合本地缓存和分布式缓存实现多级缓存方案。本地缓存替换Redis存放聚合后结果,本地缓存失效时间随业务配置不同有效期。减少RPC调用次数,降低内网带宽占用率,降低分布式缓存压力。但是更新时间需要根据业务调整,因为会出现数据不一致的问题。如果缓存使用不当,也会引发缓存穿透、缓存击穿、缓存雪崩等问题。

缓存管理有相应的策略:

缓存失效时间:设置时间的常见方法——定时删除、自动过期。

缓存穿透:指的是查询一个不存在的数据导致一直查询数据库,例如爬虫。有布隆过滤器和返回空值这两种方法。

缓存击穿:由于key过期的时候有大量并发请求访问该key,可用通过加锁的方式减少数据库瞬间的并发访问量,高并发场景下需要以key为粒度来加锁,让访问依次排队。

缓存雪崩:设置了相同过期时间,导致缓存在一瞬间全部过期,全部查询都打到数据库上。常见解决方式:数据加锁控制——控制数据库写缓存的线程数量。数据预加载:针对不同key设置不同过期时间。

串行转并行

由于一次API访问请求可能聚合多个服务的响应,会放大RPC框架调用延时带来的副作用,需要考虑并行减少延时。

这里简要介绍一些串行、并行、同步、异步的概念:

  • 串行:多个任务依次执行,一个任务执行完才执行下一个任务。
  • 并行:使用计算机的多核性能,同时将不同任务委派给不同的处理器来执行,以达到同时处理的效果。
  • 同步:服务提供方受到调用方发起的请求后,一直等到任务完成后才将数据返回给调用方。同步执行会阻塞线程。
  • 异步:服务提供方受到调用方发起的请求后立即返回。异步执行不会阻塞线程。

串行转并行是尤为重要的优化方案,首先在考虑同步、异步、并行、泛型调用等调用方式,识别服务之间的依赖调用关系是很重要的,确认哪些场景下可进行且调用。

  1. 存在输入/输出依赖,服务B的输入依赖服务A的输出,服务C的输入依赖服务B的输出,这种就不适合并行调用。这种主要是由于服务拆分不合理导致的——将本应该高内聚的服务拆分为多个服务了。
  2. 服务间无依赖关系,可用并行调用方式,在聚合层一次性调用服务,然后异步获取调用结果。
  3. 先串行,再并行。聚合层先调用服务A,在获取服务A的返回结果后将其当作入参,并行调用服务B和服务C。
  4. 先并行,再串行。聚合层先并行调用服务A和服务B,然后同步阻塞结果,再把聚合后的结果当作入参来调用服务C。

并行调用通常使用异步方式返回结果。

若聚合层调用所依赖的原子服务都使用了同一个线程池,在调用过程中可能出现网络抖动、网络异常。当某个服务提供者变得不可用或者响应慢时,就会影响到服务调用方的服务性能,甚至会引起因某个服务提供者接口的响应时间长而导致服务调用方占满整个线程池的情况,从而引发更严重的服务雪崩效应。为了应对该风险,可用使用资源隔离策略和熔断与降级策略。

  • 资源隔离策略:指每个接口都拥有单独的资源,比如线程池、数据库连接池等
  • 熔断与降级策略:指在接口的错误率或超时次数达到设定的阈值后,直接返回预设的结果,而不用调用实际的服务。

服务的熔断与降级

在大中型分布式系统中,一个接口通常会依赖很多服务,在高并发访问下,这些服务的稳定性对系统的影响非常大。但是,服务之间的接口依赖有很多不可控因素:网络连接缓慢、服务响应慢、资源繁忙、服务暂时不可用、服务宕机等。

熔断器三种状态:关闭、打开、半开(仅一个请求被放行,如果放行请求执行成功,则转为关闭)

熔断机制为每个依赖服务都配置一个熔断器开关。

主流的开源容错系统:Hystrix和Sentinel,Hystrix发现服务可用时才自动关闭熔断开关;Sentinel则是以流量为切入点。

自动开关降级是根据降级策略自动触发的,可用分为:超时降级、失败次数降级、故障降级以及限流降级这四种场景。

降级方式:延迟服务、服务接口拒绝服务、页面拒绝服务、关闭服务。

具体的Hystrix和Sentinel实例使用和集成Dubbo这里不加赘述,有兴趣的可以自己查看相关文档。

限流

由于流量具有随机不可预测性,一旦突然的流量超过了系统的承受功能,可能导致请求处理响应慢、CPU负载飙高,最终导致系统崩溃。于是需要对流量做限制,尽可能多处理请求的同时还要保障服务不被压垮。

限流算法:令牌桶算法、漏桶算法。

限制范围一般分为应用级、接口级和用户级:

  • 应用级:为防止大量请求涌入,在入口处做应用级的限流。
  • 接口级:由于每个接口复杂程度、响应时间、处理能力各不相同,需要以接口级针对不同接口设置不同的限流阈值,充分使用集群资源。
  • 用户级:资源不足时,只能将有限的资源分给重要用户,例如付费用户。

在应用级针对IP限流还是总体限流,触发限流后是重定向到错误页还是其他方。

在单体应用只要对应用做了限流,则依赖的各种服务都得到了保护。但是对分布式系统不一样,因为存在各种节点扩容、缩容,以及不同负载均衡策略,所以无法准确控制整个服务的请求限制。分布式场景下单机限流存在计算不准确,错误限流等问题。

分布式限流:各台服务器维护各自的计数器,并且将计数器的结果放到分布式缓存中,以Redis来保存接口的每秒请求次数,做分布式限流。当然也可以结合单机和分布式限流做混合限流策略。

接口幂等性

对同一个系统使用同样的条件,对系统资源进行一次请求或多次重复请求的影响结果是一致的。

分布式环境下,由于网络环境复杂容易出现前端重复操作、APP自动重试、网络故障、消息重复、响应速度慢等情况,所以分布式环境下接口的重复调用概率会远高于单体应用。例如线上环境经常会碰到:重复操作、消息队列重复投递、重试机制、网络波动。

但是重试可能会导致产生重复数据或者数据不准确的情况,所以分布式架构中要求所有调用过程都必须具备幂等性。

保障CRUD等操作具备幂等性的常用方法有:MVCC、唯一主键、去重表及token机制等。

MVCC是乐观锁的一种实现(其实就是加了个version字段,然后操作时都要先确认version字段),其缺点是程序每次执行update操作前面,都需要进行select操作,查询当前数据的快照。

唯一主键:主键不是自增,而是全局唯一。但是如果业务侧是分库分表,则唯一主键机制不生效。同时在高并发情况下会导致数据库的压力非常大,不支持update幂等操作。

去重表:利用数据库的唯一索引进行防重处理,做了幂等表(唯一主键),因为在同一事务中处理,如果幂等操作抛出异常,则事务回滚。优点是减少了一次查询数据操作,但是缺点是多了一次插入数据操作。由于用的还是唯一主键特性,所以高并发下还是会增加数据库压力。注意:如果两个表不在同一个数据库,则去重表不能保证操作具备幂等性。

token机制:TGS生成token接口和校验接口,前端每次调用接口前需要先获取token,然后TGS对token进行生命周期管控,在规定时间内token只允许使用一次,非首次使用均属于重复使用。缺点是会生成大量无效的token。

配置中心

配置方式一般分为静态配置和动态配置。

静态配置通过文档方式进行配置,在启动前就已经写好,但有缺点:无审计、无版本控制、泄露隐私数据、更改配置时需要重启服务。

动态配置包括应用配置、业务配置和功能开关。

为了解决静态配置不足的问题,对配置中心有以下要求:

  • 配置是可分离的,所有配置可从微服务中抽离出来,对任何配置的修改都无需改动任何一行代码
  • 配置是中央式的,通过统一的中央配置平台区配置和管理不同的微服务。
  • 配置是稳定的。
  • 配置可追溯。

消息队列

业务场景中会出现需要用户服务主动发起调用的情况,导致调用链路变长、业务耦合严重。实际上某些服务调用完全可以以异步流程处理,为了解决服务之间重度耦合的问题,架构层引入了消息队列。可以规避服务之间耦合调用的弊端,还能实现流量削峰、异步消息。

主流消息队列包括:RocketMQ、ActiveMQ、RabbitMQ、Kafka等。通过消息队列对下游服务做通知,如果下游服务需要依赖该服务,则只需要订阅该消息的Topic,即可实现通知与业务调用的解耦。

分布式事务

如何保障多个数据节点之间数据的一致性,及如何处理分布式事务。

事务具有的ACID特性:原子性、一致性、隔离性、持久性。

这里衍生了CAP定理(不可能定理)——无法同时满足一致性、可用系和分区容错性。

BASE理论——数据无法做到强一致性,但是每个应用可以根据自身业务特性,使数据达到最终一致性:基本可用、软装态、最终一致性。

分布式事务方案:一般用强一致性分布式事务TCC和基于事务消息的柔性事务来解决系统所面临的分布式事务问题。

TCC:事务补偿方案,一般用于与交易相关的数据强一致性场景下:由三个阶段租成

  • Try阶段:尝试执行,完成对所有业务检查(一致性),预留必须的业务资源(准隔离性)
  • Confirm阶段:执行,但是需要满足幂等性设计要求,失败后需要重试。
  • Cancel阶段:取消执行,释放Try阶段预留的业务资源,必须满足幂等性。

开源方案包括:tcc-transaction、Apache ServiceComb Saga、servicecomb-pack、Seata。

柔性事务:对于不需要强一致性的场景,用基于事务消息的最终一致性方案解决分布式事务问题。事务消息实现了消息生产者的本地事务与消息发送的原子性,保证了消息生产者本地事务处理成功与消息发送成功的最终一致性。

亿级流量网关开发

微服务网关是微服务架构下系统的唯一流量入口,因为安全性问题,可用将内部系统暴露给外部用户的范围控制到最小,主要解决:权限控制、访问控制、速率限制等问题。

单体架构改造为微服务架构后,系统原本的功能由多个聚合服务提供,前端在调用具体服务时需要根据不同的业务场景填写不同的请求地址。这种架构模式给研发人员带来了很麻烦,很容易引发线上事故,主要问题体现在以下几方面:

  • 请求地址不统一:每个服务都提供各自的服务接口地址,前端需要根据不同的业务场景调用后端的不同服务地址。
  • 功能重复开发:每个服务都需要实现请求鉴权、访问限流、服务熔断等功能。
  • 状态码不统一:权限认证由每个服务各自处理,导致返回的状态码不一致。
  • 跨域:随着业务趋于复杂,业务呈现多元化发展,使用不同的域名区分后台服务地址,导致存在前端H5页面跨域访问的问题。
  • 日志分散:入口请求日志散落在每个服务中,不方便统一收集。
  • 无最新文档:更改接口迭代后,由于没有集成的接口文档,导致后期接口变得复杂且无法维护。

使用微服务网关后,前端所有请求地址都指向微服务网关的域名,由网关将请求转发到对应的服务,解决了前端H5页面跨域访问的问题,且鉴权、限流、熔断功能都由网关来完成并返回约定的状态码,避免了对非业务功能的重复开发。接口文档由网关管理中心根据API自动实时生成和更新,可时刻保持最新状态。

网关职责

从业务角度来看,网关职责为:

  • 请求接入:所有API服务请求的统一接入点
  • 业务聚合:所有后端业务服务的聚合点
  • 中介策略:实现安全过滤、身份验证、路由、数据过滤等策略。
  • 统一管理:对所有API服务和熔断、限流等策略进行统一管理。

其实本质上可以理解为一个反向路由,其屏蔽了接口的内部细节,接收所有调用者的请求,为调用者提供统一的入口,通过路由机制将请求转发到下游服务。同时也是过滤器,实现与业务无关的横切面功能:安全认证、限流熔断、日志监控等。

从技术角度来看,网关工作原理:

  • 协议转换:将不同协议转换未通用协议,例如将HTTP转换为RPC
  • 插件化:将网关中每种业务能力设计为插件,插件仅负责单一的业务功能,其插件具备可插拔模式
  • 链式处理:消息从流入到流出的每个环节对应的插件都会对经过的消息进行处理,整个过程形成一个链条。其优势在于处理请求和执行步骤分开,每个插件仅关系插件上需完成的处理逻辑。
  • 异步请求:所有请求通过网关异步转发到下游服务,可保障网关的吞吐量始终处于稳定状态。

网关核心包括了:接入层、分发层和监控层。

接入层负责加载系统插件,使用责任链设计调度每个插件来处理前端请求,将非法请求拦截在系统之外

分发层将请求路由到下游服务,网关使用资源隔离策略来屏蔽不同接口之间对资源的相互影响,同时针对当前调用量及调用结果做熔断、降级处理,并处理其返回结果。

监控层生成各种监控指标,并提供监控日志、运维分析报表、自动报警等消息。

插件有各种类型,例如:鉴权插件、验签插件、IP黑名单插件、限流插件、时间校验插件、防刷插件。

部署架构:由于网关系统是处于前端请求和下游服务之间的中间件系统,其本身无状态、支持横向扩展。部署时可将网关核心部署到服务器或Kubernetes容器中,每个微服务网关核心运行在独立的Pod中,当流量超过设定阈值时,网关可自动扩容。

网关高可用设计

做好网关高可用设计尽量减少停机,当出现故障及时响应、快速恢复、以保障业务系统的稳定运行和可持续访问。

影响高可用的因素有很多例如:应用发布、系统故障、基础设施故障、数据故障、系统压力、外部依赖。

提高系统可用系的常用方法:服务多副本、限流机制、降级设置、超时重试、隔离策略、熔断机制、负载均衡策略。

自研高性能异步网关

网关自研最重要的第一步是定义接口协议,确认前端和网关的接口交互方式及接口协议,主要使用HTTP方式以JSON格式与前端交互。

API的注册与发布,将下游服务需要暴露给外部用户访问的接口注册到网关管理平台,由网关统一提供调用入口。API注册包括第三方注册和自注册两种模式。其中自注册采用程序扫描自定义注解的方式自动注册,具有配置效率高、零配置错误的优势。

实现自注册模式包含自定义注解、注解解析、接口注册这三步流程。

异步化请求:将请求解析线程池和业务处理线程池分离,提高系统的并发能力和业务处理吞吐能力,以Servlet3.x、Spring异步API Netty框架支持异步化。

Netty来源于JBoss的一种高性能、异步事件驱动的NIO框架,所有的I/O操作都是异步非阻塞的,采用了串行化设计理念,抽象出两组线程池,一组专门负责接收前端的连接,另一组专门负责网络读/写操作。

通常RPC的调用国车过需要前端使用服务器端提供的接口,具体是使用jar文件,通过引用jar文件获取到接口的具体信息,这种依赖jar文件的模式会频繁更新网关,以致于网关系统难以维护。

Dubbo提供了泛化接口,解决了这个问题,其原理跟普通的RPC调用原理一致,区别在于泛化调用时参数传递涉及的POJO对象及返回值中涉及的POJO对象都用Map类型标识,中间有序列化和反序列化,无需依赖其他jar文件,可以与下游服务之间使用RPC方式进行通信。

请求快照:在日常运营过程中,需要日志排查报错进行查询生产问题,网关会自动记录每次请求前端的设备信息、入参明细、响应明细、接口响应耗时等信息,以结构化形式保存起来形成当前请求的快照信息。涉及:流量采集(配置接口采集流量)、数据存储(反序列化对象存入数据库)及快照查询。

网关优化

要在不同接口之间做好资源隔离,杜绝由于某个接口性能差导致整个网关服务宕机的情况,需要考虑在网关中如何合理使用缓存,进行一系列性能优化。

资源隔离:防止某些API由于性能问题耗光网关的所有资源,影响其他API,则需要给每个接口分配不同的线程池,实现以接口为粒度的资源隔离策略。例如使用Hystrix的信号量隔离或者线程池隔离。

可以用Netty经典的Boss和Work线程模型来避免由于线程切换所带来的性能开销,Boss负责客户端请求,Work负责业务调用。如果网关吞吐量不稳定,很可能是下游服务业务逻辑的时长不可控,导致通信Work模块被阻塞,可以增加专用的业务处理线程池来处理业务调用。同时可以考虑使用Epoll加速的方式,遍历内核I/O事件异步唤醒的计核即可极大提高应用程序效率,而不是去遍历所有的并发连接。

高速缓存:可以在网关中存放业务数据缓存和元数据缓存。启动时可以将数据库中API元数据信息全部加载到本地缓存,若接口变动,则网关管理端主动通知网关增量更新接口的元数据信息。

自研网关遇到的困难

网关找不到服务提供者:容易发现有服务已经成功注册到注册中心,泛化调用时可以正常调用,但是却提示找不到服务提供者。但使用消费者调用具体服务时,却还能正常调用的情况。

原因在于泛化调用时,创建Proxy时没有判断是否创建成功,而是之间初始化为true。

解决方案:只有当代理对象成功创建时,才设置为initialized=true

多余的class:不建议将具体对象的完整类名暴露给前端。

解决方案:去除掉代码中暴露POJO对象类的测试代码。

错误传值:后端服务的某些接口参数为Integer,但是若该接口参数为非必填字段,则当前端提交的参数值为空时,导致后端接口异常。

Dubbo泛化调用在序列化时,会根据参数的类型直接给出值。

解决方案:根据参数值来判断,若值为空,则直接返回null即可,其实不止是Integer,其他非String类型也都有相同的这个问题。

日期格式异常:Dubbo泛化调用在序列化时,会根据字段类型做时间的格式化操作

解决方案:格式化日期出现错误并抛出异常时,捕获异常,且将日期的格式化形式调整后,重新再次格式化一次。

自定义异常失效:Dubbo使用ExceptionFilter异常做统一的拦截处理,对异常进行处理的规则有一定的要求。由于自定义异常为非受检异常,且不符合Dubbo异常拦截器中直接抛出的要求,所以会包装为RuntimeException后抛出。

解决方案:在Provider端的配置文件增加"<dubbo:provider filter="-exception"/>",去掉Dubbo自身的Exception,防止自身异常把自定义异常转为字符串后抛出RuntimeException。

源码修改由于双亲委派模型,所以可以通过:

  1. 直接把修改后的源码合并到Dubbo源码中重新编译,然后网关项目直接依赖修改后的jar文件,但是缺点是后期Dubbo版本升级,需要更新版本中再修改一次
  2. 在网关源码中新建与被修改源码相同的包路径和文件名。这样可以在不修改Dubbo开源版本源码的基础上间接对Dubbo源码做了修改。

后面的测试工程和契约测试平台就不详加赘述,当然对于持续集成和持续交付这块,目前也有了很成熟的方案:GitLab、Maven、Jenkins Pipeline、Docker、Kubernetes、SonarQube、Jacoco、Mockito等。

关于无感发布也有:灰度发布、金丝雀发布和滚动发布等做法。

在测试阶段,最好能实施以下混沌工程,例如:CPU满载实验、磁盘写满实验、内存负载实验、数据库调用延时实验、Redis调用延时实验、Dubbo服务延时实验、Dubbo线程池满实验等,最好能开发测试平台的可视化。

Last modification:February 15th, 2025 at 04:11 pm