Dunwu Blog

大道至简,知易行难

Systemd 应用

搬运自:Systemd 入门教程:命令篇Systemd 入门教程:实战篇

Systemd 是 Linux 系统工具,用来启动守护进程,已成为大多数发行版的标准配置。

本文介绍它的基本用法,分为上下两篇。今天介绍它的主要命令,下一篇介绍如何用于实战。

由来

历史上,Linux 的启动一直采用init进程。

下面的命令用来启动服务。

1
2
3
$ sudo /etc/init.d/apache2 start
# 或者
$ service apache2 start

这种方法有两个缺点。

一是启动时间长。init进程是串行启动,只有前一个进程启动完,才会启动下一个进程。

二是启动脚本复杂。init进程只是执行启动脚本,不管其他事情。脚本需要自己处理各种
情况,这往往使得脚本变得很长。

Systemd 概述

Systemd 就是为了解决这些问题而诞生的。它的设计目标是,为系统的启动和管理提供一套
完整的解决方案。

根据 Linux 惯例,字母d是守护进程(daemon)的缩写。 Systemd 这个名字的含义,就
是它要守护整个系统。

使用了 Systemd,就不需要再用init了。Systemd 取代了initd,成为系统的第一个进
程(PID 等于 1),其他进程都是它的子进程。

1
$ systemctl --version

上面的命令查看 Systemd 的版本。

Systemd 的优点是功能强大,使用方便,缺点是体系庞大,非常复杂。事实上,现在还有很
多人反对使用 Systemd,理由就是它过于复杂,与操作系统的其他部分强耦合,违反”keep
simple, keep stupid”
Unix 哲学

img

(上图为 Systemd 架构图)

系统管理

Systemd 并不是一个命令,而是一组命令,涉及到系统管理的方方面面。

systemctl

systemctl是 Systemd 的主命令,用于管理系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 重启系统
$ sudo systemctl reboot

# 关闭系统,切断电源
$ sudo systemctl poweroff

# CPU停止工作
$ sudo systemctl halt

# 暂停系统
$ sudo systemctl suspend

# 让系统进入冬眠状态
$ sudo systemctl hibernate

# 让系统进入交互式休眠状态
$ sudo systemctl hybrid-sleep

# 启动进入救援状态(单用户状态)
$ sudo systemctl rescue

systemd-analyze

systemd-analyze命令用于查看启动耗时。

1
2
3
4
5
6
7
8
9
10
11
# 查看启动耗时
$ systemd-analyze

# 查看每个服务的启动耗时
$ systemd-analyze blame

# 显示瀑布状的启动过程流
$ systemd-analyze critical-chain

# 显示指定服务的启动流
$ systemd-analyze critical-chain atd.service

hostnamectl

hostnamectl命令用于查看当前主机的信息。

1
2
3
4
5
# 显示当前主机的信息
$ hostnamectl

# 设置主机名。
$ sudo hostnamectl set-hostname rhel7

localectl

localectl命令用于查看本地化设置。

1
2
3
4
5
6
# 查看本地化设置
$ localectl

# 设置本地化参数。
$ sudo localectl set-locale LANG=en_GB.utf8
$ sudo localectl set-keymap en_GB

timedatectl

timedatectl命令用于查看当前时区设置。

1
2
3
4
5
6
7
8
9
10
# 查看当前时区设置
$ timedatectl

# 显示所有可用的时区
$ timedatectl list-timezones

# 设置当前时区
$ sudo timedatectl set-timezone America/New_York
$ sudo timedatectl set-time YYYY-MM-DD
$ sudo timedatectl set-time HH:MM:SS

loginctl

loginctl命令用于查看当前登录的用户。

1
2
3
4
5
6
7
8
# 列出当前session
$ loginctl list-sessions

# 列出当前登录用户
$ loginctl list-users

# 列出显示指定用户的信息
$ loginctl show-user ruanyf

Unit

含义

Systemd 可以管理所有系统资源。不同的资源统称为 Unit(单位)。

Unit 一共分成 12 种。

  • Service unit:系统服务
  • Target unit:多个 Unit 构成的一个组
  • Device Unit:硬件设备
  • Mount Unit:文件系统的挂载点
  • Automount Unit:自动挂载点
  • Path Unit:文件或路径
  • Scope Unit:不是由 Systemd 启动的外部进程
  • Slice Unit:进程组
  • Snapshot Unit:Systemd 快照,可以切回某个快照
  • Socket Unit:进程间通信的 socket
  • Swap Unit:swap 文件
  • Timer Unit:定时器

systemctl list-units命令可以查看当前系统的所有 Unit 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 列出正在运行的 Unit
$ systemctl list-units

# 列出所有Unit,包括没有找到配置文件的或者启动失败的
$ systemctl list-units --all

# 列出所有没有运行的 Unit
$ systemctl list-units --all --state=inactive

# 列出所有加载失败的 Unit
$ systemctl list-units --failed

# 列出所有正在运行的、类型为 service 的 Unit
$ systemctl list-units --type=service

Unit 的状态

systemctl status命令用于查看系统状态和单个 Unit 的状态。

1
2
3
4
5
6
7
8
# 显示系统状态
$ systemctl status

# 显示单个 Unit 的状态
$ sysystemctl status bluetooth.service

# 显示远程主机的某个 Unit 的状态
$ systemctl -H root@rhel7.example.com status httpd.service

除了status命令,systemctl还提供了三个查询状态的简单方法,主要供脚本内部的判
断语句使用。

1
2
3
4
5
6
7
8
# 显示某个 Unit 是否正在运行
$ systemctl is-active application.service

# 显示某个 Unit 是否处于启动失败状态
$ systemctl is-failed application.service

# 显示某个 Unit 服务是否建立了启动链接
$ systemctl is-enabled application.service

Unit 管理

对于用户来说,最常用的是下面这些命令,用于启动和停止 Unit(主要是 service)。

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
# 立即启动一个服务
$ sudo systemctl start apache.service

# 立即停止一个服务
$ sudo systemctl stop apache.service

# 重启一个服务
$ sudo systemctl restart apache.service

# 杀死一个服务的所有子进程
$ sudo systemctl kill apache.service

# 重新加载一个服务的配置文件
$ sudo systemctl reload apache.service

# 重载所有修改过的配置文件
$ sudo systemctl daemon-reload

# 显示某个 Unit 的所有底层参数
$ systemctl show httpd.service

# 显示某个 Unit 的指定属性的值
$ systemctl show -p CPUShares httpd.service

# 设置某个 Unit 的指定属性
$ sudo systemctl set-property httpd.service CPUShares=500

依赖关系

Unit 之间存在依赖关系:A 依赖于 B,就意味着 Systemd 在启动 A 的时候,同时会去启
动 B。

systemctl list-dependencies命令列出一个 Unit 的所有依赖。

1
$ systemctl list-dependencies nginx.service

上面命令的输出结果之中,有些依赖是 Target 类型(详见下文),默认不会展开显示。如
果要展开 Target,就需要使用--all参数。

1
$ systemctl list-dependencies --all nginx.service

Unit 的配置文件

概述

每一个 Unit 都有一个配置文件,告诉 Systemd 怎么启动这个 Unit 。

Systemd 默认从目录/etc/systemd/system/读取配置文件。但是,里面存放的大部分文件
都是符号链接,指向目录/usr/lib/systemd/system/,真正的配置文件存放在那个目录。

systemctl enable命令用于在上面两个目录之间,建立符号链接关系。

1
2
3
$ sudo systemctl enable clamd@scan.service
# 等同于
$ sudo ln -s '/usr/lib/systemd/system/clamd@scan.service' '/etc/systemd/system/multi-user.target.wants/clamd@scan.service'

如果配置文件里面设置了开机启动,systemctl enable命令相当于激活开机启动。

与之对应的,systemctl disable命令用于在两个目录之间,撤销符号链接关系,相当于
撤销开机启动。

1
$ sudo systemctl disable clamd@scan.service

配置文件的后缀名,就是该 Unit 的种类,比如sshd.socket。如果省略,Systemd 默认
后缀名为.service,所以sshd会被理解成sshd.service

配置文件的状态

systemctl list-unit-files命令用于列出所有配置文件。

1
2
3
4
5
# 列出所有配置文件
$ systemctl list-unit-files

# 列出指定类型的配置文件
$ systemctl list-unit-files --type=service

这个命令会输出一个列表。

1
2
3
4
5
6
$ systemctl list-unit-files

UNIT FILE STATE
chronyd.service enabled
clamd@.service static
clamd@scan.service disabled

这个列表显示每个配置文件的状态,一共有四种。

  • enabled:已建立启动链接
  • disabled:没建立启动链接
  • static:该配置文件没有[Install]部分(无法执行),只能作为其他配置文件的依赖
  • masked:该配置文件被禁止建立启动链接

注意,从配置文件的状态无法看出,该 Unit 是否正在运行。这必须执行前面提到
systemctl status命令。

1
$ systemctl status bluetooth.service

一旦修改配置文件,就要让 SystemD 重新加载配置文件,然后重新启动,否则修改不会生
效。

1
2
$ sudo systemctl daemon-reload
$ sudo systemctl restart httpd.service

配置文件的格式

配置文件就是普通的文本文件,可以用文本编辑器打开。

systemctl cat命令可以查看配置文件的内容。

1
2
3
4
5
6
7
8
9
10
11
$ systemctl cat atd.service

[Unit]
Description=ATD daemon

[Service]
Type=forking
ExecStart=/usr/bin/atd

[Install]
WantedBy=multi-user.target

从上面的输出可以看到,配置文件分成几个区块。每个区块的第一行,是用方括号表示的区
别名,比如[Unit]。注意,配置文件的区块名和字段名,都是大小写敏感的。

每个区块内部是一些等号连接的键值对。

1
2
3
4
5
[Section]
Directive1=value
Directive2=value

. . .

注意,键值对的等号两侧不能有空格。

配置文件的区块

[Unit]区块通常是配置文件的第一个区块,用来定义 Unit 的元数据,以及配置与其他
Unit 的关系。它的主要字段如下。

  • Description:简短描述
  • Documentation:文档地址
  • Requires:当前 Unit 依赖的其他 Unit,如果它们没有运行,当前 Unit 会启动失败
  • Wants:与当前 Unit 配合的其他 Unit,如果它们没有运行,当前 Unit 不会启动失败
  • BindsTo:与Requires类似,它指定的 Unit 如果退出,会导致当前 Unit 停止运行
  • Before:如果该字段指定的 Unit 也要启动,那么必须在当前 Unit 之后启动
  • After:如果该字段指定的 Unit 也要启动,那么必须在当前 Unit 之前启动
  • Conflicts:这里指定的 Unit 不能与当前 Unit 同时运行
  • Condition...:当前 Unit 运行必须满足的条件,否则不会运行
  • Assert...:当前 Unit 运行必须满足的条件,否则会报启动失败

[Install]通常是配置文件的最后一个区块,用来定义如何启动,以及是否开机启动。它
的主要字段如下。

  • WantedBy:它的值是一个或多个 Target,当前 Unit 激活时(enable)符号链接会放
    /etc/systemd/system目录下面以 Target 名 + .wants后缀构成的子目录中
  • RequiredBy:它的值是一个或多个 Target,当前 Unit 激活时,符号链接会放
    /etc/systemd/system目录下面以 Target 名 + .required后缀构成的子目录中
  • Alias:当前 Unit 可用于启动的别名
  • Also:当前 Unit 激活(enable)时,会被同时激活的其他 Unit

[Service]区块用来 Service 的配置,只有 Service 类型的 Unit 才有这个区块。它的
主要字段如下。

  • Type:定义启动时的进程行为。它有以下几种值。
  • Type=simple:默认值,执行ExecStart指定的命令,启动主进程
  • Type=forking:以 fork 方式从父进程创建子进程,创建后父进程会立即退出
  • Type=oneshot:一次性进程,Systemd 会等当前服务退出,再继续往下执行
  • Type=dbus:当前服务通过 D-Bus 启动
  • Type=notify:当前服务启动完毕,会通知Systemd,再继续往下执行
  • Type=idle:若有其他任务执行完毕,当前服务才会运行
  • ExecStart:启动当前服务的命令
  • ExecStartPre:启动当前服务之前执行的命令
  • ExecStartPost:启动当前服务之后执行的命令
  • ExecReload:重启当前服务时执行的命令
  • ExecStop:停止当前服务时执行的命令
  • ExecStopPost:停止当其服务之后执行的命令
  • RestartSec:自动重启当前服务间隔的秒数
  • Restart:定义何种情况 Systemd 会自动重启当前服务,可能的值包括always(总是
    重启)、on-successon-failureon-abnormalon-aborton-watchdog
  • TimeoutSec:定义 Systemd 停止当前服务之前等待的秒数
  • Environment:指定环境变量

Unit 配置文件的完整字段清单,请参
官方文档

Target

启动计算机的时候,需要启动大量的 Unit。如果每一次启动,都要一一写明本次启动需要
哪些 Unit,显然非常不方便。Systemd 的解决方案就是 Target。

简单说,Target 就是一个 Unit 组,包含许多相关的 Unit 。启动某个 Target 的时候
,Systemd 就会启动里面所有的 Unit。从这个意义上说,Target 这个概念类似于”状态点
“,启动某个 Target 就好比启动到某种状态。

传统的init启动模式里面,有 RunLevel 的概念,跟 Target 的作用很类似。不同的是
,RunLevel 是互斥的,不可能多个 RunLevel 同时启动,但是多个 Target 可以同时启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 查看当前系统的所有 Target
$ systemctl list-unit-files --type=target

# 查看一个 Target 包含的所有 Unit
$ systemctl list-dependencies multi-user.target

# 查看启动时的默认 Target
$ systemctl get-default

# 设置启动时的默认 Target
$ sudo systemctl set-default multi-user.target

# 切换 Target 时,默认不关闭前一个 Target 启动的进程,
# systemctl isolate 命令改变这种行为,
# 关闭前一个 Target 里面所有不属于后一个 Target 的进程
$ sudo systemctl isolate multi-user.target

Target 与 传统 RunLevel 的对应关系如下。

1
2
3
4
5
6
7
8
9
Traditional runlevel      New target name     Symbolically linked to...

Runlevel 0 | runlevel0.target -> poweroff.target
Runlevel 1 | runlevel1.target -> rescue.target
Runlevel 2 | runlevel2.target -> multi-user.target
Runlevel 3 | runlevel3.target -> multi-user.target
Runlevel 4 | runlevel4.target -> multi-user.target
Runlevel 5 | runlevel5.target -> graphical.target
Runlevel 6 | runlevel6.target -> reboot.target

它与init进程的主要差别如下。

(1)默认的 RunLevel(在/etc/inittab文件设置)现在被默认的 Target 取代,
位置是/etc/systemd/system/default.target,通常符号链接到graphical.target
图形界面)或者multi-user.target(多用户命令行)。

(2)启动脚本的位置,以前是/etc/init.d目录,符号链接到不同的 RunLevel 目
录 (比如/etc/rc3.d/etc/rc5.d等),现在则存放
/lib/systemd/system/etc/systemd/system目录。

(3)配置文件的位置,以前init进程的配置文件是/etc/inittab,各种服务的
配置文件存放在/etc/sysconfig目录。现在的配置文件主要存放在/lib/systemd目录
,在/etc/systemd目录里面的修改可以覆盖原始设置。

日志管理

Systemd 统一管理所有 Unit 的启动日志。带来的好处就是,可以只用journalctl一个命
令,查看所有日志(内核日志和应用日志)。日志的配置文件
/etc/systemd/journald.conf

journalctl功能强大,用法非常多。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# 查看所有日志(默认情况下 ,只保存本次启动的日志)
$ sudo journalctl

# 查看内核日志(不显示应用日志)
$ sudo journalctl -k

# 查看系统本次启动的日志
$ sudo journalctl -b
$ sudo journalctl -b -0

# 查看上一次启动的日志(需更改设置)
$ sudo journalctl -b -1

# 查看指定时间的日志
$ sudo journalctl --since="2012-10-30 18:17:16"
$ sudo journalctl --since "20 min ago"
$ sudo journalctl --since yesterday
$ sudo journalctl --since "2015-01-10" --until "2015-01-11 03:00"
$ sudo journalctl --since 09:00 --until "1 hour ago"

# 显示尾部的最新10行日志
$ sudo journalctl -n

# 显示尾部指定行数的日志
$ sudo journalctl -n 20

# 实时滚动显示最新日志
$ sudo journalctl -f

# 查看指定服务的日志
$ sudo journalctl /usr/lib/systemd/systemd

# 查看指定进程的日志
$ sudo journalctl _PID=1

# 查看某个路径的脚本的日志
$ sudo journalctl /usr/bin/bash

# 查看指定用户的日志
$ sudo journalctl _UID=33 --since today

# 查看某个 Unit 的日志
$ sudo journalctl -u nginx.service
$ sudo journalctl -u nginx.service --since today

# 实时滚动显示某个 Unit 的最新日志
$ sudo journalctl -u nginx.service -f

# 合并显示多个 Unit 的日志
$ journalctl -u nginx.service -u php-fpm.service --since today

# 查看指定优先级(及其以上级别)的日志,共有8级
# 0: emerg
# 1: alert
# 2: crit
# 3: err
# 4: warning
# 5: notice
# 6: info
# 7: debug
$ sudo journalctl -p err -b

