概述
Spring Cloud Alibaba 衍生自 Spring Cloud Community, 其已经通过 Spring 认证并被收录在 官方网站 中。根据阿里的介绍,这套微服务解决方案中包括了一部分开源组件和一部分阿里云商业化产品。这次我们要上手的将只包含开源部分,对于一些未提供的能力,比如网关服务,将结合 Spring Cloud Community 提供补充,上手过程中会基于本人目前的经验,对其中相关知识点做出简单总结。
围绕开源生态,探究如何将这套微服务方案落地到生产中,是本篇的核心目的。作为初探篇,将着重于应用代码侧的接入,很多设计是结合现有比较常见的开发生产习惯决定的。对于其中的各类中间件环境如何实现 HA,本篇将暂不探讨,有兴趣的可以从本篇末尾获得查看官方建议的链接。
主要功能
- 流控与服务降级(Flow Control and service degradation):默认支持 WebServlet、WebFlux, OpenFeign、RestTemplate、Spring Cloud Gateway, Zuul, Dubbo 和 RocketMQ 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。
- 服务注册与发现(Service registration and discovery):适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 负载均衡的支持。
- 分布式配置管理(Distributed configuration):支持分布式系统中的外部化配置,配置更改时自动刷新。
- 事件驱动(Event-driven):基于分布式消息实现构建高性能事件驱动的微服务体系。
- 分布式事务(Distributed Transaction):高效并且对业务零侵入地解决分布式事务问题。
- 微服务网关(Microservices Gateway):为整套内部微服务链路提供一个可靠且高性能的对外网关,并配套流控、路由、鉴权等能力。
- 服务间远程过程调用(Remote Procedure Call):提供可靠方便的RPC框架,实现各个系统间就像在调用本地程序一样地调用远程服务。
工程简介
在这次上手实践过程中,我们临时不会接入基于消息的异步事件驱动功能。同时我们需要引入一种常见的业务场景,以达到辅助理解的作用。这里将以交易场景为例构建工程雏形,我们假设交易系统基于第三方支付服务商、银行等聚合,并主要有以下两个模块:
- Trade:交易模块,是整个交易系统的入口,为每一次交易动作的发起提供支撑,无论这笔交易的最终结果如何
- Bill:账单模块,是整个交易系统的后置节点,为每一笔成功的交易生成流水凭证,并将和Trade资源有机的关联起来
基于以上两个模块,我们还需要对基础的支付流程有一个大致的了解,假设我们的系统只有简单的线性同步调用模型,以某个用户的某次成功付款为例,流程如下:

组件版本
| 组件 | 版本 | 功能 |
|---|---|---|
| Spring Cloud Alibaba | 2.2.6.RC1 | 以pom方式接入,用于各类client中的依赖引入和版本控制 |
| Nacos server | 1.4.2 | 服务注册发现与分布式配置中心 |
| Sentinel dashboard | 1.8.1 | 流控、降级与流量指标监控控制台 |
| Seata server | 1.3.0 | 分布式事务TC |
| Spring Cloud Community | Spring Cloud Hoxton.SR9 | 以pom方式接入,用于各类社区版组件client中的依赖引入和版本控制 |
| Spring Cloud Gateway Server | 2.2.6.RELEASE | 微服务网关 |
| Spring Cloud OpenFeign | 2.2.6.RELEASE | RPC调用组件,未使用官方推荐的Dubbo |
代码模块
基于上述最简单的交易场景模型,并结合要使用的微服务组件,我们将实际的代码划分成了以下模块:
- ebpp-common: 公共包,用于存储URI、常量、工具等。
- ebpp-gateway :网关服务,使用 Spring Cloud Gateway 完成统一鉴权、负载均衡等。
- ebpp-nacos-server: Nacos服务,用作服务注册发现、服务管理、服务路由、配置中心等。
- ebpp-sentinel-dashboard: Sentinel 控制台服务。
- ebpp-seata-server:Seata TC 服务,进行分布式事务协调。
- ebpp-xxx-route: 路径路由,用于各服务API接口的定义,携带URI、出入参等,便于其他服务引用,以及解决URI变化造成其他调用者无法获知导致调用失败的问题。
- ebpp-service-trade: 交易服务。
- ebpp-service-bill: 账单服务,与支付系统配合模拟强一致性分布式事务,使用seata,基于全局事务管理理论(两阶段提交)变种出的AT模式。
细节
依赖管理
出于对依赖版本的规范化,我们推荐使用下面的的结构:
— project root
— — module A
— — — pom.xml of module A
— — module B
— — — pom.xml of module B
— pom.xml of full project
相应的,需要在 pom.xml of full project 的 dependencyManagement 标签中引入以下配置,进而规范 module 内部各种 Spring Cloud Alibaba、Spring Cloud Community 依赖的版本:
<!-- spring cloud alibaba-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.6.RC1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- spring cloud community -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
另外,需要提前说明一下业务代码包的核心依赖关系,以免存在误解:
ebpp-service-xxx 依赖于 ebpp-xxx-route 依赖于 ebpp-common
Nacos
名词理解
Nacos 中定义了一些专用概念,来规范其技术使用,我们可以试着从 Nacos 的两个核心功能(服务管理、配置管理)分别去理解以下的几个名词概念:
- 命名空间(Namespace):用于进行租户粒度的配置隔离。不同的命名空间下,可以存在相同的Group或DataID的配置。Namespace的常用场景之一是不同环境的配置的区分隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等。
- 服务分组(Group):不同的服务可以归类到同一分组。本人理解的是,可以用来定义同多种服务同属于一个项目集的标识,比如订单微服务、库存微服务同属于电商系统,前者可以拥有相同的服务分组。
- 配置项:一个具体的可配置参数与其值域,通常以 param-key=param-value 的形式存在。例如我们常配置系统的日志输出级别(logLevel=INFO|WARN|ERROR)就是一个配置项。
- 配置集:一组相关或者不相关的配置项的集合称为配置集。在系统中,一个配置文件通常就是一个配置集,包含了系统各个方面的配置。例如,一个配置集可能包含了数据源、线程池、日志级别等配置项。
- 配置集ID(DataId):Nacos 中的某个配置集的 ID。配置集 ID 是组织划分配置的维度之一。Data ID 通常用于组织划分系统的配置集。一个系统或者应用可以包含多个配置集,每个配置集都可以被一个有意义的名称标识。Data ID 通常采用类 Java 包(如 com.taobao.tc.refund.log.level)的命名规则保证全局唯一性。此命名规则非强制。
- 配置分组:区别于服务分组,配置分组是 Nacos 中的一组配置集,是组织配置的维度之一,每个配置分组都有一个唯一的 Group Id。一个 Group Id 下可以有若干个 Data Id,这样做不仅对配置集进行了分组,同时也区分了 Data Id 相同的配置集。举个例子,订单服务和商品服务内部都要配置有自己的数据源链接(datasource-url),按照配置集ID的命名建议,如果它们的 Data Id 都是 spring.datasource.druid.url,在配置进行集中管理时就会存在冲突,这时候如果让它们分别从属于 ORDER_GROUP 和 PRODUCT_GROUP 这两个 Group Id 下,就可以避免冲突问题。
Nacos Client
在官方介绍中我们了解到,Nacos 提供了服务注册发现和配置中心两大功能,所以 Nacos 的客户端也被分为了 nacos-discovery 和 nacos-config 两种。我们只需要在接入的服务中,引入对应的 Spring Cloud Starter 依赖并在启动所需配置文件中进行简单配置,就可完成集成。
以 ebpp-service-trade 为例,先要在 pom.xml of ebpp-service-trade 引入 stater 依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
然后在 bootstrap.properties 中加入如下配置就可以了:
# 最好配置应用名
spring.application.name=ebpp-service-trade
# nacos-discovery
# 这里必须配置命名空间的空间ID,不可是名称
# spring.cloud.nacos.discovery.namespace=f1140e5e-0608-4f9c-b578-d34820f32068
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.cloud.nacos.discovery.group=EBPP_GROUP
# nacos-config
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.file-extension=properties
spring.cloud.nacos.config.group=DEFAULT_GROUP
在日常编码工作中,怎样的配置架构更合理
- 关于多环境配置(以 dev、test、prod 三种环境为例):dev 环境下,我们的目的是更直观、更方便的读写配置文件;test 和 prod 环境下,出于安全运维的考虑,一部分重要的或者全部的应用配置一般都会由配置中心统一管理
- 关于配置文件的加载优先级:一般的情况下(指没有进行特殊指定时),bootstrap 优先于 application,properties 优先于 yml,resources/config 优先于 resources/
- 关于服务配置的实时动态更新:目前以我的认知,更倾向于大部分配置启动时加载,小部分配置实时更新的策略。
- 关于相同环境下配置文件的拆分:我的观点是尽量少拆分直至不拆分。因为现在很少会出现一个微服务应用(或一个可执行 JAR 包)由多个团队(或许多许多人)开发维护的情形,所以就很少出现相同环境下有许多个配置文件(配置集)的情况。 由于很少出现,如果继续将原来1~2个配置文件拆分成若干个分工明确的小型配置文件,反而会增加日常的维护成本,增大编码的复杂度。同样的,如果不需要特别细分的配置权限管理,依然去拆分成许多个小的配置子集,其实也是不合理的。那么有人会质疑,之所以做拆分,是因为要实现修改中心配置时,微服务应用内部可以实时地动态更新,如果拆分的话,会减轻应用每次更新配置集时的资源消耗。其实并非如此,之所以会有这样的顾虑,是因为还没有理解接入 Nacos 配置中心的工作原理,假如我们在 Nacos 端修改了某一个 key 的配置,对应的微服务应用其实只会更新这一条配置,以及依赖它的某几个 Bean (如果需要)。配置的更新并非是文件维度的,而是 key 维度,所以说拆分可以降低资源消耗这点也许就是个伪命题。下图也可以简单看出配置动态更新中的端倪。

