2024年3月

故事

春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着...

一声不和谐的座机电话声打破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥原因。”客服部小姐姐甜美的声音从电话那头传来。“哦哦,好,我看一下,把商品编号发一下吧......”

由于前一段时间的系统熟悉,小猫对现在的数据表模型已经了然于胸,当下就直接定位到了商品规格信息表,发现数据库中客户想购买的规格已经被下架了,但是前端的缓存好像并没有被刷新。

小猫在系统中找到了之前开发人员留的后门接口,直接curl语句重新刷新了一下接口,缓存问题搞定了。

关于商品缓存和数据库不一致的情况,其实小猫一周会遇到好几个这样的客诉,他深受DB以及缓存不一致的苦,于是他下定决心想要从根本上解决问题,而不是curl调用后门接口......

写在前面

小猫的态度其实还是相当值得肯定的,当他下定决心从根本上排查问题的时候开始,小猫其实就是一名合格而且负责的研发,这也是我们每一位软件研发人员所需要具备的处理事情的态度。

在软件系统演进的过程中,只有我们在修复历史遗留的问题的时候,才是真正意义上地对系统进行了维护,如果我们使用一些极端的手段(例如上述提到的后门接口curl语句)来保持古老而陈腐的代码继续工作的时候,这其实是一种苟且。一旦系统有了问题,我们其实就需要及时进行优化修复,否则会形成不好的示范,更多的后来者倾向于类似的方式解决问题,这也是为什么FixController存在的原因,这其实就是系统腐化的标志。

言归正传,关于缓存和DB不一致相信大家在日常开发的过程中都有遇到过,那么我们接下来就和大家好好盘一盘,缓存和DB不一致的时候,咱们是如何去解决的。接下来,大家会看到解决方案以及实战。
缓存概要

常规接口缓存读取更新

常规缓存读取

看到上面的图,我们可以清晰地知道缓存在实际场景中的工作原理。

  1. 发生请求的时候,优先读取缓存,如果命中缓存则返回结果集。
  2. 如果缓存没有命中,则回归数据库查询。
  3. 将数据库查询得到的结果集再次同步到缓存中,并且返回对应的结果集。

这是大家比较熟悉的缓存使用方式,可以有效减轻数据库压力,提升接口访问性能。但是在这样的一个架构中,会有一个问题,就是一份数据同时保存在数据库和缓存中,如果数据发生变化,需要同时更新缓存和数据库,由于更新是有先后顺序的,并且它不像数据库中多表事务操作满足ACID特性,所以这样就会出现数据一致性的问题。

DB和缓存不一致方案与实战DEMO

关于缓存和DB不一致,其实无非就是以下四种解决方案:

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

先更新缓存,再更新数据库(不建议)

更新缓存后更新数据库

这种方案其实是不提倡的,这种方案存在的问题是缓存更新成功,但是更新数据库出现异常了。这样会导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。

先更新数据库,再更新缓存

先更新数据库,再更新缓存,如果缓存更新失败了,其实也会导致数据库和缓存中的数据不一致,这样客户端请求过来的可能一直就是错误的数据。

更新数据库之后更新缓存

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

这种场景在并发量比较小的时候可能问题不大,理想情况是应用访问缓存的时候,发现缓存中的数据是空的,就会从数据库中加载并且保存到缓存中,这样数据是一致的,但是在高并发的极端情况下,由于删除缓存和更新数据库非原子行为,所以这期间就会有其他的线程对其访问。于是,如下图。

高并发删除缓存,后更新数据库

解释一下上图,老猫罗列了两个线程,分别是线程1和线程2。

  1. 线程1会先删除缓存中的数据,但是尚未去更新数据库。
  2. 此时线程2看到缓存中的数据是空的,就会去数据库中查询该值,并且重新更新到缓存中。
  3. 但是此时线程1并没有更新成功,或者是事务还未提交(MySQL的事务隔离级别,会导致未提交的事务数据不会被另一个线程看到),由于线程2快于线程1,所以线程2去数据库查询得到旧值。
  4. 这种情况下最终发现缓存中还是为旧值,但是数据库中却是最新的。

由此可见,这种方案其实也并不是完美的,在高并发的情况下还是会有问题。那么下面的这种总归是完美的了吧,有小伙伴肯定会这么认为,让我们一起来分析一下。

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

先说结论,其实这种方案也并不是完美的。咱们通过下图来说一个比较极端的场景。

更新数据库,后删除缓存

上图中,我们执行的时间顺序是按照数字由小到大进行。在高并发场景下,我们说一下比较极端的场景。

上面有线程1和线程2两个线程。其中线程1是读线程,当然它也会负责将读取的结果集同步到缓存中,线程2是写线程,主要负责更新和重新同步缓存。

  1. 由于缓存失效,所以线程1开始直接查询的就是DB。
  2. 此时写线程2开始了,由于它的速度较快,所以直接完成了DB的更新和缓存的删除更新。
  3. 当线程2完成之后,线程1又重新更新了缓存,那此时缓存中被更新之后的当然是旧值了。

如此,咱们又发现了问题,又出现了数据库和缓存不一致的情况。

那么显然上面的这四种方案其实都多多少少会存在问题,那么究竟如何去保持数据库和缓存的一致性呢?

保证强一致性

如果有人问,那我们能否保证缓存和DB的强一致性呢?回答当然是肯定的,那就是针对更新数据库和刷新缓存这两个动作加上锁。当DB和缓存数据完成同步之后再去释放,一旦其中任何一个组件更新失败,我们直接逆向回滚操作。我们可能还得做快照便于其历史缓存重写。那这种设计显然代价会很大。

其实在很大一部分情况下,要求缓存和DB数据强一致大部分都是伪需求。我们可能只要达到最终尽量保持缓存一致即可。有缓存要求的大部分业务其实也是能接受数据在短期内不一致的情况。所以我们就可以使用下面的这两种最终一致性的方案。

错误重试达到最终一致

如下示意图所示:

基于消息队列

