分布式延时消息的另外一种选择 Redisson (推荐使用)

news2024/11/19 1:41:59

前言

目录

前言

基本使用

内部数据结构介绍

基本流程

发送延时消息

获取延时消息

初始化延时队列

总结


因为工作中需要用到分布式的延时队列,调研了一段时间,选择使用 Redisson DelayedQueue,为了搞清楚内部运行流程,特记录下来。

总体流程大概是图中的这个样子,初看一眼有点不知从何下手,接下来我会通过以下几点来分析流程,相信看完本文你能了解整个运行流程。

  • 基本使用

  • 内部数据结构介绍

  • 基本流程

  • 发送延时消息

  • 获取延时消息

  • 初始化延时队列

基本使用

发送延迟消息代码如下,发送了一条延迟时间为 5s 的消息。

public void produce() {
  String queuename = "delay-queue";
  RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
  RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
  delayedQueue.offer("测试延迟消息", 5, TimeUnit.SECONDS);
}

接收消息代码如下,可以看到 delayedQueue 是没有用到的,那么为什么要加这一行呢,这个后面总结部分回答。

public void consume() throws InterruptedException {
 String queuename = "delay-queue";
  RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
  RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
  String msg = blockingQueue.take();
  //收到消息进行处理...
}

这两段代码可以写在两个不同的 Java 工程里,只要连接的是同一个 Redis 就行。

调用 comsume() 之后,如果队列里没有消息,会阻塞等待队列里有消息并且取到了才会返回。之所以这么说是因为可能有别的 Java 进程也在跟你一样取同一个队列里的消息,如果消息被另一个抢完了,那这时就还得阻塞等待。

这时看上去的原理是这样的:

生产者调用 offer() 后,自己内部开启一个定时器,等到了时间在发送到 redis 的 list 里。

图片

如果是这样设计的话,相信大家都能看出来一个很简单的问题,要是延时时间还没到,生产者自己挂了,那样消息就丢了。所以,还是让我们接着往下看。

内部数据结构介绍

redisson 源码里一共创建了三个队列:【消息延时队列】、【消息顺序队列】、【消息目标队列】。

图片

假设在同一时间按照 msg1、msg2、msg3 的顺序发消息到延时队列,这三条消息就会被保存在【消息延时队列】和【消息顺序队列】。

可以看到【消息延时队列】的顺序是按照到期时间升序排列的,而不是像【消息顺序队列】按照插入顺序排。

消息到期后会将消息从前两个队列移除(怎么移?谁来移?),插入【消息目标队列】,也就是图中第三个队列。

消费者也是阻塞在【消息目标队列】上取消息。

这时可以简单说明下每个队列的作用:

  • 【消息延时队列】利用按照到期时间排序的特性,可以很快找到下一个要到期的消息,客户端内部自己定时到【消息目标队列】取

  • 【消息顺序队列】这个队列对分析的流程关联不大,可以忽略

  • 【消息目标队列】存放到期的消息,供消费端取

其实【消息延时队列】队列里存的时间(也就是 zet 的 score)是到期的时间戳,为了画图方便,图里就画的是延迟的时间,不过不影响理解。

理解好这几个队列的名字和作用,后面还会一直用到,如果忘了可以翻回来回顾下。

因为书写理解方便和【消息顺序队列】在本文没涉及到,后面部分好几次提到的内容: 把到期的消息从【消息延时队列】移到【消息目标队列】里 ,这句话实际的代码逻辑是这样:把【消息延时队列】和【消息顺序队列】里的到期消息移除,把它们插入到【消息目标队列】。

基本流程

知道了内部所使用到的数据结构后,这里可以简单说下整体的基本流程。

先说 发送延迟消息 ,发送的延迟消息会先存在【消息延时队列】和【消息顺序队列】,如果【消息延时队列】原本是空的,会发布订阅信息提醒有新的消息。

获取延迟消息 只需要从【消息目标队列】阻塞的取就行了,因为里面都是到期数据。

