BlankLin

lazy and boring

0%

什么是物化视图

  • 普通视图(View)是从一张或者多张数据库表查询导出的虚拟表,可以反映出基础表之间的数据变化,但是本身是不存储数据的,每次的查询都会从基础表重新聚合出查询结果,所以普通视图查询其实等同于创建视图时的查询语句的查询效率。
  • 物化视图(Materialized View)是查询结果集的一份持久化存储,也称为底表的快照(snapshot),查询结果集的范围很宽泛,可以是基础表中部分数据的一份简单拷贝,也可以是多表join之后产生的结果或其子集,或者原始数据的聚合指标等等。物化视图不会随着基础表的变化而变化,如果要更新数据的话,需要用户手动进行,如周期性执行SQL,或利用触发器等机制。

avatar

如何创建物化视图

MergeTree排序引擎

ReplacingMergeTree去重引擎

AggregatingMergeTree聚合引擎

UniqueMergeTree 实时去重引擎

SummingMergeTree 求和引擎

CollapsingMergeTree 折叠引擎

VersionedCollapsingMergeTree 版本折叠引擎

如何写入物化视图

底表触发

同步任务触发

直接写入到分布式表

总结

两种思路

  • 后端服务主动淘汰空闲超时120s的连接,然后再主动创建连接。
  • 后端服务对空闲连接保活,周期小于120秒进行一次mysql.ping或select 1操作。(确认过dba两个操作均可使dbproxy保活连接)

hikari连接池能否支持上述方案:

  • idleTimeout:设置最大空闲时间小于120秒,空闲超时主动淘汰链接,并且hikari会自动创建新连接填充。实际发现该参数不能解决此问题,因为该参数优先级低于minimumIdle,若池中已经保持低于minimumIdle的连接数就不会再进行空闲连接淘汰。所以该参数不能解决问题。
  • maxLifetime:设置最大生存时间小于120秒,生存超时主动淘汰连接,并且hikari会自动创建新连接填充。能够解决该问题,但是会每120s就要淘汰且新建连接。

druid连接池能否支持上述方案:

  • testWhileIdle:设置true开启周期性健康检查 会主动淘汰掉失效的连接。
  • maxEvictableIdleTimeMillis:最大空闲时间设置小于120s,该参数不受minIdle参数约束,只要超过最大空闲时间就会自动淘汰连接。
    看似上面两个参数均能解决,但是由于durid默认不会主动创建新链接,所以上面两个参数使连接清空后 查询时仍旧会因创建连接而耗时增加。
  • keepAlive:设置true开启保活机制,可解决该问题
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
druid:
# 初始化大小
initialSize: 10
# 最大连接数
maxActive: 30
# 最小空闲数(周期性清除时保留的最小连接数)
minIdle: 10
# 最大空闲数(返还连接时若超过最大空闲连接数就丢弃)
maxIdle: 20
# 有效性校验
validationQuery: select 1
# 周期性check空闲连接
testWhileIdle: true
# 借出时check(影响性能)
testOnBorrow: false
# 返还时check(影响性能)
testOnReturn: false


# 周期性检查的间隔时间,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 连接的最小空闲时间,小于该时间不会被周期性check清除。单位是毫秒
minEvictableIdleTimeMillis: 50000
# 连接的最大空闲时间,大于该时间会被周期性check清除,且优先级大于minIdle。单位是毫秒
maxEvictableIdleTimeMillis: 100000
# 开启连接保活策略。作用:https://www.bookstack.cn/read/Druid/d90f9643acdca5c0.md
keepAlive: true

链接

TCP标志

SYN: (同步序列编号,Synchronize Sequence Numbers)该标志仅在三次握手建立TCP连接时有效。表示一个新的TCP连接请求。

ACK: (确认编号,Acknowledgement Number)是对TCP请求的确认标志,同时提示对端系统已经成功接收所有数据。

FIN: (结束标志,FINish)用来结束一个TCP回话.但对应端口仍处于开放状态,准备接收后续数据。

TCP状态

TCP状态在系统里都有对应的解释或设置,可见man tcp,以下介绍主要常见的几种

1)、LISTEN: 首先服务端需要打开一个socket进行监听,状态为LISTEN.
/ The socket is listening for incoming connections. 侦听来自远方TCP端口的连接请求 /

2)、SYN_SENT: 客户端通过应用程序调用connect进行active open.于是客户端tcp发送一个SYN以请求建立一个连接.之后状态置为SYN_SENT.
/The socket is actively attempting to establish a connection. 在发送连接请求后等待匹配的连接请求 /

3)、SYN_RECV: 服务端应发出ACK确认客户端的SYN,同时自己向客户端发送一个SYN. 之后状态置为SYN_RECV
/ A connection request has been received from the network. 在收到和发送一个连接请求后等待对连接请求的确认 /

4)、ESTABLISHED: 代表一个打开的连接,双方可以进行或已经在数据交互了。
/ The socket has an established connection. 代表一个打开的连接,数据可以传送给用户 /

5)、FIN_WAIT1:主动关闭(active close)端应用程序调用close,于是其TCP发出FIN请求主动关闭连接,之后进入FIN_WAIT1状态.
/ The socket is closed, and the connection is shutting down. 等待远程TCP的连接中断请求,或先前的连接中断请求的确认 /