# 日志默认分页输出,--no-pager 改为正常的标准输出
$ sudo journalctl --no-pager

# 以 JSON 格式(单行)输出
$ sudo journalctl -b -u nginx.service -o json

# 以 JSON 格式(多行)输出,可读性更好
$ sudo journalctl -b -u nginx.serviceqq
-o json-pretty

# 显示日志占据的硬盘空间
$ sudo journalctl --disk-usage

# 指定日志文件占据的最大空间
$ sudo journalctl --vacuum-size=1G

# 指定日志文件保存多久
$ sudo journalctl --vacuum-time=1years

实战

开机启动

对于那些支持 Systemd 的软件,安装的时候,会自动在/usr/lib/systemd/system目录添
加一个配置文件。

如果你想让该软件开机启动,就执行下面的命令(以httpd.service为例)。

1
$ sudo systemctl enable httpd

上面的命令相当于在/etc/systemd/system目录添加一个符号链接,指
/usr/lib/systemd/system里面的httpd.service文件。

这是因为开机时,Systemd只执行/etc/systemd/system目录里面的配置文件。这也意味
着,如果把修改后的配置文件放在该目录,就可以达到覆盖原始配置的效果。

启动服务

设置开机启动以后,软件并不会立即启动,必须等到下一次开机。如果想现在就运行该软件
,那么要执行systemctl start命令。

1
$ sudo systemctl start httpd

执行上面的命令以后,有可能启动失败,因此要用systemctl status命令查看一下该服务
的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ sudo systemctl status httpd

httpd.service - The Apache HTTP Server
Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled)
Active: active (running) since 金 2014-12-05 12:18:22 JST; 7min ago
Main PID: 4349 (httpd)
Status: "Total requests: 1; Current requests/sec: 0; Current traffic: 0 B/sec"
CGroup: /system.slice/httpd.service
├─4349 /usr/sbin/httpd -DFOREGROUND
├─4350 /usr/sbin/httpd -DFOREGROUND
├─4351 /usr/sbin/httpd -DFOREGROUND
├─4352 /usr/sbin/httpd -DFOREGROUND
├─4353 /usr/sbin/httpd -DFOREGROUND
└─4354 /usr/sbin/httpd -DFOREGROUND

12月 05 12:18:22 localhost.localdomain systemd[1]: Starting The Apache HTTP Server...
12月 05 12:18:22 localhost.localdomain systemd[1]: Started The Apache HTTP Server.
12月 05 12:22:40 localhost.localdomain systemd[1]: Started The Apache HTTP Server.

上面的输出结果含义如下。

  • Loaded行:配置文件的位置,是否设为开机启动
  • Active行:表示正在运行
  • Main PID行:主进程 ID
  • Status行:由应用本身(这里是 httpd )提供的软件当前状态
  • CGroup块:应用的所有子进程
  • 日志块:应用的日志

停止服务

终止正在运行的服务,需要执行systemctl stop命令。

1
$ sudo systemctl stop httpd.service

有时候,该命令可能没有响应,服务停不下来。这时候就不得不”杀进程”了,向正在运行的
进程发出kill信号。

1
$ sudo systemctl kill httpd.service

此外,重启服务要执行systemctl restart命令。

1
$ sudo systemctl restart httpd.service

读懂配置文件

一个服务怎么启动,完全由它的配置文件决定。下面就来看,配置文件有些什么内容。

前面说过,配置文件主要放在/usr/lib/systemd/system目录,也可能
/etc/systemd/system目录。找到配置文件以后,使用文本编辑器打开即可。

systemctl cat命令可以用来查看配置文件,下面以sshd.service文件为例,它的作用
是启动一个 SSH 服务器,供其他用户以 SSH 方式登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ systemctl cat sshd.service

[Unit]
Description=OpenSSH server daemon
Documentation=man:sshd(8) man:sshd_config(5)
After=network.target sshd-keygen.service
Wants=sshd-keygen.service

[Service]
EnvironmentFile=/etc/sysconfig/sshd
ExecStart=/usr/sbin/sshd -D $OPTIONS
ExecReload=/bin/kill -HUP $MAINPID
Type=simple
KillMode=process
Restart=on-failure
RestartSec=42s

[Install]
WantedBy=multi-user.target

可以看到,配置文件分成几个区块,每个区块包含若干条键值对。

下面依次解释每个区块的内容。

[Unit] 区块:启动顺序与依赖关系。

Unit区块的Description字段给出当前服务的简单描述,Documentation字段给出文档
位置。

接下来的设置是启动顺序和依赖关系,这个比较重要。

After字段:表示如果network.targetsshd-keygen.service需要启动,那
sshd.service应该在它们之后启动。

相应地,还有一个Before字段,定义sshd.service应该在哪些服务之前启动。

注意,AfterBefore字段只涉及启动顺序,不涉及依赖关系。

举例来说,某 Web 应用需要 postgresql 数据库储存数据。在配置文件中,它只定义要在
postgresql 之后启动,而没有定义依赖 postgresql 。上线后,由于某种原因
,postgresql 需要重新启动,在停止服务期间,该 Web 应用就会无法建立数据库连接。

设置依赖关系,需要使用Wants字段和Requires字段。

Wants字段:表示sshd.servicesshd-keygen.service之间存在”弱依赖”关系,即
如果”sshd-keygen.service”启动失败或停止运行,不影响sshd.service继续执行。

Requires字段则表示”强依赖”关系,即如果该服务启动失败或异常退出,那
sshd.service也必须退出。

注意,Wants字段与Requires字段只涉及依赖关系,与启动顺序无关,默认情况下是同
时启动的。

[Service] 区块:启动行为

Service区块定义如何启动当前服务。

启动命令

许多软件都有自己的环境参数文件,该文件可以用EnvironmentFile字段读取。

EnvironmentFile字段:指定当前服务的环境参数文件。该文件内部的key=value键值
对,可以用$key的形式,在当前配置文件中获取。

上面的例子中,sshd 的环境参数文件是/etc/sysconfig/sshd

配置文件里面最重要的字段是ExecStart

ExecStart字段:定义启动进程时执行的命令。

上面的例子中,启动sshd,执行的命令是/usr/sbin/sshd -D $OPTIONS,其中的变
$OPTIONS就来自EnvironmentFile字段指定的环境参数文件。

与之作用相似的,还有如下这些字段。

  • ExecReload字段:重启服务时执行的命令
  • ExecStop字段:停止服务时执行的命令
  • ExecStartPre字段:启动服务之前执行的命令
  • ExecStartPost字段:启动服务之后执行的命令
  • ExecStopPost字段:停止服务之后执行的命令

请看下面的例子。

1
2
3
4
5
6
[Service]
ExecStart=/bin/echo execstart1
ExecStart=
ExecStart=/bin/echo execstart2
ExecStartPost=/bin/echo post1
ExecStartPost=/bin/echo post2

上面这个配置文件,第二行ExecStart设为空值,等于取消了第一行的设置,运行结果如
下。

1
2
3
execstart2
post1
post2

所有的启动设置之前,都可以加上一个连词号(-),表示”抑制错误”,即发生错误的时
候,不影响其他命令的执行。比如,EnvironmentFile=-/etc/sysconfig/sshd(注意等号
后面的那个连词号),就表示即使/etc/sysconfig/sshd文件不存在,也不会抛出错误。

启动类型

Type字段定义启动类型。它可以设置的值如下。

  • simple(默认值):ExecStart字段启动的进程为主进程
  • forking:ExecStart字段将以fork()方式启动,此时父进程将会退出,子进程将成
    为主进程
  • oneshot:类似于simple,但只执行一次,Systemd 会等它执行完,才启动其他服务
  • dbus:类似于simple,但会等待 D-Bus 信号后启动
  • notify:类似于simple,启动结束后会发出通知信号,然后 Systemd 再启动其他服
  • idle:类似于simple,但是要等到其他任务都执行完,才会启动该服务。一种使用场
    合是为让该服务的输出,不与其他服务的输出相混合

下面是一个oneshot的例子,笔记本电脑启动时,要把触摸板关掉,配置文件可以这样写

1
2
3
4
5
6
7
8
9
[Unit]
Description=Switch-off Touchpad

[Service]
Type=oneshot
ExecStart=/usr/bin/touchpad-off

[Install]
WantedBy=multi-user.target

上面的配置文件,启动类型设为oneshot,就表明这个服务只要运行一次就够了,不需要
长期运行。

如果关闭以后,将来某个时候还想打开,配置文件修改如下。

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Switch-off Touchpad

[Service]
Type=oneshot
ExecStart=/usr/bin/touchpad-off start
ExecStop=/usr/bin/touchpad-off stop
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

上面配置文件中,RemainAfterExit字段设为yes,表示进程退出以后,服务仍然保持执
行。这样的话,一旦使用systemctl stop命令停止服务,ExecStop指定的命令就会执行
,从而重新开启触摸板。

重启行为

Service区块有一些字段,定义了重启行为。

KillMode字段:定义 Systemd 如何停止 sshd 服务。

上面这个例子中,将KillMode设为process,表示只停止主进程,不停止任何 sshd 子
进程,即子进程打开的 SSH session 仍然保持连接。这个设置不太常见,但对 sshd 很重
要,否则你停止服务的时候,会连自己打开的 SSH session 一起杀掉。

KillMode字段可以设置的值如下。

  • control-group(默认值):当前控制组里面的所有子进程,都会被杀掉
  • process:只杀主进程
  • mixed:主进程将收到 SIGTERM 信号,子进程收到 SIGKILL 信号
  • none:没有进程会被杀掉,只是执行服务的 stop 命令。

接下来是Restart字段。

Restart字段:定义了 sshd 退出后,Systemd 的重启方式。

上面的例子中,Restart设为on-failure,表示任何意外的失败,就将重启 sshd。如果
sshd 正常停止(比如执行systemctl stop命令),它就不会重启。

Restart字段可以设置的值如下。

  • no(默认值):退出后不会重启
  • on-success:只有正常退出时(退出状态码为 0),才会重启
  • on-failure:非正常退出时(退出状态码非 0),包括被信号终止和超时,才会重启
  • on-abnormal:只有被信号终止和超时,才会重启
  • on-abort:只有在收到没有捕捉到的信号终止时,才会重启
  • on-watchdog:超时退出,才会重启
  • always:不管是什么退出原因,总是重启

对于守护进程,推荐设为on-failure。对于那些允许发生错误退出的服务,可以设
on-abnormal

最后是RestartSec字段。

RestartSec字段:表示 Systemd 重启服务之前,需要等待的秒数。上面的例子设为等
待 42 秒。

[Install] 区块

Install区块,定义如何安装这个配置文件,即怎样做到开机启动。

WantedBy字段:表示该服务所在的 Target。

Target的含义是服务组,表示一组服务。WantedBy=multi-user.target指的是,sshd
所在的 Target 是multi-user.target

这个设置非常重要,因为执行systemctl enable sshd.service命令时
sshd.service的一个符号链接,就会放在/etc/systemd/system目录下面
multi-user.target.wants子目录之中。

Systemd 有默认的启动 Target。

1
2
$ systemctl get-default
multi-user.target

上面的结果表示,默认的启动 Target 是multi-user.target。在这个组里的所有服务,
都将开机启动。这就是为什么systemctl enable命令能设置开机启动的原因。

使用 Target 的时候,systemctl list-dependencies命令和systemctl isolate命令也
很有用。

1
2
3
4
5
6
# 查看 multi-user.target 包含的所有服务
$ systemctl list-dependencies multi-user.target

# 切换到另一个 target
# shutdown.target 就是关机状态
$ sudo systemctl isolate shutdown.target

一般来说,常用的 Target 有两个:一个是multi-user.target,表示多用户命令行状态
;另一个是graphical.target,表示图形用户状态,它依赖于multi-user.target。官
方文档有一张非常清晰的
Target 依赖关系图

Target 的配置文件

Target 也有自己的配置文件。

1
2
3
4
5
6
7
8
9
$ systemctl cat multi-user.target

[Unit]
Description=Multi-User System
Documentation=man:systemd.special(7)
Requires=basic.target
Conflicts=rescue.service rescue.target
After=basic.target rescue.service rescue.target
AllowIsolate=yes

注意,Target 配置文件里面没有启动命令。

上面输出结果中,主要字段含义如下。

  • Requires字段:要求basic.target一起运行。
  • Conflicts字段:冲突字段。如果rescue.servicerescue.target正在运行
    multi-user.target就不能运行,反之亦然。
  • After:表示multi-user.targetbasic.targetrescue.service
    rescue.target之后启动,如果它们有启动的话。
  • AllowIsolate:允许使用systemctl isolate命令切换到multi-user.target

修改配置文件后重启

修改配置文件以后,需要重新加载配置文件,然后重新启动相关服务。

1
2
3
4
5
# 重新加载配置文件
$ sudo systemctl daemon-reload

# 重启相关服务
$ sudo systemctl restart foobar

参考资料

Spring 资源管理

Version 6.0.3

Resource 接口

相对标准 URL 访问机制,Spring 的 org.springframework.core.io.Resource 接口抽象了对底层资源的访问接口,提供了一套更好的访问方式。

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
public interface Resource extends InputStreamSource {

boolean exists();

boolean isReadable();

boolean isOpen();

boolean isFile();

URL getURL() throws IOException;

URI getURI() throws IOException;

File getFile() throws IOException;

ReadableByteChannel readableChannel() throws IOException;

long contentLength() throws IOException;

long lastModified() throws IOException;

Resource createRelative(String relativePath) throws IOException;

String getFilename();

String getDescription();
}

正如 Resource 接口的定义所示,它扩展了 InputStreamSource 接口。Resource 最核心的方法如下:

  • getInputStream() - 定位并且打开当前资源,返回当前资源的 InputStream。每次调用都会返回一个新的 InputStream。调用者需要负责关闭流。
  • exists() - 判断当前资源是否真的存在。
  • isOpen() - 判断当前资源是否是一个已打开的 InputStream。如果为 true,则 InputStream 不能被多次读取,必须只读取一次然后关闭以避免资源泄漏。对所有常用资源实现返回 false,InputStreamResource 除外。
  • getDescription() - 返回当前资源的描述,当处理资源出错时,资源的描述会用于错误信息的输出。一般来说,资源的描述是一个完全限定的文件名称,或者是当前资源的真实 URL。

常见 Spring 资源接口:

类型 接口
输入流 org.springframework.core.io.InputStreamSource
只读资源 org.springframework.core.io.Resource
可写资源 org.springframework.core.io.WritableResource
编码资源 org.springframework.core.io.support.EncodedResource
上下文资源 org.springframework.core.io.ContextResource

内置的 Resource 实现

Spring 包括几个内置的 Resource 实现:

资源来源 前缀 说明
UrlResource file:https:ftp: UrlResource 封装了一个 java.net.URL 对象,用于访问可通过 URL 访问的任何对象,例如文件、HTTPS 目标、FTP 目标等。所有 URL 都可以通过标准化的字符串形式表示,因此可以使用适当的标准化前缀来指示一种 URL 类型与另一种 URL 类型的区别。 这包括:file:用于访问文件系统路径;https:用于通过 HTTPS 协议访问资源;ftp:用于通过 FTP 访问资源等等。
ClassPathResource classpath: ClassPathResource 从类路径上加载资源。它使用线程上下文加载器、给定的类加载器或指定的 class 类型中的任意一个来加载资源。
FileSystemResource file: FileSystemResource java.io.File 的资源实现。它还支持 java.nio.file.Path ,应用 Spring 的标准对字符串路径进行转换。FileSystemResource 支持解析为文件和 URL。
PathResource PathResourcejava.nio.file.Path 的资源实现。
ServletContextResource ServletContextResource ServletContext 的资源实现。它表示相应 Web 应用程序根目录中的相对路径。
InputStreamResource InputStreamResource 是指定 InputStream 的资源实现。注意:如果该 InputStream 已被打开,则不可以多次读取该流。
ByteArrayResource ByteArrayResource 是指定的二进制数组的资源实现。它会为给定的字节数组创建一个 ByteArrayInputStream

ResourceLoader 接口

ResourceLoader 接口用于加载 Resource 对象。其定义如下:

1
2
3
4
5
6
public interface ResourceLoader {

Resource getResource(String location);

ClassLoader getClassLoader();
}

Spring 中主要的 ResourceLoader 实现:

Spring 中,所有的 ApplicationContext 都实现了 ResourceLoader 接口。因此,所有 ApplicationContext 都可以通过 getResource() 方法获取 Resource 实例。

【示例】

1
2
3
4
5
6
7
// 如果没有指定资源前缀,Spring 会尝试返回合适的资源
Resource template = ctx.getResource("some/resource/path/myTemplate.txt");
// 如果指定 classpath: 前缀,Spring 会强制使用 ClassPathResource
Resource template = ctx.getResource("classpath:some/resource/path/myTemplate.txt");
// 如果指定 file:、http 等 URL 前缀,Spring 会强制使用 UrlResource
Resource template = ctx.getResource("file:///some/resource/path/myTemplate.txt");
Resource template = ctx.getResource("http://myhost.com/resource/path/myTemplate.txt");

下表列举了 Spring 根据各种位置路径加载资源的策略:

前缀 样例 说明
classpath: classpath:com/myapp/config.xml 从类路径加载
file: file:///data/config.xml 以 URL 形式从文件系统加载
http: http://myserver/logo.png 以 URL 形式加载
/data/config.xml 由底层的 ApplicationContext 实现决定