综上观点,我们的工程可以采用的配置架构可以是下面这个样子的

bootstrap.properties 文件负责多环境、应用的基础信息、默认启动端口等配置。例如:
server.port=9901
spring.profiles.active=dev
spring.application.name=ebpp-service-trade
bootstrap-dev.properties 文件负责日常开发环境的所有配置,注意是在本地,并非远程配置中心。
bootstrap-prod.properties 文件负责设置生产环境的远程配置中心,生产的所有配置在远端(test 环境同理)。例如:
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.file-extension=properties
spring.cloud.nacos.config.group=DEFAULT_GROUP
Nacos 配置中的 Data Id 如何与某个微服务应用关联起来
在 Nacos Spring Cloud 中,Data Id 的完整格式如下:
${prefix}-${spring.profiles.active}.${file-extension}
prefix 默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix 来配置。
spring.profiles.active 即为当前环境对应的 profile。注意:当 spring.profiles.active 为空时,对应的连接符 - 也将不存在,Data Id 的拼接格式变成 ${prefix}.${file-extension}。
file-exetension 为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。目前只支持 properties 和 yaml 类型。
以上一个问题中的配置为例,Nacos 端生产环境的 Data Id 应该是 ebpp-service-trade-prod.properties(.properties 必须要有)
Sentinel
Sentinel Client
关于 Sentinel 客户端在各个服务中的集成,以 ebpp-service-bill 为例,概括来看,主要有这么几个部分:
- Sentinel Client 与 Sentinel Dashboard 的连接配置
首先是依旧是依赖的引入,即在需要接入限流、熔断、降级的服务中引入 Spring Cloud Starter 依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
其次需要在 bill 服务的 bootstrap.properties 中做如下简单配置,这样在应用启动后,客户端与控制台就会建立其连接:
#是否提前触发sentinel初始化(sentinel会在客户端首次调用的时候进行初始化,开始向控制台发送心跳包)
spring.cloud.sentinel.eager=true
#应用与sentinel控制台交互的端口,应用本地会起一个该端口占用的HttpServer
spring.cloud.sentinel.transport.port=8719
#sentinel控制台地址
spring.cloud.sentinel.transport.dashboard=localhost:8080
#应用与sentinel控制台的心跳间隔时间
spring.cloud.sentinel.transport.heartbeat-interval-ms=5000
- Sentinel 限流熔断和降级规则的持久化配置
默认情况下 Sentinel Client 的各种限流降级规则是只被保存在客户端所在的 JVM 实例中(即内存中),这样的后果就是,一旦 JVM 重启,所有配置好的规则都将丢失,这种情况在生产环境是不可能被允许的,必须要有对应的持久化措施,官方推荐通过控制台设置规则后将规则推送到统一的规则中心,客户端实现 ReadableDataSource 接口端监听规则中心实时获取变更,流程如下:

