Qida's Blog

纸上得来终觉浅,绝知此事要躬行。

事务的自动提交机制

InnoDB,所有用户活动都发生在事务中。

InnoDB 默认采用事务自动提交autocommit)机制。也就是说,如果不是显式开启一个事务,则每条 SQL 语句都形成独立事务。如果该语句执行后没有返回错误,MySQL 会自动执行 COMMIT。但如果该语句返回错误,则根据错误情况执行 COMMITROLLBACK

如何修改当前会话的提交模式?

1
2
3
4
5
6
7
8
9
SHOW VARIABLES LIKE 'AUTOCOMMIT';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+

--1 或者 ON 表示启用, 0 或者 OFF 表示禁用
SET AUTOCOMMIT = 0;

注意:

  • 关闭后,会话将始终开启一个事务。直到你显式提交或回滚该事务后,一个新事务又被开启。
  • 如果一个关闭了 autocommit 的会话没有显式提交事务,然后会话被关闭,MySQL 将回滚该事务。
  • 有一些命令,在执行之后会强制执行 COMMIT 提交当前的活动事务。例如:
    • ALTER TABLE
    • LOCK TABLES

提交多语句事务

如何在一个事务中组合多条 SQL 语句(multiple-statement transaction)?有两种方式:

  1. 方式一:显式关闭当前会话的 autocommit,然后提交或回滚事务。

    1
    2
    3
    SET autocommit=0;
    INSERT INTO parent VALUES (10, 'Heikki');
    COMMIT;
  2. 方式二:如果不想关闭 autocommit,可以通过 START TRANSACTIONBEGIN 语句显式开启事务,然后通过 COMMITROLLBACK 语句显式结束事务。

    1
    2
    3
    4
    5
    START TRANSACTION;
    INSERT INTO parent VALUES (15, 'John');
    INSERT INTO parent VALUES (20, 'Paul');
    DELETE FROM parent WHERE b = 'Heikki';
    ROLLBACK;

最终结果:

1
2
3
4
5
6
SELECT * FROM parent;
+------+--------+
| id | name |
+------+--------+
| 10 | Heikki |
+------+--------+

在事务中混合使用存储引擎问题

MySQL 服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的。

如果在事务中混合使用了事务型和非事务型的表(例如 InnoDBMyISAM 表),可能会有意想不到的情况发生。请看下例:

1
2
3
4
5
6
7
--引入一张 MyISAM 表
CREATE TABLE `people` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`last_name` varchar(50) NOT NULL,
`first_name` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM;

示例一,在事务中混合使用存储引擎,出现报错:

1
2
3
4
5
6
START TRANSACTION;
--执行成功
INSERT INTO parent(name) VALUES('Heikki');
--执行失败
INSERT INTO people(last_name, first_name) VALUES('pete', 'Lee');
1785 - When @@GLOBAL.ENFORCE_GTID_CONSISTENCY = 1, updates to non-transactional tables can only be done in either autocommitted statements or single-statement transactions, and never in the same statement as updates to transactional tables.

示例二,当事务回滚,非事务型的表上的变更无法撤销,这会导致数据库处于不一致的状态,这种情况很难修复,事务的最终结果将无法确定。

1
2
3
4
5
START TRANSACTION;
INSERT INTO people(last_name, first_name) VALUES('pete', 'Lee');
INSERT INTO parent(name) VALUES('Heikki');
--parent表(InnoDB)回滚成功,people表(MyISAM)回滚失败
ROLLBACK;

所以,为每张表选择合适的存储引擎非常重要。

参考

《高性能 MySQL》

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://dev.mysql.com/doc/refman/5.7/en/sql-syntax-transactions.html

前文总结了 MySQL 事务的一些概念,下面总结下如何进行实操。

开启事务、提交与回滚

1
2
3
4
5
6
7
8
9
10
11
12
13
START TRANSACTION
[transaction_characteristic [, transaction_characteristic] ...]

transaction_characteristic: {
WITH CONSISTENT SNAPSHOT
| READ WRITE
| READ ONLY
}

BEGIN
COMMIT
ROLLBACK
SET autocommit = {0 | 1}

主要语法作用如下:

  • START TRANSACTIONBEGIN 开启新的事务。
  • COMMIT 提交当前事务,使其更改持久化。
  • ROLLBACK 回滚当前事务,取消其更改。
  • SET autocommit 禁用或启用当前会话的默认自动提交模式。

START TRANSACTION 是标准的 SQL 语法,推荐使用。它支持以下 BEGIN 语法所不支持的修饰符:

  • WITH CONSISTENT SNAPSHOT 在事务开启同时创建快照(一致性视图),主要用于可重复读(RR)。
  • READ WRITE 读写模式,默认值。
  • READ ONLY 只读模式,有助于提升存储引擎的性能表现。

SET TRANSACTION 语法

可以通过 SET TRANSACTION 语句设置事务的特性,包括隔离级别和读写模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SET [GLOBAL | SESSION] TRANSACTION
transaction_characteristic [, transaction_characteristic] ...

transaction_characteristic: {
ISOLATION LEVEL level
| access_mode
}

level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}

access_mode: {
READ WRITE
| READ ONLY
}

事务特性范围(作用域)

您可以设置事务特性的作用域为全局、当前会话或仅针对下一个事务,其优先级为事务 > 会话 > 全局:

  • 使用 GLOBAL 关键字:

    • 全局应用于所有后续会话。
    • 现有会话不受影响。
    • 全局设置要求 SUPER 权限。
  • 使用 SESSION 关键字:

    • 应用于当前会话中执行的所有后续事务。
    • 不影响正在进行的事务。
  • 没有 SESSIONGLOBAL 关键字:

    • 仅应用于当前会话中执行的下一个事务。

    • 后续事务将恢复为当前会话的默认值。

    • 事务中不允许使用该语句:

      1
      2
      3
      START TRANSACTION;
      SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
      ERROR 1568 (25001): Transaction characteristics can't be changed while a transaction is in progress

语法总结如下:

语法 作用域
SET GLOBAL TRANSACTION transaction_characteristic Global
SET SESSION TRANSACTION transaction_characteristic Session
SET TRANSACTION transaction_characteristic Next transaction only

事务隔离级别