6)、CLOSE_WAIT: 被动关闭(passive close)端TCP接到FIN后,就发出ACK以回应FIN请求(它的接收也作为文件结束符传递给上层应用程序),并进入CLOSE_WAIT.
/ The remote end has shut down, waiting for the socket to close. 等待从本地用户发来的连接中断请求 /

7)、FIN_WAIT2: 主动关闭端接到ACK后,就进入了FIN-WAIT-2 .
/ Connection is closed, and the socket is waiting for a shutdown from the remote end. 从远程TCP等待连接中断请求 /

8)、LAST_ACK: 被动关闭端一段时间后,接收到文件结束符的应用程序将调用CLOSE关闭连接。这导致它的TCP也发送一个 FIN,等待对方的ACK.就进入了LAST-ACK .
/ The remote end has shut down, and the socket is closed. Waiting for acknowledgement. 等待原来发向远程TCP的连接中断请求的确认 /

9)、TIME_WAIT: 只发生在主动关闭连接的一方。主动关闭方在接收到被动关闭方的FIN请求后,发送成功给对方一个ACK后,将自己的状态由FIN_WAIT2修改为TIME_WAIT,而必须再等2倍 的MSL(Maximum Segment Lifetime,MSL是一个数据报在internetwork中能存在的时间)时间之后双方才能把状态 都改为CLOSED以关闭连接。目前RHEL里保持TIME_WAIT状态的时间为60秒。
/ The socket is waiting after close to handle packets still in the network.等待足够的时间以确保远程TCP接收到连接中断请求的确认 /

10)、CLOSING: 比较少见.
/ Both sockets are shut down but we still don’t have all our data sent. 等待远程TCP对连接中断的确认 /

11)、CLOSED: 被动关闭端在接受到ACK包后,就进入了closed的状态。连接结束.
/ The socket is not being used. 没有任何连接状态 /

为什么创建的连接需要关闭?

如果不关闭连接,这些系统资源将一直在内存中,等待下一次系统gc时才会被回收,而如果系统频繁的fgc将会导致你的程序明显卡顿,如果一直没有gc,也会导致你的内存泄漏

如果一定要关闭,那是不是可以使用单例模式在所有地方使用?

是不是可以使用连接池,一次创建n个连接,用完归还到池子里等下次再被使用?

链接概念

Linux链接分两种,一种被称为硬链接(Hard Link),另一种被称为符号链接(Symbolic Link)。
默认情况下,ln命令产生硬链接。

硬连接

  • 硬连接指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。
  • 硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。

软连接

另外一种连接称之为符号连接(Symbolic Link),也叫软连接。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。

实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#创建一个测试文件f1
[oracle@Linux]$ touch f1

#创建f1的一个硬连接文件f2
[oracle@Linux]$ ln f1 f2

#创建f1的一个符号连接文件f3
[oracle@Linux]$ ln -s f1 f3

# -i参数显示文件的inode节点信息
[oracle@Linux]$ ls -li
total 0
9797648 -rw-r--r-- 2 oracle oinstall 0 Apr 21 08:11 f1
9797648 -rw-r--r-- 2 oracle oinstall 0 Apr 21 08:11 f2
9797649 lrwxrwxrwx 1 oracle oinstall 2 Apr 21 08:11 f3 -> f1

从上面的结果中可以看出,硬连接文件f2与原文件f1的inode节点相同,均为9797648,然而符号连接文件的inode节点不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 修改文件内容
[oracle@Linux]$ echo "I am f1 file" >>f1

# 打印f1文件内容
[oracle@Linux]$ cat f1
I am f1 file

# 打印f2文件内容
[oracle@Linux]$ cat f2
I am f1 file

# 打印f3文件内容
[oracle@Linux]$ cat f3
I am f1 file
# 删除f1
[oracle@Linux]$ rm -f f1

# 打印f2文件内容,未受到影响,所以硬链接未受源文件删除的影响
[oracle@Linux]$ cat f2
I am f1 file

# 打印f3文件内容,提示找不到该文件或者目录,所以软连接会受到源文件删除的影响
[oracle@Linux]$ cat f3
cat: f3: No such file or directory

通过上面的测试可以看出:当删除原始文件f1后,硬连接f2不受影响,但是符号连接f1文件无效

总结

1).删除符号连接f3,对f1,f2无影响;
2).删除硬连接f2,对f1,f3也无影响;
3).删除原文件f1,对硬连接f2没有影响,导致符号连接f3失效;
4).同时删除原文件f1,硬连接f2,整个文件会真正的被删除。

背景

早上突然收到告警短信,xxx服务磁盘使用率超过80%,如下图,赶紧上机器进行排查,下面是排查的过程
avatar

查看机器整体磁盘使用情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
df -h