DataSource 扩展常见的实现方式有:
- 拉模式:客户端主动向某个规则管理中心定期轮询拉取规则,这个规则中心可以是 RDBMS、文件,甚至是 VCS 等。这样做的方式是简单,缺点是无法及时获取变更;
- 推模式:规则中心统一推送,客户端通过注册监听器的方式时刻监听变化,比如使用 Nacos、Zookeeper 等配置中心。这种方式有更好的实时性和一致性保证。
我们这里将要采用官方推荐的 Nacos Push 模式,与官方网站上描述的不同,我们不会手动去实现客户端的 ReadableDataSource 接口,而是使用更简单快捷的 Spring Cloud Starter 方案。
先在 pom.xml of ebpp-service-bill 中引入依赖:
<!-- 这里为什么依然说是 Spring Cloud Starter,是因为
在 spring-cloud-starter-alibaba-sentinel 依赖中有针对本依赖的 <optional>true</optional> 标注,
表示其为可选择引入的,至于为什么要选择,因为有很多持久化方案。没错,你要选一个,总不能默认全引进来吧 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
接着就是 bootstrap.properties 的配置,注意多种限流类型是可以分开持久化的,可以使用 spring.cloud.sentinel.datasource.${name}.nacos.${property} 这种方式隔离开,其中的 name 可以自定义指定,最好与 rule-type 属性有一定的关联性,方便开发维护(只是本人建议)。
#sentinel持久化至nacos config
spring.cloud.sentinel.datasource.flow.nacos.server-addr=127.0.0.1:8848
spring.cloud.sentinel.datasource.flow.nacos.group-id=DEFAULT_GROUP
spring.cloud.sentinel.datasource.flow.nacos.data-id=com.higlowx.scal.ebpp.service.bill.sentinel.flow
spring.cloud.sentinel.datasource.flow.nacos.rule-type=flow
spring.cloud.sentinel.datasource.degrade.nacos.server-addr=127.0.0.1:8848
spring.cloud.sentinel.datasource.degrade.nacos.group-id=DEFAULT_GROUP
spring.cloud.sentinel.datasource.degrade.nacos.data-id=com.higlowx.scal.ebpp.service.bill.sentinel.degrade
spring.cloud.sentinel.datasource.degrade.nacos.rule-type=degrade
最后,还有一个值得我们以后去思考如何实现的问题,在控制台界面手动配置 Sentinel 规则的方式很难在生产场景大规模应用,人们需要更加高效的方式去完成配置集的初始化和动态更新。
- 配置 RPC 框架 Feign 对Sentinel 的支持
关于这个支持项的配置,只需要在引入 Feign 依赖并完成基本配置后,加入下边这行这只即可 :
feign.sentinel.enabled=true
Feign Client 在开启 Sentinel 支持后,是怎样做到在消费者一方处理生产者返回的 Sentinel 异常的
我们都知道,健壮的系统架构一定会有良好的异常处理逻辑,是针对特定异常做出特定处理的逻辑。首先我们要明确 Sentinel 的异常应在生产者抛出,并被消费者捕获处理(或直接不处理),整个异常处理是跨进程的。
我们先看生产者一方,Sentinel Client 之所以可以工作,其限流、熔断和降级等,这些特殊处理一定是在进入代码业务逻辑之前进行,否则就没有限制请求的意义了,所以一定会有请求被拦截的逻辑,同样的异常的抛出也需要在这个层面。
基于这个初始判断,我们继续思考。基于 Spring Cloud 体系,怎样的方法可以做到拦截请求?如果是我,首先会想到以下两种:
- 基于 Spring 的 Interceptor 拦截;
- 基于默认 Tomcat 容器的 Filter 拦截;
如果是你,你会选哪种呢?好吧,我们现在只是大略的追一下 Sentinel 源码,希望可以看出其中的端倪。
我们通过 Client 中的 Sentinel 配置项,可以直接反追出 SentinelProperties 配置属性实例被 SentinelWebAutoConfiguration 配置类实例所持有使用。SentinelWebAutoConfiguration 类实现了 WebMvcConfigurer 接口并重写了 addInterceptors 方法,看到这里我们基本可以确定 Sentinel Client 实现拦截请求是基于 Spring 的 Interceptor,并且可以看到,在向 Spring 注入 SentinelWebInterceptor 拦截器时,为其绑定了一个高级别的 Order 属性,以保证该拦截器在所有拦截器的最外层工作。我们再简单看一下 SentinelWebAutoConfiguration 持有的对象、属性:
@Autowired
private Optional<UrlCleaner> urlCleanerOptional;
@Autowired
private Optional<BlockExceptionHandler> blockExceptionHandlerOptional;
@Autowired
private Optional<RequestOriginParser> requestOriginParserOptional;
@Autowired
private Optional<SentinelWebInterceptor> sentinelWebInterceptorOptional;
urlCleanerOptional 和 requestOriginParserOptional 从字面意思上应该分别负责 Spring 容器内 URL 的清洗整理 和 请求源解析处理,我们暂时先不研究它们。blockExceptionHandlerOptional 是为开发者提供的自定义处理 BlockException(Sentinel 默认异常) 的机制,我们暂不使用,直接走默认处理。关注点锁定 sentinelWebInterceptorOptional ,其内部是SentinelWebInterceptor 拦截器,该拦截器是所有规则处理的关键所在,其核心配置是 SentinelWebMvcConfig 类,在自动装配 SentinelWebMvcConfig 实例时的源代码是下面这样的:
@Bean
@ConditionalOnProperty(name = "spring.cloud.sentinel.filter.enabled",
matchIfMissing = true)
public SentinelWebMvcConfig sentinelWebMvcConfig() {
SentinelWebMvcConfig sentinelWebMvcConfig = new SentinelWebMvcConfig();
sentinelWebMvcConfig.setHttpMethodSpecify(properties.getHttpMethodSpecify());
sentinelWebMvcConfig.setWebContextUnify(properties.getWebContextUnify());
if (blockExceptionHandlerOptional.isPresent()) {
blockExceptionHandlerOptional
.ifPresent(sentinelWebMvcConfig::setBlockExceptionHandler);
}
else {
if (StringUtils.hasText(properties.getBlockPage())) {
sentinelWebMvcConfig.setBlockExceptionHandler(((request, response,
e) -> response.sendRedirect(properties.getBlockPage())));
}
else {
sentinelWebMvcConfig
.setBlockExceptionHandler(new DefaultBlockExceptionHandler());
}
}
urlCleanerOptional.ifPresent(sentinelWebMvcConfig::setUrlCleaner);
requestOriginParserOptional.ifPresent(sentinelWebMvcConfig::setOriginParser);
return sentinelWebMvcConfig;
}
可以看出,如果不设置自定异常处理和自定义 Block Page 的情况下,BlockException 的处理将交由 DefaultBlockExceptionHandler 负责。其源码如下:
public class DefaultBlockExceptionHandler implements BlockExceptionHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, BlockException e) throws Exception {
// Return 429 (Too Many Requests) by default.
response.setStatus(429);
PrintWriter out = response.getWriter();
out.print("Blocked by Sentinel (flow limiting)");
out.flush();
out.close();
}
}
情况一目了然,默认情况下,生产者向消费者返回 429 状态码,以及 Blocked by Sentinel (flow limiting) 字样的描述信息。
看完生产者,我们在看一下消费者一方。消费者通过 Feign Client 调用服务,而 Feign Client 又需要兼容 Sentinel , 我们知道将 feign.sentinel.enabled 属性设置为 true 后,会开启兼容,该属性的描述是这样的:
If true, an OpenFeign client will be wrapped with a Sentinel circuit breaker.
原来,消费者端的 Sentinel Client 将 Feign Client 又包装了一层,这样就可以针对生产者返回的 429 状态码做出特定的处理,并适配 Feign Client 的异常逻辑或者 fallback 逻辑了,相关源码可以以后再详细探讨。
Sentinel 官方提供了基于 SPI 的 Datasource 自动注册工具类 InitFunc ,其应用场景是什么
根据官方说明 ,我们做了以下实验(源代码已被删除)。
在 com.higlowx.scal.ebpp.service.bill.config.spi 包下创建自定义的SPI实现类。
public class SentinelDataSourceInitFunc implements InitFunc {
//尝试使用国际化资源读取,获取配置文件中的属性值,出现报错,SentinelDataSourceInitFunc无法被实例化
//public static final ResourceBundle R = ResourceBundle.getBundle("application.properties", Locale.CHINA);
//private static final String SERVER_ADDR = R.getString("spring.cloud.sentinel.datasource.flow.nacos.server-addr");
//private static final String GROUP_ID = R.getString("spring.cloud.sentinel.datasource.flow.nacos.group-id");
//private static final String DATA_ID = R.getString("spring.cloud.sentinel.datasource.flow.nacos.data-id");
private static final String SERVER_ADDR = "localhost:8488";
private static final String GROUP_ID = "DEFAULT_GROUP";
private static final String DATA_ID = "com.higlowx.scal.ebpp.service.bill.sentinel.flow";
@Override
public void init() throws Exception {
//误以为该init方法的作用是,根据每台部署机器的特性组装各种限流降级规则,然后向Nacos传输配置,
//以免去初次生产部署应用,都要先在Nacos发布配置集,然后再启动应用的问题,现在想来有些误入歧途了,而且有报错。
//误入歧途 开始
//以下暂时仅对flow类型(流量控制)的init进行演示
//List<FlowRule> flowRules = new ArrayList<>();
//FlowRule flowRule = new FlowRule();
//理论上resource的配置可以采用扫描出资源,并进行规模化或特例化配置
//flowRule.setResource("/bill/main/create");
//flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
//count值理论上可以根据每台机器的特性计算得出,这样更加合理
//flowRule.setCount(5);
//flowRule.setStrategy(RuleConstant.STRATEGY_DIRECT);
//flowRule.setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
//flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
//flowRule.setClusterMode(false);
//flowRules.add(flowRule);
//JSONObject source = new JSONObject();
//source.put("flow", flowRules);
//ReadableDataSource<String, List<FlowRule>> flowRuleDataSource;
//flowRuleDataSource = new NacosDataSource<List<FlowRule>>(SERVER_ADDR, GROUP_ID, DATA_ID,
// source.getObject("flow", new TypeReference<List<FlowRule>>() {})
//);
//误入歧途结束
ReadableDataSource<String, List<FlowRule>> flowRuleDataSource = new NacosDataSource<List<FlowRule>>(SERVER_ADDR, GROUP_ID, DATA_ID,
new Converter<String, List<FlowRule>>() {
@Override
public List<FlowRule> convert(String source) {
return JSON.parseObject(source, new TypeReference<List<FlowRule>>() {
});
}
});
FlowRuleManager.register2Property(flowRuleDataSource.getProperty());
}
}
然后在 resources 目录下创建 META-INF/services 目录,并在该目录中新建 com.alibaba.csp.sentinel.init.InitFunc 文件,文件内容如下:
com.higlowx.scal.ebpp.service.bill.config.spi.SentinelDataSourceInitFunc
运行后得到的结论:
官方提供的该方法只可以做到应用从 Nacos 端获取 Sentinel 配置,且内部会监听 Nacos push 过来的配置更新,后续会自动刷到本实例内存中。
如果我们想要把 Nacos 配置集的更新集成到自有的 DevOps 中,且不是通过最原始的手动配置方式,官方也给出了一些简易示例:
public class NacosConfigSender {
public static void main(String[] args) throws Exception {
final String remoteAddress = "localhost:8848";
final String groupId = "Sentinel_Demo";
final String dataId = "com.alibaba.csp.sentinel.demo.flow.rule";
final String rule = "[\n"
+ " {\n"
+ " \"resource\": \"TestResource\",\n"
+ " \"controlBehavior\": 0,\n"
+ " \"count\": 5.0,\n"
+ " \"grade\": 1,\n"
+ " \"limitApp\": \"default\",\n"
+ " \"strategy\": 0\n"
+ " }\n"
+ "]";
ConfigService configService = NacosFactory.createConfigService(remoteAddress);
System.out.println(configService.publishConfig(dataId, groupId, rule));
}
}
Seata
Seata Client(TM 与 RM)
在 Seata 的架构中,有三个重要的组成部分:
- TM(Transaction Manager):事务管理器,定义全局事务的范围,开始、提交或回滚全局事务;
- TC(Transaction Coordinator):事务协调者,是 Seata Server 端,维护全局和分支事务的状态,驱动全局事务提交或回滚;
- RM(Resource Manager):资源管理器,管理分支事务处理的资源,与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
其中,除了 TC 需要以独立进程单独部署集群外,TM 和 RM 会以 JAR 包依赖的形式被嵌入进 APP 中,无论是 Server 还是 Client 都支持配置中心与服务中心,我们依旧还是会选用官方推荐的自家组件 Nacos 来完成。
在 TC 集群搭建完成之后,我们就可以开展业务代码端的接入工作了。Seata Client 的接入需要引入统一的客户端依赖,该依赖对于 TM 和 RM 是相同的。
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
在引入依赖后需要进行 bootstrap.properties 参数的设置,以 bill 服务为例,可以分为以下几个部分:
- 用于 TM、RM 路由发现 TC 集群的服务注册中心配置:
#seata registry
seata.registry.type=nacos
seata.registry.nacos.application=ebpp-seata-server
seata.registry.nacos.server-addr=127.0.0.1:8848
seata.registry.nacos.group=EBPP_GROUP
seata.registry.nacos.cluster=default
- 用于从配置中心获取 Client 自身通用基础配置项的配置:
#seata config
seata.config.type=nacos
seata.config.nacos.group=SEATA_GROUP
seata.config.nacos.server-addr=127.0.0.1:8848
- 用于设置每个 Client 自身特有配置项的配置:
seata.enabled=true
seata.service.disable-global-transaction=false
spring.cloud.alibaba.seata.tx-service-group=ebpp-service-bill-tx-group
seata.service.vgroup-mapping.ebpp-service-bill-tx-group=default
以上配置项的详细说明可以在 这里 找到,本篇不再单独说明。
另外,有一个尤其需要注意的事前工作,在我们启动 Server 和 Client 之前,Nacos 配置中心中必须要有它们的基础启动配置,否则将无法启动。官方也提供给了我们方便的初始化工具,由 配置集txt 和 向配置中心请求初始化配置的脚本 组成。脚本的执行指令如下:
sh ${SCRIPTPATH}/nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t 5a3c7d6c-f497-4d68-a71a-2e5e3340b3ca -u username -w password
# -h: host, the default value is localhost.
# -p: port, the default value is 8848.
# -g: Configure grouping, the default value is 'SEATA_GROUP'.
# -t: Tenant information, corresponding to the namespace ID field of Nacos, the default value is ''.
# -u: username, nacos 1.2.0+ on permission control, the default value is ''.
# -w: password, nacos 1.2.0+ on permission control, the default value is ''.
有的时候,脚本会找不到配置集 config.txt ,这时候需要简单修改下 nacos-config.sh 脚本中的文件路径即可。
最后的工作,就是根据业务应用要用到的 Seata 分布式事务模式,完成一些业务数据库的表建造,不同的模式对应的表是不太一样的,可以在这里找到:AT 、TCC 、SAGA 。
在具体业务中,服务的调用顺序对分布式事务的影响
我们可以从一个具体的业务情景带入叙述,一方面可以重温一下为何会产生分布式事务问题,另一方面可以研究下 Seata 应该在哪些部位发挥作用。同样以一次成功付款为例,系统可以使用A和B两种调用顺序完成处理。先看顺序A:

如果 trade 服务内部所有的本地事务全部执行成功,之后再调用bill服务去创建账单(注意这个调用处理是同步的)。如果 bill 服务在创建账单的时候因为某些异常导致执行失败,在有本地事务保障的情况下,bill 服务本身不会产生脏数据。后续的,在同步响应回 trade 服务后,trade 服务也能迅速感知到创建账单失败,并回滚掉自己本地的事务,最终在调用 ROOT 处返回处理失败,整个过程结束。
我们发现在刚才的流程中,如果不出现其他问题的话,是不存在分布式事务问题的。这个过程可以形象地概括为“做好自己的事再去请求别人”,整个线性调用有机地弥补了分布式系统带来的事务问题。然而要实现如此美好的愿景,需要一个大前提,即在整个调用链路的各个 JVM 实例外部,所有环节要素必须是正常且迅速的,这其中就包括了使用到的中间件、网络状况、数据库资源等,这么多外部要素一直保持正常很明显是不现实的。 在 trade 服务调用 bill 服务的过程中,如果由于上述的某种原因导致响应超时,这时候 trade 服务可以感知到并进行回滚,但是 bill 服务就不一定了:可能处理是成功的,仅仅是返回超时了;也有可能是失败的。 一般的,微服务之间还会接入限流、降级和熔断这样的调用保障,如此一来,消费方能够更加迅速地应对调用生产方时出现的异常,并做出反馈,更加加剧了上述问题的产生,这时候就需要选用适当的分布式事务解决方案来尽量避免这种情况的发生。
顺序B:

如果 trade 服务在执行完本地所有的事务性逻辑之前,调用了 bill 服务去创建账单。假设 bill 服务的处理响应是成功且快速的。后续如果 trade 服务内部再出现异常,导致本地事务回滚,这种情况下,bill 本地事务已经提交了,不可能再进行回滚,其服务数据如何补偿就成了极其重要的一环。比较容易想到的是单独再去调用删除脏数据的 API(不论是同步还是异步的),这样不仅增加了开发成本,还增大了调用链路的复杂度,很难保证不会再次发生问题,而且事务的 ACID 特性已被打破,脏读也有发生的可能性。 上述这种情况也是十分典型的分布式事务问题。
OpenFeign
Feign Client
本篇中使用的 RPC Client 是 Spring Cloud 生态中 OpenFeign,它是一种基于 http 协议的声明式的消费客户端,内置了 Ribbon 负载均衡组件,只需要简单的配置,就可以帮助我们实现简单快捷的生产消费体验。
以 trade 服务调用消费 bill 服务为例,我们需要在消费端(trade)引入依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
之后在启动配置中加入下面的简单配置,详细配置可以根据实际需要去增加删减:
#feign客户端统一配置:连接超时时间,单位毫秒,指建立连接最多花费的时间,超过则抛出connect timeout异常
feign.client.config.default.connect-timeout=3000
#feign客户端统一配置:读取超时间,单位毫秒,指建立连接之后,读取网络资源最多花费的时间,超出则抛出read timeout异常
feign.client.config.default.read-timeout=5000
#开启feign对sentinel的支持
#feign.sentinel.enabled=true
值得注意的是,要启用 Feign Client,必须要在 APP 启动类上加上 @EnableFeignClients 注解,这样在进程启动的时候,Spring 才可以扫描注入我们编写的 Feign Client。
Feign Client 如何编写与维护
以消费 bill 服务的账单类接口为例,一个规范的消费客户端长这个样子:
import org.springframework.cloud.openfeign.FeignClient;
import com.higlowx.scal.ebpp.service.bill.route.BillMainRoute;
@FeignClient(name = "ebpp-service-bill",fallbackFactory = BillClientFallbackFactory.class)
public interface BillClient extends BillMainRoute {
}
其中所继承的 BillMainRoute 可以是这样的:
import com.higlowx.scal.ebpp.common.consts.UriConsts;
import com.higlowx.scal.ebpp.common.res.UnifiedResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
@RequestMapping(UriConsts.BILL_PREFIX + "/main")
public interface BillMainRoute {
@GetMapping("/create")
UnifiedResponse<Object> create(@RequestParam BigDecimal amount, @RequestParam Integer tradeId);
}
BillClientFallbackFactory 负责消费端的消费失败后处理,可用于补救、重试或快速失败,:
import com.higlowx.scal.ebpp.common.res.UnifiedResponse;
import com.higlowx.scal.ebpp.common.res.UnifiedResponseCode;
import feign.hystrix.FallbackFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
/**
* 本实例必须被spring管理!!
*/
@Component
public class BillClientFallbackFactory implements FallbackFactory<BillClient> {
private static final Logger log = LoggerFactory.getLogger(BillClientFallbackFactory.class);
@Override
public BillClient create(Throwable throwable) {
log.warn("bill服务触发sentinel的熔断限流机制,trade侧调用fallback逻辑兼容rpc故障", throwable);
return new BillClient() {
@Override
public UnifiedResponse<Object> create(BigDecimal amount, Integer tradeId) {
return UnifiedResponse.out(UnifiedResponseCode.UNIFIED_FAIL, "bill接口被限流、熔断或降级");
}
};
}
}
这里,如果你对这种继承与依赖关系感兴趣的话,也可以看一下 Bill 对应的 Restful 接口是什么样子的:
import com.alibaba.fastjson.JSON;
import com.higlowx.scal.ebpp.common.res.UnifiedResponse;
import com.higlowx.scal.ebpp.common.res.UnifiedResponseCode;
import com.higlowx.scal.ebpp.service.bill.entity.Bill;
import com.higlowx.scal.ebpp.service.bill.route.BillMainRoute;
import com.higlowx.scal.ebpp.service.bill.service.BillService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
@RestController
public class MainController implements BillMainRoute {
private static final Logger LOG = LoggerFactory.getLogger(MainController.class);
@Autowired
private BillService billService;
@Override
public UnifiedResponse<Object> create(BigDecimal amount, Integer tradeId) {
Bill bill = billService.create(amount, tradeId);
UnifiedResponse<Object> out = UnifiedResponse.out(UnifiedResponseCode.OK, bill);
LOG.info("out: {}", JSON.toJSONString(out));
return out;
}
}
以上各个组件的关系可以总结为:基于 Route 接口声明,生产者实现,消费者继承。这样的结构在实际的开发维护中,会大大增加我们的工作效率。有兴趣的也可思考一下,Feign Client 可以直接交由生产者开发维护,并向消费者提供 JAR 包依赖吗,对应的原因是怎样的。
Spring Cloud Gateway
部署启动
该网关的部署比较简单,其本质上基于 webflux 和 loadbalancer,是 Spring Cloud 开源生态中的一员,部署 Spring Cloud Gateway 就像部署一个 Spring Cloud 业务应用一样简单。生产环境中,集群部署以及高性能的云原声环境可以为微服务网关提供HA支撑。
此次将仅介绍单机该如何部署。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
在引入上面的依赖后,分别创建启动类与 bootstrap.properties 配置文件,输入命令运行启动类便可开启我们的网关服务。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 启动类
**/
@SpringBootApplication
public class AppStart {
public static void main(String[] args) {
SpringApplication.run(AppStart.class, args);
}
}
# 简单的配置文件
server.port=9900
spring.application.name=ebpp-gateway
spring.profiles.active=dev
接入服务
仅仅启动服务是远远不够的,作为一个合格的网关,它应该具有路由分发、统一鉴权、限流等能力,其中前两者可以使用 Spring Cloud Gateway 自带的机制实现,此外,网关的限流可以通过接入 Sentinel 实现。
关于路由分发的配置示例(基于 Nacos 注册中心寻址业务应用的方式):
- 引入 nacos-discovery 依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
- 配置文件示例
#注册中心配置
spring.cloud.nacos.discovery.group=EBPP_GROUP
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
#让gateway从nacos中获取服务信息
spring.cloud.gateway.discovery.locator.enabled=true
#路由配置
spring.cloud.gateway.routes[0].id=ebpp-service-trade-route
spring.cloud.gateway.routes[0].uri=lb://ebpp-service-trade
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args.Path=/trade/**
spring.cloud.gateway.routes[1].id=ebpp-service-bill-route
spring.cloud.gateway.routes[1].uri=lb://ebpp-service-bill
spring.cloud.gateway.routes[1].predicates[0].name=Path
spring.cloud.gateway.routes[1].predicates[0].args.Path=/bill/**
关于实现网关自身限流的示例:
- 引入 Sentinel 依赖
<!-- sentinel dependence for service instance itself -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!-- sentinel dependence for adapting spring cloud gateway -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
- 完成基本的配置
#是否提前触发sentinel初始化(sentinel会在客户端首次调用的时候进行初始化,开始向控制台发送心跳包)
spring.cloud.sentinel.eager=true
#应用与sentinel控制台交互的端口,应用本地会起一个该端口占用的HttpServer
spring.cloud.sentinel.transport.port=8719
#sentinel控制台地址
spring.cloud.sentinel.transport.dashboard=localhost:8080
#应用与sentinel控制台的心跳间隔时间
spring.cloud.sentinel.transport.heartbeat-interval-ms=5000
#限流规则的设置与本篇中提到的相同,这里不再赘述
在实际生产中,网关中的路由规则是会不断变化的,每次有新应用加入,对应的路由规则也必须要进行修改。如果频繁地去修改路由规则并重启 gateway,势必会严重影响整个系统的可用性。可喜的是,Nacos Config 为 Spring Cloud Gateway 提供了支持,让它得以在持续运行的状态下动态更新路由规则和其他必要的参数,而且接入方式与接入普通业务系统是相同的,有兴趣的可以参考下本文中曾经描述的 Nacos Config 使用部分,这里同样不再赘述。
总结
随着应用系统复杂度和业务量的增多,对系统进行微服务化改造是必须要经历的重要一步。Spring Cloud Alibaba 为我们提供了更加适用于国内业务场景的完整解决方案。本篇仅仅只是介绍了比较初级的使用,对于一些可能存在的问题无法有效地排查出去。而在其真正落地到生产中去后,这套方案可能会给我们开发者带来更多的惊喜,当然也有可能是各种问题不断。但技术总是有两面性,解决了痛点往往伴随着新的痛点的产生,也正是这种两面性,使得技术得以不断进步。最后,我想说,没有真正完美的解决方案,只有最适合的解决方案,我们程序员的工作就是在各种权衡利弊中找到问题的最优解。
项目源码;
HA 汇总:Nacos; Seata; Spring Cloud Gateway;