MySQL 能够识别所有的四个事务隔离级别,InnoDB 引擎也支持所有的隔离级别。可以使用 ISOLATION LEVEL level 子句进行设置:

1
2
3
4
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; --读未提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; --读已提交
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; --可重复读
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; --串行化

事务读写模式

MySQL 支持两种事务读写模式,其设置方式如下:

1
2
SET TRANSACTION READ WRITE; --读写模式,默认值
SET TRANSACTION READ ONLY; --只读模式,有助于提升存储引擎的性能表现

如果要单独为某个事务指定读写模式,搭配 START TRANSACTION 使用。

SET 语法

也可以通过 SET 语句直接进行各种变量赋值,语法总结如下:

语法 作用域
SET GLOBAL var_name = value Global
SET @@GLOBAL.var_name = value Global
SET SESSION var_name = value Session
SET @@SESSION.var_name = value Session
SET var_name = value Session
SET @@var_name = value Next transaction only

变量的查询语法如下,例如 transaction_isolationtransaction_read_only

1
2
SELECT @@GLOBAL.transaction_isolation, @@GLOBAL.transaction_read_only;
SELECT @@SESSION.transaction_isolation, @@SESSION.transaction_read_only;

启动时设置

上面介绍的两种语法都是用于运行时设置,下面介绍两种方式用于在服务启动时设置:

命令行参数

1
2
--transaction-isolation=REPEATABLE-READ
--transaction-read-only=OFF

配置文件

1
2
3
[mysqld]
transaction-isolation = REPEATABLE-READ
transaction-read-only = OFF

参考

https://dev.mysql.com/doc/refman/5.7/en/commit.html

https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html

https://dev.mysql.com/doc/refman/5.7/en/set-variable.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-performance-ro-txn.html

本文大纲

Transaction Isolation

事务的隔离性

上文提到,数据库的事务隔离性,主要解决以下问题:

  • 防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。
  • 解决同一事务内的多次相同查询,数据不一致问题。

有哪些数据不一致的情况?

  • 脏读
  • 不可重复读
  • 幻读

为了数据不一致问题,引入了四个隔离级别,随着隔离级别的提升,可以解决上述更多情况。它们所使用的 SELECT 模式分别如下:

隔离级别 SELECT 默认模式 备注
读未提交
READ UNCOMMITTED
/
读已提交
READ COMMITTED
使用一致性非加锁读(Consistent Non-locking Reads)
总是使用最新快照
可重复读
REPEATABLE READ
使用一致性非加锁读(Consistent Non-locking Reads)
同一事务内总是使用首次快照,确保可重复读。
一致性读取不会在它访问的数据上加任何锁,因此其它事务可以自由地同时修改那些数据,同一份数据在 undo log 会存在多份历史版本。(即通过多版本并发控制(MVCC)实现可重复读)
串行化
SERIALIZABLE
加共享锁读
(S-Locking reads)
加锁读会给数据加共享锁,其它事务读取时可以继续加共享锁,但修改会阻塞等待以获取排它锁,保证读写的串行化,因此同一份数据只存在一份当前版本。(即通过读写锁实现可重复读)

InnoDB 可重复读实现

下面重点看下 MySQL InnoDB 如何实现可重复读这个隔离级别。它使用了一致性非加锁读(Consistent Non-locking Reads)实现多版本并发控制(MVCC),这种方法不会在它访问的数据上设置任何锁,因此其它事务可以自由地同时修改那些表,并发性能高。

示例

下图展示了两个事务并发执行时,最终会出现的五种情况:

consistent read examples

即:

事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读(current read)。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

Current Read

数据库快照适用于同一事务内的 SELECT 语句,而不一定适用于 DML 语句。不同事务间的增删改操作还是会相互影响的,因为 DML 与 SELECT 语句不同,使用的是 current read。例如:

  • 尽管事务 A 创建一致性视图时查不到 xyz 记录,但如果此后其它事务插入了 xyz 记录并提交事务,事务 A 仍然可以将它们删除:

    1
    2
    3
    4
    SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz';
    -- Returns 0: no rows match.
    DELETE FROM t1 WHERE c1 = 'xyz';
    -- Deletes several rows recently committed by other transaction.
  • 尽管事务 A 创建一致性视图时查不到 abc 记录,但如果此后其它事务插入了 abc 记录并提交事务,事务 A 仍然可以修改这些记录,并看到本事务内的修改:

    1
    2
    3
    4
    5
    6
    SELECT COUNT(c2) FROM t1 WHERE c2 = 'abc';
    -- Returns 0: no rows match.
    UPDATE t1 SET c2 = 'cba' WHERE c2 = 'abc';
    -- Affects 10 rows: another txn just committed 10 rows with 'abc' values.
    SELECT COUNT(c2) FROM t1 WHERE c2 = 'cba';
    -- Returns 10: this txn can now see the rows it just updated.

Consistent Read 实现原理

Consistent Read 实现依赖于 Undo Log 和 Consistent Read-View。

Undo Log 是什么?

A storage area that holds copies of data modified by active transactions. If another transaction needs to see the original data (as part of a consistent read operation), the unmodified data is retrieved from this storage area.

In MySQL 5.6 and MySQL 5.7, you can use the innodb_undo_tablespaces variable have undo logs reside in undo tablespaces, which can be placed on another storage device such as an SSD. In MySQL 8.0, undo logs reside in two default undo tablespaces that are created when MySQL is initialized, and additional undo tablespaces can be created using CREATE UNDO TABLESPACE syntax.

The undo log is split into separate portions, the insert undo buffer and the update undo buffer.

Consistent Read-View 是什么?

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(consistent read-view)。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。如下图:

consistent-read-view

以下表事务为例,对于当前事务 105 来说,一致性视图为:[100,103,104,105],106,其中低水位为 100,高水位为 106。这些事务分布如上图。

row trx_id committed? remark
100 N
101 Y
102 Y
103 N
104 N
105 N current trx

对于当前事务 ID 105,根据以下流程图,就只能看到已提交事务 1-99, 101, 102

consistent read process

数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。假如事务 ID 100-104 依次修改了同一份数据(如上图右),虽然数据当前版本为 104,但对于当前事务 ID 105 来说,也只能看到版本链上事务 ID 102 提交的数据版本。

如何查看最新快照