// 下面是结果
root@xxx.docker.ys:~/dlap-manager$ df -h
Filesystem Size Used Avail Use% Mounted on
overlay 20G 18G 2.7G 87% /
tmpfs 64M 0 64M 0% /dev
tmpfs 63G 0 63G 0% /sys/fs/cgroup
/dev/sda3 219G 15G 193G 8% /etc/hosts
shm 64M 0 64M 0% /dev/shm
/dev/sdb1 4.4T 949G 3.5T 22% /etc/hostname
tmpfs 63G 12K 63G 1% /run/secrets/kubernetes.io/serviceaccount
tmpfs 63G 0 63G 0% /proc/acpi
tmpfs 63G 0 63G 0% /proc/scsi
tmpfs 63G 0 63G 0% /sys/firmware

看到overlay这个文件目录占了87%,仔细研究下这个文件夹是什么

overlay介绍

OverlayFS是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如ext4fs和xfs等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行“合并”,然后向用户呈现,这也就是联合挂载技术,对比于AUFS,OverlayFS速度更快,实现更简单。 而Linux 内核为Docker提供的OverlayFS驱动有两种:overlay和overlay2。而overlay2是相对于overlay的一种改进,在inode利用率方面比overlay更有效。但是overlay有环境需求:docker版本17.06.02+,宿主机文件系统需要是ext4或xfs格式。
overlayfs通过三个目录:lower目录、upper目录、以及work目录实现,其中lower目录可以是多个,work目录为工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见,最后联合挂载完成给用户呈现的统一视图称为为merged目录
avatar
在上述图中可以看到三个层结构,即:lowerdir、uperdir、merged,其中lowerdir是只读的image layer,其实就是rootfs,对应的lowerdir是可以有多个目录。而upperdir则是在lowerdir之上的一层,这层是读写层,在启动一个容器时候会进行创建,所有的对容器数据更改都发生在这里层。最后merged目录是容器的挂载点,也就是给用户暴露的统一视角。

overlay如何工作

当容器中发生数据修改时候overlayfs存储驱动又是如何进行工作的?以下将阐述其读写过程:

    1. 读:
    • 如果文件在容器层(upperdir),直接读取文件;
    • 如果文件不在容器层(upperdir),则从镜像层(lowerdir)读取;
    1. 写:
    • 首次写入: 如果在upperdir中不存在,overlay和overlay2执行copy_up操作,把文件从lowdir拷贝到upperdir,由于overlayfs是文件级别的(即使文件只有很少的一点修改,也会产生的copy_up的行为),后续对同一文件的在此写入操作将对已经复制到容器的文件的副本进行操作。这也就是常常说的写时复制(copy-on-write)
    • 删除文件和目录: 当文件在容器被删除时,在容器层(upperdir)创建whiteout文件,镜像层(lowerdir)的文件是不会被删除的,因为他们是只读的,但without文件会阻止他们显示,当目录在容器内被删除时,在容器层(upperdir)一个不透明的目录,这个和上面whiteout原理一样,阻止用户继续访问,即便镜像层仍然存在。
    1. 注意事项
    • copy_up操作只发生在文件首次写入,以后都是只修改副本,
    • overlayfs只适用两层目录,相比于比AUFS,查找搜索都更快。
    • 容器层的文件删除只是一个“障眼法”,是靠whiteout文件将其遮挡,image层并没有删除,这也就是为什么使用docker commit 提交保存的镜像会越来越大,无论在容器层怎么删除数据,image层都不会改变。

overlay镜像存储结构

从仓库拉取ubuntu镜像,结果显示总共拉取了6层镜像如下:

1
2
3
4
5
6
7
8
9
10
11
[hadoop@bigdata-xxx.ys ~]$ sudo docker pull ubuntu-test:xenial
Trying to pull repository ubuntu-test ...
xenial: Pulling from ubuntu-test
58690f9b18fc: Pull complete
b51569e7c507: Pull complete
da8ef40b9eca: Pull complete
fb15d46c38dc: Pull complete
4c7a0de79adc: Pull complete
5eff12cba838: Pull complete
Digest: sha256:d21c70f1203a5b0fe1d8a1b60bd1924ca5458ba450828f6b88c4e973db84c8e8
Status: Downloaded newer image for ubuntu-test:xenial

此时6层镜像被存储在/var/lib/docker/overlay2下,将镜像运行起来一个容器,如下命令
1
sudo docker run -itd --name ubuntu-test ubuntu-test:xenial

运行容器之后,查询出容器的元数据
1
2
3
4
// 获取容器ID
sudo docker ps -a | grep ubuntu
// 通过容器ID查看元数据
sudo docker inspect container-id

avatar

进入容器创建一个文件,如下命令

1
2
sudo docker exec -it ubuntu-test bash
echo 'xxxxxx' > helloworld.txt

通过tree命令查看新创建的文件出现在哪里

1
tree -L 3 /var/lib/docker/overlay2/f90282cedadf6b7765ba06ea8983af306f78773baeca9ff1e418651d0b9d14f2/diff

avatar

回到问题本身

overlay目录使用了87%的存储,触发了磁盘告警,overlay目录是我们的弹性云容器运行的目录,发现通过nohup启动的jar包将所有stdout/stderr都输出到了nohup.out文件,该文件也持久化存在/var/lib/docker/overlay2下,所以需要对nohup.out进行处理,不可直接删除,因为jar启动后所有的输出流都会转存到nohup.out,这个文件句柄已经打开,所有的stdout/stderr仍然会输出,只是输出在/proc/pid/fd/1或者/proc/pid/fd/2这些目录下。