上面的图中我们看到。当然上述老猫只是画了更新线程,其实读取线程也一样。

  1. 更新线程优先更新数据,然后再去更新缓存。
  2. 此时我们发现缓存更新失败了,咱们就将其重新放到消息队列中。
  3. 单独写一个消费者接收更新失败记录,然后进行重试更新操作。

说到消息队列重试,还有一种方式是基于异步任务重试,咱们可以把更新缓存失败的这个数据保存到数据库,然后通过另外的一个定时任务进而扫描待执行任务,然后去做相关的缓存更新动作。

当然上面我们提到的这两种方案,其实比较依赖我们的业务代码做出相对应的调整。我们当然也可以借助Canal组件来监控MySQL中的binlog的日志。通过数据库的 binlog 来异步淘汰 key,利用工具(canal)将 binlog日志采集发送到 MQ 中,然后通过 ACK 机制确认处理删除缓存。先更新DB,然后再去更新缓存,这种方式,被称为 Cache Aside Pattern,属于缓存更新的经典设计模式之一。

基于canal

上述我们总结了缓存使用的一些方案,我们发现其实没有一种方案是完美的,最完美的方案其实还是得去结合具体的业务场景去使用。方案已经同步了,那么如何去撸数据库以及缓存同步的代码呢?接下来,和大家分享的当然是日常开发中比较好用的SpringCache缓存处理框架了。

SpringCache实战

SpringCache是一个框架,实现了基于注解缓存功能,只需要简单地加一个注解,就能实现缓存功能。
SpringCache提高了一层抽象,底层可以切换不同的cache实现,具体就是通过cacheManager接口来统一不同的缓存技术,cacheManager是spring提供的各种缓存技术抽象接口。

目前存在以下几种:

  • EhCacheCacheManager:将缓存的数据存储在内存中,以提高应用程序的性能。
  • GuavaCaceManager:使用Google的GuavaCache作为缓存技术。
  • RedisCacheManager:使用Redis作为缓存技术。

配置

我们日常开发中用到比较多的其实是redis作为缓存,所以咱们就可以用RedisCacheManager,做一下代码演示。咱们以springboot项目为例。

老猫这里拿看一下redisCacheManager来举例,项目开始的时候我们当忽然要在pom文件依赖的时候就肯定需要redis启用项。如下:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--使用注解完成缓存技术-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因为我们在application.yml中就需要配置redis相关的配置项:

spring:
  redis:
    host: localhost
    port: 6379
    database: 0 
    jedis:
      pool:
        max-active: 8 # 最大链接数据
        max-wait: 1ms # 连接池最大阻塞等待时间
        max-idle: 4 # 连接线中最大的空闲链接
        min-idle: 0 # 连接池中最小空闲链接
   cache:
    redis:
      time-to-live: 1800000 

常用注解

关于SpringCache常用的注解,整理如下:

SpringCache常用注解

针对上述的注解,咱们做一下demo用法,如下:

用法简单盘点

@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class);
    }
}

在service层我们注入所需要用到的cacheManager:

@Autowired
private CacheManager cacheManager;

/**
 * 公众号:程序员老猫
 * 我们可以通过代码的方式主动清除缓存,例如
 **/
public void clearCache(String productCode) {
  try {
      RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;

      Cache backProductCache = redisCacheManager.getCache("backProduct");
      if(backProductCache != null) {
          backProductCache.evict(productCode);
      }
  } catch (Exception e) {
      logger.error("redis 缓存清除失败", e);
  }
}

接下来我们看一下每一个注解的用法,以下关于缓存用法的注解,我们都可以将其加到dao层:

第一种@Cacheable

在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中。

@Cacheable 注解中的核心参数有以下几个:

  • value:缓存的名称,可以是一个字符串数组,表示该方法的结果可以被缓存到哪些缓存中。默认值为一个空数组,表示缓存到默认的缓存中。
  • key:缓存的 key,可以是一个 SpEL 表达式,表示缓存的 key 可以根据方法参数动态生成。默认值为一个空字符串,表示使用默认的 key 生成策略。
  • condition:缓存的条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被缓存。默认值为一个空字符串,表示不考虑任何条件,缓存所有结果。
  • unless:缓存的排除条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被排除在缓存之外。默认值为一个空字符串,表示不排除任何结果。

上述提及的SpEL是是Spring Framework中的一种表达式语言,此处不展开,不了解的小伙伴可以自己去查阅一下相关资料。

代码使用案例:

@Cacheable(value="picUrlPrefixDO",key="#id")
public PicUrlPrefixDO selectById(Long id) {
    PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
    return picUrlPrefixDO;
}

第二种@CachePut

表示将方法返回的值放入缓存中。
注解的参数列表和@Cacheable的参数列表一致,代表的意思也一样。
代码使用案例:

@CachePut(value = "userCache",key = "#users.id")
@GetMapping()
public User get(User user){
   User users= dishService.getById(user);
   return users;
}

第三种@CacheEvict

表示从缓存中删除数据。使用案例如下:

@CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
public Integer deleteByUrlPrefix(String urfPrefix) {
  return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
}

上述和大家分享了一下SpringCache的用法,对于上述提及的三个缓存注解中,老猫在日常开发过程中用的比较多的是@CacheEvict以及@Cacheable,如果对SpringCache实现原理感兴趣的小伙伴可以查阅一下相关的源码。

使用缓存的其他注意点

当我们使用缓存的时候,除了会遇到数据库和缓存不一致的情况之外,其实还有其他问题。严重的情况下可能还会出现缓存雪崩。关于缓存失效造成雪崩,大家可以看一下这里【
糟糕!缓存击穿,商详页进不去了
】。

