概述
事务:
什么是事务:
一个事务是由一条或者多条操作数据库的SQL语句所组成的一个不可分割的单元,只有当事务中的所有操作都正常执行,整个事务才能提交给数据库,要么成功,要么失败,不能出现部分成功和失败。
基本概念:
- 事务是一组SQL语句的执行,要么成功,要么失败,不能出现部分成功,事务操作具有原子性。
- 事务的所有SQL全部执行完,才能提交(commit) 事务,将数据写回磁盘。
- 事务执行过程中,只要有SQL出现问题,那么事务就必须回滚(rollback)到最初的状态。
SQL的提交分为手动提交和自动提交,默认自动提交。
insert into A() values(); //在当前数据的副本执行了
commit() //系统执行
手动提交:
insert into A() values();
commit();
rollback;
事务的特征:ACID
事务的原子性:Atomic
事务是一个不可分割的整体,事务必须具有原子特征,即数据操作时要么全部执行,要么全部不执行。
事务的一致性:Consistency
一个事务执行之前和执行之后,数据库数据必须保持一致性状态,一致性状态需要用户保证。
例:银行卡转账时,双方转账前的金额总和和转账后的金额总和一致。
事务的隔离性:Isolation
当两个或者多个事务并发执行时,为保证数据的安全性,将一个事务内的操作与其它的事务操作隔离起来,不被其它正在执行的事务看到。
事务的持久性:Durability
事务完成(commit,rollback)之后,数据库保证数据库中的数据修改时的永久性,即使数据库出现故障,也能保证数据恢复。
事务的隔离性使用不当造成的脏数据问题。
脏读:Dirty READ
一个事务读取了另一个事务为提交的数据。
例:当事务a和事务b并发操作时,当事务a更新数据后,事务b读取到a未提交的数据,此时事务a rollback,事务b就读取到了事务a未提交的无效的脏数据。
不可重复读:NoeRepeatable Read
一个事务操作导致另一个事务前后两次读取到不同的数据。
例:当事务a和事务b并发操作时,事务b查询读取数据后,事务a更新操作事务b读取的数据,此时事务b查询数据,发现前后两次读取的数据结果不一致。
幻读:Phantom READ:
一个事务的操作导致另一个事务前后两次查询的结果的数量不同。
例: 在事务a和事务b并发操作时,当事务b查询读取数据后,事务a新增或者删除一条满足事务b条件的数据,此时事务b再次查询,发现查询到前一次不存在的数据前一次查询的记录不见了。
(事务b读取了事务a新增的内容或者读不到事务a删除的内容)
JDBC提供的五种级别的事务隔离
由于多个线程会请求相同的数据,事务之间通常会用锁互相隔离。
由于数据库支持不同级别的锁,因此java JDBC支持不同级别的事务处理,它们由Connection对象指定。在JDBC中,定义的以下五种事务隔离级别:
- TRANSACTION_NONE : 不支持事务。
- TRANSACTION_READ_UNCOMMITTED :未提交读。
说明在提交前,一个事务可以看到另一个事务的变化。这样读脏数据,不可重复读和幻度都是被允许的。 - TRANSACTION_READ_COMMITTED: 已提交读。
说明读取未提交的数据是不被允许的。这个级别仍然允许不可重复读和幻读。 - TRANSACRION_REPEATABLE_READ: 可重复读。
说明事务保证能够再次读取相同的数据而不会失败,但是幻度仍然出现。 - TREASACTION_SERIALIZABLE: 可序列化/串行化
最高的事务级别,它防止读脏数据,不可重复读和虚读。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读 | 可以 | 可以 | 可以 |
已提交读 | 不可以 | 可以 | 可以 |
可重复读 | 不可以 | 不可以 | 可以 |
串行化 | 不可以 | 不可以 | 不可以 |
注意:
事务隔离级越高,未避免冲突所花费的性能也就越多。
MySQL的事务处理:
查看MySQL事务自动提交事务
0为手动提交,1为自动提交,默认自动提交
mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 1 |
+--------------+
1 row in set (0.00 sec)
mysql> set autocommit=0;
Query OK, 0 rows affected (0.06 sec)
手动提交事务:
begin;
insert;//数据处理
savapoint 1;设置保存点
rollback to 1; //回滚到保存点
commit;//提交事务
- begin: 开启一个事务
- commit:提交一个事务
- rollback:回滚事务到初始状态
- savepoint tg: 设置一个名称为tg的保存点。
- rollback to tg:回滚到tg位置
查询数据库隔离级别:
mysql> show variables like '%isolation%'
-> ;
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.00 sec)
设置数据库隔离级别:
//查询如何设置
mysql> help isolation;
Name: 'ISOLATION'
Description:
Syntax:
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
}
This statement specifies transaction characteristics. It takes a list
of one or more characteristic values separated by commas. Each
characteristic value sets the transaction isolation level or access
mode. The isolation level is used for operations on InnoDB tables. The
access mode specifies whether transactions operate in read/write or
read-only mode.
In addition, SET TRANSACTION can include an optional GLOBAL or SESSION
keyword to indicate the scope of the statement.
URL: http://dev.mysql.com/doc/refman/8.0/en/set-transaction.html
mysql> set transaction ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)
如何实现事务的隔离性:通过行锁和表锁
数据的存储:
在innodb存储引擎中,所有的数据都被逻辑的存放在表空间中,表空间(tablespace)是存储引擎中最高的存储逻辑单位,在表空间下面有包括段(sgement),区(extent),页(page);
同一个数据库实例的所有表空间都有相同的页大小,默认情况下,表空间中页的大小都为16kb,但是可以通过innodb_page_size 选项对默认大小进行修改,需要注意的是不同的页大小最终也会导致区大小的不同。
行溢出数据:
在innodb存储引擎中,数据和主索引是存在一块的,当innode存储carchar或者blob这类的大对象时,我们并不会将所有的内容都存放在数据页结点中,要么将一部分存在数据存在数据页一部分通过偏移量指向溢出页。或者是将所有数据都存在溢出页,数据页中存放指针。
存储引擎:
MySQL最大的特点就是支持插件式的存储引擎。
常用的存储引擎为MYISAM,INNODB,MEMORY
MyISAM存储引擎:
特点:
不支持事务,不支持外键,索引采用非聚集索引(主键索引和辅助索引都存放地址),优势是访问快。
MyISAM的表在磁盘中存储三个文件:
文件名和表名相同,后缀名不同。
- .frm: 存储表的定义
- .MYD: (MY Data ) 存储数据
- .MYI(MY index) 存储索引
INNODB存储引擎:
特点:
支持事务,支持自动增长列(aotuincreament),外键功能,索引采用聚集索引(所有数据都在主键索引的叶子结点上),索引和数据在磁盘上存储一块,INNODB在磁盘上有两个文件
- .frm:数据库定义文件
- .ibd: 存储数据和索引
MEMORY存储引擎:
memory是使用内存来存储数据的,每一个memory在磁盘上就存在一个文件**.frm(数据库表定义)**
memory访问特别快,因为数据和索引都存在内存中,索引采用hash结构(不能进行范围查找) ,但是服务一旦关闭,数据就会丢失。
MYSQL存储引擎的差异:
种类 | 锁机制 | b-数索引 | 哈希索引 | 外键 | 事务 | 索引缓存 | 数据缓存 |
---|---|---|---|---|---|---|---|
MyISAM | 表锁 | 支持 | 不支持 | 不支持 | 不支持 | 支持 | 不支持 |
INNODB | 行锁 | 支持 | 不支持 | 支持 | 支持 | 支持 | 支持 |
MEMORY | 表锁 | 支持 | 支持 | 不支持 | 不支持 | 支持 | 支持 |
- 锁机制:表示数据库在并发请求访问的时候,多个事务在操作时,并发操作的力度。
- B-数索引和hash索引: 主要加速SQL的查询速度
- 外键:子表的字段依赖父表的主键,设置两张表的依赖关系。
- 事务:多个SQL语句,保证它们执行原子操作,要么成功,要么失败,不能只成功一部分,失败需要考虑回滚问题。
- 索引缓存和数据缓存:和MySQL Server 的查询缓存有关,在没有对数据和索引在修改之前,重复查询可以不用进行磁盘IO,读取上一次内存查询的缓存就可以了。
MySQL设置存储引擎:
查看存储引擎:
mysql自带的存储引擎
- Engine: 引擎名称
- Support: mysql是否支持
- Comment: 备注
- Transactions: 是否支持事务
- XA: 是否支持分布式
- Savepoints: 是否支持设置保存点
mysql> show enginesG
*************************** 1. row ***************************
Engine: MEMORY
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 2. row ***************************
Engine: MRG_MYISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: CSV
Support: YES
Comment: CSV storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 4. row ***************************
Engine: FEDERATED
Support: NO
Comment: Federated MySQL storage engine
Transactions: NULL
XA: NULL
Savepoints: NULL
*************************** 5. row ***************************
Engine: PERFORMANCE_SCHEMA
Support: YES
Comment: Performance Schema
Transactions: NO
XA: NO
Savepoints: NO
*************************** 6. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 7. row ***************************
Engine: InnoDB
Support: DEFAULT
Comment: Supports transactions, row-level locking, and foreign keys
Transactions: YES
XA: YES
Savepoints: YES
*************************** 8. row ***************************
Engine: BLACKHOLE
Support: YES
Comment: /dev/null storage engine (anything you write to it disappears)
Transactions: NO
XA: NO
Savepoints: NO
*************************** 9. row ***************************
Engine: ARCHIVE
Support: YES
Comment: Archive storage engine
Transactions: NO
XA: NO
Savepoints: NO
9 rows in set (0.00 sec)
创建表时设置存储引擎:
mysql> create table xixi(id int )
-> engine=myisam;
Query OK, 0 rows affected (0.16 sec)
在已存在的表上修改存储引擎
mysql> alter table xixi engine=innodb;
Query OK, 0 rows affected (0.96 sec)
Records: 0 Duplicates: 0 Warnings: 0
修改默认存储引擎
需要修改配置文件 Windows-》my.ini
Unix -》my.cof
default-storage-engine = INNODB;
修改完配置修改保存重启就可以生效
锁:
锁粒度:
MySQL中提供了两种封锁粒度:行级锁和表级锁;
应该尽量只锁定需要修改的那部分数据,而不是所有资源。锁定的数据量越少,发生锁争用的可能性就越小,系统的并发度就越高。
但是加锁会消耗资源,锁的各种操作(获取锁,释放锁,检查锁)都会增阿吉系统开销。因此封锁粒度越小,系统开销就越大。
在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。
封锁类型:
MyISAM表锁:
MyISAM存储引擎支持表锁,其并发比较简单,只支持表锁粒度,锁的粒度较大,并发能力弱,但是不会引起死锁,它支持表共享的读锁和表互斥的写锁。
对MyISAM表的读操作,不会阻塞其它用户对同一张表的读操作,但是会阻塞其它用户对同一张表的写操作。
对MyISAM表的写操作,则会阻塞其它用户对同一个表的读和写操作,MyISAM的读和写之间互斥,写与写之间互斥,读与读之间共享。
INNODB 行锁:
INNODB实现两种类型的行锁:共享锁和排它锁。具体下文有介绍。
INNODB中 行锁是通过给索引上的索引项加索引实现的 ,而不是给表中的行记录加锁,意味着如果表中的行不存在索引,INNODB使用表锁(因为没有索引,存储引擎只能给所有行都加锁,和表锁一样,然后会把记录返回给MySQL Server,他会筛选出符合条件的行进行加锁,其余行就会释放锁)。
读写锁:
- 排它锁:简称X锁,又称为写锁。
- 共享锁:简称S锁,又称为读锁。
有以下两个规定: - 一个事务对数据对象A加了x锁,就可以对a进行读取和更新,加锁期间其它事务不能对a加任何锁。
- 一个事务对数据对象a加了s锁,就可以对a进行读取操作,但是不能进行更新操作。加锁期间其它事务可以对a加s锁,但是不能加x锁。
锁的兼容关系如下:
意向锁:
使用意向锁可以更容易地支持锁粒度封锁。
在存在行级锁和表级锁地情况下,事务t想要对表a加x锁,就需要先检测是否有其它事务对表a或者表a中的任意一行加了锁,那么就需要对表a的每一行都检测一次,这是非常耗时的。
意向锁在原来的x/s锁基础上引入了ix/is,他们都是表锁,用来表示一个事务在表中的某个数据行上加x锁或者s锁。有以下两个规定:
- 一个事务在获得某个数据行对象的s锁之前,必须先获得表的is锁或者更强的锁。
- 一个事务在获得某个数据行对象的x锁之前,必须先获得表的ix锁。
通过引入对象锁,事务t想要对表a加x锁,只需要先检测是否有其它事务对表a加了x/ix/s/is锁,如果加了就表示有其它事务正在使用这个表或者表中的某一行。因此事务t加x锁失败。
各种锁的兼容关系如下:
- 任意的is/ix之间都是兼容的,因为他们只是表示想要对表加锁,而不是真正加锁。
- s锁只与s锁和is锁兼容,也就是说事务t想要对数据行加s锁,其它事务可以已经获得对表或者表中行的锁。
表锁和行锁的特点
- 表锁:开销小,加锁快,不会出现死锁,锁粒度比较大,发生锁冲突的概率比较高,并发程度低。
- 行锁:开销大,加锁慢,会出现死锁,锁粒度比较小,发生锁冲突的概率比较小,并发程度高。
封锁协议:
三级封锁协议:
-
一级封锁协议
事务t要修改数据a时必须加x锁,直到t结束才释放锁。
可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务修改就不会被覆盖。
-
二级封锁协议
在一级的基础上,要求读取数据a时必须加s锁,读取完马上释放s锁(不是整个事务完释放,一个事务可能有多次读)。
可以解决读脏数据的问题,因为如果一个事务对数据a进行修改,根据1级封锁协议,会加x锁,那么就不能在加s锁了,也就不会读入数据。
-
三级封锁协议
在二级的基础上,要求读取数据a时必须加s锁,**直到事务结束才释放s锁,**可以解决不可重复读问题,因为读a时,其它事务不能对a加x锁,从而避免了在读期间数据发生改变。
两段锁协议:
加锁和解锁分两个阶段进行。
可串行化调度是指,通过并发控制,事务并发执行的事务结果与某个串行执行的事务结果相同。
事务遵循两端锁协议是保证可串行化调度的充分条件。如下面操作满足两段锁协议,它是可串行化调度。
lock-x(A)...lock-s(B)...lock-s(c)...unlock(A)...unlock(c)....unlock(B)
但并不是必要条件,例如以下操作不满足两端锁协议,但是它还是可串行化调度。
lock-x(A)...unlock(A)...lock-s(B)...unlock(B)...lock-s(C)...unlock(C)
MySQL的隐式与显示锁定
mysql的innodb存储引擎采用两端锁协议,会根据隔离级别在需要的时候自动加锁,并且所有锁都是在同一时刻被释放,这被称为隐式锁定。
innodb也可以使用特定的语句进行显示锁定:
select ... lock in share mode; // 获取共享锁
select ... for update; //获取排它锁
验证INNODB没有索引时加的是表锁,
- 创建一个没有索引的测试表,并向表中添加数据不同数据。
mysql> create table test1(id int);
Query OK, 0 rows affected (1.65 sec)
mysql> insert into test1(id) values(1);
Query OK, 1 row affected (0.15 sec)mysql> insert into test1(id) values(2);
Query OK, 1 row affected (0.00 sec)
mysql> select * from test1 for update;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
2 rows in set (0.00 sec)
- 打开两个数据库操作窗口,将提交改为手动提交。
mysql> set @@autocommit=0;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 0 |
+--------------+
1 row in set (0.00 sec)
- 对test1进行数据操作,并强行获取排它锁。
mysql> select * from test1 where id=1 for update;
+------+
| id |
+------+
| 1 |
+------+
1 row in set (0.06 sec)
- 再用另一个窗口,对别的数据进行操作,并强行获取排它锁。
mysql> select * from test1 where id=2 for update;
//可以清晰的看到,这条sql语句一直卡在这,不结束也不抱错误,
//因此我们可以得知在没有索引的情况下,INNODB加的表锁。
死锁问题:
行锁查询产生死锁
INNODB会产生死锁。
我们使用Student表进行演示,其SID为主键索引。
- 打开两个数据库操作窗口,窗口1查询SID=2,窗口二查询SID=1。
mysql> select * from Student where SID=2 for update;
+-----+-------+------+------+
| SID | Sname | Sage | Ssex |
+-----+-------+------+------+
| 2 | 钱电 | 16 | n |
+-----+-------+------+------+
1 row in set (0.00 sec)
mysql> select * from Student where SID=1 for update;
+-----+-------+------+------+
| SID | Sname | Sage | Ssex |
+-----+-------+------+------+
| 1 | 赵雷 | 20 | 男 |
+-----+-------+------+------+
1 row in set (0.06 sec)
- 下来窗口2查询SID=1,窗口1查询SID=2。
//我们可以看到窗口2一直卡在那里,等待窗口1释放SID=2的资源
mysql> select * from Student where SID=1 for update;
//在窗口1产生死锁异常,并抛出错误后,窗口二继续执行。
+-----+-------+------+------+
| SID | Sname | Sage | Ssex |
+-----+-------+------+------+
| 1 | 赵雷 | 20 | 男 |
+-----+-------+------+------+
1 row in set (14.86 sec)
//窗口1直接抛出错误。产生死锁
mysql> select * from Student where SID=2 for update;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
表之间查询产生死锁:
我们再引入一张SC表,其SID也是主键。
- 窗口1查询Student表,窗口二查询SC表
mysql> select * from Student for update;
+-----+-------+------+------+
| SID | Sname | Sage | Ssex |
+-----+-------+------+------+
| 1 | 赵雷 | 20 | 男 |
| 2 | 钱电 | 16 | n |
| 3 | 孙风 | 16 | n |
| 4 | 吴兰 | 16 | n |
| 5 | 孙兰 | 16 | n |
+-----+-------+------+------+
5 rows in set (0.00 sec)
mysql> select * from SC for update;
+------+------+-------+
| SID | CID | score |
+------+------+-------+
| 1 | 1 | 80 |
| 1 | 2 | 71 |
| 1 | 3 | 87 |
| 2 | 1 | 88 |
| 2 | 2 | 70 |
| 2 | 3 | 89 |
| 3 | 1 | 68 |
| 3 | 2 | 78 |
| 3 | 3 | 87 |
| 4 | 1 | 67 |
| 4 | 2 | 58 |
| 4 | 3 | 89 |
| 5 | 1 | 56 |
| 5 | 2 | 89 |
| 6 | 3 | 38 |
+------+------+-------+
15 rows in set (0.00 sec)
- 窗口1查询SC表,窗口二查询Student表
mysql> select * from SC for update;
//窗口1等待资源
+------+------+-------+
| SID | CID | score |
+------+------+-------+
| 1 | 1 | 80 |
| 1 | 2 | 71 |
| 1 | 3 | 87 |
| 2 | 1 | 88 |
| 2 | 2 | 70 |
| 2 | 3 | 89 |
| 3 | 1 | 68 |
| 3 | 2 | 78 |
| 3 | 3 | 87 |
| 4 | 1 | 67 |
| 4 | 2 | 58 |
| 4 | 3 | 89 |
| 5 | 1 | 56 |
| 5 | 2 | 89 |
| 6 | 3 | 38 |
+------+------+-------+
15 rows in set (22.26 sec)
//在窗口2产生资源释放死锁后,窗口1继续执行
//产生死锁
mysql> select * from Student for update;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
注意:
MySQL自动检测死锁问题,把当前事务回滚,释放事务持有的锁,此时窗口2的事务就能获取行锁,执行相应的SQL语句。
在上面的例子中,两个事务都需要获取对方持有的排它锁才能继续完成事务,这种循环锁等待就是典型的死锁。
这种死锁问题,一般都是我们自己造成的,和javaSE多线程死锁的情况相似,大部分都是由于我们多个线程获取多个锁在资源的时候,获取顺序不同而导致的死锁问题。因此,我们在应用数据库的多个表或者一个表中的需要获取相同数据时,不同的代码段,应对这些表按相同的数据进行更新操作,以防冲突导致死锁问题。
多版本并发控制(MVCC):
多版本并发控制是mysql的innodb存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重读读两种隔离级别。
未提交读隔离级别总是读取最新的数据行,无需使用MVCC。
可串行化隔离级别需要对所有读取的行都加锁,单纯使用MVCC无法实现。
版本号:
- 系统版本号: 是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
- 事务版本号:事务开始时的系统版本号。
innodb的mvcc在每行记录的后面都保存着两个隐藏的列,用量存储两个版本号: - 创建版本号:指示创建一个数据行的快照时的系统版本号。
- 删除版本号:如果该快照的删除版本号大于当前事务的版本号表示该快照有效,否则表示该快照已经被删除(innob存在主键索引,如果直接删除那么等于要变动整个索引树,代价太大,因此通过删除版本号的方式标记失效)了。
undo 日志
innodb的mvcc使用到的快照存储在undo日志中,该日志通过一个回滚指针把一个数据行(record)的所有快照连接起来。
实现过程:
以下实现针对可重复度隔离级别。
select
当开始一个新的事务时,该事务的版本号肯定会大于当前所有数据行快照的创建版本号。
多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。
把没有对数据行做修改的事务称为t,t所要读取的数据行快照的创建版本号必须小于t的版本号,因为如果大于或者等于t的版本号,那么表示该数据行快照是其它事务的最新修改,因为不能去读取它。
还有就是,所要读取的数据行快照的删除版本必须大于t的版本号,如果小于等于t的版本号,那么表示该数据行快照已经被删除,不应该再去读取它。
insert:
将当前系统版本号作为数据行快照的创建版本号。
delete:
将当前系统版本号作为数据行快照的删除版本号。
uptate:
将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行delete后执行insert。
快照读与当前读:
快照读
使用mvcc读取的是快照中的数据,这样可以减少加锁所带来的开销。
select * from table ...;
当前读:
读取的是最新的数据,需要加锁。下面第一个语句需要加s锁,其它都需要加x锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
最后
以上就是俊秀犀牛为你收集整理的数据库-事务&存储引擎&锁的全部内容,希望文章能够帮你解决数据库-事务&存储引擎&锁所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复