微服务链路追踪SkyWalking第三课 OpenTracing和SPI机制,ES持久化

564 篇文章 136 订阅

第05讲:OpenTracing 简介,先有标准后有天

自从 Google Dapper 的论文发布之后,各大互联网公司和开源社区开发的分布式链路追踪产品百花齐放,同时也给使用者带来了一个问题,各个分布式链路追踪产品的 API 并不兼容,如果用户在各个产品之间进行切换,成本非常高。


而 OpenTracing 就完美的解决了这个问题,OpenTracing 通过提供平台无关、厂商无关的 API,帮助开发人员能够方便地添加(或更换)追踪系统。

Trace 简介

一个 Trace 代表一个事务、请求或是流程在分布式系统中的执行过程。OpenTracing 中的一条 Trace 被认为是一个由多个 Span 组成的有向无环图( DAG 图),一个 Span 代表系统中具有开始时间和执行时长的逻辑单元,Span 一般会有一个名称,一条 Trace 中 Span 是首尾连接的。



上图展示了分布式系统中一次客户端请求的全过程,虽然这种可视化图形对于查看各组件的组合关系是有用的,但是它不能很好显示组件的调用时间、先后关系、是串行还是并行等信息,如果想要展现更复杂的调用关系,该图会更加复杂。


如果将此次客户端请求的处理流程看作一条 Trace,其中每一次调用,无论是 HTTP 调用、RPC 调用、存储访问还是我们比较关注的本地方法调用,都可以成为一个 Span,通常如下图所示:



图中每个色块都是一个 Span,我们可以清晰的看到,请求在进入后端 load balancer 之后,首先会调用 authorization 服务处理,然后调用 billing 服务处理,最后执行 resource 服务,其中 container start-up 和 storage allocation 两步操作是并行执行的。

Span 简介

Span 代表系统中具有开始时间和执行时长的逻辑单元,Span 之间通过嵌套或者顺序排列建立逻辑因果关系。


每个 Span 中可以包含以下的信息:

  • 操作名称:例如访问的具体 RPC 服务,访问的 URL 地址等;

  • 起始时间

  • 结束时间

  • Span Tag:一组键值对构成的 Span 标签集合,其中键必须为字符串类型,值可以是字符串、bool 值或者数字;

  • Span Log:一组 Span 的日志集合;

  • SpanContext:Trace 的全局上下文信息;

  • References:Span 之间的引用关系,下面详细说明 Span 之间的引用关系;

在一个 Trace 中,一个 Span 可以和一个或者多个 Span 间存在因果关系。目前,OpenTracing 定义了 ChildOf 和 FollowsFrom 两种 Span 之间的引用关系。这两种引用类型代表了子节点和父节点间的直接因果关系。

  • ChildOf 关系一个 Span 可能是一个父级 Span 的孩子,即为 ChildOf 关系。下面这些情况会构成 ChildOf 关系:

    • 一个 HTTP 请求之中,被调用的服务端产生的 Span,与发起调用的客户端产生的 Span,就构成了 ChildOf 关系;

    • 一个 SQL Insert 操作的 Span,和 ORM 的 save 方法的 Span 构成 ChildOf 关系。


很明显,上述 ChildOf 关系中的父级 Span 都要等待子 Span 的返回,子 Span 的执行时间影响了其所在父级 Span 的执行时间,父级 Span 依赖子 Span 的执行结果。除了串行的任务之外,我们的逻辑中还有很多并行的任务,它们对应的 Span 也是并行的,这种情况下一个父级 Span 可以合并所有子 Span 的执行结果并等待所有并行子 Span 结束。


下图展示了上述两种 ChildOf 关系 的 Span:



  • FollowsFrom 关系在分布式系统中,一些上游系统(父节点)不以任何方式依赖下游系统(子节点)的执行结果,例如,上游系统通过消息队列向下游系统发送消息。这种情况下,下游系统对应的子 Span 和上游系统对应的父级 Span 之间是 FollowsFrom 关系。下图展示了一些可能的 FollowsFrom 关系:



下面的示例 Trace 是由 8 个 Span 组成,其中 Span A 和 Span C 之间是 ChildOf 关系,Span F 和 Span G 之间是 FollowsFrom 关系:


Logs 简介

每个 Span 可以进行多次 Logs 操作,每一次 Logs 操作,都需要带一个时间戳,以及一个可选的附加信息。在前文搭建的环境中,请求 http://localhost:8000/err 得到的 Trace 中就会通过 Logs 记录异常堆栈信息,如下图所示,其中不仅包括异常的堆栈信息,还包括了一些说明性的键值对信息:


Tags 简介

每个 Span 可以有多个键值对形式的 Tags,Tags 是没有时间戳的,只是为 Span 添加一些简单解释和补充信息。下图展示了前文示例中 Tags 的信息:


SpanContext 和 Baggage

SpanContext 表示进程边界,在跨进调用时需要将一些全局信息,例如,TraceId、当前 SpanId 等信息封装到 Baggage 中传递到另一个进程(下游系统)中。


Baggage 是存储在 SpanContext 中的一个键值对集合。它会在一条 Trace 中全局传输,该 Trace 中的所有 Span 都可以获取到其中的信息。


需要注意的是,由于 Baggage 需要跨进程全局传输,就会涉及相关数据的序列化和反序列化操作,如果在 Baggage 中存放过多的数据,就会导致序列化和反序列化操作耗时变长,使整个系统的 RPC 的延迟增加、吞吐量下降。


虽然 Baggage 与 Span Tags 一样,都是键值对集合,但两者最大区别在于 Span Tags 中的信息不会跨进程传输,而 Baggage 需要全局传输。因此,OpenTracing 要求实现提供 Inject 和 Extract 两种操作,SpanContext 可以通过 Inject 操作向 Baggage 中添加键值对数据,通过 Extract 从 Baggage 中获取键值对数据。