另外如果加了缓存之后,应用程序启动或服务高峰期之前,大家一定要做好缓存预热从而避免上线后瞬时大流量造成系统不可用。关于缓存预热的解决方案,由于篇幅过长老猫在此不展开了。不过方案概要可以提供,具体如下:

  • 定时预热。采用定时任务将需要使用的数据预热到缓存中,以保证数据的热度。
  • 启动时加载预热。在应用程序启动时,将常用的数据提前加载到缓存中,例如实现InitializingBean 接口,并在 afterPropertiesSet 方法中执行缓存预热的逻辑。
  • 手动触发加载:在系统达到高峰期之前,手动触发加载常用数据到缓存中,以提高缓存命中率和系统性能。
  • 热点预热。将系统中的热点数据提前加载到缓存中,以减轻系统压力。5
  • 延迟异步预热。将需要预热的数据放入一个队列中,由后台异步任务来完成预热。
  • 增量预热。按需预热数据,而不是一次性预热所有数据。通过根据数据的访问模式和优先级逐步预热数据,以减少预热过程对系统的冲击。

如果小伙伴们还有其他的预热方式也欢迎大家留言。

总结

上述总结了关于缓存在日常使用的时候的一些方案以及坑点,当然这些也是面试官最喜欢提问的一些点。文中关于缓存的介绍老猫其实并没有说完,很多其实还是需要小伙伴们自己去抽时间研究研究。不得不说缓存是一门以空间换时间的艺术。要想使用好缓存,死记硬背策略肯定是行不通的。真实的业务场景往往要复杂的多,当然解决方案也不同,老猫上面提及的这些大家可以做一个参考,遇到实际问题还是需要大家具体问题具体分析。

风控规则引擎(一):Java 动态脚本

日常场景

  1. 共享单车会根据微信分或者芝麻分来判断是否交押金
  2. 汽车租赁公司也会根据微信分或者芝麻分来判断是否交押金
  3. 在一些外卖 APP 都会提供根据你的信用等级来发放贷款产品
  4. 金融 APP 中会根据很复杂规则来判断用户是否有借款资格,以及贷款金额。

在简单的场景中,我们可以通过直接编写一些代码来解决需求,比如:

// 判断是否需要支付押金
return 芝麻分 > 650

这种方式代码简单,如果规则简单且不经常变化可以通过这种方式,在业务改变的时候,重新编写代码即可。

在金融场景中,往往会根据不同的产品,不同的时间,对接的银行等等多个维度来配置规则,单纯的直接编写代码无法满足业务需求,而且编写代码的方式对于运营人员来说无论实时性、可视化都很欠缺。

在这种情况往往会引入可视化的规则引擎,允许运营人员可以通过可视化配置的方式来实现一套规则配置,具有实时生效、可视化的效果。减少开发和运营的双重负担。

这篇主要介绍一下如何实现一个可视化的表达式的定义和执行。

表达式的定义

在上面说到的使用场景中,可以了解中至少需要支持布尔表达式。比如

  1. 芝麻分 > 650
  2. 居住地 不在 国外
  3. 年龄在 18 到 60 之间
  4. 名下无其他逾期借款

...

在上面的例子中,可以将一个表达式分为 3 个部分

  1. 规则参数 (ruleParam)
  2. 对应的操作 (operator)
  3. 对应操作的阈值 (args)

则可以将上面的布尔表达式表示为

  1. 芝麻分 > 650
{
  "ruleParam": "芝麻分",
  "operator": "大于",
  "args": ["650"]
}
  1. 居住地 不在 国外
{
  "ruleParam": "居住地",
  "operator": "位于",
  "args": ["国内"]
}
  1. 年龄在 18 到 60 之间
{
  "ruleParam": "年龄",
  "operator": "区间",
  "args": ["18", "60"]
}
  1. 名下无其他逾期借款
{
  "ruleParam": "在途逾期数量",
  "operator": "等于",
  "args": ["0"]
}

表达式执行

上面的通过将表达式使用 json 格式定义出来,下面就是如何在运行中动态的解析这个 json 格式并执行。

有了 json 格式,可以通过以下方式来执行对应的表达式

  1. 因为表达式的结构已经定义好了,可以通过手写代码来判断所有的情况实现解释执行, 这种方案简单,但增加操作需要修改对应的解释的逻辑, 且性能低
/*
{
  "ruleParam": "在途逾期数量",
  "operator": "等于",
  "args": ["0"]
}
*/
switch(operator) {
  case "等于":
    // 等于操作
    break;
  case "大于":
    // 等于操作
    break;
    ...
}
  1. 在第一次得到 json 字符串的时候,直接将其根据不同的情况生成对应的 java 代码,并动态编译成 Java Class,方便下一次执行,该方案依然需要处理各种情况,但因为在第一次编译成了 java 代码,性能和直接编写 java 代码一样

  2. 使用第三方库实现表达式的执行

使用第三方库实现动态表达式的执行

在 Java 中有很多表达式引擎,常见的有

  1. jexl3
  2. mvel
  3. spring-expression
  4. QLExpress
  5. groovy
  6. aviator
  7. ognl
  8. fel
  9. jsel

这里简单介绍一下 jexl3 和 aviator 的使用

jexl3 在 apache commons-jexl3 中,该表达式引擎比较符合人的书写习惯,其会判断操作的类型,并将参数转换成对应的类型比如 3 > 4 和 "3" > 4 这两个的执行结果是一样的

aviator 是一个高性能的 Java 的表达式类型,其要求确定参数的类型,比如上面的 "3" > 4 在 aviator 是无法执行的。

jexl3 更适合让运营手动编写的情况,能容忍一些错误情况;aviator 适合开发来使用,使用确定的类型参数来提供性能

jexl3 使用

加入依赖

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-jexl3</artifactId>
  <version>3.2.1</version>
</dependency>
// 创建一个带有缓存 jexl 表达式引擎,
JexlEngine JEXL = new JexlBuilder().cache(1000).strict(true).create();

// 根据表达式字符串来创建一个关于年龄的规则
JexlExpression ageExpression = JEXL.createExpression("age > 18 && age < 60");

// 获取需要的参数,java 代码太长了,简写一下
Map<String, Object> parameters parameters = {"age": 30}

// 执行一下
JexlContext jexlContext = new MapContext(parameters);

boolean result = (boolean) executeExpression.evaluate(jexlContext);

以上就会 jexl3 的简单使用

aviator

引入依赖

<dependency>
  <groupId>com.googlecode.aviator</groupId>
  <artifactId>aviator</artifactId>
  <version>5.3.1</version>
