分布式集合

项目

SSO单点登录

在这里插入图片描述

token是什么:按照一定规则生成的字符串,字符串可以包含用户信息
实现方式: 在项目某个模块进行登录,登录之后,按照规则生成字符串,把登录之后用户信息包含到生成的字符串里面,把①字符串通过地址栏返回②把字符串通过cookie返回,再去访问其他模块时,在地址栏带着生成的字符串,在访问模块里面获取地址栏字符串,根据字符串获取用户信息,如果可以获取到就是登录。
注册登录的实现:
JWT方式

近年来,由于手机端的兴起,前后端分离开发方式的流行,JWT这种登录的实现方式悄然兴起,那么什么是JWT呢?JWT是英文JSON Web Token的缩写,它由3部分组成,

header,一般情况下存储两个信息,1类型,一般都是JWT;2加密算法,比如:HMAC、RSA等;
payload,这里就存储登录的相关信息了,比如:登录状态、用户id、过期时间等。
signature,签名,这个是将header、payload和密钥的信息做一次加密,后台在接收到JWT的时候,一定要验签,谨防JWT的伪造。
下面咱们看看JWT的登录实现,
在这里插入图片描述

我们看到整体的流程和Cookie的实现方式是一样的,只不过是没有用到Cookie、Session。那么它与Cookie-Session的区别是什么呢?
1.登录状态、用户id并没有存储到session,而是存在JWT的payload里,返回给了前端。
2.在前端JWT不会自动存储到Cookie中,前端开发人员要处理JWT的存储问题,比如LocalStorage
3.再次发起请求,JWT不会自动放到请求头中,需前端同学手动设置
4.后端从请求头中取出JWT,验签通过后,拿到登录状态、用户id,不是从session中取

JWT的构成
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).
header
JWT的头部承载两部分信息:

  • 声明类型,这里是JWT;
  • 声明加密的算法,通常直接使用 HMAC SHA256;

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

payload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

  • 标准中注册的声明

  • 公共的声明

  • 私有的声明
    标准中注册的声明 (建议但不强制使用) :

  • iss: jwt签发者

  • sub: jwt所面向的用户

  • aud: 接收jwt的一方

  • exp: jwt的过期时间,这个过期时间必须要大于签发时间

  • nbf: 定义在什么时间之前,该jwt都是不可用的.

  • iat: jwt的签发时间

  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

公共的声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

私有的声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到JWT的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

signature
JWT的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret
    这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

应用
一般是在请求头里加入Authorization,并加上Bearer标注:

fetch('api/user/1', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:
在这里插入图片描述
在这里插入图片描述

Redis

防止库存超卖

在这里插入图片描述

  1. 首先通过redis api监听相关物品的库存信息,在事务开启前保证该物品库存信息无人修改
  2. 获取现有库存信息,判断库存不为0并且当前库存量大于等于订单所需数量
  3. 满足上述2的话则进行扣除操作
  4. 如果在1的过程中有别人更新了该物品库存信息版本,则重试
  5. 直到库存为0或者剩余库存不满足当前订单扣除数量退出

在系统初始化时,将商品的库存数量加载到Redis缓存中;
接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
将请求放入异步队列中,返回正在排队中;
服务端异步队列将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。
当后台订单创建成功之后可以通过websocket向用户发送一个秒杀成功通知。前端以此来判断是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。
————————————————

如何保证缓存与数据库双写时的数据一致性?

为什么是删除,而不是更新缓存?
我们以先更新数据库,再删除缓存来举例。

如果是更新的话,那就是先更新数据库,再更新缓存。

举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能在1小时内只被读取了1次,那么这1000次的更新有必要吗?

反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。

1:先更新数据库 - 删缓存 - 再次访问 - 查询数据库 - 存入缓存
2:更新redis(新key覆盖旧key) - 查询 - redis异步将数据同步mysql

解答:
(1)只要用了缓存,就肯定会有不一致,2个数据源之间是没有事务的,没法保证绝对的一致。
(2)如果想绝对一致,那就别用缓存
(3)如果能接受一定程度上的不一致,可以先更新数据库,再删除缓存。
(4)如果事先更新缓存再更新数据库,如果数据库回滚,缓存怎么处理?这种方式不推荐。如果能接受这种不一致,也可以用。

提问:
如果事先更新缓存再更新数据库,如果数据库回滚,缓存怎么处理?
数据发生了回滚,即出现异常,这里做一个异常回调,删除对应的缓存。
老师,这种思路可行吗?

解答:
不推荐 代码的侵入性太大 首先你要记下来redis之前的值 回滚的时候再写回去,如果是insert 你得做一次delete,如果是update 你需要update回去,如果delete你得jinsert,再者 如果回调中发生了异常怎么办

背景:使用到缓存,无论是本地内存做缓存还是使用 Redis 做缓存,那么就会存在数据同步的问题,因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。这样就会造成数据库中的数据与缓存中数据不一致的问题。

共有四种方案:

  1. 先更新数据库,后更新缓存
  2. 先更新缓存,后更新数据库
  3. 先删除缓存,后更新数据库
  4. 先更新数据库,后删除缓存

第一种和第二种方案,没有人使用的,因为第一种方案存在问题是:并发更新数据库场景下,会将脏数据刷到缓存。

第二种方案存在的问题是:如果先更新缓存成功,但是数据库更新失败,则肯定会造成数据不一致。

目前主要用第三和第四种方案。

先删除缓存,后更新数据库

该方案也会出问题,此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  1. 请求A进行写操作,删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库
    上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

答案一:延时双删

延时双删的方案的思路是,为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再sleep一段时间,然后再次删除缓存。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。

流程如下:

线程1删除缓存,然后去更新数据库
线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
线程1,根据估算的时间,sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除
如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值

答案二: 更新与读取操作进行异步串行化
采用更新与读取操作进行异步串行化

异步串行化

我在系统内部维护n个内存队列,更新数据的时候,根据数据的唯一标识,将该操作路由之后,发送到其中一个jvm内部的内存队列中(对同一数据的请求发送到同一个队列)。读取数据的时候,如果发现数据不在缓存中,并且此时队列里有更新库存的操作,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也将发送到同一个jvm内部的内存队列中。然后每个队列对应一个工作线程,每个工作线程串行地拿到对应的操作,然后一条一条的执行。

这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,如果此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库的操作之后,然后同步等待缓存更新完成,再读库。

读操作去重

多个读库更新缓存的请求串在同一个队列中是没意义的,因此可以做过滤,如果发现队列中已经有了该数据的更新缓存的请求了,那么就不用再放进去了,直接等待前面的更新操作请求完成即可,待那个队列对应的工作线程完成了上一个操作(数据库的修改)之后,才会去执行下一个操作(读库更新缓存),此时会从数据库中读取最新的值,然后写入缓存中。

如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。(返回旧值不是又导致缓存和数据库不一致了么?那至少可以减少这个情况发生,因为等待超时也不是每次都是,几率很小吧。这里我想的是,如果超时了就直接读旧值,这时候仅仅是读库后返回而不放缓存)

先更新数据库,后删除缓存

这一种情况也会出现问题,比如更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。
在这里插入图片描述
此时解决方案就是利用消息队列进行删除的补偿。具体的业务逻辑用语言描述如下:

  1. 请求 A 先对数据库进行更新操作
  2. 在对 Redis 进行删除操作的时候发现报错,删除失败
  3. 此时将Redis 的 key 作为消息体发送到消息队列中
  4. 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作

但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。(canal)
在这里插入图片描述

延迟队列

1、死信队列
“死信”是RabbitMQ中的一种消息机制。死信队列(死信交换机),又称 dead-letter-exchange(DLX)。当一条消息在一个队列中变成死信后,它会被重新发布到一个交换机中,这个交换机就是 DLX。

队列中的消息在以下三种情况下会变成死信,

消息被拒绝(reject ,nack),并且 requeue = false(不再重新投递)
消息 TTL 过期(TTL,即Time To Live,是指消息的存活时间)
队列超过最长长度
消息进入死信队列的过程:消息 -> 队列 (触发以上条件)-> DLX交换机 -> DLK队列。

2、过期消息
RabbitMQ 中存在两种方可设置消息的过期时间,

队列设置:通过对队列进行设置,这种设置后,该队列中所有的消息都存在相同的过期时间,在队列申明的时候使用 x-message-ttl 参数,单位为毫秒。
单个消息设置:通过对消息本身进行设置,那么每条消息的过期时间都不一样,设置消息属性的 expiration 参数的值,单位为毫秒。
如果同时使用这两种方法,那么以过期时间小的那个数值为准。当消息达到过期时间还没有被消费,那么那个消息就成为了一个死信消息。

3、延时队列
RabbitMQ 中不存在延时队列,但是我们可以通过设置消息的过期时间和死信队列来模拟出延时队列。消费者监听死信交换器绑定的队列,而不要监听消息发送的队列。

4、应用
1)场景