0描述符 标准输入
1描述符 标注输出
2描述符 标注错误输出

linux一切到可以看作文件,/proc/pid/fd/1 就是pid进程的标准输出,/proc/pid/fd/1 就是pid进程的标准错误输出,我们通过测试可以看到下面结果,就算我们删除了nohup.out文件,任何会将stdout/stderro输出到这个文件。

1
2
3
4
5
6
7
8
[hadoop@xxx.ys ~]$ tree -L 3 /proc/143645/fd
/proc/143645/fd
├── 0 -> /dev/null
├── 1 -> /home/hadoop/nohup.out\ (deleted)
├── 2 -> /home/hadoop/nohup.out\ (deleted)
└── 3 -> /home/hadoop/test.txt

0 directories, 4 files

    1. 重启jar包,将所有错误/标准输出忽略
      1
      2
      3
      4
      // >/dev/null是表示linux的空设备,是一个特殊的文件,写入到它的内容都会被丢弃(等同于 1>/dev/null)
      // 2>&1表示所有错误输出都重定向到标准输出
      // 所以就是将错误输出重定向到标准输出,将标准输出重定向到空设备,就是禁止所有输出/错误
      nohup java -jar xx.jar >/dev/null 2>&1 &
    1. 删除nohup.out文件
      1
      rm -f nohup.out

修改docker的根目录

安装过程

  1. 停止docker服务
    1
    systemctl stop docker
  2. 创建新的docker工作目录

    1
    mkdir -p /data2/docker

    这个目录可以自定义,但是一定要保证在/root里面

  3. 迁移/var/lib/docker

    1
    rsync -avz /var/lib/docker /data2/docker/
  4. 配置devicemapper.conf
    1
    2
    3
    4
    # 不存在就创建
    sudo mkdir -p /etc/systemd/system/docker.service.d/
    # 不存在就创建
    sudo vi /etc/systemd/system/docker.service.d/devicemapper.conf
  5. 在devicemapper.conf中添加

    1
    2
    3
    [Service]
    ExecStart=
    ExecStart=/usr/bin/dockerd --graph=/data2/docker
  6. 重启docker服务

    1
    2
    3
    4
    5
    systemctl daemon-reload

    systemctl restart docker

    systemctl enable docker
  7. 确认是否配置成功

    1
    docker info

    avatar

重新启动所有容器后,确认无误。即可删除/var/lib/docker里面所有文件。

注意事项

如果报错如下

1
Error response from daemon: Cannot restart container linyu: shim error: docker-runc not installed on system

    1. 判断是否安装runc
      1
      rpm -qi runc
    1. 未安装则先安装
      1
      sudo yum install runc
    1. 创建软连接
      1
      2
      cd /usr/libexec/docker/
      sudo ln -s docker-runc-current docker-runc
    1. 重启服务
      1
      sudo docker restart container_id

什么是CRUD

CRUD是指在做计算处理时的增加(Create)、检索(Retrieve)、更新(Update)和删除(Delete)几个单词的首字母简写。crud主要被用在描述软件系统中数据库或者持久层的基本操作功能

clickhouse物理上的crud

    1. create创建表
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      create table account(
      time DateTime default now() comment '创建时间',
      name String default '' comment '唯一标示',
      alias String default '' comment '别名',
      age UInt64 default 0 comment '年龄',
      version UInt64 default 0 comment '版本号',
      is_delete UInt8 default 0 comment '是否删除,0否,1是'
      )
      engine = MergeTree
      partition by toYYYYMMDD(time)
      order by name
      avatar
    1. insert写入数据
      1
      insert into default.account(time, name, alias, age, version, is_delete) values ('2022-01-01 11:11:11', 'blanklin', 'superhero', 20, 1, 0)

      写入数据后会按照partitionby生成对应的分区part目录
      avatar

    1. update更新
      1
      2
      3
      alter table default.account
      update alias = 'super_hero'
      where alias = 'superhero'

      这里是 mutation 操作,会生成一个mutation_version.txt
      avatar

    1. delete删除
      1
      alter table delete where id = 1

      这里是 mutation 操作,会生成一个mutation_version.txt
      avatar

    1. retrieve检索
      1
      select time, name, alias, age, version, is_delete from account where is_delete = 0 order by version desc
    1. 可以通过system.mutations查询执行计划
      avatar
      当mutation操作执行完成后,system.mutations表中对应的mutation记录中is_done字段的值会变为1。
    1. 可以通过system.parts查询执行结果
      avatar
      当旧的数据片段移除后,system.parts表中旧数据片段对应的记录会被移除。

可以看到mutation操作完成后,之前的目录已经被删除
avatar

clickhouse的mutation是什么

官方文档解释