ResourcePatternResolver 接口

ResourcePatternResolver 接口是 ResourceLoader 接口的扩展,它的作用是定义策略,根据位置模式解析 Resource 对象。

1
2
3
4
5
6
public interface ResourcePatternResolver extends ResourceLoader {

String CLASSPATH_ALL_URL_PREFIX = "classpath*:";

Resource[] getResources(String locationPattern) throws IOException;
}

PathMatchingResourcePatternResolver 是一个独立的实现,可以在 ApplicationContext 之外使用,也可以被 ResourceArrayPropertyEditor 用于填充 Resource[] bean 属性。PathMatchingResourcePatternResolver 能够将指定的资源位置路径解析为一个或多个匹配的 Resource 对象。

注意:任何标准 ApplicationContext 中的默认 ResourceLoader 实际上是 PathMatchingResourcePatternResolver 的一个实例,它实现了 ResourcePatternResolver 接口。

ResourceLoaderAware 接口

ResourceLoaderAware 接口是一个特殊的回调接口,用来标记提供 ResourceLoader 引用的对象。ResourceLoaderAware 接口定义如下:

1
2
3
public interface ResourceLoaderAware {
void setResourceLoader(ResourceLoader resourceLoader);
}

当一个类实现 ResourceLoaderAware 并部署到应用程序上下文中(作为 Spring 管理的 bean)时,它会被应用程序上下文识别为 ResourceLoaderAware,然后,应用程序上下文会调用 setResourceLoader(ResourceLoader),将自身作为参数提供(请记住,Spring 中的所有应用程序上下文都实现 ResourceLoader 接口)。

由于 ApplicationContext 是一个 ResourceLoader,该 bean 还可以实现 ApplicationContextAware 接口并直接使用提供的应用程序上下文来加载资源。 但是,一般来说,如果您只需要这些,最好使用专门的 ResourceLoader 接口。 该代码将仅耦合到资源加载接口(可以被视为实用程序接口),而不耦合到整个 Spring ApplicationContext 接口。

在应用程序中,还可以使用 ResourceLoader 的自动装配作为实现 ResourceLoaderAware 接口的替代方法。传统的构造函数和 byType 自动装配模式能够分别为构造函数参数或 setter 方法参数提供 ResourceLoader。 为了获得更大的灵活性(包括自动装配字段和多参数方法的能力),请考虑使用基于注解的自动装配功能。 在这种情况下,ResourceLoader 会自动连接到需要 ResourceLoader 类型的字段、构造函数参数或方法参数中,只要相关字段、构造函数或方法带有 @Autowired 注解即可。

资源依赖

如果 bean 本身要通过某种动态过程来确定和提供资源路径,那么 bean 可以使用 ResourceLoaderResourcePatternResolver 接口来加载资源。 例如,考虑加载某种模板,其中所需的特定资源取决于用户的角色。 如果资源是静态的,完全消除 ResourceLoader 接口(或 ResourcePatternResolver 接口)的使用,让 bean 公开它需要的 Resource 属性,并期望将它们注入其中是有意义的。

使注入这些属性变得简单的原因是所有应用程序上下文都注册并使用一个特殊的 JavaBeans PropertyEditor,它可以将 String 路径转换为 Resource 对象。 例如,下面的 MyBean 类有一个 Resource 类型的模板属性。

【示例】

1
2
3
<bean id="myBean" class="example.MyBean">
<property name="template" value="some/resource/path/myTemplate.txt"/>
</bean>

请注意,配置中引用的模板资源路径没有前缀,因为应用程序上下文本身将用作 ResourceLoader,资源本身将根据需要通过 ClassPathResourceFileSystemResource 或 ServletContextResource 加载,具体取决于上下文的确切类型。

如果需要强制使用特定的资源类型,则可以使用前缀。 以下两个示例显示如何强制使用 ClassPathResourceUrlResource(后者用于访问文件系统文件)。

1
2
<property name="template" value="classpath:some/resource/path/myTemplate.txt">
<property name="template" value="file:///some/resource/path/myTemplate.txt"/>

可以通过 @Value 注解加载资源文件 myTemplate.txt,示例如下:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyBean {

private final Resource template;

public MyBean(@Value("${template.path}") Resource template) {
this.template = template;
}

// ...
}

Spring 的 PropertyEditor 会根据资源文件的路径字符串,加载 Resource 对象,并将其注入到 MyBean 的构造方法。