订单下单10秒后,若用户没有付款,则系统自动取消订单。

2)分析

以上适合使用延时队列解决,RabbitMQ 的延时队列可以由 过期消息+死信队列 来实现。

过期消息+死信队列 实现延时队列。
过期消息通过队列中设置 x-message-ttl 参数实现。
死信队列通过在队列申明时,给队列设置 x-dead-letter-exchange 参数,然后另外申明一个队列绑定x-dead-letter-exchange对应的交换器。
不使用传统的轮询方式,优势:若数据库数据量大,则定时轮询就会特别消耗资源,拖垮服务器,且响应慢。

3)实现
在这里插入图片描述
配置:

@Configuration
public class RabbitMQConfiguration {
    //队列名称
   public   final static String orderQueue = "order_queue";
 
    //交换机名称
    public  final static String orderExchange = "order_exchange";
 
    // routingKey
    public  final static String routingKeyOrder = "routing_key_order";
 
    //死信消息队列名称
    public  final static String dealQueueOrder = "deal_queue_order";
 
    //死信交换机名称
    public  final static String dealExchangeOrder = "deal_exchange_order";
 
    //死信 routingKey
    public final static String deadRoutingKeyOrder = "dead_routing_key_order";
 
    //死信队列 交换机标识符
    public static final String DEAD_LETTER_QUEUE_KEY = "x-dead-letter-exchange";
 
    //死信队列交换机绑定键标识符
    public static final String DEAD_LETTER_ROUTING_KEY = "x-dead-letter-routing-key";
 
    @Autowired
    private CachingConnectionFactory connectionFactory;
 
    @Bean
    public Queue orderQueue() {
        // 将普通队列绑定到死信队列交换机上
        Map<String, Object> args = new HashMap<>(2);
        //args.put("x-message-ttl", 5 * 1000);//直接设置 Queue 延迟时间 但如果直接给队列设置过期时间,这种做法不是很灵活
        //这里采用发送消息动态设置延迟时间,这样我们可以灵活控制
        args.put(DEAD_LETTER_QUEUE_KEY, dealExchangeOrder);
        args.put(DEAD_LETTER_ROUTING_KEY, deadRoutingKeyOrder);
        return new Queue(RabbitMQConfiguration.orderQueue, true, false, false, args);
    }
 
    //声明一个direct类型的交换机
    @Bean
    DirectExchange orderExchange() {
        return new DirectExchange(RabbitMQConfiguration.orderExchange);
    }
 
    //绑定Queue队列到交换机,并且指定routingKey
    @Bean
    Binding bindingDirectExchangeDemo5(   ) {
        return BindingBuilder.bind(orderQueue()).to(orderExchange()).with(routingKeyOrder);
    }
 
    //创建配置死信队列
    @Bean
    public Queue deadQueueOrder() {
        Queue queue = new Queue(dealQueueOrder, true);
        return queue;
    }
 
    //创建死信交换机
    @Bean
    public DirectExchange deadExchangeOrder() {
        return new DirectExchange(dealExchangeOrder);
    }
 
    //死信队列与死信交换机绑定
    @Bean
    public Binding bindingDeadExchange() {
        return BindingBuilder.bind(deadQueueOrder()).to(deadExchangeOrder()).with(deadRoutingKeyOrder);
    }
}

消息发送端:

@RestController
@RequestMapping("/order")
public class OrderController {
    private static final Logger logger =  LoggerFactory.getLogger(OrderController.class);
    @Autowired
    private AmqpTemplate rabbitTemplate;
 
    /**
     * 模拟提交订单
     * @author nxq
     * @return java.lang.Object
     */
    @GetMapping("")
    public Object submit(){
        String orderId = UUID.randomUUID().toString();
        logger.info("submit order {}", orderId);
        this.rabbitTemplate.convertAndSend(
                RabbitMQConfiguration.orderExchange, //发送至订单交换机
                RabbitMQConfiguration.routingKeyOrder, //订单定routingKey
                orderId //订单号   这里可以传对象 比如直接传订单对象
                , message -> {
            // 如果配置了 params.put("x-message-ttl", 5 * 1000);
            // 那么这一句也可以省略,具体根据业务需要是声明 Queue 的时候就指定好延迟时间还是在发送自己控制时间
            message.getMessageProperties().setExpiration(1000 * 10 + "");
            return message;
        });
        
        return "{'orderId':'"+orderId+"'}";
    }
}

消息接收端

@Component
public class OrderFailureListener {
    private static final Logger logger =  LoggerFactory.getLogger(OrderFailureListener.class);
    @RabbitListener(
            queues = RabbitMQConfiguration.dealQueueOrder //设置订单失效的队列
    )
    public void process(String order, Message message, @Headers Map<String, Object> headers, Channel channel) throws IOException {
 
        logger.info("【订单号】 - [{}]",  order);
        // 判断订单是否已经支付,如果支付则;否则,取消订单(逻辑代码省略)
        调用mapper下单减库存操作
        若操作失败则redis+库存 订单状态改为失败
 
        // 手动ack
//        Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
        // 手动签收
//        channel.basicAck(deliveryTag, false);
        System.out.println("执行结束....");
 
    }
}

Dubbo

在这里插入图片描述

  • Provider: 暴露服务的服务提供方。
  • Consumer: 调用远程服务的服务消费方。
  • Registry: 服务注册与发现的注册中心。
  • Monitor: 统计服务的调用次调和调用时间的监控中心。
  • Container: 服务运行容器。

首先服务提供者 Provider 启动然后向注册中心注册自己所能提供的服务。

服务消费者 Consumer 启动向注册中心订阅自己所需的服务。然后注册中心将提供者元信息通知给 Consumer, 之后 Consumer 因为已经从注册中心获取提供者的地址,因此可以通过负载均衡选择一个 Provider 直接调用 。

之后服务提供方元数据变更的话注册中心会把变更推送给服务消费者。

服务提供者和消费者都会在内存中记录着调用的次数和时间,然后定时的发送统计数据到监控中心。

到这基本上就差不多了,如果之前看过丙的 Dubbo 系列文章的话,那就算看过源码了,肯定对一系列过程很清晰了,所以在适当的时机可以说自己看过 Dubbo 源码。