核心接口语义

OpenTracing 希望各个实现平台能够根据上述的核心概念来建模实现,不仅如此,OpenTracing 还提供了核心接口的描述,帮助开发人员更好的实现 OpenTracing 规范。

  • Span 接口

Span接口必须实现以下的功能:

    • 获取关联的 SpanContext:通过 Span 获取关联的 SpanContext 对象。

    • 关闭(Finish)Span:完成已经开始的 Span。

    • 添加 Span Tag:为 Span 添加 Tag 键值对。

    • 添加 Log:为 Span 增加一个 Log 事件。

    • 添加 Baggage Item:向 Baggage 中添加一组键值对。

    • 获取 Baggage Item:根据 Key 获取 Baggage 中的元素。

  • SpanContext 接口

SpanContext 接口必须实现以下功能,用户可以通过 Span 实例或者 Tracer 的 Extract 能力获取 SpanContext 接口实例。

    • 遍历 Baggage 中全部的 KV

  • Tracer 接口

Tracer 接口必须实现以下功能:

  • 创建 Span:创建新的 Span。

  • 注入 SpanContext:主要是将跨进程调用携带的 Baggage 数据记录到当前 SpanContext 中。

  • 提取 SpanContext ,主要是将当前 SpanContext 中的全局信息提取出来,封装成 Baggage 用于后续的跨进程调用。

总结

本课时主要介绍了 Trace、Span、Logs、Tags、SpanContext 等 OpenTracing 规范的核心概念,还介绍了 OpenTracing 规范定义的核心接口的功能。SkyWalking 作为 OpenTracing 的实现之一,了解 OpenTracing 的核心概念可以更好的帮助理解 SkyWalking 的实现。


第06讲:为何各大开源框架专宠 SPI 技术?

在此前的课时中,已经详细介绍了 SkyWalking Agent 用到的多种基础技术,例如,Byte Buddy、Java Agent 以及 OpenTracing 中的核心概念。本课时将深入介绍 SkyWalking Agent 以及 OAP 中都会使用到的 SPI 技术。

JDK SPI 机制

SPI(Service Provider Interface)主要是被框架开发人员使用的一种技术。例如,使用 Java 语言访问数据库时我们会使用到 java.sql.Driver 接口,每个数据库厂商使用的协议不同,提供的 java.sql.Driver 实现也不同,在开发 java.sql.Driver 接口时,开发人员并不清楚用户最终会使用哪个数据库,在这种情况下就可以使用 Java SPI 机制为 java.sql.Driver 接口寻找具体的实现。

当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。

下面通过一个简单的示例演示 JDK SPI 的基本使用方式,示例如下:

首先我们需要创建一个 Log 接口,来模拟日志打印的功能:

public interface Log {
    void log(String info);
}

接下来提供两个实现 —— Logback 和 Log4j,分别代表两个不同日志框架的实现,如下所示:

public class Logback implements Log {
    @Override
    public void log(String info) {
        System.out.println("Logback:" + info);
    }
}

public class Log4j implements Log {
    @Override
    public void log(String info) {
        System.out.println(“Log4j:” + info);
    }
}

在项目的 resources/META-INF/services 目录下添加一个名为 com.xxx.Log 的文件,这是 JDK SPI 需要读取的配置文件,具体内容如下:

com.xxx.impl.Log4j
com.xxx.impl.Logback

最后创建 main() 方法,其中会加载上述配置文件,创建全部 Log 接口实现的实例,并执行其 log() 方法,如下所示:

public class Main {
    public static void main(String[] args) {
        ServiceLoader<Log> serviceLoader = 
                ServiceLoader.load(Log.class);
        Iterator<Log> iterator = serviceLoader.iterator();
        while (iterator.hasNext()) {
            Log log = iterator.next();
            log.log("JDK SPI"); 
        }
    }
}
// 输出如下:
// Log4j:JDK SPI
// Logback:JDK SPI

JDK SPI源码分析

通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load()  方法,接下来我将对其具体实现进行深入分析。

在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader(获取当前线程绑定的 ClassLoader,查找失败后使用 SystemClassLoader),然后调用 reload() 方法,调用关系如下图所示:

在 reload() 方法中首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,该迭代器用于读取 SPI 配置文件并实例化实现类对象。

ServiceLoader.reload() 方法的具体实现,如下所示:

// 缓存,用来缓存 ServiceLoader创建的实现对象
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

public void reload() {
    providers.clear(); // 清空缓存
    lookupIterator = new LazyIterator(service, loader); // 迭代器
}

在前面的示例中,main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator  实现的。Iterator 接口有两个关键方法:hasNext() 方法和 next() 方法,这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法,hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示:

首先来看 LazyIterator.hasNextService() 方法,该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历,大致实现如下所示:

private static final String PREFIX = "META-INF/services/";
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs  null) {
        // PREFIX前缀与服务接口的名称拼接起来,就是META-INF目录下定义的SPI配
        // 置文件(即示例中的META-INF/services/com.xxx.Log)
        String fullName = PREFIX + service.getName();
        // 加载配置文件
        if (loader 
 null)
            configs = ClassLoader.getSystemResources(fullName);
        else
            configs = loader.getResources(fullName);
    }
    // 按行SPI遍历配置文件的内容
    while ((pending == null) || !pending.hasNext()) { 
        if (!configs.hasMoreElements()) {
            return false;
        }
        // 解析配置文件
        pending = parse(service, configs.nextElement()); 
    }
    nextName = pending.next(); // 更新 nextName字段
    return true;
}