从官方对于mutaiton的解释链接中,我们需要注意到几个关键词,如下

    1. manipulate table data
      操作表数据
    1. asynchronous background processes
      异步后台处理
    1. rewriting whole data parts
      重写全部数据part
    1. a SELECT query that started executing during a mutation will see data from parts that have already been mutated along with data from parts that have not been mutated yet
      在突变期间的查询语句,将看到已经完成突变的数据part和还未发生突变的part
    1. data that was inserted into the table before the mutation was submitted will be mutated and data that was inserted after that will not be mutated
      在提交突变之前插入表中的数据将被突变,而在此之后插入的数据将不会被突变
    1. There is no way to roll back the mutation once it is submitted, but if the mutation is stuck for some reason it can be cancelled with the KILL MUTATION query
      突变一旦被提交就没有方式可以回滚,但是如果突变由于一些原因被卡住,可以使用KILL MUTATION取消突变

源码解读

当用户执行一个如上的Mutation操作获得返回时,ClickHouse内核其实只做了两件事情:
avatar

    1. 检查Mutation操作是否合法;

      主体逻辑在MutationsInterpreter::validate函数

    1. 保存Mutation命令到存储文件中,唤醒一个异步处理merge和mutation的工作线程;

      主体逻辑在StorageMergeTree::mutate函数中。

Merge逻辑
StorageMergeTree::merge函数是MergeTree异步Merge的核心逻辑,Data Part Merge的工作除了通过后台工作线程自动完成,用户还可以通过Optimize命令来手动触发。自动触发的场景中,系统会根据后台空闲线程的数据来启发式地决定本次Merge最大可以处理的数据量大小,max_bytes_to_merge_at_min_space_in_pool: 决定当空闲线程数最大时可处理的数据量上限(默认150GB)
max_bytes_to_merge_at_max_space_in_pool: 决定只剩下一个空闲线程时可处理的数据量上限(默认1MB)
当用户的写入量非常大的时候,应该适当调整工作线程池的大小和这两个参数。当用户手动触发merge时,系统则是根据disk剩余容量来决定可处理的最大数据量。

Mutation逻辑
系统每次都只会订正一个Data Part,但是会聚合多个mutation任务批量完成,这点实现非常的棒。因为在用户真实业务场景中一次数据订正逻辑中可能会包含多个Mutation命令,把这多个mutation操作聚合到一起订正效率上就非常高。系统每次选择一个排序键最小的并且需要订正Data Part进行操作,本意上就是把数据从前往后进行依次订正。
avatar
avatar
avatar

mutation和merge相互独立执行。看完本文前面的分析,大家应该也注意到了目前Data Part的merge和mutation是相互独立执行的,Data Part在同一时刻只能是在merge或者mutation操作中。对于MergeTree这种存储彻底Immutable的设计,数据频繁merge、mutation会引入巨大的IO负载。实时上merge和mutation操作是可以合并到一起去考虑的,这样可以省去数据一次读写盘的开销。对数据写入压力很大又有频繁mutation的场景,会有很大帮助

clickhouse逻辑CRUD

VersionedCollapsingMergeTree介绍,引擎继承自 MergeTree 并将折叠行的逻辑添加到合并数据部分的算法中。 VersionedCollapsingMergeTree 用于相同的目的 折叠树 但使用不同的折叠算法,允许以多个线程的任何顺序插入数据。 特别是, Version 列有助于正确折叠行,即使它们以错误的顺序插入。 相比之下, CollapsingMergeTree 只允许严格连续插入。

创建VersionedCollapsingMergeTree表

1
2
3
4
5
6
7
8
9
10
11
create table test_version_collapsing(
time DateTime default now() comment '创建时间',
name String default '' comment '唯一标示',
alias String default '' comment '别名',
age UInt64 default 0 comment '年龄',
version UInt64 default 0 comment '版本号',
sign Int8 default 0 comment '是否删除,0否,1是'
)
engine = VersionedCollapsingMergeTree(sign, version)
partition by toYYYYMMDD(time)
order by name

插入数据

1
insert into default.test_version_collapsing(time, name, alias, age, version, sign) values ('2022-01-01 11:11:11', 'blanklin', 'superhero', 20, 1, 1)

更新数据

    1. 先找出要更新的这条数据
      1
      2
      select time, name, alias, age, version, sign from default.test_version_collapsing
      where name = 'blanklin' and sign = 1
    1. 假设要更新alias=update_super_hero,其他值不变,将version由1.捞出的值上+1,类似以下sql
      1
      2
      3
      4
      5
      # 先将旧行标示为删除,就是将sign = -1
      insert into default.test_version_collapsing(time, name, alias, age, version, sign) values ('2022-01-01 11:11:11', 'blanklin', 'superhero', 20, 1, -1);

      # 再去插入新行,包含要更新的列
      insert into default.test_version_collapsing(time, name, alias, age, version, sign) values ('2022-01-01 11:11:11', 'blanklin', 'update_superhero', 20, 2, 1).
    1. 捞出更新后的那条数据
      1
      select name, argMax(age, version), argMax(alias, version) from default.test_version_collapsing group by name having sum(sign) > 0;

删除数据

    1. 先找出要更新的这条数据
      1
      2
      select time, name, alias, age, version, sign from default.test_version_collapsing
      where name = 'blanklin' and sign = 1
    1. 假设要删除alias=update_super_hero,其他值不变,将sign=1,类似以下sql
      1
      insert into default.test_version_collapsing(time, name, alias, age, version, sign) values ('2022-01-01 11:11:11', 'blanklin', 'update_superhero', 20, 2, -1)
    1. 捞出更新后的那条数据
      1
      select name, argMax(age, version), argMax(alias, version) from default.test_version_collapsing group by name having sum(sign) > 0;