</dependency>
Expression ageExpression = executeExpression = AviatorEvaluator.compile("age > 18 && age < 60");

// 获取需要的参数,java 代码太长了,简写一下
Map<String, Object> parameters parameters = {"age": 30}

boolean result = (boolean) ageExpression.execute(parameters);

注意 aviator 是强类型的,需要注意传入 age 的类型,如果 age 是字符串类型需要进行类型转换

性能测试

不同表达式引擎的性能测试

Benchmark                                         Mode  Cnt           Score           Error  Units
Empty              thrpt    3  1265642062.921 ± 142133136.281  ops/s
Java               thrpt    3    22225354.763 ±  12062844.831  ops/s
JavaClass          thrpt    3    21878714.150 ±   2544279.558  ops/s
JavaDynamicClass   thrpt    3    18911730.698 ±  30559558.758  ops/s
GroovyClass        thrpt    3    10036761.622 ±    184778.709  ops/s
Aviator            thrpt    3     2871064.474 ±   1292098.445  ops/s
Mvel               thrpt    3     2400852.254 ±     12868.642  ops/s
JSEL               thrpt    3     1570590.250 ±     24787.535  ops/s
Jexl               thrpt    3     1121486.972 ±     76890.380  ops/s
OGNL               thrpt    3      776457.762 ±    110618.929  ops/s
QLExpress          thrpt    3      385962.847 ±      3031.776  ops/s
SpEL               thrpt    3      245545.439 ±     11896.161  ops/s
Fel                thrpt    3       21520.546 ±     16429.340  ops/s
GroovyScript       thrpt    3          91.827 ±       106.860  ops/s

总结

这是写的规则引擎的第一篇,主要讲一下

  1. 如何讲一个布尔表达式转换为 json 格式的定义方便做可视化存储和后端校验
  2. 如何去执行一个 json 格式的表达式定义

在这里也提供了一些不同的表达式引擎和性能测试,如果感兴趣的可以去尝试一下。

下一篇主要讲一下在引擎里面规则参数、操作符是如何设计的,也讲一下可视化圆形的设计

一、摘要

在前几篇线程系列文章中,我们介绍了线程池的相关技术,任务执行类只需要实现
Runnable
接口,然后交给线程池,就可以轻松的实现异步执行多个任务的目标,提升程序的执行效率,比如如下异步执行任务下载。

// 创建一个线程池
ExecutorService executor = Executors.newFixedThreadPool(2);
// 提交任务
executor.submit(new Runnable() {

    @Override
    public void run() {
        // 执行下载某文件任务
        System.out.println("执行下载某文件任务");
    }
});

而实际上
Runnable
接口并不能满足所有的需求,比如有些场景下,我们想要获取任务执行的返回结果,
Runnable
接口因为无返回值,只能想办法通过额外的方式来写入和读取,操作起来十分不便。

因此,从 JDK 1.5 开始,Java 标准库提供了一个
Callable
接口,与
Runnable
接口相比,它的方法上多了一个返回值;同时
Callable
是一个泛型接口,可以返回指定类型的结果,比如如下的实现类!

public class Task implements Callable<String> {

    @Override
    public String call() throws Exception {
        // 执行下载某文件任务
        System.out.println("执行下载某文件任务");
        return "xxx";
    }
}

问题来了,如何获取异步执行的结果呢?

在 JDK 1.5 中,Java 标准库还提供了一个
Future
接口,它可以用来获取异步执行的结果。

下面我们一起来了解一下这个
Future
接口!

二、Future

Future
接口,表示一个可能还没有完成异步任务的结果,它提供了检查任务是否已完成、以及等待任务完成并获取结果等方法。

如果看过
ExecutorService.submit()
方法,会发现它的返回参数都是
Future
类型,
Future
类型的实例可以用来获取异步任务执行的结果。

下面我们先来看一个简单的示例,以便于更好的理解!

public class Task implements Callable<String> {
    
    @Override
    public String call() throws Exception {
        // 执行下载某文件任务,并返回文件名称
        System.out.println("thread name:" +  Thread.currentThread().getName() + " 开始执行下载任务");
        return "xxx.png";
    }
}
public class FutureTest {

    public static void main(String[] args) throws Exception {
        // 创建一个线程池
        ExecutorService executor = Executors.newFixedThreadPool(1);
        // 初始化一个任务
        Callable<String> task = new Task();
        // 提交任务并获得Future的实例
        Future<String> future = executor.submit(task);
        // 从Future获取异步执行返回的结果(可能会阻塞等待结果)
        String result =future.get();
        System.out.println("任务执行结果:" +  result);

        // 任务执行完毕之后,关闭线程池(可选)
        executor.shutdown();
    }
}

输出结果如下:

thread name:pool-1-thread-1 开始执行下载任务
任务执行结果:xxx.png

从以上的示例可以清晰的看到,当需要获取异步线程的执行结果返回值时,通常需要搭配使用
Future

Callable
接口来实现,大体可以用如下步骤来概括:

  • 1.首先提交一个实现
    Callable
    接口的任务到线程池中
  • 2.然后获取一个
    Future
    类型的对象
  • 3.最后在主线程中调用
    Future
    对象的
    get()
    方法,如果异步任务执行完成,就可以直接获得结果;如果异步任务执行没有完成,
    get()
    方法会阻塞,直到任务执行完成后才能获取结果

分析源码你会发现,
Callable
接口主要用途是定义一个支持返回结果的方法;重点实现主要集中在
Future
接口上。

下面我们重点来看下
Future
接口方法!

2.1、Future 接口方法

方法 描述
get() 获取结果(会阻塞等待)
get(long timeout, TimeUnit unit) 在指定的时间内获取结果,如果超时,会抛异常并退出等待状态
cancel(boolean mayInterruptIfRunning) 尝试取消当前任务,当传入参数为
true
时,表示尝试中断任务的执行,
false
表示不中断,继续执行直到完成,如果取消成功,返回
true
;反之
false
isCancelled() 判断任务是否已取消
isDone() 判断任务是否已完成

2.2、Future 接口实现类