如果想要加载多个资源文件,可以使用 classpath*: 前缀,例如:classpath*:/config/templates/*.txt

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyBean {

private final Resource[] templates;

public MyBean(@Value("${templates.path}") Resource[] templates) {
this.templates = templates;
}

// ...
}

应用上下文和资源路径

构造应用上下文

应用上下文构造函数(针对特定的应用上下文类型)通常将字符串或字符串数组作为资源的位置路径,例如构成上下文定义的 XML 文件。

【示例】

1
2
3
4
5
ApplicationContext ctx = new ClassPathXmlApplicationContext("conf/appContext.xml");
ApplicationContext ctx = new FileSystemXmlApplicationContext("conf/appContext.xml");
ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath:conf/appContext.xml");
ApplicationContext ctx = new ClassPathXmlApplicationContext(
new String[] {"services.xml", "daos.xml"}, MessengerService.class);

使用通配符构造应用上下文

ApplicationContext 构造器的中的资源路径可以是单一的路径(即一对一地映射到目标资源);也可以是通配符形式——可包含 classpath*:也可以是前缀或 ant 风格的正则表达式(使用 spring 的 PathMatcher 来匹配)。

示例:

1
ApplicationContext ctx = new ClassPathXmlApplicationContext("classpath*:conf/appContext.xml");

使用 classpath* 表示类路径下所有匹配文件名称的资源都会被获取(本质上就是调用了 ClassLoader.getResources(…) 方法),接着将获取到的资源组装成最终的应用上下文。

在位置路径的其余部分,classpath*: 前缀可以与 PathMatcher 结合使用,如:classpath*:META-INF/*-beans.xml

问题

Spring 配置资源中有哪些常见类型?

  • XML 资源
  • Properties 资源
  • YAML 资源

参考资料

版本管理中间件 Flyway

Flyway 是一个数据迁移工具。

简介

什么是 Flyway

Flyway 是一个开源的数据库迁移工具。

为什么要使用数据迁移

为了说明数据迁移的作用,我们来举一个示例:

(1)假设,有一个叫做 Shiny 的项目,它的架构是一个叫做 Shiny Soft 的 App 连接叫做 Shiny DB 的数据库。

(2)对于大多数项目而言,最简单的持续集成场景如下所示:

img

这意味着,我们不仅仅要处理一份环境中的修改,由此会引入一些版本冲突问题:

在代码侧(即应用软件)的版本问题比较容易解决:

  • 有方便的版本控制工具
  • 有可复用的构建和持续集成
  • 规范的发布和部署过程

那么,数据库层面的版本问题如何解决呢?

目前仍然没有方便的数据库版本工具。许多项目仍使用 sql 脚本来解决版本冲突,甚至是遇到冲突问题时才想起用 sql 语句去解决。

由此,引发一些问题:

  • 机器上的数据库是什么状态?
  • 脚本到底生效没有?
  • 生产环境修复的问题是否也在测试环境修复了?
  • 如何建立一个新的数据库实例?

数据迁移就是用来搞定这些混乱的问题:

  • 通过草稿重建一个数据库。
  • 在任何时候都可以清楚的了解数据库的状态。
  • 以一种明确的方式将数据库从当前版本迁移到一个新版本。

Flyway 如何工作?

最简单的场景是指定 Flyway 迁移到一个空的数据库。

img

Flyway 会尝试查找它的 schema 历史表,如果数据库是空的,Flyway 就不再查找,而是直接创建数据库。

现再你就有了一个仅包含一张空表的数据库,默认情况下,这张表叫 flyway_schema_history

img

这张表将被用于追踪数据库的状态。

然后,Flyway 将开始扫描文件系统或应用 classpath 中的 migrations。这些 migrations 可以是 sql 或 java。

这些 migrations 将根据他们的版本号进行排序。

img

任意 migration 应用后,schema 历史表将更新。当元数据和初始状态替换后,可以称之为:迁移到新版本。

Flyway 一旦扫描了文件系统或应用 classpath 下的 migrations,这些 migrations 会检查 schema 历史表。如果它们的版本号低于或等于当前的版本,将被忽略。保留下来的 migrations 是等待的 migrations,有效但没有应用。

img

migrations 将根据版本号排序并按序执行。

img

快速上手

Flyway 有 4 种使用方式:

  • 命令行
  • JAVA API
  • Maven
  • Gradle

命令行

适用于非 Java 用户,无需构建。

1
> flyway migrate -url=... -user=... -password=...

(1)下载解压

进入官方下载页面,选择合适版本,下载并解压到本地。

(2)配置 flyway

编辑 /conf/flyway.conf

1
2
3
flyway.url=jdbc:h2:file:./foobardb
flyway.user=SA
flyway.password=

(3)创建第一个 migration

/sql 目录下创建 V1__Create_person_table.sql 文件,内容如下:

1
2
3
4
create table PERSON (
ID int not null,
NAME varchar(100) not null
);

(4)迁移数据库

运行 Flyway 来迁移数据库:

1
flyway-5.1.4> flyway migrate

运行正常的情况下,应该可以看到如下结果:

1
2
3
4
5
6
Database: jdbc:h2:file:./foobardb (H2 1.4)
Successfully validated 1 migration (execution time 00:00.008s)
Creating Schema History table: "PUBLIC"."flyway_schema_history"
Current version of schema "PUBLIC": << Empty Schema >>
Migrating schema "PUBLIC" to version 1 - Create person table
Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.033s)

(5)添加第二个 migration

/sql 目录下创建 V2__Add_people.sql 文件,内容如下:

1
2
3
insert into PERSON (ID, NAME) values (1, 'Axel');
insert into PERSON (ID, NAME) values (2, 'Mr. Foo');
insert into PERSON (ID, NAME) values (3, 'Ms. Bar');

运行 Flyway

1
flyway-5.1.4> flyway migrate

运行正常的情况下,应该可以看到如下结果:

1
2
3
4
5
Database: jdbc:h2:file:./foobardb (H2 1.4)
Successfully validated 2 migrations (execution time 00:00.018s)
Current version of schema "PUBLIC": 1
Migrating schema "PUBLIC" to version 2 - Add people
Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.016s)

JAVA API

(1)准备

  • Java8+
  • Maven 3.x

(2)添加依赖

pom.xml 中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<project ...>
...
<dependencies>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>5.1.4</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.3.170</version>
</dependency>
...
</dependencies>
...
</project>

(3)集成 Flyway

添加 App.java 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.flywaydb.core.Flyway;

public class App {
public static void main(String[] args) {
// Create the Flyway instance
Flyway flyway = new Flyway();

// Point it to the database
flyway.setDataSource("jdbc:h2:file:./target/foobar", "sa", null);

// Start the migration
flyway.migrate();
}
}

(4)创建第一个 migration

添加 src/main/resources/db/migration/V1__Create_person_table.sql 文件,内容如下:

1
2
3
4
create table PERSON (
ID int not null,
NAME varchar(100) not null
);

(5)执行程序

执行 App#main

运行正常的情况下,应该可以看到如下结果:

1
2
3
4
INFO: Creating schema history table: "PUBLIC"."flyway_schema_history"
INFO: Current version of schema "PUBLIC": << Empty Schema >>
INFO: Migrating schema "PUBLIC" to version 1 - Create person table
INFO: Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.062s).

(6)添加第二个 migration

添加 src/main/resources/db/migration/V2__Add_people.sql 文件,内容如下:

1
2
3
insert into PERSON (ID, NAME) values (1, 'Axel');
insert into PERSON (ID, NAME) values (2, 'Mr. Foo');
insert into PERSON (ID, NAME) values (3, 'Ms. Bar');

运行正常的情况下,应该可以看到如下结果:

1
2
3
INFO: Current version of schema "PUBLIC": 1
INFO: Migrating schema "PUBLIC" to version 2 - Add people
INFO: Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.090s).

Maven

与 Java API 方式大体相同,区别在 集成 Flyway 步骤:

Maven 方式使用插件来集成 Flyway:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<project xmlns="...">
...
<build>
<plugins>
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>5.1.4</version>
<configuration>
<url>jdbc:h2:file:./target/foobar</url>
<user>sa</user>
</configuration>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.191</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>

因为用的是插件,所以执行方式不再是运行 Java 类,而是执行 maven 插件:

1
> mvn flyway:migrate

:point_right: 参考:示例源码

Gradle

本人不用 Gradle,略。

入门篇

概念

Migrations

在 Flyway 中,对于数据库的任何改变都称之为 Migrations

Migrations 可以分为 Versioned migrations 和 Repeatable migrations。

Versioned migrations 有 2 种形式:regular 和 undo。

Versioned migrations 和 Repeatable migrations 都可以使用 SQL 或 JAVA 来编写。

Versioned migrations

由一个版本号(version)、一段描述(description)、一个校验(checksum)组成。版本号必须是惟一的。Versioned migrations 只能按顺序执行一次。

一般用于:

  • 增删改 tables/indexes/foreign keys/enums/UDTs。
  • 引用数据更新
  • 用户数据校正

Regular 示例:

1
2
3
4
5
6
7
8
9
CREATE TABLE car (
id INT NOT NULL PRIMARY KEY,
license_plate VARCHAR NOT NULL,
color VARCHAR NOT NULL
);

ALTER TABLE owner ADD driver_license_id VARCHAR;

INSERT INTO brand (name) VALUES ('DeLorean');
Undo migrations

注:仅专业版支持

Undo Versioned Migrations 负责撤销 Regular Versioned migrations 的影响。

Undo 示例:

1
2
3
4
5
DELETE FROM brand WHERE name='DeLorean';

ALTER TABLE owner DROP driver_license_id;

DROP TABLE car;
Repeatable migrations

由一段描述(description)、一个校验(checksum)组成。Versioned migrations 每次执行后,校验(checksum)会更新。

Repeatable migrations 用于管理可以通过一个文件来维护版本控制的数据库对象。

一般用于:

  • 创建(重建)views/procedures/functions/packages 等。
  • 大量引用数据重新插入

示例:

1
2
CREATE OR REPLACE VIEW blue_cars AS
SELECT id, license_plate FROM cars WHERE color='blue';
基于 SQL 的 migrations

migrations 最常用的编写形式就是 SQL。

基于 SQL 的 migrations 一般用于:

  • DDL 变更(针对 TABLES,VIEWS,TRIGGERS,SEQUENCES 等的 CREATE/ALTER/DROP 操作)
  • 简单的引用数据变更(引用数据表中的 CRUD)
  • 简单的大量数据变更(常规数据表中的 CRUD)

命名规则

为了被 Flyway 自动识别,SQL migrations 的文件命名必须遵循规定的模式:

img

  • Prefix - V 代表 versioned migrations (可配置), U 代表 undo migrations (可配置)、 R 代表 repeatable migrations (可配置)
  • Version - 版本号通过.(点)或_(下划线)分隔 (repeatable migrations 不需要)
  • Separator - __ (两个下划线) (可配置)
  • Description - 下划线或空格分隔的单词
  • Suffix - .sql (可配置)
基于 JAVA 的 migrations

基于 JAVA 的 migrations 适用于使用 SQL 不容易表达的场景:

  • BLOB 和 CLOB 变更
  • 大量数据的高级变更(重新计算、高级格式变更)

命名规则

为了被 Flyway 自动识别,JAVA migrations 的文件命名必须遵循规定的模式:

img

  • Prefix - V 代表 versioned migrations (可配置), U 代表 undo migrations (可配置)、 R 代表 repeatable migrations (可配置)
  • Version - 版本号通过.(点)或_(下划线)分隔 (repeatable migrations 不需要)
  • Separator - __ (两个下划线) (可配置)
  • Description - 下划线或空格分隔的单词

:point_right: 更多细节请参考:https://flywaydb.org/documentation/migrations

Callbacks

注:部分 events 仅专业版支持。

尽管 Migrations 可能已经满足绝大部分场景的需要,但是某些情况下需要你一遍又一遍的执行相同的行为。这可能会重新编译存储过程,更新视图以及许多其他类型的开销。

因为以上原因,Flyway 提供了 Callbacks,用于在 Migrations 生命周期中添加钩子。

Callbacks 可以用 SQL 或 JAVA 来实现。

SQL Callbacks

SQL Callbacks 的命名规则为:event 名 + SQL migration。

如: beforeMigrate.sql, beforeEachMigrate.sql, afterEachMigrate.sql 等。

SQL Callbacks 也可以包含描述(description)。这种情况下,SQL Callbacks 文件名 = event 名 + 分隔符 + 描述 + 后缀。例:beforeRepair__vacuum.sql

当同一个 event 有多个 SQL callbacks,将按照它们描述(description)的顺序执行。

注: Flyway 也支持你配置的 sqlMigrationSuffixes

JAVA Callbacks

当 SQL Callbacks 不够方便时,才应考虑 JAVA Callbacks。

JAVA Callbacks 有 3 种形式:

  1. 基于 Java 的 Migrations - 实现 JdbcMigration、SpringJdbcMigration、MigrationInfoProvider、MigrationChecksumProvider、ConfigurationAware、FlywayConfiguration
  2. 基于 Java 的 Callbacks - 实现 org.flywaydb.core.api.callback 接口。
  3. 自定义 Migration resolvers 和 executors - 实现 MigrationResolver、MigrationExecutor、ConfigurationAware、FlywayConfiguration 接口。

:point_right: 更多细节请参考:https://flywaydb.org/documentation/callbacks

Error Handlers

注:仅专业版支持。

(略)

Dry Runs

注:仅专业版支持。

(略)

命令

Flyway 的功能主要围绕着 7 个基本命令:MigrateCleanInfoValidateUndoBaselineRepair

注:各命令的使用方法细节请查阅官方文档。

支持的数据库

资料

| Github | 官方文档 |

🚪 传送

◾ 💧 钝悟的 IT 知识图谱

H2

概述

H2 是一个开源的嵌入式数据库引擎,采用 java 语言编写,不受平台的限制。同时 H2 提供了一个十分方便的 web 控制台用于操作和管理数据库内容。H2 还提供兼容模式,可以兼容一些主流的数据库,因此采用 H2 作为开发期的数据库非常方便。

使用说明

H2 控制台应用

H2 允许用户通过浏览器接口方式访问 SQL 数据库。

  1. 进入官方下载地址,选择合适版本,下载并安装到本地。
  2. 启动方式:在 bin 目录下,双击 jar 包;执行 java -jar h2*.jar;执行脚本:h2.bath2.sh
  3. 在浏览器中访问:http://localhost:8082,应该可以看到下图中的页面:

img

点击 Connect ,可以进入操作界面:

img

操作界面十分简单,不一一细说。

嵌入式应用

JDBC API

1
2
3
Connection conn = DriverManager.
getConnection("jdbc:h2:~/test");
conn.close();

详见:Using the JDBC API

连接池

1
2
3
4
5
import org.h2.jdbcx.JdbcConnectionPool;
JdbcConnectionPool cp = JdbcConnectionPool.
create("jdbc:h2:~/test", "sa", "sa");
Connection conn = cp.getConnection();
conn.close(); cp.dispose();

详见:Connection Pool

Maven

1
2
3
4
5
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
</dependency>

详见:Maven 2

Hibernate

hibernate.cfg.xml (or use the HSQLDialect):

1
2
3
<property name="dialect">
org.hibernate.dialect.H2Dialect
</property>

详见:Hibernate

Datasource class: org.h2.jdbcx.JdbcDataSource
oracle.toplink.essentials.platform.database.H2Platform

详见:TopLink and Glassfish

运行方式

嵌入式

数据库持久化存储为单个文件。

连接字符串:\~/.h2/DBName 表示数据库文件的存储位置,如果第一次连接则会自动创建数据库。

  • jdbc:h2:\~/test - ‘test’ 在用户根目录下
  • jdbc:h2:/data/test - ‘test’ 在 /data 目录下
  • jdbc:h2:test - ‘test’ 在当前工作目录

内存式

数据库只在内存中运行,关闭连接后数据库将被清空,适合测试环境

连接字符串:jdbc:h2:mem:DBName;DB_CLOSE_DELAY=-1

如果不指定 DBName,则以私有方式启动,只允许一个连接。

  • jdbc:h2:mem:test - 一个进程中有多个连接
  • jdbc:h2:mem: - 未命名的私有库,一个连接

服务模式

H2 支持三种服务模式:

  • web server:此种运行方式支持使用浏览器访问 H2 Console
  • TCP server:支持客户端/服务器端的连接方式
  • PG server:支持 PostgreSQL 客户端

启动 tcp 服务连接字符串示例:

  • jdbc:h2:tcp://localhost/\~/test - 用户根目录
  • jdbc:h2:tcp://localhost//data/test - 绝对路径

启动服务

执行 java -cp *.jar org.h2.tools.Server

执行如下命令,获取选项列表及默认值

1
java -cp h2*.jar org.h2.tools.Server -?

常见的选项如下:

  • -web:启动支持 H2 Console 的服务
  • -webPort <port>:服务启动端口,默认为 8082
  • -browser:启动 H2 Console web 管理页面
  • -tcp:使用 TCP server 模式启动
  • -pg:使用 PG server 模式启动

设置

  • jdbc:h2:..;MODE=MySQL 兼容模式(或 HSQLDB 等)
  • jdbc:h2:..;TRACE_LEVEL_FILE=3 记录到 *.trace.db

连接字符串参数

  • DB_CLOSE_DELAY - 要求最后一个正在连接的连接断开后,不要关闭数据库
  • MODE=MySQL - 兼容模式,H2 兼容多种数据库,该值可以为:DB2、Derby、HSQLDB、MSSQLServer、MySQL、Oracle、PostgreSQL
  • AUTO_RECONNECT=TRUE - 连接丢失后自动重新连接
  • AUTO_SERVER=TRUE - 启动自动混合模式,允许开启多个连接,该参数不支持在内存中运行模式
  • TRACE_LEVEL_SYSTEM_OUTTRACE_LEVEL_FILE - 输出跟踪日志到控制台或文件, 取值 0 为 OFF,1 为 ERROR(默认值),2 为 INFO,3 为 DEBUG
  • SET TRACE_MAX_FILE_SIZE mb - 设置跟踪日志文件的大小,默认为 16M

maven 方式

此外,使用 maven 也可以启动 H2 服务。添加以下插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>java</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>org.h2.tools.Server</mainClass>
<arguments>
<argument>-web</argument>
<argument>-webPort</argument>
<argument>8090</argument>
<argument>-browser</argument>
</arguments>
</configuration>
</plugin>

在命令行中执行如下命令启动 H2 Console

1
mvn exec:java

或者建立一个 bat 文件

1
2
3
@echo off
call mvn exec:java
pause

此操作相当于执行了如下命令:

1
java -jar h2-1.3.168.jar -web -webPort 8090 -browser

Spring 整合 H2

(1)添加依赖

1
2
3
4
5
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.194</version>
</dependency>

(2)spring 配置

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
31
32
33
34
35
36
37
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:jdbc="http://www.springframework.org/schema/jdbc"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.2.xsd
http://www.springframework.org/schema/jdbc
http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">

<!--配置数据源-->
<bean id="dataSource" class="org.h2.jdbcx.JdbcConnectionPool"
destroy-method="dispose">
<constructor-arg>
<bean class="org.h2.jdbcx.JdbcDataSource">
<!-- 内存模式 -->
<property name="URL" value="jdbc:h2:mem:test"/>
<!-- 文件模式 -->
<!-- <property name="URL" value="jdbc:h2:testRestDB" /> -->
<property name="user" value="root"/>
<property name="password" value="root"/>
</bean>
</constructor-arg>
</bean>

<!-- JDBC模板 -->
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<constructor-arg ref="dataSource"/>
</bean>
<bean id="myJdbcTemplate" class="org.zp.notes.spring.jdbc.MyJdbcTemplate">
<property name="jdbcTemplate" ref="jdbcTemplate"/>
</bean>

<!-- 初始化数据表结构 -->
<jdbc:initialize-database data-source="dataSource" ignore-failures="ALL">
<jdbc:script location="classpath:sql/h2/create_table_student.sql"/>
</jdbc:initialize-database>
</beans>

H2 SQL

SELECT

img

INSERT

img

UPDATE

img

DELETE

img

BACKUP

img

EXPLAIN

img

7、MERGE
img

RUNSCRIPT

运行 sql 脚本文件

img

SCRIPT

根据数据库创建 sql 脚本

img

SHOW

img

ALTER

ALTER INDEX RENAME

img

ALTER SCHEMA RENAME

img

ALTER SEQUENCE

img

ALTER TABLE

img

增加约束

img

修改列

img

删除列

img

删除序列

img

ALTER USER

修改用户名

img

修改用户密码

img

ALTER VIEW

img

COMMENT

img

CREATE CONSTANT

img

CREATE INDEX

img

CREATE ROLE

img

CREATE SCHEMA

img

CREATE SEQUENCE

img

CREATE TABLE

img

CREATE TRIGGER

img

CREATE USER

img

CREATE VIEW

img

DROP

img

GRANT RIGHT

给 schema 授权授权

img

给 schema 授权给 schema 授权

img

复制角色的权限

img

REVOKE RIGHT

移除授权

img

移除角色具有的权限

img

ROLLBACK

从某个还原点(savepoint)回滚

img

回滚事务

img

创建 savepoint

img

数据类型

img

INT Type

img

集群

H2 支持两台服务器运行两个数据库成为集群,两个数据库互为备份,如果一个服务器失效,另一个服务器仍然可以工作。另外只有服务模式支持集群配置。

H2 可以通过 CreateCluster 工具创建集群,示例步骤如下(在在一台服务器上模拟两个数据库组成集群):

  • 创建目录
    • 创建两个服务器工作的目录
  • 启动 tcp 服务
    • 执行如下命令分别在 9101、9102 端口启动两个使用 tcp 服务模式的数据库
  • 使用 CreateCluster 工具创建集群
    • 如果两个数据库不存在,该命令将会自动创建数据库。如果一个数据库失效,可以先删除坏的数据库文件,重新启动数据库,然后重新运行 CreateCluster 工具
  • 连接数据库现在可以使用如下连接字符串连接集群数据库
    • 监控集群运行状态
    • 可以使用如下命令查看配置的集群服务器是否都在运行
  • 限制
    • H2 的集群并不支持针对事务的负载均衡,所以很多操作会使两个数据库产生不一致的结果
  • 执行如下操作时请小心:
    • 自动增长列和标识列不支持集群,当插入数据时,序列值需要手动创建不支持 SET AUTOCOMMIT FALSE 语句;
    • 如果需要设置成为不自动提交,可以执行方法 Connection.setAutoCommit(false)

参考资料

🚪 传送

◾ 💧 钝悟的 IT 知识图谱

PostgreSQL

PostgreSQL 是一个关系型数据库(RDBM)。

关键词:Database, RDBM, psql

img

安装

本文仅以运行在 Centos 环境下举例。

进入官方下载页面,根据操作系统选择合适版本。

官方下载页面要求用户选择相应版本,然后动态的给出安装提示,如下图所示:

img

前 3 步要求用户选择,后 4 步是根据选择动态提示的安装步骤

(1)选择 PostgreSQL 版本

(2)选择平台

(3)选择架构

(4)安装 PostgreSQL 的 rpm 仓库(为了识别下载源)

1
yum install https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm

(5)安装客户端

1
yum install postgresql10

(6)安装服务端(可选的)

1
yum install postgresql10-server

(7)设置开机启动(可选的)

1
2
3
/usr/pgsql-10/bin/postgresql-10-setup initdb
systemctl enable postgresql-10
systemctl start postgresql-10

添加新用户和新数据库

初次安装后,默认生成一个名为 postgres 的数据库和一个名为 postgres 的数据库用户。这里需要注意的是,同时还生成了一个名为 postgres 的 Linux 系统用户。

首先,新建一个 Linux 新用户,可以取你想要的名字,这里为 dbuser。

1
sudo adduser dbuser

使用 psql 命令登录 PostgreSQL 控制台:

1
sudo -u postgres psql

这时相当于系统用户 postgres 以同名数据库用户的身份,登录数据库,这是不用输入密码的。如果一切正常,系统提示符会变为”postgres=#”,表示这时已经进入了数据库控制台。以下的命令都在控制台内完成。

(1)使用 \password 命令,为 postgres 用户设置一个密码。

1
postgres=# \password postgres

(2)创建数据库用户 dbuser(刚才创建的是 Linux 系统用户),并设置密码。

1
CREATE USER dbuser WITH PASSWORD 'password';

(3)创建用户数据库,这里为 exampledb,并指定所有者为 dbuser。

1
CREATE DATABASE exampledb OWNER dbuser;

(4)将 exampledb 数据库的所有权限都赋予 dbuser,否则 dbuser 只能登录控制台,没有任何数据库操作权限。

1
GRANT ALL PRIVILEGES ON DATABASE exampledb to dbuser;

(5)使用\q 命令退出控制台(也可以直接按 ctrl+D)。

登录数据库

添加新用户和新数据库以后,就要以新用户的名义登录数据库,这时使用的是 psql 命令。

1
psql -U dbuser -d exampledb -h 127.0.0.1 -p 5432

上面命令的参数含义如下:-U 指定用户,-d 指定数据库,-h 指定服务器,-p 指定端口。

输入上面命令以后,系统会提示输入 dbuser 用户的密码。输入正确,就可以登录控制台了。

psql 命令存在简写形式。如果当前 Linux 系统用户,同时也是 PostgreSQL 用户,则可以省略用户名(-U 参数的部分)。举例来说,我的 Linux 系统用户名为 ruanyf,且 PostgreSQL 数据库存在同名用户,则我以 ruanyf 身份登录 Linux 系统后,可以直接使用下面的命令登录数据库,且不需要密码。

1
psql exampledb

此时,如果 PostgreSQL 内部还存在与当前系统用户同名的数据库,则连数据库名都可以省略。比如,假定存在一个叫做 ruanyf 的数据库,则直接键入 psql 就可以登录该数据库。

psql

另外,如果要恢复外部数据,可以使用下面的命令。

1
psql exampledb < exampledb.sql

控制台命令

除了前面已经用到的 \password 命令(设置密码)和 \q 命令(退出)以外,控制台还提供一系列其他命令。

1
2
3
4
5
6
7
8
9
10
\password           设置密码
\q 退出
\h 查看SQL命令的解释,比如\h select
\? 查看psql命令列表
\l 列出所有数据库
\c [database_name] 连接其他数据库
\d 列出当前数据库的所有表格
\d [table_name] 列出某一张表格的结构
\x 对数据做展开操作
\du 列出所有用户

数据库操作

基本的数据库操作,就是使用一般的 SQL 语言。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 创建新表
CREATE TABLE user_tbl(name VARCHAR(20), signup_date DATE);
# 插入数据
INSERT INTO user_tbl(name, signup_date) VALUES('张三', '2013-12-22');
# 选择记录
SELECT * FROM user_tbl;
# 更新数据
UPDATE user_tbl set name = '李四' WHERE name = '张三';
# 删除记录
DELETE FROM user_tbl WHERE name = '李四' ;
# 添加栏位
ALTER TABLE user_tbl ADD email VARCHAR(40);
# 更新结构
ALTER TABLE user_tbl ALTER COLUMN signup_date SET NOT NULL;
# 更名栏位
ALTER TABLE user_tbl RENAME COLUMN signup_date TO signup;
# 删除栏位
ALTER TABLE user_tbl DROP COLUMN email;
# 表格更名
ALTER TABLE user_tbl RENAME TO backup_tbl;
# 删除表格
DROP TABLE IF EXISTS backup_tbl;

备份和恢复

1
2
pg_dump --format=t -d db_name -U user_name -h 127.0.0.1 -O -W  > dump.sql
psql -h 127.0.0.1 -U user_name db_name < dump.sql

参考资料

🚪 传送

◾ 💧 钝悟的 IT 知识图谱

SQLite

SQLite 是一个无服务器的、零配置的、事务性的的开源数据库引擎。
💻 完整示例源码

SQLite 简介

SQLite 是一个C语言编写的轻量级、全功能、无服务器、零配置的的开源数据库引擎。

SQLite 的设计目标是嵌入式的数据库,很多嵌入式产品中都使用了它。SQLite 十分轻量,占用资源非常的低,在嵌入式设备中,可能只需要几百K的内存就够了。SQLite 能够支持Windows/Linux/Unix等等主流的操作系统,同时能够跟很多程序语言相结合,同样比起Mysql、PostgreSQL这两款开源的世界著名数据库管理系统来讲,它的处理速度比他们都快。

SQLite 大小只有 3M 左右,可以将整个 SQLite 嵌入到应用中,而不用采用传统的客户端/服务器(Client/Server)的架构。这样做的好处就是非常轻便,在许多智能设备和应用中都可以使用 SQLite,比如微信就采用了 SQLite 作为本地聊天记录的存储。

优点

  • SQLite 是自给自足的,这意味着不需要任何外部的依赖。
  • SQLite 是无服务器的、零配置的,这意味着不需要安装或管理。
  • SQLite 事务是完全兼容 ACID 的,允许从多个进程或线程安全访问。
  • SQLite 是非常小的,是轻量级的,完全配置时小于 400KiB,省略可选功能配置时小于 250KiB。
  • SQLite 支持 SQL92(SQL2)标准的大多数查询语言的功能。
  • 一个完整的 SQLite 数据库是存储在一个单一的跨平台的磁盘文件。
  • SQLite 使用 ANSI-C 编写的,并提供了简单和易于使用的 API。
  • SQLite 可在 UNIX(Linux, Mac OS-X, Android, iOS)和 Windows(Win32, WinCE, WinRT)中运行。

局限

特性 描述
RIGHT OUTER JOIN 只实现了 LEFT OUTER JOIN。
FULL OUTER JOIN 只实现了 LEFT OUTER JOIN。
ALTER TABLE 支持 RENAME TABLE 和 ALTER TABLE 的 ADD COLUMN variants 命令,不支持 DROP COLUMN、ALTER COLUMN、ADD CONSTRAINT。
Trigger 支持 支持 FOR EACH ROW 触发器,但不支持 FOR EACH STATEMENT 触发器。
VIEWs 在 SQLite 中,视图是只读的。您不可以在视图上执行 DELETE、INSERT 或 UPDATE 语句。
GRANT 和 REVOKE 可以应用的唯一的访问权限是底层操作系统的正常文件访问权限。

安装

Sqlite 可在 UNIX(Linux, Mac OS-X, Android, iOS)和 Windows(Win32, WinCE, WinRT)中运行。

一般,Linux 和 Mac 上会预安装 sqlite。如果没有安装,可以在官方下载地址下载合适安装版本,自行安装。

SQLite 语法

这里不会详细列举所有 SQL 语法,仅列举 SQLite 除标准 SQL 以外的,一些自身特殊的 SQL 语法。

📖 扩展阅读:标准 SQL 基本语法

大小写敏感

SQLite 是不区分大小写的,但也有一些命令是大小写敏感的,比如 GLOBglob 在 SQLite 的语句中有不同的含义。

注释

1
2
3
4
5
-- 单行注释
/*
多行注释1
多行注释2
*/