看过源码,那说下服务暴露的流程?

服务的暴露起始于 Spring IOC 容器刷新完毕之后,会根据配置参数组装成 URL, 然后根据 URL 的参数来进行本地或者远程调用。

会通过 proxyFactory.getInvoker,利用 javassist 来进行动态代理,封装真的实现类,然后再通过 URL 参数选择对应的协议来进行 protocol.export,默认是 Dubbo 协议。

在第一次暴露的时候会调用 createServer 来创建 Server,默认是 NettyServer。

然后将 export 得到的 exporter(输出)存入一个 Map 中,供之后的远程调用查找,然后会向注册中心注册提供者的信息。

基本上就是这么个流程,说了这些差不多了,太细的谁都记住不。
在这里插入图片描述
为什么要封装成 invoker
至于为什么要封装成 invoker 其实就是想屏蔽调用的细节,统一暴露出一个可执行体,这样调用者简单的使用它,向它发起 invoke 调用,它有可能是一个本地的实现,也可能是一个远程的实现,也可能一个集群实现。

为什么要搞个本地暴露呢
因为可能存在同一个 JVM 内部引用自身服务的情况,因此暴露的本地服务在内部调用的时候可以直接消费同一个 JVM 的服务避免了网络间的通信。

看过源码,那说下服务引入的流程?

服务的引入时机有两种,第一种是饿汉式,第二种是懒汉式。

饿汉式就是加载完毕就会引入,懒汉式是只有当这个服务被注入到其他类中时启动引入流程,默认是懒汉式。

会先根据配置参数组装成 URL ,一般而言我们都会配置的注册中心,所以会构建 RegistryDirectory 向注册中心注册消费者的信息,并且订阅提供者、配置、路由等节点。

得知提供者的信息之后会进入 Dubbo 协议的引入,会创建 Invoker(引用者) ,期间会包含 NettyClient,来进行远程通信,最后通过 Cluster(集群)来包装 Invoker,默认是 FailoverCluster,最终返回代理类。

说这么多差不多了,关键的点都提到了。
在这里插入图片描述

看过源码,那说下服务调用的流程?

调用某个接口的方法会调用之前生成的代理类,然后会从 cluster 中经过路由的过滤、负载均衡机制选择一个 invoker 发起远程调用,此时会记录此请求和请求的 ID 等待服务端的响应。

服务端接受请求之后会通过参数找到之前暴露存储的 map,得到相应的 exporter(输出者) ,然后最终调用真正的实现类,再组装好结果返回,这个响应会带上之前请求的 ID。

消费者收到这个响应之后会通过 ID 去找之前记录的请求,然后找到请求之后将响应塞到对应的 Future 中,唤醒等待的线程,最后消费者得到响应,一个流程完毕。

关键的就是 cluster、路由、负载均衡,然后 Dubbo 默认是异步的,所以请求和响应是如何对应上的。

之后可能还会追问 Dubbo 异步转同步如何实现的之类的,在丙之前文章里面都说了,忘记的同学可以回去看看。

异步调用,其实 Dubbo 天然就是异步的,可以看到 client 发送请求之后会得到一个 ResponseFuture,然后把 future 包装一下塞到上下文中,这样用户就可以从上下文中拿到这个 future,然后用户可以做了一波操作之后再调用 future.get 等待结果。

同步调用,这是我们最常用的,也就是 Dubbo 框架帮助我们异步转同步了,从代码可以看到在 Dubbo 源码中就调用了 future.get,所以给用户的感觉就是我调用了这个接口的方法之后就阻塞住了,必须要等待结果到了之后才能返回,所以就是同步的。

可以看到 Dubbo 本质上就是异步的,为什么有同步就是因为框架帮我们转了一下,而同步和异步的区别其实就是future.get 在用户代码被调用还是在框架代码被调用。

RabbitMQ

结构组成和核心组件

RabbitMQ 结构图中有几个重要的概念:VHost(虚机主机)、Exchange(交换机)、Queue(队列)、Binding(绑定)。整个消息传递的流程都是围绕着几个组件来进行的。生产者把消息发布到Exchange上,然后Exchange把消息路由到与Exchange绑定的Queue队列中,消费者建立与Queue的连接后,消费消息。

1、Broker(服务节点)

RabbitMQ Server服务器,服务节点称为Broker。

2、Virtual Host(虚拟主机)

虚拟主机,标识一批交换机、消息队列和相关对象。虚拟主机是共享相同的身份认证和加密环境的独立服务器域。每个vhost本质上就是一个mini版的RabbitMQ服务器,拥有自己的队列、交换器、绑定和权限机制。vhost是AMQP概念的基础,必须在链接时指定,RabbitMQ默认的vhost是 /。

3、Exchange(交换机)

交换机,用来接收生产者(producer)发送的消息,并将这些消息路由给服务器中的队列(Queue)。

4、Queue(消息队列)

消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。

5、Binding(绑定)

绑定,用于交换机(Exchange)和消息队列(Queue)之间的关联。一个绑定就是基于路由键将交换机和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。

6、Channel(信道)

信道,多路复用连接中的一条独立的双向数据流通道。新到是建立在真实的TCP连接内地虚拟链接,AMQP命令都是通过新到发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说,建立和销毁TCP都是非常昂贵的开销,所以引入了信道的概念,以复用一条TCP连接(一个TCP链接,可以包含多个信道)。

7、Producer(生产者)

Producer消息生产者,也是一个向交换器发布消息的客户端应用程序。

8、Consumer(消费者)

消息消费者,表示一个从一个消息队列中取得消息的客户端应用程序。

9、Connection(连接)

网络连接,比如一个TCP连接。一个consumer需要和broker建立连接,以获取队列中的消息。

10、Message(消息)

消息,消息是不具名的,它是由消息头和消息体组成。消息体是不透明的,而消息头则是由一系列的可选属性组成,这些属性包括routing-key(路由键)、priority(优先级)、delivery-mode(消息的路由模式)等。

消息的传递过程(重点)

RabbitMQ 的消息策略是怎样的呢?消息是怎样传递的呢?首先明确一点就是生产者产生的消息并不是直接发送给消息队列Queue的,而是要经过Exchange(交换器),由Exchange再将消息路由到一个或多个Queue,这里还会对不符合路由规则(由Exchange Type决定规则)的消息进行丢弃掉。RabbitMQ 通过Binding将Exchange和Queue链接在一起,然后将消息准确的推送到对应的Queue中。

Routing Key:生产者在将消息发送给Exchange的时候,一般会指定一个routing key,来指定这个消息的路由规则,而这个routing key需要与Exchange Type及binding key联合使用才能最终生效。
Binding Key:binding key 用来绑定(Binding)Exchange与Queue。当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。
1)消息的传递
在这里插入图片描述
2)结构图
在这里插入图片描述
要解释清楚 RabbitMQ 整个消息的传递过程,我们需要上面两张图来看,分两步:发送消息和消费消息。

1、发送消息过程
Producer(生产者)向 RabbitMQ Server(Broker)发送消息,

  1. 获取Conection
  2. 获取Channel
  3. 定义Exchange和Queue
  4. 用一个RoutingKey将Queue Binding到一个Exchange上
  5. 通过指定一个Exchange和一个RoutingKey来将消息发送到对应的Queue上,发送完成。

生产者需要关心:Exchange、Queue、Binding。

2、消费消息过程
Consumer(消费者)向 RabbitMQ Server(Broker)订阅消息,

  1. 获取Conection
  2. 获取Channel
  3. 指定一个Queue直接到它关心的Queue上取消息,取到消息后自己消费,按配置决定是否返回确认。

