欢迎光临
我们一直在努力

EasyScheduler的架构原理及实现思路

mumupudding阅读(4)


系统架构设计

在对调度系统架构说明之前,我们先来认识一下调度系统常用的名词

1.名词解释

DAG: 全称Directed Acyclic Graph,简称DAG。工作流中的Task任务以有向无环图的形式组装起来,从入度为零的节点进行拓扑遍历,直到无后继节点为止。举例如下图:DAG示例

流程定义:通过拖拽任务节点并建立任务节点的关联所形成的可视化DAG

流程实例:流程实例是流程定义的实例化,可以通过手动启动或定时调度生成

任务实例:任务实例是流程定义中任务节点的实例化,标识着具体的任务执行状态

任务类型: 目前支持有SHELL、SQL、SUB_PROCESS、PROCEDURE、MR、SPARK、PYTHON、DEPENDENT,同时计划支持动态插件扩展,注意:其中子 SUB_PROCESS 也是一个单独的流程定义,是可以单独启动执行的

调度方式: 系统支持基于cron表达式的定时调度和手动调度。命令类型支持:启动工作流、从当前节点开始执行、恢复被容错的工作流、恢复暂停流程、从失败节点开始执行、补数、调度、重跑、暂停、停止、恢复等待线程。其中 恢复被容错的工作流恢复等待线程 两种命令类型是由调度内部控制使用,外部无法调用

定时调度:系统采用 quartz 分布式调度器,并同时支持cron表达式可视化的生成

依赖:系统不单单支持 DAG 简单的前驱和后继节点之间的依赖,同时还提供任务依赖节点,支持流程间的自定义任务依赖

优先级 :支持流程实例和任务实例的优先级,如果流程实例和任务实例的优先级不设置,则默认是先进先出

邮件告警:支持 SQL任务 查询结果邮件发送,流程实例运行结果邮件告警及容错告警通知

失败策略:对于并行运行的任务,如果有任务失败,提供两种失败策略处理方式,继续是指不管并行运行任务的状态,直到流程失败结束。结束是指一旦发现失败任务,则同时Kill掉正在运行的并行任务,流程失败结束

补数:补历史数据,支持区间并行和串行两种补数方式

2.系统架构

2.1 系统架构图

系统架构图

2.2 架构说明

  • MasterServer

    MasterServer采用分布式无中心设计理念,MasterServer主要负责 DAG 任务切分、任务提交监控,并同时监听其它MasterServer和WorkerServer的健康状态。MasterServer服务启动时向Zookeeper注册临时节点,通过监听Zookeeper临时节点变化来进行容错处理。

    该服务内主要包含:
    • Distributed Quartz分布式调度组件,主要负责定时任务的启停操作,当quartz调起任务后,Master内部会有线程池具体负责处理任务的后续操作

    • MasterSchedulerThread是一个扫描线程,定时扫描数据库中的 command 表,根据不同的命令类型进行不同的业务操作

    • MasterExecThread主要是负责DAG任务切分、任务提交监控、各种不同命令类型的逻辑处理

    • MasterTaskExecThread主要负责任务的持久化

  • WorkerServer

    WorkerServer也采用分布式无中心设计理念,WorkerServer主要负责任务的执行和提供日志服务。WorkerServer服务启动时向Zookeeper注册临时节点,并维持心跳。

    该服务包含:
    • FetchTaskThread主要负责不断从Task Queue中领取任务,并根据不同任务类型调用TaskScheduleThread对应执行器。

    • LoggerServer是一个RPC服务,提供日志分片查看、刷新和下载等功能

  • ZooKeeper

    ZooKeeper服务,系统中的MasterServer和WorkerServer节点都通过ZooKeeper来进行集群管理和容错。另外系统还基于ZooKeeper进行事件监听和分布式锁。我们也曾经基于Redis实现过队列,不过我们希望EasyScheduler依赖到的组件尽量地少,所以最后还是去掉了Redis实现。

  • Task Queue

    提供任务队列的操作,目前队列也是基于Zookeeper来实现。由于队列中存的信息较少,不必担心队列里数据过多的情况,实际上我们压测过百万级数据存队列,对系统稳定性和性能没影响。

  • Alert

    提供告警相关接口,接口主要包括告警两种类型的告警数据的存储、查询和通知功能。其中通知功能又有邮件通知和**SNMP(暂未实现)**两种。

  • API

    API接口层,主要负责处理前端UI层的请求。该服务统一提供RESTful api向外部提供请求服务。接口包括工作流的创建、定义、查询、修改、发布、下线、手工启动、停止、暂停、恢复、从该节点开始执行等等。

  • UI

    系统的前端页面,提供系统的各种可视化操作界面,详见**系统使用手册**部分。

2.3 架构设计思想

一、去中心化vs中心化
中心化思想

中心化的设计理念比较简单,分布式集群中的节点按照角色分工,大体上分为两种角色:master-slave角色

  • Master的角色主要负责任务分发并监督Slave的健康状态,可以动态的将任务均衡到Slave上,以致Slave节点不至于“忙死”或”闲死”的状态。
  • Worker的角色主要负责任务的执行工作并维护和Master的心跳,以便Master可以分配任务给Slave。

中心化思想设计存在的问题:

  • 一旦Master出现了问题,则群龙无首,整个集群就会崩溃。为了解决这个问题,大多数Master/Slave架构模式都采用了主备Master的设计方案,可以是热备或者冷备,也可以是自动切换或手动切换,而且越来越多的新系统都开始具备自动选举切换Master的能力,以提升系统的可用性。
  • 另外一个问题是如果Scheduler在Master上,虽然可以支持一个DAG中不同的任务运行在不同的机器上,但是会产生Master的过负载。如果Scheduler在Slave上,则一个DAG中所有的任务都只能在某一台机器上进行作业提交,则并行任务比较多的时候,Slave的压力可能会比较大。
去中心化

去中心化

  • 在去中心化设计里,通常没有Master/Slave的概念,所有的角色都是一样的,地位是平等的,全球互联网就是一个典型的去中心化的分布式系统,联网的任意节点设备down机,都只会影响很小范围的功能。

  • 去中心化设计的核心设计在于整个分布式系统中不存在一个区别于其他节点的”管理者”,因此不存在单点故障问题。但由于不存在” 管理者”节点所以每个节点都需要跟其他节点通信才得到必须要的机器信息,而分布式系统通信的不可靠行,则大大增加了上述功能的实现难度。

  • 实际上,真正去中心化的分布式系统并不多见。反而动态中心化分布式系统正在不断涌出。在这种架构下,集群中的管理者是被动态选择出来的,而不是预置的,并且集群在发生故障的时候,集群的节点会自发的举行"会议"来选举新的"管理者"去主持工作。最典型的案例就是ZooKeeper及Go语言实现的Etcd。

  • EasyScheduler的去中心化是Master/Worker注册到Zookeeper中,实现Master集群和Worker集群无中心,并使用Zookeeper分布式锁来选举其中的一台Master或Worker为“管理者”来执行任务。

二、分布式锁实践

EasyScheduler使用ZooKeeper分布式锁来实现同一时刻只有一台Master执行Scheduler,或者只有一台Worker执行任务的提交。

  1. 获取分布式锁的核心流程算法如下

获取分布式锁流程

  1. EasyScheduler中Scheduler线程分布式锁实现流程图:获取分布式锁流程
三、线程不足循环等待问题
  • 如果一个DAG中没有子流程,则如果Command中的数据条数大于线程池设置的阈值,则直接流程等待或失败。
  • 如果一个大的DAG中嵌套了很多子流程,如下图则会产生“死等”状态:

线程不足循环等待问题上图中MainFlowThread等待SubFlowThread1结束,SubFlowThread1等待SubFlowThread2结束, SubFlowThread2等待SubFlowThread3结束,而SubFlowThread3等待线程池有新线程,则整个DAG流程不能结束,从而其中的线程也不能释放。这样就形成的子父流程循环等待的状态。此时除非启动新的Master来增加线程来打破这样的”僵局”,否则调度集群将不能再使用。

对于启动新Master来打破僵局,似乎有点差强人意,于是我们提出了以下三种方案来降低这种风险:

  1. 计算所有Master的线程总和,然后对每一个DAG需要计算其需要的线程数,也就是在DAG流程执行之前做预计算。因为是多Master线程池,所以总线程数不太可能实时获取。
  2. 对单Master线程池进行判断,如果线程池已经满了,则让线程直接失败。
  3. 增加一种资源不足的Command类型,如果线程池不足,则将主流程挂起。这样线程池就有了新的线程,可以让资源不足挂起的流程重新唤醒执行。

注意:Master Scheduler线程在获取Command的时候是FIFO的方式执行的。

于是我们选择了第三种方式来解决线程不足的问题。

四、容错设计

容错分为服务宕机容错和任务重试,服务宕机容错又分为Master容错和Worker容错两种情况

1. 宕机容错

服务容错设计依赖于ZooKeeper的Watcher机制,实现原理如图:

EasyScheduler容错设计

其中Master监控其他Master和Worker的目录,如果监听到remove事件,则会根据具体的业务逻辑进行流程实例容错或者任务实例容错。

  • Master容错流程图:

Master容错流程图ZooKeeper Master容错完成之后则重新由EasyScheduler中Scheduler线程调度,遍历 DAG 找到”正在运行”和“提交成功”的任务,对”正在运行”的任务监控其任务实例的状态,对”提交成功”的任务需要判断Task Queue中是否已经存在,如果存在则同样监控任务实例的状态,如果不存在则重新提交任务实例。

  • Worker容错流程图:

Worker容错流程图

Master Scheduler线程一旦发现任务实例为” 需要容错”状态,则接管任务并进行重新提交。

注意:由于” 网络抖动”可能会使得节点短时间内失去和ZooKeeper的心跳,从而发生节点的remove事件。对于这种情况,我们使用最简单的方式,那就是节点一旦和ZooKeeper发生超时连接,则直接将Master或Worker服务停掉。

2.任务失败重试

这里首先要区分任务失败重试、流程失败恢复、流程失败重跑的概念:

  • 任务失败重试是任务级别的,是调度系统自动进行的,比如一个Shell任务设置重试次数为3次,那么在Shell任务运行失败后会自己再最多尝试运行3次
  • 流程失败恢复是流程级别的,是手动进行的,恢复是从只能从失败的节点开始执行从当前节点开始执行
  • 流程失败重跑也是流程级别的,是手动进行的,重跑是从开始节点进行

接下来说正题,我们将工作流中的任务节点分了两种类型。

  • 一种是业务节点,这种节点都对应一个实际的脚本或者处理语句,比如Shell节点,MR节点、Spark节点、依赖节点等。

  • 还有一种是逻辑节点,这种节点不做实际的脚本或语句处理,只是整个流程流转的逻辑处理,比如子流程节等。

每一个业务节点都可以配置失败重试的次数,当该任务节点失败,会自动重试,直到成功或者超过配置的重试次数。逻辑节点不支持失败重试。但是逻辑节点里的任务支持重试。

如果工作流中有任务失败达到最大重试次数,工作流就会失败停止,失败的工作流可以手动进行重跑操作或者流程恢复操作

五、任务优先级设计

在早期调度设计中,如果没有优先级设计,采用公平调度设计的话,会遇到先行提交的任务可能会和后继提交的任务同时完成的情况,而不能做到设置流程或者任务的优先级,因此我们对此进行了重新设计,目前我们设计如下:

  • 按照不同流程实例优先级优先于同一个流程实例优先级优先于同一流程内任务优先级优先于同一流程内任务提交顺序依次从高到低进行任务处理。
    • 具体实现是根据任务实例的json解析优先级,然后把流程实例优先级_流程实例id_任务优先级_任务id信息保存在ZooKeeper任务队列中,当从任务队列获取的时候,通过字符串比较即可得出最需要优先执行的任务

      • 其中流程定义的优先级是考虑到有些流程需要先于其他流程进行处理,这个可以在流程启动或者定时启动时配置,共有5级,依次为HIGHEST、HIGH、MEDIUM、LOW、LOWEST。如下图

流程优先级配置

    - 任务的优先级也分为5级,依次为HIGHEST、HIGH、MEDIUM、LOW、LOWEST。如下图

任务优先级配置

六、Logback和gRPC实现日志访问
  • 由于Web(UI)和Worker不一定在同一台机器上,所以查看日志不能像查询本地文件那样。有两种方案:

  • 将日志放到ES搜索引擎上

  • 通过gRPC通信获取远程日志信息

  • 介于考虑到尽可能的EasyScheduler的轻量级性,所以选择了gRPC实现远程访问日志信息。

grpc远程访问

  • 我们使用自定义Logback的FileAppender和Filter功能,实现每个任务实例生成一个日志文件。
  • FileAppender主要实现如下:
/** * task log appender */public class TaskLogAppender extends FileAppender<ILoggingEvent {    ...   @Override   protected void append(ILoggingEvent event) {       if (currentlyActiveFile == null){           currentlyActiveFile = getFile();       }       String activeFile = currentlyActiveFile;       // thread name: taskThreadName-processDefineId_processInstanceId_taskInstanceId       String threadName = event.getThreadName();       String[] threadNameArr = threadName.split("-");       // logId = processDefineId_processInstanceId_taskInstanceId       String logId = threadNameArr[1];       ...       super.subAppend(event);   }}

以/流程定义id/流程实例id/任务实例id.log的形式生成日志

  • 过滤匹配以TaskLogInfo开始的线程名称:

  • TaskLogFilter实现如下:

/***  task log filter*/public class TaskLogFilter extends Filter<ILoggingEvent {   @Override   public FilterReply decide(ILoggingEvent event) {       if (event.getThreadName().startsWith("TaskLogInfo-")){           return FilterReply.ACCEPT;       }       return FilterReply.DENY;   }}

总结

本文从调度出发,初步介绍了大数据分布式工作流调度系统–EasyScheduler的架构原理及实现思路。

使用lombok编写优雅的Bean对象

mumupudding阅读(6)

使用java编写代码,十之八九都是在写java类,从而构建java对象。lombok之前也说了不少,但使用了这么多年,感觉还是有很多技巧可以使用的。

毫无疑问,使用lombok,编写的java代码很优雅,而使用起来和普通的java编码方式创建的类毫无二致。

不过,这样就满足了吗?实际上lombok很多注解,让这个java类在使用的时候,也可以更优雅。

本文就从ORM实体类、Builder模式工具类、Wither工具类以及Accessors工具类几个层面对比一下。

首先说明,不同的方式本质上没有优劣之分,不过在不同的应用场景就会变得很奇妙了。

ORM实体类

当一个java Bean类作为ORM实体类,或者xml、json的映射类时,需要这个类有这几个特征:

  • 拥有无参构造器
  • 拥有setter方法,用以反序列化;
  • 拥有getter方法,用以序列化。

那么最简单的情况就是:

@Datapublic class UserBean{  private Integer id;  private String userName;}
  • 复习一下,Data 注解相当于装配了 @Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode

那么,作为实体类、或者序列化的Bean类,足够用了。

Builder

构造器模式,在很多工具类中频繁的使用。

package com.pollyduan.builder;import lombok.Builder;@Builderpublic class UserBean {   private Integer id;   private String userName;}

它做了什么事?

  • 它创建了一个private 的全参构造器。也就意味着 无参构造器没有; 同时也意味着这个类不可以直接构造对象。
  • 它为每一个属性创建了一个同名的方法用于赋值,代替了setter,而该方法的返回值为对象本身。

使用一下:

UserBean u=UserBean.builder() .id(1001) .userName("polly") .build();System.out.println(u);

还不错,然并卵,由于这个Bean并没有getter方法,里边的数据没办法直接使用。光说没用,继续执行你会发现输出是这个东西:com.pollyduan.builder.UserBean@20322d26,连看都看不出是什么东东。因此,Builder提供了一个种可能性,实际使用还需要更多的东西。

那么,我们为了测试方便需要添加 @ToString() 注解,就会输出 UserBean(id=1001, userName=polly)

换一个思路,你可能想,我不添加ToString注解,我把他转成json输出:

UserBean u=UserBean.builder() .id(1001) .userName("polly") .build();ObjectMapper mapper=new ObjectMapper();System.out.println(mapper.writeValueAsString(u));

很不幸,你会收到下面的异常:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class com.pollyduan.builder.UserBean and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

看到 no properties discovered 了吧,没错,工具类无法找到属性,因为没有 getter,那么我们加上 @Getter 就可以了。

package com.pollyduan.builder;import lombok.Builder;import lombok.Getter;@Builder@Getterpublic class UserBean {   private Integer id;   private String userName;}

序列化为json可以了,那么从一个json反序列化为对象呢?

@Builder@Getter@Setterpublic class UserBean {   private Integer id;   private String userName;}

还是不行,如无意外,会遇到 com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance ofcom.pollyduan.builder.UserBean(no Creators, like default construct, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

前面已经交代了,光增加 @Setter 还不够,他还需要一个无参构造器。那么,下面可以吗?

package com.pollyduan.builder;import lombok.Builder;import lombok.Data;@Builder@Datapublic class UserBean {   private Integer id;   private String userName;}

同样不行,因为虽然 Data使用的时候可以直接使用无参构造器,但由于 Builder 引入了全参构造器,那么按照java原生的规则,无参构造器就没有了。那么就再加一个无参构造器

@Builder@Data@NoArgsConstructor

很不幸,Builder又报错了,它找不到全参构造器了。好吧,最终的效果如下:

@Builder@Data@AllArgsConstructor(access = AccessLevel.PRIVATE)@NoArgsConstructor
  • 注意全参构造器的访问级别,不要破坏Builder的规则。

更进一步,看如下示例:

package com.pollyduan.builder;import java.util.List;import lombok.AccessLevel;import lombok.AllArgsConstructor;import lombok.Builder;import lombok.Data;import lombok.NoArgsConstructor;@Builder@Data@AllArgsConstructor(access = AccessLevel.PRIVATE)@NoArgsConstructorpublic class UserBean {   private Integer id;   private String userName;   private List<String> addresses;}

思考一下,这个List 我们也需要在外面new 一个 ArrayList,然后build进去,使用起来并不舒服。lombok提供了另一个注解配合使用,那就是 @Singular,如下:

package com.pollyduan.builder;import java.util.List;import lombok.AccessLevel;import lombok.AllArgsConstructor;import lombok.Builder;import lombok.Data;import lombok.NoArgsConstructor;@Builder@Data@AllArgsConstructor(access = AccessLevel.PRIVATE)@NoArgsConstructorpublic class UserBean {   private Integer id;   private String userName;   @Singular   private List<String> favorites;}

那么就可以这样操作这个列表了。

UserBean u = UserBean.builder() .id(1001) .userName("polly") .favorite("music") .favorite("movie") .build();

是不是很方便?同时还提供了一个 clearXXX方法,清空集合。

还有一个小坑,如果我们增加一个example属性,然后给它一个默认值:

@Builder@Data@AllArgsConstructor(access = AccessLevel.PRIVATE)@NoArgsConstructorpublic class UserBean {   private Integer id;   private String userName;   private String example="123456";}

测试一下看:

UserBean u = UserBean.builder() .id(1001) .userName("polly") .build();System.out.println(u.toString());

输出结果:UserBean(id=1001, userName=polly, example=null)

咦,不对呀?缺省值呢??这要从Builder的原理来解释,他实际上是分别设置了一套属性列表的值,然后使用全参构造器创建对象。那么,默认值在Bean上,不在Builder上,那么Builder没赋值,它的值就是null,最后把所有属性都复制给UserBean,从而null覆盖了默认值。

如何让Builder实体来有默认值呢?只需要给该字段增加 @Default 注解级可。

package com.pollyduan.builder;import lombok.AccessLevel;import lombok.AllArgsConstructor;import lombok.Builder;import lombok.Builder.Default;import lombok.Data;import lombok.NoArgsConstructor;@Builder@Data@AllArgsConstructor(access = AccessLevel.PRIVATE)@NoArgsConstructorpublic class UserBean {   private Integer id;   private String userName;   @Default   private String example="123456";}

重新执行测试用例,输出:UserBean(id=1001, userName=polly, example=123456),OK,没毛病了。

Wither

用wither方式构建对象,这在Objective-C 中比较多见。

适用的场景是,使用几个必要的参数构建对象,其他参数,动态的拼装。举个例子,我们构建一个ApiClient,它的用户名和密码是必须的,他的ApiService的地址有一个默认值,然后我们可以自己定制这个地址。

package com.pollyduan.wither;import lombok.AllArgsConstructor;import lombok.experimental.Wither;@Wither@AllArgsConstructor //WITHER NEED IT.public class ApiClient { private String appId; private String appKey; private String endpoint="http://api.pollyduan.com/myservice";}

如何使用呢?

@Testpublic void test1() { ApiClient client1=new ApiClient(null, null,null); System.out.println(client1); Object client2 = client1.withAppId("10001")  .withAppKey("abcdefg")  .withEndpoint("http://127.0.0.1/"); System.out.println(client2);}

默认的使用null去初始化一个对象还是很奇怪的。和 Builder一样,Wither也是提供了可能性,实际使用还需要调整一下。

我们可以设置一个必选参数的构造器,如下:

package com.pollyduan.wither;import lombok.AllArgsConstructor;import lombok.NonNull;import lombok.RequiredArgsConstructor;import lombok.experimental.Wither;@RequiredArgsConstructor@Wither@AllArgsConstructorpublic class ApiClient { @NonNull private String appId; @NonNull private String appKey; private String endpoint="http://api.pollyduan.com/myservice";}

这样使用时就可以这样:

 @Test public void test1() {  ApiClient client1=new ApiClient("10001", "abcdefgh");  System.out.println(client1);    Object client2 = client1.withEndpoint("http://127.0.0.1/");  System.out.println(client2); }

是不是优雅了很多?而且实际上使用时也使用链式语法:

ApiClient client1=new ApiClient("10001", "abcdefgh") withEndpoint("http://127.0.0.1/");

另外还有一个小细节,前面的示例输出如下:

com.pollyduan.wither.ApiClient@782830ecom.pollyduan.wither.ApiClient@470e2030

这个日志表明,with() 返回的对象并不是原来的对象,而是一个新对象,这很重要。

Accessors

访问器模式,是给一个普通的Bean增加一个便捷的访问器,包括读和写。

它有两种工作模式,fluent和chain,举例说明:

package com.pollyduan.accessors;import lombok.Data;import lombok.experimental.Accessors;@Accessors(fluent = true)@Datapublic class UserBean { private Integer id; private String userName; private String password; }

使用代码:

UserBean u=new UserBean() .id(10001) .userName("polly") .password("123456");u.userName("Tom");System.out.println(u.userName());

这和 Builder 类似,但更小巧,而且不影响属性的读写,只不过使用属性同名字符串代替了getter和setter。

另一个模式是 chain:

UserBean u=new UserBean() .setId(10001) .setUserName("polly") .setPassword("123456");u.setUserName("Tom");System.out.println(u.getUserName());

可以看得出来,这fluent的区别就在于使用getter和setter。

从B站的代码泄露事件中,我们能学到些什么?

mumupudding阅读(4)

先声明一下,本文不聊ISSUE中的七七八八,也不聊代码是否写的好,更不聊是不是跟蔡徐坤有关之类的吃瓜内容。仅站在技术人的角度,从这次的代码泄露事件,聊聊在代码的安全管理上,通常都需要做哪些事来预防此类事件的发生。同时,大家在阅读本文的时候,也可以深入思考下,自己团队是否也存在类似的问题,经过这次的事件,是否有必要针对性的做一些优化。

最小权限

“最小权限”原则是我们在学习Linux用户管理时候经常被提到,并且被反复强调的内容。该原则是指每个程序和系统用户都应该具有完成任务所必需的最小权限集合。赋予每一个合法动作最小的权限,就是为了保护数据以及功能避免受到错误或者恶意行为的破坏。

在代码的安全管理上,“最小权限”原则同样适用。但是,从此次的代码泄露内容中可以看到,这一点做的并不好,一起来看看源自泄露代码的README介绍:

从说明中,可以知道这是一个后端项目的大仓库,每个业务线都通过文件夹的方式管理自己的业务模块。那也就是说,每个业务模块的人都是可以看到这个大仓库的。继续看README最后的负责人信息:

可以看到这个大仓库中包含了非常多的业务模块与相关负责人信息。

由于Git的权限管理都是对整个仓库的,无法精细化到具体的文件夹。换言之,对于这个大仓库的访问,其实是有非常多的人员可以拿到全量代码的,而其中有大部分代码可能并不是自己业务线的内容。可见,这个仓库的内容,对于代码自身的保护上,并没有做到最小权限控制。所以,当出现代码泄漏的时候、对于泄露范围就很难控制。可能一个小环节的过失,就会导致非常大的泄漏灾难。

配置分离

配置与代码的分离,自云原生应用的流行开始,就一直被反复的强调。将配置内容隔离开之后,赋予代码的将仅仅是逻辑,对于各种与环境相关的敏感信息都应该被剥离出去,这就使得代码中将不再含有各种环境信息和敏感信息。同时,这么做也可以轻松的实现多环境的不同配置,这种实现方式与我们传统通过构建工具来实现的多环境是完全不同的。只有在严格分离了配置之后,才真正的可以实现:一次构建,多处运行的目标。基于构建工具实现的多环境部署,实质上多次构建,多处运行的结果,每个环境运行的介质只是上都不是同一个程序。

为什么要强调这一点呢?同样的,我们看看从网络上流出的一段代码片段:

如果这段代码是真实存在的话,那么配置管理和安全意识上确实就做得非常差了。

所以,对于配置中心的建设,大论大小团队,从安全角度上来说,都是非常重要的。何况在目前有大量开源配置中心的大背景之下,做到配置分离,是一件性价比非常高的事。如果做到这一点,那么即使代码有流出,对于重要密钥、数据库账户密码等等敏感信息也不会被泄露。

外部监控

任何与安全相关的内容,都少不了监控。事前的所有策略,最终都有可能被一一击破,留给我们最后的一道防线就是在事件发生之后马上得将其堵住,以防止问题的扩大,而造成更大的损失。

在笔者知道这件事的时候,距离代码上传已经有6小时之久了。各类技术讨论群中几乎也都是相关信息的八卦。几乎每一次刷新页面,都是几百Star的增长。虽然处理的速度不能说快,但是没过多久,与之相关的仓库都开始无法访问。至于,是不是有做代码泄露扫描的监控,我们不得而知。因为,在扫描告警之后,对于代码的扩散控制,作为B站来说,本身是被动的,还是需要Github等仓库运营方来完成。所以,这中间到底哪一步慢了,我们无法考证。

不过这些都不重要,这里主要强调一点,除了管理上的防护之外,必须留一手外部监控,以快速的发现泄漏并采取措施。这块可能大部分开发人员不太了解,这边我就稍微提一下。做一下这个是不是很费劲?

对于很多中小团队来说,可能本身就没有什么人力去做这件事,那么是不是就没办法了呢?实际上,很多安全服务商,甚至一些大型互联网公司都有提供这样的产品服务,比如携程的Github Scan:

如果说,你连买这类产品的钱都不想出,那么顺手推荐一个开源项目可以参考一下:Github leaked patrol

最近,欢迎留言交流,说说大家所在团队对于代码的安全性都是如何做的?用了什么商业服务?或是用了什么开源软件?欢迎一起分享学习与进步!

最全的JAVA知识汇总(附讲解和思维导图)

mumupudding阅读(3)

jvm 一行代码是怎么运行的

首先,java代码会被编译成字节码,字节码就是java虚拟机定义的一种编码格式,需要java虚拟机才能够解析,java虚拟机需要将字节码转换成机器码才能在cpu上执行。我们可以用硬件实现虚拟机,这样虽然可以提高效率但是就没有了一次编译到处运行的特性了,所以一般在各个平台上用软件来实现,目前的虚拟机还提供了一套运行环境来进行垃圾回收,数组越界检查,权限校验等。虚拟机一般将一行字节码解释成机器码然后执行,称为解释执行,也可以将一个方法内的所有字节码解释成机器码之后在执行,前者执行效率低,后者会导致启动时间慢,一般根据二八法则,将百分之20的热点代码进行即时编译。JIT编译的机器码存放在一个叫codecache的地方,这块内存属于堆外内存,如果这块内存不够了,那么JIT编译器将不再进行即时编译,可能导致程序运行变慢。

jvm如何加载一个类

第一步:加载,双亲委派:启动类加载器(jre/lib),系统扩展类加载器(ext/lib),应用类加载器(classpath),前者为c++编写,所以系统加载器的parent为空,后面两个类加载器都是通过启动类加载器加载完成后才能使用。加载的过程就是查找字节流,可以通过网络,也可以自己在代码生成,也可以来源一个jar包。另外,同一个类,被不同的类加载器加载,那么他们将不是同一个类,java中通过类加载器和类的名称来界定唯一,所以我们可以在一个应用成存在多个同名的类的不同实现。

第二步:链接:(验证,准备,解析) 验证主要是校验字节码是否符合约束条件,一般在字节码注入的时候关注的比较多。准备:给静态字段分配内存,但是不会初始化,解析主要是为了将符号引用转换为实际引用,可能会触发方法中引用的类的加载。

第三步:初始化,如果赋值的静态变量是基础类型或者字符串并且是final的话,该字段将被标记为常量池字段,另外静态变量的赋值和静态代码块,将被放在一个叫cinit的方法内被执行,为了保证cinit方法只会被执行一次,这个方法会加锁,我们一般实现单例模式的时候为保证线程安全,会利用类的初始化上的锁。 初始化只有在特定条件下才会被触发,例如new 一个对象,反射被调用,静态方法被调用等。

java对象的内存布局

java中每一个非基本类型的对象,都会有一个对象头,对象头中有64位作为标记字段,存储对象的哈希码,gc信息,锁信息,另外64位存储class对象的引用指针,如果开启指针压缩的话,该指针只需要占用32位字节。

Java对象中的字段,会进行重排序,主要为了保证内存对齐,使其占用的空间正好是8的倍数,不足8的倍数会进行填充,所以想知道一个属性相对对象其始地址的偏移量需要通过unsafe里的fieldOffset方法,内存对齐也为了避免让一个属性存放在两个缓存行中,disruptor中为了保证一个缓存行只能被一个属性占用,也会用空对象进行填充,因为如果和其他对象公用一个缓存行,其他对象的失效会将整个缓存行失效,影响性能开销,jdk8中引入了contended注解来让一个属性独占一个缓存行,内部也是进行填充,用空间换取时间,如何计算一个对象占用多少内存,如果不精确的话就进行遍历然后加上对象头,这种情况没办法考虑重排序和填充,如果精确的话只能通过javaagent的instrument工具。

反射的原理

反射真的慢么?

首先class.forname和class.getmethod 第一个是一个native方法,第二个会遍历自己和父类中的方法,并返回方法的一个拷贝,所以这两个方法性能都不好,建议在应用层进行缓存。而反射的具体调用有两种方式,一种是调用本地native方法,一种是通过动态字节码生成一个类来调用,默认采用第一种,当被调用15次之后,采用第二种动态字节码方式,因为生成字节码也耗时,如果只调用几次没必要,而第一种方式由于需要在java和c++之间切换,native 方法本身性能消耗严重,所以对于热点代码频繁调用反射的话,性能并不会很差。

属性的反射,采用unsafe类中setvalue来实现,需要传入该属性相对于对象其始地址的偏移量,也就是直接操作内存。其实就是根据这个属性在内存中的起始地址和类型来读取一个字段的值,在LockSupport类中,park和unpark方法,设置谁将线程挂起的时候也有用到这种方式。

动态代理

java本身的动态代理也是通过字节码实现的

Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)

工具类中需要提供 类加载器,需要实现的接口,拦截器的实现,也就是需要在InvocationHandler中调用原方法并做增强处理。并且这个实现,一定会被放到新生成的动态代理类里。

生成动态代理类的步骤:先通过声明的接口生成一个byte数组,这个数组就是字节流,通过传入的类加载进行加载生成一个class对象,这个class 里面有个构造方法接收一个参数,这个参数就是InvocationHandler,通过这个构造方法的反射获取一个实例类,在这个class里面,接口的实现中会调用InvocationHandler,而这个class对象为了防止生成太多又没有被回收,所以是一个弱引用对象。

jvm的内存模型

并发问题的根源:可见性,原子性,乱序执行

java内存模型定义了一些规则来禁止cpu缓存和编译器优化,happen-before用来描述两个操作的内存的可见性,有以下6条

  • 1.程序的顺序执行,前一个语句对后一个语句可见 (当两个语句没有依赖的情况下还是可以乱序执行)
  • 2.volatile变量的写对另一个线程的读可见
  • 3.happen-before 具有传递性
  • 4.一个线程对锁的释放对另外一个线程的获取锁可见 (也就是一个线程在释放锁之前对共享变量的操作,另外一个线程获取锁后会看的到)
  • 5.线程a调用了线程b的start()方法,那么线程a在调用start方法之前的操作,对线程b内的run()方法可见
  • 6.线程a调用了线程b的join方法,那么线程b里的所有操作,将对线程a调用join之后的操作可见。

jvm的垃圾回收

两种实现:引用计数和可达性分析,引用计数会出现循环引用的问题,目前一般采用可达性分析。

为了保证程序运行线程和垃圾回收线程不会发生并发影响,jvm采用安全点机制来实现stop the world,也就是当垃圾收集线程发起stop the world请求后,工作线程开始进行安全点检测,只有当所有线程都进入安全点之后,垃圾收集线程才开始工作,在垃圾收集线程工作过程中,工作线程每执行一行代码都会进行安全点检测,如果这行代码安全就继续执行,如果这行代码不安全就将该线程挂起,这样可以保证垃圾收集线程运行过程中,工作线程也可以继续执行。

安全点:例如阻塞线程肯定是安全点,运行的jni线程如果不访问java对象也是安全的,如果线程正在编译生成机器码那他也是安全的,Java虚拟机在有垃圾回收线程执行期间,每执行一个字节码都会进行安全检测。

基础垃圾收集算法:清除算法会造成垃圾碎片,清除后整理压缩浪费cpu耗时,复制算法浪费内存。

基础假设:大部分的java对象只存活了一小段时间,只有少部分java对象存活很久。新建的对象放到新生代,当经过多次垃圾回收还存在的,就把它移动到老年代。针对不同的区域采用不同的算法。因为新生代的对象存活周期很短,经常需要垃圾回收,所以需要采用速度最快的算法,也就是复制,所以新生代会分成两块。一块eden区,两块大小相同的survivor区。

新的对象默认在eden区进行分配,由于堆空间是共享的,所以分配内存需要加锁同步,不然会出现两个对象指向同一块内存,为了避免频繁的加锁,一个线程可以申请一块连续内存,后续内存的分配就在这里进行,这个方案称为tlab。tlab里面维护两个指针,一个是当前空余内存起始位置,另外一个tail指向尾巴申请的内存结束位置,分配内存的时候只需要进行指针加法并判断是否大于tail,如果超过则需要重新申请tlab。

如果eden区满了则会进行一次minorGc ,将eden区的存活对象和from区的对象移动到to区,然后交换from和to的指针。

垃圾收集器的分类:针对的区域,老年代还是新生代,串行还是并行,采用的算法分类复制还是标记整理

g1 基于可控的停顿时间,增加吞吐量,取代cms g1将内存分为多个块,每个块都可能是 eden survivor old 三种之一 首先清除全是垃圾的快 这样可以快速释放内存。

如果发现JVM经常进行full gc 怎么排查?

不停的进行full gc表示可能老年代对象占有大小超过阈值,并且经过多次full gc还是没有降到阈值以下,所以猜测可能老年代里有大量的数据存活了很久,可能是出现了内存泄露,也可能是缓存了大量的数据一直没有释放,我们可以用jmap将gc日志dump下来,分析下哪些对象的实例个数很多,以及哪些对象占用空间最多,然后结合代码进行分析。

并发和锁

线程的状态机

线程池参数:核心线程数,最大线程数,线程工厂,线程空闲时间,任务队列,拒绝策略先创建核心线程,之后放入任务队列,任务队列满了创建线程直到最大线程数,在超过最大线程数就会拒绝,线程空闲后超过核心线程数的会释放,核心线程也可以通过配置来释放,针对那些一天只跑一个任务的情况。newCachedThreadPool线程池会导致创建大量的线程,因为用了同步队列。

synchronized

同步块会有一个monitorenter和多个monitorexist ,重量级锁是通过linux内核pthread里的互斥锁实现的,包含一个waitset和一个阻塞队列。自旋锁,会不停尝试获取锁,他会导致其他阻塞的线程没办法获取到锁,所以他是不公平锁,而轻量级锁和偏向锁,均是在当前对象的对象头里做标记,用cas方法设置该标记,主要用于多线程在不同时间点获取锁,以及单线程获取锁的情况,从而避免重量级锁的开销,锁的升级和降级也需要在安全点进行。

  • reentrantlock相对synchronized的优势:可以控制公平还是非公平,带超时,响应中断。
  • CyclicBarrier 多个线程相互等待,只有所有线程全部完成后才通知一起继续 (调用await 直到所有线程都调用await才一起恢复继续执行)
  • countdownlatch 一个线程等待,其他线程执行完后它才能继续。(调用await后被阻塞,直到其他地方调用countdown()将state减到1 这个地方的其他可以是其他多个线程也可以其他单个任务)
  • semaphore 同一个时刻只运行n个线程,限制同时工作的线程数目。
  • 阻塞队列一般用两个锁,以及对应的条件锁来实现,默认为INTEGER.MAX为容量,而同步队列没有容量,优先级队列内部用红黑树来实现。

如果要频繁读取和插入建议用concurrenthashmap 如果频繁修改建议用 concurrentskiplistmap,copyonwrite适合读多写少,写的时候进行拷贝,并加锁。读不加锁,可能读取到正在修改的旧值。concurrent系列实际上都是弱一致性,而其他的都是fail-fast,抛出ConcurrentModificationException,而弱一致性允许修改的时候还可以遍历。例如concurrent类的size方法可能不是百分百准确。

AQS 的设计,用一个state来表示状态,一个先进先出的队列,来维护正在等待的线程,提供了acquire和release来获取和释放锁,锁,条件,信号量,其他并发工具都是基于aqs实现。

字符串

字符串可以通过intern()方法缓存起来,放到永久代,一般一个字符串申明的时候会检查常量区是否存在,如果存在直接返回其地址,字符串是final的,他的hashcode算法采用31进制相加,字符串的拼接需要创建一个新的字符串,一般使用stringbuilder。String s1 = “abc”; String s2 = “abc”; String s1 = new String(“abc”); s1和s2可能是相等的,因为都指向常量池。

集合

  • vector 线程安全,arraylist 实现 randomaccess 通过数组实现支持随机访问,linkedlist 双向链表可以支持快速的插入和删除。
  • treeset 依赖于 treemap 采用红黑树实现,可以支持顺序访问,但是插入和删除复杂度为 log(n)
  • hashset 依赖于 hashmap 采用哈希算法实现,可以支持常数级别的访问,但是不能保证有序
  • linkedhashset 在hashset的节点上加了一个双向链表,支持按照访问和插入顺序进行访问
  • hashtable早版本实现,线程安全 不支持空键。
  • hashmap:根据key的hashcode的低位进行位运算,因为高位冲突概率较高,根据数组长度计算某个key对应数组位置,类似求余算法,在put的时候会进行初始化或者扩容,当元素个数超过 数组的长度乘以负载因子的时候进行扩容,当链表长度超过8会进行树化,数组的长度是2的多少次方,主要方便位运算,另一个好处是扩容的时候迁移数据只需要迁移一半。当要放 15个元素的时候,一般数组初始化的长度为 15/0.75= 20 然后对应的2的多少次方,那么数组初始化长度为 32.
  • ConcurrentHashMap 内部维护了一个segment数组,这个segment继承自reentrantlock,他本身是一个hashmap,segment数组的长度也就是并发度,一般为16. hashentry内部的value字段为volatile来保证可见性.size()方法需要获取所有的segment的锁,而jdk8的size()方法用一个数组存储每个segment对应的长度。

io

输入输出流的数据源有 文件流,字节数组流,对象流 ,管道。带缓存的输入流,需要执行flush,reader和writer是字符流,需要根据字节流封装。

bytebuffer里面有position,capcity,limit 可以通过flip重置换,一般先写入之后flip后在从头开始读。

文件拷贝 如果用一个输入流和一个输出流效率太低,可以用transfer方法,这种模式不用到用户空间,直接在内核进行拷贝。

一个线程一个连接针对阻塞模式来说效率很高,但是吞吐量起不来,因为没办法开那么多线程,而且线程切换也有开销,一般用多路复用,基于事件驱动,一个线程去扫描监听的连接中是否有就绪的事件,有的话交给工作线程进行读写。一般用这种方式实现C10K问题。

堆外内存(direct) 一般适合io频繁并且长期占用的内存,一般建议重复使用,只能通过Native Memory Tracking(NMT)来诊断,MappedByteBuffer可以通过FileChannel.map来创建,可以在读文件的时候少一次内核的拷贝,直接将磁盘的地址映射到用户空间,使用户感觉像操作本地内存一样,只有当发生缺页异常的时候才会触发去磁盘加载,一次只会加载要读取的数据页,例如rocketmq里一次映射1g的文件,并通过在每个数据页写1b的数据进行预热,将整个1G的文件都加载到内存。

设计模式

  • 创建对象:工厂 构建 单例
  • 结构型: 门面 装饰 适配器 代理
  • 行为型:责任链 观察者 模版
  • 封装(隐藏内部实现) 继承(代码复用) 多态(方法的重写和重载)
  • 设计原则:单一指责,开关原则,里氏替换,接口分离,依赖反转

一篇文章从了解到入门shell

mumupudding阅读(10)

1、shell介绍

shell 俗称叫做壳,计算机的壳层,和内核是相对的,用于和用户交互,接收用户指令,调用相应的程序。shell

因此,把shell分为2大类

1.1、图形界面shell(Graphical User Interface shell 即 GUI shell)

也就是用户使用GUI和计算机核交互的shell,比如Windows下使用最广泛的Windows Explorer(Windows资源管理器),Linux下的X Window,以及各种更强大的CDE、GNOME、KDE、 XFCE。

他们都是GUI Shell。

1.2、命令行式shell(Command Line Interface shell ,即CLI shell)

也就是通过命令行和计算机交互的shell。Windows NT 系统下有 cmd.exe(命令提示字符)和近年来微软大力推广的 Windows PowerShell。Linux下有bash / sh / ksh / csh/zsh等一般情况下,习惯把命令行shell(CLI shell)直接称做shell,以后,如果没有特别说明,shell就是指 CLI shell,后文也是主要讲Linux下的 CLI shell。

2、交互方式

根据交互方式的不一样,命令行式shell(CLI shell),又分为交互式shell和非交互式shell。

2.1、交互式shell

交互式模式就是shell等待你的输入,并且执行你提交的命令,然后马上给你反馈。这种也是我们大多数时候使用的。

2.2、非交互式shell

非交互式shell,就是把shell放在写在一个文件里面,执行的时候,不与用户交互,从前往后依次执行,执行到文件结尾时,shell也就终止了。

3、shell的种类

在Linux下 ,各种shell百花齐放,种类繁多,不同的shell,也有不同的优缺点。我们要查看当前系统下支持的shell,可以读取/etc/shells文件。

多种的shell

3.1、bash

Bourne Again Shell 用来替代Bourne shell,也是目前大多数Linux系统默认的shell。

3.2、sh

Bourne Shell 是一个比较老的shell,目前已经被/bin/bash所取代,在很多linux系统上,sh已经是一个指向bash的链接了。下面是CentOS release 6.5 的系统

sh_to_bash

3.3、csh/tcsh

C shell 使用的是“类C”语法,csh是具有C语言风格的一种shell,tcsh是增强版本的csh,目前csh已经很少使用了。

3.4、ksh

最早,bash交互体验很好,csh作为非交互式使用很爽,ksh就吸取了2者的优点。

3.5、zsh

zsh网上说的目前使用的人很少,但是感觉使用的人比较多。zsh本身是不兼容bash的,但是他可以使用仿真模式(emulation mode)来模拟bash等,基本可以实现兼容。在交互式的使用中,目前很多人都是zsh,因为zsh拥有很强大的提示和插件功能,炫酷吊炸天。推荐在终端的交互式使用中使用zsh,再安利一个插件Oh My Zsh其实我个人的理解是,在终端中使用shell,基本上只是调用各种命令,比如:curl cat ls等等,基本不会使用到zsh的编程,所以终端中使用zsh是可以的。但是在写shell脚本的时候,需要考虑兼容性,最主流的还是bash shell,所以,后文我们介绍的shell脚本也是bash shell的。

4、shell脚本

4.1、基础

#!/bin/bashecho "Hello World !"

#!:是一个特殊的标记,表明使用啥解释器来执行,比如这里使用了:/bin/bash 来执行这个脚本。#:只用一个#,就是注释echo:输出我们把上面的脚本保存成一个文件, 1.sh 后面的这个sh是shell脚本的扩展名。然后要怎嚒来执行呢?执行一个shell脚本有很多种方式:

  • sh 1.sh 这样可以直接执行这个1.sh
  • 也可以直接 ./1.sh ,但是这种要注意,才编辑好的文件这样执行可能会报错

denied

这个是因为没有这个脚本没有执行权限,运行 chmod a+x 1.sh 加上执行权限即可。这里顺带说一下,为啥直接运行1.sh不行呢?因为他默认是去PATH里面找程序,当前目录,一般都不在PATH里面。所以直接运行1.sh就回报找不到文件。

根据测试,#!/bin/bash 的标记,只是针对第二种方式 ./xxx.sh的方式有效。本文中代码,第一行均为这个标记,为了节约篇幅,已经省略.

test_run

执行并获取返回结果,有点类似JavaScript 的eval函数。

#!/bin/bashdt=`date` #反引号内的字符串会当作shell执行 ,并且返回结果。echo "dt=${dt}"

4.2、Shell 变量

shell的使用比较简单,就像这样,并且没有数据类型的概念,所有的变量都可以当成字符串来处理:

#!/bin/bashmyName="tom"youName="cat"

不需要申明,直接写就可以了,但是有几个点需要特别注意:

  • 等号两边不能有空格!!!特别要注意,非常容易写错
  • 命名只能使用英文字母,数字和下划线,首个字符不能以数字开头。
  • 中间不能有空格,可以使用下划线(_)。
  • 不能使用标点符号。
  • 不能使用bash里的关键字。

使用变量

ABC="tom"echo $ABC #使用变量前面加$美元符号echo "ABC=$ABC" #可以直接在字符串里面引用echo "ABC=${ABC}" #但是建议把变量名字用{}包起来

只读变量

ABC="tom"echo "ABC=${ABC}"readOnly ABC #设置只读ABC="CAT" #会报错,因为设置了只读,不能修改

readonly

删除变量

ABC="tom"echo "ABC=${ABC}"unset ABC #删除echo "ABC=$ABC"  echo "ABC=${ABC}" 

var

从这个例子当中,我们也发现,使用一个不存在的变量,shell不会报错,只是当作空来处理。

4.3、Shell 的字符串

使用字符串

NAME="tom"A=my #你甚至可以不用引号,但是字符串当中不能有空格,这种方式也不推荐B='my name is ${NAME}' #变量不会被解析C="my name is ${NAME}" #变量会解析echo $Aecho $Becho $C

执行结果

run_1

我们可以发现,这个字符串的单双号和PHP的处理非常类似,单引号不解析变量,双引号可以解析变量。但是都可以处理转义符号。

A='my\nname\nis\ntom'B="my\nname\nis\ntom"echo $Aecho $B

执行结果

run_2

拼接字符串其实shell拼接字符串,大概就是2种

  • 一是直接在双引号内应用变量,类似模版字符串
  • 二是直接把字符串写在一起,不需要类似Java链接字符串的“+” 和PHP链接字符串的“.”
NAME="TOM"# 使用双引号拼接echo "hello, "$NAME" !" #直接写在一起,没有字符串连接符echo "hello, ${NAME} !" #填充模版# 使用单引号拼接echo 'hello, '$NAME' !' #直接写在一起,没有字符串连接符echo 'hello, ${NAME} !' #上面已经提高过,单引号里面的变量是不会解析的

run_3

强大的字符串处理shell中简单的处理字符串,可以直接使用各种标记,只是比较难记忆,要用的时候,可以查一下。

ABC="my name is tom,his name is cat"echo "字符串长度=${#ABC}" # 取字符串长度echo "截取=${ABC:11}" # 截取字符串, 从11开始到结束echo "截取=${ABC:11:3}" # 截取字符串, 从11开始3个字符串echo "默认值=${XXX-default}" #如果XXX不存在,默认值是defaultecho "默认值=${XXX-$ABC}" #如果XXX不存在,默认值是变量ABCecho "从开头删除最短匹配=${ABC#my}" # 从开头删除 my 匹配的最短字符串echo "从开头删除最长匹配=${ABC##my*tom}" # 从开头删除 my 匹配的最长字符串echo "从结尾删除最短匹配=${ABC%cat}" # 从结尾删除 cat 匹配的最短字符串echo "从结尾删除最长匹配=${ABC%%,*t}" # 从结尾删除 ,*t 匹配的最长字符串echo "替换第一个=${ABC/is/are}" #替换第一个isecho "替换所有=${ABC//is/are}" #替换所有的is

运行结构

run_4

这里只是介绍了比较常用的一些字符串处理,实际shell支持的还有很多。

4.4、数组

Bash Shell 也是支持数组的,与绝大部分语言一样,数组下标从0开始。不过需要注意的是,它只支持一维数组。定义一个数组,用小括号阔气来,当中用“空格”分割,就像下面这样:

array=("item0" "item1" "item2")

也可以根据下标来定义元素

array[0]="new_item0"array[1]="new_item1"array[2]="new_item2"array[4]="new_item4" #数组下标可以是不连续的

读取数组元素,和变量类似

echo ${array[0]}echo "array[0]=${array[0]}"

获取数组所有的元素

echo "数组的元素为: ${array[*]}"echo "数组的元素为: ${array[@]}"

获取数组的长度

echo "数组的长度为: ${#array[*]}"echo "数组的长度为: ${#array[@]}"

4.5、输入输出

4.5.1、echo

在上文中,其实我们已经到多次,就是:echo “字符串” 来输出,一个很简单的例子

echo "Hello world!"

如果当中包含特殊符号,可以使用转义等:

echo "Hello \nworld!"echo "\"Hello\""echo '"Hello"' #当然,也可以这样,单引号不转义,上文提到过echo `date` #打印执行date的结果echo -n "123" #加-n  表示不在末尾输出换行echo "456"echo -e "\a处理特殊符号" #-e 处理特殊符号

-n 让echo输出结束以后,在默认不输出换行符-e 让echo处理特殊符号,比如:

符号 作用
\a 发出警告声
\b 删除前一个字符
\c 后不加上换行符号
\f 换行但光标仍旧停留在原来的位置
\n 换行且光标移至行首
\r 光标移至行首,但不换行
\t 插入tab

上面的特殊符号,写到mac的shell脚本里面要注意,执行的时候,要用bash执行才有效 ,sh无效。

当然,你也可以玩一点更有趣的,就是我们随时在终端中看到的五颜六色的文字:

echo -e "\033[31m 红色前景 \033[0m 缺省颜色" echo -e "\033[41m 红色背景 \033[0m 缺省颜色" 

color

其中\033[是一个特殊标记,表示终端转义开始,31m表示使用红色字体,你也可以使用其他颜色,[30-39]是前景颜色,[40-49]是背景颜色。\033[0m回复到缺省设置还可以有一些其他的动作

echo -e "\033[2J" #清除屏幕echo -e "\033[0q" #关闭所有的键盘指示灯echo -e "\033[1q" #设置"滚动锁定"指示灯(Scroll Lock)echo -e "\033[2q" #设置"数值锁定"指示灯(Num Lock)echo -e "\033[1m" #设置高亮度echo -e "\033[4m" #下划线echo -e "\033[7m" #反显echo -e "\033[y;xH" #设置光标位置 

其他更多的特殊码请自行查询。

4.5.2、read

有输出,必然有输入,read命令接收标准输入的输入。

read nameecho "my name is ${name}"

可以使用-p给一个输入提示

read -p "please input your name:" nameecho "my name is ${name}"

如果没有指定输入的变量,会把输入放在环境标量REPLY中

read -p "please input your name:"echo "my name is ${REPLY}"

计时输入,如果一段时间没有输入 ,就直接返回,使用-t 加时间

read -t 3 -p "please input your name in 3 senconds:" name

指定输入字符个数,使用-n ,后面的是输入字符个数

read -n 1 -p "Are you sure [Y/N]?" isYes

默读(输入不再监视器上显示),加一个-s参数。

read  -s  -p "Enter your password:" password

4.5.3、printf

echo已经比较强大,但是有的时候,我们需要用到字符串模版输出,printf就比较好用了,他类似C里面的printf程序。语法是:printf format-string [arguments…]比如我们要输出一个表格

printf "%-10s %-8s %-4s\n" 姓名 性别 体重kg  printf "%-10s %-8s %-4.2f\n" 郭靖 男 66.1234 printf "%-10s %-8s %-4.2f\n" 杨过 男 48.6543 printf "%-10s %-8s %-4.2f\n" 郭芙 女 47.9876 

运行结果

run_5

%s %c %d %f都是格式替代符%-10s 指一个宽度为10个字符(-表示左对齐,没有则表示右对齐),至少显示10字符宽度,如果不足则自动以空格填充,超过不限制。%-4.2f 指格式化为小数,其中.2指保留2位小数。

4.5.4、重定向

大多数 UNIX 系统命令从你的终端接受输入并将所产生的输出发送回到您的终端。一个命令通常从一个叫标准输入的地方读取输入,默认情况下,这恰好是你的终端。同样,一个命令通常将其输出写入到标准输出,默认情况下,这也是你的终端。

命令 作用
command > file 将输出重定向到 file。
command < file 将输入重定向到 file。
command >> file 将输出以追加的方式重定向到 file。
n > file 将文件描述符为 n 的文件重定向到 file。
n >> file 将文件描述符为 n 的文件以追加的方式重定向到 file。
n >& m 将输出文件 m 和 n 合并。
n <& m 将输入文件 m 和 n 合并。
<< tag 将开始标记 tag 和结束标记 tag 之间的内容作为输入。

需要注意的是文件描述符 0 通常是标准输入(STDIN),1 是标准输出(STDOUT),2 是标准错误输出(STDERR)。

输出到文件

echo "test">text.txt #直接输出echo "test">>text.txt #追加在text.txt后面

重定向输入

read a <<EOF"测试"EOFecho "a=$a"

来个比较过分的

cat  < 1.sh > text.txt

把1.sh文件的内容出入到cat,然后cat在输出到text.txt中,相当于,把1.sh的内容输出到text.txt中了

还有一种用法,把标准错误直接输出到标准输出,并且输出到文件file

command > file 2>&1

/dev/null 文件这个是一个特殊文件,他是一个黑洞,写入到它的内容都会被丢弃,如果我们不关心程序的输出,可以这样

command > /dev/null 2>&1

4.6、条件判断(if)

和其他语言一样,shell也有条件判断

单分支:

if conditionthen    command1     command2    ...fi

双分支:

if conditionthen    command1     command2    ...else    commandfi

多分支:

if condition1then    command1elif condition2 then     command2else    commandNfi

比如

if [ "2" == "2" ]; then # "2" 的2边都有空格,不能省略 ,写在一行,条件后面加一个分号    echo "2==2"else    echo "2!=2"fi

需要特别注意:[ “2” == “2” ] 其中的”==”两边都有空格,不能省略,否则结果不正确。判断普通文件是否存在

if [ -f "1.sh" ]; then # 判断一个普通文件是否存在    echo "1.sh 存在"fi

判断目录是否存在

if [ -d "1.sh" ]; then # 判断一个目录是否存在    echo "1.sh 存在"fi

判断字符串长度为0

a=""if [ -z $a ]; then    echo "a为空"fi

4.7、()、(())、[]、[[]]和{}

在shell中,有几个符号要非常注意,用的也比较多,不要搞混了,搞混了,逻辑运算很容易出错

4.7.1、单小括号()

  • 命令组括号中的命令将会新开一个子shell顺序执行,所以括号中的变量不能够被脚本余下的部分使用。括号中多个命令之间用分号隔开,最后一个命令可以没有分号,各命令和括号之间不必有空格。
    a="123"(echo "123";a="456";echo "a=$a")echo "a=$a

    命令组

  • 命令替换发现了$(cmd)结构,便将$(cmd)中的cmd执行一次,得到其标准输出,再将此输出放到原来命令。
  • 用于初始化数组如:array=(a b c d)

4.7.2、双小括号(())

  • 运算扩展,比如,你可以
    a=$((4+5)) echo "a=$a"
  • 做数值运算,重新定义变量
    a=5((a++))echo "a=$a"
  • 用于算术运算比较
    if ((1+1>1));then  echo "1+1>1"fi

4.7.3、单中括号[]

  • 用于字符串比较需要注意,用于字符串比较,运算符只能是 ==和!=,需要注意,运算符号2边必须有空格,不然结果不正确!!!比如:
    if [ "2" == "2" ]; then # "2" 的2边都有空格,不能省略  echo "2==2"else  echo "2!=2"fi
  • 用于整数比较需要注意,整数比较,只能用-eq,-gt这种形式,不能直接使用大于(>)小于(<)符号。只能用于整形。
    if [ 2 -eq 2 ]; then   echo "2==2"else  echo "2!=2"fi

    符号表

    符号 运算
    -eq 等于
    -ne 不等于
    -gt 大于
    -ge 大于等于
    -lt 小于
    -le 小于等于
  • 多个逻辑组合-a 表示and 与运算-o 表示or 或运算
    if [ "2" == "2" -a "1" == "1" ]; then #注意,在这里,不能是[ "2" == "2" ] -a [ "1" == "1" ] 会报错  echo "ok"fi

4.7.4、双中括号[[]]

[[是 bash 程序语言的关键字。并不是一个命令,[[ ]] 结构比[ ]结构更加通用。

  • 字符串匹配时甚至支持简单的正则表达式
    if [[ "123" == 12* ]]; then #右边是正则不需要引号  echo "ok"fi
  • 支持对数字的判断,是支持浮点型的,并且可以直接使用<、>、==、!=符号
    if [[  2.1 > 1.1 ]]; then  echo "ok"fi
  • 多个逻辑判断可以直接使用&&、||做逻辑运算,并且可以在多个[[]]之间进行运算
    if [[  1.1 > 1.1 ]] || [[ 1.1 == 1.1 ]]; then  echo "ok"fi

4.7.5、大括号{}

  • 统配扩展
    touch new_{1..5}.txt #创建new_1.txt new_2.txt new_3.txt new_4.txt new_5.txt  5个文件

4.8、循环

4.8.1、for循环

语法格式为:

for a in "item1" "item2" "item3"do    echo $adone

输出当前目录下 .sh结尾的文件

for a in `ls ./`do    if [[ $a == *.sh ]]  then        echo $a  fidone

4.8.2、for循环

语法

while conditiondo    commanddone

我们要输出1-10000

int=1;while(($int<=10000))do    echo $int    ((int++))done

4.8.3、until循环

语法

until conditiondo    commanddone

用法类似,这里不再赘述。循环中 continue命令与break作用和其他语言中类似。

4.9、case

case和其他语言switch类型,多分支,选择一个匹配。匹配发现取值符合某一模式后,其间所有命令开始执行直至 ;;,有点类型Java的break。如果无一匹配模式,使用星号 * 捕获该值,再执行后面的命令。

echo '输入 1 到 4 之间的数字:'echo '你输入的数字为:'read aNumcase $aNum in    1)  echo '你选择了 1'    ;;    2)  echo '你选择了 2'    ;;    3)  echo '你选择了 3'    ;;    4)  echo '你选择了 4'    ;;    *)  echo '你没有输入 1 到 4 之间的数字'    ;;esac

4.10、函数

shell也可以用户定义函数,然后在shell脚本中可以随便调用。注意:所有函数在使用前必须定义。这意味着必须将函数放在脚本开始部分,直至shell解释器首次发现它时,才可以使用。调用函数仅使用其函数名即可。语法格式如下:

[function] funname(){    cmd....    [return int]}

一个最简单的函数

Line(){  echo "--------分割线--------"}echo "123"Lineecho "456"

在Shell中,调用函数时可以向其传递参数。在函数体内部,通过 $n 的形式来获取参数的值,例如,$1表示第一个参数,$2表示第二个参数…调用的时候 ,函数名,参数直接用空格分割开。带参数的函数示例:

out(){    echo "1-->$1"    echo "2-->$2"}out 1 2 #调用的之后

还有一些其他的特殊符号需要注意

符号 作用
$# 传递到脚本的参数个数
$* 以一个单字符串显示所有向脚本传递的参数
$$ 脚本运行的当前进程ID号
$! 后台运行的最后一个进程的ID号
$@ 与$*相同,但是使用时加引号,并在引号中返回每个参数。
$? 显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。

所以我们可以写一个代码参数,返回值的函数

out(){    echo "全部参数$*"    for item in $*    do        echo "$item"    done    return $# #这类返回参数个数,返回值必须是整数}out this is perfectecho "函数返回值:$?"

4.11、shell传递参数

我们可以在执行 Shell 脚本时,向脚本传递参数,脚本内获取参数的格式为:$n。n 代表一个数字,1 为执行脚本的第一个参数,2 为执行脚本的第二个参数,以此类推……除了参数可以使用特殊符号,也可以使用上文中函数所使用的特殊符号,这里不再赘述

echo "执行的文件名:$0";echo "全部参数:$*"echo "参数个数:$#"echo "第一个参数为:$1";echo "第二个参数为:$2";echo "第三个参数为:$3";

params

5、其他实用技巧

shell脚本,他本身的功能并不强大,强大的是他可以调用其他程序,而在Linux下,系统自带的就有非常多的强大工具可以调用。

5.1、后台执行

后台执行一个脚本只需要在后面加上&符号即可,我们先用之前学习的,写一个脚本,1s输出一个数字

#!/bin/bashint=1while :do    echo $int    ((int++))    sleep 1s #睡眠一秒done

我们执行sh d.sh & 我们发现,的确会后台输出,但是会输出到当前控制台,我们可以用之前学的重定向,把输出重定向到文件

sh d.sh > out.log 2>&1 &

这样就把输出和错误重新定向到out.log文件了但是,我们发现,关闭终端以后,文件就不输出了。当我们端口连接远程主机的session或者关闭当前终端的时候, 会产生一个SIGHUP信号 ,导致程序退出,我们可以使用nuhup来忽略这个信号 ,达到真正的后台。

nuhup sh d.sh > out.log 2>&1 &

这样启动程序,就可以打到真正后台运行了。那么问题来了,我们验证程序在后台运行呢?要怎嚒结束后台程序呢?请继续看。

5.2、cat

在本文中,我们已经多次用到cat,他的作用就是读取文件输出到标准输出上,也就是我们的终端。语法是:

cat [option] file

我们也可以使用:cat -n file ,来输出行号。

5.2、tail

类似上面的例子,我们要验证程序是不是在后台,每一秒输出一个数字到文件,使用cat读取,需要不断的多次查看,一次cat只能输出一次。tail非常适合查看这种日志类文件,他的作用是读取文件末尾几行输出到标准输出上。tail out.log默认显示10行,可以使用参数-n指定行数tail -20 out.log显示文件末尾20行tail -f out.log持续监控文件out.log,如果有变化,他会试试的显示在我们的屏幕上面。

5.3、ps

ps,查询进程这个命令参数比较多,列举几个比较常用的

参数 作用
a 显示终端上的所有进程,包括其他用户的进程。
u 显示面向用户的格式信息。
x 显示没有控制终端的进程。

一般查询,使用 ps aux就可以了,查询出来比较多,可以筛选一下。这里我们使用 ps u 就可以查询出我们刚才开启的后台进程了。

ps

我们看到我们刚才启动的程序PID为7523,使用kill命令就可以杀死他了

5.4、kill

kill命令比较简单,就是根据PID结束一个程序,比如我们已经查询到,我们开的后台进行是7523,要结束他可以使用:kill 7523以上是常用用法,其实kill是给程序发送一个信号,上面的程序给会程序发送一个SIGTERM信号,程序收到这个信号,完成资源的释放,就退出了。但是也有程序不听话,收到信号就是不退出,这个时候,就要强制他退出,使用9号命令(SIGKILL),强制杀死他。简单的说kill PID 是告诉程序,你应该退出了,请自己退出。kill -9 PID ,是直接告诉程序,你被终结了,这个命令信号,不能被抓取或者忽略。

6、总结

  • shell使用的比较少,但是特别强大;
  • shell对语法比较敏感,并且应为解释器很多,每个解释器语法标准也可能不完全一致;
  • 使用到的编号、编码、参数特别多,并且都是简写,很多记不住。其实不用死记硬背,记住有这个功能就可以了,需要用到的时候再查询。

推荐

推荐一个库,通过API把消息推送到个人微信上,SDK接入:https://github.com/zjiecode/wxpusher-client

参考资料

http://c.biancheng.net/shell/https://baike.baidu.com/item/shell/99702?fr=aladdinhttps://blog.csdn.net/lixinze779/article/details/81012318https://segmentfault.com/a/1190000008080537https://blog.csdn.net/felix_f/article/details/12433171http://www.runoob.com/linux/linux-shell-printf.htmlhttp://www.runoob.com/linux/linux-shell-process-control.htmlhttps://www.jb51.net/article/123081.htmhttp://www.runoob.com/linux/linux-shell-io-redirections.htmlhttps://www.cnblogs.com/mfryf/p/3336804.htmlhttps://blog.csdn.net/vip_wangsai/article/details/72616587

文章来源:http://blog.zjiecode.com/2019/04/15/shell/

搭配 VS Code Remote 远程开发扩展在 WSL 下开发

mumupudding阅读(4)


❗ 注意:远程开发扩展需要在 Visual Studio Code Insiders 上使用。

Visual Studio Code Remote – WSL 扩展允许你直接借助 VS Code 令 「适用于 Linux 的 Windows 子系统」(WSL) 作为你的全职开发环境。你可以在基于 Linux 的环境中进行开发,使用 Linux 特有的的工具链和实用库,并在舒适的 Windows 中运行和调试基于 Linux 的应用程序。

该扩展直接在 WSL 中运行命令和其它扩展,因此你可以编辑位于 WSL 中的文件或挂载在其上的 Windows 文件系统(例如 /mnt/c),而无需担心遇到文件路径问题、二进制兼容性或其它跨操作系统的难题。

WSL Architecture

这使得 VS Code 能够提供有着在本地进行开发一样效率的开发体验——包括完整的IntelliSense(代码补全)、代码导航和调试——无论你的代码在何处托管,都是如此。

上手

安装

上手前你需要做的几件事情:

  1. 安装对应你偏好的 Linux 发行版的 「适用于 Linux 的 Windows 子系统」。VS Code 将使用你安装的默认发行版,因此可以根据需要使用 wslconfig.exe 更改默认发行版。

注意:WSL 确实存在一些 已知的对于某些类型的开发的限制,这些限制也会影响你的 VS Code 体验。

  1. 在 Windows 端安装 Visual Studio Code Insiders(而不是在 WSL 中)。

  2. 安装 Remote Development 扩展包。

  3. 考虑在 Windows 端禁用 Git 的行尾结束符自动转换,方法是使用命令提示符运行:git config –global core.autocrlf false 如果该项保持启用状态,由于行尾结束符的差异,此设置可能导致你未编辑的文件中出现了修改操作。有关详细信息,请参阅 提示和技巧

在 WSL 中打开文件夹

在 VS Code 中打开「适用于 Linux 的 Windows 子系统」中的文件夹与从命令提示符打开 Windows 文件夹非常相似。

  1. 打开 WSL 终端窗口(使用开始菜单项或从命令提示符键入 wsl )。

  2. 切换到你要在 VS Code 中打开的文件夹(包括但不限于 Windows 文件系统挂载点,如 /mnt/c

  3. 在终端中输入 code-insiders .。在第一次执行此操作时,你应该看到 VS code 在获取运行于 WSL 所需的组件。这应该只需要很短的时间,而且只需要执行一次。

  4. 片刻之后,一个新的 VS Code 窗口将会出现,你将看到一个 VS Code 正在 WSL 中打开文件夹的通知。

WSL Starting notification

VS Code 现在将继续在 WSL 中配置自身,并在 WSL 中安装你在本地运行的任何 VS Code 扩展以优化性能。 VS Code 会对你通知它所获得的进展。

  1. 完成后,你现在可以在左下角看到一个 WSL 小标识,你将能够像平常一样使用 VS Code!

WSL Status Bar Item

就是如此啦!你在此窗口中执行的任何 VS code 操作都将在 WSL 环境中执行,从编辑和文件操作到调试、使用终端等等都是如此。

管理扩展

VS Code 在以下两个位置之一运行扩展:在 UI / 客户端这边或在 WSL 中。虽然作用于 VS Code UI 的扩展(如主题和代码片段)是在本地安装的,但大多数扩展都将安装在 WSL 中。

如果从「扩展」视图安装扩展,它将自动安装在正确的位置。一旦安装后,你可以根据类别分组确定扩展的安装位置。那将有「本地 – 已安装」的类别和一个安装在 WSL 的类别。

Workspace Extension Category

>Local Extension Category” src=”https://code.visualstudio.com/assets/docs/remote/wsl/wsl-local-installed-extensions.png”></p>
<blockquote>
<p>注意:如果你是扩展作者并且你的扩展程序无法正常运行或安装在错误的位置,请参阅 <a href=支持远程开发 查看详情。

实际需要远程运行的本地扩展在「本地 – 已安装」类别中显示为「已禁用」。你可以单击「安装」按钮在远程主机上安装扩展。

Disabled Extensions

高级:强制在本地/远程运行扩展

扩展通常为单独运行在本地或远程而设计和测试,而非对于两者。但是,如果扩展程序支持这项特性,则可以强制它在 settings.json 文件中指定的特定位置运行。

例如,下面的设置将强制 Docker 和 Debugger for Chrome 扩展程序 远程运行而不是默认在本地运行:

"remote.extensionKind": {
    "msjsdiag.debugger-for-chrome": "workspace",
    "peterjausovec.vscode-docker": "workspace"
}

「ui」而不是「workspace」将强制扩展在本地 UI /客户端 端运行。 通常,除非在扩展程序的文档中另有说明,否则这应仅用于测试,因为它可以中断扩展。 了解详细信息,请参阅有关 支持远程开发 的文章。

在 WSL 中打开终端

从 VS Code 在 WSL 中打开终端很简单。在 WSL 中打开文件夹后,在 VS code (Terminal > New Terminal) 中打开的任何终端窗口将自动在 WSL 中运行,而不是在本地运行。

你还可以使用此同一终端窗口中的 code-insiders 命令行来执行许多操作,例如在 WSL 中打开新文件或文件夹。 键入 code-insiders –help 以查看命令行中可用的选项。

Using the code CLI

在 WSL 中调试

在 WSL 中打开文件夹后,可以像在本地运行应用程序一样使用 VS Code 的调试器。例如,如果在 launch.json 中选择一个启动配置并开始调试(F5),应用程序将在远程主机上启动并与调试器交互。

.vscode/launch.json 中有关配置 VS Code 调试功能的详细信息,请参阅 debugging 文档。

WSL 特定设置

在 WSL 中打开文件夹时,也会引用 VS Code 的本地用户设置。虽然这可以保持用户体验的一致性,但你可能希望更改本地计算机和 WSL 之间一些设置。幸运的是,一旦连接到 WSL,你还可以通过运行「首选项」来设置 WSL 特定设置:打开从命令选项板(F1)中打开「远程设置命令」或在设置编辑器中选择「远程」选项卡。这些将覆盖你在 WSL 中打开文件夹时所具有的任何本地设置。

已知限制

本节包含 WSL 常见已知问题的列表。目的不是提供完整的问题列表,而是强调 WSL 中常见的一些问题。

在 这里浏览有关 WSL 的活跃问题列表

常见问题

我应该如何更改「远程 – WSL」使用的发行版呢?

「远程 – WSL」扩展使用的是你的默认发行版,你可以通过 wslconfig.exe 对其进行更改。

例子如下:

wslconfig /setdefault Ubuntu

通过以下命令得知你一已安装的发行版:

wslconfig /l

我看到有关缺少库或依赖项的错误

某些扩展依赖于某些 WSL Linux 发行版原生安装缺失而找不到的库。你可以使用其包管理器将其他库添加到Linux发行版中。 对于基于 Ubuntu 和 Debian 的发行版,运行 sudo apt-get install <package> 来安装所需的库。检查扩展的文档或提及的运行时信息以获取其它安装详细信息。

在WSL中运行时,VS Code Server 的连接要求是什么?

VS Code Server 需要与 update.code.visualstudio.com 和 marketplace.visualstudio.com的 HTTPS(端口443)连接。服务器和 VS code 客户端之间的所有其它通信都是通过经过身份验证的随机的本地 TCP 端口完成的。

作为扩展作者,我需要做什么?

VS Code 扩展 API 抽象出本地/远程详细信息,因此大多数扩展都可以在不进行修改的情况下工作。但是,如果扩展可以使用它们所需的任何 node 模块或运行时,则可能需要进行调整。 我们建议你测试扩展程序,以此来确定是否需要更新。有关详细信息,请参阅 支持远程开发

问题或反馈

本文由 VS Code Remote 官方文档 翻译而成,同时发表在 ethans.me

史上最全的BAT大厂Mysql面试题在这里!

mumupudding阅读(27)

1、MySQL的复制原理以及流程

基本原理流程,3个线程以及之间的关联;

    • 主:binlog线程——记录下所有改变了数据库数据的语句,放进master上的binlog中;
    • 从:io线程——在使用start slave 之后,负责从master上拉取 binlog 内容,放进 自己的relay log中;
    • 从:sql执行线程——执行relay log中的语句;

2、MySQL中myisam与innodb的区别,至少5点

(1)、问5点不同;

1>.InnoDB支持事物,而MyISAM不支持事物2>.InnoDB支持行级锁,而MyISAM支持表级锁3>.InnoDB支持MVCC, 而MyISAM不支持4>.InnoDB支持外键,而MyISAM不支持5>.InnoDB不支持全文索引,而MyISAM支持。

(2)、innodb引擎的4大特性

插入缓冲(insert buffer),二次写(double write),自适应哈希索引(ahi),预读(read ahead)

(3)、2者selectcount(*)哪个更快,为什么

myisam更快,因为myisam内部维护了一个计数器,可以直接调取。

3、MySQL中varchar与char的区别以及varchar(50)中的50代表的涵义

(1)、varchar与char的区别char是一种固定长度的类型,varchar则是一种可变长度的类型 (2)、varchar(50)中50的涵义最多存放50个字符,varchar(50)和(200)存储hello所占空间一样,但后者在排序时会消耗更多内存,因为order by col采用fixed_length计算col长度(memory引擎也一样) (3)、int(20)中20的涵义是指显示字符的长度但要加参数的,最大为255,比如它是记录行数的id,插入10笔资料,它就显示00000000001 ~~~00000000010,当字符的位数超过11,它也只显示11位,如果你没有加那个让它未满11位就前面加0的参数,它不会在前面加020表示最大显示宽度为20,但仍占4字节存储,存储范围不变; (4)、mysql为什么这么设计对大多数应用没有意义,只是规定一些工具用来显示字符的个数;int(1)和int(20)存储和计算均一样;

4、问了innodb的事务与日志的实现方式

(1)、有多少种日志;

错误日志:记录出错信息,也记录一些警告信息或者正确的信息。查询日志:记录所有对数据库请求的信息,不论这些请求是否得到了正确的执行。慢查询日志:设置一个阈值,将运行时间超过该值的所有SQL语句都记录到慢查询的日志文件中。二进制日志:记录对数据库执行更改的所有操作。中继日志:事务日志:

(2)、事物的4种隔离级别

隔离级别读未提交(RU)读已提交(RC)可重复读(RR)串行

(3)、事务是如何通过日志来实现的,说得越深入越好。

事务日志是通过redo和innodb的存储引擎日志缓冲(Innodb log buffer)来实现的,当开始一个事务的时候,会记录该事务的lsn(log sequence number)号; 当事务执行时,会往InnoDB存储引擎的日志的日志缓存里面插入事务日志;当事务提交时,必须将存储引擎的日志缓冲写入磁盘(通过innodb_flush_log_at_trx_commit来控制),也就是写数据前,需要先写日志。这种方式称为“预写日志方式”

5、MySQL binlog的几种日志录入格式以及区别

Statement:每一条会修改数据的sql都会记录在binlog中。

优点:不需要记录每一行的变化,减少了binlog日志量,节约了IO,提高性能。(相比row能节约多少性能 与日志量,这个取决于应用的SQL情况,正常同一条记录修改或者插入row格式所产生的日志量还小于Statement产生的日志量,但是考虑到如果带条 件的update操作,以及整表删除,alter表等操作,ROW格式会产生大量日志,因此在考虑是否使用ROW格式日志时应该跟据应用的实际情况,其所 产生的日志量会增加多少,以及带来的IO性能问题。)缺点:由于记录的只是执行语句,为了这些语句能在slave上正确运行,因此还必须记录每条语句在执行的时候的 一些相关信息,以保证所有语句能在slave得到和在master端执行时候相同 的结果。另外mysql 的复制,像一些特定函数功能,slave可与master上要保持一致会有很多相关问题(如sleep()函数, last_insert_id(),以及user-defined functions(udf)会出现问题).使用以下函数的语句也无法被复制:

    • LOAD_FILE()
    • UUID()
    • USER()
    • FOUND_ROWS()
    • SYSDATE() (除非启动时启用了 –sysdate-is-now 选项)
    • 同时在INSERT …SELECT 会产生比 RBR 更多的行级锁
    • 2.Row:不记录sql语句上下文相关信息,仅保存哪条记录被修改。
    • 优点: binlog中可以不记录执行的sql语句的上下文相关的信息,仅需要记录那一条记录被修改成什么了。所以rowlevel的日志内容会非常清楚的记录下 每一行数据修改的细节。而且不会出现某些特定情况下的存储过程,或function,以及trigger的调用和触发无法被正确复制的问题
    • 缺点:所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,这样可能会产生大量的日志内容,比 如一条update语句,修改多条记录,则binlog中每一条修改都会有记录,这样造成binlog日志量会很大,特别是当执行alter table之类的语句的时候,由于表结构修改,每条记录都发生改变,那么该表每一条记录都会记录到日志中。
    • 3.Mixedlevel: 是以上两种level的混合使用,一般的语句修改使用statment格式保存binlog,如一些函数,statement无法完成主从复制的操作,则 采用row格式保存binlog,MySQL会根据执行的每一条具体的sql语句来区分对待记录的日志形式,也就是在Statement和Row之间选择 一种.新版本的MySQL中队row level模式也被做了优化,并不是所有的修改都会以row level来记录,像遇到表结构变更的时候就会以statement模式来记录。至于update或者delete等修改数据的语句,还是会记录所有行的 变更。

6、MySQL数据库cpu飙升到500%的话他怎么处理?

1、列出所有进程 show processlist,观察所有进程 ,多秒没有状态变化的(干掉)2、查看超时日志或者错误日志 (做了几年开发,一般会是查询以及大批量的插入会导致cpu与i/o上涨,当然不排除网络状态突然断了,,导致一个请求服务器只接受到一半,比如where子句或分页子句没有发送,,当然的一次被坑经历)

7、sql优化各种方法

(1)、explain出来的各种item的意义;select_type表示查询中每个select子句的类型type表示MySQL在表中找到所需行的方式,又称“访问类型”possible_keys指出MySQL能使用哪个索引在表中找到行,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用key显示MySQL在查询中实际使用的索引,若没有使用索引,显示为NULLkey_len表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度ref表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值Extra包含不适合在其他列中显示但十分重要的额外信息(2)、profile的意义以及使用场景;查询到 SQL 会执行多少时间, 并看出 CPU/Memory 使用量, 执行过程中 Systemlock, Table lock 花多少时间等等

8、备份计划,mysqldump以及xtranbackup的实现原理

(1)、备份计划;这里每个公司都不一样,您别说那种1小时1全备什么的就行(2)、备份恢复时间;这里跟机器,尤其是硬盘的速率有关系,以下列举几个仅供参考20G的2分钟(mysqldump)80G的30分钟(mysqldump)111G的30分钟(mysqldump)288G的3小时(xtra)3T的4小时(xtra)逻辑导入时间一般是备份时间的5倍以上(3)、xtrabackup实现原理在InnoDB内部会维护一个redo日志文件,我们也可以叫做事务日志文件。事务日志会存储每一个InnoDB表数据的记录修改。当InnoDB启动时,InnoDB会检查数据文件和事务日志,并执行两个步骤:它应用(前滚)已经提交的事务日志到数据文件,并将修改过但没有提交的数据进行回滚操作。

9、mysqldump中备份出来的sql,如果我想sql文件中,一行只有一个insert….value()的话,怎么办?如果备份需要带上master的复制点信息怎么办?

–skip-extended-insert

[root@helei-zhuanshu ~]# mysqldump -uroot -p helei --skip-extended-insert
Enter password:
 KEY `idx_c1` (`c1`),
 KEY `idx_c2` (`c2`)
) ENGINE=InnoDB AUTO_INCREMENT=51 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `helei`
--
LOCK TABLES `helei` WRITE;
/*!40000 ALTER TABLE `helei` DISABLE KEYS */;
INSERT INTO `helei` VALUES (1,32,37,38,'2016-10-18 06:19:24','susususususususususususu');
INSERT INTO `helei` VALUES (2,37,46,21,'2016-10-18 06:19:24','susususususu');
INSERT INTO `helei` VALUES (3,21,5,14,'2016-10-18 06:19:24','susu');

10、500台db,在最快时间之内重启

puppet,dsh

11、innodb的读写参数优化

(1)、读取参数global buffer pool以及 local buffer;(2)、写入参数;innodb_flush_log_at_trx_commitinnodb_buffer_pool_size(3)、与IO相关的参数;innodb_write_io_threads = 8innodb_read_io_threads = 8innodb_thread_concurrency = 0(4)、缓存参数以及缓存的适用场景。query cache/query_cache_type并不是所有表都适合使用query cache。造成query cache失效的原因主要是相应的table发生了变更 第一个:读操作多的话看看比例,简单来说,如果是用户清单表,或者说是数据比例比较固定,比如说商品列表,是可以打开的,前提是这些库比较集中,数据库中的实务比较小。 第二个:我们“行骗”的时候,比如说我们竞标的时候压测,把query cache打开,还是能收到qps激增的效果,当然前提示前端的连接池什么的都配置一样。大部分情况下如果写入的居多,访问量并不多,那么就不要打开,例如社交网站的,10%的人产生内容,其余的90%都在消费,打开还是效果很好的,但是你如果是qq消息,或者聊天,那就很要命。 第三个:小网站或者没有高并发的无所谓,高并发下,会看到 很多 qcache 锁 等待,所以一般高并发下,不建议打开query cache

12、你是如何监控你们的数据库的?你们的慢日志都是怎么查询的?

监控的工具有很多,例如zabbix,lepus,我这里用的是lepus

13、你是否做过主从一致性校验,如果有,怎么做的,如果没有,你打算怎么做?

主从一致性校验有多种工具 例如checksum、mysqldiff、pt-table-checksum等

14、你们数据库是否支持emoji表情,如果不支持,如何操作?

如果是utf8字符集的话,需要升级至utf8_mb4方可支持

15、你是如何维护数据库的数据字典的?

这个大家维护的方法都不同,我一般是直接在生产库进行注释,利用工具导出成excel方便流通。

16、表中有大字段X(例如:text类型),且字段X不会经常更新,以读为为主,请问

拆带来的问题:连接消耗 + 存储拆分空间;不拆可能带来的问题:查询性能;1、如果能容忍拆分带来的空间问题,拆的话最好和经常要查询的表的主键在物理结构上放置在一起(分区) 顺序IO,减少连接消耗,最后这是一个文本列再加上一个全文索引来尽量抵消连接消耗2、如果能容忍不拆分带来的查询性能损失的话:上面的方案在某个极致条件下肯定会出现问题,那么不拆就是最好的选择

17、MySQL中InnoDB引擎的行锁是通过加在什么上完成(或称实现)的?为什么是这样子的?

InnoDB是基于索引来完成行锁例: select * from tab_with_index where id = 1 for update;for update 可以根据条件来完成行锁锁定,并且 id 是有索引键的列,如果 id 不是索引键那么InnoDB将完成表锁,,并发将无从谈起

18、开放性问题:据说是腾讯的

一个6亿的表a,一个3亿的表b,通过外间tid关联,你如何最快的查询出满足条件的第50000到第50200中的这200条数据记录。

1、如果A表TID是自增长,并且是连续的,B表的ID为索引

select * from a,b where a.tid = b.id and a.tid>500000 limit 200;

2、如果A表的TID不是连续的,那么就需要使用覆盖索引.TID要么是主键,要么是辅助索引,B表ID也需要有索引。

select * from b , (select tid from a limit 50000,200) a where b.id = a .tid;

19、什么是存储过程?有哪些优缺点?

存储过程是一些预编译的SQL语句。

1、更加直白的理解:存储过程可以说是一个记录集,它是由一些T-SQL语句组成的代码块,这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。2、存储过程是一个预编译的代码块,执行效率比较高,一个存储过程替代大量T_SQL语句 ,可以降低网络通信量,提高通信速率,可以一定程度上确保数据安全

20、索引是什么?有什么作用以及优缺点?

1、索引是对数据库表中一或多个列的值进行排序的结构,是帮助MySQL高效获取数据的数据结构2、索引就是加快检索表中数据的方法。数据库的索引类似于书籍的索引。在书籍中,索引允许用户不必翻阅完整个书就能迅速地找到所需要的信息。在数据库中,索引也允许数据库程序迅速地找到表中的数据,而不必扫描整个数据库。

MySQL数据库几个基本的索引类型:普通索引、唯一索引、主键索引、全文索引

1、索引加快数据库的检索速度2、索引降低了插入、删除、修改等维护任务的速度3、唯一索引可以确保每一行数据的唯一性4、通过使用索引,可以在查询的过程中使用优化隐藏器,提高系统的性能5、索引需要占物理和数据空间

21、什么是事务?

事务(Transaction)是并发控制的基本单位。所谓的事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。事务是数据库维护数据一致性的单位,在每个事务结束时,都能保持数据一致性。

24、数据库的乐观锁和悲观锁是什么?

数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。

悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。

22、使用索引查询一定能提高查询的性能吗?为什么

通常,通过索引查询数据比全表扫描要快.但是我们也必须注意到它的代价.

1、索引需要空间来存储,也需要定期维护, 每当有记录在表中增减或索引列被修改时,索引本身也会被修改. 这意味着每条记录的INSERT,DELETE,UPDATE将为此多付出4,5 次的磁盘I/O. 因为索引需要额外的存储空间和处理,那些不必要的索引反而会使查询反应时间变慢.使用索引查询不一定能提高查询性能,索引范围查询(INDEX RANGE SCAN)适用于两种情况:2、基于一个范围的检索,一般查询返回结果集小于表中记录数的30%3、基于非唯一性索引的检索

23、简单说一说drop、delete与truncate的区

SQL中的drop、delete、truncate都表示删除,但是三者有一些差别

1、delete和truncate只删除表的数据不删除表的结构2、速度,一般来说: drop> truncate >delete3、delete语句是dml,这个操作会放到rollback segement中,事务提交之后才生效;4、如果有相应的trigger,执行的时候将被触发. truncate,drop是ddl, 操作立即生效,原数据不放到rollback segment中,不能回滚. 操作不触发trigger.

24、drop、delete与truncate分别在什么场景之下使用?

1、不再需要一张表的时候,用drop2、想删除部分数据行时候,用delete,并且带上where子句3、保留表而删除所有数据的时候用truncate

25、超键、候选键、主键、外键分别是什么?

1、超键:在关系中能唯一标识元组的属性集称为关系模式的超键。一个属性可以为作为一个超键,多个属性组合在一起也可以作为一个超键。超键包含候选键和主键。2、候选键:是最小超键,即没有冗余元素的超键。3、主键:数据库表中对储存数据对象予以唯一和完整标识的数据列或属性的组合。一个数据列只能有一个主键,且主键的取值不能缺失,即不能为空值(Null)。4、外键:在一个表中存在的另一个表的主键称此表的外键。

26、什么是视图?以及视图的使用场景有哪些?

1、视图是一种虚拟的表,具有和物理表相同的功能。可以对视图进行增,改,查,操作,试图通常是有一个表或者多个表的行或列的子集。对视图的修改不影响基本表。它使得我们获取数据更容易,相比多表查询。2、只暴露部分字段给访问者,所以就建一个虚表,就是视图。3、查询的数据来源于不同的表,而查询者希望以统一的方式查询,这样也可以建立一个视图,把多个表查询结果联合起来,查询者只需要直接从视图中获取数据,不必考虑数据来源于不同表所带来的差异

27、说一说三个范式。

第一范式(1NF):数据库表中的字段都是单一属性的,不可再分。这个单一属性由基本类型构成,包括整型、实数、字符型、逻辑型、日期型等。 第二范式(2NF):数据库表中不存在非关键字段对任一候选关键字段的部分函数依赖(部分函数依赖指的是存在组合关键字中的某些字段决定非关键字段的情况),也即所有非关键字段都完全依赖于任意一组候选关键字。 第三范式(3NF):在第二范式的基础上,数据表中如果不存在非关键字段对任一候选关键字段的传递函数依赖则符合第三范式。所谓传递函数依赖,指的是如 果存在”A → B → C”的决定关系,则C传递函数依赖于A。因此,满足第三范式的数据库表应该不存在如下依赖关系: 关键字段 → 非关键字段 x → 非关键字段y

【Nginx】Nginx多级代理,获取客户端真实请求IP以及每级代理IP

mumupudding阅读(41)

Nginx多级代理,获取客户端真实请求IP以及每级代理IP

【Nginx】Nginx多级代理,获取客户端真实请求IP以及每级代理IP

如图所示,每一级nginx里的location配置里需要加上对应的配置,最后一级nginx是直接到应用,测试时为了方便,直接用echo模块去测试,打印IP地址。

【Nginx】Nginx多级代理,获取客户端真实请求IP以及每级代理IP

原理分析:

只有客户端直接请求到的那个nginx能够拿到客户端的真实IP,所以第一级nginx配置了

proxy_set_header X-Real-IP $remote_addr;

这个配置就会将客户端IP放到http的header里,这样到最后的应用里可以通过request.getHeader去拿到客户端真实IP了

public String getRemoteIp() {
        String ip = request.getHeader(“X-Forwarded-For”);
        if (isEffective(ip) && ip.indexOf(“,”) > -1) {
            String[] array = ip.split(“,”);
            for (String str : array) {
                if (isEffective(str)) {
                    ip = str;
                    break;
                }
            }
        }
        if (!isEffective(ip)) {
            ip = request.getHeader(“Proxy-Client-IP”);
        }
        if (!isEffective(ip)) {
            ip = request.getHeader(“WL-Proxy-Client-IP”);
        }
        if (!isEffective(ip)) {
            ip = request.getHeader(“HTTP_CLIENT_IP”);
        }
        if (!isEffective(ip)) {
            ip = request.getHeader(“HTTP_X_FORWARDED_FOR”);
        }
        if (!isEffective(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
 
    private boolean isEffective(String remoteAddr) {
        if ((null != remoteAddr) && (!””.equals(remoteAddr.trim()))
                && (!”unknown”.equalsIgnoreCase(remoteAddr.trim()))) {
            return true;
        }
        return false;
    }
如何拿到用户从请求源头到应用所经过的各个代理IP呢?
 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
通过如上配置,将每级代理IP与$remote_addr用逗号分隔开

所以从上述情况来看,整体的流程很清晰了:

客户端请求(172.23.11.251) –>172.23.26.130–>172.23.26.132–>172.23.26.133–>172.23.30.100(具体应用)

但是我们发现上面IP貌似打印少了一个133的地址,我们在100上修改下echo测试

这里打印出了133的地址

nginx的安装包里有一个http_realip_module模块,在编译的时候可以编译此模块

./configure –prefix=/home/nginx/install –with-pcre=../pcre-8.36 –with-zlib=../zlib-1.2.8 –with-openssl=../openssl-1.0.2 –add-module=/root/nginx/echo-nginx-module-0.60 –with-http_realip_module && make && make install

新的配置如下:

set_real_ip_from   172.23.0.0/16;     IP段,指定接收来自哪个前端发送的 IP head 可以是单个IP或者IP段
set_real_ip_from   192.168.1.1;     单个IP
real_ip_header     X-Real-IP;         IP head  的对应参数,默认即可,含义是客户端真实IP从哪个头取?

real_ip_recursive on;  是否递归解析real_ip_header
 

深入理解 React 的 Virtual DOM

mumupudding阅读(22)

React在前端界一直很流行,而且学起来也不是很难,只需要学会JSX、理解StateProps,然后就可以愉快的玩耍了,但想要成为React的专家你还需要对React有一些更深入的理解,希望本文对你有用。

这是Choerodon的一个前端页面

在复杂的前端项目中一个页面可能包含上百个状态,对React框架理解得更精细一些对前端优化很重要。曾经这个页面点击一条记录展示详情会卡顿数秒,而这仅仅是前端渲染造成的。

为了能够解决这些问题,开发者需要了解React组件从定义到在页面上呈现(然后更新)的整个过程。

React在编写组件时使用混合HTMLJavaScript的一种语法(称为JSX)。 但是,浏览器对JSX及其语法一无所知,浏览器只能理解纯JavaScript,因此必须将JSX转换为HTML。 这是一个div的JSX代码,它有一个类和一些内容:

<div className='cn'>  文本</div>

在React中将这段jsx变成普通的js之后它就是一个带有许多参数的函数调用:

React.createElement(  'div',  { className: 'cn' },  '文本');

它的第一个参数是一个字符串,对应html中的标签名,第二个参数是它的所有属性所构成的对象,当然,它也有可能是个空对象,剩下的参数都是这个元素下的子元素,这里的文本也会被当作一个子元素,所以第三个参数是 “文本”

到这里你应该就能想象这个元素下有更多children的时候会发生什么。

<div className='cn'>  文本1  <br />  文本2</div>
React.createElement(  'div',  { className: 'cn' },  '文本1',              // 1st child  React.createElement('br'), // 2nd child  '文本1'               // 3rd child)

目前的函数有五个参数:元素的类型,全部属性的对象和三个子元素。 由于一个child也是React已知的HTML标签,因此它也将被解释成函数调用。

到目前为止,本文已经介绍了两种类型的child参数,一种是string纯文本,一种是调用其他的React.createElement函数。其实,其他值也可以作为参数,比如:

  • 基本类型 false,null,undefined和 true
  • 数组
  • React组件

使用数组是因为可以将子组件分组并作为一个参数传递:

React.createElement(  'div',  { className: 'cn' },  ['Content 1!', React.createElement('br'), 'Content 2!'])

当然,React的强大功能不是来自HTML规范中描述的标签,而是来自用户创建的组件,例如:

function Table({ rows }) {  return (    <table>      {rows.map(row => (        <tr key={row.id}>          <td>{row.title}</td>        </tr>      ))}    </table>  );}

组件允许开发者将模板分解为可重用的块。在上面的“纯函数”组件的示例中,组件接受一个包含表行数据的对象数组,并返回React.createElement对<table>元素及其行作为子元素的单个调用 。

每当开发者将组件放入JSX布局中时它看上去是这样的:

<Table rows={rows} />

但从浏览器角度,它看到的是这样的:

React.createElement(Table, { rows: rows });

请注意,这次的第一个参数不是以string描述的HTML元素,而是组件的引用(即函数名)。第二个参数是传入该组件的props对象。

将组件放在页面上

现在,浏览器已经将所有JSX组件转换为纯JavaScript,现在浏览器获得了一堆函数调用,其参数是其他函数调用,还有其他函数调用……如何将它们转换为构成网页的DOM元素?

为此,开发者需要使用ReactDOM库及其render方法:

function Table({ rows }) { /* ... */ } // 组件定义// 渲染一个组件ReactDOM.render(  React.createElement(Table, { rows: rows }), // "创建" 一个 component  document.getElementById('#root') // 将它放入DOM中);

ReactDOM.render被调用时,React.createElement最终也会被调用,它返回以下对象:

// 这个对象里还有很多其他的字段,但现在对开发者来说重要的是这些。{  type: Table,  props: {    rows: rows  },  // ...}

这些对象构成了React意义上的Virtual DOM

它们将在所有进一步渲染中相互比较,并最终转换为真正的DOM(与Virtual DOM对比)。

这是另一个例子:这次有一个div具有class属性和几个子节点:

React.createElement(  'div',  { className: 'cn' },  'Content 1!',  'Content 2!',);

变成:

{  type: 'div',  props: {    className: 'cn',    children: [      'Content 1!',      'Content 2!'    ]  }}

所有的传入的展开函数,也就是React.createElement除了第一第二个参数剩下的参数都会在props对象中的children属性中,不管传入的是什么函数,他们最终都会作为children传入props中。

而且,开发者可以直接在JSX代码中添加children属性,将子项直接放在children中,结果仍然是相同的:

<div className='cn' children={['Content 1!', 'Content 2!']} />

在Virtual DOM对象被建立出来之后ReactDOM.render会尝试按以下规则把它翻译成浏览器能够看得懂的DOM节点:

  • 如果Virtual DOM对象中的type属性是一个string类型的tag名称,创建一个tag,包含props里的全部属性。
  • 如果Virtual DOM对象中的type属性是一个函数或者class,调用它,它返回的可能还是一个Virtual DOM然后将结果继续递归调用此过程。
  • 如果props中有children属性,对children中的每个元素进行以上过程,并将返回的结果放到父DOM节点中。

最后,浏览器获得了以下HTML(对于上述table的例子):

<table>  <tr>    <td>Title</td>  </tr>  ...</table>

重建DOM

接下浏览器要“重建”一个DOM节点,如果浏览器要更新一个页面,显然,开发者并不希望替换页面中的全部元素,这就是React真正的魔法了。如何才能实现它?先从最简单的方法开始,重新调用这个节点的ReactDOM.render方法。

// 第二次调用ReactDOM.render(  React.createElement(Table, { rows: rows }),  document.getElementById('#root'));

这一次,上面的代码执行逻辑将与看到的代码不同。React不是从头开始创建所有DOM节点并将它们放在页面上,React将使用“diff”算法,以确定节点树的哪些部分必须更新,哪些部分可以保持不变。

那么它是怎样工作的?只有少数几个简单的情况,理解它们将对React程序的优化有很大帮助。请记住,接下来看到的对象是用作表示React Virtual DOM中节点的对象。

▌Case 1:type是一个字符串,type在调用之间保持不变,props也没有改变。

// before update{ type: 'div', props: { className: 'cn' } }// after update{ type: 'div', props: { className: 'cn' } }

这是最简单的情况:DOM保持不变。

▌Case 2:type仍然是相同的字符串,props是不同的。

// before update:{ type: 'div', props: { className: 'cn' } }// after update:{ type: 'div', props: { className: 'cnn' } }

由于type仍然代表一个HTML元素,React知道如何通过标准的DOM API调用更改其属性,而无需从DOM树中删除节点。

▌Case 3:type已更改为不同的组件String或从String组件更改为组件。

// before update:{ type: 'div', props: { className: 'cn' } }// after update:{ type: 'span', props: { className: 'cn' } }

由于React现在看到类型不同,它甚至不会尝试更新DOM节点:旧元素将与其所有子节点一起被删除(unmount)。因此,在DOM树上替换完全不同的元素的代价会非常之高。幸运的是,这在实际情况中很少发生。

重要的是要记住React使用===(三等)来比较type值,因此它们必须是同一个类或相同函数的相同实例。

下一个场景更有趣,因为这是开发者最常使用React的方式。

▌Case 4:type是一个组件。

// before update:{ type: Table, props: { rows: rows } }// after update:{ type: Table, props: { rows: rows } }

你可能会说,“这好像没有任何变化”,但这是不对的。

如果type是对函数或类的引用(即常规React组件),并且启动了树diff比较过程,那么React将始终尝试查看组件内部的所有child以确保render的返回值没有更改。即在树下比较每个组件 – 是的,复杂的渲染也可能变得昂贵!

组件中的children

除了上面描述的四种常见场景之外,当元素有多个子元素时,开发者还需要考虑React的行为。假设有这样一个元素:

// ...props: {  children: [      { type: 'div' },      { type: 'span' },      { type: 'br' }  ]},// ...

开发者开发者想将它重新渲染成这样(spandiv交换了位置):

// ...props: {  children: [    { type: 'span' },    { type: 'div' },    { type: 'br' }  ]},// ...

那么会发生什么?

当React看到里面的任何数组类型的props.children,它会开始将它中的元素与之前看到的数组中的元素按顺序进行比较:index 0将与index 0,index 1与index 1进行比较,对于每对子元素,React将应用上述规则集进行比较更新。在以上的例子中,它看到div变成一个span这是一个情景3中的情况。但这有一个问题:假设开发者想要从1000行表中删除第一行。React必须“更新”剩余的999个孩子,因为如果与先前的逐个索引表示相比,他们的内容现在将不相等。

幸运的是,React有一种内置的方法来解决这个问题。如果元素具有key属性,则元素将通过key而不是索引进行比较。只要key是唯一的,React就会移动元素而不将它们从DOM树中移除,然后将它们放回(React中称为挂载/卸载的过程)。

// ...props: {  children: [ // 现在react就是根据key,而不是索引来比较了    { type: 'div', key: 'div' },    { type: 'span', key: 'span' },    { type: 'br', key: 'bt' }  ]},// ...

当状态改变时

到目前为止,本文只触及了props,React哲学的一部分,但忽略了state。这是一个简单的“有状态”组件:

class App extends Component {  state = { counter: 0 }  increment = () => this.setState({    counter: this.state.counter + 1,  })  render = () => (<button onClick={this.increment}>    {'Counter: ' + this.state.counter}  </button>)}

现在,上述例子中的state对象有一个counter属性。单击按钮会增加其值并更改按钮文本。但是当用户点击时,DOM会发生什么?它的哪一部分将被重新计算和更新?

调用this.setState也会导致重新渲染,但不会导致整个页面重渲染,而只会导致组件本身及其子项。父母和兄弟姐妹都可以幸免于难。

修复问题

本文准备了一个DEMO,这是修复问题前的样子。你可以在这里查看其源代码。不过在此之前,你还需要安装React Developer Tools

打开demo要看的第一件事是哪些元素以及何时导致Virtual DOM更新。导航到浏览器的Dev Tools中的React面板,点击设置然后选择“Highlight Updates”复选框:

现在尝试在表中添加一行。如你所见,页面上的每个元素周围都会出现边框。这意味着每次添加行时,React都会计算并比较整个Virtual DOM树。现在尝试按一行内的计数器按钮。你将看到Virtual DOM如何更新 (state仅相关元素及其子元素更新)。

React DevTools暗示了问题可能出现的地方,但没有告诉开发者任何细节:特别是有问题的更新是指元素“diff”之后有不同,还是组件被unmount/mount了。要了解更多信息,开发者需要使用React的内置分析器(请注意,它不能在生产模式下工作)。

转到Chrome DevTools中的“Performance”标签。点击record按钮,然后点击表格。添加一些行,更改一些计数器,然后点击“Stop”按钮。稍等一会儿之后开发者会看到:

在结果输出中,开发者需要关注“Timing”。缩放时间轴,直到看到“React Tree Reconciliation”组及其子项。这些都是组件的名称,旁边有[update]或[mount]。可以看到有一个TableRow被mount了,其他所有的TableRow都在update,这并不是开发者想要的。

大多数性能问题都由[update]或[mount]引起

一个组件(以及组件下的所有东西)由于某种原因在每次更新时重新挂载,开发者不想让它发生(重新挂载很慢),或者在大型分支上执行代价过大的重绘,即使组件似乎没有发生任何改变。

修复mount/unmount

现在,当开发者了解React如何决定更新Virtual DOM并知道幕后发生的事情时,终于准备好解决问题了!修复性能问题首先要解决 mount/unmount。

如果开发者将任何元素/组件的多个子元素在内部表示为数组,那么程序可以获得非常明显的速度提升。

考虑一下:

<div>  <Message />  <Table />  <Footer /></div>

在虚拟DOM中,将表示为:

// ...props: {  children: [    { type: Message },    { type: Table },    { type: Footer }  ]}// ...

一个简单的Message组件(是一个div带有一些文本,像是猪齿鱼的顶部通知)和一个很长的Table,比方说1000多行。它们都是div元素的child,因此它们被放置在父节点的props.children之下,并且它们没有key。React甚至不会通过控制台警告来提醒开发者分配key,因为子节点React.createElement作为参数列表而不是数组传递给父节点。

现在,用户已经关闭了顶部通知,所以Message从树中删除。TableFooter是剩下的child。

// ...props: {  children: [    { type: Table },    { type: Footer }  ]}// ...

React如何看待它?它将它视为一系列改变了type的child:children[0]的type本来是Message,但现在他是Table。因为它们都是对函数(和不同函数)的引用,它会卸载整个Table并再次安装它,渲染它的所有子代:1000多行!

因此,你可以添加唯一键(但在这种特殊情况下使用key不是最佳选择)或者采用更智能的trick:使用 && 的布尔短路运算,这是JavaScript和许多其他现代语言的一个特性。像这样:

<div>  {isShowMessage && <Message />}  <Table />  <Footer /></div>

即使Message被关闭了(不再显示),props.children父母div仍将拥有三个元素,children[0]具有一个值false(布尔类型)。还记得true/false, null甚至undefined都是Virtual DOM对象type属性的允许值吗?浏览器最终得到类似这样的东西:

// ...props: {  children: [    false, //  isShowMessage && <Message /> 短路成了false    { type: Table },    { type: Footer }  ]}// ...

所以,不管Message是否被显示,索引都不会改变,Table仍然会和Table比较,但仅仅比较Virtual DOM通常比删除DOM节点并从中创建它们要快得多。

现在来看看更高级的东西。开发者喜欢HOC。高阶组件是一个函数,它将一个组件作为一个参数,添加一些行为,并返回一个不同的组件(函数):

function withName(SomeComponent) {  return function(props) {    return <SomeComponent {...props} name={name} />;  }}

开发者在父render方法中创建了一个HOC 。当React需要重新渲染树时,React 的Virtual DOM将如下所示:

// On first render:{  type: ComponentWithName,  props: {},}// On second render:{  type: ComponentWithName, // Same name, but different instance  props: {},}

现在,React只会在ComponentWithName上运行一个diff算法,但是这次同名引用了一个不同的实例,三等于比较失败,必须进行完全重新挂载。注意它也会导致状态丢失,幸运的是,它很容易修复:只要返回的实例都是同一个就好了:

// 单例const ComponentWithName = withName(Component);class App extends React.Component() {  render() {    return <ComponentWithName />;  }}

修复update

现在浏览器已经确保不会重新装载东西了,除非必要。但是,对位于DOM树根目录附近的组件所做的任何更改都将导致其所有子项的进行对比重绘。结构复杂,价格昂贵且经常可以避免。

如果有办法告诉React不要查看某个分支,那将是很好的,因为它没有任何变化。

这种方式存在,它涉及一个叫shouldComponentUpdate的组件生命周期函数。React会在每次调用组件之前调用此方法,并接收propsstate的新值。然后开发者可以自由地比较新值和旧值之间的区别,并决定是否应该更新组件(返回truefalse)。如果函数返回false,React将不会重新渲染有问题的组件,也不会查看其子组件。

通常比较两组propsstate一个简单的浅层比较就足够了:如果顶层属性的值相同,浏览器就不必更新了。浅比较不是JavaScript的一个特性,但开发者很多方法来自己实现它,为了不重复造轮子,也可以使用别人写好的方法

在引入浅层比较的npm包后,开发者可以编写如下代码:

class TableRow extends React.Component {  shouldComponentUpdate(nextProps, nextState) {    const { props, state } = this;    return !shallowequal(props, nextProps)           && !shallowequal(state, nextState);  }  render() { /* ... */ }}

但是你甚至不必自己编写代码,因为React在一个名为React.PureComponent的类中内置了这个功能,它类似于React.Component,只是shouldComponentUpdate已经为你实现了浅层props/state比较。

或许你会有这样的想法,能替换ComponentPureComponent就去替换。但开发者如果错误地使用PureComponent同样会有重新渲染的问题存在,需要考虑下面三种情况:

<Table    // map每次都会返回一个新的数组实例,所以每次比较都是不同的    rows={rows.map(/* ... */)}    // 每一次传入的对象都是新的对象,引用是不同的。    style={ { color: 'red' } }    // 箭头函数也一样,每次都是不同的引用。    onUpdate={() => { /* ... */ }}/>

上面的代码片段演示了三种最常见的反模式,请尽量避免它们!

正确地使用PureComponent,你可以在这里看到所有的TableRow都被“纯化”后渲染的效果。

但是,如果你迫不及待想要全部使用纯函数组件,这样是不对的。比较两组propsstate不是免费的,对于大多数基本组件来说甚至都不值得:运行shallowCompare比diff算法需要更多时间。

可以使用此经验法则:纯组件适用于复杂的表单和表格,但它们通常会使按钮或图标等简单元素变慢。

现在,你已经熟悉了React的渲染模式,接下来就开始前端优化之旅吧。

关于Choerodon猪齿鱼

Choerodon猪齿鱼是一个开源企业服务平台,基于Kubernetes的容器编排和管理能力,整合DevOps工具链、微服务和移动应用框架,来帮助企业实现敏捷化的应用交付和自动化的运营管理的开源平台,同时提供IoT、支付、数据、智能洞察、企业应用市场等业务组件,致力帮助企业聚焦于业务,加速数字化转型。

大家也可以通过以下社区途径了解猪齿鱼的最新动态、产品特性,以及参与社区贡献:

现代IM系统中的消息系统架构 – 架构篇

mumupudding阅读(24)

前言

IM全称是『Instant Messaging』,中文名是即时通讯。在这个高度信息化的移动互联网时代,生活中IM类产品已经成为必备品,比较有名的如钉钉、微信、QQ等以IM为核心功能的产品。当然目前微信已经成长为一个生态型产品,但其核心功能还是IM。还有一些非以IM系统为核心的应用,最典型的如一些在线游戏、社交应用,IM也是其重要的功能模块。可以说,IM系统已经是任何一个带有社交属性的应用需要具备的基础功能,网络上对于这类系统的设计与实现的讨论也越来越多。

IM系统在互联网初期即存在,其基础技术架构在这十几年的发展中更新迭代多次,从早期的CS、P2P架构,到现在后台已经演变为一个复杂的分布式系统,涉及移动端、网络通信、协议、安全、存储和搜索等技术的方方面面。IM系统中最核心的部分是消息系统,消息系统中最核心的功能是消息的同步、存储和检索:

  • 消息的同步:将消息完整的、快速的从发送方传递到接收方,就是消息的同步。消息同步系统最重要的衡量指标就是消息传递的实时性、完整性以及能支撑的消息规模。从功能上来说,一般至少要支持在线和离线推送,高级的IM系统还支持『多端同步』。
  • 消息的存储:消息存储即消息的持久化保存,传统消息系统通常只能支持消息在接收端的本地存储,数据基本不具备可靠性。现代消息系统能支持消息在服务端的在线存储,功能上对应的就是『消息漫游』,消息漫游的好处是可以实现账号在任意端登陆查看所有历史消息。
  • 消息的检索:消息一般是文本,所以支持全文检索也是必备的能力之一。传统消息系统通常来说也是只能支持消息的本地检索,基于本地存储的消息数据来构建。而现在消息系统在能支持消息的在线存储后,也具备了消息的『在线检索』能力。

本篇文章内容主要涉及IM系统中的消息系统架构,会介绍一种基于阿里云表格存储Tablestore的Timeline模型构建的消息系统。基于Tablestore Timeline构建的现代消息系统,能够同时支持消息系统的众多高级特性,包括『多端同步』、『消息漫游』和『在线检索』。在性能和规模上,能够做到全量消息云端存储和索引,百万TPS写入以及毫秒级延迟的消息同步和检索能力。

之后我们会继续发表两篇文章,来更详细介绍Tablestore Timeline模型概念及使用:

  • 模型篇:详细介绍Tablestore Timeline模型的基本概念和基础数据结构,并结合IM系统进行基本的建模。
  • 实现篇:会基于Tablestore Timeline实现一个具备『多端同步』、『消息漫游』和『在线检索』这些高级功能的简易IM系统,并共享我们的源代码。

传统架构 vs 现代架构

传统架构下,消息是先同步后存储。对于在线的用户,消息会直接实时同步到在线的接收方,消息同步成功后,并不会在服务端持久化。而对于离线的用户或者消息无法实时同步成功时,消息会持久化到离线库,当接收方重新连接后,会从离线库拉取所有未读消息。当离线库中的消息成功同步到接收方后,消息会从离线库中删除。传统的消息系统,服务端的主要工作是维护发送方和接收方的连接状态,并提供在线消息同步和离线消息缓存的能力,保证消息一定能够从发送方传递到接收方。服务端不会对消息进行持久化,所以也无法支持消息漫游。消息的持久化存储及索引同样只能在接收端本地实现,数据可靠性极低。

现代架构下,消息是先存储后同步。先存储后同步的好处是,如果接收方确认接收到了消息,那这条消息一定是已经在云端保存了。并且消息会有两个库来保存,一个是消息存储库,用于全量保存所有会话的消息,主要用于支持消息漫游。另一个是消息同步库,主要用于接收方的多端同步。消息从发送方发出后,经过服务端转发,服务端会先将消息保存到消息存储库,后保存到消息同步库。完成消息的持久化保存后,对于在线的接收方,会直接选择在线推送。但在线推送并不是一个必须路径,只是一个更优的消息传递路径。对于在线推送失败或者离线的接收方,会有另外一个统一的消息同步方式。接收方会主动的向服务端拉取所有未同步消息,但接收方何时来同步以及会在哪些端来同步消息对服务端来说是未知的,所以要求服务端必须保存所有需要同步到接收方的消息,这是消息同步库的主要作用。对于新的同步设备,会有消息漫游的需求,这是消息存储库的主要作用,在消息存储库中,可以拉取任意会话的全量历史消息。消息检索的实现依赖于对消息存储库内消息的索引,通常是一个近实时(NRT,near real time)的索引构建过程,这个索引同样是在线的。

以上就是传统架构和现代架构的一个简单的对比,现代架构上整个消息的同步、存储和索引流程,并没有变复杂太多。现代架构的实现本质上是把传统架构内本地存储和索引都搬到云上,最大挑战是需要集中管理全量消息的存储和索引,带来的好处是能实现多端同步消息漫游以及在线检索。可以看到现代架构中最核心的就是两个消息库『消息同步库』和『消息存储库』,以及对『消息存储库』的『消息索引』的实现,接下来我们逐步拆解这几个核心的设计和实现。

基础模型

在深入讲解消息系统的设计和实现之前,需要对消息系统内的几个基本概念和基础模型有一个理解。网上分析的很多的不同类型的消息系统实现,实现差异上主要在消息同步和存储的方案上,在消息的数据模型上其实有很大的共性。围绕数据同步模型的讨论主要在『读扩散』、『写扩散』和『混合模式』这三种方案,目前还没有更多的选择。而对于数据模型的抽象,还没有一个标准的定义。

本章节会介绍下表格存储Tablestore提出的Timeline模型,这是一个对消息系统内消息模型的一个抽象,能简化和更好的让开发者理解消息系统内的消息同步和存储模型,基于此模型我们会再深入探讨消息的同步和存储的选择和实现。

Timeline模型

Timeline是一个对消息抽象的逻辑模型,该模型会帮助我们简化对消息同步和存储模型的理解,而消息同步库和存储库的设计和实现也是围绕Timeline的特性和需求来展开。

如图是Timeline模型的一个抽象表述,Timeline可以简单理解为是一个消息队列,但这个消息队列有如下特性:

  • 每条消息对应一个顺序ID:每个消息拥有一个唯一的顺序ID(SequenceId),队列消息按SequenceId排序。
  • 新消息写入能自动分配递增的顺序ID,保证永远插入队尾:Timeline中是根据同步位点也就是顺序ID来同步消息,所以需要保证新写入的消息数据的顺序ID绝对不能比已同步的消息的顺序ID还小,否则会导致数据漏同步,所以需要支持对新写入的数据自动分配比当前已存储的所有消息的顺序ID更大的顺序ID。
  • 新消息写入也能自定义顺序ID,满足自定义排序需求:上面提到的自动分配顺序ID,主要是为了满足消息同步的需求,消息同步要求消息是根据『已同步』或是『已写入』的顺序来排序。而消息的存储,通常要求消息能根据会话顺序来排序,会话顺序通常由端的会话来决定,而不是服务端的同步顺序来定,这是两种顺序要求。
  • 支持根据顺序ID的随机定位:可根据SequenceId随机定位到Timeline中的某个位置,从这个位置开始正序或逆序的读取消息,也可支持读取指定顺序ID的某条消息。
  • 支持对消息的自定义索引:消息体内数据根据业务不同会包含不同的字段,Timeline需要支持对不同字段的自定义索引,来支持对消息内容的全文索引,或者是任意字段的灵活条件组合查询。

消息同步可以基于Timeline很简单的实现,图中的例子中,消息发送方是A,消息接收方是B,同时B存在多个接收端,分别是B1、B2和B3。A向B发送消息,消息需要同步到B的多个端,待同步的消息通过一个Timeline来进行交换。A向B发送的所有消息,都会保存在这个Timeline中,B的每个接收端都是独立的从这个Timeline中拉取消息。每个接收端同步完毕后,都会在本地记录下最新同步到的消息的SequenceId,即最新的一个位点,作为下次消息同步的起始位点。服务端不会保存各个端的同步状态,各个端均可以在任意时间从任意点开始拉取消息。

消息存储也是基于Timeline实现,和消息同步唯一的区别是,消息存储要求服务端能够对Timeline内的所有数据进行持久化,并且消息采用会话顺序来保存,需要自定义顺序ID。

消息检索基于Timeline提供的消息索引来实现,能支持比较灵活的多字段索引,根据业务的不同可有自由度较高的定制。

消息存储模型

如图是基于Timeline的消息存储模型,消息存储要求每个会话都对应一个独立的Timeline。如图例子所示,A与B/C/D/E/F均发生了会话,每个会话对应一个独立的Timeline,每个Timeline内存有这个会话中的所有消息,消息根据会话顺序排序,服务端会对每个Timeline进行持久化存储,也就拥有了消息漫游的能力。

消息同步模型

消息同步模型会比消息存储模型稍复杂一些,消息的同步一般有读扩散(也叫拉模式)和写扩散(也叫推模式)两种不同的方式,分别对应不同的Timeline物理模型。

如图是读扩散和写扩散两种不同同步模式下对应的不同的Timeline模型,按图中的示例,A作为消息接收者,其与B/C/D/E/F发生了会话,每个会话中的新的消息都需要同步到A的某个端,看下读扩散和写扩散两种模式下消息如何做同步。

  • 读扩散:消息存储模型中,每个会话的Timeline中保存了这个会话的全量消息。读扩散的消息同步模式下,每个会话中产生的新的消息,只需要写一次到其用于存储的Timeline中,接收端从这个Timeline中拉取新的消息。优点是消息只需要写一次,相比写扩散的模式,能够大大降低消息写入次数,特别是在群消息这种场景下。但其缺点也比较明显,接收端去同步消息的逻辑会相对复杂和低效。接收端需要对每个会话都拉取一次才能获取全部消息,读被大大的放大,并且会产生很多无效的读,因为并不是每个会话都会有新消息产生。
  • 写扩散:写扩散的消息同步模式,需要有一个额外的Timeline来专门用于消息同步,通常是每个接收端都会拥有一个独立的同步Timeline(或者叫收件箱),用于存放需要向这个接收端同步的所有消息。每个会话中的消息,会产生多次写,除了写入用于消息存储的会话Timeline,还需要写入需要同步到的接收端的同步Timeline。在个人与个人的会话中,消息会被额外写两次,除了写入这个会话的存储Timeline,还需要写入参与这个会话的两个接收者的同步Timeline。而在群这个场景下,写入会被更加的放大,如果这个群拥有N个参与者,那每条消息都需要额外的写N次。写扩散同步模式的优点是,在接收端消息同步逻辑会非常简单,只需要从其同步Timeline中读取一次即可,大大降低了消息同步所需的读的压力。其缺点就是消息写入会被放大,特别是针对群这种场景。
    Timeline模型不会对选择读扩散还是写扩散做约束,而是能同时支持两种模式,因为本质上两种模式的逻辑数据模型并无差别,只是消息数据是用一个Timeline来支持多端读还是复制到多个Timeline来支持多端读的问题。

针对IM这种应用场景,消息系统通常会选择写扩散这种消息同步模式。IM场景下,一条消息只会产生一次,但是会被读取多次,是典型的读多写少的场景,消息的读写比例大概是10:1。若使用读扩散同步模式,整个系统的读写比例会被放大到100:1。一个优化的好的系统,必须从设计上去平衡这种读写压力,避免读或写任意一维触碰到天花板。所以IM系统这类场景下,通常会应用写扩散这种同步模式,来平衡读和写,将100:1的读写比例平衡到30:30。当然写扩散这种同步模式,还需要处理一些极端场景,例如万人大群。针对这种极端写扩散的场景,会退化到使用读扩散。一个简单的IM系统,通常会在产品层面限制这种大群的存在,而对于一个高级的IM系统,会采用读写扩散混合的同步模式,来满足这类产品的需求。采用混合模式,会根据数据的不同类型和不同的读写负载,来决定用写扩散还是读扩散。

典型架构设计

如图是一个典型的消息系统架构,架构中包含几个重要组件:

  • :作为消息的发送和接收端,通过连接消息服务器来发送和接收消息。
  • 消息服务器:一组无状态的服务器,可水平扩展,处理消息的发送和接收请求,连接后端消息系统。
  • 消息队列:新写入消息的缓冲队列,消息系统的前置消息存储,用于削峰填谷以及异步消费。
  • 消息处理:一组无状态的消费处理服务器,用于异步消费消息队列中的消息数据,处理消息的持久化和写扩散同步。
  • 消息存储和索引库:持久化存储消息,每个会话对应一个Timeline进行消息存储,存储的消息建立索引来实现消息检索。
  • 消息同步库:写扩散形式同步消息,每个用户的收件箱对应一个Timeline,同步库内消息不需要永久保存,通常对消息设定一个生命周期。
    新消息会由端发出,通常消息体中会携带消息ID(用于去重)、逻辑时间戳(用于排序)、消息类型(控制消息、图片消息或者文本消息等)、消息体等内容。消息会先写入消息队列,作为底层存储的一个临时缓冲区。消息队列中的消息会由消息处理服务器消费,可以允许乱序消费。消息处理服务器对消息先存储后同步,先写入发件箱Timeline(存储库),后写扩散至各个接收端的收件箱(同步库)。消息数据写入存储库后,会被近实时的构建索引,索引包括文本消息的全文索引以及多字段索引(发送方、消息类型等)。

对于在线的设备,可以由消息服务器主动推送至在线设备端。对于离线设备,登录后会主动向服务端同步消息。每个设备会在本地保留有最新一条消息的顺序ID,向服务端同步该顺序ID后的所有消息。

总结

本篇文章主要介绍了现代IM系统中消息系统所需要具备的能力,对比了传统架构和现代架构。为方便接下来的深入探讨,介绍了表格存储Tablestore推出的Timeline模型,以及在IM系统中消息存储和消息同步模型的基本概念和策略,最后介绍了一个典型的架构设计。

作者:木洛

原文链接

本文为云栖社区原创内容,未经允许不得转载。