创建数据库

如下,创建一个名为 test 的数据库:

1
2
3
4
$ sqlite3 test.db
SQLite version 3.7.17 2013-05-20 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"

查看数据库

1
2
3
4
sqlite> .databases
seq name file
--- --------------- ----------------------------------------------------------
0 main /root/test.db

退出数据库

1
sqlite> .quit

附加数据库

假设这样一种情况,当在同一时间有多个数据库可用,您想使用其中的任何一个。

SQLite 的 ATTACH DATABASE 语句是用来选择一个特定的数据库,使用该命令后,所有的 SQLite 语句将在附加的数据库下执行。

1
2
3
4
5
6
sqlite> ATTACH DATABASE 'test.db' AS 'test';
sqlite> .databases
seq name file
--- --------------- ----------------------------------------------------------
0 main /root/test.db
2 test /root/test.db

🔔 注意:数据库名 maintemp 被保留用于主数据库和存储临时表及其他临时数据对象的数据库。这两个数据库名称可用于每个数据库连接,且不应该被用于附加,否则将得到一个警告消息。

分离数据库

SQLite 的 DETACH DATABASE 语句是用来把命名数据库从一个数据库连接分离和游离出来,连接是之前使用 ATTACH 语句附加的。

1
2
3
4
5
6
7
8
9
10
sqlite> .databases
seq name file
--- --------------- ----------------------------------------------------------
0 main /root/test.db
2 test /root/test.db
sqlite> DETACH DATABASE 'test';
sqlite> .databases
seq name file
--- --------------- ----------------------------------------------------------
0 main /root/test.db

备份数据库

如下,备份 test 数据库到 /home/test.sql

1
sqlite3 test.db .dump > /home/test.sql

恢复数据库

如下,根据 /home/test.sql 恢复 test 数据库

1
sqlite3 test.db < test.sql

SQLite 数据类型

SQLite 使用一个更普遍的动态类型系统。在 SQLite 中,值的数据类型与值本身是相关的,而不是与它的容器相关。

SQLite 存储类

每个存储在 SQLite 数据库中的值都具有以下存储类之一:

存储类 描述
NULL 值是一个 NULL 值。
INTEGER 值是一个带符号的整数,根据值的大小存储在 1、2、3、4、6 或 8 字节中。
REAL 值是一个浮点值,存储为 8 字节的 IEEE 浮点数字。
TEXT 值是一个文本字符串,使用数据库编码(UTF-8、UTF-16BE 或 UTF-16LE)存储。
BLOB 值是一个 blob 数据,完全根据它的输入存储。

SQLite 的存储类稍微比数据类型更普遍。INTEGER 存储类,例如,包含 6 种不同的不同长度的整数数据类型。

SQLite 亲和(Affinity)类型

SQLite 支持列的亲和类型概念。任何列仍然可以存储任何类型的数据,当数据插入时,该字段的数据将会优先采用亲缘类型作为该值的存储方式。SQLite 目前的版本支持以下五种亲缘类型:

亲和类型 描述
TEXT 数值型数据在被插入之前,需要先被转换为文本格式,之后再插入到目标字段中。
NUMERIC 当文本数据被插入到亲缘性为 NUMERIC 的字段中时,如果转换操作不会导致数据信息丢失以及完全可逆,那么 SQLite 就会将该文本数据转换为 INTEGER 或 REAL 类型的数据,如果转换失败,SQLite 仍会以 TEXT 方式存储该数据。对于 NULL 或 BLOB 类型的新数据,SQLite 将不做任何转换,直接以 NULL 或 BLOB 的方式存储该数据。需要额外说明的是,对于浮点格式的常量文本,如”30000.0”,如果该值可以转换为 INTEGER 同时又不会丢失数值信息,那么 SQLite 就会将其转换为 INTEGER 的存储方式。
INTEGER 对于亲缘类型为 INTEGER 的字段,其规则等同于 NUMERIC,唯一差别是在执行 CAST 表达式时。
REAL 其规则基本等同于 NUMERIC,唯一的差别是不会将”30000.0”这样的文本数据转换为 INTEGER 存储方式。
NONE 不做任何的转换,直接以该数据所属的数据类型进行存储。

SQLite 亲和类型(Affinity)及类型名称

下表列出了当创建 SQLite3 表时可使用的各种数据类型名称,同时也显示了相应的亲和类型:

数据类型 亲和类型
INT, INTEGER, TINYINT, SMALLINT, MEDIUMINT, BIGINT, UNSIGNED BIG INT, INT2, INT8 INTEGER
CHARACTER(20), VARCHAR(255), VARYING CHARACTER(255), NCHAR(55), NATIVE CHARACTER(70), NVARCHAR(100), TEXT, CLOB TEXT
BLOB, no datatype specified NONE
REAL, DOUBLE, DOUBLE PRECISION, FLOAT REAL
NUMERIC, DECIMAL(10,5), BOOLEAN, DATE, DATETIME NUMERIC

Boolean 数据类型

SQLite 没有单独的 Boolean 存储类。相反,布尔值被存储为整数 0(false)和 1(true)。

Date 与 Time 数据类型

SQLite 没有一个单独的用于存储日期和/或时间的存储类,但 SQLite 能够把日期和时间存储为 TEXT、REAL 或 INTEGER 值。

存储类 日期格式
TEXT 格式为 “YYYY-MM-DD HH:MM:SS.SSS” 的日期。
REAL 从公元前 4714 年 11 月 24 日格林尼治时间的正午开始算起的天数。
INTEGER 从 1970-01-01 00:00:00 UTC 算起的秒数。

您可以以任何上述格式来存储日期和时间,并且可以使用内置的日期和时间函数来自由转换不同格式。

SQLite 命令

快速开始

进入 SQLite 控制台

1
2
3
4
5
$ sqlite3
SQLite version 3.7.17 2013-05-20 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite>

进入 SQLite 控制台并指定数据库

1
2
3
4
5
$ sqlite3 test.db
SQLite version 3.7.17 2013-05-20 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite>

退出 SQLite 控制台

1
sqlite>.quit

查看命令帮助

1
sqlite>.help

常用命令清单

命令 描述
.backup ?DB? FILE 备份 DB 数据库(默认是 “main”)到 FILE 文件。
.bail ON|OFF 发生错误后停止。默认为 OFF。
.databases 列出数据库的名称及其所依附的文件。
.dump ?TABLE? 以 SQL 文本格式转储数据库。如果指定了 TABLE 表,则只转储匹配 LIKE 模式的 TABLE 表。
.echo ON|OFF 开启或关闭 echo 命令。
.exit 退出 SQLite 提示符。
.explain ON|OFF 开启或关闭适合于 EXPLAIN 的输出模式。如果没有带参数,则为 EXPLAIN on,及开启 EXPLAIN。
.header(s) ON|OFF 开启或关闭头部显示。
.help 显示消息。
.import FILE TABLE 导入来自 FILE 文件的数据到 TABLE 表中。
.indices ?TABLE? 显示所有索引的名称。如果指定了 TABLE 表,则只显示匹配 LIKE 模式的 TABLE 表的索引。
.load FILE ?ENTRY? 加载一个扩展库。
.log FILE|off 开启或关闭日志。FILE 文件可以是 stderr(标准错误)/stdout(标准输出)。
.mode MODE 设置输出模式,MODE 可以是下列之一:
csv 逗号分隔的值
column 左对齐的列
html HTML 的 <table> 代码
insert TABLE 表的 SQL 插入(insert)语句
line 每行一个值
list 由 .separator 字符串分隔的值
tabs 由 Tab 分隔的值
tcl TCL 列表元素
.nullvalue STRING 在 NULL 值的地方输出 STRING 字符串。
.output FILENAME 发送输出到 FILENAME 文件。
.output stdout 发送输出到屏幕。
.print STRING… 逐字地输出 STRING 字符串。
.prompt MAIN CONTINUE 替换标准提示符。
.quit 退出 SQLite 提示符。
.read FILENAME 执行 FILENAME 文件中的 SQL。
.schema ?TABLE? 显示 CREATE 语句。如果指定了 TABLE 表,则只显示匹配 LIKE 模式的 TABLE 表。
.separator STRING 改变输出模式和 .import 所使用的分隔符。
.show 显示各种设置的当前值。
.stats ON|OFF 开启或关闭统计。
.tables ?PATTERN? 列出匹配 LIKE 模式的表的名称。
.timeout MS 尝试打开锁定的表 MS 毫秒。
.width NUM NUM 为 “column” 模式设置列宽度。
.timer ON|OFF 开启或关闭 CPU 定时器。

实战

格式化输出

1
2
3
4
sqlite>.header on
sqlite>.mode column
sqlite>.timer on
sqlite>

输出结果到文件

1
2
3
4
5
6
7
8
9
sqlite> .mode list
sqlite> .separator |
sqlite> .output teyptest_file_1.txt
sqlite> select * from tbl1;
sqlite> .exit
$ cat test_file_1.txt
hello|10
goodbye|20
$

SQLite JAVA Client

(1)在官方下载地址下载 sqlite-jdbc-(VERSION).jar ,然后将 jar 包放在项目中的 classpath。

(2)通过 API 打开一个 SQLite 数据库连接。

执行方法:

1
2
3
4
5
6
7
8
> javac Sample.java
> java -classpath ".;sqlite-jdbc-(VERSION).jar" Sample # in Windows
or
> java -classpath ".:sqlite-jdbc-(VERSION).jar" Sample # in Mac or Linux
name = leo
id = 1
name = yui
id = 2

示例:

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
31
32
33
34
public class Sample {
public static void main(String[] args) {
Connection connection = null;
try {
// 创建数据库连接
connection = DriverManager.getConnection("jdbc:sqlite:sample.db");
Statement statement = connection.createStatement();
statement.setQueryTimeout(30); // 设置 sql 执行超时时间为 30s

statement.executeUpdate("drop table if exists person");
statement.executeUpdate("create table person (id integer, name string)");
statement.executeUpdate("insert into person values(1, 'leo')");
statement.executeUpdate("insert into person values(2, 'yui')");
ResultSet rs = statement.executeQuery("select * from person");
while (rs.next()) {
// 读取结果集
System.out.println("name = " + rs.getString("name"));
System.out.println("id = " + rs.getInt("id"));
}
} catch (SQLException e) {
// 如果错误信息是 "out of memory",可能是找不到数据库文件
System.err.println(e.getMessage());
} finally {
try {
if (connection != null) {
connection.close();
}
} catch (SQLException e) {
// 关闭连接失败
System.err.println(e.getMessage());
}
}
}
}

如何指定数据库文件

Windows

1
Connection connection = DriverManager.getConnection("jdbc:sqlite:C:/work/mydatabase.db");

Unix (Linux, Mac OS X, etc)

1
Connection connection = DriverManager.getConnection("jdbc:sqlite:/home/leo/work/mydatabase.db");

如何使用内存数据库

1
Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");

参考资料

🚪 传送

◾ 💧 钝悟的 IT 知识图谱

Cassandra

Apache Cassandra 是一个高度可扩展的分区行存储。行被组织成具有所需主键的表。

最新版本:v4.0

Quick Start

安装

先决条件

  • JDK8+
  • Python 2.7

简介

Apache Cassandra 是一套开源分布式 Key-Value 存储系统。它最初由 Facebook 开发,用于储存特别大的数据。

特性

主要特性

  • 分布式
  • 基于 column 的结构化
  • 高伸展性

Cassandra 的主要特点就是它不是一个数据库,而是由一堆数据库节点共同构成的一个分布式网络服务,对 Cassandra 的一个写操作,会被复制到其他节点上去,对 Cassandra 的读操作,也会被路由到某个节点上面去读取。对于一个 Cassandra 群集来说,扩展性能 是比较简单的事情,只管在群集里面添加节点就可以了。

突出特性

  • 模式灵活 - 使用 Cassandra,像文档存储,不必提前解决记录中的字段。你可以在系统运行时随意的添加或移除字段。这是一个惊人的效率提升,特别是在大型部署上。
  • 真正的可扩展性 - Cassandra 是纯粹意义上的水平扩展。为给集群添加更多容量,可以指向另一台电脑。你不必重启任何进程,改变应用查询,或手动迁移任何数据。
  • 多数据中心识别 - 你可以调整你的节点布局来避免某一个数据中心起火,一个备用的数据中心将至少有每条记录的完全复制。
  • 范围查询 - 如果你不喜欢全部的键值查询,则可以设置键的范围来查询。
  • 列表数据结构 - 在混合模式可以将超级列添加到 5 维。对于每个用户的索引,这是非常方便的。
  • 分布式写操作 - 有可以在任何地方任何时间集中读或写任何数据。并且不会有任何单点失败。

更多内容

🚪 传送

◾ 💧 钝悟的 IT 知识图谱

分布式 ID

分布式 ID 简介

什么是分布式 ID?

ID 是 Identity 的缩写,用于唯一的标识一条数据。分布式 ID,顾名思义,是用于在分布式系统中唯一标识数据的 ID

为什么需要分布式 ID?

传统数据库基本都支持针对单表生成唯一性的自增主键。随着数据的膨胀,单机成为了性能和容量的瓶颈。为了解决这个问题,有了分库分表技术。分库分表所要面临的第一个问题是:数据分布在不同机器上,数据库无法保证多个节点上产生的主键唯一。 这就需要用到分布式 ID 了,它起到了分布式系统中全局 ID 的作用。

分布式 ID 的设计目标

首先,分布式 ID 应该具备哪些特性呢?

  1. 全局唯一性 - 不能出现重复的 ID 号,既然是唯一标识,这是最基本的要求。
  2. 单调递增 - 保证下一个 ID 一定大于上一个 ID,例如事务版本号、IM 增量消息、排序等特殊需求。
  3. 高性能 - 分布式 ID 的生成速度要快,对本地资源消耗要小。
  4. 高可用 - 生成分布式 ID 的服务要保证可用性无限接近于 100%。
  5. 安全性 - ID 中不应包括敏感信息。

UUID

UUID 是通用唯一识别码(Universally Unique Identifier)的缩写,是一种 128 位的标识符,由32个16进制字符表示。UUID 会根据运行应用的计算机网卡 MAC 地址、时间戳、命名空间等元素,通过一定的随机算法产生

UUID 不保证全局唯一性,我们需要小心 ID 冲突(尽管这种可能性很小)。

维基百科 - UUID 中介绍了 5 种 UUID 算法。

版本 1

UUID 版本 1 根据时间和 MAC 地址生成 UUID

img

组成参数说明:

  • time_low - 与日期时间信息的低值有关
  • time_mid - 与日期时间信息的 mid 值有关
  • time_high_and_version - 与日期时间信息的高值有关
  • clock_seq_hi_and_reserved - 与计算机系统的内部时钟序列有关
  • MAC 地址 - 设备的 MAC 地址

版本 2

UUID 版本 2 根据时间和 MAC 地址、DCE Security 生成 UUID

它将版本 1 中的日期时间信息替换为本地域名。它没有被广泛使用,因为它降低了唯一性。

版本 3

UUID 版本 3 使用命名空间和名称生成 UUID命名空间本身是一个 UUID,URL 名称用作标识。二者组合后,通过 MD5 哈希算法计算生成 UUID。

img

版本 5

UUID 版本 5 和 版本 4 近似,都使用命名空间和名称生成 UUID。差异在于:版本 3 采用 MD5 作为哈希算法版本 5 采用 SHA1 作为哈希算法

img

版本 3 、版本 5 - 基于哈希命名空间标识符和名称生成 UUID,差异在于:版本 3 采用 MD5 作为哈希算法;版本 5 采用 SHA1 作为哈希算法。

版本 4

版本 4 随机生成 UUID,不包含其他 UUID 中使用的任何信息 (命名空间、MAC 地址、时间)。识别它的唯一方法是版本 4 UUID,字符只是 4 位于 UUID 第三部分的第一个位置。其他字符是随机生成的。

img

版本 4 是最常见的 UUID 实现,JDK 中也提供了实现,示例如下:

1
String uuid = UUID.randomUUID().toString();

UUID 的优缺点

  • 优点
    • 简单、生成速度较快(本地生成,不依赖其他服务)
  • 缺点
    • 无序 - 不能生成递增有序的数字,这不利于一些特定场景。如:MySQL InnoDB 存储引擎使用 B+ 树存储索引数据,索引数据在 B+ 树中是有序排列的。而 UUID 的无序性可能会引起数据位置频繁变动,严重影响性能。
    • 长度过长 - UUID 需要占用 32 个字节
    • 信息不安全 - 基于 MAC 地址生成 UUID 的算法,可能会造成 MAC 地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。