那么问题就只剩下怎么样判断时间到了,把【消息延时队列】里的消息移动到【消息目标队列】里呢?

这部分工作交给了 初始化延时队列 来处理。

这里面会定时从【消息延时队列】查询最新到期时间,定时去把【消息延时队列】里的消息移动到【消息目标队列】里。

如果【消息延时队列】是空的,就不会再定时查,而是等待发布订阅信息提醒,再定时把【消息延时队列】里的消息移动到【消息目标队列】里。

刚开始看可能有点抽象,可以看完底下一节内容之后,再回头来看这里对应的流程总结,可能会比较清晰。

发送延时消息

发送延时消息的逻辑比较简单,先看下发送的代码。

public void produce() {
  String queuename = "delay-queue";
  RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
  RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
  delayedQueue.offer("测试延迟消息", 5, TimeUnit.SECONDS);
}

从 delayedQueue.offer 方法开始,最终会执行到 RedissonDelayedQueue 的 offerAsync 方法里。

offerAsync 方法的作用就是发送一段脚本给 redis 执行,脚本内容是:

  1. 将消息和到期时间插入【消息延时队列】和【消息顺序队列】

  2. 如果最近到期的消息是刚刚插入的消息,则对指定主题发布到期时间,目的是为了让客户端定时去把【消息延时队列】里的到期数据移动到【消息目标队列】

@Override
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
  if (delay < 0) {
   throw new IllegalArgumentException("Delay can't be negative");
  }

  long delayInMs = timeUnit.toMillis(delay);
  long timeout = System.currentTimeMillis() + delayInMs;

  long randomId = ThreadLocalRandom.current().nextLong();
  return commandExecutor.evalWriteNoRetryAsync(getRawName(), codec, RedisCommands.EVAL_VOID,
  "local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);" 
  + "redis.call('zadd', KEYS[2], ARGV[1], value);"
  + "redis.call('rpush', KEYS[3], value);"
  // if new object added to queue head when publish its startTime 
  // to all scheduler workers 
  + "local v = redis.call('zrange', KEYS[2], 0, 0); "
  + "if v[1] == value then "
  + "redis.call('publish', KEYS[4], ARGV[1]); "
  + "end;",
  Arrays.<Object>asList(getRawName(), timeoutSetName, queueName, channelName),
  timeout, randomId, encode(e));
}

获取延时消息

获取延时消息是本文最简单的一部分。

public void consume() throws InterruptedException {
  String queuename = "delay-queue";
  RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
  RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
  String msg = blockingQueue.take();
  //收到消息进行处理...
}

blockingQueue.take() 方法其实只是对【消息目标队列】执行 blpop 阻塞的获取到期消息

初始化延时队列

看一下初始化的代码。

public void init() {
    String queuename = "delay-queue";
    RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
    RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
}

入口就是在 redissonClient.getDelayedQueue(blockingQueue) 中,创建了 RedissonDelayedQueue 对象,并执行了构造方法里的逻辑。

那么这里面主要做了什么事呢?

主要是调用了 QueueTransferTask 的 start() 方法。

public void start() {
  RTopic schedulerTopic = getTopic();
  statusListenerId = schedulerTopic.addListener(new BaseStatusListener() {
      @Override
    public void onSubscribe(String channel) {
      pushTask();
    }
 });

 messageListenerId = schedulerTopic.addListener(Long.class, new MessageListener<Long>() {
      @Override
      public void onMessage(CharSequence channel, Long startTime) {
     scheduleTask(startTime);
   }
 });
}

这段代码主要是设置了指定主题(主题名:redisson_delay_queue_channel:{queuename})两个发布订阅的监听器。

  1. 当指定主题有新订阅时调用 pushTask() 方法,里面又会调用 pushTaskAsync() 方法

  2. 当指定主题有新消息时调用 scheduleTask(startTime) 方法