Future
本质其实是一个接口,并不是具体的实现类,真正负责工作的还是它的实现类来完成。

我们还是以上文的线程池
ExecutorService.submit()
方法为例,看看它用的是哪种实现类!

分析一下源码,会发现线程池用的实现类是
FutureTask
,关键核心源码如下:

protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
    return new FutureTask<T>(callable);
}

FutureTask
类是一个实现了
Future
接口所有功能的具体类,可直接使用它来实现获取异步任务执行的结果值。

FutureTask
的工作原理其实也并不复杂,它接受一个
Callable
或者
Runnable
对象作为参数,然后在线程池执行器中执行该任务,最后通过
get()
方法可以同步等待获取任务的执行结果。

真正起到关键作用的是,在
FutureTask
内部,封装了一个状态变量,用于记录任务的状态(等待、运行、完成、取消等),以及任务执行结果或异常信息,通过该状态变量,我们可以判断任务是否已完成、以及获取任务的执行结果等信息。

因为
FutureTask
也实现了
Runnable
接口,因此我们也可以将
FutureTask
作为任务,提交给线程池执行器。

具体示例如下:

public class FutureTest {

    public static void main(String[] args) throws Exception {
        // 1.创建一个线程池
        ExecutorService executor = Executors.newFixedThreadPool(1);
        // 2.初始化一个任务
        Callable<String> callable = new Task();
        // 3.创建FutureTask对象
        FutureTask<String> futureTask = new FutureTask<>(callable);
        // 4.提交任务给执行器执行
        executor.execute(futureTask);
        // 5.获取任务的执行结果
        String result = futureTask.get(3, TimeUnit.SECONDS);
        System.out.println("任务执行结果:" +  result);
        // 6.关闭线程池(可选)
        executor.shutdown();
    }
}

输出结果同上!

如果想尝试取消任务的执行,也可以通过如下方式来实现!

boolean isSuccess = futureTask.cancel(true);
System.out.println("任务是否取消成功:" +  isSuccess);

除此之外,如果仔细的分析
Future
接口的类关系,会发现它的实现类非常的多,
FutureTask
只是它的一个基础实现类而已,部分类关系图如下!

其它常用实现类简介:

  • CompletableFuture
    :支持传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法
  • ForkJoinTask
    :支持把一个大任务拆成多个小任务,然后并行执行,在多核 CPU 上可以显著提升程序的执行效率
  • ScheduledFuture
    :支持周期性定时的执行任务,其中
    ScheduledFutureTask
    是一个私有类,只能通过
    ScheduledThreadPoolExecutor
    初始化操作

关于
CompletableFuture

ForkJoinTask

ScheduledFuture
,我们会在后面的文章中,再次单独介绍具体的用法。

三、小结

本文主要围绕
Future
接口用法做了一次简单的知识总结,其中
FutureTask
类是
Future
接口中一个非常重要的实现类,通过它可以获取异步任务执行的返回值,通常用于异步计算带有返回值的任务。

限于篇幅的原因,本文没有对
FutureTask
做过深入的原理讲解,主要围绕具体用法进行介绍,有兴趣的朋友可以阅读这篇文章
《Java的Future机制详解》
,以便更清晰的了解它的实现原理。

如果有描述不对的地方,欢迎留言指出,共同进步!

四、参考

1.
https://www.liaoxuefeng.com/wiki/1252599548343744/1306581155184674

2.
https://www.cnblogs.com/xrq730/p/4872722.html

3.
https://juejin.cn/post/7231074060787908663

4.
https://zhuanlan.zhihu.com/p/54459770

本文分享自华为云社区《
Regex Mastery: 从基础到高级,解锁正则表达式的全方位应用
》,作者:柠檬味拥抱。

正则表达式是一种强大的文本匹配和处理工具,广泛应用于文本处理、数据抽取、表单验证等领域。本文将从正则表达式的基础知识出发,逐步深入,最终结合代码实战,带你全面了解正则表达式的奥妙。

正则表达式基础

1. 什么是正则表达式?

正则表达式是一种描述字符串匹配规则的文本模式,可以用来检索、替换、验证等操作。它由一系列字符和操作符组成,表示一种匹配规则。

2. 基础语法

  • 字符匹配: 使用普通字符直接匹配文本,如
    abc
    匹配字符串中的 “abc”。
  • 元字符: 具有特殊意义的字符,如
    .
    表示匹配任意字符,
    ^
    表示匹配字符串的开头。
  • 字符类: 用方括号表示,如
    [aeiou]
    匹配任何一个元音字母。

3. 量词和边界

  • 量词: 用于指定匹配的次数,如
    *
    表示零次或多次,
    +
    表示一次或多次,
    ?
    表示零次或一次。
  • 边界: 使用
    ^
    表示字符串的开头,
    $
    表示字符串的结尾。

正则表达式代码实战

1. 使用正则表达式验证邮箱

import re

def validate_email(email):
pattern
= r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' ifre.match(pattern, email):
print(f
"{email} 是一个有效的邮箱地址")else:
print(f
"{email} 不是一个有效的邮箱地址")

# 测试
validate_email(
"user@example.com")
validate_email(
"invalid_email@.com")

2. 提取HTML中的链接

import re

html_content
= '<a href="https://www.example.com">Visit our website</a>'# 提取链接
links
= re.findall(r'href="([^"]*)"', html_content)

# 输出链接
for link inlinks:
print(f
"链接: {link}")

3. 替换文本中的日期格式

import re

text
= "今天是2024年2月27日,明天是2024-02-28。"# 替换日期格式
formatted_text
= re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\2/\3/\1', text)

print(f
"替换前: {text}")
print(f
"替换后: {formatted_text}")

4. 使用正则表达式判断密码强度

import re

def check_password_strength(password):
# 至少8个字符,包含至少一个大写字母、一个小写字母、一个数字和一个特殊字符
pattern
= r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$' ifre.match(pattern, password):
print(
"密码强度符合要求")else:
print(
"密码强度不够")

# 测试
check_password_strength(
"StrongPass123!")
check_password_strength(
"weakpassword")