数据库自增序列

大多数数据库都支持自增主键。基于此特性,可以利用事务管理控制生成唯一 ID。

以 MySQL 举例,我们通过下面的方式即可。

(1)创建一个专用于生成 ID 的表

1
2
3
4
5
6
CREATE TABLE `sequence_id` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`stub` char(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。

(2)通过 replace into 来插入数据。

1
2
3
4
BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;

插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,具体步骤是这样的:

  • 第一步:尝试把数据插入到表中。
  • 第二步:如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。

这种方式的优缺点也比较明显:

  • 优点
    • 方案简单
    • 有序
    • ID 长度小
  • 缺点
    • 性能差
    • 每次获取 ID 都要访问一次数据库,增加了对数据库的压力
    • 不安全,根据发号数量信息可能推测出业务规模
    • 单点问题,如果数据库宕机会造成服务不可用,可以使用高可用方案来解决,但会增加复杂度

数据库生成号段

数据库自增序列这种模式,每次获取 ID 都要请求一次数据库。当请求并发量高时,会给数据库带来很大的压力,并且生成 ID 的性能也比较差。

可以采用批处理的思路来优化数据库自增序列方案。申请 ID 改为批量获取,不再一次只申请一个 ID,而是一次批量生成一个 segment(号段),号段的大小由 step(步长)控制。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。各个业务不同的发号需求用 biz_tag 字段来区分,每个 biz_tag 的 ID 获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对 biz_tag 分库分表就行。

以 MySQL 举例,我们通过下面的方式即可。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '',
`max_id` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL,
`description` varchar(256) DEFAULT NULL,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

insert into leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 2000, 'Test leaf Segment Mode Get Id')

重要字段说明:

  • biz_tag 用来区分业务
  • max_id 表示该 biz_tag 目前所被分配的 ID 号段的最大值
  • step 表示每次分配的号段长度。原来获取 ID 每次都需要写数据库,现在只需要把 step 设置得足够大,比如 1000。那么只有当 1000 个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从 1 减小到了 1/step。

大致架构如下图所示:

image

test_tag 在第一台 Leaf 机器上是 1~1000 的号段,当这个号段用完时,会去加载另一个长度为 step=1000 的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是 3001~4000。同时数据库对应的 biz_tag 这条数据的 max_id 会从 3000 被更新成 4000,更新号段的 SQL 语句如下:

1
2
3
4
Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit

数据库号段模式的优缺点:

  • 优点
    • 有序
    • ID 长度小
    • 效率比数据库自增序列方式高很多
  • 缺点
    • 号段使用完,还是需要向数据库发起事务更新,以获取新号段
    • 不安全,根据发号数量信息可能推测出业务规模
    • 单点问题,如果数据库宕机会造成服务不可用,可以使用高可用方案来解决,但会增加复杂度

扩展:滴滴的 tinyid 和美团的 Leaf 都是基于数据库生成号段方案实现的,不过都各自做了一些优化。

美团技术团队还对分布式 ID 生成做了一篇技术分享:Leaf——美团点评分布式 ID 生成系统,其对于数据库号段模式的优化要点如下:

  • Leaf 采用双 Buffer 优化,避免号段耗尽时,阻塞以获取新号段。其本质上是:通过双缓存,提前预热号段缓存。
  • 此外,基于 Atlas(以改名 DBProxy)保障数据库的高可用。也就是保护了号段数据存储的高可用。

原子计数器

一些 NoSQL 数据库提供了原子性的计数器,可以基于这点,来实现分布式 ID。

Redis 生成自增键

Redis 的 String 类型提供 INCRINCRBY 命令将 key 中储存的数字原子递增

为避免单点问题,可以采用 Redis Cluster。

Redis 方案的优缺点:

  • 优点:高性能、有序
  • 缺点:和数据库自增序列方案的缺点类似

ZooKeeper 生成自增键

利用 ZooKeeper 中的顺序节点特性,很容易使我们创建的 ID 编码具有有序的特性。并且我们也可以通过客户端传递节点的名称,根据不同的业务编码区分不同的业务系统,从而使编码的扩展能力更强。

每个需要 ID 编码的业务服务器可以看作是 ZooKeeper 的客户端。ID 编码生成器可以作为 ZooKeeper 的服务端。客户端通过发送请求到 ZooKeeper 服务器,来获取编码信息,服务端接收到请求后,发送 ID 编码给客户端。

Drawing 2.png

可以利用 ZooKeeper 数据模型中的顺序节点作为 ID 编码。客户端通过调用 create 函数创建顺序节点。服务器成功创建节点后,会响应客户端请求,把创建好的节点信息发送给客户端。客户端用数据节点名称作为 ID 编码,进行之后的本地业务操作。

:::details 要点

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
31
32
33
34
35
36
37
@Slf4j
public class ZookeeperDistributedId {

public static void main(String[] args) throws Exception {

// 获取客户端
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);

// 开启会话
client.start();

String id1 = client.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT_SEQUENTIAL)
.forPath("/zkid/id_");
log.info("id: {}", id1);

String id2 = client.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT_SEQUENTIAL)
.forPath("/zkid/id_");
log.info("id: {}", id2);

List<String> children = client.getChildren().forPath("/zkid");
if (CollectionUtil.isNotEmpty(children)) {
for (String child : children) {
client.delete().forPath("/zkid/" + child);
}
}
client.delete().forPath("/zkid");

// 关闭客户端
client.close();
}

}

:::

ZooKeeper 方案的优缺点:

  • 优点:简单、可靠性高
  • 缺点:性能不高

雪花算法(Snowflake)

雪花算法(Snowflake)是由 Twitter 公布的分布式主键生成算法,它会生成一个 64 bit 的整数,可以保证不同进程主键的不重复性,以及相同进程主键的有序性。在同一个进程中,它首先是通过时间位保证不重复,如果时间相同则是通过序列位保证。 同时由于时间位是单调递增的,且各个服务器如果大体做了时间同步,那么生成的主键在分布式环境可以认为是总体有序的,这就保证了对索引字段的插入的高效性。

键的组成

使用雪花算法生成的主键,二进制表示形式包含 4 部分,从高位到低位分表为:1bit 符号位、41bit 时间戳位、10bit 工作进程位以及 12bit 序列号位。

  • 符号位 (1bit)

预留的符号位,恒为零。

  • 时间戳位 (41bit)

41 位的时间戳可以容纳的毫秒数是 2 的 41 次幂,一年所使用的毫秒数是:365 * 24 * 60 * 60 * 1000。通过计算可知:

1
Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L);

结果约等于 69.73 年。ShardingSphere 的雪花算法的时间纪元从 2016 年 11 月 1 日零点开始,可以使用到 2086 年,相信能满足绝大部分系统的要求。

  • 工作进程位 (10bit)

该标志在 Java 进程内是唯一的,如果是分布式应用部署应保证每个工作进程的 id 是不同的。该值默认为 0,可通过属性设置。

  • 序列号位 (12bit)

该序列是用来在同一个毫秒内生成不同的 ID。如果在这个毫秒内生成的数量超过 4096(2 的 12 次幂),那么生成器会等待到下个毫秒继续生成。

雪花算法主键的详细结构见下图:

雪花算法

时钟回拨

服务器时钟回拨会导致产生重复序列,因此默认分布式主键生成器提供了一个最大容忍的时钟回拨毫秒数。 如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序报错;如果在可容忍的范围内,默认分布式主键生成器会等待时钟同步到最后一次主键生成的时间后再继续工作。 最大容忍的时钟回拨毫秒数的默认值为 0,可通过属性设置。

雪花算法是强依赖于时间的,而如果机器时间发生回拨,有可能会生成重复的 ID。

我们可以针对算法做一些优化,来防止时钟回拨生成重复 ID。

用当前时间和上一次的时间进行判断,如果当前时间小于上一次的时间那么肯定是发生了回拨。普通的算法会直接抛出异常,这里我们可以对其进行优化,一般分为两个情况:

  • 如果时间回拨时间较短,比如配置 5ms 以内,那么可以直接等待一定的时间,让机器的时间追上来。
  • 如果时间的回拨时间较长,我们不能接受这么长的阻塞等待,那么又有两个策略:
    • 直接拒绝,抛出异常。打日志,通知 RD 时钟回滚。
    • 利用扩展位。上面我们讨论过,不同业务场景位数可能用不到那么多比特位,那么我们可以把扩展位数利用起来。比如:当这个时间回拨比较长的时候,我们可以不需要等待,直接在扩展位加 1。两位的扩展位允许我们有三次大的时钟回拨,一般来说就够了,如果其超过三次我们还是选择抛出异常,打日志。

灵活定制

上面只是一个将 64bit 划分的标准,当然也不一定这么做,可以根据不同业务的具体场景来划分,比如下面给出一个业务场景:

  • 服务目前 QPS10 万,预计几年之内会发展到百万。
  • 当前机器三地部署,上海,北京,深圳都有。
  • 当前机器 10 台左右,预计未来会增加至百台。

这个时候我们根据上面的场景可以再次合理的划分 62bit,QPS 几年之内会发展到百万,那么每毫秒就是千级的请求,目前 10 台机器那么每台机器承担百级的请求,为了保证扩展,后面的循环位可以限制到 1024,也就是 2^10,那么循环位 10 位就足够了。

机器三地部署我们可以用 3bit 总共 8 来表示机房位置,当前的机器 10 台,为了保证扩展到百台那么可以用 7bit 128 来表示,时间位依然是 41bit,那么还剩下 64-10-3-7-41-1 = 2bit,还剩下 2bit 可以用来进行扩展。

img

雪花算法小结

雪花算法的利弊

  • 优点
    • 生成的 ID 都是趋势递增的。
    • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成 ID 的性能也是非常高的。
    • 可以根据自身业务特性分配 bit 位,非常灵活。
  • 缺点
    • 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

雪花算法的适用场景

当我们需要无序不能被猜测的 ID,并且需要一定高性能,且需要 long 型,那么就可以使用我们雪花算法。比如常见的订单 ID,用雪花算法别人就无法猜测你每天的订单量是多少。

参考资料

oh-my-zsh 应用

Zsh 简介

Zsh 是什么

使用 Linux 的人都知道:*Shell 是一个用 C 语言编写的程序,它是用户使用 Linux 的桥梁。_Shell 既是一种命令语言,又是一种程序设计语言**。

Shell 的类型有很多种,linux 下默认的是 bash,虽然 bash 的功能已经很强大,但对于以懒惰为美德的程序员来说,bash 的提示功能不够强大,界面也不够炫,并非理想工具。

Zsh 也是一种 Shell(据传说 99% 的 Bash 操作 和 Zsh 是相同的),它的功能极其强大,只是配置过于复杂,起初只有极客才在用。后来,出现了一个名叫 oh-my-zsh 的开源项目,使用 zsh 就变得十分简易了。

Zsh 安装

环境要求

  • CentOS 6.7 64 bit
  • root 用户

安装 zsh

  • 先看下你的 CentOS 支持哪些 shell:cat /etc/shells,正常结果应该是这样的:
1
2
3
4
5
6
/bin/sh
/bin/bash
/sbin/nologin
/bin/dash
/bin/tcsh
/bin/csh

如果已经有 zsh ,那么我们就不必安装了。

  • CentOS 安装:sudo yum install -y zsh
  • Ubuntu 安装:sudo apt-get install -y zsh
  • 检查系统的 shell:cat /etc/shells,你会发现多了一个:/bin/zsh

安装 oh-my-zsh

使用 Zsh,怎么能离开灵魂伴侣 oh-my-zsh

1
2
# 安装 oh-my-zsh
wget https://github.com/robbyrussell/oh-my-zsh/raw/master/tools/install.sh -O - | sh

配置 oh-my-zsh

插件

oh-my-zsh 插件太多,不一一列举,请参考:oh-my-zsh 插件列表

  • 启用 oh-my-zsh 中自带的插件。
  • 查看 oh-my-zsh 插件数:ls -l /root/.oh-my-zsh/plugins |grep "^d"|wc -l
  • 编辑配置文件:vim /root/.zshrc
  • 插件推荐:
    • zsh-autosuggestions
      • 这个插件会对历史命令一些补全,类似 fish 终端
      • 安装,复制该命令:git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-\~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions - 编辑:vim \~/.zshrc,找到这一行,后括号里面的后面添加:plugins=( 前面的一些插件名称,换行,加上:zsh-autosuggestions) - 刷新下配置:source \~/.zshrc
    • extract
      • 功能强大的解压插件,所有类型的文件解压一个命令 x 全搞定,再也不需要去记 tar 后面到底是哪几个参数了。
    • z
      • 强大的目录自动跳转命令,会记忆你曾经进入过的目录,用模糊匹配快速进入你想要的目录。
    • zsh-syntax-highlighting
      • 这个插件会对终端命令高亮显示,比如正确的拼写会是绿色标识,否则是红色,另外对于一些 shell 输出语句也会有高亮显示,算是不错的辅助插件
      • 安装,复制该命令:git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-\~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
      • 编辑:vim \~/.zshrc,找到这一行,后括号里面的后面添加:plugins=( 前面的一些插件名称,换行,加上:zsh-syntax-highlighting) - 刷新下配置:source \~/.zshrc
    • wd
      • 简单地讲就是给指定目录映射一个全局的名字,以后方便直接跳转到这个目录,比如:
      • 编辑配置文件,添加上 wd 的名字:vim /root/.zshrc
      • 我常去目录:**/opt/setups**,每次进入该目录下都需要这样:cd /opt/setups
      • 现在用 wd 给他映射一个快捷方式:cd /opt/setups ; wd add setups
      • 以后我在任何目录下只要运行:wd setups 就自动跑到 /opt/setups 目录下了
    • autojump
      • 这个插件会记录你常去的那些目录,然后做一下权重记录,你可以用这个命令看到你的习惯:j --stat,如果这个里面有你的记录,那你就只要敲最后一个文件夹名字即可进入,比如我个人习惯的 program:j program,就可以直接到:/usr/program
      • 插件下载:wget https://github.com/downloads/wting/autojump/autojump_v21.1.2.tar.gz
      • 解压:tar zxvf autojump_v21.1.2.tar.gz
      • 进入解压后目录并安装:cd autojump_v21.1.2/ ; ./install.sh
      • 再执行下这个:source /etc/profile.d/autojump.sh
      • 编辑配置文件,添加上 autojump 的名字:vim /root/.zshrc

主题

oh-my-zsh 主题太多,不一一列举,请参考:oh-my-zsh 主题列表

  • 查看 oh-my-zsh 主题数:ls -l /root/.oh-my-zsh/themes |grep "^-"|wc -l
  • 个人比较推荐的是(排名有先后):
    • ys
    • agnoster
    • avit
    • blinks
  • 编辑配置文件:vim /root/.zshrc
  • 配置好新主题需要重新连接 shell 才能看到效果

zsh 效果如下:

img

快捷键

  • 呃,这个其实可以不用讲的,你自己用的时候你自己会发现的,各种便捷,特别是用 Tab 多的人一定会有各种惊喜的。
  • 使用 ctrl-r 来搜索命令历史记录。按完此快捷键后,可以输入关键命令词语,如果历史记录有含有此词语会显示出来。
  • 命令别名: - 在命令行中输入 alias 可以查看已经有的命令别名 - 自己新增一些别名,编辑文件:vim \~/.zshrc,在文件加入下面格式的命令,比如以下是网友提供的一些思路:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
alias cls='clear'
alias ll='ls -l'
alias la='ls -a'
alias grep="grep --color=auto"
alias -s html='vim' # 在命令行直接输入后缀为 html 的文件名,会在 Vim 中打开
alias -s rb='vim' # 在命令行直接输入 ruby 文件,会在 Vim 中打开
alias -s py='vim' # 在命令行直接输入 python 文件,会用 vim 中打开,以下类似
alias -s js='vim'
alias -s c='vim'
alias -s java='vim'
alias -s txt='vim'
alias -s gz='tar -xzvf' # 在命令行直接输入后缀为 gz 的文件名,会自动解压打开
alias -s tgz='tar -xzvf'
alias -s zip='unzip'
alias -s bz2='tar -xjvf'

参考资料

缓存基本原理

缓存是一种利用空间换时间的设计,其目标就是更快更近

缓存简介

为什么需要缓存

众所周知,当今是一个互联网时代,而互联网应用几乎遍及我们日常生活的方方面面。一般而言,一个互联网应用的请求/响应流程会有以下几个主要流程:

  • 客户端发起请求,请求经过网络 I/O,分发到服务层。
  • 服务层可能有多级服务,请求需要被多个服务层层处理。
  • 不同服务根据请求进行计算时,可能依赖于不同数据库的数据,需要通过网络 I/O 读写数据库。

显然,这一套流程下来,可能需要消耗大量的计算机资源,并且响应时间也可能很久。如果并发请求量很大的话,可能会进一步加剧这种问题。

img

为了解决以上问题,最直接的方式就是引入缓存。缓存可以作用于请求/响应流程的任意环节,并有效减少后续环节的执行次数,从而大大提升性能。

实际上,缓存作为性能优化的第一手段,被广泛应用于计算机的硬件、软件领域。

什么是缓存

缓存就是数据交换的缓冲区,用于将频繁访问的数据暂存在访问速度快的存储介质

