CS-Notes/docs/notes/数据库系统原理.md
2019-12-08 17:26:50 +08:00

541 lines
24 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- GFM-TOC -->
* [事务](#一事务)
* [概念](#概念)
* [ACID](#acid)
* [AUTOCOMMIT](#autocommit)
* [并发一致性问题](#二并发一致性问题)
* [丢失修改](#丢失修改)
* [读脏数据](#读脏数据)
* [不可重复读](#不可重复读)
* [幻影读](#幻影读)
* [封锁](#三封锁)
* [封锁粒度](#封锁粒度)
* [封锁类型](#封锁类型)
* [封锁协议](#封锁协议)
* [MySQL 隐式与显示锁定](#mysql-隐式与显示锁定)
* [隔离级别](#四隔离级别)
* [未提交读READ UNCOMMITTED](#未提交读read-uncommitted)
* [提交读READ COMMITTED](#提交读read-committed)
* [可重复读REPEATABLE READ](#可重复读repeatable-read)
* [可串行化SERIALIZABLE](#可串行化serializable)
* [多版本并发控制](#五多版本并发控制)
* [基本思想](#基本思想)
* [版本号](#版本号)
* [Undo 日志](#undo-日志)
* [ReadView](#readview)
* [快照读与当前读](#快照读与当前读)
* [Next-Key Locks](#六next-key-locks)
* [Record Locks](#record-locks)
* [Gap Locks](#gap-locks)
* [Next-Key Locks](#next-key-locks)
* [关系数据库设计理论](#七关系数据库设计理论)
* [函数依赖](#函数依赖)
* [异常](#异常)
* [范式](#范式)
* [ER ](#八er-)
* [实体的三种联系](#实体的三种联系)
* [表示出现多次的关系](#表示出现多次的关系)
* [联系的多向性](#联系的多向性)
* [表示子类](#表示子类)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
# 事务
## 概念
事务指的是满足 ACID 特性的一组操作可以通过 Commit 提交一个事务也可以使用 Rollback 进行回滚
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207222237925.png"/> </div><br>
## ACID
### 1. 原子性Atomicity
事务被视为不可分割的最小单元事务的所有操作要么全部提交成功要么全部失败回滚
回滚可以用回滚日志Undo Log来实现回滚日志记录着事务所执行的修改操作在回滚时反向执行这些修改操作即可
### 2. 一致性Consistency
数据库在事务执行前后都保持一致性状态在一致性状态下所有事务对同一个数据的读取结果都是相同的
### 3. 隔离性Isolation
一个事务所做的修改在最终提交以前对其它事务是不可见的
### 4. 持久性Durability
一旦事务提交则其所做的修改将会永远保存到数据库中即使系统发生崩溃事务执行的结果也不能丢失
系统发生奔溃可以用重做日志Redo Log进行恢复从而实现持久性与回滚日志记录数据的逻辑修改不同重做日志记录的是数据页的物理修改
----
事务的 ACID 特性概念简单但不是很好理解主要是因为这几个特性不是一种平级关系
- 只有满足一致性事务的执行结果才是正确的
- 在无并发的情况下事务串行执行隔离性一定能够满足此时只要能满足原子性就一定能满足一致性
- 在并发的情况下多个事务并行执行事务不仅要满足原子性还需要满足隔离性才能满足一致性
- 事务满足持久化是为了能应对系统崩溃的情况
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207210437023.png"/> </div><br>
## AUTOCOMMIT
MySQL 默认采用自动提交模式也就是说如果不显式使用`START TRANSACTION`语句来开始一个事务那么每个查询操作都会被当做一个事务并自动提交
# 并发一致性问题
在并发环境下事务的隔离性很难保证因此会出现很多并发一致性问题
## 丢失修改
T<sub>1</sub> T<sub>2</sub> 两个事务都对一个数据进行修改T<sub>1</sub> 先修改T<sub>2</sub> 随后修改T<sub>2</sub> 的修改覆盖了 T<sub>1</sub> 的修改
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207221744244.png"/> </div><br>
## 读脏数据
T<sub>1</sub> 修改一个数据T<sub>2</sub> 随后读取这个数据如果 T<sub>1</sub> 撤销了这次修改那么 T<sub>2</sub> 读取的数据是脏数据
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207221920368.png"/> </div><br>
## 不可重复读
T<sub>2</sub> 读取一个数据T<sub>1</sub> 对该数据做了修改如果 T<sub>2</sub> 再次读取这个数据此时读取的结果和第一次读取的结果不同
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207222102010.png"/> </div><br>
## 幻影读
T<sub>1</sub> 读取某个范围的数据T<sub>2</sub> 在这个范围内插入新的数据T<sub>1</sub> 再次读取这个范围的数据此时读取的结果和和第一次读取的结果不同
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207222134306.png"/> </div><br>
----
产生并发不一致性问题的主要原因是破坏了事务的隔离性解决方法是通过并发控制来保证隔离性并发控制可以通过封锁来实现但是封锁操作需要用户自己控制相当复杂数据库管理系统提供了事务的隔离级别让用户以一种更轻松的方式处理并发一致性问题
# 封锁
## 封锁粒度
MySQL 中提供了两种封锁粒度行级锁以及表级锁
应该尽量只锁定需要修改的那部分数据而不是所有的资源锁定的数据量越少发生锁争用的可能就越小系统的并发程度就越高
但是加锁需要消耗资源锁的各种操作包括获取锁释放锁以及检查锁状态都会增加系统开销因此封锁粒度越小系统开销就越大
在选择封锁粒度时需要在锁开销和并发程度之间做一个权衡
## 封锁类型
### 1. 读写锁
- 互斥锁Exclusive简写为 X 又称写锁
- 共享锁Shared简写为 S 又称读锁
有以下两个规定
- 一个事务对数据对象 A 加了 X 就可以对 A 进行读取和更新加锁期间其它事务不能对 A 加任何锁
- 一个事务对数据对象 A 加了 S 可以对 A 进行读取操作但是不能进行更新操作加锁期间其它事务能对 A S 但是不能加 X
锁的兼容关系如下
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207213523777.png"/> </div><br>
### 2. 意向锁
使用意向锁Intention Locks可以更容易地支持多粒度封锁
在存在行级锁和表级锁的情况下事务 T 想要对表 A X 就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁那么就需要对表 A 的每一行都检测一次这是非常耗时的
意向锁在原来的 X/S 锁之上引入了 IX/ISIX/IS 都是表锁用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 有以下两个规定
- 一个事务在获得某个数据行对象的 S 锁之前必须先获得表的 IS 锁或者更强的锁
- 一个事务在获得某个数据行对象的 X 锁之前必须先获得表的 IX
通过引入意向锁事务 T 想要对表 A X 只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 如果加了就表示有其它事务正在使用这个表或者表中某一行的锁因此事务 T X 锁失败
各种锁的兼容关系如下
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207214442687.png"/> </div><br>
解释如下
- 任意 IS/IX 锁之间都是兼容的因为它们只表示想要对表加锁而不是真正加锁
- 这里兼容关系针对的是表级锁而表级的 IX 锁和行级的 X 锁兼容两个事务可以对两个数据行加 X 事务 T<sub>1</sub> 想要对数据行 R<sub>1</sub> X 事务 T<sub>2</sub> 想要对同一个表的数据行 R<sub>2</sub> X 两个事务都需要对该表加 IX 但是 IX 锁是兼容的并且 IX 锁与行级的 X 锁也是兼容的因此两个事务都能加锁成功对同一个表中的两个数据行做修改
## 封锁协议
### 1. 三级封锁协议
**一级封锁协议**
事务 T 要修改数据 A 时必须加 X 直到 T 结束才释放锁
可以解决丢失修改问题因为不能同时有两个事务对同一个数据进行修改那么事务的修改就不会被覆盖
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207220440451.png"/> </div><br>
**二级封锁协议**
在一级的基础上要求读取数据 A 时必须加 S 读取完马上释放 S
可以解决读脏数据问题因为如果一个事务在对数据 A 进行修改根据 1 级封锁协议会加 X 那么就不能再加 S 锁了也就是不会读入数据
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207220831843.png"/> </div><br>
**三级封锁协议**
在二级的基础上要求读取数据 A 时必须加 S 直到事务结束了才能释放 S
可以解决不可重复读的问题因为读 A 其它事务不能对 A X 从而避免了在读的期间数据发生改变
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207221313819.png"/> </div><br>
### 2. 两段锁协议
加锁和解锁分为两个阶段进行
可串行化调度是指通过并发控制使得并发执行的事务结果与某个串行执行的事务结果相同串行执行的事务互不干扰不会出现并发一致性问题
事务遵循两段锁协议是保证可串行化调度的充分条件例如以下操作满足两段锁协议它是可串行化调度
```html
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
```
但不是必要条件例如以下操作不满足两段锁协议但它还是可串行化调度
```html
lock-x(A)...unlock(A)...lock-s(B)...unlock(B)...lock-s(C)...unlock(C)
```
## MySQL 隐式与显示锁定
MySQL InnoDB 存储引擎采用两段锁协议会根据隔离级别在需要的时候自动加锁并且所有的锁都是在同一时刻被释放这被称为隐式锁定
InnoDB 也可以使用特定的语句进行显示锁定
```sql
SELECT ... LOCK In SHARE MODE;
SELECT ... FOR UPDATE;
```
# 隔离级别
## 未提交读READ UNCOMMITTED
事务中的修改即使没有提交对其它事务也是可见的
## 提交读READ COMMITTED
一个事务只能读取已经提交的事务所做的修改换句话说一个事务所做的修改在提交之前对其它事务是不可见的
## 可重复读REPEATABLE READ
保证在同一个事务中多次读取同一数据的结果是一样的
## 可串行化SERIALIZABLE
强制事务串行执行这样多个事务互不干扰不会出现并发一致性问题
该隔离级别需要加锁实现因为要使用加锁机制保证同一时间只有一个事务执行也就是保证事务串行执行
----
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191207223400787.png"/> </div><br>
# 多版本并发控制
多版本并发控制Multi-Version Concurrency Control, MVCC MySQL InnoDB 存储引擎实现隔离级别的一种具体方式用于实现提交读和可重复读这两种隔离级别而未提交读隔离级别总是读取最新的数据行要求很低无需使用 MVCC可串行化隔离级别需要对所有读取的行都加锁单纯使用 MVCC 无法实现
## 基本思想
在封锁一节中提到加锁能解决多个事务同时执行时出现的并发一致性问题在实际场景中读操作往往多于写操作因此又引入了读写锁来避免不必要的加锁操作例如读和读没有互斥关系读写锁中读和写操作仍然是互斥的 MVCC 利用了多版本的思想写操作更新最新的版本快照而读操作去读旧版本快照没有互斥关系这一点和 CopyOnWrite 类似
MVCC 中事务的修改操作DELETEINSERTUPDATE会为数据行新增一个版本快照
脏读和不可重复读最根本的原因是事务读取到其它事务未提交的修改在事务进行读取操作时为了解决脏读和不可重复读问题MVCC 规定只能读取已经提交的快照当然一个事务可以读取自身未提交的快照这不算是脏读
## 版本号
- 系统版本号 SYS_ID是一个递增的数字每开始一个新的事务系统版本号就会自动递增
- 事务版本号 TRX_ID 事务开始时的系统版本号
## Undo 日志
MVCC 的多版本指的是多个版本的快照快照存储在 Undo 日志中该日志通过回滚指针 ROLL_PTR 把一个数据行的所有快照连接起来
例如在 MySQL 创建一个表 t包含主键 id 和一个字段 x我们先插入一个数据行然后对该数据行执行两次更新操作
```sql
INSERT INTO t(id, x) VALUES(1, "a");
UPDATE t SET x="b" WHERE id=1;
UPDATE t SET x="c" WHERE id=1;
```
因为没有使用 `START TRANSACTION` 将上面的操作当成一个事务来执行根据 MySQL AUTOCOMMIT 机制每个操作都会被当成一个事务来执行所以上面的操作总共涉及到三个事务快照中除了记录事务版本号 TRX_ID 和操作之外还记录了一个 bit DEL 字段用于标记是否被删除
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191208164808217.png"/> </div><br>
INSERTUPDATEDELETE 操作会创建一个日志并将事务版本号 TRX_ID 写入DELETE 可以看成是一个特殊的 UPDATE还会额外将 DEL 字段设置为 1
## ReadView
MVCC 维护了一个 ReadView 结构主要包含了当前系统未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, ...}还有该列表的最小值 TRX_ID_MIN TRX_ID_MAX
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/image-20191208171445674.png"/> </div><br>
在进行 SELECT 操作时根据数据行快照的 TRX_ID TRX_ID_MIN TRX_ID_MAX 之间的关系从而判断数据行快照是否可以使用
- TRX_ID < TRX_ID_MIN表示该数据行快照时在当前所有未提交事务之前进行更改的因此可以使用
- TRX_ID > TRX_ID_MAX表示该数据行快照是在事务启动之后被更改的因此不可使用
- TRX_ID_MIN <= TRX_ID <= TRX_ID_MAX需要根据隔离级别再进行判断
- 提交读如果 TRX_ID TRX_IDs 列表中表示该数据行快照对应的事务还未提交则该快照不可使用否则表示已经提交可以使用
- 可重复读都不可以使用因为如果可以使用的话那么其它事务也可以读到这个数据行快照并进行修改那么当前事务再去读这个数据行得到的值就会发生改变也就是出现了不可重复读问题
在数据行快照不可使用的情况下需要沿着 Undo Log 的回滚指针 ROLL_PTR 找到下一个快照再进行上面的判断
## 快照读与当前读
### 1. 快照读
MVCC SELECT 操作是快照中的数据不需要进行加锁操作
```sql
SELECT * FROM table ...;
```
### 2. 当前读
MVCC 其它会对数据库进行修改的操作INSERTUPDATEDELETE需要进行加锁操作从而读取最新的数据可以看到 MVCC 并不是完全不用加锁而只是避免了 SELECT 的加锁操作
```sql
INSERT;
UPDATE;
DELETE;
```
在进行 SELECT 操作时可以强制指定进行加锁操作以下第一个语句需要加 S 第二个需要加 X
```sql
SELECT * FROM table WHERE ? lock in share mode;
SELECT * FROM table WHERE ? for update;
```
# Next-Key Locks
Next-Key Locks MySQL InnoDB 存储引擎的一种锁实现
MVCC 不能解决幻影读问题Next-Key Locks 就是为了解决这个问题而存在的在可重复读REPEATABLE READ隔离级别下使用 MVCC + Next-Key Locks 可以解决幻读问题
## Record Locks
锁定一个记录上的索引而不是记录本身
如果表没有设置索引InnoDB 会自动在主键上创建隐藏的聚簇索引因此 Record Locks 依然可以使用
## Gap Locks
锁定索引之间的间隙但是不包含索引本身例如当一个事务执行以下语句其它事务就不能在 t.c 中插入 15
```sql
SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
```
## Next-Key Locks
它是 Record Locks Gap Locks 的结合不仅锁定一个记录上的索引也锁定索引之间的间隙它锁定一个前开后闭区间例如一个索引包含以下值10, 11, 13, and 20那么就需要锁定以下区间
```sql
(-, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +)
```
# 关系数据库设计理论
## 函数依赖
A->B 表示 A 函数决定 B也可以说 B 函数依赖于 A
如果 {A1A2... An} 是关系的一个或多个属性的集合该集合函数决定了关系的其它所有属性并且是最小的那么该集合就称为键码
对于 A->B如果能找到 A 的真子集 A'使得 A'-> B那么 A->B 就是部分函数依赖否则就是完全函数依赖
对于 A->BB->C A->C 是一个传递函数依赖
## 异常
以下的学生课程关系的函数依赖为 {Sno, Cname} -> {Sname, Sdept, Mname, Grade}键码为 {Sno, Cname}也就是说确定学生和课程之后就能确定其它信息
| Sno | Sname | Sdept | Mname | Cname | Grade |
| :---: | :---: | :---: | :---: | :---: |:---:|
| 1 | 学生-1 | 学院-1 | 院长-1 | 课程-1 | 90 |
| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-2 | 80 |
| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-1 | 100 |
| 3 | 学生-3 | 学院-2 | 院长-2 | 课程-2 | 95 |
不符合范式的关系会产生很多异常主要有以下四种异常
- 冗余数据例如 `学生-2` 出现了两次
- 修改异常修改了一个记录中的信息但是另一个记录中相同的信息却没有被修改
- 删除异常删除一个信息那么也会丢失其它信息例如删除了 `课程-1` 需要删除第一行和第三行那么 `学生-1` 的信息就会丢失
- 插入异常例如想要插入一个学生的信息如果这个学生还没选课那么就无法插入
## 范式
范式理论是为了解决以上提到四种异常
高级别范式的依赖于低级别的范式1NF 是最低级别的范式
### 1. 第一范式 (1NF)
属性不可分
### 2. 第二范式 (2NF)
每个非主属性完全函数依赖于键码
可以通过分解来满足
<font size=4> **分解前** </font><br>
| Sno | Sname | Sdept | Mname | Cname | Grade |
| :---: | :---: | :---: | :---: | :---: |:---:|
| 1 | 学生-1 | 学院-1 | 院长-1 | 课程-1 | 90 |
| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-2 | 80 |
| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-1 | 100 |
| 3 | 学生-3 | 学院-2 | 院长-2 | 课程-2 | 95 |
以上学生课程关系中{Sno, Cname} 为键码有如下函数依赖
- Sno -> Sname, Sdept
- Sdept -> Mname
- Sno, Cname-> Grade
Grade 完全函数依赖于键码它没有任何冗余数据每个学生的每门课都有特定的成绩
Sname, Sdept Mname 都部分依赖于键码当一个学生选修了多门课时这些数据就会出现多次造成大量冗余数据
<font size=4> **分解后** </font><br>
关系-1
| Sno | Sname | Sdept | Mname |
| :---: | :---: | :---: | :---: |
| 1 | 学生-1 | 学院-1 | 院长-1 |
| 2 | 学生-2 | 学院-2 | 院长-2 |
| 3 | 学生-3 | 学院-2 | 院长-2 |
有以下函数依赖
- Sno -> Sname, Sdept
- Sdept -> Mname
关系-2
| Sno | Cname | Grade |
| :---: | :---: |:---:|
| 1 | 课程-1 | 90 |
| 2 | 课程-2 | 80 |
| 2 | 课程-1 | 100 |
| 3 | 课程-2 | 95 |
有以下函数依赖
- Sno, Cname -> Grade
### 3. 第三范式 (3NF)
非主属性不传递函数依赖于键码
上面的 关系-1 中存在以下传递函数依赖
- Sno -> Sdept -> Mname
可以进行以下分解
关系-11
| Sno | Sname | Sdept |
| :---: | :---: | :---: |
| 1 | 学生-1 | 学院-1 |
| 2 | 学生-2 | 学院-2 |
| 3 | 学生-3 | 学院-2 |
关系-12
| Sdept | Mname |
| :---: | :---: |
| 学院-1 | 院长-1 |
| 学院-2 | 院长-2 |
# ER
Entity-Relationship有三个组成部分实体属性联系
用来进行关系型数据库系统的概念设计
## 实体的三种联系
包含一对一一对多多对多三种
- 如果 A B 是一对多关系那么画个带箭头的线段指向 B
- 如果是一对一画两个带箭头的线段
- 如果是多对多画两个不带箭头的线段
下图的 Course Student 是一对多的关系
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/1d28ad05-39e5-49a2-a6a1-a6f496adba6a.png" width="380px"/> </div><br>
## 表示出现多次的关系
一个实体在联系出现几次就要用几条线连接
下图表示一个课程的先修关系先修关系出现两个 Course 实体第一个是先修课程后一个是后修课程因此需要用两条线来表示这种关系
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/ac929ea3-daca-40ec-9e95-4b2fa6678243.png" width="250px"/> </div><br>
## 联系的多向性
虽然老师可以开设多门课并且可以教授多名学生但是对于特定的学生和课程只有一个老师教授这就构成了一个三元联系
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/5bb1b38a-527e-4802-a385-267dadbd30ba.png" width="350px"/> </div><br>
## 表示子类
用一个三角形和两条线来连接类和子类与子类有关的属性和联系都连到子类上而与父类和子类都有关的连到父类上
<div align="center"> <img src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/14389ea4-8d96-4e96-9f76-564ca3324c1e.png" width="450px"/> </div><br>
# 参考资料
- AbrahamSilberschatz, HenryF.Korth, S.Sudarshan, . 数据库系统概念 [M]. 机械工业出版社, 2006.
- 施瓦茨. 高性能 MYSQL(第3版)[M]. 电子工业出版社, 2013.
- 史嘉权. 数据库系统概论[M]. 清华大学出版社有限公司, 2006.
- [The InnoDB Storage Engine](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html)
- [Transaction isolation levels](https://www.slideshare.net/ErnestoHernandezRodriguez/transaction-isolation-levels)
- [Concurrency Control](http://scanftree.com/dbms/2-phase-locking-protocol)
- [The Nightmare of Locking, Blocking and Isolation Levels!](https://www.slideshare.net/brshristov/the-nightmare-of-locking-blocking-and-isolation-levels-46391666)
- [Database Normalization and Normal Forms with an Example](https://aksakalli.github.io/2012/03/12/database-normalization-and-normal-forms-with-an-example.html)
- [The basics of the InnoDB undo logging and history system](https://blog.jcole.us/2014/04/16/the-basics-of-the-innodb-undo-logging-and-history-system/)
- [MySQL locking for the busy web developer](https://www.brightbox.com/blog/2013/10/31/on-mysql-locks/)
- [浅入浅出 MySQL InnoDB](https://draveness.me/mysql-innodb)
- [Innodb 中的事务隔离级别和锁的关系](https://tech.meituan.com/2014/08/20/innodb-lock.html)
<div align="center"><img width="320px" src="https://cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com/githubio/公众号二维码-2.png"></img></div>