Dunwu Blog

大道至简,知易行难

MySQL 面试

关系数据库综合

【基础】什么是范式?什么是反范式?

:::details 要点

数据库规范化,又称“范式”,是数据库设计的指导理论。范式的目标是:使数据库结构更合理,消除存储异常,使数据冗余尽量小,增进数据的一致性

根据约束程度从低到高有:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)等等。

  • 1NF 要求所有属性都不可再分解
  • 2NF 要求不存在部分依赖
  • 3NF 要求不存在传递依赖

反范式,顾名思义,与范式的目标正好相反。范式的目标是消除冗余反范式的目标是冗余以提高查询效率

范式并非越严格越好,现代数据库设计,一般最多满足 3NF。范式越高意味着表的划分更细,一个数据库中需要的表也就越多,用户不得不将原本相关联的数据分摊到多个表中。当用户同时需要这些数据时只能通过关联表的形式将数据重新合并在一起。同时把多个表联接在一起的花费是巨大的,尤其是当需要连接的两张或者多张表数据非常庞大的时候,表连接操作几乎是一个噩梦,这严重地降低了系统运行性能。因此,有时为了提高查询效率,有必要适当的冗余数据,以达到空间换时间的目的——这就是“反范式”

:::

:::details 细节

第一范式 (1NF)

1NF 要求所有属性都不可再分解

第二范式 (2NF)

2NF 要求记录有唯一标识,即实体的唯一性,即不存在部分依赖

假设有一张 student 表,结构如下:

1
2
-- 学生表
student(学号、课程号、姓名、学分、成绩)

举例来说,现有一张 student 表,具有学号、课程号、姓名、学分等字段。从中可以看出,表中包含了学生信息和课程信息。由于非主键字段必须依赖主键,这里学分依赖课程号,姓名依赖学号,所以不符合 2NF。

不符合 2NF 可能会存在的问题:

  • 数据冗余 - 每条记录都含有相同信息。
  • 删除异常 - 删除所有学生成绩,就把课程信息全删除了。
  • 插入异常 - 学生未选课,无法记录进数据库。
  • 更新异常 - 调整课程学分,所有行都调整。

根据 2NF 可以拆分如下:

1
2
3
4
5
6
-- 学生表
student(学号、姓名)
-- 课程表
course(课程号、学分)
-- 学生课程关系表
student_course(学号、课程号、成绩)

第三范式 (3NF)

如果一个关系属于第二范式,并且在两个(或多个)非主键属性之间不存在函数依赖(非主键属性之间的函数依赖也称为传递依赖),那么这个关系属于第三范式。

3NF 是对字段的冗余性,要求任何字段不能由其他字段派生出来,它要求字段没有冗余,即不存在传递依赖

假设有一张 student 表,结构如下:

1
2
-- 学生表
student(学号、姓名、年龄、班级号、班主任)

上表属于第二范式,因为主键由单个属性组成(学号)。

因为存在依赖传递:(学号) → (学生)→(所在班级) → (班主任) 。

可能会存在问题:

  • 数据冗余 - 有重复值;
  • 更新异常 - 有重复的冗余信息,修改时需要同时修改多条记录,否则会出现数据不一致的情况

可以基于 3NF 拆解:

1
2
student(学号、姓名、年龄、所在班级号)
class(班级号、班主任)

:::

【基础】为什么不推荐使用存储过程?

:::details 要点

存储过程的优点:

  • 执行效率高:一次编译多次使用。
  • 安全性强:在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。
  • 可复用:将代码封装,可以提高代码复用。
  • 性能好
    • 由于是预先编译,因此具有很高的性能。
    • 一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率。

存储过程的缺点:

  • 可移植性差:存储过程不能跨数据库移植。由于不同数据库的存储过程语法几乎都不一样,十分难以维护(不通用)。
  • 调试困难:只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。
  • 版本管理困难:比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。
  • 不适合高并发的场景:高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。

_综上,存储过程的优缺点都非常突出,是否使用一定要慎重,需要根据具体应用场景来权衡_。

:::

MySQL CRUD

扩展阅读:

【中级】如何避免重复插入数据?

:::details 要点

在 mysql 中,当存在主键冲突或唯一键冲突的情况下,根据插入策略不同,一般有以下三种避免方法:

  • INSERT IGNORE INTO:若无则插入,若有则忽略
  • REPLACE INTO:若无则插入,若有则先删除后插入
  • INSERT INTO ... ON DUPLICATE KEY UPDATE:若无则插入,若有则更新

下面结合示例来说明三种方式的效果。

