Tallate

该吃吃该喝喝 啥事别往心里搁

容器的原理:Namespace做隔离,Cgroups做限制,rootfs做文件系统。
k8s的主要作用是调度容器,管理容器的生命周期,在k8s中调度的最小单位是pod,除此之外还有service、cluster这些概念。

阅读全文 »

性能分析-CPU

性能指标

平均负载

平均负载是指单位时间内,处于可运行状态不可中断状态的平均进程数,也就是平均活跃进程数。所以,它不仅包括了正在使用 CPU 的进程,还包括等待 CPU 和等待 I/O 的进程,因此和CPU使用率也并没有直接关系。

  • 所谓可运行状态的进程,是指正在使用 CPU 或者正在等待 CPU 的进程,也就是我们常用 ps 命令看到的,处于 R 状态(Running 或 Runnable)的进程。
  • 不可中断状态的进程则是正处于内核态关键流程中的进程,并且这些流程是不可打断的,比如最常见的是等待硬件设备的 I/O 响应,也就是我们在 ps 命令中看到的 D 状态(Uninterruptible Sleep,也称为 Disk Sleep)的进程。

当平均负载为2时,意味着:

  • 在只有2个CPU的系统上,意味着所有CPU都刚好被完全占用;
  • 在4个CPU的系统上,意味着CPU有50%的空闲;
  • 在只有一个CPU的系统中,意味着有一半的进程竞争不到CPU。

平均负载为多少比较合适?

  • 先看系统有几个CPU,可以通过top命令或文件/proc/cpuinfo读取
  • 经验讲当平均负载高于CPU数量70%时,需要分析排查负载过高的问题
  • uptime查看负载变化趋势,把系统的平均负载监控起来,根据更多的历史数据来判断负载变化趋势,如果有明显升高趋势再做分析。

CPU使用率

CPU使用率的定义和计算

CPU 使用率,是单位时间内 CPU 繁忙情况的统计,跟平均负载并不一定完全对应。
比如:
CPU 密集型进程,使用大量 CPU 会导致平均负载升高,此时这两者是一致的;
I/O 密集型进程,等待 I/O 也会导致平均负载升高,但 CPU 使用率不一定很高;
大量等待 CPU 的进程调度也会导致平均负载升高,此时的 CPU 使用率也会比较高。

Linux 通过 /proc 虚拟文件系统,向用户空间提供了系统内部状态的信息,而 /proc/stat 提供的就是系统的 CPU 和任务统计信息:

1
2
3
4
$ cat /proc/stat | grep ^cpu
cpu 280580 7407 286084 172900810 83602 0 583 0 0 0
cpu0 144745 4181 176701 86423902 52076 0 301 0 0 0
cpu1 135834 3226 109383 86476907 31525 0 282 0 0 0
  • 第一行cpu指所有cpu的累加,后面的cpu0, cpu1表示各个cpu的统计
  • user(通常缩写为 us),代表用户态 CPU 时间。注意,它不包括下面的 nice 时间,但包括了 guest 时间。
  • nice(通常缩写为 ni),代表低优先级用户态 CPU 时间,也就是进程的 nice 值被调整为 1-19 之间时的 CPU 时间。这里注意,nice 可取值范围是 -20 到 19,数值越
  • 大,优先级反而越低。
  • system(通常缩写为sys),代表内核态 CPU 时间
  • idle(通常缩写为id),代表空闲时间。注意,它不包括等待 I/O 的时间(iowait)。
  • iowait(通常缩写为 wa),代表等待 I/O 的 CPU 时间。
  • irq(通常缩写为 hi),代表处理硬中断的 CPU 时间。
  • softirq(通常缩写为 si),代表处理软中断的 CPU 时间。
  • steal(通常缩写为 st),代表当系统运行在虚拟机中的时候,被其他虚拟机占用的 CPU 时间。
  • guest(通常缩写为 guest),代表通过虚拟化运行其他操作系统的时间,也就是运行虚拟机的 CPU 时间。
  • guest_nice(通常缩写为 gnice),代表以低优先级运行虚拟机的时间。

CPU使用率,就是除了空闲时间外的其他时间占总CPU时间的百分比,用公式来表示就是:

1
CPU使用率 = 1 - 空闲时间 / 总CPU时间

但是这个值是开机以来的CPU使用率,没什么参考价值,重要的是计算单位时间内的CPU使用率或简称平均CPU使用率

1
平均CPU使用率 = 1 - (空闲时间new - 空闲时间old) / (总CPU时间new - 总CPU时间old)

平均CPU使用率可以通过top、ps命令来查看。

造成CPU使用率过高的原因可能是:

  • 用户CPU过高
  • 系统CPU过高(如上下文切换)
  • 等待IO的CPU(如等待磁盘的响应)
  • 中断CPU(包括软中断和硬中断)

CPU上下文切换

CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
根据任务的不同,CPU 的上下文切换可以分为几个不同的场景,也就是进程上下文切换线程上下文切换以及中断上下文切换

进程上下文切换

根据Linux的特权等级分级:

  • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源;
  • 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过 系统调用 陷入到内核中,才能访问这些特权资源。
    需要注意的是,系统调用陷入内核态执行完毕后,还需要切换回用户态,此时其实又发生了一次上下文切换,所以一次系统调用伴随了2次的CPU上下文切换。