需要注意的是,这里会先订阅指定主题,然后触发执行 onSubscribe() 方法。

所以我们主要搞懂这三个方法都是做什么的,那么整个初始化流程就明白了。

因为这三个方法是相互调用的,只看文字的话容易云里雾里,这里有个流程图,看方法解释文字的时候可以对照着流程图看比较有印象。

图片

三个方法调用流程图.drawio.png

  • scheduleTask()

    这个方法看起来多,但核心内容就是根据方法参数指定的时间调用 pushTask()。

    private void scheduleTask(final Long startTime) {
      TimeoutTask oldTimeout = lastTimeout.get();
      if (startTime == null) {
        return;
      }
    
      if (oldTimeout != null) {
        oldTimeout.getTask().cancel();
      }
    
      long delay = startTime - System.currentTimeMillis();
      if (delay > 10) {
        Timeout timeout = connectionManager.newTimeout(new TimerTask() {                    
          @Override
          public void run(Timeout timeout) throws Exception {
            pushTask();
    
            TimeoutTask currentTimeout = lastTimeout.get();
            if (currentTimeout.getTask() == timeout) {
              lastTimeout.compareAndSet(currentTimeout, null);
            }
          }
        }, delay, TimeUnit.MILLISECONDS);
        if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) {
          timeout.cancel();
        }
      } else {
        pushTask();
      }
    }
    
  • pushTaskAsync()

    这个方法是抽象方法,在创建 RedissonDelayedQueue 对象的时候传进来的,代码如下:

    @Override
    protected RFuture<Long> pushTaskAsync() {
      return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
      "local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
      + "if #expiredValues > 0 then "
      + "for i, v in ipairs(expiredValues) do "
      + "local randomId, value = struct.unpack('dLc0', v);"
      + "redis.call('rpush', KEYS[1], value);"
      + "redis.call('lrem', KEYS[3], 1, v);"
      + "end; "
      + "redis.call('zrem', KEYS[2], unpack(expiredValues));"
      + "end; "
      // get startTime from scheduler queue head task
      + "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
      + "if v[1] ~= nil then "
      + "return v[2]; "
      + "end "
      + "return nil;",
      Arrays.<Object>asList(getRawName(), timeoutSetName, queueName),
      System.currentTimeMillis(), 100);
    }
    

    看不懂也不要紧,听我解释下就明白了。

    这里发送了一段脚本给 redis 执行:

    我的理解就是初始化的时候

    1是为了处理旧的消息,比如生产者1发送了消息,然后时间没到自己下线了,这时如果没有其他客户端在线,就没有人能把数据从【消息目标队列】移到【消息目标队列】了。

    2是返回的这个时间戳,会拿这个定时,等时间到了去【消息目标队列】拉去到期的消息。

    简单总结就是这个方法是把到期消息从【消息延时队列】放到【消息目标队列】里,并且返回了最近要到期消息的时间戳。

  1. 从【消息延时队列】取出前一百条到期的消息,如果有的话,添加到【消息目标队列】里,并将这些消息从【消息延时队列】和【消息顺序队列】中移除

  2. 从【消息延时队列】取出下一条要到期的消息,返回它的到期时间戳(如果队列里没消息返回空)。

  • pushTask()

    private void pushTask() {
      RFuture<Long> startTimeFuture = pushTaskAsync();
      startTimeFuture.whenComplete((res, e) -> {
        if (e != null) {
          if (e instanceof RedissonShutdownException) {
            return;
          }
          log.error(e.getMessage(), e);
          scheduleTask(System.currentTimeMillis() + 5 * 1000L);
          return;
        }
    
        if (res != null) {
          scheduleTask(res);
        }
      });
    }
    

    这个代码看起来就比较简单,调用了 pushTaskAsync() 获取最近要到期消息的时间戳(异步封装了一下)。

    有异常的话就调用 scheduleTask() 五秒后再执行一次 pushTask()。

    没有异常的话如果有最近要到期消息的时间戳(说明【消息延时队列】里还有未到期消息),用这个最新到期时间调用 scheduleTask(),在这个指定的时间调用 pushTask()。

    这个方法简单总结就是决定了要不要调用、什么时候再调用 pushTask(),主要操作逻辑都在 pushTaskAsync() 里(把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费)。

