Dunwu Blog

大道至简,知易行难

版本管理中间件 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 知识图谱

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 知识图谱

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 知识图谱

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 知识图谱

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 知识图谱

分布式 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

参考资料

分布式事务

事务简介

什么是事务

在数据存储环境中,可能会出现各种各样的问题:

  • 数据库软件或硬件可能会随时失效(包括正在执行写操作的过程中)。
  • 应用程序可能随时崩愤(包括一系列操作执行到中间某一步)。
  • 应用与数据库节点间的连接可能会随时中断,数据库节点间也存在同样问题。
  • 多个客户端可能同时写入数据库,导致数据覆盖。
  • 客户端可能读到一些无意义的、部分更新的数据。
  • 客户端之间由于边界条件竞争所引入的各种奇怪问题。

为了解决以上问题,产生了事务这个概念。

事务(Transaction)指的是满足 ACID 特性的一组操作。事务内的 SQL 语句,要么全执行成功,要么全执行失败。可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

通俗的说,事务将多个读、写操作捆绑在一起成为一个逻辑操作单元事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。如果失败,应用程序可以安全地重试。这样,由于不需要担心部分失败的情况(无论出于任何原因),应用层的错误处理就变得简单很多。

ACID

那么,什么是 ACID 特性呢?ACID 是数据库事务正确执行的四个基本要素的单词缩写:

  • 原子性(Atomicity)
    • 原子是指不可分解为更小粒度的东西。事务的原子性意味着:事务中的所有操作要么全部成功,要么全部失败
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
    • ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID 的隔离性所定义。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
    • 一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情。
  • 隔离性(Isolation)
    • 同时运行的事务互不干扰。换句话说,一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。

  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对系统崩溃的情况。

什么是分布式事务

在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。

分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。

随着互联网快速发展,微服务,SOA 等服务架构模式正在被大规模的使用,现在分布式系统一般由多个独立的子系统组成,多个子系统通过网络通信互相协作配合完成各个功能。

有很多用例会跨多个子系统才能完成,比较典型的是电子商务网站的下单支付流程,至少会涉及交易系统和支付系统,而且这个过程中会涉及到事务的概念,即保证交易系统和支付系统的数据一致性,此处我们称这种跨系统的事务为分布式事务,具体一点而言,分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

举个互联网常用的交易业务为例:

上图中包含了库存和订单两个独立的微服务,每个微服务维护了自己的数据库。在交易系统的业务逻辑中,一个商品在下单之前需要先调用库存服务,进行扣除库存,再调用订单服务,创建订单记录。

可以看到,如果多个数据库之间的数据更新没有保证事务,将会导致出现子系统数据不一致,业务出现问题。

分布式事务相比于本地事务,实现复杂度要高很多,主要是因为其存在以下难点

  • 事务的原子性:事务操作跨不同节点,当多个节点某一节点操作失败时,需要保证多节点操作的都做或都不做(All or Nothing)的原子性。
  • 事务的一致性:当发生网络传输故障或者节点故障,节点间数据复制通道中断,在进行事务操作时需要保证数据一致性,保证事务的任何操作都不会使得数据违反数据库定义的约束、触发器等规则。
  • 事务的隔离性:事务隔离性的本质就是如何正确多个并发事务的处理的读写冲突和写写冲突,因为在分布式事务控制中,可能会出现提交不同步的现象,这个时候就有可能出现“部分已经提交”的事务。此时并发应用访问数据如果没有加以控制,有可能出现“脏读”问题。

在分布式领域,要实现强一致性,代价非常高昂。因此,有人基于 CAP 理论以及 BASE 理论,有人就提出了柔性事务的概念。柔性事务是指:在不影响系统整体可用性的情况下 (Basically Available 基本可用),允许系统存在数据不一致的中间状态 (Soft State 软状态),在经过数据同步的延时之后,最终数据能够达到一致。并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐

CAP 理论

CAP 定理是加州大学计算机科学家埃里克·布鲁尔提出来的猜想,后来被证明成为分布式计算领域公认的定理。

CAP 定理,指的是:在一个分布式系统中,当发生网络分区时,那么强一致性和可用性只能二选一

CAP 就是取 Consistency、Availability、Partition Tolerance 的首字母而命名。