接下来,在 hasNextService() 方法中完成 SPI 配置文件的解析之后,再来看 LazyIterator.nextService() 方法,该方法负责实例化 hasNextService() 方法读取到的实现类,其中会将实例化的对象放到 providers 集合中缓存起来,核心实现如下所示:

private S nextService() {
    String cn = nextName;
    nextName = null;
    // 加载 nextName字段指定的类
    Class<?> c = Class.forName(cn, false, loader);
    if (!service.isAssignableFrom(c)) { // 检测类型
        fail(service, "Provider " + cn  + " not a subtype");
    }
    S p = service.cast(c.newInstance()); // 创建实现类的对象
    providers.put(cn, p); // 将实现类名称以及相应实例对象添加到缓存
    return p;
}

在 main() 方法中使用的迭代器的底层实现介绍完了,我们再来看一下其使用的真正迭代器,核心实现如下:

public Iterator<S> iterator() {
    return new Iterator<S>() {
        // knownProviders用来迭代 providers缓存
        Iterator<Map.Entry<String,S>> knownProviders
            = providers.entrySet().iterator();

        public boolean hasNext() {
            // 先走查询缓存,缓存查询失败,再通过 LazyIterator加载
            if (knownProviders.hasNext()) 
                return true;
            return lookupIterator.hasNext();
        }

        public S next() {
            // 先走查询缓存,缓存查询失败,再通过 LazyIterator加载
            if (knownProviders.hasNext())
                return knownProviders.next().getValue();
            return lookupIterator.next();
        }
        // 省略remove()方法
    };

JDK SPI 在 JDBC 中的应用

了解了 JDK SPI 实现的原理之后,我们来看实践中 JDBC 是如何使用 JDK SPI 机制加载不同数据库厂商的实现类。

JDK 中只定义了一个 java.sql.Driver 接口,具体的实现是由不同数据库厂商来提供的。这里以 MySQL 提供的 JDBC 实现包为例进行分析。

在 mysql-connector-java-*.jar 包中的 META-INF/services 目录下,有一个 java.sql.Driver 文件中只有一行内容,如下所示:

com.mysql.cj.jdbc.Driver

在使用 mysql-connector-java-*.jar 包连接 MySQL 数据库的时候,我们会用到如下语句创建数据库连接:

String url = "jdbc:xxx://xxx:xxx/xxx";
Connection conn = DriverManager.getConnection(url, username, pwd);

DriverManager 是 JDK 提供的数据库驱动管理器,其中的代码片段,如下所示:

static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

在调用 getConnection() 方法的时候,DriverManager 类会被 Java 虚拟机加载、解析并触发 static 代码块的执行,在 loadInitialDrivers() 方法中通过 JDK SPI 扫描 Classpath 下  java.sql.Driver 接口实现类并实例化,核心实现如下所示:

private static void loadInitialDrivers() {
    String drivers = System.getProperty("jdbc.drivers")
    // 使用 JDK SPI机制加载所有 java.sql.Driver实现类
    ServiceLoader<Driver> loadedDrivers = 
           ServiceLoader.load(Driver.class);
    Iterator<Driver> driversIterator = loadedDrivers.iterator();
    while(driversIterator.hasNext()) {
        driversIterator.next();
    }
    String[] driversList = drivers.split(":");
    for (String aDriver : driversList) { // 初始化Driver实现类
        Class.forName(aDriver, true,
            ClassLoader.getSystemClassLoader());
    }
}

在 MySQL 提供的 com.mysql.cj.jdbc.Driver 实现类中,同样有一段 static 静态代码块,这段代码会创建一个 com.mysql.cj.jdbc.Driver 对象并注册到 DriverManager.registeredDrivers 集合中( CopyOnWriteArrayList 类型),如下所示:

static {
   java.sql.DriverManager.registerDriver(new Driver());
}

在 getConnection() 方法中,DriverManager 从该 registeredDrivers 集合中获取对应的 Driver 对象创建 Connection,核心实现如下所示:

private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
    // 省略 try/catch代码块以及权限处理逻辑
    for(DriverInfo aDriver : registeredDrivers) {
        Connection con = aDriver.driver.connect(url, info);
        return con;
    }
}

Dubbo 对 JDK SPI 的改进

通过前面的分析可以发现,JDK SPI 在查找具体实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类,而我们只需要其中一个实现类时,就会生成不必要的对象。

Dubbo 为了解决上述问题,自己设计了一套 SPI 实现,但是思想与 JDK SPI 机制类似。作为思路的扩展,这里简单介绍一下 Dubbo SPI 的实现原理(SkyWalking 使用是 JDK SPI 而不是 Dubbo SPI )。

首先,Dubbo 将 SPI 配置文件改成了 KV 格式,例如:

dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

其中 key 就是一个简单的标记,当我们在为一个接口查找具体实现类时,可以指定 key 来选择具体实现,例如,这里指定 key 为 dubbo,Dubbo SPI 就知道我们要的是:org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个实现类。

Dubbo SPI 核心实现是 ExtensionLoader(位于 dubbo-common 模块中的 extension 包中),功能类似于 JDK SPI 中的 java.util.ServiceLoader,其使用方式如下所示:

Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class)
      .getExtension("dubbo");
// 很明显,在查找 Protocol这个接口的实现类时,还指定了"dubbo"这个key

ExtensionLoader.getExtensionLoader() 方法会根据接口类型从缓存中查找相应的 ExtensionLoader 实现,核心实现如下:

private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> 
    EXTENSION_LOADERS = new ConcurrentHashMap<>();

public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
    ExtensionLoader<T> loader = 
         (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    if (loader == null) {
        EXTENSION_LOADERS.putIfAbsent(type,
               new ExtensionLoader<T>(type));
        loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    }
    return loader;
}