消费者只需要关心:Queue。

工作模式(交换机类型)

Exchange分发消息时,根据交换机的类型不同,分发策略也不同。常见的交换机类型有四种:direct、fanout、topic、headers(headers匹配AMQP消息的header而不是路由键(Routing-key),此外headers交换器和direct交换器完全一致,但是性能差了很多,很少使用)。

1、fanout(扇型交换机)
fanout类型的Exchange路由规则非常简单,它会把所有发送到该Exchange的消息路由到所有与它绑定的Queue中。
在这里插入图片描述
上图所示,生产者(P)生产消息1将消息1推送到Exchange,由于Exchange Type=fanout这时候会遵循fanout的规则将消息推送到所有与它绑定Queue,也就是图上的两个Queue最后两个消费者消费。

2、direct(直连交换机)
direct类型的Exchange路由规则也很简单,它会把消息路由到那些binding key与routing key完全匹配的Queue中。
在这里插入图片描述
当生产者(P)发送消息时Rotuing key=booking时,这时候将消息传送给Exchange,Exchange获取到生产者发送过来消息后,会根据自身的规则进行与匹配相应的Queue,这时发现Queue1和Queue2都符合,就会将消息传送给这两个队列,如果我们以Rotuing key=create和Rotuing key=confirm发送消息时,这时消息只会被推送到Queue2队列中,其他Routing Key的消息将会被丢弃。

3、topic(主题交换机)
前面提到的direct规则是严格意义上的匹配,换言之Routing Key必须与Binding Key相匹配的时候才将消息传送给Queue,那么topic这个规则就是模糊匹配,可以通过通配符满足一部分规则就可以传送。它的约定是:

  • routing key为一个句点号“. ”分隔的字符串(我们将被句点号“. ”分隔开的每一段独立的字符串称为一个单词),如 “stock.usd.nyse”、“nyse.vmw”、“quick.orange.rabbit”。
  • binding key与routing key一样也是句点号“. ”分隔的字符串。
  • binding key中可以存在两种特殊字符“ ”与“#”,用于做模糊匹配,其中“”用于匹配一个单词,“#”用于匹配多个单词(可以是零个)

在这里插入图片描述
当生产者发送消息Routing Key=F.C.E的时候,这时候只满足Queue1,所以会被路由到Queue1中,如果Routing Key=A.C.E这时候会被同是路由到Queue1和Queue2中,如果Routing Key=A.F.B时,这里只会发送一条消息到Queue2中。

4、headers(头交换机)
headers类型的Exchange不依赖于routing key与binding key的匹配规则来路由消息,而是根据发送的消息内容中的headers属性进行匹配。
在绑定Queue与Exchange时指定一组键值对;当消息发送到Exchange时,RabbitMQ会取到该消息的headers(也是一个键值对的形式),对比其中的键值对是否完全匹配Queue与Exchange绑定时指定的键值对;如果完全匹配则消息会路由到该Queue,否则不会路由到该Queue。

此外headers交换器和direct交换器完全一致,但是性能差了很多,很少使用。

持久化(重点)

1、队列持久化和消息持久化

重启RabbitMQ后,队列和交换器都会丢失(随同里面的消息),原因在于每个队列和交换器的durable属性,该属性默认为false。RabbitMQ提供了durable属性来实现持久化,保证断电后消息不丢失。RabbitMQ 的持久化分:交换机、队列持久化和消息持久化。

  • 交换机、队列持久化:持久化交换机(Exchange)和队列(Queue),设置交换机和队列的durable属性为ture。
  • 消息持久化:持久化消息本身,设置消息的“投递模式”属性设置为 2 (delivery_mode=2)。

所以,RabbitMQ 的消息持久化,需要做到以下三点:

  • 把消息的投递模式选项设置为2(delivery_mode=2)
  • 将消息发送到持久化的交换机(durable=true)
  • 消息到达持久化队列(durable=true)

注意,如果原先有非持久的交换器或者队列,需要删除后才可重新创建,否则就创建其他名称的交换器或者队列,代码如下:

//声明持久交换器
channel.ExchangeDeclare(
    "HelloExchange",    //交换器名称
    ExchangeType.Direct,//交换器类型
    true,              //是否持久话
    false,              //是否自动删除
    null                //关于交换器的详细设置,键值对形式
    );
//声明持久队列
channel.QueueDeclare(
    "HelloQueue",//队列名称
    true,       //是否持久化
    false,       //是否只对首次声明的队列可见
    false,       //是否自动删除
    null         关于队列和队列内消息的详细设置,键值对形式
    );
//发布持久消息
string msg_str = "这是生产者第一次发布的消息";
IBasicProperties msg_pro = channel.CreateBasicProperties();
msg_pro.ContentType = "text/plain";//发布的数据类型
msg_pro.DeliveryMode = 2;//标记持久化

2、持久化原理
RabbitMQ 的持久化机制是:把持久化的数据写入磁盘上的一个持久化日志文件,在做数据恢复时,从磁盘读取持久化的数据重建。当发布一条持久化的消息到持久化的交换机上时,RabbitMQ 会在消息提交到日志文件后才发送响应。如果RabbitMQ重启,服务器会自动重建交换机和队列,重播持久性日志文件中的消息到合适的队列或者交换机上。

消息持久化对RabbitMQ的性能有较大影响,写入磁盘要比写入内存慢很多,而且会极大的减少RabbitMQ服务器每秒可处理的消息总数,导致消息吞度量降低至少10倍的情况并不少见。持久化消息在RabbitMQ内建集群环境中工作的并不好,实际上集群上的队列均匀分布在各个节点上而且没有冗余,如果运行a队列的节点崩溃了,那么直到节点恢复前,这个队列就从整个集群消失了,而且这个节点上的所有队列不可用,而且持久化队列也无法重建。

事务

RabbitMQ 支持 AMQP 事务,来处理消息丢失的情况(确认机制比事务更轻量)。AMQP事务与数据库事务不同。

AMQP事务:提供的一种保证消息成功投递的方式,通过将信道开启事务模式后,利用信道 Channel 的三个命令来实现以事务方式发送消息,若发送失败,通过异常处理回滚事务,确保消息成功投递。

channel.txSelect(): 开启事务
channel.txCommit() :提交事务
channel.txRollback() :回滚事务
RabbitMQ的事务非常消耗性能,不但会降低大约2-10倍的消息吞度量,而且会使生产者应用程序之间产生同步,与使用MQ解耦异步系统的初衷相背离。

AMQP是什么?

RabbitMQ就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。

RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。

AMQP的3层协议?

Module Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。

Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。

TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。

确认机制(重点)

1、Confirm 消息确认机制
相比较事务模式,RabbitMQ 提供了更好的方案来保证消息投递:发送方确认模式。

和事务类似,我们需要将信道 channel 设置为 confirm 模式,而且只能通过重新创建信道来关闭该设置。一旦信道进入 confirm 模式,所有的信道上发布的消息都会被指派一个唯一的ID。当消息被投递到队列后,信道就会发送一个发送方确认模式给生产者程序,使得生产者知道消息安全到达队列了。如果发送的消息丢失,RabbitMQ会发送一条nack消息,告诉生产者消息丢失,生产者会再次发送消息(re-publish)。发送发确认模式最大的好处是它们是异步的,没有回滚的概念,更加轻量级,对性能的影响也几乎忽略不计。