img

  • 一致性(Consistency):在任何给定时间,网络中的所有节点都具有完全相同(最近)的值。
  • 可用性(Availability):对网络的每个请求都会收到响应,但不能保证返回的数据是最新的。
  • 分区容错性(Partition Tolerance):即使任意数量的节点出现故障,网络仍会继续运行。

一致性

一致性(Consistency)指的是多个数据副本是否能保持一致的特性。

在一致性的条件下,分布式系统在执行写操作成功后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。

数据一致性又可以分为以下几点:

  • 强一致性 - 数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
  • 最终一致性 - 即物理存储的数据可能是不一致的,终端用户访问到的数据可能也是不一致的,但系统经过一段时间的自我修复和修正,数据最终会达到一致。

举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。

img

接下来,用户的读操作就会得到 v1。这就叫一致性。

img

问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。

img

为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。

img

这样的话,用户向 G2 发起读操作,也能得到 v1。

img

可用性

可用性指分布式系统在面对各种异常时可以提供正常服务的能力,可以用系统可用时间占总时间的比值来衡量,4 个 9 的可用性表示系统 99.99% 的时间是可用的。

在可用性条件下,系统提供的服务一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。

分区容错性

分区容错性(Partition Tolerance)指 分布式系统在遇到任何网络分区故障的时候,仍然需要能对外提供一致性和可用性的服务,除非是整个网络环境都发生了故障

在一个分布式系统里面,节点组成的网络本来应该是连通的。然而可能因为一些故障,使得有些节点之间不连通了,整个网络就分成了几块区域。数据就散布在了这些不连通的区域中,这就叫分区。

假设,某个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了。这时分区就是无法容忍的。

提高分区容错性的办法就是一个数据项复制到多个节点上,那么出现分区之后,这一数据项就可能分布到各个区里。容错性就提高了。

然而,要把数据复制到多个节点,就会带来一致性的问题,就是多个节点上面的数据可能是不一致的。要保证一致,每次写操作就都要等待全部节点写成功,而这等待又会带来可用性的问题。

总的来说就是,数据存在的节点越多,分区容错性越高,但要复制更新的数据就越多,一致性就越难保证。为了保证一致性,更新所有节点数据所需要的时间就越长,可用性就会降低。