查找到接口对应的 ExtensionLoader 对象之后,会调用 getExtension() 方法,再根据传入的 key 查找相应的实现类,最终将其实例化后返回:

// 缓存,记录了 key到实现类对象Holder之间的映射关系
private final ConcurrentMap<String, Holder<Object>> cachedInstances = 
     new ConcurrentHashMap<>();

public T getExtension(String name) {
    Holder<Object> holder = getOrCreateHolder(name);
    Object instance = holder.get();
    if (instance  null) { // double-check防止并发问题
        synchronized (holder) {
            instance = holder.get();
            if (instance 
 null) {
                // createExtension()方法中完成了 SPI配置文件的查找以及实现类
                // 的实例化,具体实现与 JDK SPI原理类似,其中还会处理 Dubbo中
                // 自定义的一些注解,不再展开分析
                instance = createExtension(name);
                holder.set(instance);
            }
        }
    }
    return (T) instance;
}

总结

本课时首先介绍了 JDK SPI 机制的原理,并通过 Log 示例演示了 JDK SPI 的使用方式,然后深入到 ServiceLoader 的源码中分析了 JDK SPI 的实现方式,接下来介绍了 JDBC 4.0 如何使用 JDK SPI 机制加载数据库驱动类,最后介绍了 Dubbo 对 JDK SPI 的改进。


第07讲:Java High Level Client,读写 ES 利器

通过前面搭建 SkyWalking 的运行环境我们知道,SkyWalking OAP 后端可以使用多种存储对数据进行持久化,例如 MySQL、TiDB 等,默认使用 ElasticSearch 作为持久化存储,在后面的源码分析过程中也将以 ElasticSearch 作为主要存储进行分析。

ElasticSearch 基本概念

本课时将快速介绍一下 ElasticSearch 的基本概念,如果你没有用过 ElasticSearch ,可以通过本小节迅速了解 ElasticSearch 中涉及的基本概念。Elasticsearch 是一个基于 Apache Lucene 的开源搜索引擎,无论在开源还是专业领域,Apache Lucene 可以被认为是迄今为止最先进、性能最好、功能最全的搜索引擎库。但是,Apache Lucene 只是一个工具库,本身使用也比较复杂,直接使用 Apache Lucene 对开发人员的要求比较高,需要检索方面的知识来理解它是如何工作的。ElasticSearch 使用 Java 对 Apache Lucene 进行了封装,提供了简单易用的 RESTful API,隐藏 Apache Lucene 的复杂性,降低了全文检索的编程门槛。

ElasticSearch 中有几个比较核心的概念,为了方便你理解,我将其与数据库中的概念进行映射,如下图所示:

注意:在老版本的 ElasticSearch 中,Index 和 Document 之间还有个 Type 的概念,每个 Index 下可以建立多个 Type,Document 存储时需要指定 Index 和 Type。从 ES 6.0 版本开始单个 Index 中只能有一个 Type,ES 7.0 版本以后将不建议使用 Type,ES 8.0 以后完全不支持 Type。

Document 是构建 Index 的基本单元。例如,一条订单数据就可以是一个 Document,其中可以包含多个 Field,例如,订单的创建时间、价格、明细,等等。Document 以 JSON 格式表示,Field 则是这条 JSON 数据中的字段,如下所示:

{
  "_index""order_index",
  "_type""1",
  "_id""kg72dW8BOCMUWGurIFiy",
  "_version"1,
  "_score"1,
  "_source": {
    "create_time""2020-02-01 17:35:00",
    "creator""xxxxx",
    "order_status""NEW",
    "price"100.00,
  }
}

Index 是具有某些类似特征的 Document 的集合,Index 与 Document 之间的关系就类似于数据库中 Table 与 Row 之间的关系。在 Index 中可以存储任意数量的 Document。在后续介绍的示例中可以看到,对 Document 的添加、删除、更新、搜索等操作,都需要明确的指定 Index 名称。

最后,还需要了解 ElasticSearch 中一个叫作 Index Template(模板)的概念。Index Template 一般会包含 settings、mappings、index_patterns 、order、aliases 几部分:

  • index_patterns 负责匹配 Index 名称,Index Template 只会应用到名称与之匹配的 Index 上,而且 ElasticSearch 只会在 Index 创建的时候应用匹配的 Index Template,后续修改 Index Template 时不会影响已有的 Index。通过 index_patterns 匹配可以让多个 Index 重用一个 Index Template。
  • settings 主要用于设置 Index 中的一些相关配置信息,如分片数、副本数、refresh 间隔等信息(后面会介绍分片数和副本数的概念);
  • mappings 主要是一些说明信息,类似于定义该 Index 的 schema 信息,例如,指定每个 Field 字段的数据类型;>
  • order 主要作用于在多个 Index Template 同时匹配到一个 Index 的情况,如果此时这些Index Template 中的配置出现不一致,则以 order 的最大值为准,order 默认值为 0。另外,创建 Index 的命令中如果自带了 settings 或 mappings 配置,则其优先级最高;
  • aliases 则是为匹配的 Index 创建别名。我们可以通过请求 http://localhost:9200/_alias/*获取所有别名与 Index 之间的对应关系。

下面是 SkyWalking 使用的 segment 模板,它会匹配所有 segment-* 索引,segment-yyyyMMdd 索引是用来存储 Trace 数据的:

{
    "segment": {
        "order"0,
        "index_patterns": [
            "segment-*"
        ],
        "settings": {
            "index": {
                "refresh_interval""3s",
                "number_of_shards""2",
                "number_of_replicas""0"
                // 省略 analysis字段设置
            }
        },
        "mappings": {
            "type": {
                "properties": {
                    "segment_id": {
                        "type""keyword"
                    },
                    "trace_id": {
                        "type""keyword"
                    },
                    "service_id": {
                        "type""integer"
                    },
                    // 省略其他字段的设置
                }
            }
        },
        "aliases": { // 为匹配的Index创建别名
            "segment": {}
        }
    }
}

节点角色

一个 ElasticSearch 集群是由一个或多个节点组成,这些节点共同存储了集群中的所有数据,并且 ElasticSearch 提供了跨节点的联合索引和搜索功能。集群名称是一个 ElasticSearch 集群的唯一标识,在请求 ElasticSearch 集群时都需要使用到这个集群名称。在同一个网络环境中,需要保证集群名称不重复,否则集群中的节点可能会加入到错误的集群中。

ElasticSearch 集群是去中心化的,ElasticSearch 节点的相互发现是基于 Pull-Push 版本的 Gossip 算法实现的。Zen Discovery 是 ElasticSearch 默认的发现实现,提供了广播和单播的能力,帮助一个集群内的节点完成快速的相互发现。

ElasticSearch 集群中的节点有多个可选的角色,这些角色都是通过在节点的配置文件中配置的。

  • Master Eligible Node (候选主节点):可以被选举为 Master 的候选节点;
  • Master Node (主节点):完成节点发现阶段之后,才会进入主节点选举阶段,为了防止在网络分区的场景下出现脑裂问题,一般采用 quorum 版本的 Bully 算法变体(本课时重点是帮助你快速了解 ElasticSearch 基础知识,不展开该算法的具体原理)。所以,主节点是从候选主节点中选举出来的,主要负责管理 ElasticSearch 集群,通过广播的机制与其他节点维持关系,负责集群中的 DDL 操作(创建/删除索引),管理其他节点上的分片;
  • Data Node(数据节点):存放数据的节点,负责数据的增删改查;
  • Coordinating Node(协调节点):每个节点都是一个潜在的协调节点,协调节点最大的作用就是响应客户端的请求,将各个分片里的数据汇总起来一并返回给客户端,因此 ElasticSearch 的节点需要有足够的 CPU 和内存资源去处理汇总操作;
  • Ingest Node(提取节点):能执行预处理管道,不负责数据也不负责集群相关的事务。

分片&副本

在 ElasticSearch 中的一个 Index 可以存储海量的 Document,单台机器的磁盘大小是无法存储的,而且在进行数据检索的时候,单台机器也存在性能瓶颈,无法为海量数据提供高效的检索。

为了解决上述问题,ElasticSearch 将单个 Index 分割成多个分片,创建 Index 时,可以按照预估值指定任意数量的分片。虽然逻辑上每个分片都属于一个 Index,但是单个分片都是一个功能齐全且独立的 Index,一个分片可以被分配到集群中的任意节点上。

通过分片的功能,Index 就有了容量水平扩展的能力,运维人员可以通过添加节点的方式扩充整个集群的容量。在处理检索请求时,不同的分片由不同的 ElasticSearch 节点进行检索,可以实现并发操作,这样也就可以大大提高检索性能。

最后,某条 Document 数据具体存储在哪个分片,完全由 ElasticSearch 的分片机制决定。当写入一条 Document 的时候,ElasticSearch 会根据指定的 key (默认是 ElasticSearch 自动生成的 Id,用户也可以手动指定)决定其所在的分片编号,计算公式如下:

分片编号 = hash(key) % 主分片数量

主分片的数量决定了 Document 所在的分片编号,所以在创建 Index 之后,主分片数量不能改变。

在进行搜索时,每个分片产生的部分查询结果,也是由 ElasticSearch 集群负责进行聚合的,整个过程对于 Client 来说是透明的,如同操作一个单节点 ElasticSearch 实例。

单台服务器在实际使用中可能会因为这样或那样的原因发生故障,例如意外断电、系统崩溃、磁盘寿命到期等,这些故障是无法预知的。当发生故障时,该节点负责的分片就无法对外提供服务了,此时需要有一定的容错机制,在发生故障时保证此分片可以继续对外提供服务。

ElasticSearch 提供的副本功能就可以很好的解决这一问题,在副本模式下,每个分片分为主分片和副本分片,下图中一个 Index 有两个分片,p0 和 p1 是两个主分片,r0 和 r1 则是相应的副本分片:

副本带来了两个好处:一个是在主分片出现故障的时候,可以通过副本继续提供服务(所以,分片副本一般不与主分片分配到同一个节点上);另一个就是查询操作可以在分片副本上执行,因此可以提升整个 ElasticSearch 查询性能。

ElasticSearch 写入流程简介

分片是 ElasticSearch 中最小的数据分配单位,即一个分片总是作为一个整体被分配到集群中的某个节点。继续深入分片的结构会发现,一个分片是由多个 Segment 构成的,如下图所示:

Segment 是最小的数据存储单元,ElasticSearch 每隔一段时间会产生一个新的 Segment,用于写入最新的数据。旧的 Segment 是不可改变的,只能用于数据查询,是无法继续向其中写入数据的。

在很多分布式系统中都能看到类似的设计,这种设计有下面几点好处:

  • 旧 Segment 不支持修改,那么在读操作的时候就不需要加锁,省去了锁本身以及竞争锁相关的开销;
  • 只有最新的 Segment 支持写入,可以实现顺序写入的效果,增加写入性能;
  • 只有最新的 Segment 支持写入,可以更好的利用文件系统的 Cache 进行缓存,提高写入和查询性能。

介绍完分片内部的 Segment 结构之后,接下来简单介绍一下 ElasticSearch 集群处理一个写入请求的大致过程:

写入请求会首先发往协调节点(Coordinating Node),之前提到,协调节点可能是 Client 连接上的任意一个节点,协调节点根据 Document Id 找到对应的主分片所在的节点。

接下来,由主分片所在节点处理写入请求,先是写入 Transaction Log 【很多分布式系统都有 WAL (Write-ahead Log)的概念,可以防止数据丢失】,而后将数据写入内存中,默认情况下每隔一秒会同步到 FileSystem Cache 中,Cache 中的数据在后续查询中已经可以被查询了,默认情况下每隔 30s,会将 FileSystem cache 中的数据写入磁盘中,当然为了降低数据丢失的概率,可以将这个时间缩短,甚至设置成同步的形式,相应地,写入性能也会受到影响。

写入其他副本的方式与写入主分片的方式类似,不再重复。需要注意的是,这里可以设置三种副本写入策略:

  • quorum:默认为 quorum 策略,即超过半数副本写入成功之后,相应写入请求即可返回给客户端;
  • one :one 策略是只要成功写入一个副本,即可向客户端返回;
  • all:all 策略是要成功写入所有副本之后,才能向客户端返回。

ElasticSearch 的删除操作只是逻辑删除, 在每个 Segment 中都会维护一个 .del 文件,删除操作会将相应 Document 在 .del 文件中标记为已删除,查询时依然可以查到,但是会在结果中将这些"已删除"的 Document 过滤掉。

由于旧 Segment 文件无法修改,ElasticSearch 是无法直接进行修改的,而是引入了版本的概念,它会将旧版本的 Document 在 .del 文件中标记为已删除,而将新版本的 Document 索引到最新的 Segment 中。

另外,随着数据的不断写入,将产生很多小 Segment 文件,ElasticSearch 会定期进行 Segment Merge,从而减少碎片文件,降低文件打开数,提升 I/O 性能。在 Merge 过程中可以同时根据 .del 文件,将被标记的 Document 真正删除,此时才是真正的物理删除。

ElasticSearch 查询流程简介

读操作分为两个阶段:查询阶段和聚合提取阶段。在查询阶段中,协调节点接受到读请求,并将请求分配到相应的分片上(如果没有特殊指定,请求可能落到主分片,也有可能落到副本分片,由协调节点的负载均衡算法来确定)。默认情况下,每个分片会创建一个固定大小的优先级队列(其中只包含 Document Id 以及 Score,并不包含 Document 的具体内容),并以 Score 进行排序,返回给协调节点。如下图所示:

在聚合阶段中,协调节点会将拿到的全部优先级队列进行合并排序,然后再通过 Document ID 查询对应的 Document ,并将这些 Document 组装到队列里返回给客户端。

High Level REST Client 入门

ElasticSearch 提供了两种 Java Client,分别是 Low Level REST Client 和 High Level REST Client,两者底层都是通过 HTTP 接口与 ElasticSearch 进行交互的:

  • Low Level REST Client  需要使用方自己完成请求的序列化以及响应的反序列化;
  • High Level REST Client 是基于 Low Level REST Client 实现的,调用方直接使用特定的请求/响应对象即可完成数据的读写,完全屏蔽了底层协议的细节,无需再关心底层的序列化问题。另外, High Level REST Client 提供的 API 都会有同步和异步( async 开头)两个版本,其中同步方法直接返回相应的 response 对象,异步方法需要添加相应的 Listener 来监听并处理返回结果。

SkyWalking 中提供的 ElasticSearchClient 是对 High Level REST Client 的封装,本课时将简单介绍 High Level REST Client 的基本操作,你可以将本课时作为 High Level REST Client 的入门参考,更加完整的 API 使用可以参考 ElasticSearch  官方文档

使用 High Level REST Client 的第一步就是初始化 RestHighLevelClient 对象,该过程底层会初始化线程池以及网络请求所需的资源,类似于 JDBC 中的 Connection 对象,相关 API 代码如下:

RestHighLevelClient client = new RestHighLevelClient(
   RestClient.builder( // 指定 ElasticSearch 集群各个节点的地址和端口号
            new HttpHost("localhost"9200"http"),
            new HttpHost("localhost"9201"http")));

拿到 RestHighLevelClient 对象之后,我们就可以通过它发送 CreateIndexRequest 请求创建 Index,示例代码如下:

// 创建 CreateIndexRequest请求,该请求会创建一个名为"skywalking"的 Index,
// 注意,Index 的名称必须是小写
CreateIndexRequest request = new CreateIndexRequest("skywalking");
// 在 CreateIndexRequest请求中设置 Index的 setting信息
request.settings(Settings.builder()
        .put("index.number_of_shards"3)   // 设置分片数量
        .put("index.number_of_replicas"2)  // 设置副本数量
);
// 在 CreateIndexRequest请求中设置 Index的 Mapping信息,新建的 Index里有
// 个user和message两个字段,都为text类型,还有一个 age字段,为 integer类型
request.mapping("type""user""type=text""age""type=integer", 
   "message""type=text");
// 设置请求的超时时间
request.timeout(TimeValue.timeValueSeconds(5));
// 发送 CreateIndex请求
CreateIndexResponse response = client.indices().create(request);
// 这里关心 CreateIndexResponse响应的 isAcknowledged字段值
// 该字段为 true则表示 ElasticSearch已处理该请求
boolean acknowledged = response.isAcknowledged();
Assert.assertTrue(acknowledged);

完成 Index 的创建之后,我们就可以通过 IndexRequest 请求向其中写入 Document 数据了,需要使用 RestHighLevelClient 发送 IndexRequest 实现 Document 的写入:

// 创建 IndexRequest请求,这里需要指定 Index名称
IndexRequest request = new IndexRequest("skywalking"); 
request.type("type");
request.id("1");  // Document Id,不指定的话,ElasticSearch 为其自动分配
// Document的具体内容
String jsonString = "{" +
        "\"user\":\"kim\"," +
        "\"postDate\":\"2013-01-30\"," +
        "\"age\":29," +
        "\"message\":\"trying out Elasticsearch\"" +
        "}";
request.source(jsonString, XContentType.JSON);
// 发送 IndexRequest请求,不抛异常,就是创建成了
IndexResponse response = client.index(request);
System.out.println(response);
// 输出如下:
// IndexResponse[index=skywalking,type=type,id=1,version=1,
// result=created,seqNo=0,primaryTerm=1,shards=
// {"total":3,"successful":1,"failed":0}]
// -----------------------
// IndexResponse 中包含写入的 Index名称、Document Id,Document 版本,创建的
// result等重要信息

在明确知道 Document 的 Id 以及所属的 Index 时,我们可以通过 GetRequest 请求查询该 Document 内容,相关代码如下:

try {
    // 创建 GetRequest请求,这里指定 Index、type以及 Document Id,
    // 在高版本中,type参数已经消失
    GetRequest request = new GetRequest("skywalking""type""1");
    // 发送 GetRequest请求
    GetResponse response = client.get(request);
    // 从 GetResponse响应中可以拿到相应的 Document以及相关信息
    String index = response.getIndex(); // 获取 Index名称
    String id = response.getId();// 获取 Document Id
    if (response.isExists()) { // 检查 Document是否存在
        long version = response.getVersion(); // Document版本
        System.out.println("version:" + version);
        // 获取不同格式的 Document内容
        String sourceAsString = response.getSourceAsString();
        System.out.println("sourceAsString:" + sourceAsString);
        Map<String, Object> sourceAsMap = response.getSourceAsMap();
        System.out.println("sourceAsMap:" + sourceAsMap);
        byte[] sourceAsBytes = response.getSourceAsBytes();
        // 按照字段进行遍历
        Map<String, DocumentField> fields = response.getFields();
        for (Map.Entry<String, DocumentField> entry : 
                 fields.entrySet()) {
            DocumentField documentField = entry.getValue();
            String name = documentField.getName();
            Object value = documentField.getValue();
            System.out.println(name + ":" + value);
        }
    } else {
        System.out.println("Document Not Exist!");
    }
} catch (ElasticsearchParseException e) { // Index不存在的异常
    if (e.status() == RestStatus.NOT_FOUND) {
        System.err.println("Can find index");
    }
}
// 输出如下:
// version:1
// sourceAsString:{"user":"kim","postDate":"2013-01-30",
// "age":29,"message":"trying out Elasticsearch"}
// sourceAsMap:{postDate=2013-01-30, message=trying out Elasticsearch, 
// user=kim, age=29}
// _seq_no:0
// _primary_term:1

有时候,我们只想检测某个 Document 是否存在,并不想查询其具体内容,可以通过 exists() 方法实现。该方法发送的还是 GetRequest 请求,但我们可以指明此次请求不查询 Document 的具体内容,在 Document 较大的时候可以节省带宽,具体使用方式如下:

GetRequest request = new GetRequest("skywalking""type""1");
// 不查询 "_source"字段以及其他字段
request.fetchSourceContext(new FetchSourceContext(false));
request.storedFields("_none_");
// 通过exists()方法发送 GetRequest
boolean exists = client.exists(request);
Assert.assertTrue(exists);

删除一个 Document 的时候,使用的是 DeleteRequest,其中需要指定 Document Id 以及所属 Index 即可,使用方式如下:

DeleteRequest request = new DeleteRequest("skywalking""type""1");
DeleteResponse response = client.delete(request);
System.out.println(response);
// 输出:
// DeleteResponse[index=skywalking,type=type,id=1,version=5,
// result=deleted,shards=ShardInfo{total=3, successful=1,
// failures=[]}]

最后来看看更新操作,使用的是 UpdateRequest,示例如下:

XContentBuilder builder = XContentFactory.jsonBuilder();
builder.startObject();
{
    builder.field("age"30);
    builder.field("message""update Test");
}
builder.endObject();
// 创建 UpdateReques请求
UpdateRequest request = new UpdateRequest("skywalking", 
     "type","1").doc(builder);
// 发送请求
UpdateResponse updateResponse = client.update(request);
System.out.println(updateResponse);
// 输出:
// UpdateResponse[index=skywalking,type=type,id=1,version=2,seqNo=1,
// primaryTerm=1,result=updated,shards=ShardInfo{total=3, 
// successful=1, failures=[]}]
// 注意,更新之后 Document version 会增加

除了通过 GetRequest 请求根据 Document Id 进行查询之外,我们还可以通过 SearchRequest 请求进行检索,在检索请求里面我们可以通过 QueryBuilder 构造具体的检索条件,下面是一个简单的示例:

// 创建 SearchRequest请求
SearchRequest searchRequest = new SearchRequest("skywalking");
// 通过 SearchSourceBuilder,用来构造检索条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 通过id进行检索,这里查询 Id为1和2的两个Document
searchSourceBuilder.query(QueryBuilders.idsQuery().addIds("1""2"));
searchRequest.source(searchSourceBuilder);
// 发送 SearchRequest 请求
SearchResponse searchResponse = client.search(searchRequest);
// 遍历 SearchHit
SearchHit[] searchHits = searchResponse.getHits().getHits();
for (SearchHit searchHit : searchHits) {
    System.out.println(searchHit.getId() + ":" + 
       searchHit.getSourceAsMap());
}
// 输出:
// 1:{postDate=2013-01-30, message=update Test, user=kim, age=31}
// 2:{postDate=2020-01-30, message=Test Message, user=Tom, age=51}