缓存的本质是一种利用空间换时间的设计:牺牲一定的数据实时性,使得访问更快更近

  • 将数据存储到读取速度更快的存储(设备);
  • 将数据存储到离应用最近的位置;
  • 将数据存储到离用户最近的位置。

缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。典型的应用场景:有 cpu cache, 磁盘 cache 等。本文中提及到缓存主要是指互联网应用中所使用的缓存组件。

缓存命中率是缓存的重要度量指标,命中率越高越好。

1
缓存命中率 = 从缓存中读取次数 / 总读取次数

何时需要缓存

引入缓存,会增加系统的复杂度,并牺牲一定的数据实时性。所以,引入缓存前,需要先权衡是否值得,考量点如下:

  • CPU 开销 - 如果应用某个计算需要消耗大量 CPU,可以考虑缓存其计算结果。典型场景:复杂的、频繁调用的正则计算;分布式计算中间状态等。
  • IO 开销 - 如果数据库连接池比较繁忙,可以考虑缓存其查询结果。

在数据层引入缓存,有以下几个好处:

  • 提升数据读取速度。
  • 提升系统扩展能力,通过扩展缓存,提升系统承载能力。
  • 降低存储成本,Cache+DB 的方式可以承担原有需要多台 DB 才能承担的请求量,节省机器成本。

缓存的基本原理

根据业务场景,通常缓存有以下几种使用方式:

  • 懒汉式(读时触发):先查询 DB 里的数据,然后把相关的数据写入 Cache。
  • 饥饿式(写时触发):写入 DB 后,然后把相关的数据也写入 Cache。
  • 定期刷新:适合周期性的跑数据的任务,或者列表型的数据,而且不要求绝对实时性。

缓存的分类

缓存从架构维度来看,可以分为客户端缓存和服务端缓存。

缓存从集群维度来看,可以分为进程内缓存和分布式缓存。

客户端缓存

  • Http 缓存:HTTP/1.1 中的 Cache-Control、HTTP/1 中的 Expires
  • 浏览器缓存:HTML5 提供的 SessionStorage 和 LocalStorage、Cookie
  • APP 缓存
    • Android
    • IOS

服务端缓存

  • CDN 缓存 - 存放 HTML、CSS、JS 等静态资源。
  • 反向代理缓存 - 动静分离,只缓存用户请求的静态资源。
  • 数据库缓存 - 数据库(如 Mysql)自身一般也有缓存,但因为命中率和更新频率问题,不推荐使用。
  • 进程内缓存 - 缓存应用字典等常用数据。
  • 分布式缓存 - 缓存数据库中的热点数据。

其中,CDN 缓存、反向代理缓存、数据库缓存一般由专职人员维护(运维、DBA)。

后端开发一般聚焦于进程内缓存、分布式缓存。

HTTP 缓存

  1. 浏览器发送请求前,根据请求头的 expires (HTTP/1) 和 cache-control (HTTP/1.1) 判断是否命中(包括是否过期)强缓存策略,

    1. 如果命中,直接从缓存获取资源,并不会发送请求。
    2. 如果没有命中,则进入下一步。
  2. 没有命中强缓存规则,浏览器会发送请求,根据请求头的 last-modifiedetag 判断是否命中协商缓存,如果命中,直接从缓存获取资源。如果没有命中,则进入下一步。

  3. 如果前两步都没有命中,则直接从服务端获取资源。

CDN 缓存

CDN 将数据缓存到离用户物理距离最近的服务器,使得用户可以就近获取请求内容。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)

国内网络异常复杂,跨运营商的网络访问会很慢。为了解决跨运营商或各地用户访问问题,可以在重要的城市,部署 CDN 应用。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

img

CDN 原理

CDN 的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布到用户访问相对集中的地区或网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。

(1)未部署 CDN 应用前的网络路径:

  • 请求:本机网络(局域网)=> 运营商网络 => 应用服务器机房
  • 响应:应用服务器机房 => 运营商网络 => 本机网络(局域网)

在不考虑复杂网络的情况下,从请求到响应需要经过 3 个节点,6 个步骤完成一次用户访问操作。

(2)部署 CDN 应用后网络路径:

  • 请求:本机网络(局域网) => 运营商网络
  • 响应:运营商网络 => 本机网络(局域网)

在不考虑复杂网络的情况下,从请求到响应需要经过 2 个节点,2 个步骤完成一次用户访问操作。

与不部署 CDN 服务相比,减少了 1 个节点,4 个步骤的访问。极大的提高了系统的响应速度。

CDN 特点

优点

  • 本地 Cache 加速 - 提升访问速度,尤其含有大量图片和静态页面站点;
  • 实现跨运营商的网络加速 - 消除了不同运营商之间互联的瓶颈造成的影响,实现了跨运营商的网络加速,保证不同网络中的用户都能得到良好的访问质量;
  • 远程加速 - 远程访问用户根据 DNS 负载均衡技术智能自动选择 Cache 服务器,选择最快的 Cache 服务器,加快远程访问的速度;
  • 带宽优化 - 自动生成服务器的远程 Mirror(镜像)cache 服务器,远程用户访问时从 cache 服务器上读取数据,减少远程访问的带宽、分担网络流量、减轻原站点 WEB 服务器负载等功能。
  • 集群抗攻击 - 广泛分布的 CDN 节点加上节点之间的智能冗余机制,可以有效地预防黑客入侵以及降低各种 D.D.o.S 攻击对网站的影响,同时保证较好的服务质量。

缺点

  • 不适宜缓存动态资源
    • 解决方案:主要缓存静态资源,动态资源建立多级缓存或准实时同步;
  • 存在数据的一致性问题
    • 解决方案(主要是在性能和数据一致性二者间寻找一个平衡)
    • 设置缓存失效时间(1 个小时,过期后同步数据)。
    • 针对资源设置版本号。

反向代理缓存

反向代理(Reverse Proxy)方式是指以代理服务器来接受网络连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个反向代理服务器。

img

反向代理缓存原理

反向代理位于应用服务器同一网络,处理所有对 WEB 服务器的请求。

反向代理缓存的原理:

  • 如果用户请求的页面在代理服务器上有缓存的话,代理服务器直接将缓存内容发送给用户。
  • 如果没有缓存则先向 WEB 服务器发出请求,取回数据,本地缓存后再发送给用户。

这种方式通过降低向 WEB 服务器的请求数,从而降低了 WEB 服务器的负载。

反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。常用的缓存应用服务器有 Varnish,Ngnix,Squid。

反向代理缓存比较

常用的代理缓存有 Varnish,Squid,Ngnix,简单比较如下:

  • Varnish 和 Squid 是专业的 cache 服务,Ngnix 需要第三方模块支持;
  • Varnish 采用内存型缓存,避免了频繁在内存、磁盘中交换文件,性能比 Squid 高;
  • Varnish 由于是内存 cache,所以对小文件如 css、js、小图片的支持很棒,后端的持久化缓存可以采用的是 Squid 或 ATS;
  • Squid 功能全而大,适合于各种静态的文件缓存,一般会在前端挂一个 HAProxy 或 Ngnix 做负载均衡跑多个实例;
  • Nginx 采用第三方模块 ncache 做的缓冲,性能基本达到 Varnish,一般作为反向代理使用,可以实现简单的缓存。

进程内缓存

进程内缓存是指应用内部的缓存,标准的分布式系统,一般有多级缓存构成。本地缓存是离应用最近的缓存,一般可以将数据缓存到硬盘或内存。

  • 硬盘缓存 - 将数据缓存到硬盘中,读取时从硬盘读取。原理是直接读取本机文件,减少了网络传输消耗,比通过网络读取数据库速度更快。可以应用在对速度要求不是很高,但需要大量缓存存储的场景。
  • 内存缓存 - 直接将数据存储到本机内存中,通过程序直接维护缓存对象,是访问速度最快的方式。

常见的本地缓存实现方案:HashMap、Guava Cache、Caffeine、Ehcache。

ConcurrentHashMap

最简单的进程内缓存可以通过 JDK 自带的 HashMapConcurrentHashMap 实现。

适用场景:不需要淘汰的缓存数据

缺点:无法进行缓存淘汰,内存会无限制的增长。

LRUHashMap

可以通过**继承 LinkedHashMap 来实现一个简单的 LRUHashMap**。重写 removeEldestEntry 方法,即可完成一个简单的最近最少使用算法。

缺点:

  • 锁竞争严重,性能比较低。
  • 不支持过期时间
  • 不支持自动刷新

Guava Cache

解决了 LRUHashMap 中的几个缺点。

Guava Cache 采用了类似 ConcurrentHashMap 的思想,分段加锁,减少锁竞争。

Guava Cache 对于过期的 Entry 并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁。

直接通过查询,判断其是否满足刷新条件,进行刷新。

Caffeine

Caffeine 实现了 W-TinyLFU(LFU + LRU 算法的变种),其命中率和读写吞吐量大大优于 Guava Cache

其实现原理较复杂,可以参考 你应该知道的缓存进化史

Ehcache

EhCache 是一个纯 Java 的进程内缓存框架,具有快速、精干等特点,是 Hibernate 中默认的 CacheProvider。

优点

  • 快速、简单
  • 支持多种缓存策略:LRU、LFU、FIFO 淘汰算法
  • 缓存数据有两级:内存和磁盘,因此无需担心容量问题
  • 缓存数据会在虚拟机重启的过程中写入磁盘
  • 可以通过 RMI、可插入 API 等方式进行分布式缓存
  • 具有缓存和缓存管理器的侦听接口
  • 支持多缓存管理器实例,以及一个实例的多个缓存区域
  • 提供 Hibernate 的缓存实现

缺点

  • 使用磁盘 Cache 的时候非常占用磁盘空间
  • 不保证数据的安全
  • 虽然支持分布式缓存,但效率不高(通过组播方式,在不同节点之间同步数据)。

进程内缓存对比

常用进程内缓存技术对比:

比较项 ConcurrentHashMap LRUMap Ehcache Guava Cache Caffeine
读写性能 很好,分段锁 一般,全局加锁 好,需要做淘汰操作 很好
淘汰算法 LRU,一般 支持多种淘汰算法,LRU,LFU,FIFO LRU,一般 W-TinyLFU, 很好
功能丰富程度 功能比较简单 功能比较单一 功能很丰富 功能很丰富,支持刷新和虚引用等 功能和 Guava Cache 类似
工具大小 jdk 自带类,很小 基于 LinkedHashMap,较小 很大,最新版本 1.4MB 是 Guava 工具类中的一个小部分,较小 一般,最新版本 644KB
是否持久化
是否支持集群
  • ConcurrentHashMap - 比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是其由于是 JDK 自带的类,在各种框架中依然有大量的使用,比如我们可以用来缓存我们反射的 Method,Field 等等;也可以缓存一些链接,防止其重复建立。在 Caffeine 中也是使用的 ConcurrentHashMap 来存储元素。
  • LRUMap - 如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。
  • Ehcache - 由于其 jar 包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择 Ehcache。需要注意的是,虽然 Ehcache 也支持分布式缓存,但是由于其节点间通信方式为 rmi,表现不如 Redis,所以一般不建议用它来作为分布式缓存。
  • Guava Cache - Guava 这个 jar 包在很多 Java 应用程序中都有大量的引入,所以很多时候其实是直接用就好了,并且其本身是轻量级的而且功能较为丰富,在不了解 Caffeine 的情况下可以选择 Guava Cache。
  • Caffeine - 其在命中率,读写性能上都比 Guava Cache 好很多,并且其 API 和 Guava cache 基本一致,甚至会多一点。在真实环境中使用 Caffeine,取得过不错的效果。

总结一下:**如果不需要淘汰算法则选择 ConcurrentHashMap,如果需要淘汰算法和一些丰富的 API,推荐选择 Caffeine**。

分布式缓存

分布式缓存解决了进程内缓存最大的问题:如果应用是分布式系统,节点之间无法共享彼此的进程内缓存

分布式缓存的应用场景:

  • 缓存经过复杂计算得到的数据
  • 缓存系统中频繁访问的热点数据,减轻数据库压力

不同分布式缓存的实现原理往往有比较大的差异。本文主要针对 Memcached 和 Redis 进行说明。

Memcached

Memcached 是一个高性能,分布式内存对象缓存系统,通过在内存里维护一个统一的巨大的 hash 表,它能够用来存储各种格式的数据,包括图像、视频、文件以及数据库检索的结果等。

简单的说就是:将数据缓存到内存中,然后从内存中读取,从而大大提高读取速度。

Memcached 特性

  • 使用物理内存作为缓存区,可独立运行在服务器上。每个进程最大 2G,如果想缓存更多的数据,可以开辟更多的 Memcached 进程(不同端口)或者使用分布式 Memcached 进行缓存,将数据缓存到不同的物理机或者虚拟机上。
  • 使用 key-value 的方式来存储数据。这是一种单索引的结构化数据组织形式,可使数据项查询时间复杂度为 O(1)。
  • 协议简单,基于文本行的协议。直接通过 telnet 在 Memcached 服务器上可进行存取数据操作,简单,方便多种缓存参考此协议;
  • 基于 libevent 高性能通信。Libevent 是一套利用 C 开发的程序库,它将 BSD 系统的 kqueue,Linux 系统的 epoll 等事件处理功能封装成一个接口,与传统的 select 相比,提高了性能。
  • 分布式能力取决于 Memcached 客户端,服务器之间互不通信。各个 Memcached 服务器之间互不通信,各自独立存取数据,不共享任何信息。服务器并不具有分布式功能,分布式部署取决于 Memcached 客户端。
  • 采用 LRU 缓存淘汰策略。在 Memcached 内存储数据项时,可以指定它在缓存的失效时间,默认为永久。当 Memcached 服务器用完分配的内时,失效的数据被首先替换,然后也是最近未使用的数据。在 LRU 中,Memcached 使用的是一种 Lazy Expiration 策略,自己不会监控存入的 key/vlue 对是否过期,而是在获取 key 值时查看记录的时间戳,检查 key/value 对空间是否过期,这样可减轻服务器的负载。
  • 内置了一套高效的内存管理算法。这套内存管理效率很高,而且不会造成内存碎片,但是它最大的缺点就是会导致空间浪费。当内存满后,通过 LRU 算法自动删除不使用的缓存。
  • 不支持持久化。Memcached 没有考虑数据的容灾问题,重启服务,所有数据会丢失。

Memcached 工作原理

(1)内存管理

Memcached 利用 slab allocation 机制来分配和管理内存,它按照预先规定的大小,将分配的内存分割成特定长度的内存块,再把尺寸相同的内存块分成组,数据在存放时,根据键值 大小去匹配 slab 大小,找就近的 slab 存放,所以存在空间浪费现象。

这套内存管理效率很高,而且不会造成内存碎片,但是它最大的缺点就是会导致空间浪费。

(2)缓存淘汰策略

Memcached 的缓存淘汰策略是 LRU + 到期失效策略。

当你在 Memcached 内存储数据项时,你有可能会指定它在缓存的失效时间,默认为永久。当 Memcached 服务器用完分配的内时,失效的数据被首先替换,然后是最近未使用的数据。

在 LRU 中,Memcached 使用的是一种 Lazy Expiration 策略:Memcached 不会监控存入的 key/vlue 对是否过期,而是在获取 key 值时查看记录的时间戳,检查 key/value 对空间是否过期,这样可减轻服务器的负载。

(3)分区

Memcached 服务器之间彼此不通信,它的分布式能力是依赖客户端来实现。

具体来说,就是在客户端实现一种算法,根据 key 来计算出数据应该向哪个服务器节点读/写。

而这种选取集群节点的算法常见的有三种:

  • 哈希取余算法 - 使用公式:hash(key)% N 计算出 哈希值 来决定数据映射到哪一个节点。
  • 一致性哈希算法 - 可以很好的解决 稳定性问题,可以将所有的 存储节点 排列在 首尾相接Hash 环上,每个 key 在计算 Hash 后会 顺时针 找到 临接存储节点 存放。而当有节点 加入退出 时,仅影响该节点在 Hash 环上 顺时针相邻后续节点
  • 虚拟 Hash 槽算法 - 使用 分散度良好哈希函数 把所有数据 映射 到一个 固定范围整数集合 中,整数定义为 slot),这个范围一般 远远大于 节点数。 是集群内 数据管理迁移基本单位。采用 大范围槽 的主要目的是为了方便 数据拆分集群扩展。每个节点会负责 一定数量的槽

Redis

Redis 是一个开源(BSD 许可)的,基于内存的,多数据结构存储系统。可以用作数据库、缓存和消息中间件。

Redis 还可以使用客户端分片来扩展写性能。内置了 复制(replication),LUA 脚本(Lua scripting),LRU 驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis 哨兵(Sentinel)和自动分区(Cluster)提供高可用性(high availability)。

Redis 特性

  • 支持多种数据类型 - string、hash、list、set、sorted set。

  • 支持多种数据淘汰策略

    • volatile-lru - 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
    • volatile-ttl - 从已设置过期时间的数据集中挑选将要过期的数据淘汰
    • volatile-random - 从已设置过期时间的数据集中任意选择数据淘汰
    • allkeys-lru - 从所有数据集中挑选最近最少使用的数据淘汰
    • allkeys-random - 从所有数据集中任意选择数据进行淘汰
    • noeviction - 禁止驱逐数据
  • 提供两种持久化方式 - RDB 和 AOF

  • 通过 Redis cluster 提供集群模式。