系统调用过程的CPU上下文切换:

  1. CPU 寄存器里原来用户态的指令位置,需要先保存起来。
  2. 接着,为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。
  3. 最后才是跳转到内核态运行内核任务。
  4. 系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。

    需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。

进程上下文切换:
进程上下文切换
进程的上下文切换就比系统调用时多了一步:在保存当前进程的内核状态和 CPU 寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。

  • 进程上下文切换,是指从一个进程切换到另一个进程运行。而系统调用过程中一直是同一个进程在运行。
  • 进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。

进程上下文潜在的性能问题:

  1. 频繁的上下文切换容易导致平均负载升高:每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。这个时间还是相当可观的,特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大缩短了真正运行进程的时间。这也正是上一节中我们所讲的,导致平均负载升高的一个重要因素。
  2. Linux 通过 TLB(Translation Lookaside Buffer)来管理虚拟内存到物理内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问也会随之变慢。特别是在多处理器系统上,缓存是被多个处理器共享的,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其他处理器的进程。

切换进程上下文的时机:
只有在进程调度的时候,才需要切换上下文。Linux 为每个 CPU 都维护了一个就绪队列,将活跃进程(即正在运行和正在等待 CPU 的进程)按照优先级和等待 CPU 的时间排序,然后选择最需要 CPU 的进程,也就是优先级最高和等待 CPU 时间最长的进程来运行。

  1. 进程执行完终止了,它之前使用的 CPU 会释放出来,这个时候再从就绪队列里,拿一个新的进程过来运行。
  2. 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。
  3. 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
  4. 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
  5. 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。
  6. 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

线程上下文切换

线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。

当进程只有一个线程时,可以认为进程就等于线程。
当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

因此,根据切换的多个线程所属的进程不同,有2种情况:

  1. 前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。
  2. 前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

    同进程内的线程切换消耗的资源更少。

中断上下文切换

为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。
跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

CPU瓶颈定位

1、平均负载
系统平均活跃进程数,反映了系统的整体负载情况
2、CPU使用率
根据CPU上运行任务的不同,CPU使用率可以分为:

  • 用户 CPU 使用率,包括用户态 CPU 使用率(user)和低优先级用户态 CPU 使用率(nice),表示 CPU 在用户态运行的时间百分比。用户 CPU 使用率高,通常说明有应用程序比较繁忙。
  • 系统 CPU 使用率,表示 CPU 在内核态运行的时间百分比(不包括中断)。系统 CPU 使用率高,说明内核比较繁忙。
  • 等待 I/O 的CPU使用率,通常也称为iowait,表示等待 I/O 的时间百分比。iowait 高,通常说明系统与硬件设备的 I/O 交互时间比较长。
  • 软中断和硬中断的 CPU 使用率,分别表示内核调用软中断处理程序、硬中断处理程序的时间百分比。它们的使用率高,通常说明系统发生了大量的中断。
  • 除了上面这些,还有在虚拟化环境中会用到的窃取 CPU 使用率(steal)和客户 CPU 使用率(guest),分别表示被其他虚拟机占用的 CPU 时间百分比,和运行客户虚拟机的 CPU 时间百分比。

3、进程上下文切换
过多的上下文切换会将原本运行进程的CPU时间,消耗在寄存器、内核栈以及虚拟内存等数据的保存和回复上,缩短进程真正运行的时间,成为性能瓶颈。包括:

  • 无法获取资源而导致的自愿上下文切换;
  • 被系统强制调度导致的非自愿上下文切换。

4、CPU缓存的命中率
包括L1、L2、L3等三级缓存。

性能剖析

uptime - 查看平均负载
平均负载最理想情况下等于CPU个数,超过时表示发生了过载,达到70%时就应该分析排查负载高的问题,。

watch - 可用于观察负载变化情况

top 或 读取/proc/cpuinfo - 查看系统有几个CPU

top - 查看CPU使用率

mpstat - 处理器统计

pidstat - 进程统计

-w 查看进程上下文切换情况

  • cswch 每秒自愿上下文切换(voluntary context switches)的次数
    是指进程无法获取所需资源,导致的上下文切换。比如说, I/O、内存等系统资源不足时,就会发生自愿上下文切换。
  • nvcswch 每秒非自愿上下文切换(non voluntary context switches)的次数。
    是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。比如说,大量进程都在争抢 CPU 时,就容易发生非自愿上下文切换。

stress - 系统压测

sysstat - Linux常用性能工具

sysstat包含mpstat、pidstat
mpstat 是一个常用的多核 CPU 性能分析工具,用来实时查看每个 CPU 的性能指标,以及所有 CPU 的平均指标。
pidstat 是一个常用的进程性能分析工具,用来实时查看进程的 CPU、内存、I/O 以及上下文切换等性能指标。

vmstat - 查看系统上下文切换情况

主要用来分析系统的内存使用情况,也常用来分析CPU上下文切换和中断的次数

  • cs(context switch)是每秒上下文切换的次数。
  • in(interrupt)则是每秒中断的次数。
  • r(Running or Runnable)是就绪队列的长度,也就是正在运行和等待CPU的进程数。
  • b(Blocked)则是处于不可中断睡眠状态的进程数。

