微服务基础
代码地址
Docker
自定义网络中可以通过容器名相互访问。
version: "3.8"
services:
mysql:
image: mysql
container_name: mysql
ports:
- "3306:3306"
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: 123
volumes:
- "./mysql/conf:/etc/mysql/conf.d"
- "./mysql/data:/var/lib/mysql"
- "./mysql/init:/docker-entrypoint-initdb.d"
networks:
- hm-net
hmall:
build:
context: .
dockerfile: Dockerfile
container_name: hmall
ports:
- "8080:8080"
networks:
- hm-net
depends_on:
- mysql
nginx:
image: nginx
container_name: nginx
ports:
- "18080:18080"
- "18081:18081"
volumes:
- "./nginx/nginx.conf:/etc/nginx/nginx.conf"
- "./nginx/html:/usr/share/nginx/html"
depends_on:
- hmall
networks:
- hm-net
networks:
hm-net:
name: hmall
# mysql docker run
docker run -d \
--name mysql \
-p 3306:3306 \
-e TZ=Asia/Shanghai \
-e MYSQL_ROOT_PASSWORD=123 \
-v /root/mysql/data:/var/lib/mysql \
-v /root/mysql/conf:/etc/mysql/conf.d \
-v /root/mysql/init:/docker-entrypoint-initdb.d \
--network my-net\
mysql
微服务
注册中心
原理:注册,订阅,续约,负载均衡
nacos配置:
docker run -d \
--name nacos \
--env-file /root/docker/nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
--network my-net\
nacos/nacos-server:v2.1.0-slim
OpenFeign连接池:
Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:
- HttpURLConnection:默认实现,不支持连接池
- Apache HttpClient :支持连接池
- OKHttp:支持连接池
因此我们通常会使用带有连接池的客户端来代替默认的HttpURLConnection。比如,我们使用OK Http.
<!--OK http 的依赖 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
feign:
okhttp:
enabled: true # 开启OKHttp功能
拆出api时调用方找不到bean——调用方配置包扫描路径
@EnableFeignClients(basePackages = "com.hmall.api.client")
feign配置日志级别:
- OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。
- 而且其日志级别有4级:
- NONE:不记录任何日志信息,这是【默认值】。
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
配置日志方式:
// api包下
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.FULL;
}
}
// 需要调试的服务的启动类注解中, defaultConfig=xxx
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = com.hmall.api.config.DefaultFeignConfig.class)
网关
解决问题:身份校验,路由
网关请求处理流程:定义处理逻辑,定义过滤器链顺序
网关过滤器链中的过滤器有两种:
-
GlobalFilter
:全局过滤器,作用范围是所有路由,不可配置.【只需要添加自定义路由即可】 -
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
. 【由于携带参数,所以实际上是定义一个工厂类;Springboot内置了许多GatewayFilter】
关于身份校验:
mvc拦截器配置:
- 编写拦截器逻辑:preHandle, postHandle, afterCompletion
- 写配置类,配置拦截器:addInterceptors
- 自动装配配置类:spring.facotories
为什么定义在hm-common
因为所有的微服务(除了网关),都要添加这个拦截器,而所有微服务都引用了hm-common,所以直接在common添加配置类
如何让配置在网关不生效使用条件装配注解
@Conditional
,由于只有网关没有导入mvc,所以可以用是否存在mvc的核心类作为区分,即@ConditionalOnClass((DispatcherServlet.class)
配置管理
配置流程:
添加依赖:
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
配置管理:
- 配置bootstrap.yml
- 把公共的配置配置到nacos中
配置热更新:
网关动态路由:略
服务保护
服务保护方案:
- 请求限流(水坝)
- 线程隔离(船舱)
- 服务熔断(保险丝)
Sentinel | Hystrix | |
---|---|---|
限流 | 基于QPS,支持流量整形 | 有限的支持 |
线程隔离 | 信号量隔离 | 线程池隔离/信号量隔离 |
Fallback | 支持 | 支持 |
熔断策略 | 基于慢调用比例或异常比例 | 基于异常比率 |
控制台 | 开箱即用,可配置规则、查看秒级监控、机器发现等 | 不完善 |
配置方式 | 基于控制台,重启后失效 | 基于注解或配置文件,永久生效 |
Sentinel快速入门:
- 引入依赖:
<!--sentinel-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
- 添加配置:(可以配置到nacos共享中)
spring:
cloud:
sentinel:
transport:
dashboard: 192.168.10.100:8858
http-method-specify: true #开启请求方式前缀
- docker部署dashboard:
# 查看版本
docker search sentinel
# 下载镜像的最新版本
docker pull bladex/sentinel-dashboard
# 运行容器 Sentinel默认端口 8858
docker run --name sentinel -p 8858:8858 -td bladex/sentinel-dashboard
# 访问Sentinel监控平台 http://localhost:8858/
账户:sentinel
密码:sentinel
(监控的接口需要被访问一下才会显示在监控台中)
Feign调用添加fallback【Feign整合Sentinel】
sentinel的监控对象为触点,那么怎么监视feign调用添加fallback逻辑呢?
- 添加配置(服务模块配置)
feign:
sentinel:
enabled: true # 开启feign对sentinel的支持
-
编写fallback工厂类,注册成Bean
-
FeignClient注解添加fallback参数
@FeignClient(value = "item-service", fallback = ItemClientFallbackFactory.class)
分布式事务
Seata架构:
Seata服务部署:
seata是一个独立的微服务,需要自己部署,步骤如下:
- 由于采用mysql持久化,先导入数据表
seata-tc.sql
- 复制配置文件夹
seata/application.yml
- 加载并启动容器
| 文件连接
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.10.100 \ #虚拟机地址
-v /root/docker/seata:/seata-server/resources \
--privileged=true \
--network my-net \
-d \
seataio/seata-server:1.5.2
导入Seata:
- 导入依赖
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
- 添加配置文件【做成共享配置,shared-seata.yml】
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 192.168.10.100:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: hmall # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
hmall: "default"
nacos中确定一个服务: namespace-group-service-cluster
- 如果报错
Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,
是因为jdk不是1.8导致的,解决方法如下
# 添加虚拟机选项
--add-opens java.base/java.lang=ALL-UNNAMED
XA模式(强一致性):
- 修改配置文件,开启XA模式
seata:
data-source-proxy-mode: XA
- 全局事务入口添加
@GlobalTransational
注解
AT模式(最终一致性):
- 每个服务的数据库添加undolog数据表
seata-at.log
- 修改配置(默认就是AT)
seata:
data-source-proxy-mode: TA
- 全局事务入口添加
@GlobalTransational
注解
RabbitMQ
基本概念
docker部署:
docker run \
-e RABBITMQ_DEFAULT_USER=rabbitmq \
-e RABBITMQ_DEFAULT_PASS=rabbitmq \
-v mq-plugins:/plugins \ #挂载数据卷,挂载本地目录会报错
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network my-net\
-d \
rabbitmq:management
基本概念:
配置
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
spring:
rabbitmq:
host: localhost
virtual-host: /
port: 5672
username: rabbitmq
password: rabbitmq
队列模型
workQueue
:默认轮询,每个消息处理一次。加快消息处理速度
能者多劳【通过配置prefetch】
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
fanout exchange
:广播
direct exchange
:定向(基于bingKey做路由)
topic exchange
:订阅(bingKey支持通配符)
#
:匹配一个或多个词*
:匹配不多不少恰好1个词
声明队列和交换机
- 基于Bean
@Configuration
public class FanoutConfiguration {
@Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange("my.fanout");
}
@Bean
public Queue fanoutQueue1() {
return new Queue("fanout.queue1");
}
@Bean
public Binding bindingFanoutQueue1(FanoutExchange fanoutExchange, Queue fanoutQueue1) {
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
}
- 基于注解
// 基于注解声明队列和交换机
@RabbitListener(bindings = @QueueBinding(
value = @Queue("direct.queue1"),
exchange = @Exchange(value = "my.direct", type = ExchangeTypes.DIRECT),
key = {"red", "green"}
))
public void directQueueListener1(String message) {
log.info("监听到来自direct.queue1的消息: {}", message);
}
消息转换器
采用JSON序列化代替默认的JDK序列化
- 引入jackson依赖
<!--Jackson-->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>
- 在publisher和consumer中都要配置MessageConverter
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}
消息转换器中添加的messageId可以便于我们将来做幂等性判断
消息可靠性
发送者可靠性
- 发送者重连:
spring:
rabbitmq:
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间【默认】
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial ~ interval * multiplier 【默认】
max-attempts: 3 # 最大重试次数【默认】
SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。
如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
- 发送者确认
ReturnCallback
:是否成功路由。只需要绑定一个统一回调。ConfirmCallback
:确认是否到达 Exchange 【不管路由是否成功,到达队列】。每个消息都要绑定自己的回调。
ReturnCallback在交换器路由不到队列时触发回调,除非代码绑定队列出错,否则不会触发,所以一般不用。
ConfirmCallback会导致发消息时频繁与Mq通信,所以如果不是必要,也一般不用,如果使用,注意在收到nack时不要重试太多次,避免严重影响性能。
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型(simple:同步阻塞, correlated:异步回调)
publisher-returns: true # 开启publisher return机制
returnCallback
配置:
@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
private final RabbitTemplate rabbitTemplate;
@PostConstruct
public void init(){
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returned) {
log.error("触发return callback,");
log.debug("exchange: {}", returned.getExchange());
log.debug("routingKey: {}", returned.getRoutingKey());
log.debug("message: {}", returned.getMessage());
log.debug("replyCode: {}", returned.getReplyCode());
log.debug("replyText: {}", returned.getReplyText());
}
});
}
}
confirmCallback
配置:
@Test
public void testConfirmCallback() {
// 1.创建CorrelationData
CorrelationData cd = new CorrelationData();
// 2.给Future添加ConfirmCallback
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
// 2.1.Future发生异常时的处理逻辑,基本不会触发
log.error("send message fail", ex);
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
// 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
log.debug("发送消息成功,收到 ack!");
}else{ // result.getReason(),String类型,返回nack时的异常描述
log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
}
}
});
//3. 发送消息
rabbitTemplate.convertAndSend("my.direct", "green", "hello spring amq", cd);
}
MQ可靠性
- 数据持久化
交换机持久化【默认】,队列持久化【默认】,消息持久化。
临时消息存在内存,消息过多时会导致扇出,造成阻塞。
持久化的消息存在内存,同时也直接保存到磁盘,避免扇出造成太大阻塞,且性能也不差。
Message message = MessageBuilder.withBody("hello spring amq".getBytes())
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.build();
- lazyQueue
消息直接存入磁盘,不存到内存,且针对写磁盘进行优化,解决了一般队列+数据持久化的并发问题。
3.12版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为LazyQueue模式。
Bean配置:
@Bean
public Queue lazyQueue() {
return QueueBuilder
.durable("lazy.queue")
.lazy() // 声明队列为延迟队列
.build();
}
注解配置:
@RabbitListener(queuesToDeclare = @Queue(
value = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void lazyQueueListener(String message) {
log.info("监听到来自lazy.queue的消息: {}", message);
}
控制台配置:
消费者可靠性
- 消费者消息确认【默认开启】
none
:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用manual
:手动模式。需要自己在业务代码中调用api,发送ack
或reject
,存在业务入侵,但更灵活auto
:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack
. 当业务出现异常时,根据异常判断返回不同结果:
- 如果是业务异常,会自动返回
nack
;- 如果是消息处理或校验异常,自动返回
reject
;
配置:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto #自动处理【默认】
- 失败重试【默认关闭】
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。
极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力:
配置
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
#下面都是默认配置
initial-interval: 1000ms # 初始的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
默认情况下,如果重试三次失败,就会直接丢弃消息,造成可靠性下降,可以修改策略。
配置第三种方式
@Configuration
@Slf4j
public class ErrorMessageConfig {
@Bean
public Queue errorMessageQueue() {
return new Queue("error.queue");
}
@Bean
public DirectExchange errorMessageExchange() {
return new DirectExchange("error.exchange");
}
@Bean
public Binding errorMessageBinding(Queue errorMessageQueue, DirectExchange errorMessageExchange) {
return BindingBuilder.bind(errorMessageQueue).to(errorMessageExchange).with("error");
}
@Bean
public RepublishMessageRecoverer errorMessageRecoverer(RabbitTemplate rabbitTemplate) {
return new RepublishMessageRecoverer(rabbitTemplate, "error.exchange", "error");
}
}
- 幂等业务处理
唯一消息id
- 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
延迟消息
采用延时消息实现延时任务。
应用场景:对于超过一定时间未支付的订单,取消订单并释放占用的库存。
- 死信交换机+TTL
死信(Dead Letter):
- 消费者使用
basic.reject
或basic.nack
声明消费失败,并且消息的requeue
参数设置为false - 消息是一个过期消息,超时无人消费
- 要投递的队列消息满了,无法投递
如果一个队列中的消息已经成为死信,并且这个队列通过
dead-letter-exchange
属性指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机就称为死信交换机(Dead Letter Exchange)。
发送消息时指定过期时间:
@Test
public void testDelayMessage() {
rabbitTemplate.convertAndSend("normal.exchange", "normal.key", "hello dl message", message -> {
message.getMessageProperties().setExpiration("10000");
return message;
});
}
- 延时消息插件
下载插件:github地址
将插件文件移动到对应的插件目录下(一般为/plugins)后,执行:
docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
注解方式声明:
// 延时交换机
@RabbitListener(bindings = @QueueBinding(
value = @Queue("delay.queue"),
exchange = @Exchange(value = "delay.exchange", type = ExchangeTypes.DIRECT, delayed = "true"),
key = {"delay.key"}
))
public void delayQueue(String message) {
log.info("监听到来自delay.queue的消息: {}", message);
}
Bean方式声明:
@Slf4j
@Configuration
public class DelayExchangeConfig {
@Bean
public DirectExchange delayExchange(){
return ExchangeBuilder
.directExchange("delay.exchange") // 指定交换机类型和名称
.delayed() // 设置delay的属性为true
.durable(true) // 持久化
.build();
}
@Bean
public Queue delayedQueue(){
return new Queue("delay.queue");
}
@Bean
public Binding delayQueueBinding(){
return BindingBuilder.bind(delayedQueue()).to(delayExchange()).with("delay");
}
}
发送时指定延时时间:
@Test
public void testDelayMessageByPlugin() {
rabbitTemplate.convertAndSend("delay.exchange", "delay.key", "hello delay message by plugin", message -> {
message.getMessageProperties().setDelay(10000);
return message;
});
}
延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时。
如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。
因此,不建议设置延迟时间过长的延迟消息。
最佳实践:取消订单
场景:
-
用户下单:调用交易服务,保存订单,扣减库存(锁单,防止超卖)
-
用户支付:调用支付服务,扣减余额,通过MQ通知交易服务更新订单状态为已支付
-
存在问题:
- 如果用户迟迟没有支付,库存被扣减没有恢复
- 如果MQ故障,那么即使用户已支付,订单状态也没有更新
-
解决办法:一段时间后,交易服务check一下支付服务,是否有支付记录
- 已支付:更新订单状态为已支付
- 未支付:更新订单状态为关闭,恢复商品库存
-
解决方式:延迟消息