2、Confirm 的三种方式
生产者将信道设置成confirm模式,一旦信道进入confirm模式,所有在该信道上面发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,broker就会发送一个确认给生产者 (包含消息的唯一ID) ,这就使得生产者知道消息已经正确到达目的队列了,如果消息和队列是可持久化的,那么确认消息会将消息写入磁盘之后发出,broker回传给生产者的确认消息中deliver-tag 域包含了确认消息的序列号,此外broker也可以设置basic.ack的multiple 域,表示到这个序列号之前的所有消息都已经得到了处理。

开启confirm模式,

// 创建连接
ConnectionFactory factory = new ConnectionFactory();
factory.setUsername(config.UserName);
factory.setPassword(config.Password);
factory.setVirtualHost(config.VHost);
factory.setHost(config.Host);
factory.setPort(config.Port);
Connection conn = factory.newConnection();
// 创建信道
Channel channel = conn.createChannel();
// 声明队列
channel.queueDeclare(config.QueueName, false, false, false, null);
// 开启发送方确认模式
channel.confirmSelect();

1)Confirm 普通模式(单条)
每发送一条消息,调用 channel.waitForConfirms() 方法等待服务端confirm,这实际上是一种串行的confirm,每publish一条消息之后就等待服务端confirm,如果服务端返回false或者超时时间内未返回,客户端进行消息重传。

// 开启发送方确认模式
channel.confirmSelect();
String message = String.format("时间 => %s", new Date().getTime());
channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
if (channel.waitForConfirms()) { // confirm 普通单条
    System.out.println("消息发送成功" );
}

2)Confirm 批量模式(批量)

每发送一批消息之后,调用 channel.waitForConfirmsOrDie() 方法,等待服务端confirm,这种批量确认的模式极大的提高了confirm效率,但是如果一旦出现confirm返回false或者超时的情况,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息,如果这种情况频繁发生的话,效率也会不升反降。

// 开启发送方确认模式
channel.confirmSelect();
for (int i = 0; i < 10; i++) {
    String message = String.format("时间 => %s", new Date().getTime());
    channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
}
channel.waitForConfirmsOrDie(); //直到所有信息都发布,只要有一个未确认就会IOException
System.out.println("全部执行完成");

3)Confirm 异步模式(异步)

RabbitMQ 使用 channel.addConfirmListener()异步监听,异步模式的优点,就是执行效率高,不需要等待消息执行完,只需要监听消息即可。消息确认有可能是批量确认的,是否批量确认在于返回的multiple的参数,此参数为bool值,如果true表示批量执行了deliveryTag这个值以前的所有消息,如果为false的话表示单条确认。

// 开启发送方确认模式
channel.confirmSelect();
for (int i = 0; i < 10; i++) {
    String message = String.format("时间 => %s", new Date().getTime());
    channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
}
//异步监听确认和未确认的消息
channel.addConfirmListener(new ConfirmListener() {
    @Override
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {
        System.out.println("未确认消息,标识:" + deliveryTag);
    }
    @Override
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {
        System.out.println(String.format("已确认消息,标识:%d,多个消息:%b", deliveryTag, multiple));
    }
});

有测试总结:Confirm批量确定和Confirm异步模式性能相差不大,Confirm模式要比事务快10倍左右。

## 八、死信队列的应用
1、死信队列

“死信”是RabbitMQ中的一种消息机制。死信队列(死信交换机),又称 dead-letter-exchange(DLX)。当一条消息在一个队列中变成死信后,它会被重新发布到一个交换机中,这个交换机就是 DLX。

队列中的消息在以下三种情况下会变成死信,

  1. 消息被拒绝(reject ,nack),并且 requeue = false(不再重新投递)
  2. 消息 TTL 过期(TTL,即Time To Live,是指消息的存活时间)
  3. 队列超过最长长度

消息进入死信队列的过程:消息 -> 队列 (触发以上条件)-> DLX交换机 -> DLK队列。

2、过期消息
RabbitMQ 中存在两种方可设置消息的过期时间,

  • 队列设置:通过对队列进行设置,这种设置后,该队列中所有的消息都存在相同的过期时间,在队列申明的时候使用 x-message-ttl 参数,单位为毫秒。
  • 单个消息设置:通过对消息本身进行设置,那么每条消息的过期时间都不一样,设置消息属性的 expiration 参数的值,单位为毫秒。

如果同时使用这两种方法,那么以过期时间小的那个数值为准。当消息达到过期时间还没有被消费,那么那个消息就成为了一个死信消息。

3、延时队列
RabbitMQ 中不存在延时队列,但是我们可以通过设置消息的过期时间和死信队列来模拟出延时队列。消费者监听死信交换器绑定的队列,而不要监听消息发送的队列。

4、应用
1)场景

订单下单10秒后,若用户没有付款,则系统自动取消订单。

2)分析

以上适合使用延时队列解决,RabbitMQ 的延时队列可以由 过期消息+死信队列 来实现。

过期消息+死信队列 实现延时队列。
过期消息通过队列中设置 x-message-ttl 参数实现。
死信队列通过在队列申明时,给队列设置 x-dead-letter-exchange 参数,然后另外申明一个队列绑定x-dead-letter-exchange对应的交换器。
不使用传统的轮询方式,优势:若数据库数据量大,则定时轮询就会特别消耗资源,拖垮服务器,且响应慢。

3)实现

大致流程,
在这里插入图片描述

消息的重要问题

1、消息的顺序性(如何保证RabbitMQ消息的顺序性)
有的业务场景需要我们,保证RabbitMQ消息的顺序性。当 RabbitMQ 中一个queue对应多个consumer的时候,无法保证消息消费的顺序性。解决方案:

  • 1)原queue拆分成多个queue,一个queue对应一个consumer,就是多一些queue而已,确实是麻烦点;

  • 2)或者就一个queue但是对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理;

其他:针对这个问题,通过某种算法,将需要保持先后顺序的消息放到同一个消息队列中。然后只用一个消费者去消费该队列。同一个queue里的消息一定是顺序消息的。我的观点是保证入队有序就行,出队以后的顺序交给消费者自己去保证,没有固定套路。例如B消息的业务应该保证在A消息后业务后执行,那么我们保证A消息先进queueA,B消息后进queueB就可以了。

2、消息重复消费(如何保证消息不被重复消费?)
如何保证消息不被重复消费?保证消息不被重复消费的关键是保证消息队列的幂等性,这个问题针对业务场景来答分以下几点:

  • 1)如果你拿到这个消息做数据库的insert操作。那就容易了,给这个消息做一个唯一主键,那么就算出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据。

  • 2)如果你拿到这个消息做redis的set的操作,那就容易了,不用解决,因为你无论set几次结果都是一样的,set操作本来就算幂等操作。

  • 3)如果上面两种情况还不行,上大招。准备一个第三方介质,来做消费记录。以redis为例,给消息分配一个全局id,只要消费过该消息,将<id,message>以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。

综上,消息队列出现消息重复的原因有多种,消息队列并不能保证消息的唯一,所以我们只能在业务层面上做这些控制。

  • 全局唯一id,比如通过消息队列来生成订单,那订单号就是唯一的,在进行插入数据库之前先判断是否这个订单号是否已经存在了,如果已经存在了,说明已经消费过这条消息了,直接丢弃。
  • 消息确认表,将消息标示号存入redis或者数据库,在进行消费之前进行一个判断。
    总之解决的方案很多,要看具体业务场景。

3、消息丢失(如何解决丢数据的问题?)

如何解决丢数据的问题?

1)生产者丢数据

生产者的消息没有投递到MQ中怎么办?从生产者弄丢数据这个角度来看,RabbitMQ提供transaction和confirm模式来确保生产者不丢消息。