5. 从文本中提取手机号码

import re

text
= "请联系客户:+86 13812345678 或者发送邮件至info@example.com"# 提取手机号码
phone_numbers
= re.findall(r'(?:(?:\+|00)86)?1[345789]\d{9}', text)

# 输出手机号码
for number inphone_numbers:
print(f
"手机号码: {number}")

6. 拆分逗号分隔的字符串

import re

csv_data
= "apple,orange,banana,grape"# 使用正则表达式拆分
items
= re.split(r',', csv_data)

# 输出拆分结果
print(
"拆分后的结果:", items)

7. 使用正则表达式验证URL

import re

def validate_url(url):
# 简单的URL验证,以http或https开头,后面跟着域名
pattern
= r'^(https?://)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^/#?]+)*(?:\?[^#]*)?(?:#(.*))?$' ifre.match(pattern, url):
print(f
"{url} 是一个有效的URL")else:
print(f
"{url} 不是一个有效的URL")

# 测试
validate_url(
"https://www.example.com")
validate_url(
"ftp://invalid-url.com")

8. 从HTML中提取文本内容

import re

html_content
= '<p>This is a <b>sample</b> HTML content.</p>'# 提取纯文本内容
text_content
= re.sub(r'<[^>]+>', '', html_content)

print(f
"HTML内容: {html_content}")
print(f
"提取后的文本内容: {text_content}")

9. 匹配重复字符

import re

text
= "这个单词是重复重复的,但是它们都是有意义的重复。"# 匹配连续重复的单词
repeated_words
= re.findall(r'\b(\w+)\s+\1\b', text)

# 输出匹配结果
print(
"连续重复的单词:", repeated_words)

10. 使用正则表达式提取日志信息

import re

log_data
= """2024-02-27 10:15: Error inmodule A2024-02-27 11:30: Warning inmodule B2024-02-27 12:45: Info: Application started"""# 提取日志信息
log_entries
= re.findall(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}): (Error|Warning|Info): (.+)', log_data)

# 输出提取结果
for entry inlog_entries:
timestamp, log_level, message
=entry
print(f
"{timestamp} - [{log_level}] {message}")

11. 使用正则表达式进行文本替换

import re

text
= "这是一个示例文本,包含一些需要替换的词汇,例如apple和orange。"# 替换文本中的水果名称
replaced_text
= re.sub(r'\b(apple|orange)\b', 'fruit', text)

print(f
"替换前: {text}")
print(f
"替换后: {replaced_text}")

12. 匹配多行文本

import re

multiline_text
= """This is line 1.
Another line here.
And a third line.
"""# 匹配包含"line"的行
matching_lines
= re.findall(r'.*line.*', multiline_text, re.MULTILINE)

# 输出匹配结果
for line inmatching_lines:
print(f
"匹配的行: {line}")

我们深入了解了正则表达式在处理日志、进行文本替换等实际场景中的应用。正则表达式的强大之处在于它的灵活性和通用性,可以适应各种文本处理需求。希望这些例子能够进一步拓展你对正则表达式的认识,并激发你在实际项目中更广泛地应用它的兴趣。如果有其他关于正则表达式的问题或者需要更多的实例,欢迎继续提问。

13. 正则表达式分组与捕获

在正则表达式中,使用括号可以创建分组,通过分组可以实现更精细的匹配和捕获。

import re

text
= "2024-02-27 08:30: Process A started, Process B started"# 匹配并捕获时间和进程名称
pattern
= r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}): (Process [A-Z]) started'matches=re.findall(pattern, text)

# 输出捕获结果
for match inmatches:
timestamp, process_name
=match
print(f
"时间: {timestamp}, 进程: {process_name}")

14. 非贪婪匹配

正则表达式默认是贪婪匹配,即尽可能匹配更多的字符。在量词后加上
?
可以实现非贪婪匹配。

import re

text
= "<b>bold text</b> and <i>italic text</i>"# 贪婪匹配
greedy_match
= re.search(r'<.*>', text).group()

# 非贪婪匹配
non_greedy_match
= re.search(r'<.*?>', text).group()

print(f
"贪婪匹配: {greedy_match}")
print(f
"非贪婪匹配: {non_greedy_match}")

15. 使用正则表达式验证IP地址

import re

def validate_ip_address(ip):
    pattern = r'^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)){3}$'
    
    if re.match(pattern, ip):
        print(f"{ip} 是一个有效的IP地址")
    else:
        print(f"{ip} 不是一个有效的IP地址")

# 测试
validate_ip_address("192.168.0.1")
validate_ip_address("256.0.0.1")

通过这些高级的正则表达式实例,我们进一步提升了对正则表达式功能的理解和应用。分组、非贪婪匹配等特性使得正则表达式更加灵活和强大,能够满足更复杂的匹配需求。希望这些例子有助于你更深入地理解和运用正则表达式。如果你还有其他关于正则表达式的问题,欢迎提出。

16. 零宽断言

零宽断言是一种特殊的正则表达式结构,用于在匹配位置上添加条件,但并不消耗字符。常见的零宽断言包括正向先行断言(
(?=...)
)、负向先行断言(
(?!...)
)、正向后行断言(
(?<=...)
)、负向后行断言(
(?<!...)
)等。

import re

text = "apple orange banana"

# 匹配单词前面是"apple"的空格
positive_lookahead = re.search(r'apple(?=\s)', text).group()

# 匹配单词前面不是"apple"的空格
negative_lookahead = re.search(r'(?<!apple)\s', text).group()

print(f"正向先行断言: {positive_lookahead}")
print(f"负向先行断言: {negative_lookahead}")

17. 使用正则表达式验证日期格式

import re

def validate_date(date):
    pattern = r'^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'
    
    if re.match(pattern, date):
        print(f"{date} 是一个有效的日期格式")
    else:
        print(f"{date} 不是一个有效的日期格式")

# 测试
validate_date("2024-02-27")
validate_date("2024/02/27")

18. 正则表达式的替换与回调函数

利用正则表达式进行替换时,可以结合回调函数,实现更复杂的替换逻辑。