如果要查看最新快照,可以通过以下三个方法:

  • 使用 READ COMMITTED 隔离级别
  • 提交当前事务并发起新查询,刷新时间点
  • 使用加锁读(读锁或写锁)

下例展示了第二种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
             Session A              Session B

START TRANSACTION; START TRANSACTION;
time
| SELECT * FROM t;
| empty set
| INSERT INTO t VALUES (1, 2);
|
v SELECT * FROM t;
empty set
COMMIT;

SELECT * FROM t;
empty set

COMMIT;

SELECT * FROM t;
---------------------
| 1 | 2 |
---------------------

参考

《高性能 MySQL》

https://dev.mysql.com/doc/refman/5.7/en/innodb-multi-versioning.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://time.geekbang.org/column/article/70562

ACID 模型

维基百科关于 ACID 的定义:

ACID 是数据库事务的一组属性,旨在即使在发生错误、电源故障等情况下也能保证数据有效性。在数据库环境中,一系列满足 ACID 属性的数据库操作(可以视作对数据的单个逻辑操作)称为事务。例如,将资金从某个银行账户转账到另一个银行账户。

下面重点讨论 MySQL InnoDB 存储引擎如何与 ACID 模型进行交互:

原子性(Atomicity)

一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部成功提交,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。

相关的 MySQL 功能包括:

  • 事务的自动提交(autocommit)设置。
  • START TRANSACTIONCOMMITROLLBACK 语句。

一致性(Consistency)

数据库总是从一个一致性的状态转换到另外一个一致性的状态,即使出现系统崩溃等异常情况。

相关的 MySQL 功能包括:

隔离性(Isolation)

隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。事务隔离分为不同级别,详见下述隔离级别

相关的 MySQL 功能包括:

  • 事务的自动提交(autocommit)设置。
  • SET TRANSACTION ISOLATION LEVEL 语句。

持久性(Durability)

一旦事务提交,则其所做的修改会永久保存到数据库中。此时即使系统崩溃、修改的数据也不会丢失。

持久性是个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。而且不可能有能做到 100% 的持久性保证的策略,否则为何还要做数据库备份?

与持久性相关的 MySQL 功能比较多,这里不做讨论。

读现象问题

我们重点来关注下隔离性。隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。因此如果不考虑隔离性,会引发如下问题:

脏读(Dirty reads)

一个事务能够看到其它事务尚未提交的修改。例如:

脏读

不可重复读(Non-repeatable reads)

一个事务的两次查询返回不同的结果。例如:

不可重复读

有两种策略可以避免不可重复读:

  • 采用共享锁(s-lock)或排它锁(x-lock),进行加锁读(Locking reads)。
  • 采用多版本并发控制(MVCC),进行一致性非加锁读(Consistent Non-locking Reads)。

幻读(Phantom reads)

一个事务的两次查询返回不同的结果集。例如:

幻读

隔离级别

通过提升事务的隔离级别(Isolation Level),可以逐一解决上述问题。所谓隔离级别,就是在数据库事务中,为保证多个事务并发读写数据的正确性而提出的定义,它并不是 MySQL 专有的概念,而是源于 ANSI/ISO 制定的 SQL-92 标准。

每种关系型数据库都提供了各自特色的隔离级别实现,虽然在通常的隔离级别定义中是以锁为实现单元,但实际的实现千差万别。以最常见的 MySQL InnoDB 存储引擎为例,它是基于 MVCC(Multi-Versioning Concurrency Control)和锁的复合实现,性能较高。MySQL InnoDB 存储引擎的事务隔离级别及其解决问题如下:

隔离级别 脏读
(Dirty reads)
不可重复读
(Non-repeatable reads)
幻读
(Phantom reads)
SELECT 默认模式
读未提交
READ UNCOMMITTED
读已提交
READ COMMITTED
× 使用一致性非加锁读(Consistent Non-locking Reads (MVCC))
总是使用最新快照
可重复读
REPEATABLE READ
× × ×(InnoDB 特有)
使用 gap lock 或 next-key lock
使用一致性非加锁读(Consistent Non-locking Reads (MVCC))
同一事务内总是使用首次快照,确保可重复读。
串行化
SERIALIZABLE
× × ×
使用 gap lock 或 next-key lock
加共享锁读
(S-Locking reads)

读未提交(READ UNCOMMITTED)

一个事务能够看到其它事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。

这个级别会导致很多问题,从性能上来说,也不会比其它级别好太多,但却缺乏其它级别的很多好处,实际应用中很少使用。

读已提交(READ COMMITTED)

事务能够看到的数据都是其它事务已经提交的修改,也就是保证不会看到任何中间性状态,因此不会出现脏读问题。但读已提交仍然是比较低的隔离级别,并不保证再次读取时能够获取同样的数据,也就是允许其它事务并发修改数据,允许不可重复读和幻读出现。

Tips: 事务隔离级别越高,就越能保证数据的完整性一致性,但同时对并发性能的影响也越大。通常,对于绝大多数的应用程序来说,在非 MySQL 数据库的情况下,可以优先考虑将数据库系统的隔离级别设置为读已提交,这能够在避免起码的脏读的同时,保证较好的并发性能。尽管这种事务隔离级别会导致不可重复读、幻读,但较为科学的做法是在可能出现这类问题的个别场合中,由应用程序主动采取读锁或写锁来进行事务控制。

MySQL 读已提交的默认行为如下:

  • 同一事务中的一致性读取(Consistent read)总是会设置和读取自己的最新快照(snapshot),因此会产生不可重复读问题,因为其它事务可能会并发修改数据。

  • 对于加锁读、UPDATEDELETE 语句,InnoDB 仅锁定匹配的索引记录。由于禁用了 gap lock,因此会产生幻读问题,因为其它事务可以在间隙(gap)中插入新行。

    gap:

    A place in an InnoDB index data structure where new values could be inserted. When you lock a set of rows with a statement such as SELECT ... FOR UPDATE, InnoDB can create locks that apply to the gaps as well as the actual values in the index.

    gap lock:

    A lock on a gap between index records, or a lock on the gap before the first or after the last index record.

可重复读(REPEATABLE READ)