transaction机制就是说,发送消息前,开启事物(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事物就会回滚(channel.txRollback()),如果发送成功则提交事物(channel.txCommit())。

然而缺点就是吞吐量下降了。因此,按照博主的经验,生产上用confirm模式的居多。一旦channel进入confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,rabbitMQ就会发送一个Ack给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达目的队列了.如果rabiitMQ没能处理该消息,则会发送一个Nack消息给你,你可以进行重试操作。

2)消息队列丢数据

处理消息队列丢数据的情况,一般是开启消息持久化。这个持久化配置可以和confirm机制配合使用,你可以在消息持久化磁盘后,再给生产者发送一个Ack信号。这样,如果消息持久化磁盘之前,rabbitMQ阵亡了,那么生产者收不到Ack信号,生产者会自动重发。

那么如何持久化呢,这里顺便说一下吧,其实也很容易,就下面两步:

  • 将queue的持久化标识durable设置为true,则代表是一个持久的队列
  • 发送消息的时候将deliveryMode=2

这样设置以后,rabbitMQ就算挂了,重启后也能恢复数据。在消息还没有持久化到硬盘时,可能服务已经死掉,这种情况可以通过引入mirrored-queue即镜像队列,但也不能保证消息百分百不丢失(整个集群都挂掉)

3)消费者丢数据

启用手动确认模式可以解决这个问题,

  • 自动确认模式,消费者挂掉,待ack的消息回归到队列中。消费者抛出异常,消息会不断的被重发,直到处理成功。不会丢失消息,即便服务挂掉,没有处理完成的消息会重回队列,但是异常会让消息不断重试。
  • 手动确认模式,如果消费者来不及处理就死掉时,没有响应ack时会重复发送一条信息给其他消费者;如果监听程序处理异常了,且未对异常进行捕获,会一直重复接收消息,然后一直抛异常;如果对异常进行了捕获,但是没有在finally里ack,也会一直重复发送消息(重试机制)。
  • 不确认模式,acknowledge=“none” 不使用确认机制,只要消息发送完成会立即在队列移除,无论客户端异常还是断开,只要发送完就移除,不会重发。

如何确保消息正确地发送至 RabbitMQ? 如何确保消息接收方消费了消息?

1)发送方确认模式

将信道设置成 confirm 模式(发送方确认模式),则所有在信道上发布的消息都会被指派一个唯一的 ID。

一旦消息被投递到目的队列后,或者消息被写入磁盘后(可持久化的消息),信道会发送一个确认给生产者(包含消息唯一 ID)。

如果 RabbitMQ 发生内部错误从而导致消息丢失,会发送一条 nack(notacknowledged,未确认)消息。

发送方确认模式是异步的,生产者应用程序在等待确认的同时,可以继续发送消息。当确认消息到达生产者应用程序,生产者应用程序的回调方法就会被触发来处理确认消息。

2)接收方确认机制

消费者接收每一条消息后都必须进行确认(消息接收和消息确认是两个不同操作)。只有消费者确认了消息,RabbitMQ 才能安全地把消息从队列中删除。

这里并没有用到超时机制,RabbitMQ 仅通过 Consumer 的连接中断来确认是否需要重新发送消息。也就是说,只要连接不中断,RabbitMQ 给了 Consumer 足够长的时间来处理消息。保证数据的最终一致性;

下面罗列几种特殊情况:

(1)如果消费者接收到消息,在确认之前断开了连接或取消订阅,RabbitMQ 会认为消息没有被分发,然后重新分发给下一个订阅的消费者。(可能存在消息重复消费的隐患,需要去重)

(1)2如果消费者接收到消息却没有确认消息,连接也未断开,则 RabbitMQ 认为该消费者繁忙,将不会给该消费者分发更多的消息。

如何避免消息重复投递或重复消费?

在消息生产时,MQ 内部针对每条生产者发送的消息生成一个 inner-msg-id,作为去重的依据(消息投递失败并重传),避免重复的消息进入队列;在消息消费时,要求消息体中必须要有一个 bizId(对于同一业务全局唯一,如支付 ID、订单 ID、帖子 ID 等)作为去重的依据,避免同一条消息被重复消费。

RabbitMQ 的消息是怎么发送的?

首先客户端必须连接到 RabbitMQ 服务器才能发布和消费消息,客户端和 rabbit server 之间会创建一个 tcp 连接,一旦 tcp 打开并通过了认证(认证就是你发送给 rabbit 服务器的用户名和密码),你的客户端和 RabbitMQ 就创建了一条 amqp 信道(channel),信道是创建在“真实” tcp 上的虚拟连接,amqp 命令都是通过信道发送出去的,每个信道都会有一个唯一的 id,不论是发布消息,订阅队列都是通过这个信道完成的。

Dubbo

一、Dubbo 的工作流程

在这里插入图片描述

角色:

  • Provider: 暴露服务的服务提供方。
  • Consumer: 调用远程服务的服务消费方。
  • Registry: 服务注册与发现的注册中心。
  • Monitor: 统计服务的调用次调和调用时间的监控中心。
  • Container: 服务运行容器。

调用流程:

  1. 服务容器负责启动,加载,运行服务提供者。
  2. 服务提供者在启动时,向注册中心注册自己提供的服务。
  3. 服务消费者在启动时,向注册中心订阅自己所需的服务。
  4. 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
  5. 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
  6. 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。

首先服务提供者 Provider 启动然后向注册中心注册自己所能提供的服务。

服务消费者 Consumer 启动向注册中心订阅自己所需的服务。然后注册中心将提供者元信息通知给 Consumer, 之后 Consumer 因为已经从注册中心获取提供者的地址,因此可以通过负载均衡选择一个 Provider 直接调用 。

之后服务提供方元数据变更的话注册中心会把变更推送给服务消费者。

服务提供者和消费者都会在内存中记录着调用的次数和时间,然后定时的发送统计数据到监控中心。

一些注意点

首先注册中心和监控中心是可选的,你可以不要监控,也不要注册中心,直接在配置文件里面写然后提供方和消费方直连。

然后注册中心、提供方和消费方之间都是长连接,和监控方不是长连接,并且消费方是直接调用提供方,不经过注册中心。

就算注册中心和监控中心宕机了也不会影响到已经正常运行的提供者和消费者,因为消费者有本地缓存提供者的信息。

二、Dubbo 的十层架构

在这里插入图片描述

大的三层分别为 Business(业务层)、RPC 层、Remoting,并且还分为 API 层和 SPI 层。

分为大三层其实就是和我们知道的网络分层一样的意思,只有层次分明,职责边界清晰才能更好的扩展。

而分 API 层和 SPI 层这是 Dubbo 成功的一点,采用微内核设计+SPI扩展,使得有特殊需求的接入方可以自定义扩展,做定制的二次开发。

接下来咱们再来看看每一层都是干嘛的。

  • Service,业务层,就是咱们开发的业务逻辑层。
  • Config,配置层,主要围绕 ServiceConfig 和 ReferenceConfig,初始化配置信息。
  • Proxy,代理层,服务提供者还是消费者都会生成一个代理类,使得服务接口透明化,代理层做远程调用和返回结果。
  • Register,注册层,封装了服务注册和发现。
  • Cluster,路由和集群容错层,负责选取具体调用的节点,处理特殊的调用要求和负责远程调用失败的容错措施。
  • Monitor,监控层,负责监控统计调用时间和次数。
  • Portocol,远程调用层,主要是封装 RPC 调用,主要负责管理 Invoker,Invoker代表一个抽象封装了的执行体,之后再做详解。
  • Exchange,信息交换层,用来封装请求响应模型,同步转异步。
  • Transport,网络传输层,抽象了网络传输的统一接口,这样用户想用 Netty 就用 Netty,想用 Mina 就用 Mina。
  • Serialize,序列化层,将数据序列化成二进制流,当然也做反序列化。