注意项

    1. 只有相同分区内的数据才能删除和更新
    1. 如果不使用 having sum(sign) > 0的方式去查询,则可以使用final方式查询
      1
      select * from test_version_collapsing final;
    1. 也可以使用optimize方式强制合并分区,再查询,但是这个方式可能会造成集群cpu飙高,而且optimize一个大表需要时间很长,效率极低
      1
      optimize table test_version_collapsing final
    1. sign必须要唯一,添加用1,则删除一定是-1,才可以被折叠处理

分布式DDL执行链路

在介绍具体的分布式DDL执行链路之前,先为大家梳理一下哪些操作是可以走分布式DDL执行链路的,大家也可以自己在源码中查看一下ASTQueryWithOnCluster的继承类有哪些:
cgi

  • ASTAlterQuery:
    包括ATTACH_PARTITIONFETCH_PARTITIONFREEZE_PARTITIONFREEZE_ALL等操作(对表的数据分区粒度进行操作)。
  • ASTCreateQuery:
    包括常见的建库、建表、建视图,还有ClickHouse独有的Attach Table(可以从存储文件中直接加载一个之前卸载的数据表)。
  • ASTCreateQuotaQuery:
    包括对租户的配额操作语句,例如create quaota,或者alter quota语句
  • ASTCreateRoleQuery:
    包括对租户角色操作语句,例如create/alter/drop/set/set default/show create role语句,或者show roles
  • ASTCreateRowPolicyQuery
    对表的查询做行级别的策略限制,例如create row policy 或者 alter row policy
  • ASTCreateSettingsProfileQuery
    对角色或者租户的资源限制和约束,例如create settings profile 或者 alter settings profile
  • ASTCreateUserQuery
    对租户的操作语句,例如create create user 或者 alter create user
  • ASTDropAccessEntityQuery
    涉及到了clickhouse权限相关的所有删除语句,包括DROP USER,DROP ROLE,DROP QUOTA,DROP [ROW] POLICY,DROP [SETTINGS] PROFILE
  • ASTDropQuery:
    其中包含了三种不同的删除操作(Drop / Truncate / Detach),Detach TableAttach Table对应,它是表的卸载动作,把表的存储目录整个移到专门的detach文件夹下,然后关闭表在节点RAM中的”引用”,这张表在节点中不再可见。
  • ASTGrantQuery:
    这是授权相关的RBAC,可以对库/表授予或者撤销读/写等权限命令,例如GRANT insert on db.tb to acount,或者REVOKE all on db.tb from account
  • ASTKillQueryQuery:
    可以Kill正在运行的Query,也可以Kill之前发送的Mutation命令。
  • ASTOptimizeQuery:
    这是MergeTree表引擎特有的操作命令,它可以手动触发MergeTree表的合并动作,并可以强制数据分区下的所有Data Part合并成一个。
  • ASTRenameQuery:
    修改表名,可更改到不同库下。

DDL Query Task分发

cgi
ClickHouse内核对每种SQL操作都有对应的IInterpreter实现类,其中的execute方法负责具体的操作逻辑。而以上列举的ASTQuery对应的IInterpreter实现类中的execute方法都加入了分布式DDL执行判断逻辑,把所有分布式DDL执行链路统一都DDLWorker::executeDDLQueryOnCluster方法中。
executeDDLQueryOnCluster的过程大致可以分为三个步骤:

检查DDLQuery的合法性,

  • 1、校验query规则
    cgi
  • 2、初始化DDLWorker,取config.xml表的配置
    cgi
  • 3、替换query里的数据库名称
    cgi
    这里替换库名的逻辑是,
  • 3.1、如果query里有带上库名称,则直接使用,若无,则走2
  • 3.2、metrika.xml里配置了shard的默认库<default_database>default</default_database>,则使用默认库,否则走3
  • 3.3、使用当前session的database
    cgi

DDLQuery写入到Zookeeper任务队列中

  • 1、构造DDLLogEntry对象,把entry对象加入到queue队列中
    cgi
    注意:queue_dir是由config.xml配置的,如下
    1
    2
    3
    <distributed_ddl>
    <path>/clickhouse/task_queue/ddl</path>
    </distributed_ddl>
  • 2、去zookeeper执行创建znode,把entry序列化存入znode
    cgi
  • 3、在znode下创建active和finished的znode
    cgi

    下面截图为query-xxx的记录的entry内容
    cgi

等待Zookeeper任务队列的反馈把结果返回给用户。

cgi

DDL Query Task执行线程

  • 1、DDLWorker构造函数去取了config.xml配置,并且开启了2个线程,分别是执行线程和清理线程
    cgi
  • 2、执行线程加入到线程池后,执行ddl task
    cgi
  • 3、过滤掉 query 中带有 on cluster xxx的语句,根据不同的query选择不同执行方式
    cgi
  • 4、alter、optimize、truncate语句需要在leader节点执行
    cgi

    注意:Replcated表的alter、optimize、truncate这些query是会先判断是否leader节点,不是则不处理,在执行时,会先给zookeeper加一个分布式锁,锁住这个任务防止被修改,执行时都是把自己的host:port注册到znode/query-xxx/active下,执行完成后,结果写到znode/query-xxx/finished下。