这是 MySQL InnoDB 存储引擎默认的隔离级别

  • 同一事务中的一致性读取(Consistent read)总是会读取第一次读取时首次建立的快照(snapshot)。这意味着如果你在同一事务中发起多个普通(非加锁) SELECT 语句,其查询结果是相互一致的。一致性读取机制保证了同一事务中可重复读,避免了不可重复读问题,不管其它事务是否提交了 INSERTDELETEUPDATE 操作。如果想每次 SELECT 都返回最新快照,要么隔离级别降为 READ COMMITTED,要么使用加锁读。

  • 对于加锁读、UPDATEDELETE 语句,加锁行为取决于语句是使用具有唯一搜索条件的唯一索引还是范围搜索条件:

    • 对于具有唯一搜索条件的唯一索引, InnoDB 仅锁定匹配的索引记录。例如:

      1
      2
      3
      4
      -- 事务 T1 的 x-lock 会阻止其它事务加锁读或修改 id = 10 的记录
      SELECT * FROM parent WHERE id = 10 FOR UPDATE;
      -- 事务 T2 无法修改 id = 10 的记录,直到事务 T1 结束
      UPDATE parent SET name = 'Pete' WHERE id = 10;
    • 对于范围搜索条件,InnoDB 使用 gap locknext-key lock 锁定扫描到的索引范围, 以阻止其它会话插入被范围所覆盖的间隙。这是 InnoDB 和其它一些数据库实现的不同,解决了可重复读级别下的幻读问题。例如:

      1
      2
      3
      4
      5
      6
      -- 事务 T1 的 gap lock 会阻止其它事务插入 id > 10 的记录
      SELECT * FROM parent WHERE id > 10 FOR UPDATE;
      -- 事务 T2 无法插入 id > 10 的新记录,直到事务 T1 结束
      INSERT INTO parent(id, name) VALUES(11, 'Pete');
      -- 事务 T2 可以插入 id <= 9 的新记录,无需等待事务 T1
      INSERT INTO parent(id, name) VALUES(9, 'Pete');

串行化(SERIALIZABLE)

并发事务之间的读写操作是串行化的,通常意味着读取需要获取共享锁(读锁),更新需要获取排他锁(写锁),如果 SQL 使用 WHERE 语句,还会获取 gap lock 和 next-key lock,可能导致大量的超时和锁争用的问题。

这是最高的隔离级别,实际应用中很少使用,只有在非常需要确保数据一致性而且可以接受没有并发的情况下,才会考虑。

参考

《高性能 MySQL》

https://en.wikipedia.org/wiki/Isolation_(database_systems)#Isolation_levels

https://en.wikipedia.org/wiki/Isolation_(database_systems)#Dirty_reads

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_dirty_read

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_non_repeatable_read

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_phantom

https://dev.mysql.com/doc/refman/5.7/en/mysql-acid.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-transaction-model.html

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。

为了解决这种问题,数据库系统实现了各种死锁检测死锁超时机制。越复杂的系统,比如 InnoDB 存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致出现非常慢的查询。还有一种解决方式,就是当查询的时间达到锁等待超时的设定后放弃锁请求,例如:

1
1205 - Lock wait timeout exceeded; try restarting transaction

InnoDB 目前处理死锁的方法是,将持有最少行级排它锁的事务进行回滚,这是相对比较简单的死锁回滚算法。

锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些则不会。死锁的产生有双重原因:

  1. 有些是因为真正的数据冲突,这种情况通常很难避免。
  2. 有些则完全是由于存储引擎的实现方式导致的。

死锁发生以后,只有部分或者完全回滚其中一个事务,才能打破死锁。对于事务型的系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。

同时,为了避免产生死锁问题,根源在于程序设计时要注意不同事务间 SQL 语句的执行顺序,避免互相锁住对方的资源。

参考

https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlocks.html

锁的粒度

所谓的锁粒度,就是在锁的开销数据的安全性之间寻求平衡,这种平衡当然也会影响到性能:

一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有资源。更理想的方式是,只对会修改的数据片进行精确的锁定。任何时候,在给定的资源上,锁定的数据量越少,则系统的并发程度越高,只要相互之间不发生冲突即可。

问题是加锁也需要消耗资源。锁的各种操作,包括获得锁、检查锁是否已经解除、释放锁等,都会增加系统的开销。如果系统花费大量的时间来管理锁,而不是存取数据,那么系统的性能可能会因此受到影响。

InnoDB 存储引擎目前有以下两种锁粒度:

表锁

表锁(Table Lock)是 MySQL 中最基本的锁粒度,并且是开销最小的粒度。MyISAM 存储引擎仅支持表锁。

行锁

行锁(Row Lock)可以最大程度的支持并发处理,同时也带来了最大的锁开销。行锁只在存储引擎层实现,而不在 MySQL 服务器层。InnoDB 存储引擎支持行锁级别。

锁粒度与索引的关系

以一个例子总结锁粒度与索引的关系:

1
2
3
4
5
6
7
CREATE TABLE `child` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB;

1、InnoDB 行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB 才使用行锁,否则,InnoDB 将使用表锁:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,全表扫描(type=ALL)
EXPLAIN SELECT * FROM child WHERE name = 'D' FOR UPDATE;
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | child | ALL | NULL | NULL | NULL | NULL | 5 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+

--执行查询,加表锁
SELECT * FROM child WHERE name = 'D' FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 4 | 3 | D |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 1 | NULL |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+

--执行查询,由于事务 T1 加了表锁,事务 T2 对 parent_id = 4 索引项的行锁被阻塞,一直等待
SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;

2、由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。应用设计的时候要注意这一点:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 2 AND name = 'A' FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 2 | Using where |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+

--执行查询
SELECT * FROM child WHERE parent_id = 2 AND name = 'A' FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 1 | 2 | A |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 2 AND name = 'C' FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 2 | Using where |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+

-- 执行查询,虽然 T1、T2 访问不同行的记录,但由于使用了相同的索引键 parent_id = 2,出现锁冲突,从而阻塞,一直等待
SELECT * FROM child WHERE parent_id = 2 AND name = 'C' FOR UPDATE;

3、当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。

4、即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划,以确认是否真正使用了索引:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,虽然使用了索引 idx_parent_id,但 MySQL 认为全表扫描效率更高,因此实际上没有使用索引
EXPLAIN SELECT * FROM child WHERE parent_id = 2 FOR UPDATE;
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | child | ALL | idx_parent_id | NULL | NULL | NULL | 5 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+