下面是示例的初始化准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 建表
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` VARCHAR(255) NOT NULL COMMENT '名称',
`age` INT(3) DEFAULT '0' COMMENT '年龄',
PRIMARY KEY (`id`),
UNIQUE KEY `name`(`name`)
) DEFAULT CHARSET = utf8mb4;

-- 测试数据
INSERT INTO `user`
VALUES (1, '刘备', 30);
INSERT INTO `user`
VALUES (2, '关羽', 28);

INSERT IGNORE INTO

INSERT IGNORE INTO 会根据主键或者唯一键判断,忽略数据库中已经存在的数据:

  • 若数据库没有该条数据,就插入为新的数据,跟普通的 INSERT INTO 一样
  • 若数据库有该条数据,就忽略这条插入语句,不执行插入操作
1
2
3
4
5
6
7
8
9
10
11
INSERT IGNORE INTO user (name, age)
VALUES ('关羽', 29), ('张飞', 25);

-- 最终数据
+----+--------+------+
| id | name | age |
+----+--------+------+
| 1 | 刘备 | 30 |
| 2 | 关羽 | 28 |
| 3 | 张飞 | 25 |
+----+--------+------+

REPLACE INTO

REPLACE INTO 会根据主键或者唯一键判断:

  • 若表中已存在该数据,则先删除此行数据,然后插入新的数据,相当于 delete + insert
  • 若表中不存在该数据,则直接插入新数据,跟普通的 insert into 一样
1
2
3
4
5
6
7
8
9
10
11
12
REPLACE INTO user(id, name, age)
VALUES (2, '关羽', 29), (4, '赵云', 22);

-- 最终数据
+----+--------+------+
| id | name | age |
+----+--------+------+
| 1 | 刘备 | 30 |
| 2 | 关羽 | 29 |
| 3 | 张飞 | 25 |
| 4 | 赵云 | 22 |
+----+--------+------+

INSERT … ON DUPLICATE KEY UPDATE

INSERT ... ON DUPLICATE KEY UPDATE 会根据主键或者唯一键判断:

  • 若数据库已有该数据,则直接更新原数据,相当于 UPDATE
  • 若数据库没有该数据,则插入为新的数据,相当于 INSERT
1
2
3
4
5
6
7
8
9
10
11
12
13
INSERT INTO user(id, name, age)
VALUES (2, '关羽', 27)
ON DUPLICATE KEY UPDATE name=values(name), age=values(age);

-- 最终数据
+----+--------+------+
| id | name | age |
+----+--------+------+
| 1 | 刘备 | 30 |
| 2 | 关羽 | 27 |
| 3 | 张飞 | 25 |
| 4 | 赵云 | 22 |
+----+--------+------+

:::

【基础】EXISTS 和 IN 有什么区别?

:::details 要点

EXISTS 和 IN 区别如下:

  • 功能
    • EXISTS 用于判断子查询的结果集是否为空。
    • IN 用于判断某个值是否在指定的集合中。
  • 性能
    • EXISTS 先外后内 - 先对外表进行循环查询,再将查询结果放入 EXISTS 的子查询中进行条件比较,一旦找到匹配记录,则终止内表子查询。
    • IN 先内后外 - 先查询内表,将内表的查询结果作为条件,提供给外表查询语句进行比较。
  • 应用
    • 如果查询的两个表大小相当,那么 EXISTSIN 差别不大。
    • EXISTS 适合外表小而内表大的场景。
    • IN 适合外表大而内表小的场景。

:::

:::details 细节

EXISTS 和 IN 的对比示例如下:

1
2
SELECT * FROM A WHERE cc IN (SELECT cc FROM B)
SELECT * FROM A WHERE EXISTS (SELECT cc FROM B WHERE B.cc=A.cc)

当 A 小于 B 时,用 EXISTS。因为 EXISTS 的实现,相当于外表循环,实现的逻辑类似于:

1
2
3
for i in A
for j in B
if j.cc == i.cc then ...

当 B 小于 A 时用 IN,因为实现的逻辑类似于:

1
2
3
for i in B
for j in A
if j.cc == i.cc then ...

哪个表小就用哪个表来驱动,A 表小就用 EXISTS,B 表小就用 IN;如果两个表大小相当,则使用 EXISTSIN 的区别不大。

:::

【基础】UNION 和 UNION ALL 有什么区别?

:::details 要点

UNIONUNION ALL 都是将两个结果集合并为一个,两个要联合的 SQL 语句字段个数必须一样,而且字段类型要“相容”(一致)

  • UNION 需要进行去重扫描,因此消息较低;而 UNION ALL 不会进行去重。
  • UNION 会按照字段的顺序进行排序;而 UNION ALL 只是简单的将两个结果合并就返回。

:::

【基础】JOIN 有哪些类型?

:::details 要点

**在 SELECT, UPDATE 和 DELETE 语句中,“连接”可以用于联合多表查询。连接使用 JOIN 关键字,并且条件语句使用 ON 而不是 WHERE**。

连接可以替换子查询,并且一般比子查询的效率更快

JOIN 有以下类型:

  • 内连接 - 内连接又称等值连接,用于获取两个表中字段匹配关系的记录,使用 INNER JOIN 关键字。在没有条件语句的情况下返回笛卡尔积
    • 笛卡尔积 - “笛卡尔积”也称为交叉连接(CROSS JOIN),它的作用就是可以把任意表进行连接,即使这两张表不相关
    • 自连接(=) - “自连接(=)”可以看成内连接的一种,只是连接的表是自身而已
    • 自然连接(NATURAL JOIN) - “自然连接”会自动连接所有同名列。自然连接使用 NATURAL JOIN 关键字。
  • 外连接
    • 左连接(LEFT JOIN) - “左外连接”会获取左表所有记录,即使右表没有对应匹配的记录。左外连接使用 LEFT JOIN 关键字。
    • 右连接(RIGHT JOIN) - “右外连接”会获取右表所有记录,即使左表没有对应匹配的记录。右外连接使用 RIGHT JOIN 关键字。

SQL JOIN

:::

【中级】为什么不推荐多表 JOIN?

扩展阅读:https://www.cnblogs.com/eiffelzero/p/18608160

:::details 要点

《阿里巴巴 Java 开发手册》 中强制要求超过三个表禁止 join。这是为什么呢?

主要原因如下:

  • 性能问题
    • 查询效率低:当涉及多个表进行 JOIN 操作时,MySQL 需要执行多次扫描,尤其是在没有合适索引支持的情况下,性能可能会大幅下降。每增加一个表的 JOIN,查询的复杂度呈指数增长。
    • 临时表的创建:MySQL 在执行复杂的多表 JOIN 时,通常会创建临时表来存储中间结果。如果数据量很大,临时表可能会溢出到磁盘,导致磁盘 I/O 操作增加,从而显著影响查询性能。
  • 索引的作用有限
    • 在多表 JOIN 的操作中,虽然每个表可以使用索引加速查询,但是当涉及到多个表的连接时,MySQL 必须在这些表之间执行 JOIN 操作,这时索引的效果会大大降低。特别是在没有合适索引的情况下,JOIN 查询会导致全表扫描,极大地降低了查询效率。
  • 数据冗余
    • 在多表 JOIN 时,如果一个表中的一行数据与另一个表中的多行数据进行匹配,结果会产生数据冗余。例如,假设有两个表:ABA 中有 10 条记录,B 中有 5 条记录。如果在 AB 上做 JOIN 操作,且匹配条件满足 2 条记录,那么最终的结果会有 20 条记录(10 * 2)。这会导致数据量急剧增加,浪费存储空间。
  • 可读性和可维护性
    • 多表 JOIN 的 SQL 查询通常比较复杂,尤其是当涉及多个表、多个连接条件以及嵌套查询时,查询语句的可读性会下降,增加了维护的难度。
    • 复杂的查询可能让开发者和运维人员难以理解和优化,从而增加了错误的风险。
  • 可能引发死锁
    • 在进行多个表 JOIN 操作时,如果涉及到多张表的锁定,可能会导致死锁。特别是在高并发的环境下,频繁执行 JOIN 操作容易导致多个事务之间相互等待,最终导致死锁问题。
  • 优化器的作用有限
    • MySQL 的优化器对多表 JOIN 的优化能力相对有限,尤其在处理非常复杂的查询时,可能无法有效选择最优的执行计划,从而导致性能瓶颈。
    • 虽然 MySQL 使用了 查询缓存索引优化,但对于多表 JOIN 的优化仍然受到很多限制,导致性能不如预期。

:::

【中级】DELETE、DROP 和 TRUNCATE 有什么区别?

:::details 要点

  • DROP 删除数据表,包括数据和结构。在 InnoDB 中,表数据存于 .ibd 文件;表结构元数据存于 .frm 文件。DROP 本质上是就是直接删除 .ibd.frm 文件。
  • DELETE 删除数据,但保留表结构。执行 DELETE 后,空间大小不会立刻变化。这是因为,DLETE 操作实际上只是标记,被写入 biglog、redo log 和 undo log。
  • TRUNCATE 会删除全部表数据,且不会记录日志,因此无法回滚。TRUNCATE 执行后,自增主键重新从 1 开始。

:::

【中级】哪种 COUNT 性能最好?

:::details 要点

先说结论:按照效率排序的话,COUNT(字段) < COUNT(主键 id) < COUNT(1)COUNT(*)推荐采用 COUNT(*)

对于 COUNT(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

对于 COUNT(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

单看这两个用法的差别的话,你能对比出来,COUNT(1) 执行得要比 COUNT(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

对于 COUNT(字段) 来说

  • 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  • 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

但是 COUNT(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。COUNT(*) 肯定不是 null,按行累加。

:::

:::details 细节

InnoDB 和 MyISAM 的 count(*) 实现方式有什么区别?

不同的 MySQL 引擎中,COUNT(*) 有不同的实现方式:

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 COUNT(*) 的时候会直接返回这个数,效率很高;
  • 而 InnoDB 引擎就麻烦了,它执行 COUNT(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

为什么 InnoDB 不跟 MyISAM 一样,也维护一个计数器?

因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。

InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 COUNT(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。

  • MyISAM 表虽然 COUNT(*) 很快,但是不支持事务;
  • show table status 命令虽然返回很快,但是不准确;
  • InnoDB 表直接 COUNT(*) 会遍历全表,虽然结果准确,但会导致性能问题。

如何优化查询计数?

  • 可以使用 Redis 保存计数,但存在数据丢失和逻辑不一致的问题。
  • 可以使用数据库其他表保存计数,利用事务的原子性和隔离性,可以避免数据丢失和逻辑不一致的问题。

:::

MySQL 数据类型

扩展阅读:

【基础】CHAR 和 VARCHAR 的区别是什么?

:::details 要点

CHARVARCHAR 的主要区别在于:CHAR 是定长字符串,VARCHAR 是变长字符串。

  • 长度限制
    • CHAR(M)VARCHAR(M) 的 M 都代表能够保存的字符数的最大值,无论是字母、数字还是中文,每个都只占用一个字符。
  • 占用空间
    • CHAR 在存储时会在右边填充空格以达到指定的长度,检索时会去掉空格;
    • VARCHAR 在存储时需要使用 1 或 2 个额外字节记录字符串的长度,检索时不需要处理。
      • 字符长度超过 255,使用 2 个字节
      • 字符长度未超过 255,使用 1 个字节
  • 应用
  • CHAR 适合存储长度较短或长度固定多的字符串。例如 Bcrypt 算法、MD5 算法加密后的密码、身份证号码;
  • VARCHAR 适合存储长度不确定的字符串。例如用户昵称、文章标题等。

BINARYVARBINARY 类似于 CHARVARCHAR,不同的是它们包含二进制字符串而不要非二进制字符串。也就是说,它们包含字节字符串而不是字符字符串。这说明它们没有字符集,并且排序和比较基于列值字节的数值值。

:::

【基础】金额数据用什么类型存储?

:::details 要点

MySQL 中有 3 种类型可以表示浮点数,分别是 FLOATDOUBLEDECIMAL

采用 FLOATDOUBLE 类型会丢失精度。数据的精确度取决于分配给每种数据类型的存储长度。由于计算机只能存储二进制,所以浮点型数据在存储的时候,必须转化成二进制。

  • 单精度类型 FLOAT 存储空间为 4 字节,即 32 位。
  • 双精度类型 DOUBLE 存储空间为 8 字节,即 64 位。

如果存储的数据转为二进制后,超过存储的位数,数据就被截断,因此存在丢失精度的可能。

更重要的是,从 MySQL 8.0.17 版本开始,当创建表用到类型 Float 或 Double 时,会抛出下面的警告:MySQL 提醒用户不该用上述浮点类型,甚至提醒将在之后版本中废弃浮点类型。

1
Specifying number of digits for floating point data types is deprecated and will be removed in a future release

【示例】丢失精度案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 创建表
CREATE TABLE `test` (
`value` FLOAT(10,2) DEFAULT NULL
);

mysql> insert into test value (131072.32);
Query OK, 1 row affected (0.01 sec)

mysql> select * from test;
+-----------+
| value |
+-----------+
| 131072.31 |
+-----------+
1 row in set (0.02 sec)

说明:示例中,使用 FLOAT 类型,明明保留了两位小数。但是写入的数据却从 131072.32 变成了 131072.31

DECIMAL 类型是 MySQL 官方唯一指定能精确存储的类型。因此,对于不允许丢失精度的场景(如金额数据),可以使用 DECIMAL 类型。

然而,在海量并发的互联网业务中使用,金额字段的设计并不推荐使用 DECIMAL 类型,而更推荐使用 BIGINT 整型类型。这里会用到一个巧思:将资金类型的数据用分为单位存储,而不是用元为单位存储。如 1 元在数据库中用整型类型 100 存储。

为什么更推荐用 BIGINT 存储金钱数据?因为 DECIMAL 是个变长字段,若要定义金额字段,则定义为 DECIMAL(8,2) 是远远不够的。这样只能表示存储最大值为 999999.99,百万级的资金存储。用户的金额至少要存储百亿的字段,而统计局的 GDP 金额字段则可能达到数十万亿级别。用类型 DECIMAL 定义,不好统一。另外重要的是,类型 DECIMAL 是通过二进制实现的一种编码方式,计算效率远不如整型来的高效。因此,推荐使用 BIGINT 来存储金额相关的字段。

扩展阅读:MySQL 如何选择 float, double, decimal

:::

【基础】如何存储 emoji 😃?

:::details 要点

在表结构设计中,除了将列定义为 CHARVARCHAR 用以存储字符以外,还需要额外定义字符对应的字符集,因为每种字符在不同字符集编码下,对应着不同的二进制值。常见的字符集有 gbkutf8,通常推荐把默认字符集设置为 utf8

随着移动互联网的飞速发展,**推荐把 MySQL 的默认字符集设置为 utf8mb4**,否则,某些 emoji 表情字符无法在 UTF8 字符集下存储。

【示例】设置表的字符集为 utf8mb4

1
ALTER TABLE test CHARSET utf8mb4;

注意:上述修改只是将表的字符集修改为 utf8mb4,下次新增列时,若不显式地指定字符集,新列的字符集会变更为 utf8mb4但对于已经存在的列,其默认字符集并不做修改

【示例】设置表的默认字符集为 utf8mb4

正确设置 utf8mb4 字符集方法如下:

1
ALTER TABLE test CONVERT TO CHARSET utf8mb4;

:::

【基础】时间数据选择 DATETIME 还是 TIMESTAMP?

:::details 要点

表结构设计时,对时间字段的存储,通常会有 3 种选择:DATETIMETIMESTAMPINT

DATETIMETIMESTAMPINT 数据表示范围:

  • DATETIME 占用 8 个字节,可表示范围为:1000-01-01 00:00:00.0000009999-12-31 23:59:59.999999
  • TIMESTAMP 占用 4 个字节,可表示范围为:'1970-01-01 00:00:01.000000' UTC'2038-01-09 03:14:07.999999' UTC。表示从 1970-01-01 00:00:00 到现在的毫秒数。
  • INT 类型就是直接存储 ‘1970-01-01 00:00:00’ 到现在的毫秒数,本质和 TIMESTAMP 一样,因此用 INT 不如直接使用 TIMESTAMP

此外,TIMESTAMP 还存在潜在的性能问题。虽然从毫秒数转换到类型 TIMESTAMP 本身需要的 CPU 指令并不多,这并不会带来直接的性能问题。但是如果使用默认的操作系统时区,则每次通过时区计算时间时,要调用操作系统底层系统函数 __tz_convert(),而这个函数需要额外的加锁操作,以确保这时操作系统时区没有修改。所以,当大规模并发访问时,由于热点资源竞争,会产生两个问题。

  • 性能不如 DATETIME: DATETIME 不存在时区转化问题。
  • 性能抖动: 海量并发时,存在性能抖动问题。

为了优化 TIMESTAMP 的使用,强烈建议使用显式的时区,而不是操作系统时区。比如在配置文件中显示地设置时区,而不要使用系统时区

综上,由于 TIMESTAMP 存在时间上限和潜在性能问题,所以推荐使用 DATETIME 类型来存储时间字段。

:::

MySQL 存储

扩展阅读:

【中级】MySQL 支持哪些存储引擎?

:::details 要点

存储引擎层负责数据的存储和提取。MySQL 的存储引擎采用了插拔式架构,可以根据需要替换。

MySQL 内置了以下存储引擎:

  • InnoDB - InnoDB 是 MySQL 5.5 版本以后的默认存储引擎。
    • 优点:支持事务,支持行级锁,支持外键约束等,并发性能不错且支持自动故障恢复
  • MyISAM - MyISAM 是 MySQL 5.5 版本以前的默认存储引擎。
    • 优点:速度快,占用资源少。
    • 缺点:不支持事务,不支持行级锁,不支持外键约束,也不支持自动故障恢复功能。
  • Memory - 使用系统内存作为存储介质,以便得到更快的响应速度。不过,如果 mysqld 进程崩溃,则会导致所有的数据丢失。因此,Memory 引擎常用于临时表。
  • NDB - 也被称为 NDB Cluster 存储引擎,主要用于 MySQL Cluster 分布式集群环境,类似于 Oracle 的 RAC 集群。
  • Archive - Archive 存储引擎有很好的压缩机制,非常适合用于归档数据。
    • Archive 存储引擎只支持 INSERTSELECT 操作。
    • Archive 存储引擎采用 zlib 算法压缩数据,压缩比可达到 1: 10。
  • CSV - 可以将 CSV 文件作为 MySQL 的表来处理,但这种表不支持索引。

:::

【中级】InnoDB 和 MyISAM 有哪些差异?

:::details 要点

对比项 MyISAM InnoDB
外键 不支持 支持
事务 不支持 支持四种事务隔离级别
锁粒度 支持表级锁 支持表级锁、行级锁
索引 采用 B+ 树索引(非聚簇索引) 采用 B+ 树索引(聚簇索引)
表空间
关注点 性能 事务
计数器 维护了计数器,SELECT COUNT(*) 效率为 O(1) 没有维护计数器,需要全表扫描
自动故障恢复 不支持 支持(依赖于 redo log)

:::

【中级】如何选择存储引擎?

:::details 要点

  • 大多数情况下,使用默认的 InnoDB 就够了。如果要提供提交、回滚和恢复的事务安全(ACID 兼容)能力,并要求实现并发控制,InnoDB 就是比较靠前的选择了。
  • 如果数据表主要用来插入和查询记录,则 MyISAM 引擎提供较高的处理效率。
  • 如果只是临时存放数据,数据量不大,并且不需要较高的数据安全性,可以选择将数据保存在内存的 MEMORY 引擎中。MySQL 中使用该引擎作为临时表,存放查询的中间结果。
  • 如果存储归档数据,可以使用 ARCHIVE 引擎。

使用哪一种引擎可以根据需要灵活选择,因为存储引擎是基于表的,所以一个数据库中多个表可以使用不同的引擎以满足各种性能和实际需求。使用合适的存储引擎将会提高整个数据库的性能。

:::

【中级】MySQL 有哪些物理存储文件?

:::details 要点

MySQL 不同存储引擎的物理存储文件是不一样的。

InnoDB 的物理文件结构为:

  • .frm 文件:与表相关的元数据信息都存放在 frm 文件,包括表结构的定义信息等。
  • .ibd 文件或 .ibdata 文件: 这两种文件都是存放 InnoDB 数据的文件,之所以有两种文件形式存放 InnoDB 的数据,是因为 InnoDB 的数据存储方式能够通过配置来决定是使用共享表空间存放存储数据,还是用独享表空间存放存储数据。
    • 独享表空间存储方式使用.ibd文件,并且每个表一个.ibd文件
    • 共享表空间存储方式使用.ibdata文件,所有表共同使用一个.ibdata文件(或多个,可自己配置)

MyISAM 的物理文件结构为:

  • .frm文件:与表相关的元数据信息都存放在 frm 文件,包括表结构的定义信息等。
  • .MYD (MYData) 文件:MyISAM 存储引擎专用,用于存储 MyISAM 表的数据。
  • .MYI (MYIndex) 文件:MyISAM 存储引擎专用,用于存储 MyISAM 表的索引相关信息。

:::

【中级】什么是 Buffer Pool?

:::details 要点

Buffer Pool(缓冲池)是 MySQL InnoDB 存储引擎的核心组件之一,它是数据库系统中的内存缓存区域,主要用于缓存表和索引的数据

主要作用

  1. 减少磁盘 I/O:将频繁访问的数据页缓存在内存中,避免每次查询都要从磁盘读取
  2. 提高查询性能:内存访问速度远快于磁盘访问
  3. 写缓冲:对数据的修改先在内存中进行,再通过后台线程定期刷新到磁盘

工作原理

  • Buffer Pool 以页 (page) 为单位存储数据,默认每页 16KB
  • 使用 LRU (最近最少使用)算法管理内存页
  • 包含”年轻代”和”老年代”两个区域,防止全表扫描污染缓存

:::

【中级】什么是 Change Buffer?

:::details 要点

Change Buffer 是 InnoDB 存储引擎中的一种关键优化机制,主要用于提高非唯一二级索引的写操作性能

Change Buffer 是一种特殊的内存数据结构,用于缓存对非唯一二级索引页的修改操作(INSERT、UPDATE、DELETE),当这些索引页不在缓冲池 (Buffer Pool) 中时,避免立即从磁盘读取索引页。

工作原理

  • 写操作发生时:当修改非唯一二级索引的数据时,InnoDB 会检查目标索引页是否在 Buffer Pool 中。

    • 如果在:直接修改
    • 如果不在:将修改操作记录到 Change Buffer
  • 后续读取时:当需要读取该索引页时,InnoDB 会将 Change Buffer 中的修改与从磁盘读取的原始页合并。

  • 后台合并:有专门的线程定期将 Change Buffer 中的变更合并到磁盘上的索引页。

优势

  • 减少磁盘 I/O:避免为写入操作立即读取索引页
  • 提高吞吐量:多个变更可以合并执行
  • 减少随机 I/O:将随机写入转为顺序写入

适用场景

  • 适用于写多读少的非唯一二级索引
  • 特别适合大量 DML 操作但索引不常被查询的业务场景

不适用场景

  • 唯一索引(需要立即检查唯一性约束)
  • 索引被频繁查询(会导致频繁合并操作)

相关配置

  • innodb_change_buffer_max_size:Change Buffer 最大占 Buffer Pool 的比例(默认 25%)
  • innodb_change_buffering:指定缓冲的变更类型(all/none/inserts/deletes 等)

:::

MySQL 日志

【基础】MySQL 有哪些类型的日志?

:::details 要点

MySQL 日志文件有很多,包括 :

  • 错误日志(error log):错误日志文件对 MySQL 的启动、运行、关闭过程进行了记录,能帮助定位 MySQL 问题。
  • 慢查询日志(slow query log):慢查询日志是用来记录执行时间超过 long_query_time 这个变量定义的时长的查询语句。通过慢查询日志,可以查找出哪些查询语句的执行效率很低,以便进行优化。
  • 一般查询日志(general log):一般查询日志记录了所有对 MySQL 数据库请求的信息,无论请求是否正确执行。
  • 二进制日志(bin log):关于二进制日志,它记录了数据库所有执行的 DDL 和 DML 语句(除了数据查询语句 select、show 等),以事件形式记录并保存在二进制文件中。

还有两个 InnoDB 存储引擎特有的日志文件:

  • 重做日志(redo log):重做日志至关重要,因为它们记录了对于 InnoDB 存储引擎的事务日志。
  • 回滚日志(undo log):回滚日志同样也是 InnoDB 引擎提供的日志,顾名思义,回滚日志的作用就是对数据进行回滚。当事务对数据库进行修改,InnoDB 引擎不仅会记录 redo log,还会生成对应的 undo log 日志;如果事务执行失败或调用了 rollback,导致事务需要回滚,就可以利用 undo log 中的信息将数据回滚到修改之前的样子。

:::

【基础】bin log 和 redo log 有什么区别?

:::details 要点

  • bin log 会记录所有与数据库有关的日志记录,包括 InnoDB、MyISAM 等存储引擎的日志;而 redo log 只记 InnoDB 存储引擎的日志。
  • 记录的内容不同,bin log 记录的是关于一个事务的具体操作内容,即该日志是逻辑日志。而 redo log 记录的是关于每个页(Page)的更改的物理情况。
  • 写入的时间不同,bin log 仅在事务提交前进行提交,也就是只写磁盘一次。而在事务进行的过程中,却不断有 redo ertry 被写入 redo log 中。
  • 写入的方式也不相同,redo log 是循环写入和擦除,bin log 是追加写入,不会覆盖已经写的文件。

:::

【基础】redo log 如何刷盘?

:::details 要点

redo log 的写入不是直接落到磁盘,而是在内存中设置了一片称之为 redo log buffer 的连续内存空间,也就是 redo 日志缓冲区。

在如下的一些情况中,log buffer 的数据会刷入磁盘:

  • log buffer 空间不足时:log buffer 的大小是有限的,如果不停的往这个有限大小的 log buffer 里塞入日志,很快它就会被填满。如果当前写入 log buffer 的 redo 日志量已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
  • 事务提交时:在事务提交时,为了保证持久性,会把 log buffer 中的日志全部刷到磁盘。注意,这时候,除了本事务的,可能还会刷入其它事务的日志。
  • 后台线程输入:有一个后台线程,大约每秒都会刷新一次log buffer中的redo log到磁盘。
  • 正常关闭服务器时
  • 触发 checkpoint 规则

重做日志缓存、重做日志文件都是以 块(block) 的方式进行保存的,称之为、重做日志块(redo log block), 块的大小是固定的 512 字节。我们的 redo log 它是固定大小的,可以看作是一个逻辑上的 log group,由一定数量的 log block 组成。

它的写入方式是从头到尾开始写,写到末尾又回到开头循环写。

其中有两个标记位置:

write pos是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到磁盘。

write_pos追上checkpoint时,表示 redo log 日志已经写满。这时候就不能接着往里写数据了,需要执行checkpoint规则腾出可写空间。

所谓的 checkpoint 规则,就是 checkpoint 触发后,将 buffer 中日志页都刷到磁盘。

:::

【中级】日志为什么要两阶段提交?

:::details 要点

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。

  1. 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。
    • 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。
    • 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  2. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

:::

【中级】什么是 WAL?

:::details 要点

WAL(Write-Ahead Logging)是一种数据库事务日志管理技术,确保在修改数据之前先将修改记录写入日志。它的关键点就是 先写日志,再写磁盘

WAL 是一种通用技术,被广泛应用于各种数据库,但实现各有不同。在 InnoDB 中,redo log 就是 WAL 的实现。

大致流程为:

  • 事务开始时,修改记录到重做日志缓冲区。
  • 重做日志缓冲区的数据周期性刷新到磁盘上的 redo log 文件。
  • 事务提交时,确保 redo log 已写入磁盘,然后将数据页的修改写入数据文件。
  • 系统崩溃时,通过 redo log 重新应用未完成的事务,恢复数据库到一致状态。

::

【中级】什么是 Log Buffer?

:::details 要点

Log Buffer 用于缓冲 redo log 的写入,减少频繁刷盘 fsync 的开销,将多次写入优化为一次批量写入。

redo log 是 InnoDB 的重做日志,用于崩溃恢复,确保数据正确性。redo log 采用 WAL 机制:先写日志,再写磁盘数据,将随机写入转换为顺序写入。

Log Buffer 的刷盘时机

  • 事务提交时:事务产生的多条 redo log 会先缓存在 Log Buffer,提交时一次性写入文件(受配置参数控制)。
  • 容量触发:当 Log Buffer 超过总容量的一半(默认 16MB)时自动刷盘。
  • 后台线程:每隔 1 秒定时刷盘。

配置参数innodb_flush_log_at_trx_commit

  • 0:事务提交不刷盘,依赖后台线程每秒刷盘。性能最佳,但可能丢失 1 秒数据。
  • 1(默认):事务提交时同步刷盘(写 OS cache 并调用 fsync)。数据最安全,性能最差。
  • 2:事务提交时仅写 OS cache,后台线程每秒调用 fsync。性能折中,服务器宕机可能丢失 1 秒数据。

:::

MySQL 复制

【中级】MySQL 如何实现主从同步?

:::details 要点

复制解决的基本问题是让一台服务器的数据与其他服务器保持同步。一台主库的数据可以同步到多台从库上,从库本身也可以被配置成另外一台服务器的主库。主库和从库之间可以有多种不同的组合方式。

MySQL 复制采用主从同步,基于 binlog(二进制日志) 实现。其流程大致为:

  • 主库记录 DML/DDL 操作到 binlog。
  • 从库获取 binlog 并重放,保持数据同步。

MySQL 支持三种复制方式:同步、异步、半同步。下面是三种方式的对比:

模式 机制 优点 缺点
异步复制(默认) 主库不等待从库响应 高性能 数据一致性弱(可能丢失)
同步复制 主库等待所有从库确认 强一致性 性能差,延迟高
半同步复制 主库等待至少一个从库确认 平衡性能与一致性 比异步略慢

异步复制

MySQL 异步复制可以分为三个步骤,分别由三个线程完成:

  • binlog dump 线程 - 主库接收事务请求,更新数据,并即时响应客户端(不等待从库)。主库上有一个特殊的 binlog dump 线程,负责将主服务器上的数据更改写入 binlog 中。
  • I/O 线程 - 从库上有一个 I/O 线程,负责从主库上读取 binlog,并写入从库的中继日志(relay log)中。
  • SQL 线程 - 从库上有一个 SQL 线程,负责重放中继日志(relay log),更新从库数据。

需要注意的是,采用异步复制有丢失数据的风险,主库崩溃时,未同步的 binlog 可能丢失(弱一致性)。

同步复制

主库必须等待所有从库完成 binlog 同步后才响应客户端。

特点

  • 数据强一致性(所有节点完全同步)
  • 性能极差(延迟高,吞吐量低)
  • 生产环境基本不使用

半同步复制

MySQL 5.7 引入了半同步复制:主库只需等待至少 N 个从库(可配置)确认即返回。

特点

  • 性能与可靠性的平衡(比全同步快,比异步安全)。
  • 仅当主库和所有已确认从库同时崩溃时可能丢数据。

:::

【中级】如何处理 MySQL 主从同步延迟?

:::details 要点

主从延迟的常见解决方案

  • 二次查询(兜底策略):从库查不到时,再查主库。缺点是:恶意查询可能导致主库压力增大。
  • 强制写后读走主库:写入后立即读的操作绑定走主库。缺点是:代码耦合,灵活性差。
  • 关键业务读写主库,非关键业务读写分离
  • 使用缓存:主库写入后同步缓存,查询优先查缓存。缺点是:引入缓存后,新增了一致性问题。
  • 提升从库配置:优化从库硬件(CPU、内存、磁盘等),提高同步效率。

MySQL 主从延迟的常见原因及优化方案

原因 优化方案
从库单线程复制 启用 并行复制(多线程同步)。
网络延迟 优化网络,缩短主从物理距离。
从库性能不足 升级硬件(CPU、内存、存储)。
长事务 减少主库长事务,优化 SQL。
从库数量过多 合理控制从库数量,避免主库同步压力过大。
从库查询负载高 增加从库实例,优化慢查询。

小结

  • 主从延迟 无法完全避免,只能优化降低延迟时间。
  • 业务层面应结合 缓存、读写分离策略、关键业务走主库 等方式综合解决。
  • 技术层面可优化 并行复制、网络、硬件 等。

:::

【中级】如何实现 MySQL 读写分离?

:::details 要点

读写分离的基本原理是:主服务器用来处理写操作以及实时性要求比较高的读操作,而从服务器用来处理读操作

为何要读写分离?

  • 有效减少锁竞争 - 主服务器只负责写,从服务器只负责读,能够有效的避免由数据更新导致的行锁竞争,使得整个系统的查询性能得到极大的改善。
  • 提高查询吞吐量 - 通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。
  • 提升数据库可用性 - 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升数据库的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。

读写分离的实现是根据 SQL 语义分析,将读操作和写操作分别路由至主库与从库。

读写分离有两种实现方式:代码封装、中间件。以下是两种方案的对比:

方案 实现方式 优点 缺点
代码封装 业务层通过代理类路由读写请求(读走从库,写走主库)。 简单灵活,可定制化 - 适合业务特定需求 主从切换需修改配置并重启 - 多语言需重复开发
中间件 独立代理服务(如 MySQL-Proxy、ShardingSphere),客户端无感知。 屏蔽多语言差异,统一管理数据源 有额外维护成本,可能成为性能瓶颈

结论:代码封装适合简单架构,但扩展性差;中间件适合复杂架构,但需维护。

常见的读写分离中间件

  • MySQL-Proxy(官方)
  • Atlas(360)
  • ShardingSphere(Apache)
  • Mycat

:::

MySQL 分库分表

【中级】什么是分库分表?为何要分库分表?

:::details 要点

什么是分库分表?

分库分表是一种数据库水平拆分方案,用于解决单机数据库的存储瓶颈性能瓶颈问题。

  • 分库:将数据分散到不同的数据库实例(如 DB1DB2)。
  • 分表:将数据分散到同一数据库的不同表(如 order_1order_2)。

为何要分库分表?

分库分表主要基于以下理由:

  • 并发连接 - 单库超过每秒 2000 个并发时,而一个健康的单库最好保持在每秒 1000 个并发左右,不要太大。
  • 磁盘容量 - 磁盘容量占满,会导致服务器不可用。
  • SQL 性能 - 单表数据量过大,会导致 SQL 执行效率低下。一般,单表超过 1000 万条数据,就可以考虑分表了。
# 分库分表前 分库分表后
并发支撑情况 MySQL 单机部署,扛不住高并发 MySQL 从单机到多机,能承受的并发增加了多倍
磁盘使用情况 MySQL 单机磁盘容量几乎撑满 拆分为多个库,数据库服务器磁盘使用率大大降低
SQL 执行性能 单表数据量太大,SQL 越跑越慢 单表数据量减少,SQL 执行效率明显提升

:::

【中级】分库分表有哪些策略?

:::details 要点

分库分表策略主要有两种:

  • 根据数值范围划分
  • 根据 Hash 划分
  • 路由表

数值范围路由

数值范围路由,就是根据 ID、时间范围 这类具有排序性的字段来进行划分。例如:用户 Id 为 1-9999 的记录分到第一个库,10000-20000 的分到第二个库,以此类推。

按这种策略划分出来的数据,具有数据连续性。

  • 优点:数据迁移很简单。
  • 缺点:容易产生热点问题,大量的流量都打在最新的数据上了。

Hash 路由

典型的 Hash 路由,如根据数值取模,当需要扩容时,一般以 2 的幂次方进行扩容(这样,扩容时迁移的数据量会小一些)。例如:用户 Id mod n,余数为 0 的记录放到第一个库,余数为 1 的放到第二个库,以此类推。

一般采用 预分区 的方式,提前根据 数据量 规划好 分区数,比如划分为 5121024 张表,保证可支撑未来一段时间的 数据容量,再根据 负载情况 迁移到其他 数据库 中。扩容时通常采用 翻倍扩容,避免 数据映射 全部被 打乱,导致 全量迁移 的情况。

  • 优点:数据离散分布,不存在热点问题。
  • 缺点:数据迁移、扩容麻烦(之前的数据需要重新计算 hash 值重新分配到不同的库或表)。当节点数量变化时,如扩容收缩节点,数据节点映射关系需要重新计算,会导致数据的 重新迁移

路由表

这种策略,就是用一张独立的表记录路由信息。

  • 优点:简单、灵活,尤其是在扩容、迁移时,只需要迁移指定的数据,然后修改路由表即可。
  • 缺点:每次查询,必须先查路由表,增加了 IO 开销。并且,如果路由表本身太大,也会面临性能瓶颈,如果想对路由表再做分库分表,将出现死循环式的路由算法选择问题。

:::

【高级】分库分表存在哪些问题?

:::details 要点

分库分表主要存在以下问题:

  • 分布式 ID 问题
  • 分布式事务问题
  • 跨节点 Join 和聚合
  • 跨分片的排序分页

分布式 ID 问题

一旦数据库被切分到多个物理结点上,我们将不能再依赖数据库自身的主键生成机制。一方面,某个分区数据库自生成的 ID 无法保证在全局上是唯一的;另一方面,应用程序在插入数据之前需要先获得 ID,以便进行 SQL 路由。

分布式 ID 的解决方案详见:分布式 ID

分布式事务问题

跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于 XA 的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务。

分布式事务的解决方案详见:分布式事务

跨节点 Join 和聚合

分库分表后,无法直接跨节点 joincountorder bygroup by 以及聚合。

针对这类问题,普遍做法是二次查询

  • 在第一次查询时,获取各个节点上的结果。

  • 在程序中将这些结果进行合并、筛选。

跨分片的排序分页

一般来讲,分页时需要按照指定字段进行排序。当排序字段就是分片字段的时候,我们通过分片规则可以比较容易定位到指定的分片,而当排序字段非分片字段的时候,情况就会变得比较复杂了。为了最终结果的准确性,我们需要在不同的分片节点中将数据进行排序并返回,并将不同分片返回的结果集进行汇总和再次排序,最后再返回给用户。如下图所示:

img

上面图中所描述的只是最简单的一种情况(取第一页数据),看起来对性能的影响并不大。但是,如果想取出第 10 页数据,情况又将变得复杂很多,如下图所示:

img

有些读者可能并不太理解,为什么不能像获取第一页数据那样简单处理(排序取出前 10 条再合并、排序)。其实并不难理解,因为各分片节点中的数据可能是随机的,为了排序的准确性,必须把所有分片节点的前 N 页数据都排序好后做合并,最后再进行整体的排序。很显然,这样的操作是比较消耗资源的,用户越往后翻页,系统性能将会越差。

那如何解决分库情况下的分页问题呢?有以下几种办法:

如果是在前台应用提供分页,则限定用户只能看前面 n 页,这个限制在业务上也是合理的,一般看后面的分页意义不大(如果一定要看,可以要求用户缩小范围重新查询)。

如果是后台批处理任务要求分批获取数据,则可以加大 page size,比如每次获取 5000 条记录,有效减少分页数(当然离线访问一般走备库,避免冲击主库)。

分库设计时,一般还有配套大数据平台汇总所有分库的记录,有些分页查询可以考虑走大数据平台。

:::

【高级】如何实现迁库和扩容?

:::details 要点

停机迁移/扩容(不推荐)

停机迁移/扩容是最暴力、最简单的迁移、扩容方案。

img

停机迁移/扩容流程

  1. 预估停服时间,发布停服公告;停服,不允许数据访问。
  2. 编写临时的数据导入程序,从老数据库中读取数据。
  3. 将数据写入中间件。
  4. 中间件根据分片规则,将数据分发到分库(分表)中。
  5. 应用程序修改配置,重启。

停机迁移/扩容方案分析

  • 优点:简单、无数据一致性问题。
  • 缺点
    • 停服时间长(数据量大时可能需数小时)。
    • 风险高,失败后难以回滚。

结论:代价过高,不推荐使用。

双写迁移

双写迁移方案核心思想

  • 新旧库同时写入,通过开关控制读写状态(只写旧库、只写新库、双写)。
  • 逐步切换读请求到新库,确保数据一致性。

双写迁移方案关键步骤

  1. 双写阶段:先写旧库,再写新库,以旧库结果为准。记录旧库成功但新库失败的日志,用于补偿。
  2. 数据校验:运行对比程序,检查新旧库数据差异并修复。
  3. 灰度切换读请求:逐步将读流量切至新库,观察稳定性。
  4. 最终切换:读写全部切至新库,清理旧库冗余数据。

img

双写迁移流程

  1. 修改应用程序配置,将数据同时写入老数据库和中间件。这就是所谓的双写,同时写俩库,老库和新库。
  2. 编写临时程序,读取老数据库。
  3. 将数据写入中间件。如果数据不存在,直接写入;如果数据存在,比较时间戳,只允许新数据覆盖老数据。
  4. 导入数据后,有可能数据还是存在不一致,那么就对数据进行校验,比对新老库的每条数据。如果存在差异,针对差异数据,执行(3)。循环(3)、(4)步骤,直至数据完全一致。
  5. 修改应用程序配置,将数据只写入中间件。
  6. 中间件根据分片规则,将数据分发到分库(分表)中。

双写迁移方案分析

优点

  • 无需停服,业务影响小。
  • 可灰度验证,风险可控。

缺点

  • 实现复杂,需处理双写一致性和补偿逻辑。

主从替换

生产环境的数据库,为了保证高可用,一般会采用主从架构。主库支持读写操作,从库支持读操作。

img

由于主从节点数据一致,所以将从库升级为主节点,并修改分片配置,将从节点作为分库之一,就实现了扩容。

img

主从替换方案流程

  1. 解除主从关系,从库升级为主库。
  2. 应用程序,修改配置,读写通过中间件。
  3. 分库分表中间,修改分片配置。将数据按照新的规则分发。
  4. 编写临时程序,清理冗余数据。比如:原来是一个单库,数据量为 400 万。从节点升级为分库之一后,每个分库都有 400 万数据,其中 200 万是冗余数据。清理完后,进行数据校验。
  5. 为每个分库添加新的从库,保证高可用。

主从替换方案分析

  • 无需停机,无需全量数据迁移。
  • 利用现有从库资源,节省成本。

三种方案对比

方案 适用场景 优点 缺点
停机迁移 小规模数据,容忍停服 简单,无一致性问题 停服时间长,风险高
双写迁移 大规模数据,要求高可用 无停服,灰度可控 复杂,需补偿机制
主从替换 已有主从架构 无需迁移数据,快速扩容 依赖现有从库,清理冗余复杂

推荐选择

  • 优先双写迁移:适合大多数业务,平衡风险与复杂度。
  • 主从升级:适合已有主从且数据量适中的场景。
  • 避免停机迁移:除非数据量极小且可接受停服。

:::

MySQL 优化

【基础】如何发现慢 SQL?

:::details 要点

慢 SQL 的监控主要通过两个途径:

  • 慢查询日志:开启 MySQL 的慢查询日志,再通过一些工具比如 mysqldumpslow 去分析对应的慢查询日志,当然现在一般的云厂商都提供了可视化的平台。
  • 服务监控:可以在业务的基建中加入对慢 SQL 的监控,常见的方案有字节码插桩、连接池扩展、ORM 框架过程,对服务运行中的慢 SQL 进行监控和告警。

:::

【基础】什么是执行计划?

:::details 要点

“执行计划”是对 SQL 查询语句在数据库中执行过程的描述。 如果要分析某条 SQL 的性能问题,通常需要先查看 SQL 的执行计划,排查每一步 SQL 执行是否存在问题。

很多数据库都支持执行计划,MySQL 也不例外。在 MySQL 中,用户可以通过 EXPLAIN 命令查看优化器针对指定 SQL 生成的逻辑执行计划。

【示例】MySQL 执行计划示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql> explain select * from user_info where id = 2
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: user_info
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)

执行计划返回结果参数说明:

  • id - SELECT 查询的标识符。每个 SELECT 都会自动分配一个唯一的标识符。
  • select_type - SELECT 查询的类型。
    • SIMPLE - 表示此查询不包含 UNION 查询或子查询。
    • PRIMARY - 表示此查询是最外层的查询。
    • UNION - 表示此查询是 UNION 的第二或随后的查询。
    • DEPENDENT UNION - UNION 中的第二个或后面的查询语句,取决于外面的查询。
    • UNION RESULT - UNION 的结果。
    • SUBQUERY - 子查询中的第一个 SELECT
    • DEPENDENT SUBQUERY - 子查询中的第一个 SELECT, 取决于外面的查询。即子查询依赖于外层查询的结果。
  • table - 查询的是哪个表,如果给表起别名了,则显示别名。
  • partitions - 匹配的分区。
  • type - 表示从表中查询到行所执行的方式,查询方式是 SQL 优化中一个很重要的指标。执行效率由高到低依次为:
    • system/const - 表中只有一行数据匹配。此时根据索引查询一次就能找到对应的数据。如果是 B+ 树索引,我们知道此时索引构造成了多个层级的树,当查询的索引在树的底层时,查询效率就越低。const 表示此时索引在第一层,只需访问一层便能得到数据。
    • eq_ref - 使用唯一索引扫描。常见于多表连接中使用主键和唯一索引作为关联条件。
    • ref - 非唯一索引扫描。还可见于唯一索引最左原则匹配扫描。
    • range - 索引范围扫描。比如 <>between 等操作。
    • index - 索引全表扫描。此时遍历整个索引树。
    • ALL - 表示全表扫描。需要遍历全表来找到对应的行。
  • possible_keys - 此次查询中可能选用的索引。
  • key - 此次查询中实际使用的索引。如果这一项为 NULL,说明没有使用索引。
  • ref - 哪个字段或常数与 key 一起被使用。
  • rows - 显示此查询一共扫描了多少行,这个是一个估计值。
  • filtered - 表示此查询条件所过滤的数据的百分比。
  • extra - 额外的信息。
    • Using index - 使用覆盖索引,无需回表。
    • Using where - 服务器在存储引擎检索后过滤。
    • Using temporary - 使用临时表。MySQL 在对查询结果排序时使用临时表,常见于排序 ORDER BY 和分组查询 GROUP BY。效率低,要避免这种问题的出现。
    • Using filesort - 额外排序。无法利用索引完成排序时,就不得不将查询匹配数据进行排序,甚至可能会通过文件进行排序,效率很低。
    • Using join buffer - 使用连接缓冲

更多内容请参考:MySQL 性能优化神器 Explain 使用分析

:::

【基础】如何分析执行计划?

:::details 要点

执行计划关键字段

  • type - 按性能从高到低排序:system > const > eq_ref > ref > range > index > ALL。目标应尽可能避免 ALL(全表扫描)。
  • possible_keys - 可能使用的索引。
  • key - 实际使用的索引。
  • rows - 预估需要检查的行数,值越小越好。
  • Extra - 包含重要补充信息。

执行计划分析步骤

  1. 查看 type - 确保访问类型为 consteq_refrefrange ,避免 ALL
  2. 查看 key - 确认是否使用了合适的索引。若 keyNULL 表示未使用索引,需优化。
  3. 查看 rows - 扫描的行数越少越好。
  4. 查看 Extra - 避免 Using temporary(使用临时表) 和 Using filesort (额外排序)。

对应优化:

  • 如果 typeALL,考虑为 WHERE 条件列添加索引。
  • 如果 Extra 包含 Using filesort ,优化 ORDER BYGROUP BY
  • 如果 rows 过大,检查索引是否有效。

:::

【中级】如何优化 SQL?

:::details 要点

避免不必要的列

这个是老生常谈,但还是经常会出的情况,SQL 查询的时候,应该只查询需要的列,而不要包含额外的列,像slect * 这种写法应该尽量避免。

分页优化

在数据量比较大,分页比较深的情况下,需要考虑分页的优化。

例如:

1
select * from table where type = 2 and level = 9 order by id asc limit 190289,10;

优化方案:

  • 延迟关联

先通过 where 条件提取出主键,在将该表与原数据表关联,通过主键 id 提取数据行,而不是通过原来的二级索引提取数据行

例如:

1
2
3
select a.* from table a,
(select id from table where type = 2 and level = 9 order by id asc limit 190289,10 ) b
where a.id = b.id
  • 书签方式

书签方式就是找到 limit 第一个参数对应的主键值,根据这个主键值再去过滤并 limit

例如:

1
2
select * from table where id >
(select * from table where type = 2 and level = 9 order by id asc limit 190

索引优化

合理地设计和使用索引,是优化慢 SQL 的利器。

利用覆盖索引

InnoDB 使用非主键索引查询数据时会回表,但是如果索引的叶节点中已经包含要查询的字段,那它没有必要再回表查询了,这就叫覆盖索引

例如对于如下查询:

1
select name from test where city='上海'

我们将被查询的字段建立到联合索引中,这样查询结果就可以直接从索引中获取

1
alter table test add index idx_city_name (city, name);

低版本避免使用 or 查询

在 MySQL 5.0 之前的版本要尽量避免使用 or 查询,可以使用 union 或者子查询来替代,因为早期的 MySQL 版本使用 or 查询可能会导致索引失效,高版本引入了索引合并,解决了这个问题。

避免使用 != 或者 <> 操作符

SQL 中,不等于操作符会导致查询引擎放弃查询索引,引起全表扫描,即使比较的字段上有索引

解决方法:通过把不等于操作符改成 or,可以使用索引,避免全表扫描

例如,把column<>’aaa’,改成 column>’aaa’ or column<’aaa’,就可以使用索引了

适当使用前缀索引

适当地使用前缀所云,可以降低索引的空间占用,提高索引的查询效率。

比如,邮箱的后缀都是固定的“@xxx.com”,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引

1
alter table test add index index2(email(6));

PS: 需要注意的是,前缀索引也存在缺点,MySQL 无法利用前缀索引做 order by 和 group by 操作,也无法作为覆盖索引

避免列上函数运算

要避免在列字段上进行算术运算或其他表达式运算,否则可能会导致存储引擎无法正确使用索引,从而影响了查询的效率

1
2
select * from test where id + 1 = 50;
select * from test where month(updateTime) = 7;

正确使用联合索引

使用联合索引的时候,注意最左匹配原则。

JOIN 优化

优化子查询

尽量使用 Join 语句来替代子查询,因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,而临时表的创建与销毁会占用一定的系统资源以及花费一定的时间,同时对于返回结果集比较大的子查询,其对查询性能的影响更大

小表驱动大表

关联查询的时候要拿小表去驱动大表,因为关联的时候,MySQL 内部会遍历驱动表,再去连接被驱动表。

比如 left join,左表就是驱动表,A 表小于 B 表,建立连接的次数就少,查询速度就被加快了。

1
select name from A left join B ;

适当增加冗余字段

增加冗余字段可以减少大量的连表查询,因为多张表的连表查询性能很低,所有可以适当的增加冗余字段,以减少多张表的关联查询,这是以空间换时间的优化策略

避免使用 JOIN 关联太多的表

《阿里巴巴 Java 开发手册》规定不要 join 超过三张表,第一 join 太多降低查询的速度,第二 join 的 buffer 会占用更多的内存。

如果不可避免要 join 多张表,可以考虑使用数据异构的方式异构到 ES 中查询。

排序优化

利用索引扫描做排序

MySQL 有两种方式生成有序结果:其一是对结果集进行排序的操作,其二是按照索引顺序扫描得出的结果自然是有序的

但是如果索引不能覆盖查询所需列,就不得不每扫描一条记录回表查询一次,这个读操作是随机 IO,通常会比顺序全表扫描还慢

因此,在设计索引时,尽可能使用同一个索引既满足排序又用于查找行

例如:

1
2
--建立索引(date,staff_id,customer_id)
select staff_id, customer_id from test where date = '2010-01-01' order by staff_id,customer_id;

只有当索引的列顺序和 ORDER BY 子句的顺序完全一致,并且所有列的排序方向都一样时,才能够使用索引来对结果做排序

UNION 优化

条件下推

MySQL 处理 union 的策略是先创建临时表,然后将各个查询结果填充到临时表中最后再来做查询,很多优化策略在 union 查询中都会失效,因为它无法利用索引

最好手工将 where、limit 等子句下推到 union 的各个子查询中,以便优化器可以充分利用这些条件进行优化

此外,除非确实需要服务器去重,一定要使用 union all,如果不加 all 关键字,MySQL 会给临时表加上 distinct 选项,这会导致对整个临时表做唯一性检查,代价很高。

:::

【中级】MySQL 中如何解决深分页问题?

:::details 要点

深分页 (Deep Pagination) 是指当数据量很大时,查询靠后的分页数据(比如第 1000 页)性能急剧下降的问题。

解决方案有以下几种:

(1)使用索引覆盖+延迟关联

1
2
3
4
5
6
7
8
9
10
-- 原始深分页查询(性能差)
SELECT * FROM large_table ORDER BY id LIMIT 100000, 10;

-- 优化后的查询
SELECT * FROM large_table
INNER JOIN (
SELECT id FROM large_table
ORDER BY id
LIMIT 100000, 10
) AS tmp USING(id);

(2)使用游标分页(记录上一页最后一条记录)

1
2
3
4
5
6
7
8
9
-- 第一页
SELECT * FROM large_table ORDER BY id LIMIT 10;

-- 获取上一页最后一条记录的 id=12345
-- 下一页查询
SELECT * FROM large_table
WHERE id > 12345
ORDER BY id
LIMIT 10;

(3)使用子查询优化

1
2
3
4
SELECT * FROM large_table
WHERE id >= (SELECT id FROM large_table ORDER BY id LIMIT 100000, 1)
ORDER BY id
LIMIT 10;

:::

MySQL 架构

【中级】SQL 查询语句的执行顺序是怎么样的?

:::details 要点

所有的查询语句都是从 FROM 开始执行的,在执行过程中,每个步骤都会为下一个步骤生成一个虚拟表,这个虚拟表将作为下一个执行步骤的输入。

执行顺序

1
2
3
4
5
6
7
8
9
(8) SELECT (9)DISTINCT<Select_list>
(1) FROM <left_table> (3) <join_type>JOIN<right_table>
(2) ON<join_condition>
(4) WHERE<where_condition>
(5) GROUP BY<group_by_list>
(6) WITH {CUBE|ROLLUP}
(7) HAVING<having_condtion>
(10) ORDER BY<order_by_list>
(11) LIMIT<limit_number>

扩展阅读:SQL 的书写顺序和执行顺序

:::

【高级】一条 SQL 查询语句是如何执行的?

:::details 要点

MySQL 整个查询执行过程,总的来说分为 6 个步骤:

  1. 连接器 - 客户端和 MySQL 服务器建立连接;连接器负责跟客户端建立连接获取权限维持和管理连接
  2. 查询缓存 - MySQL 服务器首先检查查询缓存,如果命中缓存,则立刻返回结果。否则进入下一阶段。MySQL 缓存弊大于利,因为失效非常频繁——任何更新都会清空查询缓存。
  3. 分析器 - MySQL 服务器进行 SQL 解析:语法分析词法分析
  4. 优化器 - MySQL 服务器用优化器生成对应的执行计划根据策略选择最优索引
  5. 执行器 - MySQL 服务器根据执行计划,调用存储引擎的 API 来执行查询
  6. 返回结果 - MySQL 服务器将结果返回给客户端,同时缓存查询结果。

:::

【高级】一条 SQL 更新语句是如何执行的?

:::details 要点

更新流程和查询的流程大致相同,不同之处在于:更新流程还涉及两个重要的日志模块:

  • redo log(重做日志)
    • InnoDB 存储引擎独有的日志(物理日志)
    • 采用循环写入
  • bin log(归档日志)
    • MySQL Server 层通用日志(逻辑日志)
    • 采用追加写入

为了保证 redo log 和 bin log 的数据一致性,所以采用两阶段提交方式更新日志。

::

【高级】MySQL 如何选择执行计划?

:::details 要点

MySQL 选择执行计划的过程涉及多个步骤,主要依赖于查询优化器来决定最有效的执行方式。以下是 MySQL 选择执行计划的一些关键步骤和考虑因素:

  1. 解析 SQL 语句:首先,MySQL 会解析输入的 SQL 语句,检查语法是否正确,并将其转换为内部表示形式。
  2. 查询重写:在这一阶段,MySQL 可能会对原始查询进行一些优化或重写,例如常量传播、子查询优化等,以简化后续处理。
  3. 统计信息收集:MySQL 会使用存储在数据字典中的表和索引统计信息来估计不同执行路径的成本。这些统计信息包括但不限于表的大小、索引的选择性(即索引值的唯一性)、数据分布情况等。
  4. 生成候选执行计划:基于查询结构和可用索引,MySQL 的查询优化器会生成一个或多个可能的执行计划。这包括选择合适的连接算法(如嵌套循环连接、哈希连接或排序-合并连接)、访问路径(全表扫描、索引扫描等)以及连接顺序等。
  5. 估算成本:对于每一个候选执行计划,MySQL 都会估算其执行成本,这通常基于 I/O 操作次数、CPU 时间、内存使用等因素。成本模型是基于一系列假设和统计信息构建的。
  6. 选择最低成本的执行计划:最后,MySQL 会选择那个被认为具有最低成本的执行计划作为最终执行计划。
  7. 缓存执行计划:为了减少重复计算的成本,MySQL 会将某些查询的执行计划缓存起来,以便下次遇到相同的查询时可以直接使用已有的执行计划。

值得注意的是,虽然 MySQL 尽力选择最优的执行计划,但并不总是能够做到这一点,特别是在面对复杂查询或统计信息不准确的情况下。因此,在实践中,数据库管理员和开发人员可能需要通过分析查询执行计划(使用EXPLAIN命令),调整索引策略或者手动改写查询来帮助优化器做出更好的决策。此外,参数设置(比如optimizer_switch)也会影响优化器的行为。

:::

【高级】order by 是怎么工作的?

:::details 要点

用 explain 命令查看执行计划时,Extra 这个字段中的“Using filesort”表示的就是需要排序。

全字段排序

1
select city,name,age from t where city='杭州' order by name limit 1000;

这个语句执行流程如下所示 :

执行流程

  • 初始化 sort_buffer,确定放入需要排序的字段(如 namecityage)。
  • 从索引中找到满足条件的记录,取出对应的字段值存入 sort_buffer
  • sort_buffer 中的数据按照排序字段进行排序。
  • 返回排序后的结果。

内存与磁盘排序

  • 如果排序数据量小于 sort_buffer_size,排序在内存中完成。
  • 如果数据量过大,MySQL 会使用临时文件进行外部排序(归并排序)。MySQL 将需要排序的数据分成 N 份,每一份单独排序后存在这些临时文件中。然后把这 N 个有序文件再合并成一个有序的大文件。

优化器追踪:通过 OPTIMIZER_TRACE 可以查看排序过程中是否使用了临时文件(number_of_tmp_files)。

rowid 排序

  • 执行流程
    • 当单行数据过大时,MySQL 会采用 rowid 排序,只将排序字段(如 name)和主键 id 放入 sort_buffer
    • 排序完成后,根据 id 回表查询其他字段(如 cityage)。
  • 性能影响rowid 排序减少了 sort_buffer 的内存占用,但增加了回表操作,导致更多的磁盘 I/O。

全字段排序 VS rowid 排序

  • 内存优先
    • 如果内存足够大,MySQL 优先使用全字段排序,以减少磁盘访问。
    • 只有在内存不足时,才会使用 rowid 排序。
  • 设计思想如果内存够,就要多利用内存,尽量减少磁盘访问。

并不是所有的 order by 语句,都需要排序操作的。MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。如果查询的字段和排序字段可以通过联合索引覆盖,MySQL 可以直接利用索引的有序性,避免排序操作。

:::

参考资料

MongoDB 建模

::: info 概述

数据建模是指对数据库中的数据以及相关实体间的链接进行组织。MongoDB 中的数据具有灵活的模式模型,因此:

  • 单个 集合 中的 文档 不必具有相同的字段集。
  • 字段的数据类型可能因集合中的文档而异。

通常,集合中的文档具有相似的结构。为确保数据模型的一致性,可以创建 模式验证规则

:::

阅读全文 »

Sqoop

Sqoop 简介

Sqoop 是一种工具,旨在在 Hadoop 和关系数据库之间进行批量数据迁移的工具。

Sqoop 是一个常用的数据迁移工具,主要用于在不同存储系统之间实现数据的导入与导出:

  • 导入数据:从 MySQL,Oracle 等关系型数据库中导入数据到 HDFS、Hive、HBase 等分布式文件存储系统中;
  • 导出数据:从 分布式文件系统中导出数据到关系数据库中。

Image

目前 Sqoop 主要分为 Sqoop1 和 Sqoop2 两个版本,其中,版本号为 1.4.x 属于 Sqoop1,而版本号为 1.99.x 的属于 Sqoop2。这两个版本开发时的定位方向不同,体系结构具有很大的差异,因此它们之间互不兼容。

Sqoop1 功能结构简单,部署方便,提供命令行操作方式,主要适用于系统服务管理人员进行简单的数据迁移操作;Sqoop2 功能完善、操作简便,同时支持多种访问模式(命令行操作、Web 访问、Rest API),引入角色安全机制增加安全性等多种优点,但是结构复杂,配置部署更加繁琐。

Sqoop 社区提供了多种连接器,可以在很多数据存储之间进行数据迁移。

  • 内置连接器
    • 经过优化的专用 RDBMS 连接器:MySQL、PostgreSQL、Oracle、DB2、SQL Server、Netzza 等
    • 通用的 JDBC 连接器:支持 JDBC 协议的数据库
  • 第三方连接器
    • 数据仓库:Teradata
    • NoSQL 数据库:Couchbase

Sqoop 原理

Sqoop 的工作原理是:将执行命令转化成 MapReduce 作业来实现数据的迁移

导入原理

在导入数据之前,Sqoop 使用 JDBC 检查导入的数据表,检索出表中的所有列以及列的 SQL 数据类型,并将这些 SQL 类型映射为 Java 数据类型。在转换后的 MapReduce 应用中使用这些对应的 Java 类型来保存字段的值,Sqoop 的代码生成器使用这些信息来创建对应表的类,用于保存从表中抽取的记录。

img

导出原理

在导出数据之前,Sqoop 会根据数据库连接字符串来选择一个导出方法,对于大部分系统来说,Sqoop 会选择 JDBC。Sqoop 会根据目标表的定义生成一个 Java 类,这个生成的类能够从文本中解析出记录数据,并能够向表中插入类型合适的值,然后启动一个 MapReduce 作业,从 HDFS 中读取源数据文件,使用生成的类解析出记录,并且执行选定的导出方法。

img

Sqoop 应用

参考手册:

Sqoop 官方文档之安装说明

Sqoop 官方文档之 Shell 命令

Sqoop 官方文档之连接器

Sqoop 与 MySQL

查询 MySQL 所有数据库

通常用于 Sqoop 与 MySQL 连通测试:

1
2
3
4
sqoop list-databases \
--connect jdbc:mysql://hadoop001:3306/ \
--username root \
--password root

img

查询指定数据库中所有数据表

1
2
3
4
sqoop list-tables \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root

Sqoop 与 HDFS

MySQL 数据导入到 HDFS

导入命令

示例:导出 MySQL 数据库中的 help_keyword 表到 HDFS 的 /sqoop 目录下,如果导入目录存在则先删除再导入,使用 3 个 map tasks 并行导入。

注:help_keyword 是 MySQL 内置的一张字典表,之后的示例均使用这张表。

1
2
3
4
5
6
7
8
9
sqoop import \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword \ # 待导入的表
--delete-target-dir \ # 目标目录存在则先删除
--target-dir /sqoop \ # 导入的目标目录
--fields-terminated-by '\t' \ # 指定导出数据的分隔符
-m 3 # 指定并行执行的 map tasks 数量

日志输出如下,可以看到输入数据被平均 split 为三份,分别由三个 map task 进行处理。数据默认以表的主键列作为拆分依据,如果你的表没有主键,有以下两种方案:

  • 添加 -- autoreset-to-one-mapper 参数,代表只启动一个 map task,即不并行执行;
  • 若仍希望并行执行,则可以使用 --split-by <column-name> 指明拆分数据的参考列。

img

导入验证
1
2
3
4
# 查看导入后的目录
hadoop fs -ls -R /sqoop
# 查看导入内容
hadoop fs -text /sqoop/part-m-00000

查看 HDFS 导入目录,可以看到表中数据被分为 3 部分进行存储,这是由指定的并行度决定的。

img

HDFS 数据导出到 MySQL

1
2
3
4
5
6
7
8
sqoop export  \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword_from_hdfs \ # 导出数据存储在 MySQL 的 help_keyword_from_hdf 的表中
--export-dir /sqoop \
--input-fields-terminated-by '\t'\
--m 3

表必须预先创建,建表语句如下:

1
CREATE TABLE help_keyword_from_hdfs LIKE help_keyword;

Sqoop 与 Hive

MySQL 数据导入到 Hive

Sqoop 导入数据到 Hive 是通过先将数据导入到 HDFS 上的临时目录,然后再将数据从 HDFS 上 Load 到 Hive 中,最后将临时目录删除。可以使用 target-dir 来指定临时目录。

导入命令
1
2
3
4
5
6
7
8
9
10
11
sqoop import \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword \ # 待导入的表
--delete-target-dir \ # 如果临时目录存在删除
--target-dir /sqoop_hive \ # 临时目录位置
--hive-database sqoop_test \ # 导入到 Hive 的 sqoop_test 数据库,数据库需要预先创建。不指定则默认为 default 库
--hive-import \ # 导入到 Hive
--hive-overwrite \ # 如果 Hive 表中有数据则覆盖,这会清除表中原有的数据,然后再写入
-m 3 # 并行度

导入到 Hive 中的 sqoop_test 数据库需要预先创建,不指定则默认使用 Hive 中的 default 库。

1
2
3
4
# 查看 hive 中的所有数据库
hive> SHOW DATABASES;
# 创建 sqoop_test 数据库
hive> CREATE DATABASE sqoop_test;
导入验证
1
2
3
4
# 查看 sqoop_test 数据库的所有表
hive> SHOW TABLES IN sqoop_test;
# 查看表中数据
hive> SELECT * FROM sqoop_test.help_keyword;

img

可能出现的问题

img

如果执行报错 java.io.IOException: java.lang.ClassNotFoundException: org.apache.hadoop.hive.conf.HiveConf,则需将 Hive 安装目录下 lib 下的 hive-exec-**.jar 放到 sqoop 的 lib

1
2
3
[root@hadoop001 lib]# ll hive-exec-*
-rw-r--r--. 1 1106 4001 19632031 11 月 13 21:45 hive-exec-1.1.0-cdh5.15.2.jar
[root@hadoop001 lib]# cp hive-exec-1.1.0-cdh5.15.2.jar ${SQOOP_HOME}/lib

Hive 导出数据到 MySQL

由于 Hive 的数据是存储在 HDFS 上的,所以 Hive 导入数据到 MySQL,实际上就是 HDFS 导入数据到 MySQL。

查看 Hive 表在 HDFS 的存储位置
1
2
3
4
# 进入对应的数据库
hive> use sqoop_test;
# 查看表信息
hive> desc formatted help_keyword;

Location 属性为其存储位置:

img

这里可以查看一下这个目录,文件结构如下:

img

执行导出命令
1
2
3
4
5
6
7
8
sqoop export  \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword_from_hive \
--export-dir /user/hive/warehouse/sqoop_test.db/help_keyword \
-input-fields-terminated-by '\001' \ # 需要注意的是 hive 中默认的分隔符为 \001
--m 3

MySQL 中的表需要预先创建:

1
CREATE TABLE help_keyword_from_hive LIKE help_keyword;

Sqoop 与 HBase

本小节只讲解从 RDBMS 导入数据到 HBase,因为暂时没有命令能够从 HBase 直接导出数据到 RDBMS。

MySQL 导入数据到 HBase

导入数据

help_keyword 表中数据导入到 HBase 上的 help_keyword_hbase 表中,使用原表的主键 help_keyword_id 作为 RowKey,原表的所有列都会在 keywordInfo 列族下,目前只支持全部导入到一个列族下,不支持分别指定列族。

1
2
3
4
5
6
7
8
sqoop import \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword \ # 待导入的表
--hbase-table help_keyword_hbase \ # hbase 表名称,表需要预先创建
--column-family keywordInfo \ # 所有列导入到 keywordInfo 列族下
--hbase-row-key help_keyword_id # 使用原表的 help_keyword_id 作为 RowKey

导入的 HBase 表需要预先创建:

1
2
3
4
5
6
# 查看所有表
hbase> list
# 创建表
hbase> create 'help_keyword_hbase', 'keywordInfo'
# 查看表信息
hbase> desc 'help_keyword_hbase'
导入验证

使用 scan 查看表数据:

img

全库导出

Sqoop 支持通过 import-all-tables 命令进行全库导出到 HDFS/Hive,但需要注意有以下两个限制:

  • 所有表必须有主键;或者使用 --autoreset-to-one-mapper,代表只启动一个 map task;
  • 你不能使用非默认的分割列,也不能通过 WHERE 子句添加任何限制。

第二点解释得比较拗口,这里列出官方原本的说明:

  • You must not intend to use non-default splitting column, nor impose any conditions via a WHERE clause.

全库导出到 HDFS:

1
2
3
4
5
6
7
sqoop import-all-tables \
--connect jdbc:mysql://hadoop001:3306/数据库名 \
--username root \
--password root \
--warehouse-dir /sqoop_all \ # 每个表会单独导出到一个目录,需要用此参数指明所有目录的父目录
--fields-terminated-by '\t' \
-m 3

全库导出到 Hive:

1
2
3
4
5
6
7
8
sqoop import-all-tables -Dorg.apache.sqoop.splitter.allow_text_splitter=true \
--connect jdbc:mysql://hadoop001:3306/数据库名 \
--username root \
--password root \
--hive-database sqoop_test \ # 导出到 Hive 对应的库
--hive-import \
--hive-overwrite \
-m 3

Sqoop 数据过滤

query 参数

Sqoop 支持使用 query 参数定义查询 SQL,从而可以导出任何想要的结果集。使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
sqoop import \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--query 'select * from help_keyword where $CONDITIONS and help_keyword_id < 50' \
--delete-target-dir \
--target-dir /sqoop_hive \
--hive-database sqoop_test \ # 指定导入目标数据库 不指定则默认使用 Hive 中的 default 库
--hive-table filter_help_keyword \ # 指定导入目标表
--split-by help_keyword_id \ # 指定用于 split 的列
--hive-import \ # 导入到 Hive
--hive-overwrite \ 、
-m 3

在使用 query 进行数据过滤时,需要注意以下三点:

  • 必须用 --hive-table 指明目标表;
  • 如果并行度 -m 不为 1 或者没有指定 --autoreset-to-one-mapper,则需要用 --split-by 指明参考列;
  • SQL 的 where 字句必须包含 $CONDITIONS,这是固定写法,作用是动态替换。

增量导入

1
2
3
4
5
6
7
8
9
10
11
12
sqoop import \
--connect jdbc:mysql://hadoop001:3306/mysql \
--username root \
--password root \
--table help_keyword \
--target-dir /sqoop_hive \
--hive-database sqoop_test \
--incremental append \ # 指明模式
--check-column help_keyword_id \ # 指明用于增量导入的参考列
--last-value 300 \ # 指定参考列上次导入的最大值
--hive-import \
-m 3

incremental 参数有以下两个可选的选项:

  • append:要求参考列的值必须是递增的,所有大于 last-value 的值都会被导入;
  • lastmodified:要求参考列的值必须是 timestamp 类型,且插入数据时候要在参考列插入当前时间戳,更新数据时也要更新参考列的时间戳,所有时间晚于 last-value 的数据都会被导入。

通过上面的解释我们可以看出来,其实 Sqoop 的增量导入并没有太多神器的地方,就是依靠维护的参考列来判断哪些是增量数据。当然我们也可以使用上面介绍的 query 参数来进行手动的增量导出,这样反而更加灵活。

类型支持

Sqoop 默认支持数据库的大多数字段类型,但是某些特殊类型是不支持的。遇到不支持的类型,程序会抛出异常 Hive does not support the SQL type for column xxx 异常,此时可以通过下面两个参数进行强制类型转换:

  • --map-column-java<mapping> - 重写 SQL 到 Java 类型的映射;
  • --map-column-hive <mapping> - 重写 Hive 到 Java 类型的映射。

示例如下,将原先 id 字段强制转为 String 类型,value 字段强制转为 Integer 类型:

1
$ sqoop import ... --map-column-java id=String,value=Integer

参考资料

MySQL 锁

不同存储引擎对于锁的支持粒度是不同的,由于 InnoDB 是 MySQL 的默认存储引擎,所以本文以 InnoDB 对于锁的支持进行阐述。

锁的分类

为了解决并发一致性问题,MySQL 支持了很多种锁来实现不同程度的隔离性,以保证数据的安全性。

独享锁和共享锁

InnoDB 实现标准行级锁定,根据是否独享资源,可以把锁分为两类:

  • 独享锁(Exclusive),简写为 X 锁,又称为“写锁”、“排它锁”。
    • 独享锁锁定的数据只允许进行锁定操作的事务使用,其他事务无法对已锁定的数据进行查询或修改。
    • 使用方式:SELECT ... FOR UPDATE;
  • 共享锁(Shared),简写为 S 锁,又称为“读锁”。
    • 共享锁锁定的资源可以被其他用户读取,但不能修改。在进行 SELECT 的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。
    • 使用方式:SELECT ... LOCK IN SHARE MODE;

为什么要引入读写锁机制?

实际上,读写锁是一种通用的锁机制,并非 MySQL 的专利。在很多软件领域,都存在读写锁机制。

因为读操作本身是线程安全的,而一般业务往往又是读多写少的情况。因此,如果对读操作进行互斥,是不必要的,并且会大大降低并发访问效率。正式为了应对这种问题,产生了读写锁机制。

读写锁的特点是:读读不互斥读写互斥写写互斥。简言之:只要存在写锁,其他事务就不能做任何操作

注:InnoDB 下的行锁、间隙锁、next-key 锁统统属于独享锁。

悲观锁和乐观锁

基于加锁方式分类,MySQL 可以分为悲观锁和乐观锁。

  • 悲观锁 - 假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
    • 在查询完数据的时候就把事务锁起来,直到提交事务(COMMIT
    • 实现方式:使用数据库中的锁机制
  • 乐观锁 - 假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。只在更新的时候,判断一下在此期间是否有其他线程更新该数据。
    • 实现方式:更新数据时,先使用版本号机制或 CAS 算法检查数据是否被修改

为什么要引入乐观锁?

乐观锁也是一种通用的锁机制,在很多软件领域,都存在乐观锁机制。

锁,意味着互斥,意味着阻塞。在高并发场景下,锁越多,阻塞越多,势必会拉低并发性能。那么,为了提高并发度,能不能尽量不加锁呢?

乐观锁,顾名思义,就是假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。虽然不加锁,但不意味着什么都不做,而是在更新的时候,判断一下在此期间是否有其他线程更新该数据。乐观锁最常见的实现方式,是使用版本号机制或 CAS 算法(Compare And Swap)去实现。

  • 乐观锁的优点是:减少锁竞争,提高并发度。

  • 乐观锁的缺点是:

    • 存在 ABA 问题。所谓的 ABA 问题是指在并发编程中,如果一个变量初次读取的时候是 A 值,它的值被改成了 B,然后又其他线程把 B 值改成了 A,而另一个早期线程在对比值时会误以为此值没有发生改变,但其实已经发生变化了
    • 如果乐观锁所检查的数据存在大量锁竞争,会由于不断循环重试,产生大量的 CPU 开销

【示例】MySQL 乐观锁示例

假设,order 表中有一个字段 status,表示订单状态:status 为 1 代表订单未支付;status 为 2 代表订单已支付。现在,要将 id 为 1 的订单状态置为已支付,则操作如下:

1
2
3
4
5
select status, version from order where id=#{id}

update order
set status=2, version=version+1
where id=#{id} and version=#{version};

乐观锁更多详情可以参考:使用 mysql 乐观锁解决并发问题

全局锁、表级锁、行级锁

前文提到了,锁,意味着互斥,意味着阻塞。在高并发场景下,锁越多,阻塞越多,势必会拉低并发性能。在不得不加锁的情况下,显然,加锁的范围越小,锁竞争的发生频率就越小,系统的并发程度就越高。但是,加锁也需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销,锁粒度越小,系统的锁操作开销就越大。因此,在选择锁粒度时,也需要在锁开销和并发程度之间做一个权衡。

根据加锁的范围,MySQL 的锁大致可以分为:

  • 全局锁 - “全局锁”会锁定整个数据库
  • 表级锁(table lock) - “表级锁”锁定整张表。用户对表进行写操作前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他用户才能获得读锁,读锁之间不会相互阻塞。表级锁有:
    • 表锁 - 表锁就是对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。表锁一般是在数据库引擎不支持行锁的时候才会被用到的。
    • 元数据锁(MDL) - MDL 不需要显式使用,在访问一个表的时候会被自动加上。
    • 意向锁(Intention Lock)
    • 自增锁(AUTO-INC)
  • 行级锁(row lock) - “行级锁”锁定指定的行记录。这样其它线程还是可以对同一个表中的其它行记录进行操作。行级锁有:
    • 记录锁(Record Lock)
    • 间隙锁(Gap Lock)
    • 临键锁(Next-Key Lock)
    • 插入意向锁

以上各种加锁粒度,在不同存储引擎中的支持情况并不相同。如:InnoDB 支持全局锁、表级锁、行级锁;而 MyISAM 只支持全局锁、表级锁。

每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

全局锁

全局锁会锁定整个数据库。全局锁的典型使用场景是:全库逻辑备份

全局锁的用法

要给整个数据库加全局锁,可以执行以下命令:

1
flush tables with read lock

执行命名后,整个库处于只读状态,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

如果要释放全局锁,可以执行以下命令:

1
unlock tables

此外,在客户端断开的时候会自动释放锁。

全局锁的限制

全局锁锁定期间,整个数据库都是只读状态,这意味着数据库不能更新数据。数据库备份很耗时,锁定整个数据库会导致业务停滞,如何避免这种问题?

在可重复读(Repeatable Read)隔离级别下,事务开启时会创建一个 Read View,并在整个事务期间使用该视图,确保数据一致性。即使其他事务在此期间修改数据,也不会影响备份事务的 Read View,从而保证备份数据的隔离性。如此一来,就无需加全局锁了。

官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数 –single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。对于全部是 InnoDB 引擎的库,建议选择使用 –single-transaction 参数,对应用会更友好。如果有的表使用了不支持事务的引擎(如 MyIsAM),那么备份就只能通过 FTWRL 方法,导致阻塞业务。

表级锁

“表级锁”会锁定整张表。用户对表进行写操作前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他用户才能获得读锁,读锁之间不会相互阻塞。

表锁

表锁就是对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。

**表锁的语法是 lock tables … read/write**,示例如下:

1
2
-- 为 xxx 表加读/写锁
lock tables XXX read/write

与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

元数据锁(Metadata Lock,MDL)

元数据锁,英文为 metadata lock,缩写为 MDL。

MDL 无需显式使用,访问表的时候会被自动加上。MDL 的作用是,保证读写的正确性。假设,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

MySQL 5.5 版本中引入了 MDL。

  • 对一个表做“增删改查”操作的时候,加 MDL 读锁。读锁之间不互斥,因此可以有多个线程同时对一张表增删改查。
  • 对一个表做“结构变更”操作的时候,加 MDL 写锁。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

MDL 会直到事务提交才释放,在做表结构变更的时候,一定要小心不要导致锁住线上查询和更新。

如果数据库有一个长事务(所谓的长事务,就是开启了事务,但是一直还没提交),那么在对表结构做变更操作的时候,可能会发生意想不到的事情,比如下面这个顺序的场景:

  1. 首先,线程 A 先启用了事务(但是一直不提交),然后执行一条 SELECT 语句,此时就先对该表加上 MDL 读锁;
  2. 然后,线程 B 也执行了同样的 SELECT 语句,此时并不会阻塞,因为“读读”并不冲突;
  3. 接着,线程 C 修改了表字段,此时由于线程 A 的事务并没有提交,也就是 MDL 读锁还在占用着,这时线程 C 就无法申请到 MDL 写锁,就会被阻塞,

那么在线程 C 阻塞后,后续有对该表的 SELECT 语句,就都会被阻塞。如果此时有大量该表的 SELECT 语句的请求到来,就会有大量的线程被阻塞住,这时数据库的线程很快就会爆满了。

为什么线程 C 因为申请不到 MDL 写锁,而导致后续的申请读锁的查询操作也会被阻塞?这是因为申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。

为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 kill 掉这个长事务,然后再做表结构的变更。在 MySQL 的 information_schema 库的 innodb_trx 表中,可以查到当前执行中的事务。

意向锁(Intention Lock)

InnoDB 支持不同粒度的锁定,允许行锁和表锁共存。存在表级锁和行级锁时,必须先申请意向锁,再获取行级锁。意向锁是表级锁,表示事务稍后需要对表中的行使用哪种类型的锁(共享或独享)。意向锁是 InnoDB 自动添加的,不需要用户干预

意向锁有两种类型:

  • 意向共享锁(IS - 表示事务有意向对表中的行设置共享锁(S)。

  • 意向独享锁(IX - 表示事务有意向对表中的行设置独享锁(X)。

比如 SELECT ... FOR SHARE 设置 IS 锁, SELECT ... FOR UPDATE 设置 IX 锁。

意向锁的规则如下:

  • 一个事务在获得某个数据行的共享锁(S)之前,必须先获得表的意向共享锁(IS)或者更强的锁;
  • 一个事务在获得某个数据行的独享锁(X)之前,必须先获得表的意向独享锁(IX)。

也就是,当执行插入、更新、删除操作,需要先对表加上 IX 锁,然后对该记录加 X 锁。而快照读(普通的 SELECT)是不会加行级锁的,快照读是利用 MVCC 实现一致性读,是无锁的。

不过,SELECT 也是可以对记录加共享锁和独享锁的,具体方式如下:

1
2
3
4
5
-- 先在表上加上 IS 锁,然后对读取的记录加 S 锁
select ... lock in share mode;

-- 先在表上加上 IX 锁,然后对读取的记录加 X 锁
select ... for update;

IX/IS 是表级锁,不会和行级的 X/S 发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables ... read)和独享表锁(lock tables ... write)发生冲突

如果申请的锁与现有锁兼容,则锁申请成功;反之,则锁申请失败。锁申请失败的情况下,申请锁的事务会一直等待,直到存在冲突的锁被释放。如果存在与申请的锁相冲突的锁,并且该锁迟迟得不到释放,就会导致死锁。

为什么要引入意向锁?

如果没有意向锁,那么加独享表锁时,就需要遍历表里所有记录,查看是否有记录存在独享锁,这样效率会很低。

有了意向锁,在对记录加独享锁前,会先加上表级别的意向独享锁。此时,如果需要加独享表锁,可以直接查该表是否有意向独享锁:如果有,就意味着表里已经有记录被加了独享锁。这样一来,就不用去遍历表里的记录了。

综上所述,意向锁的目的是为了快速判断表里是否有记录被加锁

自增锁(AUTO-INC)

表里的主键通常都会设置成自增的,这是通过对主键字段声明 AUTO_INCREMENT 属性实现的。之后可以在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 AUTO-INC 锁实现的。

AUTO-INC 锁是特殊的表级锁,锁不是在一个事务提交后才释放,而是在执行完插入语句后就会立即释放

在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。

一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 AUTO_INCREMENT 修饰的字段的值是连续递增的。但是,AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。

因此, 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增。一样也是在插入数据的时候,会为被 AUTO_INCREMENT 修饰的字段加上轻量级锁,然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁

InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。

  • innodb_autoinc_lock_mode = 0,就采用 AUTO-INC 锁,语句执行结束后才释放锁;
  • innodb_autoinc_lock_mode = 2,就采用轻量级锁,申请自增主键后就释放锁,并不需要等语句执行后才释放。
  • innodb_autoinc_lock_mode = 1
    • 普通 insert 语句,自增锁在申请之后就马上释放;
    • 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;

以上模式中,innodb_autoinc_lock_mode = 2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在“主从复制的场景”中会发生数据不一致的问题。要解决这个问题,可以设置 binlog_format = row,这样在 binlog 中记录的是主库分配的自增值,从库同步数据时,就可以保持一致。

行锁

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

在 InnoDB 引擎中,行锁是通过给索引上的索引项加锁来实现的如果没有索引,InnoDB 将会通过隐藏的聚簇索引来对记录加锁。此外,在 InnoDB 引擎中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。因此,如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

行锁的具体实现算法有三种:Record Lock、Gap Lock 以及 Next-Key Lock。

记录锁(Record Lock)

记录锁(Record Lock)锁定一个记录上的索引,而不是记录本身。例如,执行 SELECT value FROM t WHERE value BETWEEN 10 and 20 FOR UPDATE; 后,会禁止任何其他事务插入、更新或删除 t.value 值在 10 到 20 范围之内的数据,因为该范围内的所有现有值之间的间隙已被锁定。

记录锁始终锁定索引记录,即使表定义为没有索引。如果表没有设置索引,InnoDB 会自动创建一个隐藏的聚簇索引并使用该索引进行记录锁定。

Record Lock 是有 S 锁和 X 锁之分的:

  • 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容);
  • 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。

【示例】记录锁示例

注:测试环境的事务隔离级别为可重复级别

初始化数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 创建表
DROP TABLE IF EXISTS `t`;
CREATE TABLE `t` (
`id` INT(10) NOT NULL AUTO_INCREMENT,
`value` INT(10) DEFAULT 0,
PRIMARY KEY (`id`)
)
ENGINE = InnoDB
DEFAULT CHARSET = `utf8`;

-- 分别插入 id 为 1、10、20 的数据
INSERT INTO `t`(`id`, `value`) VALUES (1, 1);
INSERT INTO `t`(`id`, `value`) VALUES (10, 10);
INSERT INTO `t`(`id`, `value`) VALUES (20, 20);

事务一、添加 X 型记录锁

1
2
3
4
5
6
7
8
9
10
11
-- 开启事务
BEGIN;

-- 对 id 为 1 的记录添加 X 型记录锁
SELECT * FROM `t` WHERE `id` = 1 FOR UPDATE;

-- 延迟 20 秒执行后续语句,保持锁定状态
SELECT SLEEP(20);

-- 释放锁
COMMIT;

事务二、被锁定的行记录无法修改

1
2
3
4
5
-- 修改 id = 10 的行记录,正常执行
UPDATE `t` SET `value` = 0 WHERE `id` = 10;

-- 修改 id = 1 的行记录,由于 id = 1 被 X 型记录锁锁定,直到事务一释放锁,方能执行
UPDATE `t` SET `value` = 0 WHERE `id` = 1;

间隙锁(Gap Lock)

间隙锁(Gap Lock)锁定索引之间的间隙,但是不包含索引本身

间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,它们彼此不冲突,不同事务可以在间隙上持有冲突锁,并不存在互斥关系。例如,事务 A 可以在某个间隙上持有 S 型间隙锁,而事务 B 在同一间隙上持有 X 型间隙锁。允许存在冲突间隙锁的原因是:如果从索引中清除记录,则必须合并不同事务在该记录上持有的间隙锁。

间隙锁只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。如果将事务隔离级别更改为 读已提交,则间隙锁定对搜索和索引扫描禁用,并且仅用于外键约束检查和重复键检查。

在 MySQL 中,间隙锁默认是开启的,即 innodb_locks_unsafe_for_binlog 参数值是 disable 的,且 MySQL 中默认的是 RR 事务隔离级别。

【示例】间隙锁示例

注:测试环境的事务隔离级别为可重复级别

初始化数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 创建表
DROP TABLE IF EXISTS `t`;
CREATE TABLE `t` (
`id` INT(10) NOT NULL AUTO_INCREMENT,
`value` INT(10) DEFAULT 0,
PRIMARY KEY (`id`)
)
ENGINE = InnoDB
DEFAULT CHARSET = `utf8`;

-- 分别插入 id 为 1、10、20 的数据
INSERT INTO `t`(`id`, `value`) VALUES (1, 1);
INSERT INTO `t`(`id`, `value`) VALUES (10, 10);
INSERT INTO `t`(`id`, `value`) VALUES (20, 20);

事务一、添加间隙锁

1
2
3
4
5
6
7
8
9
10
11
-- 开启事务
BEGIN;

-- 对 id 为 1 的记录添加间隙锁
SELECT * FROM `t` WHERE `id` BETWEEN 1 AND 10 FOR UPDATE;

-- 延迟 20 秒执行后续语句,保持锁定状态
SELECT SLEEP(20);

-- 释放锁
COMMIT;

事务二、被锁定范围内的行记录无法修改

1
2
3
4
5
6
7
8
9
10
11
12
-- 插入 id 为 1 到 10 范围之外的数据,正常执行
INSERT INTO `t`(`id`, `value`) VALUES (15, 15);

-- 更新 id 为 1 到 10 范围之外的数据,正常执行
UPDATE `t` SET `value` = 0 WHERE `id` = 20;

-- 插入 id 为 1 到 10 范围之内的数据,被阻塞
INSERT INTO `t`(`id`, `value`) VALUES (5, 5);

-- 更新 id 为 1 到 10 范围之内的数据,被阻塞
UPDATE `t` SET `value` = 0 WHERE `id` = 1;
UPDATE `t` SET `value` = 0 WHERE `id` = 10;

临键锁(Next-Key Lock)

临键锁(Next-Key Lock)是记录锁和间隙锁的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙(它锁定一个前开后闭区间)。

假设索引包含值 10、11、13 和 20,那么该索引可能的 Next-Key Lock 涵盖以下区间:

1
2
3
4
5
(-, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)

所以,Next-Key Lock 即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。MVCC 不能解决幻读问题,Next-Key 锁就是为了解决幻读问题而提出的。在可重复读(REPEATABLE READ)隔离级别下,使用** MVCC + Next-Key 锁**可以解决幻读问题。

只有可重复读、串行化隔离级别下的特定操作才会取得间隙锁或 Next-Key Lock。在 SelectUpdateDelete 时,除了基于唯一索引的查询之外,其它索引查询时都会获取间隙锁或 Next-Key Lock,即锁住其扫描的范围。主键索引也属于唯一索引,所以主键索引是不会使用间隙锁或 Next-Key Lock。

索引分为主键索引和非主键索引两种,如果一条 SQL 语句操作了主键索引,MySQL 就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL 会先锁定该非主键索引,再锁定相关的主键索引。在 UPDATEDELETE 操作时,MySQL 不仅锁定 WHERE 条件扫描过的所有索引记录,而且会锁定相邻的键值,即所谓的 Next-Key Lock。

插入意向锁

插入意向锁不是意向锁,而是一种特殊的间隙锁。当一个事务试图插入一条记录时,需要判断插入位置是否已被其他事务加了间隙锁(临键锁(Next-Key Lock 也包含间隙锁)。如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻);在此期间,会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。

假设存在值为 4 和 7 的索引记录。分别尝试插入值 5 和 6 的单独事务在获得插入行上的排他锁之前,每个事务都使用插入意向锁锁定 4 和 7 之间的间隙,但不要互相阻塞,因为行不冲突。

【示例】获取插入意向锁

初始化数据

1
2
mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
mysql> INSERT INTO child (id) values (90),(102);

事务 A 对 id 大于 100 的索引记录设置独享锁。独享锁包括了 id=102 之前的间隙锁:

1
2
3
4
5
6
7
mysql> BEGIN;
mysql> SELECT * FROM child WHERE id > 100 FOR UPDATE;
+-----+
| id |
+-----+
| 102 |
+-----+

事务 B 将记录插入到间隙中。事务在等待获取独享锁时获取插入意向锁。

1
2
mysql> BEGIN;
mysql> INSERT INTO child (id) VALUES (101);

死锁

“死锁”是指两个或多个事务竞争同一资源,并请求锁定对方占用的资源,从而导致恶性循环的现象

产生死锁的场景:

  • 当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。

  • 多个事务同时锁定同一个资源时,也会产生死锁。

死锁示例

(1)数据初始化

1
2
3
4
5
6
7
8
9
10
-- 创建表 test
CREATE TABLE `test` (
`id` INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`value` INT(10) NOT NULL
);

-- 数据初始化
INSERT INTO `test` (`id`, `value`) VALUES (1, 1);
INSERT INTO `test` (`id`, `value`) VALUES (2, 2);
INSERT INTO `test` (`id`, `value`) VALUES (3, 3);

(2)两个事务严格按下表顺序执行,产生死锁

事务 A 事务 B
BEGIN; BEGIN;
– 查询 value = 4 的记录
SELECT * FROM test WHERE value = 4 FOR UPDATE;
– 结果为空
– 查询 value = 5 的记录
SELECT * FROM test WHERE value = 5 FOR UPDATE;
– 结果为空
INSERT INTO test (id, value) VALUES (4, 4);
– 锁等待中
INSERT INTO test (id, value) VALUES (5, 5);
– 锁等待中
– 由于死锁无法执行到此步骤
COMMIT;
– 由于死锁无法执行到此步骤
COMMIT;

死锁是如何产生的

行锁的具体实现算法有三种:Record Lock、Gap Lock 以及 Next-Key Lock。Record Lock 是专门对索引项加锁;Gap Lock 是对索引项之间的间隙加锁;Next-Key Lock 则是前面两种的组合,对索引项以其之间的间隙加锁。

只有在可重复读或以上隔离级别下的特定操作才会取得 Gap Lock 或 Next-Key Lock,在 Select、Update 和 Delete 时,除了基于唯一索引的查询之外,其它索引查询时都会获取 Gap Lock 或 Next-Key Lock,即锁住其扫描的范围。主键索引也属于唯一索引,所以主键索引是不会使用 Gap Lock 或 Next-Key Lock。

在 MySQL 中,Gap Lock 默认是开启的,即 innodb_locks_unsafe_for_binlog 参数值是 disable 的,且 MySQL 中默认的是可重复读事务隔离级别。

当我们执行以下查询 SQL 时,由于 value 列为非唯一索引,此时又是 RR 事务隔离级别,所以 SELECT 的加锁类型为 Gap Lock,这里的 gap 范围是 (4,+∞)。

1
SELECT * FROM test where value = 4 for update;

执行查询 SQL 语句获取的 Gap Lock 并不会导致阻塞,而当我们执行以下插入 SQL 时,会在插入间隙上再次获取插入意向锁。插入意向锁其实也是一种 gap 锁,它与 Gap Lock 是冲突的,所以当其它事务持有该间隙的 Gap Lock 时,需要等待其它事务释放 Gap Lock 之后,才能获取到插入意向锁。

以上事务 A 和事务 B 都持有间隙 (4,+∞)的 gap 锁,而接下来的插入操作为了获取到插入意向锁,都在等待对方事务的 gap 锁释放,于是就造成了循环等待,导致死锁。

1
INSERT INTO `test` (`id`, `value`) VALUES (5, 5);

img

另一个死锁场景

InnoDB 存储引擎的主键索引为聚簇索引,其它索引为辅助索引。如果使用辅助索引来更新数据库,就需要使用聚簇索引来更新数据库字段。如果两个更新事务使用了不同的辅助索引,或一个使用了辅助索引,一个使用了聚簇索引,就都有可能导致锁资源的循环等待。由于本身两个事务是互斥,也就构成了以上死锁的四个必要条件了。

img

出现死锁的步骤:

img

综上可知,在更新操作时,我们应该尽量使用主键来更新表字段,这样可以有效避免一些不必要的死锁发生。

避免死锁

死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。由此可知,要想避免死锁,就要从这几个必要条件上去着手:

  • 更新表时,尽量使用主键更新,减少冲突;
  • 避免长事务,尽量将长事务拆解,可以降低与其它事务发生冲突的概率;
  • 设置合理的锁等待超时参数,我们可以通过 innodb_lock_wait_timeout 设置合理的等待超时阈值,特别是在一些高并发的业务中,我们可以尽量将该值设置得小一些,避免大量事务等待,占用系统资源,造成严重的性能开销。
  • 在编程中尽量按照固定的顺序来处理数据库记录,假设有两个更新操作,分别更新两条相同的记录,但更新顺序不一样,有可能导致死锁;
  • 在允许幻读和不可重复读的情况下,尽量使用读已提交事务隔离级别,可以避免 Gap Lock 导致的死锁问题;
  • 还可以使用其它的方式来代替数据库实现幂等性校验。例如,使用 Redis 以及 ZooKeeper 来实现,运行效率比数据库更佳。

解决死锁

当出现死锁以后,有两种策略:

  • 设置事务等待锁的超时时间。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
  • 开启死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。但是,我直接把这个时间设置成一个很小的值,比如 1s,也是不可取的。当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。

所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。为了解决死锁问题,不同数据库实现了各自的死锁检测和超时机制。InnoDB 的处理策略是:将持有最少行级排它锁的事务进行回滚。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。因此,死锁检测可能会耗费大量的 CPU。

参考资料

MongoDB 简介

::: info 概述

MongoDB 是一个流行的、开源的文档数据库

本文简单介绍了 MongoDB 的功能、特性、发行版本、简史、概念,可以让读者在短时间内对于 MongoDB 有一个初步的认识。

:::

阅读全文 »

Spring IoC

IoC 简介

IoC 是什么

IoC控制反转(Inversion of Control,缩写为 IoC)。IoC 又称为依赖倒置原则(设计模式六大原则之一),它的要点在于:程序要依赖于抽象接口,不要依赖于具体实现。它的作用就是用于降低代码间的耦合度

IoC 的实现方式有两种:

  • 依赖注入(Dependency Injection,简称 DI):不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
  • 依赖查找(Dependency Lookup):容器中的受控对象通过容器的 API 来查找自己所依赖的资源和协作对象。

理解 Ioc 的关键是要明确两个要点:

  • 谁控制谁,控制什么:传统 Java SE 程序设计,我们直接在对象内部通过 new 进行创建对象,是程序主动去创建依赖对象;而 IoC 是有专门一个容器来创建这些对象,即由 Ioc 容器来控制对象的创建;谁控制谁?当然是 IoC 容器控制了对象;控制什么?那就是主要控制了外部资源获取(不只是对象包括比如文件等)。
  • 为何是反转,哪些方面反转了:有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;为何是反转?因为由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,所以是反转;哪些方面反转了?依赖对象的获取被反转了。

IoC 能做什么

IoC 不是一种技术,而是编程思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了 IoC 容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

其实 IoC 对编程带来的最大改变不是从代码上,而是从思想上,发生了“主从换位”的变化。应用程序原本是老大,要获取什么资源都是主动出击,但是在 IoC/DI 思想中,应用程序就变成被动的了,被动的等待 IoC 容器来创建并注入它所需要的资源了。

IoC 很好的体现了面向对象设计法则之一—— 好莱坞法则:“别找我们,我们找你”;即由 IoC 容器帮对象找相应的依赖对象并注入,而不是由对象主动去找。

IoC 和 DI

其实它们是同一个概念的不同角度描述,由于控制反转概念比较含糊(可能只是理解为容器控制对象这一个层面,很难让人想到谁来维护对象关系),所以 2004 年大师级人物 Martin Fowler 又给出了一个新的名字:“依赖注入”,相对 IoC 而言,“依赖注入”明确描述了“被注入对象依赖 IoC 容器配置依赖对象”。

注:如果想要更加深入的了解 IoC 和 DI,请参考大师级人物 Martin Fowler 的一篇经典文章 Inversion of Control Containers and the Dependency Injection pattern

IoC 容器

IoC 容器就是具有依赖注入功能的容器。IoC 容器负责实例化、定位、配置应用程序中的对象及建立这些对象间的依赖。应用程序无需直接在代码中 new 相关的对象,应用程序由 IoC 容器进行组装。在 Spring 中 BeanFactory 是 IoC 容器的实际代表者。

Spring IoC 容器如何知道哪些是它管理的对象呢?这就需要配置文件,Spring IoC 容器通过读取配置文件中的配置元数据,通过元数据对应用中的各个对象进行实例化及装配。一般使用基于 xml 配置文件进行配置元数据,而且 Spring 与配置文件完全解耦的,可以使用其他任何可能的方式进行配置元数据,比如注解、基于 java 文件的、基于属性文件的配置都可以。

Bean

JavaBean 是一种 JAVA 语言写成的可重用组件。为写成 JavaBean,类必须是具体的和公共的,并且具有无参数的构造器。JavaBean 对外部通过提供 getter / setter 方法来访问其成员。

由 IoC 容器管理的那些组成你应用程序的对象我们就叫它 Bean。Bean 就是由 Spring 容器初始化、装配及管理的对象,除此之外,bean 就与应用程序中的其他对象没有什么区别了。那 IoC 怎样确定如何实例化 Bean、管理 Bean 之间的依赖关系以及管理 Bean 呢?这就需要配置元数据,在 Spring 中由 BeanDefinition 代表,后边会详细介绍,配置元数据指定如何实例化 Bean、如何组装 Bean 等。

Spring IoC

Spring IoC 容器中的对象仅通过构造函数参数、工厂方法的参数或在对象实例被构造或从工厂方法返回后设置的属性来定义它们的依赖关系(即与它们一起工作的其他对象)。然后容器在创建 bean 时注入这些依赖项。这个过程基本上是 bean 本身通过使用类的直接构造或诸如服务定位器模式之类的机制来控制其依赖关系的实例化或位置的逆过程(因此称为控制反转)。

org.springframework.beansorg.springframework.context 是 IoC 容器的基础。

IoC 容器

在 Spring 中,有两种 IoC 容器:BeanFactoryApplicationContext

  • BeanFactory:**BeanFactory 是 Spring 基础 IoC 容器**。BeanFactory 提供了 Spring 容器的配置框架和基本功能。
  • ApplicationContext:**ApplicationContext 是具备应用特性的 BeanFactory 的子接口**。它还扩展了其他一些接口,以支持更丰富的功能,如:国际化、访问资源、事件机制、更方便的支持 AOP、在 web 应用中指定应用层上下文等。

实际开发中,更推荐使用 ApplicationContext 作为 IoC 容器,因为它的功能远多于 BeanFactory

org.springframework.context.ApplicationContext 接口代表 Spring IoC 容器,负责实例化、配置和组装 bean。容器通过读取配置元数据来获取关于要实例化、配置和组装哪些对象的指令。配置元数据以 XML、Java 注释或 Java 代码表示。它允许您表达组成应用程序的对象以及这些对象之间丰富的相互依赖关系。

Spring 提供了 ApplicationContext 接口的几个实现,例如:

1
BeanFactory beanFactory = new ClassPathXmlApplicationContext("classpath.xml");
1
BeanFactory beanFactory = new FileSystemXmlApplicationContext("fileSystemConfig.xml");

在大多数应用场景中,不需要显式通过用户代码来实例化 Spring IoC 容器的一个或多个实例。

下图显示了 Spring IoC 容器的工作步骤

img

使用 IoC 容器可分为三步骤:

  1. 配置元数据:需要配置一些元数据来告诉 Spring,你希望容器如何工作,具体来说,就是如何去初始化、配置、管理 JavaBean 对象。
  2. 实例化容器:由 IoC 容器解析配置的元数据。IoC 容器的 Bean Reader 读取并解析配置文件,根据定义生成 BeanDefinition 配置元数据对象,IoC 容器根据 BeanDefinition 进行实例化、配置及组装 Bean。
  3. 使用容器:由客户端实例化容器,获取需要的 Bean。

配置元数据

元数据(Metadata)又称中介数据、中继数据,为描述数据的数据(data about data),主要是描述数据属性(property)的信息。

配置元数据的方式:

  • 基于 xml 配置:Spring 的传统配置方式。通常是在顶级元素 <beans> 中通过 <bean>元素配置元数据。这种方式的缺点是:如果 JavaBean 过多,则产生的配置文件足以让你眼花缭乱。
  • **基于注解配置**:Spring 2.5 引入了对基于注解的配置元数据的支持。可以大大简化你的配置。
  • **基于 Java 配置**:从 Spring 3.0 开始,Spring 支持使用 Java 代码来配置元数据。通常是在 @Configuration 修饰的类中通过 @Bean 指定实例化 Bean 的方法。更多详情,可以参阅 @Configuration@Bean@Import@DependsOn 注释。

这些 bean 定义对应于构成应用程序的实际对象。例如:定义服务层对象、数据访问对象 (DAO)、表示对象(如 Struts Action 实例)、基础设施对象(如 Hibernate SessionFactories、JMS 队列等)。通常,不会在容器中配置细粒度的域对象,因为创建和加载域对象通常是 DAO 和业务逻辑的责任。但是,可以使用 Spring 与 AspectJ 的集成来配置在 IoC 容器控制之外创建的对象。

以下示例显示了基于 XML 的配置元数据的基本结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">

<!--id 属性用于唯一标识单个 bean 定义-->
<!--class 属性用于指明 bean 类型的完全限定名-->
<bean id="..." class="...">
<!-- 这里配置 Bean 的属性 -->
</bean>

<bean id="..." class="...">
<!-- 这里配置 Bean 的属性 -->
</bean>

<!-- 更多的 Bean 定义 -->

</beans>

实例化容器

可以通过为 ApplicationContext 的构造函数指定外部资源路径,来加载配置元数据。

1
ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", "daos.xml");

以下示例显示了服务层对象 (services.xml) 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">

<!-- services -->

<bean id="petStore" class="org.springframework.samples.jpetstore.services.PetStoreServiceImpl">
<property name="accountDao" ref="accountDao"/>
<property name="itemDao" ref="itemDao"/>
<!-- additional collaborators and configuration for this bean go here -->
</bean>

<!-- more bean definitions for services go here -->

</beans>

以下示例显示了数据访问对象 (daos.xml) 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="accountDao"
class="org.springframework.samples.jpetstore.dao.jpa.JpaAccountDao">
<!-- additional collaborators and configuration for this bean go here -->
</bean>

<bean id="itemDao" class="org.springframework.samples.jpetstore.dao.jpa.JpaItemDao">
<!-- additional collaborators and configuration for this bean go here -->
</bean>

<!-- more bean definitions for data access objects go here -->

</beans>

上面的示例中,服务层由 PetStoreServiceImpl 类和类型为 JpaAccountDaoJpaItemDao 的两个数据访问对象(基于 JPA 对象关系映射标准)组成。 property name 元素指的是 JavaBean 属性的名称,ref 元素指的是另一个 bean 定义的名称。 idref 元素之间的这种联系表达了协作对象之间的依赖关系。

Spring 支持通过多个 xml 文件来定义 Bean,每个单独的 XML 配置文件都代表架构中的一个逻辑层或模块。可以使用 ApplicationContext 构造函数从所有这些 XML 片段加载 bean 定义。或者,使用 <import/> 元素从另一个或多个文件加载 bean 定义。如下所示:

1
2
3
4
5
6
7
8
<beans>
<import resource="services.xml"/>
<import resource="resources/messageSource.xml"/>
<import resource="/resources/themeSource.xml"/>

<bean id="bean1" class="..."/>
<bean id="bean2" class="..."/>
</beans>

在上面的示例中,外部 bean 定义从三个文件加载:services.xmlmessageSource.xmlthemeSource.xmlservices.xml 文件必须和当前 xml 文件位于同一目录或类路径位置;而 messageSource.xmlthemeSource.xml 必须位于当前文件所在目录的子目录 resources 下。/resources/ 会被忽略。但是,鉴于这些路径是相对的,最好不要使用 /。根据 Spring Schema,被导入文件的内容,包括顶级 <beans/> 元素,必须是有效的 XML bean 定义。

注意:

可以,但不推荐使用相对 “../” 路径来引用父目录中的文件。这样做会创建对当前应用程序之外的文件的依赖。特别是,不建议将此引用用于 classpath:URL(例如, classpath:../services.xml),其中运行时解析过程会选择“最近的”类路径根,然后查看其父目录。类路径配置更改可能会导致选择不同的、不正确的目录。

可以使用完全限定的资源位置而不是相对路径:例如,file:C:/config/services.xmlclasspath:/config/services.xml。建议为此类绝对路径保留一定的间接性  —  例如,通过 “${...}” 占位符来引用运行时指定 的 JVM 参数。

命名空间本身提供了导入指令功能。 Spring 提供的一系列 XML 命名空间中提供了除了普通 bean 定义之外的更多配置特性  —  例如,contextutil 命名空间。

使用容器

ApplicationContext 能够维护不同 bean 及其依赖项的注册表。通过使用方法 T getBean(String name, Class T requiredType),可以检索并获取 bean 的实例

ApplicationContext 允许读取 bean 定义并访问它们,如以下示例所示:

1
2
3
4
5
6
7
8
// create and configure beans
ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", "daos.xml");

// retrieve configured instance
PetStoreService service = context.getBean("petStore", PetStoreService.class);

// use configured instance
List<String> userList = service.getUsernameList();

最灵活的变体是 GenericApplicationContext 结合阅读器委托  —  例如,结合 XML 文件的 XmlBeanDefinitionReader,如下例所示:

1
2
3
GenericApplicationContext context = new GenericApplicationContext();
new XmlBeanDefinitionReader(context).loadBeanDefinitions("services.xml", "daos.xml");
context.refresh();

可以在同一个 ApplicationContext 上混合和匹配此类读取器委托,从不同的配置源读取 bean 定义。

然后,可以使用 getBean 检索 bean 的实例。 ApplicationContext 接口还有一些其他方法用于检索 bean,但理想情况下,应用程序代码不应该使用它们。实际上,应用程序代码根本不应该调用 getBean() 方法,因此根本不依赖 Spring API。例如,Spring 与 Web 框架的集成为各种 Web 框架组件(例如控制器和 JSF 管理的 bean)提供了依赖注入,让您可以通过元数据(例如自动装配注释)声明对特定 bean 的依赖。

IoC 依赖来源

自定义 Bean

容器内建 Bean 对象

容器内建依赖

IoC 配置元数据

IoC 容器的配置有三种方式:

  • 基于 xml 配置
  • 基于 properties 配置
  • 基于注解配置
  • 基于 Java 配置

作为 Spring 传统的配置方式,xml 配置方式一般为大家所熟知。

如果厌倦了 xml 配置,Spring 也提供了注解配置方式或 Java 配置方式来简化配置。

本文,将对 Java 配置 IoC 容器做详细的介绍。

Xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<import resource="resource1.xml" />
<bean id="bean1" class=""></bean>
<bean id="bean2" class=""></bean>
<bean name="bean2" class=""></bean>

<alias alias="bean3" name="bean2"/>
<import resource="resource2.xml" />
</beans>

标签说明:

  • <beans> 是 Spring 配置文件的根节点。
  • <bean> 用来定义一个 JavaBean。id 属性是它的标识,在文件中必须唯一;class 属性是它关联的类。
  • <alias> 用来定义 Bean 的别名。
  • <import> 用来导入其他配置文件的 Bean 定义。这是为了加载多个配置文件,当然也可以把这些配置文件构造为一个数组(new String[] {“config1.xml”, config2.xml})传给 ApplicationContext 实现类进行加载多个配置文件,那一个更适合由用户决定;这两种方式都是通过调用 Bean Definition Reader 读取 Bean 定义,内部实现没有任何区别。<import> 标签可以放在 <beans> 下的任何位置,没有顺序关系。

实例化容器

实例化容器的过程:
定位资源(XML 配置文件)
读取配置信息(Resource)
转化为 Spring 可识别的数据形式(BeanDefinition)

1
2
ApplicationContext context =
new ClassPathXmlApplicationContext(new String[] {"services.xml", "daos.xml"});

组合 xml 配置文件
配置的 Bean 功能各不相同,都放在一个 xml 文件中,不便管理。
Java 设计模式讲究职责单一原则。配置其实也是如此,功能不同的 JavaBean 应该被组织在不同的 xml 文件中。然后使用 import 标签把它们统一导入。

1
2
<import resource="classpath:spring/applicationContext.xml"/>
<import resource="/WEB-INF/spring/service.xml"/>

使用容器

使用容器的方式就是通过getBean获取 IoC 容器中的 JavaBean。
Spring 也有其他方法去获得 JavaBean,但是 Spring 并不推荐其他方式。

1
2
3
4
5
6
7
// create and configure beans
ApplicationContext context =
new ClassPathXmlApplicationContext(new String[] {"services.xml", "daos.xml"});
// retrieve configured instance
PetStoreService service = context.getBean("petStore", PetStoreService.class);
// use configured instance
List<String> userList = service.getUsernameList();

注解配置

Spring2.5 引入了注解。
于是,一个问题产生了:使用注解方式注入 JavaBean 是不是一定完爆 xml 方式?
未必。正所谓,仁者见仁智者见智。任何事物都有其优缺点,看你如何取舍。来看看注解的优缺点:
优点:大大减少了配置,并且可以使配置更加精细——类,方法,字段都可以用注解去标记。
缺点:使用注解,不可避免产生了侵入式编程,也产生了一些问题。

  • 你需要将注解加入你的源码并编译它;

  • 注解往往比较分散,不易管控。

注:spring 中,先进行注解注入,然后才是 xml 注入,因此如果注入的目标相同,后者会覆盖前者。

启动注解

Spring 默认是不启用注解的。如果想使用注解,需要先在 xml 中启动注解。
启动方式:在 xml 中加入一个标签,很简单吧。

1
<context:annotation-config/>

注:<context:annotation-config/> 只会检索定义它的上下文。什么意思呢?就是说,如果你
为 DispatcherServlet 指定了一个WebApplicationContext,那么它只在 controller 中查找@Autowired注解,而不会检查其它的路径。

@Required

@Required 注解只能用于修饰 bean 属性的 setter 方法。受影响的 bean 属性必须在配置时被填充在 xml 配置文件中,否则容器将抛出BeanInitializationException

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
public class AnnotationRequired {
private String name;
private String sex;

public String getName() {
return name;
}

/**
* @Required 注解用于bean属性的setter方法并且它指示,受影响的bean属性必须在配置时被填充在xml配置文件中,
* 否则容器将抛出BeanInitializationException。
*/
@Required
public void setName(String name) {
this.name = name;
}

public String getSex() {
return sex;
}

public void setSex(String sex) {
this.sex = sex;
}
}

@Autowired

@Autowired注解可用于修饰属性、setter 方法、构造方法。

@Autowired 注入过程

  • 元信息解析
  • 依赖查找
  • 依赖注入(字段、方法)

注:@Autowired注解也可用于修饰构造方法,但如果类中只有默认构造方法,则没有必要。如果有多个构造器,至少应该修饰一个,来告诉容器哪一个必须使用。

可以使用 JSR330 的注解@Inject来替代@Autowired

范例

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
public class AnnotationAutowired {
private static final Logger log = LoggerFactory.getLogger(AnnotationRequired.class);

@Autowired
private Apple fieldA;

private Banana fieldB;

private Orange fieldC;

public Apple getFieldA() {
return fieldA;
}

public void setFieldA(Apple fieldA) {
this.fieldA = fieldA;
}

public Banana getFieldB() {
return fieldB;
}

@Autowired
public void setFieldB(Banana fieldB) {
this.fieldB = fieldB;
}

public Orange getFieldC() {
return fieldC;
}

public void setFieldC(Orange fieldC) {
this.fieldC = fieldC;
}

public AnnotationAutowired() {}

@Autowired
public AnnotationAutowired(Orange fieldC) {
this.fieldC = fieldC;
}

public static void main(String[] args) throws Exception {
AbstractApplicationContext ctx =
new ClassPathXmlApplicationContext("spring/spring-annotation.xml");

AnnotationAutowired annotationAutowired =
(AnnotationAutowired) ctx.getBean("annotationAutowired");
log.debug("fieldA: {}, fieldB:{}, fieldC:{}", annotationAutowired.getFieldA().getName(),
annotationAutowired.getFieldB().getName(),
annotationAutowired.getFieldC().getName());
ctx.close();
}
}

xml 中的配置

1
2
3
4
5
<!-- 测试@Autowired -->
<bean id="apple" class="org.zp.notes.spring.beans.annotation.sample.Apple"/>
<bean id="potato" class="org.zp.notes.spring.beans.annotation.sample.Banana"/>
<bean id="tomato" class="org.zp.notes.spring.beans.annotation.sample.Orange"/>
<bean id="annotationAutowired" class="org.zp.notes.spring.beans.annotation.sample.AnnotationAutowired"/>

@Qualifier

@Autowired注解中,提到了如果发现有多个候选的 bean 都符合修饰类型,Spring 就会抓瞎了。

那么,如何解决这个问题。

可以通过@Qualifier指定 bean 名称来锁定真正需要的那个 bean。

范例

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
public class AnnotationQualifier {
private static final Logger log = LoggerFactory.getLogger(AnnotationQualifier.class);

@Autowired
@Qualifier("dog") /** 去除这行,会报异常 */
Animal dog;

Animal cat;

public Animal getDog() {
return dog;
}

public void setDog(Animal dog) {
this.dog = dog;
}

public Animal getCat() {
return cat;
}

@Autowired
public void setCat(@Qualifier("cat") Animal cat) {
this.cat = cat;
}

public static void main(String[] args) throws Exception {
AbstractApplicationContext ctx =
new ClassPathXmlApplicationContext("spring/spring-annotation.xml");

AnnotationQualifier annotationQualifier =
(AnnotationQualifier) ctx.getBean("annotationQualifier");

log.debug("Dog name: {}", annotationQualifier.getDog().getName());
log.debug("Cat name: {}", annotationQualifier.getCat().getName());
ctx.close();
}
}

abstract class Animal {
public String getName() {
return null;
}
}

class Dog extends Animal {
public String getName() {
return "狗";
}
}

class Cat extends Animal {
public String getName() {
return "猫";
}
}

xml 中的配置

1
2
3
4
<!-- 测试@Qualifier -->
<bean id="dog" class="org.zp.notes.spring.beans.annotation.sample.Dog"/>
<bean id="cat" class="org.zp.notes.spring.beans.annotation.sample.Cat"/>
<bean id="annotationQualifier" class="org.zp.notes.spring.beans.annotation.sample.AnnotationQualifier"/>

@Resource

Spring 支持 JSP250 规定的注解@Resource。这个注解根据指定的名称来注入 bean。

如果没有为@Resource指定名称,它会像@Autowired一样按照类型去寻找匹配。

在 Spring 中,由CommonAnnotationBeanPostProcessor来处理@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
29
30
31
32
33
34
35
36
public class AnnotationResource {
private static final Logger log = LoggerFactory.getLogger(AnnotationResource.class);

@Resource(name = "flower")
Plant flower;

@Resource(name = "tree")
Plant tree;

public Plant getFlower() {
return flower;
}

public void setFlower(Plant flower) {
this.flower = flower;
}

public Plant getTree() {
return tree;
}

public void setTree(Plant tree) {
this.tree = tree;
}

public static void main(String[] args) throws Exception {
AbstractApplicationContext ctx =
new ClassPathXmlApplicationContext("spring/spring-annotation.xml");

AnnotationResource annotationResource =
(AnnotationResource) ctx.getBean("annotationResource");
log.debug("type: {}, name: {}", annotationResource.getFlower().getClass(), annotationResource.getFlower().getName());
log.debug("type: {}, name: {}", annotationResource.getTree().getClass(), annotationResource.getTree().getName());
ctx.close();
}
}

xml 的配置

1
2
3
4
<!-- 测试@Resource -->
<bean id="flower" class="org.zp.notes.spring.beans.annotation.sample.Flower"/>
<bean id="tree" class="org.zp.notes.spring.beans.annotation.sample.Tree"/>
<bean id="annotationResource" class="org.zp.notes.spring.beans.annotation.sample.AnnotationResource"/>

@PostConstruct@PreDestroy

@PostConstruct@PreDestroy 是 JSR 250 规定的用于生命周期的注解。

从其名号就可以看出,一个是在构造之后调用的方法,一个是销毁之前调用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AnnotationPostConstructAndPreDestroy {
private static final Logger log = LoggerFactory.getLogger(AnnotationPostConstructAndPreDestroy.class);

@PostConstruct
public void init() {
log.debug("call @PostConstruct method");
}

@PreDestroy
public void destroy() {
log.debug("call @PreDestroy method");
}
}

@Inject

从 Spring3.0 开始,Spring 支持 JSR 330 标准注解(依赖注入)。

注:如果要使用 JSR 330 注解,需要使用外部 jar 包。

若你使用 maven 管理 jar 包,只需要添加依赖到 pom.xml 即可:

1
2
3
4
5
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<version>1</version>
</dependency>

@Inject@Autowired 一样,可以修饰属性、setter 方法、构造方法。

范例

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
public class AnnotationInject {
private static final Logger log = LoggerFactory.getLogger(AnnotationInject.class);
@Inject
Apple fieldA;

Banana fieldB;

Orange fieldC;

public Apple getFieldA() {
return fieldA;
}

public void setFieldA(Apple fieldA) {
this.fieldA = fieldA;
}

public Banana getFieldB() {
return fieldB;
}

@Inject
public void setFieldB(Banana fieldB) {
this.fieldB = fieldB;
}

public Orange getFieldC() {
return fieldC;
}

public AnnotationInject() {}

@Inject
public AnnotationInject(Orange fieldC) {
this.fieldC = fieldC;
}

public static void main(String[] args) throws Exception {
AbstractApplicationContext ctx =
new ClassPathXmlApplicationContext("spring/spring-annotation.xml");
AnnotationInject annotationInject = (AnnotationInject) ctx.getBean("annotationInject");

log.debug("type: {}, name: {}", annotationInject.getFieldA().getClass(),
annotationInject.getFieldA().getName());

log.debug("type: {}, name: {}", annotationInject.getFieldB().getClass(),
annotationInject.getFieldB().getName());

log.debug("type: {}, name: {}", annotationInject.getFieldC().getClass(),
annotationInject.getFieldC().getName());

ctx.close();
}
}

Java 配置

基于 Java 配置 Spring IoC 容器,实际上是Spring 允许用户定义一个类,在这个类中去管理 IoC 容器的配置

为了让 Spring 识别这个定义类为一个 Spring 配置类,需要用到两个注解:@Configuration@Bean

如果你熟悉 Spring 的 xml 配置方式,你可以将@Configuration等价于<beans>标签;将@Bean等价于<bean>标签。

@Bean

@Bean 的修饰目标只能是方法或注解。

@Bean 只能定义在 @Configuration@Component 注解修饰的类中。

声明一个 bean

此外,@Configuration 类允许在同一个类中通过@Bean 定义内部 bean 依赖。

声明一个 bean,只需要在 bean 属性的 set 方法上标注@Bean 即可。

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
@Configuration
public class AnnotationConfiguration {
private static final Logger log = LoggerFactory.getLogger(JavaComponentScan.class);

@Bean
public Job getPolice() {
return new Police();
}

public static void main(String[] args) {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AnnotationConfiguration.class);
ctx.scan("org.zp.notes.spring.beans");
ctx.refresh();
Job job = (Job) ctx.getBean("police");
log.debug("job: {}, work: {}", job.getClass(), job.work());
}
}

public interface Job {
String work();
}

@Component("police")
public class Police implements Job {
@Override
public String work() {
return "抓罪犯";
}
}

这等价于配置

1
2
3
<beans>
<bean id="police" class="org.zp.notes.spring.ioc.sample.job.Police"/>
</beans>

@Bean 注解用来表明一个方法实例化、配置合初始化一个被 Spring IoC 容器管理的新对象。

如果你熟悉 Spring 的 xml 配置,你可以将@Bean 视为等价于<beans>标签。

@Bean 注解可以用于任何的 Spring @Component bean,然而,通常被用于@Configuration bean。

@Configuration

@Configuration 是一个类级别的注解,用来标记被修饰类的对象是一个BeanDefinition

@Configuration 声明 bean 是通过被 @Bean 修饰的公共方法。此外,@Configuration 允许在同一个类中通过 @Bean 定义内部 bean 依赖。

1
2
3
4
5
6
7
@Configuration
public class AppConfig {
@Bean
public MyService myService() {
return new MyServiceImpl();
}
}

这等价于配置

1
2
3
<beans>
<bean id="myService" class="com.acme.services.MyServiceImpl"/>
</beans>

AnnotationConfigApplicationContext 实例化 IoC 容器。

依赖解决过程

容器执行 bean 依赖解析如下:

  • ApplicationContext 使用配置元数据创建和初始化 Bean。配置元数据可以由 XML、Java 代码或注解指定。
  • 对于每个 bean,其依赖关系以属性、构造函数参数或静态工厂方法的参数的形式表示。这些依赖项在实际创建 bean 时提供给 bean。
  • 每个属性或构造函数参数都是要设置的值的实际定义,或者是对容器中另一个 bean 的引用。
  • 作为值的每个属性或构造函数参数都从其指定格式转换为该属性或构造函数参数的实际类型。默认情况下,Spring 可以将以字符串格式提供的值转换为所有内置类型,例如 int、long、String、boolean 等。

Spring 容器在创建容器时验证每个 bean 的配置。但是,在实际创建 bean 之前,不会设置 bean 属性本身。在创建容器时会创建 singleton 型的实例并设置为默认的 Bean。否则,只有在请求时才会创建 bean。

需注意:构造器注入,可能会导致无法解决循环依赖问题。

例如:A 类通过构造器注入需要 B 类的实例,B 类通过构造器注入需要 A 类的实例。Spring IoC 容器会在运行时检测到此循环引用,并抛出 BeanCurrentlyInCreationException

一种解决方案是使用 setter 方法注入替代构造器注入。

另一种解决方案是:bean A 和 bean B 之间的循环依赖关系,强制其中一个 bean 在完全初始化之前注入另一个 bean(典型的先有鸡还是先有蛋的场景)。

Spring 会在容器加载时检测配置问题,例如引用不存在的 bean 或循环依赖。在实际创建 bean 时,Spring 会尽可能晚地设置属性并解析依赖关系。这意味着,如果在创建该对象或其依赖项之一时出现问题,则正确加载的 Spring 容器稍后可以在您请求对象时生成异常  —  例如,bean 由于丢失或无效而引发异常。某些配置问题的这种潜在的延迟可见性是默认情况下 ApplicationContext 实现预实例化单例 bean 的原因。以在实际需要之前创建这些 bean 的一些前期时间和内存为代价,您会在创建 ApplicationContext 时发现配置问题,而不是稍后。您仍然可以覆盖此默认行为,以便单例 bean 延迟初始化,而不是急切地预先实例化。

最佳实践

singleton 的 Bean 如何注入 prototype 的 Bean

Spring 创建的 Bean 默认是单例的,但当 Bean 遇到继承的时候,可能会忽略这一点。

假设有一个 SayService 抽象类,其中维护了一个类型是 ArrayList 的字段 data,用于保存方法处理的中间数据。每次调用 say 方法都会往 data 加入新数据,可以认为 SayService 是有状态,如果 SayService 是单例的话必然会 OOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* SayService 是有状态,如果 SayService 是单例的话必然会 OOM
*/
@Slf4j
public abstract class SayService {

List<String> data = new ArrayList<>();

public void say() {
data.add(IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")) + UUID.randomUUID().toString());
log.info("I'm {} size:{}", this, data.size());
}

}

但实际开发的时候,开发同学没有过多思考就把 SayHello 和 SayBye 类加上了 @Service 注解,让它们成为了 Bean,也没有考虑到父类是有状态的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@Slf4j
public class SayBye extends SayService {

@Override
public void say() {
super.say();
log.info("bye");
}

}

@Service
@Slf4j
public class SayHello extends SayService {

@Override
public void say() {
super.say();
log.info("hello");
}

}

在为类标记上 @Service 注解把类型交由容器管理前,首先评估一下类是否有状态,然后为 Bean 设置合适的 Scope。

调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@RestController
@RequestMapping("beansingletonandorder")
public class BeanSingletonAndOrderController {

@Autowired
List<SayService> sayServiceList;
@Autowired
private ApplicationContext applicationContext;

@GetMapping("test")
public void test() {
log.info("====================");
sayServiceList.forEach(SayService::say);
}

}

可能有人认为,为 SayHello 和 SayBye 两个类都标记了 @Scope 注解,设置了 PROTOTYPE 的生命周期就可以解决上面的问题。

1
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)

但实际上还是有问题。因为@RestController 注解 =@Controller 注解 +@ResponseBody 注解,又因为 @Controller 标记了 @Component 元注解,所以 @RestController 注解其实也是一个 Spring Bean。

Bean 默认是单例的,所以单例的 Controller 注入的 Service 也是一次性创建的,即使 Service 本身标识了 prototype 的范围也没用。

修复方式是,让 Service 以代理方式注入。这样虽然 Controller 本身是单例的,但每次都能从代理获取 Service。这样一来,prototype 范围的配置才能真正生效。

1
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProx)

参考资料

Spring 依赖查找

依赖查找是主动或手动的依赖查找方式,通常需要依赖容器或标准 API 实现

IoC 依赖查找大致可以分为以下几类:

  • 根据 Bean 名称查找
  • 根据 Bean 类型查找
  • 根据 Bean 名称 + 类型查找
  • 根据 Java 注解查找

此外,根据查找的 Bean 对象是单一或集合对象,是否需要延迟查找等特定常见,有相应不同的 API。

单一类型依赖查找

单一类型依赖查找接口- BeanFactory

  • 根据 Bean 名称查找
    • getBean(String)
    • Spring 2.5 覆盖默认参数:getBean(String,Object...)
  • 根据 Bean 类型查找
    • Bean 实时查找
      • Spring 3.0 getBean(Class)
      • Spring 4.1 覆盖默认参数:getBean(Class,Object...)
    • Spring 5.1 Bean 延迟查找
      • getBeanProvider(Class)
      • getBeanProvider(ResolvableType)
  • 根据 Bean 名称 + 类型查找:getBean(String,Class)

集合类型依赖查找

集合类型依赖查找接口- ListableBeanFactory

  • 根据 Bean 类型查找

    • 获取同类型 Bean 名称列表
      • getBeanNamesForType(Class)
      • Spring 4.2 getBeanNamesForType(ResolvableType)
    • 获取同类型 Bean 实例列表
      • getBeansOfType(Class) 以及重载方法
  • 通过注解类型查找

    • Spring 3.0 获取标注类型 Bean 名称列表

      • getBeanNamesForAnnotation(Class<? extends Annotation>)
    • Spring 3.0 获取标注类型 Bean 实例列表

      • getBeansWithAnnotation(Class<? extends Annotation>)
    • Spring 3.0 获取指定名称+ 标注类型 Bean 实例

      • findAnnotationOnBean(String,Class<? extends Annotation>)

层次性依赖查找

层次性依赖查找接口- HierarchicalBeanFactory

  • 双亲 BeanFactorygetParentBeanFactory()
  • 层次性查找
    • 根据 Bean 名称查找
      • 基于 containsLocalBean 方法实现
    • 根据 Bean 类型查找实例列表
      • 单一类型:BeanFactoryUtils#beanOfType
      • 集合类型:BeanFactoryUtils#beansOfTypeIncludingAncestors
    • 根据 Java 注解查找名称列表
      • BeanFactoryUtils#beanNamesForTypeIncludingAncestors

延迟依赖查找

Bean 延迟依赖查找接口

  • org.springframework.beans.factory.ObjectFactory
  • org.springframework.beans.factory.ObjectProvider(Spring 5 对 Java 8 特性扩展)
  • 函数式接口
    • getIfAvailable(Supplier)
    • ifAvailable(Consumer)
  • Stream 扩展- stream()

安全依赖查找

依赖查找类型 代表实现 是否安全
单一类型查找 BeanFactory#getBean
ObjectFactory#getObject
ObjectProvider#getIfAvailable
集合类型查找 ListableBeanFactory#getBeansOfType
ObjectProvider#stream

注意:层次性依赖查找的安全性取决于其扩展的单一或集合类型的 BeanFactory 接口

内建可查找的依赖

AbstractApplicationContext 内建可查找的依赖

Bean 名称 Bean 实例使用场景
environment Environment 对象 外部化配置以及 Profiles
systemProperties java.util.Properties 对象 Java 系统属性
systemEnvironment java.util.Map 对象 操作系统环境变量
messageSource MessageSource 对象 国际化文案
lifecycleProcessor LifecycleProcessor 对象 Lifecycle Bean 处理器
applicationEventMulticaster ApplicationEventMulticaster 对象 Spring 事件广播器

注解驱动 Spring 应用上下文内建可查找的依赖(部分)

Bean 名称 Bean 实例 使用场景
org.springframework.context.annotation.internalConfigurationAnnotationProcessor ConfigurationClassPostProcessor 对象 处理 Spring 配置类
org.springframework.context.annotation.internalAutowiredAnnotationProcessor AutowiredAnnotationBeanPostProcessor 对象 处理@Autowired 以及@Value 注解
org.springframework.context.annotation.internalCommonAnnotationProcessor CommonAnnotationBeanPostProcessor 对象 (条件激活)处理 JSR-250 注解,如@PostConstruct 等
org.springframework.context.event.internalEventListenerProcessor EventListenerMethodProcessor 对象 处理标注@EventListener 的 Spring 事件监听方法
org.springframework.context.event.internalEventListenerFactory DefaultEventListenerFactory 对象 @EventListener 事件监听方法适配为 ApplicationListener
org.springframework.context.annotation.internalPersistenceAnnotationProcessor PersistenceAnnotationBeanPostProcessor 对象 (条件激活)处理 JPA 注解场景

依赖查找中的经典异常

BeansException 子类型

异常类型 触发条件(举例) 场景举例
NoSuchBeanDefinitionException 当查找 Bean 不存在于 IoC 容器时 BeanFactory#getBeanObjectFactory#getObject
NoUniqueBeanDefinitionException 类型依赖查找时,IoC 容器存在多个 Bean 实例 BeanFactory#getBean(Class)
BeanInstantiationException 当 Bean 所对应的类型非具体类时 BeanFactory#getBean
BeanCreationException 当 Bean 初始化过程中 Bean 初始化方法执行异常时
BeanDefinitionStoreException BeanDefinition 配置元信息非法时 XML 配置资源无法打开时

参考资料

Spring 依赖注入

DI,是 Dependency Injection 的缩写,即依赖注入。依赖注入是 IoC 的最常见形式。依赖注入是手动或自动绑定的方式,无需依赖特定的容器或 API。

依赖注入 (Dependency Injection,简称 DI) 是一个过程,其中对象仅通过构造函数参数、工厂方法的参数或对象实例在构造或从工厂方法返回。然后容器在创建 bean 时注入这些依赖项。这个过程基本上是 bean 本身的逆过程(因此得名,控制反转),它通过使用类的直接构造或服务定位器模式自行控制其依赖项的实例化或位置。

使用 DI,代码更干净,当对象具有依赖关系时,解耦更有效。对象不查找其依赖项,也不知道依赖项的位置或类别。结果,您的类变得更容易测试,特别是当依赖关系在接口或抽象基类上时,它们允许在单元测试中使用存根或模拟实现。

容器全权负责组件的装配,它会把符合依赖关系的对象通过 JavaBean 属性或者构造函数传递给需要的对象

DI 是组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。

理解 DI 的关键是:“谁依赖谁,为什么需要依赖,谁注入谁,注入了什么”,那我们来深入分析一下:

  • 谁依赖于谁:当然是应用程序依赖于 IoC 容器;
  • 为什么需要依赖:应用程序需要 IoC 容器来提供对象需要的外部资源;
  • 谁注入谁:很明显是 IoC 容器注入应用程序某个对象,应用程序依赖的对象;
  • 注入了什么:就是注入某个对象所需要的外部资源(包括对象、资源、常量数据)。

IoC 依赖注入 API

  • 根据 Bean 名称注入
  • 根据 Bean 类型注入
  • 注入容器内建 Bean 对象
  • 注入非 Bean 对象
  • 注入类型
    • 实时注入
    • 延迟注入

依赖注入模式

依赖注入模式可以分为手动注入模式和自动注入模式。

手动注入模式

手动注入模式:配置或者编程的方式,提前安排注入规则

  • XML 资源配置元信息
  • Java 注解配置元信息
  • API 配置元信息

自动注入模式

自动注入模式即自动装配。自动装配(Autowiring)是指 Spring 容器可以自动装配 Bean 之间的关系。Spring 可以通过检查 ApplicationContext 的内容,自动解析合作者(其他 Bean)。

  • 自动装配可以显著减少属性或构造函数参数的配置。
  • 随着对象的发展,自动装配可以更新配置。

注:由于自动装配存在一些限制和不足,官方不推荐使用。

自动装配策略

当使用基于 XML 的配置元数据时,可以使用 <bean/> 元素的 autowire 属性为 Bean 指定自动装配模式。自动装配模式有以下类型:

模式 说明
no 默认值,未激活 Autowiring,需要手动指定依赖注入对象。
byName 根据被注入属性的名称作为 Bean 名称进行依赖查找,并将对象设置到该属性。
byType 根据被注入属性的类型作为依赖类型进行查找,并将对象设置到该属性。
constructor 特殊 byType 类型,用于构造器参数。

org.springframework.beans.factory.config.AutowireCapableBeanFactoryBeanFactory 的子接口,它是 Spring 中用于实现自动装配的容器。

@Autowired 注入过程

  • 元信息解析
  • 依赖查找
  • 依赖注入(字段、方法)

自动装配的限制和不足

自动装配有以下限制和不足:

  • 属性和构造函数参数设置中的显式依赖项会覆盖自动装配。您不能自动装配简单属性,例如基础数据类型、字符串和类(以及此类简单属性的数组)。
  • 自动装配不如显式装配精准。Spring 会尽量避免猜测可能存在歧义的结果。
  • Spring 容器生成文档的工具可能无法解析自动装配信息。
  • 如果同一类型存在多个 Bean 时,自动装配时会存在歧义。容器内的多个 Bean 定义可能与要自动装配的 Setter 方法或构造函数参数指定的类型匹配。对于数组、集合或 Map 实例,这不一定是问题。但是,对于期望单值的依赖项,如果没有唯一的 Bean 定义可用,则会引发异常。

自动装配的限制和不足,详情可以参考官方文档:Limitations and Disadvantages of Autowiring 小节

依赖注入方式

依赖注入有如下方式:

依赖注入方式 配置元数据举例
Setter 方法注入 <proeprty name="user" ref="userBean"/>
构造器注入 <constructor-arg name="user" ref="userBean" />
字段注入 @Autowired User user;
方法注入 @Autowired public void user(User user) { ... }
接口回调注入 class MyBean implements BeanFactoryAware { ... }

构造器注入

  • 手动模式
    • xml 配置元信息
    • 注解配置元信息
    • Java 配置元信息
  • 自动模式
    • constructor

构造器注入是通过容器调用具有多个参数的构造函数来完成的,每个参数代表一个依赖项。调用带有特定参数的静态工厂方法来构造 bean 几乎是等价的,并且本次讨论对构造函数和静态工厂方法的参数进行了类似的处理。

下面是一个构造器注入示例:

1
2
3
4
5
6
7
8
9
10
11
12
public class SimpleMovieLister {

// the SimpleMovieLister has a dependency on a MovieFinder
private final MovieFinder movieFinder;

// a constructor so that the Spring container can inject a MovieFinder
public SimpleMovieLister(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}

// business logic that actually uses the injected MovieFinder is omitted...
}

构造函数参数解析匹配通过使用参数的类型进行。如果 bean 定义的构造函数参数中不存在潜在的歧义,则在 bean 定义中定义构造函数参数的顺序是在实例化 bean 时将这些参数提供给适当构造函数的顺序。

1
2
3
4
5
6
7
8
package x.y;

public class ThingOne {

public ThingOne(ThingTwo thingTwo, ThingThree thingThree) {
// ...
}
}

假设 ThingTwo 和 ThingThree 类没有继承关系,则不存在潜在的歧义。因此,以下配置工作正常,您无需在 <constructor-arg/> 元素中显式指定构造函数参数索引或类型。

1
2
3
4
5
6
7
8
9
10
<beans>
<bean id="beanOne" class="x.y.ThingOne">
<constructor-arg ref="beanTwo"/>
<constructor-arg ref="beanThree"/>
</bean>

<bean id="beanTwo" class="x.y.ThingTwo"/>

<bean id="beanThree" class="x.y.ThingThree"/>
</beans>

当引用另一个 bean 时,类型是已知的,并且可以发生匹配(就像前面的示例一样)。当使用简单类型时,例如 <value>true</value> ,Spring 无法确定 value 的类型,因此无法在没有帮助的情况下按类型匹配。考虑以下类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package examples;

public class ExampleBean {

// Number of years to calculate the Ultimate Answer
private final int years;

// The Answer to Life, the Universe, and Everything
private final String ultimateAnswer;

public ExampleBean(int years, String ultimateAnswer) {
this.years = years;
this.ultimateAnswer = ultimateAnswer;
}
}

构造函数参数类型匹配

在上述场景中,如果您使用 type 属性显式指定构造函数参数的类型,则容器可以使用简单类型的类型匹配,如以下示例所示:

1
2
3
4
<bean id="exampleBean" class="examples.ExampleBean">
<constructor-arg type="int" value="7500000"/>
<constructor-arg type="java.lang.String" value="42"/>
</bean>

构造函数参数索引匹配

可以使用 index 属性显式指定构造函数参数的索引,如以下示例所示

1
2
3
4
<bean id="exampleBean" class="examples.ExampleBean">
<constructor-arg index="0" value="7500000"/>
<constructor-arg index="1" value="42"/>
</bean>

构造函数参数名称匹配

1
2
3
4
<bean id="exampleBean" class="examples.ExampleBean">
<constructor-arg name="years" value="7500000"/>
<constructor-arg name="ultimateAnswer" value="42"/>
</bean>

可以使用 @ConstructorProperties 显式命名构造函数参数。

1
2
3
4
5
6
7
8
9
10
11
12
package examples;

public class ExampleBean {

// Fields omitted

@ConstructorProperties({"years", "ultimateAnswer"})
public ExampleBean(int years, String ultimateAnswer) {
this.years = years;
this.ultimateAnswer = ultimateAnswer;
}
}

Setter 方法注入

  • 手动模式
    • xml 配置元信息
    • 注解配置元信息
    • Java 配置元信息
  • 自动模式
    • byName
    • byType

Setter 方法注入是通过容器在调用无参数构造函数或无参数静态工厂方法来实例化 bean 后调用 bean 上的 setter 方法来完成的。

以下示例显示了一个只能通过使用纯 setter 注入进行依赖注入的类。

1
2
3
4
5
6
7
8
9
10
11
12
public class SimpleMovieLister {

// the SimpleMovieLister has a dependency on the MovieFinder
private MovieFinder movieFinder;

// a setter method so that the Spring container can inject a MovieFinder
public void setMovieFinder(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}

// business logic that actually uses the injected MovieFinder is omitted...
}

在 Spring 中,可以混合使用构造器注入和 setter 方法注入。建议将构造器注入用于强制依赖项;并将 setter 方法注入或配置方法用于可选依赖项。需要注意的是,在 setter 方法上使用 @Required 注解可用于使属性成为必需的依赖项;然而,更建议使用构造器注入来完成这项工作。

字段注入

手动模式(Java 注解配置元信息)

  • @Autowired
  • @Resource
  • @Inject(可选)

方法注入

手动模式(Java 注解配置元信息)

  • @Autowired
  • @Resource
  • @Inject(可选)
  • @Bean

接口回调注入

Aware 系列接口回调

內建接口 说明
BeanFactoryAware 获取 IoC 容器- BeanFactory
ApplicationContextAware 获取 Spring 应用上下文- ApplicationContext 对象
EnvironmentAware 获取 Environment 对象
ResourceLoaderAware 获取资源加载器对象- ResourceLoader
BeanClassLoaderAware 获取加载当前 Bean Class 的 ClassLoader
BeanNameAware 获取当前 Bean 的名称
MessageSourceAware 获取 MessageSource 对象,用于 Spring 国际化
ApplicationEventPublisherAware 获取 ApplicationEventPublishAware 对象,用于 Spring 事件
EmbeddedValueResolverAware 获取 StringValueResolver 对象,用于占位符处理

依赖注入选型

  • 低依赖:构造器注入
  • 多依赖:Setter 方法注入
  • 便利性:字段注入
  • 声明类:方法注入

限定注入和延迟注入

限定注入

  • 使用 @Qualifier 注解限定
    • 通过 Bean 名称限定
    • 通过分组限定
  • 通过 @Qualifier 注解扩展限定
    • 自定义注解:如 Spring Cloud 的 @LoadBalanced

延迟注入

  • 使用 ObjectFactory
  • 使用 ObjectProvider(推荐)

依赖注入数据类型

基础类型

  • 基础数据类型:booleanbytecharshortintfloatlongdouble
  • 标量类型:NumberCharacterBooleanEnumLocaleCharsetCurrencyPropertiesUUID
  • 常规类型:ObjectStringTimeZoneCalendarOptional
  • Spring 类型:ResourceInputSourceFormatter 等。

集合类型

数组类型:基础数据类型、标量类型、常规类型、String 类型的数组

集合类型:

  • CollectionListSet
  • MapProperties

依赖处理过程

入口:DefaultListableBeanFactory#resolveDependency

依赖描述符:DependencyDescriptor

自定义绑定候选对象处理器:AutowireCandidateResolver

@Autowired@Value@javax.inject.Inject 处理器:AutowiredAnnotationBeanPostProcessor

通用注解处理器:CommonAnnotationBeanPostProcessor

  • 注入注解
    • javax.xml.ws.WebServiceRef
    • javax.ejb.EJB
    • javax.annotation.Resources
  • 生命周期注解
    • javax.annotation.PostConstruct
    • javax.annotation.PreDestroy

自定义依赖注入注解:

  • 生命周期处理
    • InstantiationAwareBeanPostProcessor
    • MergedBeanDefinitionPostProcessor
  • 元数据
    • InjectionMetadata
    • InjectionMetadata.InjectedElement

依赖查找 VS. 依赖注入

类型 依赖处理 实现复杂度 代码侵入性 API 依赖性 可读性
依赖查找 主动 相对繁琐 侵入业务逻辑 依赖容器 API 良好
依赖注入 被动 相对便利 低侵入性 不依赖容器 API 一般

参考资料

JavaWeb 之 Servlet 指南

JavaWeb 简介

Web 应用程序

Web,在英语中 web 即表示网页的意思,它用于表示 Internet 主机上供外界访问的资源。

Web 应用程序是一种可以通过 Web 访问的应用程序,程序的最大好处是用户很容易访问应用程序,用户只需要有浏览器即可,不需要再安装其他软件。

Internet 上供外界访问的 Web 资源分为:

  • 静态 web 资源:指 web 页面中供人们浏览的数据始终是不变。常见静态资源文件:html、css、各种图片类型(jpg、png)
  • 动态 web 资源:指 web 页面中供人们浏览的数据是由程序产生的,不同时间点访问 web 页面看到的内容各不相同。常见动态资源技术:JSP/Servlet、ASP、PHP

常见 Web 服务器

Servlet 简介

什么是 Servlet

Servlet(Server Applet),即小服务程序或服务连接器。Servlet 是 Java 编写的服务器端程序,具有独立于平台和协议的特性,主要功能在于交互式地浏览和生成数据,生成动态 Web 内容。

  • 狭义的 Servlet 是指 Java 实现的一个接口。
  • 广义的 Servlet 是指任何实现了这个 Servlet 接口的类。

Servlet 运行于支持 Java 的应用服务器中。从原理上讲,Servlet 可以响应任何类型的请求,但绝大多数情况下 Servlet 只用来扩展基于 HTTP 协议的 Web 服务器。

Servlet 和 CGI 的区别

Servlet 技术出现之前,Web 主要使用 CGI 技术。它们的区别如下:

  • Servlet 是基于 Java 编写的,处于服务器进程中,他能够通过多线程方式运行 service() 方法,一个实例可以服务于多个请求,而且一般不会销毁;
  • CGI(Common Gateway Interface),即通用网关接口。它会为每个请求产生新的进程,服务完成后销毁,所以效率上低于 Servlet。

Servlet 版本以及主要特性

版本 日期 JAVA EE/JDK 版本 特性
Servlet 4.0 2017 年 10 月 JavaEE 8 HTTP2
Servlet 3.1 2013 年 5 月 JavaEE 7 非阻塞 I/O,HTTP 协议升级机制
Servlet 3.0 2009 年 12 月 JavaEE 6, JavaSE 6 可插拔性,易于开发,异步 Servlet,安全性,文件上传
Servlet 2.5 2005 年 10 月 JavaEE 5, JavaSE 5 依赖 JavaSE 5,支持注解
Servlet 2.4 2003 年 11 月 J2EE 1.4, J2SE 1.3 web.xml 使用 XML Schema
Servlet 2.3 2001 年 8 月 J2EE 1.3, J2SE 1.2 Filter
Servlet 2.2 1999 年 8 月 J2EE 1.2, J2SE 1.2 成为 J2EE 标准
Servlet 2.1 1998 年 11 月 未指定 First official specification, added RequestDispatcher, ServletContext
Servlet 2.0 JDK 1.1 Part of Java Servlet Development Kit 2.0
Servlet 1.0 1997 年 6 月

Servlet 任务

Servlet 执行以下主要任务:

  • 读取客户端(浏览器)发送的显式的数据。这包括网页上的 HTML 表单,或者也可以是来自 applet 或自定义的 HTTP 客户端程序的表单。
  • 读取客户端(浏览器)发送的隐式的 HTTP 请求数据。这包括 cookies、媒体类型和浏览器能理解的压缩格式等等。
  • 处理数据并生成结果。这个过程可能需要访问数据库,执行 RMI 或 CORBA 调用,调用 Web 服务,或者直接计算得出对应的响应。
  • 发送显式的数据(即文档)到客户端(浏览器)。该文档的格式可以是多种多样的,包括文本文件(HTML 或 XML)、二进制文件(GIF 图像)、Excel 等。
  • 发送隐式的 HTTP 响应到客户端(浏览器)。这包括告诉浏览器或其他客户端被返回的文档类型(例如 HTML),设置 cookies 和缓存参数,以及其他类似的任务。

Servlet 生命周期

img

Servlet 生命周期如下:

  1. 加载 - 第一个到达服务器的 HTTP 请求被委派到 Servlet 容器。容器通过类加载器使用 Servlet 类对应的文件加载 servlet;
  2. 初始化 - Servlet 通过调用 init () 方法进行初始化。
  3. 服务 - Servlet 调用 service() 方法来处理客户端的请求。
  4. 销毁 - Servlet 通过调用 destroy() 方法终止(结束)。
  5. 卸载 - Servlet 是由 JVM 的垃圾回收器进行垃圾回收的。

Servlet API

Servlet 包

Java Servlet 是运行在带有支持 Java Servlet 规范的解释器的 web 服务器上的 Java 类。

Servlet 可以使用 javax.servletjavax.servlet.http 包创建,它是 Java 企业版的标准组成部分,Java 企业版是支持大型开发项目的 Java 类库的扩展版本。

Java Servlet 就像任何其他的 Java 类一样已经被创建和编译。在您安装 Servlet 包并把它们添加到您的计算机上的 Classpath 类路径中之后,您就可以通过 JDK 的 Java 编译器或任何其他编译器来编译 Servlet。

Servlet 接口

Servlet 接口定义了下面五个方法:

1
2
3
4
5
6
7
8
9
10
11
public interface Servlet {
void init(ServletConfig var1) throws ServletException;

ServletConfig getServletConfig();

void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;

String getServletInfo();

void destroy();
}

init() 方法

init 方法被设计成只调用一次。它在第一次创建 Servlet 时被调用,在后续每次用户请求时不再调用。因此,它是用于一次性初始化,就像 Applet 的 init 方法一样。

Servlet 创建于用户第一次调用对应于该 Servlet 的 URL 时,但是您也可以指定 Servlet 在服务器第一次启动时被加载。

当用户调用一个 Servlet 时,就会创建一个 Servlet 实例,每一个用户请求都会产生一个新的线程,适当的时候移交给 doGet 或 doPost 方法。init() 方法简单地创建或加载一些数据,这些数据将被用于 Servlet 的整个生命周期。

init 方法的定义如下:

1
2
3
public void init() throws ServletException {
// 初始化代码...
}

service() 方法

service() 方法是执行实际任务的核心方法。Servlet 容器(即 Web 服务器)调用 service() 方法来处理来自客户端(浏览器)的请求,并把格式化的响应写回给客户端。

service() 方法有两个参数:ServletRequestServletResponseServletRequest 用来封装请求信息,ServletResponse 用来封装响应信息,因此本质上这两个类是对通信协议的封装。

每次服务器接收到一个 Servlet 请求时,服务器会产生一个新的线程并调用服务。service() 方法检查 HTTP 请求类型(GET、POST、PUT、DELETE 等),并在适当的时候调用 doGetdoPostdoPutdoDelete 等方法。

下面是该方法的特征:

1
2
3
4
public void service(ServletRequest request,
ServletResponse response)
throws ServletException, IOException{
}

service() 方法由容器调用,service 方法在适当的时候调用 doGet、doPost、doPut、doDelete 等方法。所以,您不用对 service() 方法做任何动作,您只需要根据来自客户端的请求类型来重写 doGet() 或 doPost() 即可。

doGet() 和 doPost() 方法是每次服务请求中最常用的方法。下面是这两种方法的特征。

doGet() 方法

GET 请求来自于一个 URL 的正常请求,或者来自于一个未指定 METHOD 的 HTML 表单,它由 doGet() 方法处理。

1
2
3
4
5
public void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Servlet 代码
}

doPost() 方法

POST 请求来自于一个特别指定了 METHOD 为 POST 的 HTML 表单,它由 doPost() 方法处理。

1
2
3
4
5
public void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Servlet 代码
}

destroy() 方法

destroy() 方法只会被调用一次,在 Servlet 生命周期结束时被调用。destroy() 方法可以让您的 Servlet 关闭数据库连接、停止后台线程、把 Cookie 列表或点击计数器写入到磁盘,并执行其他类似的清理活动。

在调用 destroy() 方法之后,servlet 对象被标记为垃圾回收。destroy 方法定义如下所示:

1
2
3
public void destroy() {
// 终止化代码...
}

Servlet 和 HTTP 状态码

title: JavaEE Servlet HTTP 状态码
date: 2017-11-08
categories:

  • javaee
    tags:
  • javaee
  • servlet
  • http

HTTP 状态码

HTTP 请求和 HTTP 响应消息的格式是类似的,结构如下:

  • 初始状态行 + 回车换行符(回车+换行)
  • 零个或多个标题行+回车换行符
  • 一个空白行,即回车换行符
  • 一个可选的消息主体,比如文件、查询数据或查询输出

例如,服务器的响应头如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
HTTP/1.1 200 OK
Content-Type: text/html
Header2: ...
...
HeaderN: ...
(Blank Line)
<!doctype ...>
<html>
<head>...</head>
<body>
...
</body>
</html>

状态行包括 HTTP 版本(在本例中为 HTTP/1.1)、一个状态码(在本例中为 200)和一个对应于状态码的短消息(在本例中为 OK)。

以下是可能从 Web 服务器返回的 HTTP 状态码和相关的信息列表:

  • 1**:信息性状态码
  • 2**:成功状态码
    • 200:请求正常成功
    • 204:指示请求成功但没有返回新信息
    • 206:指示服务器已完成对资源的部分 GET 请求
  • 3**:重定向状态码
    • 301:永久性重定向
    • 302:临时性重定向
    • 304:服务器端允许请求访问资源,但未满足条件
  • 4**:客户端错误状态码
    • 400:请求报文中存在语法错误
    • 401:发送的请求需要有通过 HTTP 认证的认证信息
    • 403:对请求资源的访问被服务器拒绝了
    • 404:服务器上无法找到请求的资源
  • 5**:服务器错误状态码
    • 500:服务器端在执行请求时发生了错误
    • 503:服务器暂时处于超负载或正在进行停机维护,现在无法处理请求

设置 HTTP 状态码的方法

下面的方法可用于在 Servlet 程序中设置 HTTP 状态码。这些方法通过 HttpServletResponse 对象可用。

序号 方法 & 描述
1 **public void setStatus ( int statusCode )**该方法设置一个任意的状态码。setStatus 方法接受一个 int(状态码)作为参数。如果您的反应包含了一个特殊的状态码和文档,请确保在使用 PrintWriter 实际返回任何内容之前调用 setStatus。
2 **public void sendRedirect(String url)**该方法生成一个 302 响应,连同一个带有新文档 URL 的 Location 头。
3 **public void sendError(int code, String message)**该方法发送一个状态码(通常为 404),连同一个在 HTML 文档内部自动格式化并发送到客户端的短消息。

HTTP 状态码实例

下面的例子把 407 错误代码发送到客户端浏览器,浏览器会显示 “Need authentication!!!” 消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 导入必需的 java 库
import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
import java.util.*;

// 扩展 HttpServlet 类
public class showError extends HttpServlet {

// 处理 GET 方法请求的方法
public void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException
{
// 设置错误代码和原因
response.sendError(407, "Need authentication!!!" );
}
// 处理 POST 方法请求的方法
public void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

现在,调用上面的 Servlet 将显示以下结果:

1
2
3
4
5
HTTP Status 407 - Need authentication!!!
type Status report
message Need authentication!!!
description The client must first authenticate itself with the proxy (Need authentication!!!).
Apache Tomcat/5.5.29

参考资料