import re

def multiply(match):
number
= int(match.group(1))return str(number * 2)

text
= "Numbers: 2, 5, 8, 12"# 使用回调函数替换数字
result
= re.sub(r'\b(\d+)\b', multiply, text)

print(f
"替换前: {text}")
print(f
"替换后: {result}")

通过这些高级的正则表达式实例,我们深入了解了零宽断言、日期格式验证以及替换与回调函数的应用。这些功能强大的特性使得正则表达式成为处理复杂文本匹配和替换任务的得力工具。希望这些例子有助于你更灵活地运用正则表达式解决实际问题。如果你还有其他关于正则表达式的疑问或需求,欢迎继续提问。

总结

通过本文的内容,我们全面深入地探讨了正则表达式的基础知识和高级应用。在基础部分,我们学习了正则表达式的基本概念、语法元素以及常见的匹配规则。随后,我们通过多个实例展示了正则表达式在不同场景的代码应用,包括邮箱验证、HTML链接提取、文本替换、密码强度验证等。

在高级部分,我们介绍了正则表达式的进阶特性,包括分组与捕获、非贪婪匹配、零宽断言等。这些特性使得正则表达式更加灵活,能够应对更复杂的文本匹配需求。同时,我们还探讨了如何使用正则表达式验证日期格式、IP地址,以及利用回调函数实现更复杂的替换逻辑。

通过这篇文章,读者不仅学到了正则表达式的基础知识,还深入了解了它在实际编程中的广泛应用。正则表达式作为文本处理的得力工具,能够提高开发效率,简化代码逻辑。希望本文的内容能够帮助读者更自信、更灵活地运用正则表达式解决实际问题,同时也鼓励进一步深入学习和探索这一强大工具。如有任何问题或疑问,欢迎随时提问,共同学习进步。

点击关注,第一时间了解华为云新鲜技术~

作者:vivo 互联网服务器团队 - Qiu Xiangcun

本文将探讨如何通过使用Intel QuickAssist Technology(QAT)来优化VUA的HTTPS转发性能。我们将介绍如何使用QAT通过硬件加速来提高HTTPS转发的性能,并探讨QAT在不同应用场景中的表现。最后,我们将讨论如何根据实际情况进行优化,以获得最佳转发性能。

VLB 全称 vivo load balance。

vivo负载均衡作为vivo互联网业务的IDC流量入口,承接了很多重要业务的公网流量。本文针对 VLB 的七层负载VUA HTTPS 性能优化进行探索,以获取最佳转发性能。

一、vivo VLB整体架构

图片

▲  图1 vivo VLB整体架构

VLB 整体架构的核心包括:基于DPDK的四层负载VGW,基于Apache APISIX和NGINX扩展功能的七层负载VUA,以及统一管控运维平台。

其主要特点为:

  • 高性能
    :具备千万级并发和百万级新建能力。

  • 高可用
    :通过 ECMP、健康检查等,提供由负载本身至业务服务器多层次的高可用。

  • 可拓展
    :支持四层/七层负载集群、业务服务器的横向弹性伸缩、灰度发布。

  • 四层负载能力
    :通过 BGP 向交换机宣告VIP;支持均衡算法如轮询、加权轮询、加权最小连接数、一致性哈希;FullNAT 转发模式方便部署等。

  • 七层负载能力
    :支持基于域名和 URL 的转发规则配置;支持均衡算法如轮询、加权轮询等。

  • SSL/TLS 能力
    :证书、私钥、握手策略的管理配置;支持 SNI 配置;支持基于多种加速卡的 SSL 卸载硬件加速等。

  • 流量防控
    :提供一定的 Syn-Flood 防护能力;提供网络流量控制手段如 QoS 流控、ACL 访问控制等。

  • 管控平台
    :支持多种维度的网络和业务指标配置、监控和告警。

本文针对 VLB 中七层负载VUA的 SSL/TLS 性能优化两种方法进行概述性介绍:

  • 基于硬件技术的QAT_HW

  • 基于指令集优化的QAT_SW

二、VUA七层负载均衡

2.1 VUA介绍

目前公司接入层最大的能力痛点,就是动态上游、动态路由、动态证书、流量灰度、黑白名单、动态调度、日志查询与追踪等。为了支持公司业务的持续发展,特别是业务的全面容器化,亟需建设一个统一接入平台,融合目前线上的NGINX集群及Ingress NGINX,用于承载公司web端、移动端、合作伙伴、内部系统、IOT设备流量,对齐行业的接入层能力,保障业务的顺利发展。

VUA定义:vivo Unified Access。

vivo 统一接入层,是基于APISIX-2.4的二次开发。

2.2 VUA架构

图片