--虽然使用了索引 idx_parent_id,但由于进行了全表扫描,因此实际使用表锁
SELECT * FROM child WHERE parent_id = 2 FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 1 | 2 | A |
| 2 | 2 | C |
| 3 | 2 | C |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--执行查询,由于事务 T1 加了表锁,事务 T2 对 parent_id = 4 索引项的行锁被阻塞,一直等待
SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;

参考

《高性能 MySQL》

MySQL 支持两种读机制:

  • 一致性非加锁读(Consistent Non-locking Reads),是 InnoDB 在 RR 隔离级别下处理 SELECT 查询语句的默认模式,用于实现多版本并发控制(MVCC)以解决不可重复读问题。由于无锁,并发性能高。
  • 加锁读(Locking Reads),是 InnoDB 在 SERIALIZABLE 隔离级别下处理 SELECT 查询语句的默认模式,查询默认加共享锁读(S-Locking reads)。由于有锁,并发性能低(因为获取写锁需阻塞等待读锁释放)。

加锁读机制

InnoDB 支持两种类型的 加锁读(Locking Reads),为事务操作提供额外的安全性

  • 共享锁(Shared Lock, S-Lock),也叫读锁(Read Lock)
    • 语法:SELECT ... LOCK IN SHARE MODE or SELECT ... FOR SHARE in MySQL 8.0.1,在检索行上设置共享锁(s-lock)
    • 其它事务允许读取检索行,但不允许更新或删除,更新或删除会一直阻塞等待,直到该事务结束。
  • 排它锁(Exclusive Lock, X-Lock),也叫写锁(Write Lock)
    • 语法:SELECT ... FOR UPDATE 在检索行上设置排它锁(x-lock)
    • 其它事务不允许更新或删除
    • 不允许加共享锁读取 SELECT ... LOCK IN SHARE MODE
    • 如果事务隔离级别为 SERIALIZABLE,不允许读取(因为该级别的读取默认需要获得共享读锁)
    • 上述操作将一直阻塞等待,直到该事务结束。

共享锁和排它锁之间存在冲突的四种情况总结如下:

T1 持有共享锁(S-Lock) T1 持有排它锁(X-Lock)
T2 获取共享锁(S-Lock) 兼容 冲突
T2 获取排它锁(X-Lock) 冲突 冲突

下面进一步分析共享锁和排它锁:

共享锁(读锁)

共享锁是共享性的,或者说是相互不阻塞的。持有该锁的多个事务允许同时读取同一个资源,而互不干扰。

举个例子,如果事务 T1 持有对行 r 的共享锁,那么来自另一个事务 T2 的锁请求,将按如下两种方式处理:

  • T2 的共享锁请求能够立即授予。其结果是,T1T2 都持有对行 r 的共享锁。
  • T2 的排它锁请求不被授予。

排它锁(写锁)

排它锁是排它性的,也就是说一个排它锁会阻塞其它的共享锁和排它锁,这是出于安全策略的考虑,只有这样,才能确保在给定的时间里,有且只有一个持有该锁的事务执行更新或删除操作,并防止其它事务读取正在操作的同一资源。

举个例子,如果事务 T1 持有对行 r 的排它锁,那么来自另一个事务 T2任一锁请求都不被授予。相反,事务 T2 必须等待事务 T1 直到其释放对行 r 的锁定。

锁定方式

大多数时候,MySQL 锁的内部管理都是透明的,其表现如下:

  • SELECTInnoDB 的读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)这两种事务隔离级别下,默认采用一致性非加锁读取,因此无需加锁即可读取所需数据
  • 如果需要使用加锁读提升数据安全性,实现悲观并发控制,可采用共享锁(LOCK IN SHARE MODE)或排它锁(FOR UDPATE)进行显式锁定。
  • UPDATEDELETE 默认采用排它锁,隐式锁定。

总结如下:

语句 锁的类型 锁定方式
SELECT ... FROM 如果事务隔离为 SERIALIZABLE,使用共享锁。否则无锁。 隐式锁定
SELECT ... LOCK IN SHARE MODE 共享锁(shared next-key lock) 显式锁定
SELECT ... FOR UDPATE 排它锁(exclusive next-key lock) 显式锁定
UPDATE ... WHERE ... 排它锁(exclusive next-key lock) 隐式锁定
DELETE FROM ... WHERE ... 排它锁(exclusive next-key lock) 隐式锁定
INSERT 排它锁(exclusive index-record lock) 隐式锁定

隐式锁定