了解了上面几个方法的流程和含义,还记得一开头提到的添加了两个发布订阅的监听器吗?

1.当指定主题有新订阅时调用 pushTask() 方法,里面又会调用 pushTaskAsync() 方法

2.当指定主题有新消息时调用 scheduleTask(startTime) 方法

需要注意的是,这里会先订阅指定主题,然后触发执行 onSubscribe() 方法

  1. 在初始化延时队列刚启动的时候,处理到期旧数据:把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费;处理新数据:获取下次到期时间决定下次调用 pushTask() 的时间。

    上面讲的这种情况是站在当前客户端的视角,但毕竟这是监听订阅信息,如果启动不止一个客户端的话(就算是1个生产者1个消费者,也算两个客户端),总有一个客户端的订阅信息回调函数,会不会有问题?

    仔细想想是没有的,处理到期旧数据:之前启动的客户端已经处理完了;处理新数据:获取最近到期时间,在 scheduleTask() 里,如果之前有正在定时的任务,会把原来正在定时的任务取消掉。这个被取消的任务,时间要么就是当前这个时间,要嘛是之后的时间,取消掉不会影响逻辑。

  2. 为了应对原本【消息延时队列】里没消息了这种情况,流程结束了,重启定时去调用 pushTask() ,把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费。

总结

再放一下开头的图总体流程图:

图片

  1. 初始化延时队列 时会把【消息延时队列】里的到期数据移动到【消息目标队列】,没有也有可能;然后是找最近要到期的消息时间,定时去拉,这个刚启动也是可能没有的,不过不要紧,这两步是为了处理滞留在【消息延时队列】的旧数据(在发送了延时消息后,还没到期时所有客户端都下线了,这样就没人能把【消息延时队列】里的到期数据移动到【消息目标队列】里,就会出现这种情况);

    最主要的还是设置了发布订阅监听器,当有人发送延时消息的时候能收到通知,定时去将【消息延时队列】里的到期数据移动到【消息目标队列】。

  2. 发送延时消息 会先发送到【消息延时队列】和【消息顺序队列】,如果【消息延时队列】里没有数据,则将刚发送的到期时间发布到指定主题,提醒其他客户端有新消息。

  3. 初始化延时队列时设置的发布订阅监听器把【消息延时队列】里的到期数据移动到【消息目标队列】里。

  4. 获取延迟消息 只需要执行 blpop 阻塞的获取【消息目标队列】的消息就可以了。

这里回答开头部分说的问题,到这看完了本文,你可以试着自己想一想这个问题的答案。

接收消息代码如下,可以看到 delayedQueue 是没有用到的,那么为什么要加这一行呢,这个后面总结部分回答。

public void consume() throws InterruptedException {
    String queuename = "delay-queue";
    RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
    RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
    String msg = blockingQueue.take();
    //收到消息进行处理...
}

其实这个问题也是我开发过程中遇到的一个奇怪的地方,接收方代码没有初始化延时队列。

首先再啰嗦一句,初始化延时队列的作用是会定时去把【消息延时队列】里的到期数据移动到【消息目标队列】。

如果只有发送方初始化延时队列:

  1. 发送方发送了延迟消息,在到期之前下线了(它就不能把【消息延时队列】里的到期数据移动到【消息目标队列】),而且没有其他发送方。

  2. 接收方不管有多少个,都没人能把【消息延时队列】里的到期数据移动到【消息目标队列】。

所以接收方代码里也初始化延时队列能够避免一部分数据丢失问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1435085.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

何时用‘x-->?‘将其代入lim中?极限的化简