关于dubbo十层架构的一些理解,

  • 在RPC中,Protocol是核心层,也就是只要有Protocol + Invoker + Exporter就可以完成非透明的RPC调用,然后在Invoker的主过程上Filter拦截点。
  • 而Cluster是外围概念,所以Cluster的目的是将多个Invoker伪装成一个Invoker,这样其它人只要关注Protocol层Invoker即可,加上Cluster或者去掉Cluster对其它层都不会造成影响,因为只有一个提供者时,是不需要Cluster的。
  • Proxy层封装了所有接口的透明化代理,而在其它层都以Invoker为中心,只有到了暴露给用户使用时,才用Proxy将Invoker转成接口,或将接口实现转成Invoker,也就是去掉Proxy层RPC是可以Run的,只是不那么透明,不那么看起来像调本地服务一样调远程服务。
  • 而Remoting实现是Dubbo协议的实现,如果你选择RMI协议,整个Remoting都不会用上,Remoting内部再划为Transport传输层和Exchange信息交换层,Transport层只负责单向消息传输,是对Mina、Netty、Grizzly的抽象,它也可以扩展UDP传输,而Exchange层是在传输层之上封装了Request-Response语义。
  • Registry和Monitor实际上不算一层,而是一个独立的节点,只是为了全局概览,用层的方式画在一起。

三、服务调用

1、服务调用过程

在这里插入图片描述

首先消费者启动会向注册中心拉取服务提供者的元信息,然后调用流程也是从 Proxy 开始,毕竟都需要代理才能无感知。

Proxy 持有一个 Invoker 对象,调用 invoke 之后需要通过 Cluster 先从 Directory 获取所有可调用的远程服务的 Invoker 列表,如果配置了某些路由规则,比如某个接口只能调用某个节点的那就再过滤一遍 Invoker 列表。

剩下的 Invoker 再通过 LoadBalance 做负载均衡选取一个。然后再经过 Filter 做一些统计什么的,再通过 Client 做数据传输,比如用 Netty 来传输。

传输需要经过 Codec 接口做协议构造,再序列化。最终发往对应的服务提供者。

服务提供者接收到之后也会进行 Codec 协议处理,然后反序列化后将请求扔到线程池处理。某个线程会根据请求找到对应的 Exporter ,而找到 Exporter 其实就是找到了 Invoker,但是还会有一层层 Filter,经过一层层过滤链之后最终调用实现类然后原路返回结果。

完成整个调用过程!
3、服务调用过程
Dubbo 服务调用过程比较复杂,包含众多步骤。比如发送请求、编解码、服务降级、过滤器链处理、序列化、线程派发以及响应请求等步骤。官方给出的服务调用过程图,如下,
在这里插入图片描述
首先服务消费者通过代理对象 Proxy 发起远程调用,接着通过网络客户端 Client 将编码后的请求发送给服务提供方的网络层上,也就是 Server。Server 在收到请求后,首先要做的事情是对数据包进行解码。然后将解码后的请求发送至分发器 Dispatcher,再由分发器将请求派发到指定的线程池上,最后由线程池调用具体的服务。这就是一个远程调用请求的发送与接收过程。至于响应的发送与接收过程,这张图中没有表现出来。

四、Dubbo 常见配置

在这里插入图片描述

知道什么是 SPI 嘛?

这又是一个方向了,从上面的回答中,不论是从 Dubbo 协议,还是 cluster ,什么 export 方法等等无处不是 SPI 的影子,所以如果是问 Dubbo 方面的问题,问 SPI 是毋庸置疑的,因为源码里 SPI 无处不在,而且 SPI 也是 Dubbo 可扩展性的基石。

所以这个题目没什么套路,直接答就行。

SPI 是 Service Provider Interface,主要用于框架中,框架定义好接口,不同的使用者有不同的需求,因此需要有不同的实现,而 SPI 就通过定义一个特定的位置,Java SPI 约定在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,然后文件里面记录的是此 jar 包提供的具体实现类的全限定名

所以就可以通过接口找到对应的文件,获取具体的实现类然后加载即可,做到了灵活的替换具体的实现类。

为什么 Dubbo 不用 JDK 的 SPI,而是要自己实现?

问这个问题就是看你有没有深入的了解,或者自己思考过,不是死板的看源码,或者看一些知识点。

很多点是要思考的,不是书上说什么就是什么,你要知道这样做的理由,有什么好处和坏处,这很容易看出一个人是死记硬背还是有自己的思考。

答:因为 Java SPI 在查找扩展实现类的时候遍历 SPI 的配置文件并且将实现类全部实例化,假设一个实现类初始化过程比较消耗资源且耗时,但是你的代码里面又用不上它,这就产生了资源的浪费。

因此 Dubbo 就自己实现了一个 SPI,给每个实现类配了个名字,通过名字去文件里面找到对应的实现类全限定名然后加载实例化,按需加载。

这答出来就加分了,面试官心里在拍手了,不错不错有点东西。

Dubbo有哪几种集群容错方案,默认是哪种?

在这里插入图片描述

Dubbo 为什么默认用 Javassist

上面你回答 Dubbo 用 Javassist 动态代理,所以很可能会问你为什么要用这个代理,可能还会引申出 JDK 的动态代理、ASM、CGLIB。

所以这也是个注意点,如果你不太清楚的话上面的回答就不要扯到动态代理了,如果清楚的话那肯定得提,来诱导面试官来问你动态代理方面的问题,这很关键。

面试官是需要诱导的,毕竟他也想知道你优秀的方面到底有多优秀,你也取长补短,双赢双赢。

来回答下为什么用 Javassist,很简单,就是快,且字节码生成方便。

ASM 比 Javassist 更快,但是没有快一个数量级,而Javassist 只需用字符串拼接就可以生成字节码,而 ASM 需要手工生成,成本较高,比较麻烦。

如果让你设计一个 RPC 框架,如何设计?

你可以从底层向上开始说起。

首先需要实现高性能的网络传输,可以采用 Netty 来实现,不用自己重复造轮子,然后需要自定义协议,毕竟远程交互都需要遵循一定的协议,然后还需要定义好序列化协议,网络的传输毕竟都是二进制流传输的。

然后可以搞一套描述服务的语言,即 IDL(Interface description language),让所有的服务都用 IDL 定义,再由框架转换为特定编程语言的接口,这样就能跨语言了。

此时最近基本的功能已经有了,但是只是最基础的,工业级的话首先得易用,所以框架需要把上述的细节对使用者进行屏蔽,让他们感觉不到本地调用和远程调用的区别,所以需要代理实现。

然后还需要实现集群功能,因此的要服务发现、注册等功能,所以需要注册中心,当然细节还是需要屏蔽的。

最后还需要一个完善的监控机制,埋点上报调用情况等等,便于运维。

这样一个 RPC 框架的雏形就差不多了。

常见的Linux命令

常用的文件、目录命令

ls:用户查看目录下的文件,ls -a可以用来查看隐藏文件,ls -l可以用于查看文件的详细信息,包括权限、大小、所有者等信息。
图片
touch:用于创建文件。如果文件不存在,则创建一个新的文件,如果文件已存在,则会修改文件的时间戳。