DDL Query Task清理线程

  • 1、DDLWorker构造函数去取了config.xml配置,并且开启了2个线程,分别是执行线程和清理线程
    cgi
  • 2、执行清理逻辑,每次执行后,下一次执行需要过1分钟后才可以接着做清理
    cgi

分布式DDL的执行链路总结

  • 1)节点收到用户的分布式DDL请求

  • 2)节点校验分布式DDL请求合法性,在Zookeeper的任务队列中创建Znode并上传DDL LogEntry(query-xxxx),同时在LogEntryZnode下创建activefinish两个状态同步的Znode

  • 3)Cluster中的节点后台线程消费Zookeeper中的LogEntry队列执行处理逻辑,处理过程中把自己注册到acitve Znode下,并把处理结果写回到finish Znode
    cgi

  • 4)用户的原始请求节点,不断轮询LogEntry Znode下的activefinish状态Znode,当目标节点全部执行完成任务或者触发超时逻辑时,用户就会获得结果反馈

这个分发逻辑中有个值得注意的点:分布式DDL执行链路中有超时逻辑,如果触发超时用户将无法从客户端返回中确定最终执行结果,需要自己去Zookeepercheck节点返回结果(也可以通过system.zookeeper系统表查看)。每个节点只有一个后台线程在消费执行DDL任务,碰到某个DDL任务(典型的是optimize任务)执行时间很长时,会导致DDL任务队列积压从而产生大面积的超时反馈。

可以看出Zookeeper在分布式DDL执行过程中主要充当DDL Task的分发、串行化执行、结果收集的一致性介质。

问题描述

用户反馈api请求时快时慢,慢的时候网页打开很久都没有结果,直到超时给出500错误

排查过程

初步怀疑

当前程序分布式部署在多个节点上,通过vip进行负载均衡,所以对于直接将反馈慢的页面打开的请求通过curl方式登陆生产环境所有节点,进行轮训一遍,发现其中02节点需要等待很久,其他节点均正常,所以问题应该出在02节点,
注意:这个定位过程当然也可以使用监控图就一目了然了。推荐使用grafana prometheus spring boot dashboardgoogle搜寻下相关配置就可以了。
cgi

查询该节点负载

1
2
3
4
# 得到进程的pid
jps -mlv
# 通过top命令查询节点实际负载
top -Hp pid

cgi

通过top命令发现该节点memory使用了接近90%,怀疑出现内存泄露

查询该节点内存使用情况

1
jstat -gcutil pid 1000

cgi

如我所料,几乎不到1s就开始做一次fgc,所以服务才越来越慢响应

内存分析

使用jmap分析内存概要

1
jmap -heap pid | head -n20

cgi

使用jmap打印堆内存的对象,带上live,则只统计活着的对象

1
2
jmap -histo pid | head -n20
jmap -histo:live pid | head -n20

cgi

打印进程的内存使用情况

1
jmap -dump:format=b,file=dumpFileName pid

cgi

dump出来了12G的文件,通过scp工具转存到本地

jprofiler分析堆内存

  • 1、把dumpFileName文件转存为.hprof格式后直接双击打开,按照instance count逆序排列
    cgi
  • 2、会发现有hashmap类型占了最大头,但是这个类型,双击它,选择merged incoming reference,查看合并后的来源引用统计
    cgi
  • 3、还是选最大头的文件数一直拆到最里层,找出来源引用是org.apache.ibatis.executor.result.DefaultResultHandler这个类,基本能定位到问题根源了,是我们连接clickhouse客户端去查询结果时,未对结果集做限制,导致了一个很大的结果集返回到内存中。
    cgi

解决问题

当然是对结果集做限制,检测用户输入的sql,是否包含limit条数限制,若未限制,则对sql进行改写,增加500条数限制
cgi

GC的运行原理

GCgarbage collection):垃圾回收,主要是指YGCFGC
YGC(minor garbage collection):新生代垃圾回收
FGC(major garbage collection):老年代垃圾回收

堆内存结构

cgi
堆内存采用了分代结构,包括新生代和老年代,新生代分为:eden区、from survivor区(简称s0)、to survivor区(简称s1),三者默认比例上8:1:1,另外新生代和老年代的比例则是1:2。
堆内存之所以采用分代结构,是因为绝大多数对象都是短生命周期的,这样设计可以把不同的生命周期的对象放在不同的区域中,然后针对新生代和老年代采用不同的垃圾回收算法,从而使得GC效率最高。

YGC是什么时候触发的?

大多数情况下,对象直接在年轻代中的Eden区进行分配,如果Eden区域没有足够的空间,那么就会触发YGCMinor GC),YGC处理的区域只有新生代。因为大部分对象在短时间内都是可收回掉的,因此YGC后只有极少数的对象能存活下来,而被移动到S0区(采用的是复制算法)。
当触发下一次YGC时,会将Eden区和S0区的存活对象移动到S1区,同时清空Eden区和S0区。当再次触发YGC时,这时候处理的区域就变成了Eden区和S1区(即S0和S1进行角色交换)。每经过一次YGC,存活对象的年龄就会加1。