▲ 图2 APISIX 架构(图片来源:
Github-apache/apisix

  • Apache APISIX
    :OpenResty 1.19.3.1 + Lua组成(组件本身是无状态的)。

  • Manager-api
    :由 Go 语言开发,用于配置的管理和变更。

  • APISIX-Ingress-Controller
    :基于K8S原生Controller机制开发完成,支持多副本Leader-Election热备机制。主要监听K8s api server,用于pod信息上报到Manager-api。

  • Etcd
    :用于保存路由、upstream等配置信息。

图片

▲ 图3 VUA 架构

图片

三、QAT加速技术

Intel QuickAssist 技术 OpenSSL引擎 (QAT_Engine) 支持硬件加速以及基于矢量化指令的优化软件。这一特性始于第三代Intel® Xeon®可扩展处理器,为用户提供了更多加速其工作负载的选项。

3.1 异步架构

VUA 基于 NGINX 原生的异步处理框架上拓展出针对异步硬件引擎的异步事件处理机制,整体交互流程如下图所示:

图片

  • ASYNC_start_job:NGINX 调用 ssl lib 库接口 SSL_do_handshake, 开启一个异步任务。
  • RSA/ECDH 加解密操作。

  • QAT 引擎将加密消息发送给驱动,创建异步事件监听 fd,将 fd 绑定到异步任务的上下文中。

  • qat_pause_job: 调用该接口保存异步任务执行的堆栈信息,任务暂时被挂起,等待硬件加解密操作完成。同时进程堆栈切换到 NGINX IO 调用主流程,ssl 返回 WANT_ASYNC,NGINX开始处理其他等待时间。

  • NGINX IO处理框架获取保存在异步任务上下文中的 asyncfd,并添加到 epoll 队列中启动监听。

  • 加速卡处理任务完成,QAT 引擎调用 qat_wake_job 接口唤醒任务(也就是将 async fd 标记为可读)。QAT 为 NGINX 提供了多种轮询方式去轮询加速卡响应队列,目前 VUA 采用的是启发式轮询的方式,具体参数可以在配置文件中定义。

  • NGINX 处理异步事件重新调用异步任务框架的 ASYNC_start_job 接口,这时候程序切换上下文,堆栈执行后跳回之前 pause job 的地方。

3.2 QAT组件架构概览

图片

  • Application

应用层主要包含两块内容:

(1)QAT 异步框架的 patch,该 patch 提供对异步模式的支持;

(2)QAT 引擎,engine 是 openssl 本身支持的一种机制,用以抽象各种加密算法的实现方式,intel 提供了 QAT 引擎的开源代码用以专门支持 QAT 加速。

  • SAL(service access layer)

服务接入层,给上层 Application 提供加速卡接入服务,目前 QAT 主要提供 crypto 和 compression 两种服务,每一种服务都相互独立,接入层封装了一系列实用的接口,包括创建实例,初始化消息队列、发送\接受请求等。

  • ADF(acceleration driver framework)

加速卡驱动框架,提供 SAL 需要的驱动支持,如上图,包括 intel_qat.ko、8950pci 驱动、usdm 内存管理驱动等。

3.3 QAT_HW和QAT_SW

QAT_HW基于QAT硬件加速卡,通过Openssl引擎使用qatengine.so库中链接的QAT驱动程序。

QAT_SW是基于QAT软件加速,通过Openssl引擎使用qatengine.so库中链接的crypto_mb和ipsec_mb库。基于intel AVX-512 整数乘加 (IFMA) 操作缓冲区库,当用户构建指令支持qat_sw时,通过批处理队列中维护的多个请求执行操作,并使用 OpenSSL 异步基础架构将批处理请求最多提交到8个 Crypto Multi-buffer API,后者使用AVX512 矢量指令并行处理它们。主要面向非对称 PKE 和 AES-GCM 的英特尔® QAT 软件加速,RSA支持密钥大小 2048、3072、4096,AES128-GCM、AES192-GCM 和 AES256-GCM。

如果平台同时支持 QAT_HW 和 QAT_SW,则默认是使用 QAT 硬件加速非对称算法和对称链式密码,使用 QAT 软件加速对称 GCM 密码。如果平台没有 QAT 硬件支持,那么它将使用 QAT_SW 加速来实现 qatengine 中支持的非对称算法。

下图说明了 QAT_Engine 的高级软件架构。NGINX 和 HAProxy 等应用程序是与 OpenSSL接口的常见应用程序。OpenSSL是一个用于 TLS/SSL 协议的工具包,从 1.1.0 版本开始,它开发了一个模块化系统来插入特定于设备的引擎。如上所述,QAT_Engine 中有两个独立的内部实体,通过它们可以执行加速。

图片

▲(图片来源:
Github-intel/QAT_Engine

四、优化方案性能提升对比

4.1 QAT_HW

本方案采用intel 8970型号加速卡进行测试,采用RSA证书进行HTTPS加解密。

(1)测试方法

执行机部署适配 QAT 引擎后的VUA,发包测试机进行压测灌包,在 CPU 负载达到 100%后比较得出VUA在进行 QAT 优化后的新建 QPS对比。

(2)测试场景

图片

(3)本地测试数据对比

使用QAT加速卡性能对比

图片

QAT卡优化方案,通过 VUA进行 HTTPS 打流业务实测,与采用OpenSSL 软件加解密场景做对比:

  • 使用QAT加速卡,同worker下,RSA 平均QPS提升1.27倍。

  • 随着进程数的增加,QAT加速卡达到瓶颈,趋于稳定,在56 worker下,最高可达4.4w qps。

此优化方案所带来的性能提升主要依赖于:

  • QAT采用用户态驱动的方式,实现了内核态到用户态内存零拷贝。

  • VUA采用异步模式调用 OpenSSL API,代替传统的同步模式调用。

  • QAT驱动程序支持多加速卡同时进行卸载加速。

4.2 QAT_SW

本方案采用icelake 6330型号(支持AVX512指令集)进行测试,采用RSA证书进行HTTPS加解密。

(1)测试方法

执行机部署适配指令集优化的VUA,发包测试机进行压测灌包,在 CPU 负载达到 100%后比较得出VUA在进行指令集优化后的新建 QPS对比。

(2)测试组网

图片

(3)本地测试数据对比

使用指令集优化性能对比

图片

指令集优化方案,通过 VUA进行 HTTPS 打流业务实测,与采用openssl软件加解密场景做对比:

  • 使用指令集优化,同worker下,RSA 平均QPS提升1倍。

  • 随着进程数的增加,指令集优化加速会成线性增长,在56 worker下,最高可达5.1w qps。

此优化方案所带来的性能提升主要依赖于:

  • 使用 AVX512 指令优化加解密

五、总结与思考

截止目前,vivo VLB在软硬件加速领域,已经同时支持exar加速卡与intel QAT 硬件和软件指令集加速等方案,成功实现核心网络组件自主可控,为构建高性能的网关架构赋能行业打下坚实的基础。

未来 vivo VLB 将持续构建接入层网关能力体系。

  • 安全与合规

    作为vivo统一流量接入入口,VLB 将持续构建安全可靠的通信安全基础设施,打造全方位的安全防护体系。

  • 多协议支持

    VLB 在高效接入能力建设方面将持续投入,通过引入 QUIC 协议,将提升用户在弱网场景下的用户体验。

    通过 MQTT 协议可以通过非常小的接入成本实现新设备和协议接入,积极拥抱万物互联。