按照 Document Id 进行查询之外,ElasticSearch 还提供了一套 Query DSL 语言帮助我们构造复杂的查询条件,查询条件主要分为下面两部分:

  • Leaf 子句:Leaf 子句一般是针对某个字段的查询,例如:
  • Compound 子句:Conpound 子句是由一个或多个 Leaf 子句或 Compound 子句构成的,例如 Bool Query,包含多个返回 Boolean 值的子句(可参考:Bool Query 的官方文档)。

下面使用 ElasticSearch Query DSL 实现一个复杂查询:

// 创建 SearchRequest请求,查询的Index为skywalking
SearchRequest searchRequest = new SearchRequest("skywalking");
// 通过 SearchSourceBuilder,用来构造检索条件
SearchSourceBuilder sourceBuilder = 
       SearchSourceBuilder.searchSource();
// 创建 BoolQueryBuilder
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 符合条件的 Document中 age字段的值必须位于[10,40]这个范围
List<QueryBuilder> mustQueryList = boolQueryBuilder.must();
mustQueryList.add(QueryBuilders.rangeQuery("age").gte(10));
mustQueryList.add(QueryBuilders.rangeQuery("age").lte(40));
// 符合条件的 Document中 user字段的值必须为kim
boolQueryBuilder.must().add(
    QueryBuilders.termQuery("user""kim"));
sourceBuilder.query(boolQueryBuilder);
searchRequest.source(sourceBuilder);
// 发送 SearchRequest 请求
SearchResponse searchResponse = client.search(searchRequest);
SearchHit[] searchHits = searchResponse.getHits().getHits();
for (SearchHit searchHit : searchHits) {
    System.out.println(searchHit.getId() + ":" + 
       searchHit.getSourceAsMap());
}
// 输出:
// 1:{postDate=2013-01-30, message=update Test, user=kim, age=31}

示例中匹配的 Document 中 age 字段的值位于[10,40]这个范围中,且 user 字段值为 "kim",类似于 SQL 语句中的【age >=10 and  age <=40 and user == "kim"】语句。

High Level REST Client 还提供了批量操作的 API —— BulkProcessor 会将多个请求积攒成一个 bulk,然后批量发给 ElasticSearch 集群进行处理。使用 BulkProcessor 批量操作可以减少请求发送次数,提高请求消息的有效负载,降低 ElasticSearch 集群压力。

BulkProcessor 中有几个核心参数需要设置。

  • setBulkActions() 方法:设置每个 BulkRequest  包含的请求数量,默认值为 1000;
  • setBulkSize():设置每个 BulkRequest 的大小,默认值为 5MB;
  • setFlushInterval():设置两次发送 BulkRequest 执行的时间间隔。没有默认值;
  • setConcurrentRequests():设置并发请求数。默认是 1,表示允许执行 1 个并发请求,积攒 BulkRequest 和发送 bulk 是异步的,其数值表示发送 bulk 的并发线程数(取值可以是任意大于 0 的数字),若设置为 0 表示二者同步;
  • setBackoffPolicy():设置最大重试次数和重试周期。默认最大重试次数为 8 次,初始延迟是 50 ms。

下面来看创建 BulkProcessor 的具体流程:

// 该 Listener可以监听每个 BulkRequest请求相关事件
BulkProcessor.Listener listener = new BulkProcessor.Listener() {...}
// 创建 BulkProcessor
BulkProcessor bulkProcessor = BulkProcessor.builder(client::bulkAsync,
         listener).setBulkActions(bulkActions) 
         .setBulkSize(new ByteSizeValue(bulkSize, ByteSizeUnit.MB))
         .setFlushInterval(TimeValue.timeValueSeconds(flushInterval))
         .setConcurrentRequests(concurrentRequests)
         .setBackoffPolicy(BackoffPolicy.exponentialBackoff(
            TimeValue.timeValueMillis(100), 3)).build();

这里添加的 BulkProcessor.Listener 实现如下,其中提供了三个方法分别监听 BulkRequest 请求触发的不同事件:

new BulkProcessor.Listener() {
   public void beforeBulk(long executionId, BulkRequest request) { 
      ... // 在 BulkRequest请求发送之前,会触发该方法
   }

   public void afterBulk(long executionId, BulkRequest request,
      BulkResponse response)
 

      … // 在收到 BulkResponse响应时触发该方法,这里可以通过 
          // response.hasFailures()方法判断请求是否失败
   } 

   public void afterBulk(long executionId, BulkRequest request,
        Throwable failure)
 

        … // 在 BulkRequest请求抛出异常的时候,会触发该方法
   } 
}

之后我们就可以通过 BulkProcessor.add() 方法向其中添加请求,这些请求最终会积攒成一个 BulkRequest 请求发出:

bulkProcessor.add(new IndexRequest(...));
bulkProcessor.add(new DeleteRequest(...));

一般情况下,我们只需要在全局维护一个 BulkProcessor 对象即可,在应用程序关闭之前需要通过 awaitClose() 方法等待全部请求发送出去后再关闭 BulkProcessor 对象。

总结

本课时首先介绍了 ElasticSearch 中的核心概念,为了便于理解,将这些概念与数据库进行了对比。接下来介绍了 ElasticSearch 集群中各个节点的角色,以及 ElasticSearch 引入分片和副本功能要解决的问题。之后对 ElasticSearch 读写数据的核心流程进行概述。最后,通过示例的方式介绍了 ElasticSearch High Level REST Client 的基本使用方式。在后续课时讲解 SkyWalking OAP 实现时,你会再次看到 High Level REST Client 的身影。


  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值