FGC是什么时候触发的?

  • 1、YGC时,To Survivor区不足以存放存活的对象,对象会直接进入到老年代。经过多次YGC后,如果存活对象的年龄达到了设定阈值,则会晋升到老年代中。动态年龄判定规则,To Survivor区中相同年龄的对象,如果其大小之和占到了 To Survivor 区一半以上的空间,那么大于此年龄的对象会直接进入老年代,而不需要达到默认的分代年龄。大对象:由-XX:PretenureSizeThreshold启动参数控制,若对象大小大于此值,就会绕过新生代, 直接在老年代中分配。当晋升到老年代的对象大于了老年代的剩余空间时,就会触发FGCMajor GC),FGC处理的区域同时包括新生代和老年代。老年代的内存使用率达到了一定阈值(可通过参数调整),直接触发FGC

  • 2、空间分配担保:在YGC之前,会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果小于,说明YGC是不安全的,则会查看参数 HandlePromotionFailure 是否被设置成了允许担保失败,如果不允许则直接触发Full GC;如果允许,那么会进一步检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于也会触发 Full GC

  • 3、Metaspace(元空间)在空间不足时会进行扩容,当扩容到了-XX:MetaspaceSize 参数的指定值时,也会触发FGC

  • 4、System.gc() 或者Runtime.gc() 被显式调用时,触发FGC

GC对程序会产生什么影响

不管是YGC还是FGC,都会造成一定程度上的程序卡顿(stop the world问题:GC线程开始工作,其他工作线程被挂起),即使采用ParNew、CMS、G1这些更先进的垃圾回收算法,也只是减少卡顿的时间,并不能完全消除卡顿

  • FGC过于频繁:
    FGC通常是比较慢的,少则几百号秒,多则几秒,正常情况下FGC每隔几个小时或者几天才会执行一次,对系统的影响是可接受的,所以一旦出现FGC频繁(比如几分钟/几十分钟出现一次)会导致工作线程频繁被停掉,让系统看起来就一直卡顿,使得程序的整体性能变差。
  • YGC耗时过长:
    一般来说YGC的总耗时指需要几十毫秒或上百毫秒,对于系统来说几乎无感知,所以如果YGC耗时达到1秒甚至几秒(快赶上FGC的耗时),那么卡顿就会加剧,加上YGC本身会比较频繁发生,就可能导致服务响应时间超时。
  • FGC耗时过长:
    FGC耗时增加,卡顿时间也会随之增加,尤其对于高并发服务,可能导致FGC期间比较多的超时问题,可用性降低,这种也需要关注
  • YGC过于频繁:
    即使YGC不会引起服务超时,但是YGC过于频繁也会降低服务的整体性能,对于高并发服务也是需要关注的。

其中,「FGC过于频繁」和「YGC耗时过长」,这两种情况属于比较典型的GC问题,大概率会对程序的服务质量产生影响。剩余两种情况的严重程度低一些,但是对于高并发或者高可用的程序也需要关注。

导致FGC的原因总结

  • 大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代。(即本文中的案例)
  • 内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM.
  • 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC.
  • 程序BUG导致动态生成了很多新类,使得 Metaspace 不断被占用,先引发FGC,最后导致OOM.
  • 代码中显式调用了gc方法,包括自己的代码甚至框架中的代码。
  • JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等等。

问题描述

官方解释

1
2
3
4
5
6
7
8
9
10
min_free_kbytes:
This is used to force the Linux VM to keep a minimum number
of kilobytes free. The VM uses this number to compute a
watermark[WMARK_MIN] value for each lowmem zone in the system.
Each lowmem zone gets a number of reserved free pages based
proportionally on its size.
Some minimal amount of memory is needed to satisfy PF_MEMALLOC
allocations; if you set this to lower than 1024KB, your system will
become subtly broken, and prone to deadlock under high loads.
Setting this too high will OOM your machine instantly.

1
echo 3145728 >> /proc/sys/vm/min_free_kbytes
1
sudo sysctl -a | grep min_free

docker-bridge

1
sysctl -a | grep oom

docker-bridge

min_free_kbytes大小的影响
min_free_kbytes设的越大,watermark的线越高,同时三个线之间的buffer量也相应会增加。这意味着会较早的启动kswapd进行回收,且会回收上来较多的内存(直至watermark[high]才会停止),这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量。极端情况下设置min_free_kbytes接近内存大小时,留给应用程序的内存就会太少而可能会频繁地导致OOM的发生。

min_free_kbytes设的过小,则会导致系统预留内存过小。kswapd回收的过程中也会有少量的内存分配行为(会设上PF_MEMALLOC)标志,这个标志会允许kswapd使用预留内存;另外一种情况是被OOM选中杀死的进程在退出过程中,如果需要申请内存也可以使用预留部分。这两种情况下让他们使用预留内存可以避免系统进入deadlock状态。