Redis 原理

  • 缓存淘汰
    • Redis 有两种数据淘汰实现
      • 消极方式 - 访问 Redis key 时,如果发现它已经失效,则删除它
      • 积极方式 - 周期性从设置了失效时间的 key 中,根据淘汰策略,选择一部分失效的 key 进行删除。
  • 分区
    • Redis Cluster 集群包含 16384 个虚拟 Hash 槽,它通过一个高效的算法来计算 key 属于哪个 Hash 槽。
    • Redis Cluster 支持请求分发 - 节点在接到一个命令请求时,会先检测这个命令请求要处理的键所在的槽是否由自己负责,如果不是的话,节点将向客户端返回一个 MOVED 错误,MOVED 错误携带的信息可以指引客户端将请求重定向至正在负责相关槽的节点。
  • 主从复制
    • Redis 2.8 后支持异步复制。它有两种模式:
      • 完整重同步(full resychronization) - 用于初次复制。执行步骤与 SYNC 命令基本一致。
      • 部分重同步(partial resychronization) - 用于断线后重复制。如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只需接收并执行这些写命令,即可将主从服务器的数据库状态保持一致。
    • 集群中每个节点都会定期向集群中的其他节点发送 PING 消息,以此来检测对方是否在线。
    • 如果一个主节点被认为下线,则在其从节点中,根据 Raft 算法,选举出一个节点,升级为主节点。
  • 数据一致性
    • Redis 不保证强一致性,因为这会使得集群性能大大降低。
    • Redis 是通过异步复制来实现最终一致性。

分布式缓存对比

不同的分布式缓存功能特性和实现原理方面有很大的差异,因此他们所适应的场景也有所不同。

img

这里选取三个比较出名的分布式缓存(MemCache,Redis,Tair)来作为比较:

比较项 MemCache Redis Tair
数据结构 只支持简单的 Key-Value 结构 String,Hash, List, Set, Sorted Set String,HashMap, List,Set
持久化 不支持 支持 支持
容量大小 数据纯内存,数据存储不宜过多 数据全内存,资源成本考量不宜超过 100GB 可以配置全内存或内存+磁盘引擎,数据容量可无限扩充
读写性能 很高 很高 (RT0.5ms 左右) String 类型比较高 (RT1ms 左右),复杂类型比较慢 (RT5ms 左右)
过期策略 过期后,不删除缓存 有六种策略来处理过期数据 支持
  • MemCache - 只适合基于内存的缓存框架;且不支持数据持久化和容灾。
  • Redis - 支持丰富的数据结构,读写性能很高,但是数据全内存,必须要考虑资源成本,支持持久化。
  • Tair - 支持丰富的数据结构,读写性能较高,部分类型比较慢,理论上容量可以无限扩充。

总结:如果服务对延迟比较敏感,Map/Set 数据也比较多的话,比较适合 Redis。如果服务需要放入缓存量的数据很大,对延迟又不是特别敏感的话,那就可以选择 Memcached。

多级缓存

整体缓存框架

通常,一个大型软件系统的缓存采用多级缓存方案:

img

请求过程:

  1. 浏览器向客户端发起请求,如果 CDN 有缓存则直接返回;
  2. 如果 CDN 无缓存,则访问反向代理服务器;
  3. 如果反向代理服务器有缓存则直接返回;
  4. 如果反向代理服务器无缓存或动态请求,则访问应用服务器;
  5. 应用服务器访问进程内缓存;如果有缓存,则返回代理服务器,并缓存数据;(动态请求不缓存)
  6. 如果进程内缓存无数据,则读取分布式缓存;并返回应用服务器;应用服务器将数据缓存到本地缓存(部分);
  7. 如果分布式缓存无数据,则应用程序读取数据库数据,并放入分布式缓存;

使用进程内缓存

如果应用服务是单点应用,那么进程内缓存当然是缓存的首选方案

对于进程内缓存,其本来受限于内存的大小的限制,以及进程缓存更新后其他缓存无法得知,所以一般来说进程缓存适用于:

  • 数据量不是很大且更新频率较低的数据。
  • 如果更新频繁的数据,也想使用进程内缓存,那么可以将其过期时间设置为较短的时间,或者设置较短的自动刷新时间。

这种方案存在以下问题:

  • 如果应用服务是分布式系统,应用节点之间无法共享缓存,存在数据不一致问题。
  • 由于进程内缓存受限于内存大小的限制,所以缓存不能无限扩展。

使用分布式缓存

如果应用服务是分布式系统,那么最简单的缓存方案就是直接使用分布式缓存。

其应用场景如图所示:

img

Redis 用来存储热点数据,如果缓存不命中,则去查询数据库,并更新缓存。

这种方案存在以下问题:

  1. 缓存服务如果挂了,这时应用只能访问数据库,容易造成缓存雪崩。
  2. 访问分布式缓存服务会有一定的 I/O 以及序列化反序列化的开销,虽然性能很高,但是其终究没有在内存中查询快。

使用多级缓存

单纯使用进程内缓存和分布式缓存都存在各自的不足。如果需要更高的性能以及更好的可用性,我们可以将缓存设计为多级结构。将最热的数据使用进程内缓存存储在内存中,进一步提升访问速度。

这个设计思路在计算机系统中也存在,比如 CPU 使用 L1、L2、L3 多级缓存,用来减少对内存的直接访问,从而加快访问速度。

一般来说,多级缓存架构使用二级缓存已可以满足大部分业务需求,过多的分级会增加系统的复杂度以及维护的成本。因此,多级缓存不是分级越多越好,需要根据实际情况进行权衡。

一个典型的二级缓存架构,可以使用进程内缓存(如: Caffeine/Google Guava/Ehcache/HashMap)作为一级缓存;使用分布式缓存(如:Redis/Memcached)作为二级缓存。

多级缓存查询

![img](https://raw.githubusercontent.com/dunwu/images/master/cs/java/javaweb/technology/cache/多级缓存 2.png)

多级缓存查询流程如下:

  1. 首先,查询 L1 缓存,如果缓存命中,直接返回结果;如果没有命中,执行下一步。
  2. 接下来,查询 L2 缓存,如果缓存命中,直接返回结果并回填 L1 缓存;如果没有命中,执行下一步。
  3. 最后,查询数据库,返回结果并依次回填 L2 缓存、L1 缓存。

多级缓存更新

对于 L1 缓存,如果有数据更新,只能删除并更新所在机器上的缓存,其他机器只能通过超时机制来刷新缓存。超时设定可以有两种策略:

  • 设置成写入后多少时间后过期
  • 设置成写入后多少时间刷新

对于 L2 缓存,如果有数据更新,其他机器立马可见。但是,也必须要设置超时时间,其时间应该比 L1 缓存的有效时间长。

为了解决进程内缓存不一致的问题,设计可以进一步优化:

![img](https://raw.githubusercontent.com/dunwu/images/master/cs/java/javaweb/technology/cache/多级缓存 3.png)

通过消息队列的发布、订阅机制,可以通知其他应用节点对进程内缓存进行更新。使用这种方案,即使消息队列服务挂了或不可靠,由于先执行了数据库更新,但进程内缓存过期,刷新缓存时,也能保证数据的最终一致性。

缓存淘汰算法

缓存一般存于访问速度较快的存储介质,快也就意味着资源昂贵并且有限。正所谓,好钢要用在刀刃上。因此,缓存要合理利用,需要设定一些机制,将一些访问频率偏低或过期的数据淘汰。

淘汰缓存首先要做的是,确定什么时候触发淘汰缓存,一般有以下几个思路:

  • 基于空间 - 设置缓存空间大小。
  • 基于容量 - 设置缓存存储记录数。
  • 基于时间
    • TTL(Time To Live,即存活期) - 缓存数据从创建到过期的时间。
    • TTI(Time To Idle,即空闲期) - 缓存数据多久没被访问的时间。

接下来,就要确定如何淘汰缓存,常见的缓存淘汰算法有以下几个:

  • FIFO(First In First Out,先进先出) - 淘汰最先进入的缓存数据。缓存的行为就像一个队列。
    • 优点:这种方案非常简单
    • 缺点:可能会导致缓存命中率低。因为,进入缓存的先后顺序和访问频率无关,这种算法可能会将访问频率高的数据给淘汰。
  • LIFO(Last In First Out,后进先出) - 淘汰最后进入的缓存数据。缓存的行为就像一个栈。
    • 优点:这种方案非常简单
    • 缺点:和 FIFO 一样,也可能会导致缓存命中率低。因为,进入缓存的先后顺序和访问频率无关,这种算法可能会将访问频率高的数据给淘汰。
  • MRU(Most Recently Used,最近最多使用) - 淘汰最近最多使用缓存。
    • 优点:适用于一些特殊场景,例如数据访问具有较强的局部性。举个例子,用户访问一个信息流页面,已经看过的内容,他肯定不想再看到,此时就可以使用 MRU。
    • 缺点:某些情况下,可能会导致频繁的淘汰缓存,从而降低缓存命中率
  • LRU(Least Recently Used,最近最少使用) - 淘汰最近最少使用缓存。
    • 优点:避免了 FIFO 缓存命中率低的问题。
    • 缺点:存在临界区问题。假设,缓存只保留 1 分钟以内的热点数据。如果有个数据在 1 个小时的前 59 分钟访问了 1 万次(可见这是个热点数据),最后一分钟没有任何访问;而其他数据有被访问,就会导致这个热点数据被淘汰。
  • LFU(Less Frequently Used,最近最少频率使用) - 该算法对 LRU 做了进一步优化:利用额外的空间记录每个数据的使用频率,然后淘汰使用频率最低的数据,如果所有数据使用频率相同,可以用 FIFO 淘汰最早的缓存数据。
    • 优点:解决了 LRU 的临界区问题。
    • 缺点:记录使用频率,会产生额外的空间开销

缓存更新

一般来说,系统如果不是严格要求缓存和数据库保持一致性的话,尽量不要将读请求和写请求串行化。串行化可以保证一定不会出现数据不一致的情况,但是它会导致系统的吞吐量大幅度下降。

缓存更新的策略有几种模式:

  • Cache Aside
  • Read/Write Through

需要注意的是:以上几种缓存更新策略,都无法保证数据强一致。如果一定要保证强一致性,可以通过两阶段提交(2PC)或 Paxos 协议来实现。但是 2PC 太慢,而 Paxos 太复杂,所以如果不是非常重要的数据,不建议使用强一致性方案。

Cache Aside

Cache Aside 应该是最常见的缓存更新策略了。

Cache Aside 的思路是:先更新数据库,再删除缓存。具体来说:

  • 失效:尝试读缓存,如果不命中,则读数据库,然后更新缓存。

  • 命中:尝试读缓存,命中则直接返回数据。

  • 更新:先更新数据库,再删除缓存。

为什么不能先更新数据库,再更新缓存?

多个并发的写操作可能导致脏数据:当有多个并发的写请求时,无法保证更新数据库的顺序和更新缓存的顺序一致,从而导致数据库和缓存数据不一致的问题。

说明:如上图的场景中,两个写线程由于执行顺序,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。

为什么不能先删缓存,再更新数据库?

存在并发读请求和写请求时,可能导致脏数据

说明:如上图的场景中,读线程和写线程并行执行,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。

先更新数据库,再删除缓存就没问题了吗

存在并发读请求和写请求时,可能导致脏数据

上图中问题发生的概率非常低:因为通常数据库更新操作比内存操作耗时多出几个数量级,最后一步回写缓存速度非常快,通常会在更新数据库之前完成。所以 Cache Aside 模式选择先更新数据库,再删除缓存,而不是先删缓存,再更新数据库。

不过,如果真的出现了这种场景,为了避免缓存中一直保留着脏数据,可以为缓存设置过期时间,过期后缓存自动失效。通常,业务系统中允许少量数据短时间出现不一致的情况。

Read/Write Through

Read Through 的思路是:查询时更新缓存。当缓存失效时,缓存服务自己进行加载。

Write Through 的思路是:当数据更新时,缓存服务负责更新缓存。

Through vs. Cache Aside

Read Through vs. Cache Aside

  • Cache Aside 模式中,应用需要维护两个数据源头:一个是缓存,一个是数据库。
  • Read-Through 模式中,应用无需管理缓存和数据库,只需要将数据库的同步委托给缓存服务即可。

Write behind

Write Behind 又叫 Write Back。Write Behind 的思路是:应用更新数据时,只更新缓存, 缓存服务每隔一段时间将缓存数据批量更新到数据库中,即延迟写入。这个设计的好处就是让提高 I/O 效率,因为异步,Write Behind 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

更详细的分析可以参考:分布式之数据库和缓存双写一致性方案解析

缓存问题

缓存雪崩

“缓存雪崩”是指,缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大导致系统雪崩

举例来说,对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。

解决缓存雪崩的主要手段如下:

  • 增加缓存系统可用性(事前)。例如:部署 Redis Cluster(主从+哨兵),以实现 Redis 的高可用,避免全盘崩溃。
  • 采用多级缓存方案(事中)。例如:本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。
  • 限流、降级、熔断方案(事中),避免被流量打死。如:使用 Hystrix 进行熔断、降级。
  • 缓存如果支持持久化,可以在恢复工作后恢复数据(事后)。如:Redis 支持持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

上面的解决方案简单来说,就是多级缓存方案。系统收到一个查询请求,先查本地缓存,再查分布式缓存,最后查数据库,只要命中,立即返回。

解决缓存雪崩的辅助手段如下:

  • 监控缓存,弹性扩容
  • 缓存的过期时间可以取个随机值。这么做是为避免缓存同时失效,使得数据库 IO 骤升。比如:以前是设置 10 分钟的超时时间,那每个 Key 都可以随机 8-13 分钟过期,尽量让不同 Key 的过期时间不同。

缓存穿透

“缓存穿透”是指,查询的数据在数据库中不存在,那么缓存中自然也不存在。所以,应用在缓存中查不到,则会去查询数据库,当这样的请求多了后,数据库的压力就会增大。

解决缓存穿透,一般有两种方法:

(一)缓存空值

对于返回为 NULL 的依然缓存,对于抛出异常的返回不进行缓存

![img](https://raw.githubusercontent.com/dunwu/images/master/cs/java/javaweb/technology/cache/缓存穿透 1.png)

采用这种手段的会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。

(二)过滤不可能存在的数据

![img](https://raw.githubusercontent.com/dunwu/images/master/cs/java/javaweb/technology/cache/缓存穿透 2.png)

制定一些规则过滤一些不可能存在的数据。可以使用布隆过滤器(针对二进制操作的数据结构,所以性能高),比如你的订单 ID 明显是在一个范围 1-1000,如果不是 1-1000 之内的数据那其实可以直接给过滤掉。

针对于一些恶意攻击,攻击带过来的大量 key 是不存在的,那么我们采用第一种方案就会缓存大量不存在 key 的数据。

此时我们采用第一种方案就不合适了,我们完全可以先对使用第二种方案进行过滤掉这些 key。

针对这种 key 异常多、请求重复率比较低的数据,我们就没有必要进行缓存,使用第二种方案直接过滤掉。

而对于空数据的 key 有限的,重复率比较高的,我们则可以采用第一种方式进行缓存。

缓存击穿

“缓存击穿”是指,热点缓存数据失效瞬间,大量请求直接访问数据库。例如,某些 key 是热点数据,访问非常频繁。如果某个 key 失效的瞬间,大量的请求过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。

为了避免这个问题,我们可以采取下面的两个手段:

  • 分布式锁 - 锁住热点数据的 key,避免大量线程同时访问同一个 key。
  • 定时异步刷新 - 可以对部分数据采取失效前自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。

小结

上面逐一介绍了缓存使用中常见的问题。这里,从发生时间段的角度整体归纳一下缓存问题解决方案。

  • 事前:Redis 高可用方案(Redis Cluster + 主从 + 哨兵),避免缓存全面崩溃。
  • 事中:(一)采用多级缓存方案,本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。(二)限流 + 熔断 + 降级(Hystrix),避免极端情况下,数据库被打死。
  • 事后:Redis 持久化(RDB+AOF),一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

分布式缓存 Memcached ,由于数据类型不如 Redis 丰富,并且不支持持久化、容灾。所以,一般会选择 Redis 做分布式缓存。

缓存策略

缓存预热

缓存预热是指系统启动后,直接查询热点数据并缓存。这样就可以避免用户请求的时候,先查询数据库,然后再更新缓存的问题。

解决方案:

  • 手动刷新缓存:直接写个缓存刷新页面,上线时手工操作下。
  • 应用启动时刷新缓存:数据量不大,可以在项目启动的时候自动进行加载。
  • 定时异步刷新缓存

如何缓存

不过期缓存

缓存更新模式:

  1. 开启事务
  2. 写 SQL
  3. 提交事务
  4. 写缓存

不要把写缓存操作放在事务中,尤其是写分布式缓存。因为网络抖动可能导致写缓存响应时间很慢,引起数据库事务阻塞。如果对缓存数据一致性要求不是那么高,数据量也不是很大,可以考虑定期全量同步缓存。

这种模式存在这样的情况:存在事务成功,但缓存写失败的可能。但这种情况相对于上面的问题,影响较小。

过期缓存

采用懒加载。对于热点数据,可以设置较短的缓存时间,并定期异步加载。

总结

最后,通过一张思维导图来总结一下本文所述的知识点,帮助大家对缓存有一个系统性的认识。

img

参考资料