vmstat主要用于看系统整体的上下文切换情况,如果想看每个进程的详细情况,可以用pidstat

top - 默认以3秒时间间隔统计CPU使用率

ps - 统计进程整个生命周期的CPU使用率

pidstat - 分析每个进程CPU使用情况

perf 分析性能问题 比如CPU使用率过高

perf top 实时显示占用CPU时钟最多的函数或指令,因此可以用来查找热点函数
perf record、perf report 保存性能分析结果及展示结果

Future - 好莱坞原则的应用

Future

FutureTask

并行化

刚开始接触并发编程,容易写出下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
long startTime = System.currentTimeMillis();
ExecutorService threadPool = Executors.newCachedThreadPool();
List<Future> futures = new ArrayList<>();
for (int i = 0; i < 3; i++) {
Future<?> future = threadPool.submit(() -> {
try {
// 假设这里是一个一直阻塞着的调用
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
futures.add(future);
}
futures.forEach(future -> {
try {
future.get(1000, TimeUnit.MILLISECONDS);
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println((System.currentTimeMillis() - startTime));

此时开发很有可能会误以为结果是 1s 左右,毕竟 3 个FutureTask同时执行、每个只等待 1s,结果确实应该是 1s,但是这里并没有实现并行化,因为future.get是轮询调用的,第一个执行完毕后,第二个仍然需要等待 1s,因此结果是 3s。
虽然每个 FutureTask 是同时开始执行的,但是future.get并不是同时开始等待的,如果想要达到并行执行的效果,一定是在上一个执行完毕的时候,下一个就已经执行完毕了,此时就可以直接获取结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
long startTime = System.currentTimeMillis();
ExecutorService threadPool = Executors.newCachedThreadPool();
ExecutorService innerThreadPool = Executors.newCachedThreadPool();
List<Future> futures = new ArrayList<>();
for (int i = 0; i < 3; i++) {
Future<?> future = threadPool.submit(() -> {
// 假设这里是一个一直阻塞着的调用
Future<?> innerFuture = innerThreadPool.submit(() -> {
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
try {
innerFuture.get(800, TimeUnit.MILLISECONDS);
} catch (Exception e) {
e.printStackTrace();
}
});
futures.add(future);
}
futures.forEach(future -> {
try {
future.get(1000, TimeUnit.MILLISECONDS);
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println((System.currentTimeMillis() - startTime));

阻塞还是轮询

阻塞虽然看起来很廉价,似乎线程阻塞后就不占用资源了,实际上很多阻塞都是通过轮询标志位实现的,比如 FutureTask 内部实现了这样的一种状态机:
FutureTask状态机
每次状态的变更都是通过CASUNSAFE)实现线程安全的,比如set方法:

1
2
3
4
5
6
7
protected void set(V v) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = v;
UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
finishCompletion();
}
}

Guava - ListenableFuture

ListenableFuture 是在 JDK1.8 之前出现的,现在 CompletableFuture 一般是更好的选择。

CompletionService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long start = System.currentTimeMillis();
CompletionService<String> cs = new ExecutorCompletionService<>(Executors.newFixedThreadPool(3));
cs.submit(() -> {
Thread.sleep(1000);
return "x";
});
cs.submit(() -> {
Thread.sleep(1000);
return "x";
});
for (int i = 0; i < 2; i++) {
String s = cs.take().get();
System.out.println(s);
}
System.out.println(System.currentTimeMillis() - start);
  • 将线程任务提交到线程池执行,并将 Future 提交到阻塞队列供客户端获取,阻塞队列默认是LinkedBlockingQueue
  • CompletionService 内部只有在任务完成的时候才会把 Future 丢到队列中,因此如果任务特别耗时,会导致take调用一直不返回,注意下面源码中的done方法:
    1
    2
    3
    4
    5
    6
    7
    8
    private class QueueingFuture extends FutureTask<Void> {
    QueueingFuture(RunnableFuture<V> task) {
    super(task, null);
    this.task = task;
    }
    protected void done() { completionQueue.add(task); }
    private final Future<V> task;
    }

CompletableFuture

CompletableFuture是对Future的加强,
CompletableFuture实现了 Future 接口,这意味着它本身也提供了通过阻塞或轮询获取结果的方式,相对 Future 来说,它的相对优势在于任务的编排,相对 CompletionService 来说,CompletableFuture又有接口更灵活的优势。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
long start = System.currentTimeMillis();
List<String> items = Lists.newArrayList("a", "b");
List<CompletableFuture<String>> futures = items.stream().map(
item -> CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return item + "x";
}))
.collect(Collectors.toList());
List<String> strs = futures.stream().map(CompletableFuture::join)
.collect(Collectors.toList());
strs.forEach(System.out::println);
System.out.println(System.currentTimeMillis() - start);

注意上面的CompletableFuture.supplyAsyncCompletableFuture.join

  1. 提交异步执行任务 CompletableFuture.supplyAsync,有2个方法,其中有个需要用户指定Executor,如果没有指定则使用JDK内置的线程池ForkJoinPool.commonPool()
    1. 提交任务到CompletableFuture和直接提交到ExecutorService的区别是线程任务提交前会用AsyncSupply封装
  2. 线程池执行任务
    1. 任务经过AsyncSupply封装,会调用重写的AsyncSupply.exec()
    2. 执行完毕后,回调CompletableFuture.internalComplete
  3. 任务结果聚合CompletableFuture::join
    1. 如果结果已经计算出来,则直接取result
    2. 否则CompletableFuture.waitingGet
      创建等待队列q = new WaitNode(interruptible, 0L, 0L);
      排队queued = UNSAFE.compareAndSwapObject(this, WAITERS, q.next = waiters, q);
      ForkJoinPool.managedBlock(q);阻塞,会调WaitNode.block()来判断是否还阻塞着
    3. 回头看CompletableFuture.internalComplete里的代码,会调LockSupport.unpark(t);唤醒等待的线程

参考

  1. [译]20 个使用 Java CompletableFuture 的例子
  2. Java CompletableFuture 详解

桐生枝梨子(高显秘书、相貌平平、认真、火灾毁容)
里中二郎(死者、高显亲生骨肉)
克子
一原高显(社长、癌症)
一原苍介(高显弟弟、大学教授)
一原直之(苍介弟弟、精明)
一原纪代美(苍介另一哥哥的太太)
由香(纪代美女儿)
一原曜子(苍介妹妹)
加奈江(曜子女儿)
本间重太郎(高显朋友)
本间菊代(重太郎太太、现假扮)
一原健彦(苍介儿子、喜欢由香)
小林真穗(店长、高显情人)
矢崎警部
古木律师
鲹泽弘美(古木助理)

尹之壹
火灾
车祸逃逸
高显遗嘱
七七法事
八泽温泉(墓地)
枝梨子遗书
由香的死:拿走信封、之后死了、倒着的N(俄语字母)、刀伤、勒脖
红酒
安眠药
审讯
直之的珍珠领带夹
私生子
脚印
纪代美的一对珍珠
头发
茶道
碎冰锥

Doc Values / fielddata - 正排索引

在搜索的时候,我们能通过搜索关键词快速得到结果集。当排序的时候,我们需要倒排索引里面某个字段值的集合,此时倒排索引无法发挥作用。换句话说,我们需要 转置 倒排索引。转置 结构在其他系统中经常被称作 列存储 。实质上,它将所有单字段的值存储在单数据列中,这使得对其进行操作是十分高效的,例如排序。
ES有2种方法实现:

  • Fielddata(可以存储Text类型)
  • Doc Values(列式存储,对Text类型无效)
Doc Values Field data
何时创建 索引时,和倒排索引一起创建 搜索时动态创建
创建位置 磁盘文件 JVM Heap
优点 避免大量内存占用 索引速度快,不占用额外的磁盘空间
缺点 降低索引速度,占用额外磁盘空间 文档过多时,动态创建开销大,占用过多JVM Heap
缺省值 ES 2.x 之后 ES 1.x 及之前

当 working set 远小于节点的可用内存,系统会自动将所有的文档值保存在内存中,使得其读写十分高速; 当其远大于可用内存,操作系统会自动把 Doc Values 加载到系统的页缓存中,从而避免了 jvm 堆内存溢出异常。

关闭 Doc Values

Doc Value默认是启用的,可以通过Mapping设置关闭

1
2
3
4
5
6
7
8
9
PUT test_keyword/_mapping
{
"properties": {
"user_name": {
"type": "keyword",
"doc_values": false
}
}
}
  • 关闭有什么好处?增加索引速度、减少磁盘占用空间
  • 关闭会有什么问题?如果后续需要重新打开,则需要重建索引
  • 什么时候需要关闭?明确不需要做排序及聚合分析

排序与相关性

  • sort将目标字段转换为排序所需的格式,date 字段的值表示为自 epoch (January 1, 1970 00:00:00 UTC)以来的毫秒数,通过 sort 字段的值进行返回。
  • 如果字段是一个数组(多值),可以使用 mode 指定其中 min 、 max 、 avg 或是 sum 进行排序;
  • 评分的计算方式取决于 查询类型 不同的查询语句用于不同的目的: fuzzy 查询会计算与关键词的拼写相似程度,terms 查询会计算 找到的内容与关键词组成部分匹配的百分比,但是通常我们说的 relevance 是我们用来计算全文本字段的值相对于全文本检索词相似程度的算法,Elasticsearch 的相似度算法 被定义为检索词频率/反向文档频率(TF/IDF),包括以下内容:
    • 检索词频率
        检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过 5 次要比只出现过 1 次的相关性高。
    • 反向文档频率
        每个检索词在索引中出现的频率?频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。
    • 字段长度准则
        字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段权重更大。
  • 单个查询可以联合使用 TF/IDF 和其他方式,比如短语查询中检索词的距离或模糊查询里的检索词相似度。如果多条查询子句被合并为一条复合查询语句 ,比如 bool 查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。
  • 字符串索引后(analusis)会有变化,排序时希望使用原字段(not_analyzed)进行排序,我们想要对同一个字段索引两次,而不是在_source 中保存两份字符串字段,这可以通过为字段添加一个 not_analyzed 子字段来实现:主字段用于搜索、子字段用于排序:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    "tweet": { 
    "type": "string",
    "analyzer": "english",
    "fields": {
    "raw": {
    "type": "string",
    "index": "not_analyzed"
    }
    }
    }
    "sort" : {
    "date": {
    "order": "desc",
    "mode": "min"
    }
    }
  • 默认情况下,返回结果是按相关性倒序排列的
  • 可以在查询参数中加入 explain 参数解释排序结果
    1
    2
    3
    4
    GET /_search?explain 
    {
    "query" : { "match" : { "tweet" : "honeymoon" }}
    }

分页

几种分页方式及应用场景

  • Regular
    平时查询ES只会返回头部的10条数据,一般用于实时获取顶部的部分文档,例如查询最新的订单。
  • Scroll
    需要全部文档时,例如导出全部数据
  • Pagination
    from + size的方式
    如果需要深度分页,则选用Search After

from 和 size(分页)、Search After

ES中的分页是从每个分片上获取from + size条数据,然后协调节点聚合所有结果,再选取前from + size条数据。
因为是from + size,所以from特别大时会有深分页问题
解决办法是Search After

1
2
3
4
5
6
7
8
9
10
11
12
POST users/_search
{
"size": 1,
"query": {
"match_all": {}
},
"search_after": [13, "idididid"],
"sort": [
{"age": "desc"},
{"_id": "asc"}
]
}

缺点是:

  • 不支持指定页数(From)
  • 只能往下翻

需要指定搜索sort:

  • 需要保证值是唯一的,可以加入_id保证唯一性
  • 每次查询使用上一次查询得到的最后一个文档的sort值进行查询(即上边的13和”idididid”)

Search After会通过唯一排序值定位,将每次要处理的文档数都控制在size个。

游标查询 Scroll

scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。
游标查询允许我们 先做查询初始化,然后再批量地拉取结果。 这有点儿像传统数据库中的 cursor
游标查询会取某个时间点的快照数据。 查询初始化之后索引上的任何变化会被它忽略。 它通过保存旧的数据文件来实现这个特性,结果就像保留初始化时的索引 视图 一样。
深度分页的代价根源是结果集全局排序,如果去掉全局排序的特性的话查询结果的成本就会很低。 游标查询用字段 _doc 来排序。 这个指令让 Elasticsearch 仅仅从还有结果的分片返回下一批结果。
启用游标查询可以通过在查询的时候设置参数 scroll 的值为我们期望的游标查询的过期时间。 游标查询的过期时间会在每次做查询的时候刷新,所以这个时间只需要足够处理当前批的结果就可以了,而不是处理查询结果的所有文档的所需时间。 这个过期时间的参数很重要,因为保持这个游标查询窗口需要消耗资源,所以我们期望如果不再需要维护这种资源就该早点儿释放掉。 设置这个超时能够让 Elasticsearch 在稍后空闲的时候自动释放这部分资源。

1
2
3
4
5
6
GET /old_index/_search?scroll=1m // 保持游标查询窗口一分钟。
{
"query": { "match_all": {}},
"sort" : ["_doc"], // 关键字 _doc 是最有效的排序顺序。
"size": 1000
}

这个查询的返回结果包括一个字段 _scroll_id, 它是一个 base64 编码的长字符串。现在我们能传递字段 _scroll_id_search/scroll 查询接口获取下一批结果:

1
2
3
4
5
GET /_search/scroll
{
"scroll": "1m", // 注意再次设置游标查询过期时间为一分钟。
"scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTsxMDk5NDpkUmpiR2FjOFNhNnlCM1ZDMWpWYnRROzEwOTk1OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MTA5OTM6ZFJqYkdhYzhTYTZ5QjNWQzFqVmJ0UTsxMTE5MDpBVUtwN2lxc1FLZV8yRGVjWlI2QUVBOzEwOTk2OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MDs="
}

这个游标查询返回的下一批结果。 尽管我们指定字段 size 的值为 1000,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为 size * number_of_primary_shards
当没有更多结果返回的时候,我们就处理完所有匹配的文档了。

缺点:

  • Scroll会创建一个快照,如果查询期间有新的数据写入以后,无法被查到

算分优化 - Function Score Query

算分函数

ES提供了几种默认的计算分值的函数:

weight

设置权重

field_value_factor

使用某个数值修改_score的值,比如乘以某个系数
原算分乘以某个字段得到最终结果,比如下面就是乘以原文档中的count字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET doc/_search
{
"query": {
"function_score": {
"query": {
"match": {
"title": "a"
}
},
"field_value_factor": {
"field": "count"
}
}
}
}

还可以根据某个函数来计算评分,比如如下命令新算分 = 老算分 * log(1 + factor * count)

1
2
3
4
5
6
7
8
9
10
11
12
13
GET doc/_search
{
"query": {
"function_score": {
...
"field_value_factor": {
"field": "count",
"modifier": "log1p",
"factor": 0.1
}
}
}
}

random_score

为每一个用户使用一个不同的,随机算分结果
使用场景:让每个用户能看到不同的随机排名,但是也希望同一个用户访问时,结果的相对顺序保持一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET doc/_search
{
"query": {
"function_score": {
"query": {
"match": {
"title": "a"
}
},
"random_score": {
"seed": 911119
}
}
}
}

衰减函数

以某个字段的值为标准,距离某个值越近,得分越高

script_score

自定义脚本完全控制所需逻辑
elasticsearch painless脚本评分
Elasticsearch中使用painless实现评分

boost

  • boost mode
    multiply:默认方式,算分与函数值的乘积
    sum:算分与函数的和
    min / max:算分与函数取最小/最大值
    replace:使用函数值取代算分
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    GET doc/_search
    {
    "query": {
    "function_score": {
    "query": {
    "match": {
    "title": "a"
    }
    },
    "field_value_factor": {
    "field": "count"
    },
    "boost_mode": "sum"
    }
    }
    }
  • max boost
    将算分控制在一个最大值

算分原理

计算目标文档和origin之间的距离

  • NumericFieldDataScoreFunction#distance
    数值距离=max(0, |doubleValue - origin| - offset)
  • GeoFieldDataScoreFunction#distance:
    地域距离

衰减函数

  • GaussDecayFunction
    高斯衰减=e^(distance^2 / 2scale)

为什么使用HBase

HBase是开源版的BigTable。

  • 高性能的列式存储
  • 高可靠的弹性伸缩

HBase VS RDBMS(传统关系数据库)

  • 数据类型
    RDBMS:关系模型,丰富的数据类型和存储方式
    HBase存的数据都是字符串,用户根据自己的需要解析字符串
  • 数据操作
    RDBMS:丰富的CRUD操作,多表连接
    HBase:不存在复杂的表与表之间的关系,只有简单的插入、查询、删除、清空等
  • 存储模式
    RDBMS:基于行模式存储,行被连续存储在磁盘页,在读取时需要顺序扫描行并筛选,如果每一行只有少量数据值对于查询是有用的,那么基于行模式的存储就会浪费许多磁盘空间和内存带宽;
    HBase:基于列存储,每个列族都由几个文件保存,不同列族的文件是分离的,可以支持更大并发的查询,因为仅需处理查询所需的列,而不需要像RDBMS那样处理整行;同一个列族的数据会被一起进行压缩,由于同一列族内的数据相似度较高,因此可以获得较高的压缩比。
  • 数据索引
    RDBMS会根据需要构建多个索引
    HBase只有一个索引:行键
  • 数据维护
    RDBMS更新后老数据会被替换
    HBase更新只会生成一个新版本,老版本数据仍然保留。
  • 可伸缩性
    RDBMS很难实现横向扩展。
    HBase可以灵活地水平扩展。
  • 事务
    HBase不支持事务,不能实现跨行更新的原子性。

使用HBase

  • Native Java API
  • HBase Shell
  • Thrift Gateway
  • REST Gateway
  • Pig
  • Hive

启动HBase

从官网下载HBase:https://hbase.apache.org/
注意兼容性:http://hbase.apache.org/book.html#hadoop
Hadoop安装后只包含HDFS和MapReduce,并不包含HBase,需要在Hadoop之上继续安装HBase。

编辑配置文件conf/hbase-site.xml,可以修改数据写入目录:

1
2
3
4
5
6
<configuration>
<property>
<name>hbase.rootdir</name>
<value>file:///DIRECTORY/hbase</value>
</property>
</configuration>

将 DIRECTORY 替换成期望写文件的目录. 默认 hbase.rootdir 是指向 /tmp/hbase-${user.name} ,重启时数据会丢失。
编辑环境变量conf/hbase-env.sh

1
2
3
4
# 如果JAVA_HOME已经有了就不用设置了
export JAVA_HOME=...
# 表示由hbase自己管理ZooKeeper,不需要单独的ZooKeeper
export HBASE_MANAGES_ZK=true

启动hbase:

1
./bin/start-hbase.sh

关闭hbase:

1
./bin/stop-hbase.sh

查看日志

如果启动失败后者后续的命令执行失败了,可以查看根目录下的日志:

1
vim logs/hbase-hgc-master-hgc-X555LD.log

Shell

使用shell连接HBase:

1
./bin/hbase shell
1
2
# 查看命令列表,要注意的是表名,行和列需要加引号
> help

create - 创建表、列族:

1
2
3
4
5
6
7
8
9
10
11
# 创建表
> create 'test', 't1'
# 创建表t1,列族为f1,列族版本号为5
> create 't1', {NAME => 'f1', VERSIONS => 5}
# 创建表t1,有3个列族分别为f1、f2、f3
> create 't1', {NAME => 'f1'}, {NAME => 'f2'}, {NAME => 'f3'}
> create 't1', 'f1', 'f2', 'f3'
# 创建表t1,根据分割算法HexStringSplit分布在15个Region里
> create 't1', 'f1', {NUMREGIONS => 15, SPLITALGO => 'HexStringSplit'}
# 创建表t1,指定切分点
> create 't1', 'f1', {SPLIT => ['10', '20', '30', '40']}

list - 查询表信息

1
> list

put - 向表、行、列指定的单元格添加数据:

1
2
# 向表t1中的行row1和列f1:c1所对应的单元格中添加数据value1,时间戳为1421822284898
> put 't1', 'row1', 'f1:c1', 'value1', 1421822284898

get - 获取单元格数据

1
2
3
4
# 从表t1获取数据,行row1、列f1,时间范围为TIMERANGE,版本号为1的数据
> get 't1', 'row1', {COLUMN => 'f1:c1', TIMERANGE => [0, 1421822284900], VERSIONS => 1}
# 获取表t1、行row1、列f1上的数据
> get 't1', 'row1', 'f1'

Java API

原理

数据模型


  • HBase使用表来组织数据,表由行和列组成,列又划分为若干列族。

  • 每个表由若干行组成,每个行由行键标识。
    访问表中的行只能通过:单个行键、行键区间、全表扫描实现。
  • 列族
    列族是基本的访问控制单元。
    列族里的数据通过列限定符来定位。
  • 单元格
    在表中,可以通过行、列族和列限定符确定一个单元格Cell
    单元格中存储的数据没有数据类型,总是被视为字节数组。
    每个单元格中可以保存一个数据的多个版本,每个版本对应一个不同的时间戳。
  • 时间戳
    单元格中的数据通过时间戳进行索引,每次对一个单元格执行增删改操作都会隐式生成并存储一个时间戳。
  • 数据坐标
    HBase中可以根据<行键, 列族, 列限定符, 时间戳>的四元组来确定一条数据。

面向列存储

  • 行式数据库
    行式存储将每一行连续地存储在磁盘页中,要找一行数据就要连续地扫描磁盘。
    如果每行只有少量属性的值对查询有用,那么行式存储就会浪费非常多的磁盘空间和内存带宽。
    行式数据库主要适合于小批量的数据处理,如联机事务型数据处理,常见实现如MySQL。
  • 列式数据库
    以列为单位进行存储,关系中多个元组的同一列值会被存储到一起,而同一个元组中不同列则通常会被分别存储到不同的磁盘页中。
    列式数据库主要适用于批量数据处理Ad-Hoc Query,优点是可以降低IO开销,支持大量并发用户查询,因为仅需要处理可以回答这些查询的列,而不是分类整理与特定查询无关的数据行;具有较高的数据压缩比。
    列式数据库主要用于数据挖掘、决策支持和地理信息系统等查询密集型系统中,因为一次查询就可以得出结果,而不必每次都要遍历所有的数据库。
    缺点1:连接操作效率低,执行连接操作时需要昂贵的元组重构代价,因为一个元组的不同属性被分散到不同磁盘页中存储,当需要一个完整的元组时,就要从多个磁盘页中读取相应字段的值来重新组合得到原来的一个元组。
    缺点2:不适合频繁更新同一行元组的场景,理由同上,因为一个元组的不同属性分散到不同的磁盘页,因此写操作频繁会导致不能很好命中缓冲。因此HBase更适合数据被存储后不会发生修改的场景。

HBase架构

HBase系统架构

HBase的实现包含3个主要的功能组件:

  • 一个Master主服务器
    Master负责管理HBase表的分区(Region)信息
    比如一个表包含哪些Region,这些Region被划分到哪台Region服务器上。
    同时也负责维护Region服务器列表,实时监测集群中的Region服务器,把特定的Region分配到可用的Region服务器上,并确保整个集群内部不同Region服务器之间的负载均衡。
    负责Region集群的故障转移,当某个Region服务器因出现故障而失效时,Master会把故障服务器上存储的Region重新分配给其他可用的Region服务器。
    负责模式变化,如表和列族的创建。
  • 许多个Region服务器
    Region服务器负责存储和维护分配给自己的Region,处理来自客户端的读写请求。
  • 库函数
    链接到每个客户端
    客户端并不是直接从Master上读取数据,而是先获取Region的存储位置后再直接从Region服务器上读取数据。而且需要注意的是客户端不直接和Master交互,而是从ZooKeeper上获取Region信息,这可以保证Master的负载尽可能小。

客户端

客户端会访问HBase的服务端接口,并缓存已经访问过的Region位置信息,用来提高后续访问数据的速度。

ZooKeeper服务器

  • Master将Region服务器的状态注册到ZooKeeper
  • Master选举。
  • 保存-ROOT-表和Master的地址,然后客户端可以根据-ROOT-表来一级一级找到所需的数据

Master服务器

  • 管理对表的CRUD操作
  • 实现不同Region之间的负载均衡
  • 在Region分裂或合并后,重新调整Region的分布
  • 将发生故障失效的Region迁移到其他Region服务器

Region服务器

Region服务器的主要职责:

  • 维护分配给自己的Region
  • 响应用户的读写请求

Region一般采用HDFS作为底层文件存储系统,并依赖HDFS来实现数据复制维护数据副本的功能。

Region的定位 - 如何找到一个Region

每个Region都有一个RegionID来标识它的唯一性,要定位一个Region可以使用<表名, 开始主键, RegionID>的三元组。
HBase还会维护一张<Region标识符, Region服务器>的映射表,被称为元数据表,又名 .META.表
如果一个HBase表中的Region特别多,一个服务器存不下.META.表,则.META.表也会被分区存储到不同的服务器上,并用一张根数据表来维护所有元数据的具体位置,又名 -ROOT-表,-ROOT-表是不能被分割的,永远只会被存储到一个唯一的Region中。

HBase元数据的三层结构

Region与行

HBase中的行是根据行键的字典序进行维护的,表中包含的行的数量可能非常大,需要通过行键对表中的行进行分区(Region)。
Region包含了位于某个值区间内的所有数据,它是负载均衡数据分发的基本单位,这些Region会被Master分发到不同的Region服务器上。
每个Region的默认大小是100MB200MB,当一个Region包含的数据达到一个阈值时,会被自动分裂成两个新的Region,通常一个Region服务器上会放置101000个Region。

Region服务器的存储结构

Region服务器内部维护了一系列Region对象和一个HLog文件

  • 每个Region由多个Store组成,每个Store对应了表中的一个列族的存储。
    每个Store又包含一个MemStore和若干StoreFile,前者是内存缓存,后者是磁盘文件,使用B树结构组织,底层实现方式是HDFS的HFile(会对内容进行压缩)。

  • HLog是磁盘上的记录文件,记录着所有的更新操作。

  • 每个Store对应了表中一个一个列族,包含了一个MemStore若干个StoreFile
    其中,MemStore是在内存中的缓存,保存最近更新的数据;StoreFile由HDFS的HFile实现,底层是磁盘中的文件,这些文件都是B树结构,方便快速读取,而且HFile的数据块通常采用压缩方式存储,可以大大减少网络和磁盘IO。
    Region存储结构

流程 - 用户读写数据

  • 写入流程
    用户写入 -> 路由Region服务器 -> HLog -> MemStore -> commit()返回给客户端

    HLog是WAL(Write Ahead Log),因此在MemStore之前写入

  • 读取流程
    用户读取 -> 路由Region服务器 -> MemStore -> StoreFile

流程 - 缓存刷新

  • 周期性刷新
    周期性调用Region.flushcache() -> 将MemStore缓存中的内容写到磁盘StoreFile中 -> 清空缓存 -> 在HLog中写入一个标记表示缓存已刷到StoreFile
    每次缓存刷新都会在磁盘上生成一个新的StoreFile文件,因此每个Store会包含多个StoreFile文件
  • 启动刷新
    启动时检查HLog -> 确认最后一次刷新后是否还有发生写入 -> 如果有发生则将这些更新写入MemStore -> 刷新缓存写入到StoreFile -> 删除旧的HLog文件 -> 开始为用户提供数据访问服务

    如果最后一次刷新后没有新数据,说明所有数据已经被永久保存。

流程 - StoreFile合并

如《缓存刷新》流程所述,每次MemStore刷新都会在磁盘上生成一个新的StoreFile,这样系统中每个Store都会有多个StoreFile,要找到Store中某个值就必须查找所有这些StoreFile文件,非常耗时。
因此,为了减少耗时,系统会调用Store.compact()把多个StoreFile合并成一个大文件。
这个合并操作比较耗费资源,因此只会在StoreFile文件的数量达到一个阈值时才会触发合并操作。

Store的工作原理

如《Region存储结构》所示,Region服务器是HBase的核心模块,而Store是Region服务器的核心,每个Store对应了表中的一个列族的存储,每个Store包含一个MemStore缓存和若干个StoreFile文件。

  • 写入数据优先写入MemStore,写满时刷新到StoreFile
  • 随着StoreFile数量不断增加,达到阈值时触发文件合并操作
  • 当StoreFile文件越来越大,达到阈值时,会触发文件分裂操作,同时当前的一个父Region会被分裂成2个子Region,父Region会下线,新分裂出的2个子Region会被Master分配到相应的Region服务器上。

StoreFile的合并和分裂

HLog的工作原理

在分布式环境下,系统出错可能导致数据丢失,比如Region故障导致MemStore缓存中的数据被清空了。HBase采用HLog来保证系统故障时的恢复。

  • HLog是每个Region服务器仅配置一个
    一个Region服务器包含多个Region,这些一台Region服务器上的Region会共用一个HLog
    这样做的好处是:一台Region服务器不需要打开多个日志文件,减少磁盘寻址次数,提高写操作性能。
    这样的坏处是:如果一个Region服务器发生故障,为了恢复其上的Region对象,需要按所属Region对HLog进行拆分,然后分发到其他Region服务器上执行恢复操作。
  • HLog是WAL(Write Ahead Log)
    用户数据需要先写HLog才能写入MemStore,并且直到MemStore缓存内容对应的日志已经被写入磁盘,该缓存内容才会被刷新到磁盘。
  • 数据恢复
    ZooKeeper会实时监测每个Region服务器的状态,当某个Region服务器发生故障,ZooKeeper会通知Master。
    Master会处理该故障服务器上的HLog文件,注意HLog会包含来自多个Region对象的日志记录,系统会根据每条日志所属的Region对象对HLog数据进行拆分,分别放到对应Region对象的目录下,然后再将失效的Region重新分配到可用的Region服务器中,并把与该Region对象相关的HLog日志记录也发送给相应的Region服务器。
    Region服务器领取到分配给自己的Region对象以及与之相关的HLog日志记录以后,会重演一遍日志记录中的操作,把日志记录中的数据写入MemStore缓存,然后刷新到磁盘的StoreFile文件中,完成数据恢复。
0%