大多数分布式系统都分布在多个子网络,每个子网络就叫做一个区(Partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。

img

上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。

一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。

AP or CP

在分布式系统中,分区容错性必不可少,因为需要总是假设网络是不可靠的。因此,CAP 理论实际在是要在可用性和一致性之间做权衡

由于分布式数据存储(如区块链)的性质,分区容错性是一个既定的事实;网络中总会有失败/无法访问的节点(尤其是因为互联网的不稳定特性)。 CAP 定理指出,当存在 P(分区)时,必须在 C(一致性)或 A(可用性)之间进行选择。

(1)AP 模式

AP 模式:对网络的每个请求都会收到响应,即使网络由于网络分区故障而无法保证它是最新的。

选择 AP 模式,实现了服务的高可用。用户访问系统的时候,都能得到响应数据,不会出现响应错误;但是,当出现分区故障时,相同的读操作,访问不同的节点,得到响应数据可能不一样。

(2)CP 模式

CP 模式:如果由于网络分区(故障节点)而无法保证特定信息是最新的,则系统将返回错误或超时。

选择 CP 模式,这样能够提供一部分的可用性。采用 CP 模型的分布式系统,一旦因为消息丢失、延迟过高发生了网络分区,就影响用户的体验和业务的可用性。因为为了防止数据不一致,集群将拒绝新数据的写入。

BASE 理论

什么是 BASE 定理

BASE 定理是对 CAP 中一致性和可用性权衡的结果

不符合 ACID 标准的系统有时被冠以 BASE。BASE 是 基本可用(Basically Available)软状态(Soft State)最终一致性(Eventually Consistent) 三个短语的缩写。

BASE 理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

  • 基本可用(Basically Available)分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。
  • 软状态(Soft State)指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时
  • 最终一致性(Eventually Consistent)强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态

BASE vs. ACID

BASE 的理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。

BASE 唯一可以确定的是“它不是 ACID”,此外它几乎没有承诺任何东西。

柔性事务

在分布式领域,要实现强一致性,代价非常高昂。因此,有人基于 CAP 理论以及 BASE 理论,提出了柔性事务的概念。

柔性事务是指:在不影响系统整体可用性的情况下 (Basically Available 基本可用),允许系统存在数据不一致的中间状态 (Soft State 软状态),在经过数据同步的延时之后,最终数据能够达到一致。并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐

下面介绍的是实现柔性事务的一些常见特性,这些特性在具体的方案中不一定都要满足,因为不同的方案要求不一样。

  • 可见性(对外可查询):在分布式事务执行过程中,如果某一个步骤执行出错,就需要明确的知道其他几个操作的处理情况,这就需要其他的服务都能够提供查询接口,保证可以通过查询来判断操作的处理情况。为了保证操作的可查询,需要对于每一个服务的每一次调用都有一个全局唯一的标识,可以是业务单据号(如订单号)、也可以是系统分配的操作流水号(如支付记录流水号)。除此之外,操作的时间信息也要有完整的记录。
  • 操作幂等性:幂等性,其实是一个数学概念。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,同一个方法,使用同样的参数,调用多次产生的业务结果与调用一次产生的业务结果相同。之所以需要操作幂等性,是因为为了保证数据的最终一致性,很多事务协议都会有很多重试的操作,如果一个方法不保证幂等,那么将无法被重试。幂等操作的实现方式有多种,如在系统中缓存所有的请求与处理结果、检测到重复操作后,直接返回上一次的处理结果等。

两阶段提交(2PC)

方案简介

二阶段提交协议(Two-phase Commit,即 2PC)是常用的分布式事务解决方案,即将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。事务的发起者称协调者,事务的执行者称参与者。

在分布式系统里,每个节点都可以知晓自己操作的成功或者失败,却无法知道其他节点操作的成功或失败。当一个事务跨多个节点时,为了保持事务的原子性与一致性,而引入一个协调者来统一掌控所有参与者的操作结果,并指示它们是否要把操作结果进行真正的提交或者回滚(rollback)。

二阶段提交的思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈,决定提交或回滚

核心思想就是对每一个事务都采用先尝试后提交的处理方式,处理后所有的读操作都要能获得最新的数据,因此也可以将二阶段提交看作是一个强一致性算法。

处理流程

简单一点理解,可以把协调者节点比喻为带头大哥,参与者理解比喻为跟班小弟,带头大哥统一协调跟班小弟的任务执行。

阶段 1:准备阶段

  1. 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。
  2. 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  3. 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。

阶段 2:提交阶段

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚 (rollback) 消息;否则,发送提交 (commit) 消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。

情况 1,当所有参与者均反馈 yes,提交事务

  1. 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
  2. 参与者执行 commit 请求,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack(应答)完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

情况 2,当任何阶段 1 一个参与者反馈 no,中断事务

  1. 协调者向所有参与者发出回滚请求(即 rollback 请求)。
  2. 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

方案总结

2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:

  • 性能问题 - 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
  • 可靠性问题 - 如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
  • 数据一致性问题 - 在阶段 2 中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。

三阶段提交(3PC)

方案简介

三阶段提交协议(Three-phase Commit,3PC),是二阶段提交协议的改进版本,与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。

三阶段提交将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。

处理流程

阶段 1:canCommit

协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:

  1. 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
  2. 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。

阶段 2:preCommit

协调者根据阶段 1 canCommit 参与者的反应情况来决定是否可以基于事务的 preCommit 操作。根据响应情况,有以下两种可能。

情况 1:阶段 1 所有参与者均反馈 yes,参与者预执行事务

  1. 协调者向所有参与者发出 preCommit 请求,进入准备阶段。
  2. 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  3. 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。

情况 2:阶段 1 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务

  1. 协调者向所有参与者发出 abort 请求。
  2. 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。

阶段 3:doCommit

该阶段进行真正的事务提交,也可以分为以下两种情况:

情况 1:阶段 2 所有参与者均反馈 ack 响应,执行真正的事务提交

  1. 如果协调者处于工作状态,则向所有参与者发出 doCommit 请求。
  2. 参与者收到 doCommit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

情况 2:任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务

  1. 如果协调者处于工作状态,向所有参与者发出 abort 请求。
  2. 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

注意:进入阶段 3 后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的 doCommit 请求或 abort 请求。此时,参与者都会在等待超时之后,继续执行事务提交。

方案总结

  • 优点:相比二阶段提交,三阶段降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。
  • 缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 doCommit 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。

补偿事务(TCC)

方案简介

TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。

TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现;

  • Try - 操作作为一阶段,负责资源的检查和预留。
  • Confirm - 操作作为二阶段提交操作,执行真正的业务。
  • Cancel - 是预留资源的取消。

TCC 事务的 Try、Confirm、Cancel 可以理解为 SQL 事务中的 Lock、Commit、Rollback。

处理流程

为了方便理解,下面以电商下单为例进行方案解析,这里把整个过程简单分为扣减库存,订单创建 2 个步骤,库存服务和订单服务分别在不同的服务器节点上。

Try 阶段

从执行阶段来看,与传统事务机制中业务逻辑相同。但从业务角度来看,却不一样。TCC 机制中的 Try 仅是一个初步操作,它和后续的确认一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:

  • 完成所有业务检查(一致性)
  • 预留必须业务资源(准隔离性)
  • Try 尝试执行业务 TCC 事务机制以初步操作(Try)为中心的,确认操作(Confirm)和取消操作(Cancel)都是围绕初步操作(Try)而展开。因此,Try 阶段中的操作,其保障性是最好的,即使失败,仍然有取消操作(Cancel)可以将其执行结果撤销。

假设商品库存为 100,购买数量为 2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。

Confirm / Cancel 阶段

根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。 Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。

Confirm:当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作

这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。

Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段

Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。

方案总结

TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:

  • 性能提升 - 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
  • 数据最终一致性 - 基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
  • 可靠性 - 解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

缺点: TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。

本地消息表

方案简介

本地消息表的方案最初是由 ebay 提出,核心思路是将分布式事务拆分成本地事务进行处理。

方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。

这样设计可以避免”业务处理成功 + 事务消息发送失败“,或”业务处理失败 + 事务消息发送成功“的棘手情况出现,保证 2 个系统事务的数据一致性。

处理流程

下面把分布式事务最先开始处理的事务方称为事务主动方,在事务主动方之后处理的业务内的其他事务称为事务被动方。

为了方便理解,下面继续以电商下单为例进行方案解析,这里把整个过程简单分为扣减库存,订单创建 2 个步骤,库存服务和订单服务分别在不同的服务器节点上,其中库存服务是事务主动方,订单服务是事务被动方。

事务的主动方需要额外新建事务消息表,用于记录分布式事务的消息的发生、处理状态。

整个业务处理流程如下:

  1. 步骤 1、事务主动方处理本地事务。 事务主动发在本地事务中处理业务更新操作和写消息表操作。 上面例子中库存服务阶段再本地事务中完成扣减库存和写消息表(图中 1、2)。
  2. 步骤 2、事务主动方通过 MQ 通知事务被动方处理事务。 消息中间件可以基于 Kafka、RocketMQ 消息队列,事务主动方法主动写消息到消息队列,事务消费方消费并处理消息队列中的消息。 上面例子中,库存服务把事务待处理消息写到消息中间件,订单服务消费消息中间件的消息,完成新增订单(图中 3 - 5)。
  3. 步骤 3、事务被动方通过 MQ 返回处理结果。 上面例子中,订单服务把事务已处理消息写到消息中间件,库存服务消费中间件的消息,并将事务消息的状态更新为已完成(图中 6 - 8)

为了数据的一致性,当处理错误需要重试,事务发送方和事务接收方相关业务处理需要支持幂等。具体保存一致性的容错处理如下:

  • 当步骤 1 处理出错,事务回滚,相当于什么都没发生。
  • 当步骤 2、步骤 3 处理出错,由于未处理的事务消息还是保存在事务发送方,事务发送方可以定时轮询超时 d 的消息数据,再次发送消息到 MQ 进行处理。事务被动方消费事务消息重试处理。
  • 如果是业务上的失败,事务被动方可以发消息给事务主动方进行回滚。
  • 如果多个事务被动方已经消费消息,事务主动方需要回滚事务时需要通知事务被动方回滚。

方案总结

方案的优点如下:

  • 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
  • 方案简单,容易实现。

缺点如下:

  • 与具体的业务场景绑定,耦合性高,不可复用
  • 需要额外维护消息数据的传输,占用业务系统资源。
  • 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。

消息事务

MQ 事务方案本质是利用 MQ 功能实现的本地消息表。事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。

  • Kafka 的解决方案是:直接抛出异常,让用户自行处理。用户可以在业务代码中反复重试提交,直到提交成功,或者删除之前修改的数据记录进行事务补偿。
  • RocketMQ 的解决方案是:通过事务反查机制来解决事务消息提交失败的问题。如果 Producer 在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。为了支撑这个事务反查机制,业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。

RocketMQ 事务消息实现

事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。

事务消息处理流程

事务消息交互流程如下图所示。

  1. 生产者将消息发送至 Apache RocketMQ 服务端。
  2. Apache RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为”暂不能投递”,这种状态下的消息即为半事务消息。
  3. 生产者开始执行本地事务逻辑。
  4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:
  • 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者。
  • 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
  1. 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为 Unknown 未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。 说明 服务端回查的间隔时间和最大回查次数,请参见 参数限制
  2. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  3. 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行处理。

事务消息生命周期 事务消息

  • 初始化:半事务消息被生产者构建并完成初始化,待发送到服务端的状态。
  • 事务待提交:半事务消息被发送到服务端,和普通消息不同,并不会直接被服务端持久化,而是会被单独存储到事务存储系统中,等待第二阶段本地事务返回执行结果后再提交。此时消息对下游消费者不可见。
  • 消息回滚:第二阶段如果事务执行结果明确为回滚,服务端会将半事务消息回滚,该事务消息流程终止。
  • 提交待消费:第二阶段如果事务执行结果明确为提交,服务端会将半事务消息重新存储到普通存储系统中,此时消息对下游消费者可见,等待被消费者获取并消费。
  • 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,Apache RocketMQ 会对消息进行重试处理。具体信息,请参见 消费重试
  • 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。 Apache RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
  • 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。更多信息,请参见 消息存储和清理机制

MQ 事务方案总结

相比本地消息表方案,MQ 事务方案优点是:

  • 业务解耦 - 消息数据独立存储 ,降低业务系统与消息系统之间的耦合。
  • 吞吐量优于本地消息表方案。

缺点是:

  • 一次消息发送需要两次网络请求 (half 消息 + commit/rollback 消息)
  • 业务处理服务需要实现消息状态回查接口

SAGA 事务

方案简介

1987 年,Hector Garcia-Molina 和 Kenneth Salem 发表了名为 SAGAS 的论文,讲述了如何处理 long lived transaction(长活事务)。Saga 事务的核心思想是:将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序依次调用补偿操作。

处理流程

Saga 事务基本协议如下

  • 将长事务拆分为多个有序子事务 - 每个 Saga 事务由一系列幂等的有序子事务 (sub-transaction) Ti 组成。
  • 每个子事务 Ti 都有对应的幂等补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。

可以看到,和 TCC 相比,Saga 没有“预留”动作,它的 Ti 就是直接提交到库。

下面以下单流程为例,整个操作包括:创建订单、扣减库存、支付、增加积分 Saga 的执行顺序有两种:

  • 事务正常执行完成 T1, T2, T3, …, Tn,例如:扣减库存 (T1),创建订单 (T2),支付 (T3),依次有序完成整个事务。
  • 事务回滚 T1, T2, …, Tj, Cj,…, C2, C1,其中 0 < j < n,例如:扣减库存 (T1),创建订单 (T2),支付 (T3,支付失败),支付回滚 (C3),订单回滚 (C2),恢复库存 (C1)。

恢复策略

Saga 定义了两种恢复策略:

  • 向前恢复 (forward recovery)

对应于上面第一种执行顺序,适用于必须要成功的场景失败需要进行重试,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中 j 是发生错误的子事务 (sub-transaction)。该情况下不需要 Ci。

  • 向后恢复 (backward recovery)

对应于上面提到的第二种执行顺序,其中 j 是发生错误的子事务 (sub-transaction),这种做法的效果是撤销掉之前所有成功的子事务,使得整个 Saga 的执行结果撤销。

Saga 事务常见的有两种不同的实现方式:命令协调和事件编排。

命令协调

  • 命令协调 (Order Orchestrator):中央协调器负责集中处理事件的决策和业务逻辑排序。

中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。

以电商订单的例子为例:

  1. 事务发起方的主业务逻辑请求 OSO 服务开启订单事务。
  2. OSO 向库存服务请求扣减库存,库存服务回复处理结果。
  3. OSO 向订单服务请求创建订单,订单服务回复创建结果。
  4. OSO 向支付服务请求支付,支付服务回复处理结果。
  5. 主业务逻辑接收并处理 OSO 事务处理结果回复。

中央协调器必须事先知道执行整个订单事务所需的流程(例如通过读取配置)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。

事件编排

  • 事件编排 (Event Choreography0:没有中央协调器(没有单点风险)时,每个服务产生并观察其他服务的事件,并决定是否应采取行动

在事件编排方法中,第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。

当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。

以电商订单的例子为例:

  1. 事务发起方的主业务逻辑发布开始订单事件
  2. 库存服务监听开始订单事件,扣减库存,并发布库存已扣减事件
  3. 订单服务监听库存已扣减事件,创建订单,并发布订单已创建事件
  4. 支付服务监听订单已创建事件,进行支付,并发布订单已支付事件
  5. 主业务逻辑监听订单已支付事件并处理。

事件编排是实现 Saga 模式的自然方式,它很简单,容易理解,不需要太多的代码来构建。如果事务涉及 2 至 4 个步骤,则可能是非常合适的。

方案总结

命令协调设计的优点和缺点:

优点如下:

  • 服务之间关系简单,避免服务之间的循环依赖关系,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器
  • 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
  • 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试

缺点如下:

  • 中央协调器容易处理逻辑容易过于复杂,导致难以维护。
  • 存在协调器单点故障风险。

事件/编排设计的优点和缺点

优点如下:

  • 避免中央协调器单点故障风险。
  • 当涉及的步骤较少服务开发简单,容易实现。

缺点如下:

  • 服务之间存在循环依赖的风险。
  • 当涉及的步骤较多,服务间关系混乱,难以追踪调测。

值得补充的是,由于 Saga 模型中没有 Prepare 阶段,因此事务间不能保证隔离性,当多个 Saga 事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:在应用层面加锁,或者应用层面预先冻结资源。

总结

各方案使用场景

介绍完分布式事务相关理论和常见解决方案后,最终的目的在实际项目中运用,因此,总结一下各个方案的常见的使用场景。

分布式事务的常见方案如下:

  • 两阶段提交(2PC) - 将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈信息决定各参与者是否要提交操作还是中止操作
  • 三阶段提交(3PC) - 与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
  • 补偿事务(TCC)
    • Try - 操作作为一阶段,负责资源的检查和预留。
    • Confirm - 操作作为二阶段提交操作,执行真正的业务。
    • Cancel - 是预留资源的取消。
  • 本地消息表 - 在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
  • 消息事务 - 基于 MQ 的分布式事务方案其实是对本地消息表的封装。
  • SAGA - Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

分布式事务方案对比:

  • 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
  • TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
  • 本地消息表/消息事务都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
  • Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。Saga 相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga 事务较适用于补偿动作容易处理的场景。
2PC 3PC TCC 本地消息表 MQ 事务 SAGA
数据一致性
容错性
复杂性
性能
维护成本

分布式事务方案设计

本文介绍的偏向于原理,业界已经有不少开源的或者收费的解决方案,篇幅所限,就不再展开介绍。

实际运用理论时进行架构设计时,许多人容易犯“手里有了锤子,看什么都觉得像钉子”的错误,设计方案时考虑的问题场景过多,各种重试,各种补偿机制引入系统,导致设计出来的系统过于复杂,落地遥遥无期。

世界上解决一个计算机问题最简单的方法:“恰好”不需要解决它!—— 阿里中间件技术专家沈询

有些问题,看起来很重要,但实际上我们可以通过合理的设计或者将问题分解来规避。设计分布式事务系统也不是需要考虑所有异常情况,不必过度设计各种回滚,补偿机制。如果硬要把时间花在解决问题本身,实际上不仅效率低下,而且也是一种浪费。

如果系统要实现回滚流程的话,有可能系统复杂度将大大提升,且很容易出现 Bug,估计出现 Bug 的概率会比需要事务回滚的概率大很多。在设计系统时,我们需要衡量是否值得花这么大的代价来解决这样一个出现概率非常小的问题,可以考虑当出现这个概率很小的问题,能否采用人工解决的方式,这也是大家在解决疑难问题时需要多多思考的地方。

参考资料

分布式会话基本原理

由于 Http 是一种无状态的协议,服务器单单从网络连接上无从知道客户身份。

会话跟踪是 Web 程序中常用的技术,用来跟踪用户的整个会话。常用会话跟踪技术是 Cookie 与 Session。

由于 Http 是一种无状态的协议,服务器单从网络连接上无从知道客户身份。

所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。

Cookie 实际上是存储在用户浏览器上的文本信息,并保留了各种跟踪的信息。

一个简单的 cookie 设置如下:

1
Set-Cookie: <cookie-name>=<cookie-value>
1
2
3
4
5
6
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[page content]
  1. 浏览器请求服务器,如果服务器需要记录该用户的状态,就是用 response 向浏览器颁发一个 Cookie。
  2. 浏览器会把 Cookie 保存下来。
  3. 当浏览器再请求该网站时,浏览器把该请求的网址连同 Cookie 一同提交给服务器。服务器检查该 Cookie,以此来辨认用户状态。

Cookie 主要用于以下三个方面:

  • 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)

注:Cookie 功能需要浏览器的支持,如果浏览器不支持 Cookie 或者 Cookie 禁用了,Cookie 功能就会失效。

属性 说明
name=value 键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型 - 如果值为 Unicode 字符,需要为字符编码。 - 如果值为二进制数据,则需要使用 BASE64 编码。
domain 指定 cookie 所属域名,默认是当前域名
path **指定 cookie 在哪个路径(路由)下生效,默认是 ‘/‘**。 如果设置为 /abc,则只有 /abc 下的路由可以访问到该 cookie,如:/abc/read
maxAge cookie 失效的时间,单位秒。如果为整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie ,关闭浏览器即失效,浏览器也不会以任何形式保存该 cookie 。如果为 0,表示删除该 cookie 。默认为 -1。 - 比 expires 好用
expires 过期时间,在设置的某个时间点后该 cookie 就会失效。 一般浏览器的 cookie 都是默认储存的,当关闭浏览器结束这个会话的时候,这个 cookie 也就会被删除
secure 该 cookie 是否仅被使用安全协议传输。安全协议有 HTTPS,SSL 等,在网络上传输数据之前先将数据加密。默认为 false。 当 secure 值为 true 时,cookie 在 HTTP 中是无效,在 HTTPS 中才有效。
httpOnly 如果给某个 cookie 设置了 httpOnly 属性,则无法通过 JS 脚本 读取到该 cookie 的信息,但还是能通过 Application 中手动修改 cookie,所以只是在一定程度上可以防止 XSS 攻击,不是绝对的安全

Session

什么是 Session

Session 代表着服务器和客户端一次会话的过程。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当客户端关闭会话,或者 Session 超时失效时会话结束。

  • session 是另一种记录服务器和客户端会话状态的机制
  • session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的 cookie 中

session.png

Session 的工作步骤

  1. 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session。
  2. 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器。
  3. 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名。
  4. 当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。

根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。

Cookie 和 Session 的主要区别可以参考以下表格:

Cookie Session
作用范围 保存在客户端(浏览器) 保存在服务器端
隐私策略 存储在客户端,比较容易遭到非法获取 存储在服务端,安全性相对 Cookie 要好一些
存储方式 只能保存 ASCII 可以保存任意数据类型。
一般情况下我们可以在 Session 中保持一些常用变量信息,比如说 UserId 等。
存储大小 不能超过 4K 存储大小远高于 Cookie
生命周期 可设置为永久保存
比如我们经常使用的默认登录(记住我)功能
一般失效时间较短
客户端关闭或者 Session 超时都会失效。

既然服务端是根据 Cookie 中的信息判断用户是否登录,那么如果浏览器中禁止了 Cookie,如何保障整个机制的正常运转。

第一种方案,每次请求中都携带一个 SessionID 的参数,也可以 Post 的方式提交,也可以在请求的地址后面拼接 xxx?SessionID=123456...

第二种方案,Token 机制。Token 机制多用于 App 客户端和服务器交互的模式,也可以用于 Web 端做用户状态管理。

Token 的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。Token 机制和 Cookie 和 Session 的使用机制比较类似。

当用户第一次登录后,服务器根据提交的用户信息生成一个 Token,响应时将 Token 返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次登录验证。

分布式 Session

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

分布式 Session 的几种实现策略:

  1. 粘性 session
  2. 应用服务器间的 session 复制共享
  3. 基于缓存的 session 共享 ✅

推荐:基于缓存的 session 共享

粘性 Session

粘性 Session(Sticky Sessions)需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。

缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session

Session 复制共享

Session 复制共享(Session Replication)在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。

缺点:占用过多内存同步过程占用网络带宽以及服务器处理器时间

基于缓存的 session 共享

使用一个单独的存储服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。

缺点:需要去实现存取 Session 的代码。

具体实现

JWT Token

使用 JWT Token 储存用户身份,然后再从数据库或者 cache 中获取其他的信息。这样无论请求分配到哪个服务器都无所谓。

tomcat + redis

这个其实还挺方便的,就是使用 session 的代码,跟以前一样,还是基于 tomcat 原生的 session 支持即可,然后就是用一个叫做 Tomcat RedisSessionManager 的东西,让所有我们部署的 tomcat 都将 session 数据存储到 redis 即可。

在 tomcat 的配置文件中配置:

1
2
3
4
5
6
7
<Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" />

<Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager"
host="{redis.host}"
port="{redis.port}"
database="{redis.dbnum}"
maxInactiveInterval="60"/>

然后指定 redis 的 host 和 port 就 ok 了。

1
2
3
4
5
<Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" />
<Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager"
sentinelMaster="mymaster"
sentinels="<sentinel1-ip>:26379,<sentinel2-ip>:26379,<sentinel3-ip>:26379"
maxInactiveInterval="60"/>

还可以用上面这种方式基于 redis 哨兵支持的 redis 高可用集群来保存 session 数据,都是 ok 的。

spring session + redis

上面那种 tomcat + redis 的方式好用,但是会严重依赖于 web 容器,不好将代码移植到其他 web 容器上去,尤其是你要是换了技术栈咋整?比如换成了 spring cloud 或者是 spring boot 之类的呢?

所以现在比较好的还是基于 Java 一站式解决方案,也就是 spring。人家 spring 基本上承包了大部分我们需要使用的框架,spirng cloud 做微服务,spring boot 做脚手架,所以用 sping session 是一个很好的选择。

在 pom.xml 中配置:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.2.1.RELEASE</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.8.1</version>
</dependency>

在 spring 配置文件中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<bean id="redisHttpSessionConfiguration"
class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
<property name="maxInactiveIntervalInSeconds" value="600"/>
</bean>

<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxTotal" value="100" />
<property name="maxIdle" value="10" />
</bean>

<bean id="jedisConnectionFactory"
class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" destroy-method="destroy">
<property name="hostName" value="${redis_hostname}"/>
<property name="port" value="${redis_port}"/>
<property name="password" value="${redis_pwd}" />
<property name="timeout" value="3000"/>
<property name="usePool" value="true"/>
<property name="poolConfig" ref="jedisPoolConfig"/>
</bean>

在 web.xml 中配置:

1
2
3
4
5
6
7
8
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/test")
public class TestController {

@RequestMapping("/putIntoSession")
public String putIntoSession(HttpServletRequest request, String username) {
request.getSession().setAttribute("name", "leo");
return "ok";
}

@RequestMapping("/getFromSession")
public String getFromSession(HttpServletRequest request, Model model){
String name = request.getSession().getAttribute("name");
return name;
}
}

上面的代码就是 ok 的,给 spring session 配置基于 redis 来存储 session 数据,然后配置了一个 spring session 的过滤器,这样的话,session 相关操作都会交给 spring session 来管了。接着在代码中,就用原生的 session 操作,就是直接基于 spring sesion 从 redis 中获取数据了。

实现分布式的会话有很多种方式,我说的只不过是比较常见的几种方式,tomcat + redis 早期比较常用,但是会重耦合到 tomcat 中;近些年,通过 spring session 来实现。

参考资料