1. 系统架构的演变
软件架构的发展经历了从单体架构、分布式架构、SOA架构到微服务架构的过程。
1.1 单体架构
Web 应用程序发展的早期,大部分 web 工程师将所有的功能模块打包到一起并放在一个 web 容器中运行,所有功能模块使用同一个数据库。
下图是一个单体架构的电商系统:
特点:
- 所有的功能集成在一个项目工程中。
- 所有的功能打在一个 war 包部署到服务器。
- 通过部署应用集群和数据库集群来提高系统的性能。
优点:
- 项目架构简单,前期开发成本低,周期短,小型项目的首选。
- 开发效率高,模块之间交互采用本地方法调用。
- 容易部署,运维成本小,直接打包为一个完整的包,拷贝到 web 容器的某个目录下即可运行。
- 容易测试:IDE 都是为开发单个应用设计的、容易测试,在本地就可以启动完整的系统。
缺点:
- 全部功能集成在一个工程中,对于大型项目不易开发、扩展及维护。
- 版本迭代速度逐渐变慢,修改一个地方就要将整个应用全部编译、部署、启动,开发及测试周期过长。
- 无法按需伸缩,通过集群的方式来实现水平扩展,无法针对某业务按需伸缩。
1.2 分布式架构
针对单体架构的不足,为了适应大型项目的开发需求,许多公司将一个单体系统按业务垂直拆分为若干系统,系统之间通过网络交互来完成用户的业务处理,每个系统可分布式部署,这种架构称为分布式架构。
特点:
- 按业务垂直拆分成一个一个的单体系统,此架构也称为垂直架构。
- 系统与系统之间的存在数据冗余,耦合性较大,如上图中三个项目都存在客户信息。
- 系统之间的接口多为实现数据同步,如上图中三个项目要同步客户信息。
优点:
- 通过垂直拆分,每个子系统变成小型系统,功能简单,前期开发成本低,周期短。
- 每个子系统可按需伸缩。
- 每个子系统可采用不同的技术。
缺点:
- 子系统之间存在数据冗余、功能冗余,耦合性高。
- 按需伸缩粒度不够,对同一个子系统中的不同的业务无法实现,比如订单管理和用户管理。
1.3 SOA 架构
SOA 是一种面向服务的架构,基于分布式架构,它将不同业务功能按服务进行拆分,并通过这些服务之间定义良好的接口和协议联系起来。
特点:
- 基于 SOA 的架构思想,将重复公用的功能抽取为组件,以服务的方式向各各系统提供服务。
- 各各系统与服务之间采用 webservice、rpc 等方式进行通信。
- ESB 企业服务总线作为系统与服务之间通信的桥梁。
优点:
- 将重复的功能抽取为服务,提高开发效率,提高系统的可重用性、可维护性。
- 可以针对不同服务的特点按需伸缩。
- 采用 ESB 减少系统中的接口耦合。
缺点:
- 系统与服务的界限模糊,会导致抽取的服务的粒度过大,系统与服务之间耦合性高。
- 虽然使用了 ESB,但是服务的接口协议不固定,种类繁多,不利于系统维护。
1.4 微服务架构
基于 SOA 架构的思想,为了满足移动互联网对大型项目及多客户端的需求,对服务层进行细粒度的拆分,所拆分的每个服务只完成某个特定的业务功能,比如订单服务只实现订单相关的业务,用户服务实现用户管理相关的业务等等,服务的粒度很小,所以称为微服务架构。
特点:
- 服务层按业务拆分为一个一个的微服务。
- 微服务的职责单一。
- 微服务之间采用 RESTful、RPC 等轻量级协议传输。
- 有利于采用前后端分离架构。
优点:
- 服务拆分粒度更细,有利于资源重复利用,提高开发效率。
- 可以更加精准的制定每个服务的优化方案,按需伸缩。
- 适用于互联网时代,产品迭代周期更短。
缺点:
- 开发的复杂性增加,因为一个业务流程需要多个微服务通过网络交互来完成。
- 微服务过多,服务治理成本高,不利于系统维护。
以上内容参考:http://www.pbteach.com/article/framework/framework_evolution/
2. 远程服务调用
2.1 RPC 和 HTTP
无论是 SOA 还是微服务,都面临着远程服务调用。那么远程服务调用方式有哪些呢?
常见的远程服务调用方式有以下 2 种:
- RPC:远程过程调用协议,类似的还有 RMI。基于原生 TCP 通信,自定义数据格式,速度快,效率高。早期的 Webservice,现在热门的 Dubbo,都是 RPC 的典型代表。
- HTTP:HTTP 其实是一种网络传输协议,基于 TCP 协议,规定了数据传输的格式。现在客户端浏览器与服务端通信基本都是采用 HTTP 协议,也可以用来进行远程服务调用。缺点是消息封装臃肿,优点是对服务的提供和调用方没有任何技术限定,自由灵活,更符合微服务理念。现在的 SpringCloud,就是 HTTP 的典型代表。
2.2 RestTemplate
Spring 提供了一个 RestTemplate 模板工具类,对基于 HTTP 的客户端进行了封装,并且实现了对象与 JSON 的序列化和反序列化,非常方便。
下面介绍 RestTemplate 的使用:
-
创建 RestTemplate 配置类,注册一个 RestTemplate 对象
@Configuration public class RestTemplateConfiguration { @Bean public RestTemplate restTemplate() { return new RestTemplate(); } }
-
在测试类中注入 RestTemplate 对象,并通过 RestTemplate 的 getForObject() 方法将得到的 JSON 字符串反序列化为 User 对象
@RunWith(SpringRunner.class) @SpringBootTest(classes = Demo2Application.class) public class UserTest { @Autowired private RestTemplate restTemplate; @Test public void testGet() { User user = restTemplate.getForObject("http://localhost/user/findById/52", User.class); System.out.println(user); } }
3. SpringCloud 简介
SpringCloud 是一套完整的微服务解决方案,基于 SpringBoot 框架。SpringCloud 将现在非常流行的一些技术整合到一起,实现了诸如:配置管理,服务发现,智能路由,负载均衡,熔断器,控制总线,集群状态等等功能。其主要涉及的组件包括:
- Eureka:服务治理组件,包含服务注册中心,服务注册与发现机制的实现。(服务治理,服务注册/发现)
- Zuul:网关组件,提供智能路由,访问过滤功能
- Ribbon:客户端负载均衡的服务调用组件(客户端负载)
- Feign:服务调用,给予Ribbon和Hystrix的声明式服务调用组件 (声明式服务调用)
- Hystrix:容错管理组件,实现断路器模式,帮助服务依赖中出现的延迟和为故障提供强大的容错能力。(熔断、断路器,容错)
架构图:
4. 微服务场景模拟
我们模拟一个服务调用的场景,搭建两个工程:zt-service-provider(服务提供方)和 zt-service-customer(服务调用方)。
服务提供方:使用 Mybatis 操作数据库,实现对数据的增删改查,并对外提供 Rest 接口服务。
服务消费方:使用 RestTemplate 远程调用服务提供方的 Rest 接口服务,获取数据。
4.1 服务提供方
4.1.1 创建数据库
DROP TABLE IF EXISTS `table_user`;
CREATE TABLE `table_user` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`user_name` varchar(20) DEFAULT NULL,
`user_gender` char(2) DEFAULT '未知',
`user_address` varchar(30) DEFAULT NULL,
`user_birthday` date DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8;
INSERT INTO `table_user` VALUES ('1', '张三', '男', '北京', '2020-01-15');
INSERT INTO `table_user` VALUES ('2', '李四', '男', '上海', '2020-01-14');
INSERT INTO `table_user` VALUES ('3', '王五', '女', '广州', '2020-01-12');
4.1.2 创建工程
-
打开 IDEA
-
Spring Initializr --> Next
-
填写项目信息 --> Next
-
添加依赖 --> Next
-
填写项目位置 --> Finish
4.1.3 编写代码
-
引入通用 mapper 依赖
<!--通用 Mapper--> <dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper-spring-boot-starter</artifactId> <version>2.1.5</version> </dependency>
-
编写配置文件 application.yaml
server: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/mapper?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8 username: root password: 123456
-
修改引导类,在类上添加 @MapperScan 注解
@SpringBootApplication @MapperScan("com.zt.mapper") public class ZtServiceProviderApplication { public static void main(String[] args) { SpringApplication.run(ZtServiceProviderApplication.class, args); } }
-
创建实体类
package com.zt.pojo; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import java.io.Serializable; import java.util.Date; @Table(name = "table_user") public class User implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer userId; private String userName; private String userGender; private String userAddress; private Date userBirthday; public User() { } public User(String userName, String userGender, String userAddress, Date userBirthday) { this.userName = userName; this.userGender = userGender; this.userAddress = userAddress; this.userBirthday = userBirthday; } public Integer getUserId() { return userId; } public void setUserId(Integer userId) { this.userId = userId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public String getUserGender() { return userGender; } public void setUserGender(String userGender) { this.userGender = userGender; } public String getUserAddress() { return userAddress; } public void setUserAddress(String userAddress) { this.userAddress = userAddress; } public Date getUserBirthday() { return userBirthday; } public void setUserBirthday(Date userBirthday) { this.userBirthday = userBirthday; } @Override public String toString() { return "User{" + "userId=" + userId + ", userName='" + userName + '\'' + ", userGender='" + userGender + '\'' + ", userAddress='" + userAddress + '\'' + ", userBirthday=" + userBirthday + '}'; } }
-
创建持久层
public interface UserMapper extends Mapper<User> { }
-
创建业务层
@Service public class UserService { @Autowired private UserMapper userMapper; public User queryUserById(Integer id) { return userMapper.selectByPrimaryKey(id); } }
-
创建控制层
@RestController @RequestMapping("user") public class UserController { @Autowired private UserService userService; @GetMapping("{id}") public User queryUserById(@PathVariable("id") Integer id) { return userService.queryUserById(id); } }
4.1.4 启动并测试
-
启动项目
-
打开浏览器,访问下面的地址
http://localhost:8081/user/1
4.2 服务消费方
4.2.1 创建工程
-
打开 IDEA
-
Spring Initializr --> Next
-
填写项目信息 --> Next
-
添加依赖 --> Next
-
填写项目位置 --> Finish
4.2.2 编写代码
-
编写配置文件 application.yaml
server: port: 8082
-
修改引导类,创建一个 RestTemplate 的 Bean 对象
@SpringBootApplication public class ZtServiceCustomerApplication { @Bean public RestTemplate restTemplate() { return new RestTemplate(); } public static void main(String[] args) { SpringApplication.run(ZtServiceCustomerApplication.class, args); } }
-
创建实体类
public class User implements Serializable { private Integer userId; private String userName; private String userGender; private String userAddress; private Date userBirthday; public User() { } public User(String userName, String userGender, String userAddress, Date userBirthday) { this.userName = userName; this.userGender = userGender; this.userAddress = userAddress; this.userBirthday = userBirthday; } public Integer getUserId() { return userId; } public void setUserId(Integer userId) { this.userId = userId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public String getUserGender() { return userGender; } public void setUserGender(String userGender) { this.userGender = userGender; } public String getUserAddress() { return userAddress; } public void setUserAddress(String userAddress) { this.userAddress = userAddress; } public Date getUserBirthday() { return userBirthday; } public void setUserBirthday(Date userBirthday) { this.userBirthday = userBirthday; } @Override public String toString() { return "User{" + "userId=" + userId + ", userName='" + userName + '\'' + ", userGender='" + userGender + '\'' + ", userAddress='" + userAddress + '\'' + ", userBirthday=" + userBirthday + '}'; } }
-
创建控制层
@Controller @RequestMapping("user") public class UserController { @Autowired private RestTemplate restTemplate; @GetMapping @ResponseBody public User queryUserById(@RequestParam("id") Integer id) { return restTemplate.getForObject("http://localhost:8081/user/"+id,User.class); } }
4.2.3 启动并测试
-
启动服务提供方项目
-
启动服务消费方项目
-
打开浏览器,访问下面的地址
http://localhost:8082/user?id=1
4.3 存在的问题
-
简单回顾一下,刚才我们写了什么
- 服务提供方:使用 Mybatis 操作数据库,实现对数据的增删改查,并对外提供 Rest 接口服务。
- 服务消费方:使用 RestTemplate 远程调用服务提供方的 Rest 接口服务,获取数据。
-
存在的问题
- 在 customer 中,我们把 url 地址硬编码到了代码中,不方便后期维护
- customer 需要记忆 provider 的地址,如果出现变更,可能得不到通知,地址将失效
- customer 不清楚 provider 的状态,服务宕机也不知道
- provider 只有 1 台服务,不具备高可用性
- 即便 provider 形成集群,consumer 还需自己实现负载均衡
-
其实上面说的问题,概括一下就是分布式服务必然要面临的问题
- 服务管理
- 如何自动注册和发现
- 如何实现状态监管
- 如何实现动态路由
- 服务如何实现负载均衡
- 服务如何解决容灾问题
- 服务如何实现统一配置
- 服务管理
-
其实上面的问题在 SpringCloud 中都得到了解决,接下来看看 SpringCloud 是如何解决的
5. Eureka 服务注册中心
5.1 Eureka 的概念
Eureka 是一个服务治理组件,它实现了服务的自动注册、发现、状态监控。
它主要包括两个组件:Eureka Server 和 Eureka Client
- Eureka Server 提供服务注册和发现的功能,也就是微服务中的注册中心。
- Eureka Client 是一个 Java 客户端,用于简化与 Eureka Server 的交互,也就是微服务中的客户端和服务端。
每个微服务启动时,都会通过 Eureka Client 向 Eureka Server 注册自己,Eureka Server 会存储该服务的信息。
5.2 Eureka 的作用
Eureka 负责管理、记录服务提供者的信息。服务调用者无需自己寻找服务,而是把自己的需求告诉 Eureka,然后 Eureka 会把符合你需求的服务告诉你。
同时,服务提供方与 Eureka 之间通过 “心跳” 机制进行监控,当某个服务提供方出现问题,Eureka 自然会把它从服务列表中剔除。
5.3 Eureka 原理图
- Eureka:就是服务注册中心(可以是一个集群),对外暴露自己的地址
- 提供者:启动后向 Eureka 注册自己信息(地址,提供什么服务)
- 消费者:向 Eureka 订阅服务,Eureka 会将对应服务的所有提供者地址列表发送给消费者,并且定期更新
- 心跳(续约):提供者定期通过 http 方式向 Eureka 刷新自己的状态
5.4 搭建 Eureka Server
5.4.1 创建工程
-
打开 IDEA
-
Spring Initializr --> Next
-
填写项目信息 --> Next
-
添加依赖 --> Next
-
填写项目位置 --> Finish
5.4.2 Eureka Server 的配置
-
修改引导类,在类上添加 @EnableEurekaServer 注解
@SpringBootApplication @EnableEurekaServer public class ZtEurekaApplication { public static void main(String[] args) { SpringApplication.run(ZtEurekaApplication.class, args); } }
-
编写配置文件 application.yaml
# 服务端口号 server: port: 10086 # 服务名称 spring: application: name: eureka-server # Eureka 相关配置 eureka: client: # Eureka 默认的服务地址空间信息配置 service-url: defaultZone: http://localhost:${server.port}/eureka # 是否从其他的服务中心同步服务列表 fetch-registry: false # 是否把自己作为服务注册到其他服务注册中心 register-with-eureka: false
5.4.3 启动并测试
-
启动项目
-
打开浏览器,访问以下地址
http://localhost:10086/
-
成功访问 Eureka Server 管理界面
5.5 注册服务到 Eureka
5.5.1 注册服务提供方
-
打开 zt-service-provider 工程
-
在 pom.xml 中,添加 SpringCloud 的相关依赖
-
参照 Eureka Server,添加 SpringCloud 依赖
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
-
添加 Eureka 客户端依赖
<!-- Eureka客户端 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency>
-
-
修改配置文件 application.yaml
server: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/mapper?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8 username: root password: 123456 application: name: service-provider eureka: client: service-url: defaultZone: http://localhost:10086/eureka
-
修改引导类,在类上添加 @EnableDiscoveryClient 注解来开启 Eureka 客户端功能
@SpringBootApplication @MapperScan("com.zt.mapper") @EnableDiscoveryClient public class ZtServiceProviderApplication { public static void main(String[] args) { SpringApplication.run(ZtServiceProviderApplication.class, args); } }
-
启动 zt-service-provider 项目
-
打开浏览器,访问以下地址
http://localhost:10086/
-
发现成功注册服务提供方
5.5.2 注册服务消费方
和上面的注册服务提供方是一样的流程,这里就不写了,注册结果如下:
5.6 优化服务消费方
前面说过服务消费方中,还存在 url 硬编码问题,接下来用 Eureka 来解决这个问题
-
修改 UserController
@Controller @RequestMapping("user") public class UserController { @Autowired private RestTemplate restTemplate; @Autowired private DiscoveryClient discoveryClient; // Eureka客户端,可以获取到 Eureka 中服务的信息 @GetMapping @ResponseBody public User queryUserById(@RequestParam("id") Integer id) { List<ServiceInstance> instances = discoveryClient.getInstances("service-provider"); // 根据服务名称,获取服务实例 ServiceInstance serviceInstance = instances.get(0); // 获取第一个实例 return restTemplate.getForObject("http://"+serviceInstance.getHost()+":"+serviceInstance.getPort()+"/user/" + id, User.class); } }
-
重新启动服务消费方项目
-
打开浏览器,访问下面的地址
http://localhost:8082/user?id=2
5.7 高可用 Eureka Server
Eureka Server 即服务的注册中心,在刚才的案例中,我们只有一个 Eureka Server,事实上 Eureka Server 也可以是一个集群,这样就形成了高可用 Eureka 注册中心。
5.7.1 服务同步
多个 Eureka Server 之间也会互相注册为服务,当服务提供者注册到 Eureka Server 集群中的某个节点时,该节点会把服务的信息同步给集群中的每个节点,从而实现数据同步。因此,无论客户端访问到 Eureka Server 集群中的任意一个节点,都可以获取到完整的服务列表信息。
5.7.2 搭建高可用的 Eureka Server
我们假设要运行两个 Eureka Server 的集群,端口分别为:10086 和 10087。只需要把 zt-eureka 这个项目启动两次即可。
-
修改 zt-eureka 的配置文件 application.yaml
# 服务端口号 server: port: 10086 # 服务名称 spring: application: name: eureka-server # Eureka 相关配置 eureka: client: # Eureka 默认的服务地址空间信息配置 service-url: defaultZone: http://localhost:10087/eureka # 配置其他 Eureka 服务的地址
-
启动第一个 zt-eureka 项目
注意:此时,会肯定会报错,因为会找不到 10087 这个节点。不用管这个错误,继续下去即可。
-
再次修改 zt-eureka 的配置文件 application.yaml
# 服务端口号 server: port: 10087 # 服务名称 spring: application: name: eureka-server # Eureka 相关配置 eureka: client: # Eureka 默认的服务地址空间信息配置 service-url: defaultZone: http://localhost:10086/eureka # 配置其他 Eureka 服务的地址
-
再次启动第二个 zt-eureka 项目
IDEA 中一个应用不能启动两次,因此我们需要重新配置一个启动器
-
访问集群
5.8 Eureka 详解
5.8.1 服务提供者
服务注册
服务提供者在启动时,会检测配置属性中的:eureka.client.register-with-eureka=true
参数,如果值确实为 true,则会向 EurekaServer 发起一个 Rest 请求,并携带自己的元数据信息,Eureka Server 会把这些信息保存到一个双层 Map 结构中。
- 第一层 Map 的 Key 就是服务 id,一般是配置中的
spring.application.name
属性 - 第二层 Map 的 key 是服务的实例 id。一般 host+ serviceId + port,例如:
locahost:service-provider:8081
- 值则是服务的实例对象,也就是说一个服务,可以同时启动多个不同实例,形成集群。
服务续约
在注册服务完成以后,服务提供者会维持一个心跳(定时向 EurekaServer 发起 Rest 请求),告诉 EurekaServer “我还活着”,我们又称为服务的续约(renew)。
有两个重要参数可以修改服务续约的行为:
eureka:
instance:
lease-expiration-duration-in-seconds: 90
lease-renewal-interval-in-seconds: 30
- lease-renewal-interval-in-seconds:服务续约(renew)的间隔,默认为 30 秒
- lease-expiration-duration-in-seconds:服务失效时间,默认值 90 秒
也就是说,默认情况下每个 30 秒服务会向注册中心发送一次心跳,证明自己还活着。如果超过 90 秒没有发送心跳,EurekaServer 就会认为该服务宕机,会从服务列表中移除,这两个值在生产环境不要修改,默认即可。
5.8.2 服务消费者
获取服务列表
当服务消费者启动时,会检测eureka.client.fetch-registry=true
参数,如果值为 true,则会拉取 Eureka Server 服务的列表只读备份,然后缓存在本地。并且每隔 30 秒会重新获取并更新数据。我们可以通过下面的参数来修改:
eureka:
client:
registry-fetch-interval-seconds: 30
生产环境中,我们不需要修改这个值,默认即可。
5.8.3 失效剔除和自我保护
服务下线
当服务进行正常关闭操作时,它会触发一个服务下线的 REST 请求给 Eureka Server,告诉服务注册中心:“我要下线了”。服务中心接受到请求之后,将该服务置为下线状态。
失效剔除
有些时候,我们的服务提供方并不一定会正常下线,可能因为内存溢出、网络故障等原因导致服务无法正常工作。Eureka Server 需要将这样的服务剔除出服务列表,因此它会开启一个定时任务,每隔 60 秒对所有失效的服务(超过90秒未响应)进行剔除。
可以通过eureka.server.eviction-interval-timer-in-ms
参数对其进行修改,单位是毫秒,生产环境不要修改。
eureka:
server:
eviction-interval-timer-in-ms: 60000
自我保护
我们关停一个服务,就会在Eureka面板看到一条警告:
这是触发了 Eureka 的自我保护机制。当一个服务未按时进行心跳续约时,Eureka 会统计最近 15 分钟心跳失败的服务实例的比例是否超过了 85%。在生产环境下,因为网络延迟等原因,心跳失败实例的比例很有可能超标,但是此时就把服务剔除列表并不妥当,因为服务可能没有宕机。Eureka 就会把当前实例的注册信息保护起来,不予剔除。生产环境下这很有效,保证了大多数服务依然可用。
但是这给我们的开发带来了麻烦, 因此开发阶段我们都会关闭自我保护模式:
eureka:
server:
enable-self-preservation: false # 关闭自我保护模式(缺省为打开)