【何时用‘x-->?将其代入lim中】 【极限的化简】 与极限解法区别&#xff1f; 方法有哪些&#xff1f; 什么条件下可以用&#xff1f; 怎么用&#xff1f;

Bootstrap5 图片轮播

Bootstrap5 轮播样式表使用的是CDN资源 <title>亚丁号</title><!-- 自定义样式表 --><link href"static/front/css/front.css" rel"stylesheet" /><!-- 新 Bootstrap5 核心 CSS 文件 --><link rel"stylesheet"…

大型语言模型(LLM)的优势、劣势和风险

最近关于大型语言模型的奇迹&#xff08;&#xff09;已经说了很多LLMs。这些荣誉大多是当之无愧的。让 ChatGPT 描述广义相对论&#xff0c;你会得到一个非常好&#xff08;且准确&#xff09;的答案。然而&#xff0c;归根结底&#xff0c;ChatGPT 仍然是一个盲目执行其指令集…

CodeFuse-VLM 开源,支持多模态多任务预训练/微调

CodeFuse-MFT-VLM 项目地址&#xff1a;https://github.com/codefuse-ai/CodeFuse-MFT-VLM CodeFuse-VLM-14B 模型地址&#xff1a;CodeFuse-VLM-14B CodeFuse-VLM框架简介 随着huggingface开源社区的不断更新&#xff0c;会有更多的vision encoder 和 LLM 底座发布&#x…

政安晨:机器学习快速入门(一){基于Python与Pandas}

对于刚接触ML&#xff08;机器学习&#xff09;的小伙伴来说&#xff0c;通过几篇文章能够快速登堂入室是非常及时且有用的&#xff0c;作者政安晨力求让小伙伴们&#xff0c;几篇文章内就可以达到这个目标&#xff0c;咱们开始&#xff01; 机器学习简介 咱们先看一下Pandas&…

Web APIs 2 事件

Web APIs 2 事件 事件监听案例&#xff1a;广告关闭案例&#xff1a;随机问答 事件监听版本事件类型案例&#xff1a;轮播图完整焦点事件键盘事件输入事件案例&#xff1a;评论字数统计 事件对象获取事件对象事件对象常用属性案例&#xff1a;评论回车发布 环境对象this回调函数…

6-2、T型加减速计算简化【51单片机+L298N步进电机系列教程】

↑↑↑点击上方【目录】&#xff0c;查看本系列全部文章 摘要&#xff1a;本节介绍简化T型加减速计算过程&#xff0c;使其适用于单片机数据处理。简化内容包括浮点数转整型数计算、加减速对称处理、预处理计算 一、浮点数转整型数计算 根据上一节内容已知 常用的晶振大小…

vscode 突然连接不上服务器了(2024年版本 自动更新从1.85-1.86)

vscode日志 ll192.168.103.5s password:]0;C:\WINDOWS\System32\cmd.exe [17:09:16.886] Got some output, clearing connection timeout [17:09:16.887] Showing password prompt [17:09:19.688] Got password response [17:09:19.688] "install" wrote data to te…

Excel——高级筛选匹配条件提取数据

一、筛选多条件 Q&#xff1a;筛选多个条件&#xff0c;并将筛选出的内容复制到其他区域 点击任意一个单元格 点击【数据】——【筛选】——【高级筛选】 选择【将筛选结果复制到其他位置】——在【列表区域】 鼠标选择对应的区域位置&#xff0c;条件区域一定要单独写出来&a…

vue2.0+使用md-edit编辑器

前言&#xff1a;小刘开发过程中&#xff0c;如果是博客项目一般是会用到富文本。众多富文本中&#xff0c;小刘选择了markdown&#xff0c;并记录分享了下来。 # 使用 npm npm i kangc/v-md-editor -Smain.js基本配置import VueMarkdownEditor from kangc/v-md-editor; import…

【观察】数据驱动AI的新纪元,联想凌拓的新使命