cat:cat是英文concatenate的缩写,用于查看文件内容。使用cat查看文件的话,不管文件的内容有多少,都会一次性显示,所以他不适合查看太大的文件。

more:more和cat有点区别,more用于分屏显示文件内容。可以用空格键向下翻页,b键向上翻页

less:和more类似,less用于分行显示

tail:可能是平时用的最多的命令了,查看日志文件基本靠他了。一般用户tail -fn 100 xx.log查看最后的100行内容

常用的权限命令

chmod:修改权限命令。一般用+号添加权限,-号删除权限,x代表执行权限,r代表读取权限,w代表写入权限,常见写法比如chmod +x 文件名 添加执行权限。

还有另外一种写法,使用数字来授权,因为r=4,w=2,x=1,平时执行命令chmod 777 文件名这就是最高权限了。

第一个数字7=4+2+1代表着所有者的权限,第二个数字7代表所属组的权限,第三个数字代表其他人的权限。

常见的权限数字还有644,所有者有读写权限,其他人只有只读权限,755代表其他人有只读和执行权限。

chown:用于修改文件和目录的所有者和所属组。一般用法chown user 文件用于修改文件所有者,chown user:user 文件修改文件所有者和组,冒号前面是所有者,后面是组。

常用的压缩命令

zip:压缩zip文件命令,比如zip test.zip 文件可以把文件压缩成zip文件,如果压缩目录的话则需添加-r选项。

unzip:与zip对应,解压zip文件命令。unzip xxx.zip直接解压,还可以通过-d选项指定解压目录。
在这里插入图片描述
gzip:用于压缩.gz后缀文件,gzip命令不能打包目录。需要注意的是直接使用gzip 文件名源文件会消失,如果要保留源文件,可以使用gzip -c 文件名 > xx.gz,解压缩直接使用gzip -d xx.gz

tar:tar常用几个选项,-x解打包,-c打包,-f指定压缩包文件名,-v显示打包文件过程,一般常用tar -cvf xx.tar 文件来打包,解压则使用tar -xvf xx.tar。

Linux的打包和压缩是分开的操作,如果要打包并且压缩的话,按照前面的做法必须先用tar打包,然后再用gzip压缩。当然,还有更好的做法就是-z命令,打包并且压缩。

使用命令tar -zcvf xx.tar.gz 文件来打包压缩,使用命令tar -zxvf xx.tar.gz来解压缩

Zookeeper

ZooKeeper主要服务于分布式系统,可以用ZooKeeper来做:统一配置管理、统一命名服务、分布式锁、集群管理。

Zookeeper文件系统

Zookeeper提供一个多层级的节点命名空间(节点称为znode)。与文件系统不同的是,这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据而目录节点不行。
Zookeeper为了保证高吞吐和低延迟,在内存中维护了这个树状的目录结构,这种特性使得Zookeeper不能用于存放大量的数据,每个节点的存放数据上限为1M。
在这里插入图片描述

  • 短暂/临时(Ephemeral):当客户端和服务端断开连接后,所创建的Znode(节点)会自动删除
  • 持久(Persistent):当客户端和服务端断开连接后,所创建的Znode(节点)不会删除

二、ZooKeeper可以做什么

1、命名服务
命名服务是指通过指定的名字来获取资源或者服务的地址,利用zk创建一个全局的路径,即是唯一的路径,这个路径就可以作为一个名字,指向集群中的集群,提供的服务的地址,或者一个远程的对象等等。

2、配置管理(文件系统、通知机制)
程序分布式的部署在不同的机器上,将程序的配置信息放在zk的znode下,当有配置发生改变时,也就是znode发生变化时,可以通过改变zk中某个目录节点的内容,利用watcher通知给各个客户端,从而更改配置。

3、集群管理
是否有机器退出和加入、选举master。对于机器的退出,所有机器约定在父目录下创建临时目录,对于新机器的加入,所有机器创建临时顺序编号目录节点。

4、分布式锁
分为两类,一个是保持独占:客户端需要的时候,就去通过createznode的方式实现,所有客户端都去创建/distribute_lock节点,用完就删除节点就行了。一个是控制时序,/distribute_lock已经预先存在,所有客户端在它下面创建临时顺序编号目录节点。主要流程是:客户端在获取分布式锁的时候在locker节点下创建临时顺序节点,释放锁的时候就删除,客户端首先调用createZnode放在在locker创建临时顺序节点,然后调用getChildren来获取locker下面的所有子节点,此时不用设置watch,客户端获取了所有子节点的path之后,反正最后要找到最小序号的那个节点,调用exist方法,同时对其注册事件监听器

5、队列管理
两种类型的队列,一种是同步队列,一个是按照FIFO方式进行入队和出队,第二种保证了队列消息的不会丢失,因为会在特定的目录下创建一个persistent_sequential节点,创建成功时watcher通知等待的队列,队列删除序列号最小的节点,此场景下,zk中的znode用于消息存储,znode存储的数据就是消息队列中的消息内容,sequential序列号就是消息的编号,按序列取出即可。

Zookeeper工作原理

  • Zookeeper的核心是原子广播,这个机制保证了各个server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式和广播模式。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数server的完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和server具有相同的系统状态。
  • 一旦leader已经和多数的follower进行了状态同步后,他就可以开始广播消息了,即进入广播状态。这时候当一个server加入zookeeper服务中,它会在恢复模式下启动,发现leader,并和leader进行状态同步。待到同步结束,它也参与消息广播。Zookeeper服务一直维持在Broadcast(广播)状态,直到leader崩溃了或者leader失去了大部分的followers支持。
  • 广播模式需要保证proposal(提议)被按顺序处理,因此zk采用了递增的事务id号(zxid)来保证。所有的提议(proposal)都在被提出的时候加上了zxid。实现中zxid是一个64为的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch。低32位是个递增计数。
  • 当leader崩溃或者leader失去大多数的follower,这时候zk进入恢复模式,恢复模式需要重新选举出一个新的leader,让所有的server都恢复到一个正确的状态。
  • 每个Server启动以后都询问其它的Server它要投票给谁。对于其他server的询问,server每次根据自己的状态都回复自己推荐的leader的id和上一次处理事务的zxid(系统启动时每个server都会推荐自己)。收到所有Server回复以后,就计算出zxid最大的哪个Server,并将这个Server相关信息设置成下一次要投票的Server。计算这过程中获得票数最多的的sever为获胜者,如果获胜者的票数超过半数,则改server被选为leader。否则,继续这个过程,直到leader被选举出来。
    • leader就会开始等待server连接
    • Follower连接leader,将最大的zxid发送给leader
    • Leader根据follower的zxid确定同步点
    • 完成同步后通知follower 已经成为uptodate状态
    • Follower收到uptodate消息后,又可以重新接受client的请求进行服务了

自我介绍

面试官你好,我叫成,我目前是哈尔滨理工大学计算机科学与技术专业大四在读,在大学期间,自学了Java相关的一些知识,以及SSM框架还有Zookeeper,Dubbo的RPC框架。在学校期间自己学习做了一个网上购物超市系统,项目整体上看是采用分布式架构,在windows下进行开发,将Redis和RabbitMQ部署到CentOS虚拟机上。项目主要分为三个模块,Common模块,Customer模块和Provider模块,其中Provider模块以Zookeeper做注册中心通过Dubbo框架使服务暴露给Customer模块,在提高性能方面使用了RabbitMQ做延时队列以达到削峰添谷的效果,同时又以Redis做缓存对系统进行优化,以避免出现库存超卖现象。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值