InnoDB 采用的是两阶段锁定协议(Two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行 COMMIT 或者 ROLLBACK 的时候才会释放,并且所有的锁是在同一时刻被释放。InnoDB 会根据隔离级别在需要的时候自动加锁,例如下列操作:

  • UPDATEDELETE

显式锁定

InnoDB 也支持通过特定语句进行显式锁定,这些语句不属于 SQL 规范:

  • SELECT ... LOCK IN SHARE MODE(共享锁)
  • SELECT ... FOR UDPATE(排它锁)

MySQL 也支持 LOCK TABLESUNLOCK TABLE 语句,这是在服务器层实现的,和存储引擎无关。它们有自己的用途,但并不能替代事务。如果应用需要用到事务,还是应该选择事务型存储引擎。

经常可以发现,应用已经将表从 MyISAM 转换到 InnoDB,但还是显示地使用 LOCK TABLE 语句。这不但没有必要,还会严重影响性能,实际上 InnoDB 的行级锁工作得更好。

例子

这里举个例子,有一张 parent 和 child 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- parnet 表
CREATE TABLE `parent` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

-- child 表,其中 parent_id 字段外键关联 parent 表的 id 主键
CREATE TABLE `child` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `fk_parent_id` (`parent_id`),
CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`)
) ENGINE=InnoDB;

如果在同一事务中先查询、后插入或更新相关数据,常规的 SELECT 语句无法得到足够保护。因为在此期间其它事务可能对同一资源进行更新或删除。例如:

1
2
3
4
5
6
7
8
9
10
11
12
--开启事务 T1
START TRANSACTION;
--为变量@id赋值
set @id=0;
SELECT @id:=id FROM parent WHERE name = 'Heikki';

--在此期间,某个事务 T2 成功删除了同一资源
DELETE FROM parent WHERE name = 'Heikki';

--事务 T1 插入失败:外键关联错误
INSERT INTO child(parend_id, name) VALUES(@id, 'Baby');
1452 - Cannot add or update a child row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))

下面分别看下如何用共享锁和排它锁解决这个问题:

LOCK IN SHARE MODE

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
--开启事务 T1
START TRANSACTION;
select * from child where parent_id = 2;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 1 | 2 | Baby |
| 2 | 2 | Baby5 |
+----+-----------+-------+
2 rows in set

--在此期间,某个事务 T2 能够成功删除同一资源
delete from child where id = 1;
Query OK, 1 row affected

--事务 T1 如果继续使用一致性非加锁读,将会得到第一次读取时的快照,因为 InnoDB 当前隔离级别为 RR
select * from child where parent_id = 2;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 1 | 2 | Baby |
| 2 | 2 | Baby5 |
+----+-----------+-------+
2 rows in set

--事务 T1 如果使用加锁读,将会得到最新快照。同时事务 T1 获取该行的共享锁,其它任何事务都只能读、不能写该行,直到事务 T1 结束,释放共享锁
select * from child where parent_id = 2 lock in share mode;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 2 | 2 | Baby5 |
+----+-----------+-------+
1 row in set

--在此期间,事务 T3 可以删除未被锁定的行
delete from child where id = 3;
Query OK, 1 row affected

--在此期间,事务 T3 无法删除带锁的行。因为它无法获取该行的排它锁,因此会阻塞直到事务 T1 解锁该行。如果等待超时,则事务回滚
delete from child where id = 2
1205 - Lock wait timeout exceeded; try restarting transaction

--事务 T1 提交,释放共享锁
commit;

--事务 T3 如果没有超时,则操作成功
Query OK, 1 row affected

FOR UPDATE

1
2
3
4
5
6
7
8
9
10
11
12
13
--开启事务 T1
START TRANSACTION;
--事务 T1 获取该行的排它锁
select * from child where parent_id = 2 for update;

--在此期间,事务 T2 可以非加锁读,因为无需先获取该行的锁
select * from child where parent_id = 2;
--也可以加共享锁读非锁定行
select * from child where parent_id = 3 lock in share mode;
--但无法加共享锁读锁定行
select * from child where parent_id = 2 lock in share mode;
--也无法获取排它锁进行修改
update child set name = 'Hello' where parent_id = 2;

————分割线————

可见,通过共享锁和排它锁都能解决这个问题。下例演示通过 SELECT ... LOCK IN SHARE MODE 设置共享锁解决开头那个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
--开启事务 T1
START TRANSACTION;
--为变量@id赋值
set @id=0;
SELECT @id:=id FROM parent WHERE NAME = 'Heikki' LOCK IN SHARE MODE;

--在此期间,某个事务 T2 无法删除同一资源。因为 T2 会一直等待,直到 T1 事务完成,所有数据都处于一致状态,并释放共享锁之后,T2 才能获取排它锁,并对数据进行修改
DELETE FROM parent WHERE name = 'Heikki';

--事务 T1 插入成功
INSERT INTO child(parend_id, name) VALUES(@id, 'Baby');
--提交事务 T1,写库
COMMIT;

T1 成功提交事务并释放共享锁之后,T2 获得排它锁。但由于 T1child 表中写入了一条对 parent 表的外键关联记录,所以 T2 删除失败:

1
1451 - Cannot delete or update a parent row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))

最后,提几个注意点:

  • 只有在通过以下方式之一禁用自动提交(autocommit)时,才能加锁读:
  • 加锁读有可能产生死锁,具体取决于事务的隔离级别。

参考

《高性能 MySQL》

https://en.wikipedia.org/wiki/Two-phase_locking

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html

https://blog.csdn.net/claram/article/details/54023216

并发控制

计算机领域中,并发控制(Concurrency Control)是一种机制,它确保并发操作可以产生正确结果。

有两种常用的并发控制机制:

  • 乐观并发控制(Optimistic Concurrency Control, OCC),又称为乐观锁(Optimistic Lock),最早是由孔祥重(H.T.Kung)教授提出的。
  • 悲观并发控制(Pessimistic Concurrency Control, PCC),又称为悲观锁(Pessimistic Lock)。

这两种机制或者锁并不是 MySQL 或者数据库中独有的概念,而是并发编程的基本概念。

乐观并发控制(Optimistic concurrency control)

https://en.wikipedia.org/wiki/Optimistic_concurrency_control

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改(低冲突和低争用),所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,没有才能更新成功;否则更新失败,重新拿数据并重试。

适用场景:

  • 它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其它事务又修改了该数据。如果其它事务有更新的话,正在提交的事务会进行回滚。因此乐观并发控制多数用于数据争用不大、冲突较少的环境中。这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其它并发控制方法更高的吞吐量。

实现方式:

CAS(Compare And Set)

CAS(Compare And Set):实现思路是在 set 的时候,加上初始状态的 compare 条件判断,只有初始状态不变时,才 set 成功。

为了避免 ABA 问题(例如 CAS 过程中只简单进行“值”的校验,在有些情况下,“值”相同不会引入错误的业务逻辑(例如余额),但有些情况下,“值”虽然相同,却已经不是原来的数据了),CAS 不能只比对“值”,还必须确保数据是原来的数据,才能修改成功。实现方式是采用“数据版本”机制,例如通过版本号(version)、时间戳(update_time),来做乐观锁的判断条件,一个数据一个版本,版本变化,即使值相同,也不应该修改成功。

例如:

悲观并发控制(Pessimistic concurrency control)

顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上互斥锁,直到使用完毕才会解锁,这样别人想拿这个数据就会 block 住直到它拿到锁。

适用场景:

  • 悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,由于会阻塞其它事务导致其一直等待,降低整体吞吐量,这样的开销往往无法承受。而乐观锁机制则避免了长事务中的数据库开销。
  • 面对并发请求,在代码中使用“一锁二判三更新”这套操作,其中第一步加锁是为了确保后两步操作的原子性,实现串行化访问临界资源,即同一时刻只能有一个线程/事务独占性的访问临界资源(同步互斥访问),确保并发情况下临界资源的线程安全。

实现方式:

JVM 同步/锁

仅适用于单机部署环境,不适用于集群部署环境。

Java:

数据库的锁

MySQL InnoDB 存储引擎中,悲观锁的类型还有很多种:

  • Shared and Exclusive Locks(共享锁和排它锁)
  • Intention Locks(意向锁)
  • Record Locks(记录锁)
  • Gap Locks(区间锁)
  • Next-Key Locks
  • Insert Intention Locks(插入意向锁)
  • AUTO-INC Locks(自增锁)
  • Predicate Locks for Spatial Indexes(空间索引谓词锁)

例如,通过 MySQL 加锁读(Locking Reads)机制,在 A 事务中先对资源加排它锁(写锁),阻塞其它事务对同一资源的读写访问,然后在事务内进行代码判断以及资源更新提交,实现串行化访问资源:

1
2
3
4
-- 共享锁(读锁)
SELECT ... LOCK IN SHARE MODE;
-- 排它锁(写锁)
SELECT ... FOR UPDATE;

并发控制总结

分布式锁

Redis:使用命令 SETNX 创建互斥锁(mutex key)。注意点:

  • 防锁死(设置锁的过期时间避免锁死)
  • 锁续命(设置后台线程为锁续命)
  • 持锁人解锁(解锁时只能由集群内同机器、同线程操作)

Zookeeper:使用命令 create -e -s 创建临时+序号(EPHEMERAL_SEQUENTIAL)节点。注意点:

  • 羊群效应

使用分布式锁的好处之一是节约数据库资源。

例子

这里举一个抽奖活动的例子,分别展示乐观锁和悲观锁的两种实现流程:

抽奖活动例子

参考

https://en.wikipedia.org/wiki/Concurrency_control

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html

《支付宝防并发方案之”一锁二判三更新”》

《高性能 MySQL》

查看数据库支持的字符集:SHOW CHARACTER SET

查看数据库支持的排序规则:SHOW COLLATION

字符集(Character Sets)

下表是 MySQL 中八个可能影响到字符集的系统变量,其中有几个如果配置不当可能会乱码问题,需重点关注:

变量 默认值 描述
character_set_client utf8 The character set for statements that arrive from the client.
character_set_connection utf8 The character set used for literals specified without a character set introducer and for number-to-string conversion.
character_set_database latin1 The character set used by the default database.
character_set_filesystem binary The file system character set.
character_set_results utf8 The character set used for returning query results to the client. This includes result data such as column values, result metadata such as column names, and error messages.
character_set_server latin1 The server’s default character set.
character_set_system utf8 The character set used by the server for storing identifiers.
character_sets_dir The directory where character sets are installed.

可以通过下图来了解 MySQL 内部字符集转换过程:

MySQL Character Set

  1. MySQL 收到请求时将请求数据从 character_set_client 转换为 character_set_connection
  2. 进行内部操作前将请求数据从 character_set_connection 转换为内部操作字符集,步骤如下:
    1. 使用每个数据字段的 CHARACTER SET 设定值;
    2. 若上述值不存在,则使用对应数据表的字符集设定值;
    3. 若上述值不存在,则使用对应数据库的字符集设定值;
    4. 若上述值不存在,则使用 character_set_server 设定值。
  3. 最后将操作结果从内部操作字符集转换为 character_set_results

而系统变量 character_set_database 主要用来设置默认创建数据库的编码格式,如果在创建数据库时没有设置编码格式,就按照这个格式设置,如下:

1
CREATE DATABASE `testdb` /*!40100 DEFAULT CHARACTER SET latin1 */

从而影响到建表时默认的字符集:

1
2
3
CREATE TABLE `test` (
`name` varchar(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

从而影响到中文字符的插入:

1
2
3
INSERT INTO test values ('你好');

[Err] 1366 - Incorrect string value: '\xE4\xBD\xA0\xE5\xA5\xBD' for column 'name' at row 1

配置说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[mysqld]
# 影响系统变量 character_set_database 和 character_set_server
character-set-server = utf8
collation-server = utf8_unicode_ci
init-connect = 'SET NAMES utf8'

# 创建新表时将使用的默认存储引擎
default-storage-engine=INNODB

[client]
default-character-set = utf8

[mysql]
default-character-set = utf8

配置后,需要重启服务:

1
2
net stop mysql
net start mysql

之后,通过命令 SHOW VARIABLES LIKE '%character%' 查看结果:

1
2
3
4
5
6
7
8
9
10
11
12
+--------------------------+----------+
| Variable_name | Value |
+--------------------------+----------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | ... |
+--------------------------+----------+

如果在创建数据库之前,没有在配置文件中配置好默认字符集,可以通过 SET 命令进行修改。

配置好后,建库结果如下:

1
CREATE DATABASE `testdb` /*!40100 DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci */

排序规则(Collations)

参考

https://dev.mysql.com/doc/refman/5.7/en/charset.html

https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html

charset

什么是代理?

代理对象 = 增强代码 + 目标对象

proxy

有哪些代理方式?

  • 静态代理
  • 动态代理

什么是动态代理?

动态代理是一种在运行时动态生成代理的机制。这个概念是与静态代理相对的,静态代理需要为每一个目标类都手工编写或用工具生成一个对应的代理类,非常繁琐。

动态代理的实现方式?

动态代理的实现方式有很多种,比如:

  • 利用 JDK 自身提供的动态代理 API(java.lang.reflect

  • 或者利用性能更高的第三方字节码生成框架(例如 ASM、cglib(基于 ASM)、Javassist 等)

最终目标都是生成一个代理类的字节码。

哪些场景用到动态代理?

比如:

包装 RPC 调用、…

面向切面的编程(AOP)

动态代理从代理对象创建到方法执行的整体流程如下:

jdk_proxy_process

下面来看下 JDK 自身提供的动态代理,底层是如何实现的。

例子

来个例子,实现如图效果:

jdk_proxy_process2

首先创建接口 Flyable 和目标类 Bird

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 目标类和代理类共同实现的接口
**/
public interface Flyable {
void fly(String param);
}

/**
* 目标类
**/
@Slf4j
public class Bird implements Flyable {
@Override
public void fly(String param) {
log.info("Target bird fly, param = {}", param);
}
}

然后是关键的一步,实现 InvocationHandler 接口,创建代理类:

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
/**
* 代理类
**/
@Slf4j
@AllArgsConstructor
public class BirdProxy implements InvocationHandler {

// 目标对象
private Flyable target;

// proxy 参数表示动态生成的 Proxy 类 通过反射创建出来的对象
// method 参数表示 proxy 对象本次执行的方法,可以判断该参数动态决定执行对应的业务逻辑
// args 参数表示 proxy 对方本次执行的方法参数
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 在目标对象执行前执行代码
log.info("Before target bird method: {}", method.getName());
// 在目标对象上执行指定方法
Object result = method.invoke(target, args);
// 在目标对象执行后执行代码
log.info("After target bird method: {}", method.getName());
return result;
}

// 创建代理对象
public static Flyable newProxy(Flyable target) {
// 方式一:显式使用反射创建代理对象(先获取 com.sun.proxy.$Proxy0 的 Class 对象)
// Class<?> proxyClass = Proxy.getProxyClass(Flyable.class.getClassLoader(), target.getClass().getInterfaces());
// Constructor<?> constructor = proxyClass.getConstructor(InvocationHandler.class);
// return (Flyable) constructor.newInstance(new BirdProxy(target));

// 方式二:隐式使用反射创建代理对象,API 更简单
return (Flyable) Proxy.newProxyInstance(Flyable.class.getClassLoader(), target.getClass().getInterfaces(), new BirdProxy(target));
}

}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
public class FlyTest {

@Test
public void test() {
// 创建目标对象
Bird target = new Bird();
// 创建代理对象
Flyable proxy = BirdProxy.newProxy(target);
// 调用任意方法,将执行代理逻辑
proxy.fly("hello world");
}
}

输出结果如下:

1
2
3
Before target bird method: fly
Target bird fly, param = hello world
After target bird method: fly

源码解析

例子中涉及到两个 API,由 Java 1.3 引入:

java.lang.reflect.InvocationHandler,代理对象内部的成员变量。作为代理对象和目标对象的桥梁,代理对象的每个方法调用,都会调用其 invoke() 方法,委托其去调用目标对象,可在此时机补充增强代码

InvocationHandler

java.lang.reflect.Proxy,用于创建代理类或代理对象,同时还是它们的父类。

Proxy

Proxy 的核心方法 newProxyInstance 用于运行时动态生成代理类并通过反射创建实例,其源码及关键注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class Proxy implements java.io.Serializable {

/** 代理类构造方法的参数类型 */
private static final Class<?>[] constructorParams = { InvocationHandler.class };

public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
throws IllegalArgumentException
{
Objects.requireNonNull(h);

final Class<?>[] intfs = interfaces.clone();
final SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
}

/*
* 查找或生成指定的代理类
*/
Class<?> cl = getProxyClass0(loader, intfs);

/*
* 反射调用代理类的构造方法(入参为指定的 invocation handler)创建实例
*/
try {
if (sm != null) {
checkNewProxyPermission(Reflection.getCallerClass(), cl);
}

// 反射获取构造方法
final Constructor<?> cons = cl.getConstructor(constructorParams);
final InvocationHandler ih = h;
if (!Modifier.isPublic(cl.getModifiers())) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
cons.setAccessible(true);
return null;
}
});
}
// 调用构造方法,创建代理对象
return cons.newInstance(new Object[]{h});
} catch (IllegalAccessException|InstantiationException e) {
throw new InternalError(e.toString(), e);
} catch (InvocationTargetException e) {
Throwable t = e.getCause();
if (t instanceof RuntimeException) {
throw (RuntimeException) t;
} else {
throw new InternalError(t.toString(), t);
}
} catch (NoSuchMethodException e) {
throw new InternalError(e.toString(), e);
}
}

/**
* Generate a proxy class. Must call the checkProxyAccess method
* to perform permission checks before calling this.
*/
private static Class<?> getProxyClass0(ClassLoader loader,
Class<?>... interfaces) {
if (interfaces.length > 65535) {
throw new IllegalArgumentException("interface limit exceeded");
}

// If the proxy class defined by the given loader implementing
// the given interfaces exists, this will simply return the cached copy;
// otherwise, it will create the proxy class via the ProxyClassFactory
return proxyClassCache.get(loader, interfaces);
}
}

获取动态代理生成的 Class 文件

java.lang.reflect.Proxy 底层使用了 sun.misc.ProxyGenerator 工具类生成代理类。通过指定 java 命令参数 -Dsun.misc.ProxyGenerator.saveGeneratedFiles=true 可以让工具类将动态生成的字节码写到本地磁盘文件($ProxyN.class)。本例生成的字节码文件反编译后源码如下,重点关注 fly 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.sun.proxy;

import com.github.proxy.Flyable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy implements Flyable {
private static Method m1;
private static Method m2;
private static Method m3;
private static Method m0;

public $Proxy0(InvocationHandler var1) throws {
super(var1);
}

public final boolean equals(Object var1) throws {
try {
return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}

public final String toString() throws {
try {
return (String)super.h.invoke(this, m2, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

public final void fly(String var1) throws {
try {
// 调用 proxy 对象的 fly 方法,则委托 InvocationHandler 对象执行 invoke 方法
super.h.invoke(this, m3, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}

public final int hashCode() throws {
try {
return (Integer)super.h.invoke(this, m0, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m2 = Class.forName("java.lang.Object").getMethod("toString");
m3 = Class.forName("com.github.proxy.Flyable").getMethod("fly", Class.forName("java.lang.String"));
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}

可见,动态生成的 $Proxy0 类同样实现了 Flyable 接口,与目标类 Bird 类形成一个三角结构:

jdk_proxy

fly 方法的实现,仅仅只是调用了 InvocationHandler 对象的 invoke 方法,传入上下文参数。具体的业务逻辑还是在自己的 InvocationHandler 中根据参数判断并自行实现。

参考

Proxy pattern 设计模式

代理设计模式

10分钟看懂动态代理设计模式

动态代理是基于什么原理?

Java 动态代理作用是什么? - 知乎用户的回答

Understanding “proxy” arguments of the invoke method of java.lang.reflect.InvocationHandler

字节码增强技术探索