知名科技杂志《连线》创始主编凯文凯利曾预测&#xff1a;“在未来的 100 年里&#xff0c;人工智能将超越任何一种人工力量&#xff0c;将人类引领到一个前所未有的时代。” 确实如此&#xff0c;犹如历史上蒸汽机、电力、计算机和互联网等通用技术一样&#xff0c;近20年来&a…

【Kotlin】Kotlin环境搭建

1 前言 Kotlin 是一种现代但已经成熟的编程语言&#xff0c;由 JetBrains 公司于 2011 年设计和开发&#xff0c;并在 2012 年开源&#xff0c;在 2016 年发布 v1.0 版本。在 2017 年&#xff0c;Google 宣布 Kotlin 正式成为 Android 开发语言&#xff0c;这进一步推动了 Kotl…

“极简壁纸“爬虫JS逆向·实战

文章目录 声明目标分析确定目标目标检索 代码补全完整代码 爬虫逻辑完整代码 运行结果 声明 本教程只用于交流学习&#xff0c;不可用于商业用途&#xff0c;不可对目标网站进行破坏性请求&#xff0c;请遵守相关法律法规。 目标分析 确定目标 获取图片下载链接 目标检索…

JVM 性能调优 - JVM 参数基础(2)

查看 JDK 版本 $ java -version java version "1.8.0_151" Java(TM) SE Runtime Environment (build 1.8.0_151-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode) 查看 Java 帮助文档 $ java -help 用法: java [-options] class [args...] …

PDF文件格式(一):新版格式交叉引用表

PDF交叉引用表是PDF的重要组成部分&#xff0c;本文介绍的是新交叉引用表&#xff0c;这种引用表的格式是PDF的obj格式&#xff0c;内容是被压缩存放在obj下的stream中&#xff0c;因此比常规的引用表格式复杂。下面就开始介绍这种交叉引用表的格式和解析的方法&#xff1a; 1…

基于Vue2用keydown、setTimeout事件实现连续按键(连击)任意键(或组合键)3秒触发自定义事件(以F1键为例)

核心代码 <template></template> <script> export default {created() {//监听弹起快捷键addEventListener("keyup", this.keyup);},destroyed(d) {//移除监听弹起快捷键removeEventListener("keyup", this.keyup);},methods: {keyup(…

leetcode(双指针)283.移动零(C++)DAY3

文章目录 1.题目示例提示 2.解答思路3.实现代码结果 4.总结 1.题目 给定一个数组 nums&#xff0c;编写一个函数将所有 0 移动到数组的末尾&#xff0c;同时保持非零元素的相对顺序。 请注意 &#xff0c;必须在不复制数组的情况下原地对数组进行操作。 示例 示例 1: 输入…

abap - 发送邮件,邮件正文带表格和excel附件

发送内容 的数据获取&#xff1a; 正文部分使用cl_document_bcs>create_document静态方法实现 传入参数为html内表结构 CLEAR lo_document .lo_document cl_document_bcs>create_document(i_type HTMi_text lt_htmli_length conlengthsi_subject lv_subje…

分享springboot框架的一个开源的本地开发部署教程(若依开源项目开发部署过程分享持续更新二开宝藏项目PostgresSQL数据库版)

1首先介绍下若依项目&#xff1a; 若依是一个基于Spring Boot和Spring Cloud技术栈开发的多租户权限管理系统。该开源项目提供了一套完整的权限管理解决方案&#xff0c;包括用户管理、角色管理、菜单管理、部门管理、岗位管理等功能。 若依项目采用前后端分离的架构&#xf…

Zephyr NRF7002 实现AppleJuice

BLE的基础知识 ble的信道和BR/EDR的信道是完全不一样的。但是范围是相同的&#xff0c;差不多也都是2.4Ghz的频道。可以简单理解为空中有40个信道0~39信道。两个设备在相同的信道里面可以进行相互通信。 而这些信道SIG又重新编号&#xff1a; 这个编号就是把37 38 39。 3个信道…