zoukankan      html  css  js  c++  java
  • Mysql 事务

    MySql从5.5版本就默认INNODB为存储引擎,INNODB支持事务,所以下面事务的学习基于INNODB数据存储引擎。事务是一个不可分割的数据库操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。事务是逻辑上的一组操作,要么都执行,要么都不执行。

    • 事务处理可以用来维护数据库的完整性,保证成批的 SQL 语句要么全部执行,要么全部不执行。

    • 事务用来管理 insert,update,delete 语句

    一、事务的自动提交模式

    MySQL中默认采用的是自动提交(autocommit)模式,可以使用命令show variables like 'autocommit';查看。如下截图:

    在自动提交模式下,如果没有start transaction显式地开始一个事务,那么每个sql语句都会被当做一个事务执行提交操作。

    可以通过命令set autocommit=0;关闭事务的自动提交模式;如下截图: 

    需要注意:autocommit参数是针对连接的,在一个连接中更改了事务的自动提交模式,不会对其他连接产生影响。比如上面我们在命令行中关闭了自动提交,我们重新打开一个连接发现事务仍然是开启状态:

    如果想全局关闭事务的自动提交,可以改 mysql.cnf(linux ) 或者 mysql.ini(window) 文件。

     注意:

     如果关闭自动提交,则所有的SQL语句都在一个事务中,直到执行了commit或rollback,该事务结束,同时开始了另外一个事务。

     如果开启自动提交,则每执行一条 SQL 语句,事务都会提交一次。


    下面通过一个示例来展示开启自动提交和关闭自动提交的差别。

    模拟银行转款示例:A向B转款100元。

    关闭事务的自动提交,然后查看A、B两个人现有账户余额:

     

    然后进行两个更新操作,将A账户的钱减100,B账户加100:

     重新打开一个 命令行窗口,即开启一个新的连接,查看 bank_account 数据表中A和B的余额:

     结果显示,A和B的余额都没有变化。下面在之前的命令行窗口中使用 COMMIT 语句提交事务,并查询 bank 数据表的数据:

     

    结果显示,bank 数据表的数据更新成功。

    从上述示例中可以得出结论:

    • 关闭自动提交后,该位置会作为一个事务起点,直到执行 COMMIT 语句和 ROLLBACK 语句后,该事务才结束。结束之后,这就是下一个事务的起点。

    • 关闭自动提交功能后,只用当执行 COMMIT 命令后,INNODB才将数据提交到数据库中,如果执行 ROLLBACK 命令,数据将会被回滚,如果不提交事务,而终止 MySQL 会话,数据库将会自动执行回滚操作。

    • 使用 BEGIN 或 START TRANSACTION 开启一个事务之后,自动提交将保持禁用状态,直到使用 COMMIT 或 ROLLBACK 结束事务。之后,自动提交模式会恢复到之前的状态,即如果 BEGIN 前 autocommit = 1,则完成本次事务后 autocommit 还是 1。如果 BEGIN 前 autocommit = 0,则完成本次事务后 autocommit 还是 0。

    注意:从上面示例中可以看出,事务在commit之前是不会持久化的,那么数据放在了哪里?

    以InnoDB存储引擎为例,INNODB有缓冲池,事务提交(commit)前是放在缓冲池中的。而且在事务执行过程中,修改数据会不断产生redo日志。在提交成功前,事务会确保redo是持久化的,这样即使宕机了,也可以通过redo恢复出来。事务数据的持久化一般跟checkpoint有关。简单地说checkpoint以redo日志文件的offset(lsn)为标志,比checkpoint lsn更小的redo日志对应的事务,它的数据都是已经持久化了的,进行恢复时无需再理会该事务。

    二、事务的ACID特性

    • 原子性(Atomicity):通过 undo log 来实现的

    • 一致性(Consistency):通过原子性,持久性,隔离性来实现的

    • 隔离性(Isolation):通过 读写锁+MVCC来实现的

    • 持久性(Durability):通过 redo log 来实现的

    我们可以把上面的四个特性作为衡量事务的四个标准。正常情况下,并不是所有存储引擎的事务都完美实现了该四个特性。例如MySQL的NDB Cluster事务不满足持久性和隔离性;InnoDB默认事务隔离级别是可重复读,不满足隔离性;Oracle默认的事务隔离级别为READ COMMITTED,不满足隔离性。下面详细的了解ACID特性。

    三、ACID特性-原子性

    1、概念

    一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

    2、实现原理

    undo log是实现原子性的关键,undo log 叫做回滚日志,属于逻辑日志,根据每行记录进行记录用于记录数据被修改前的信息。undo log主要记录的是数据的逻辑变化,为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时可以回滚。每次写入数据或者修改数据之前都会把修改前的信息记录到 undo log。当发生回滚时,InnoDB会根据undo log的内容做与之前相反的工作:对于每个insert,回滚时会执行delete;对于每个delete,回滚时会执行insert;对于每个update,回滚时会执行一个相反的update,把数据恢复为修改之前的状态。接下来看一下undo log在实现事务原子性时怎么发挥作用的。

    假设有两个表 bank和finance,表中原始数据如图所示,当进行插入,删除以及更新操作时生成的undo log,如下面图所示:

    根据上面流程可以得出如下结论:

    • 每条数据变更(insert/update/delete)操作都伴随一条undo log的生成,并且回滚日志必须先于数据持久化到磁盘上。

    为什么回滚日志比刷脏更快的持久化到磁盘?

    (1)刷脏是随机IO,因为每次修改的数据位置随机;但写undo log是追加操作,属于顺序IO。

    (2)刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而undo log中只包含真正需要写入的部分,无效IO大大减少。 

    • 所谓的回滚就是根据回滚日志做逆向操作,比如delete的逆向操作为insert,insert的逆向操作为delete,update的逆向为update等。

    如下回滚图例:

    四、ACID特性-持久性

    1、概念

    事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

    2、实现原理

    redo log是实现持久性的关键,redo log叫做重做日志,redo log属于物理日志,记录的是数据页的物理修改redo log包括两部分:一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的。当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性。接下来看一下redo log在实现事务持久性时怎么发挥作用的。

    先了解一下MySQL的数据存储机制,MySQL的表数据是存放在磁盘上的,因此想要存取的时候都要经历磁盘IO,然而即使是使用SSD磁盘IO也是非常消耗性能的。为此,为了提升性能InnoDB提供了缓冲池(Buffer Pool),Buffer Pool中包含了磁盘数据页的映射,可以当做缓存来使用:

    • 读数据:会首先从缓冲池中读取,如果缓冲池中没有,则从磁盘读取在放入缓冲池;
    • 写数据:会首先写入缓冲池,缓冲池中的数据会定期同步到磁盘中;

    上面这种缓冲池的措施虽然在性能方面带来了质的飞跃,但是它也带来了新的问题,当MySQL系统宕机,断电的时候可能会丢数据!!!因为我们的数据已经提交了,但此时是在缓冲池里头,还没来得及在磁盘持久化,所以我们急需一种机制需要存一下已提交事务的数据,为恢复数据使用。于是 redo log就派上用场了。下面看下redo log是什么时候产生的

    为什么重做日志比刷脏更快的持久化到磁盘?

    (1)刷脏是随机IO,因为每次修改的数据位置随机;但写redo log是追加操作,属于顺序IO。

    (2)刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。 

    五、ACID特性-隔离性

    1、概念

    与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响数据库允许多个并发事务同时对其数据进行读写和修改,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。

    隔离性追求的是并发情形下事务之间互不干扰。下面主要考虑最简单的读操作和写操作:

    • 一个事务写操作对另一个事务写操作的影响:锁机制保证隔离性
    • 一个事务写操作对另一个事务读操作的影响:MVCC保证隔离性

    2、事务隔离性的必要性解析

    数据库允许多个并发事务同时对其数据进行读写,如果不考虑事务的隔离性,事务并发会引起的问题有丢失更新、不可重复度、脏读和幻读。具体可以分为两类,一类是写操作对读操作造成的影响,一类是写操作对写操作造成的影响。下面具体分析下:

    (1)写操作对读操作造成的影响主要包括脏读、不可重复读、幻读。 

    • 脏读

      脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。比如:事务1中可以读到事务2未提交的数据(脏数据),这种现象是脏读。

    时间点 事务1 事务2
    t1

    开始事务:BEGIN

     
    t2

    查询A账户的金额,结果为200元

    select money from account where name='A';

    t3  

    修改A账户的金额,由200元变为100元:
    update account set money=100 where name=’A’;

    t4

    查询A账户的金额,结果为100元(脏读)

    select money from account where name='A';

     
    t5   提交事务:Commit;
    • 不可重复读

      不可重复读是指在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了。例如事务1读取A账户的金额,而事务2立马修改了A账户的金额并且提交事务给数据库,事务1再次读取A账户金额时就得到了不同的结果,发生了不可重复读。

    时间点 事务1 事务2
    t1 开始事务  
    t2

    查询A账户的金额,结果为200

    select money from account where name='A';

     
    t3   修改A账户的金额,由200元变为100元:
    update account set money=100 where name=’A’;
    t4   提交事务:commit;
    t5

    查询A账户的金额,结果为100(不可重复读)

    select money from account where name='A';

     

    不可重复读和脏读的区别是:脏读是某一事务读取了另一个事务未提交的脏数据,而不可重复读则是读取了前一事务提交的数据。

    在某些情况下,不可重复读并不是问题,比如我们多次查询某个数据当然以最后查询得到的结果为主。但在另一些情况下就有可能发生问题,例如对于同一个数据A和B依次查询就可能不同,A和B就可能打起来了……

    • 虚读(幻读)

      幻读发生在当两个完全相同的查询执行时,第二次查询所返回的结果集跟第一个查询不相同。例如事务1对账户表中所有的行的金额列做了从“200”修改为“100”的操作,这时事务2又对账户表插入了一行数据,而这行数据的金额列的数值是200并且提交给数据库。而操作事务1的用户如果再查看刚刚修改的数据,会发现还有一行没有修改,其实这行是从事务2中添加的,就好像产生幻觉一样,这就是发生了幻读。

    时间点 事务1 事务2
    t1 开始事务 开始事务
    t2

    更改账户表所有行,将金额从200改为100

    update account set money=100

     
    t3  

    插入账户表一个条数据,金额为200

    insert into account(name,money) values('C',200)

    t4   提交事务
    t5 查询发现有一行金额为200的数据(幻读)  


    幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一行。

    (2)写操作对写操作造成的影响  

    • 丢失更新

      丢失更新就是多个事务在某一时刻对同一数据进行更新,必定会产生被覆盖的数据。

      丢失更新产生的原因

    • 回滚丢失(lost update):A事务撤销时,把已经提交的B事务的更新数据覆盖了。从下面流程中看出A事务在撤销时,将B事务已经转入账户的金额给抹去了。 SQL92没有定义这种现象,标准定义的所有隔离界别都不允许第一类丢失更新发生。

    时间点 事务A 事务B
    t1 开启事务  
    t2 查询账户余额为1000  
    t3   汇入100元把余额改为1100元
    t4   提交事务
    t5 取出100元把余额改为900元  
    t6 撤销事务  
    t7 余额恢复为1000 元 (丢失更新)  
    • 覆盖丢失/两次更新问题(Second lost update): A事务覆盖B事务已经提交的数据,造成B事务所做操作丢失

    时间点 事务A 事务B
    t1 开启事务  
    t2 查询账户余额为1000 开启事务
    t3   查询账户余额为1000元
    t4   取出100元把余额改为900元
    t5   提交事务
    t6 汇入100元,把余额改为1100 元 (丢失更新)  
    t7 提交事务  

    3、隔离级别

    SQL 标准定义了四种隔离级别,隔离级别的实现核心是锁策略(Locking Strategy)以及MVCC机制,本文对锁和mvcc机制不做展开。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下

    隔离级别 解析 脏读 不可重复读 幻读
    读未提交(Read uncommitted)

    在READ UNCOMMITTED隔离级别下,因为读不会加任何锁,所以写操作可以在读的过程中修

    改数据,所以会造成脏读、不可重复读、幻读。好处是可以提升并发处理性能,能做到读写并行

    换句话说,读的操作不能排斥写请求。

    可能 可能 可能
    读已提交(read committed)

    一个事务的修改在他提交之前的所有修改,对其他事务都是不可见的。其他事务只能读到已提交

    的修改变化。在很多场景下这种逻辑是可以接受的。InnoDB在 READ COMMITTED使用排它锁,

    读取数据不加锁而是使用了MVCC机制。或者换句话说他采用了读写分离机制。但是该级别会产生

    不可重读以及幻读问题。

    不可能 可能 可能
    可重复读(repeatable read)  

    使用读写锁或者mvcc机制实现隔离效果。如果采用读写锁实现,只要没释放读锁,再次读的时候

    还是可以读到第一次读的数据,该方式的优点可避免脏读、不可重复读、实现起来简单,缺点是无

    法做到读写并行,存在幻读。如果采用采用MVCC实现,因为多次读取只生成一个版本,读到的自

    然是相同数据,优点是可避免脏读、不可重复读、读写并行,缺点是实现的复杂度高,实现的复杂

    度高,存在幻读。

    不可能 不可能 可能
    串行化(Serializable)   不可能 不可能 不可能

    注意InnoDB默认的隔离级别是RR,需要注意的是,在SQL标准中,RR是无法避免幻读问题的,但是InnoDB实现的RR避免了幻读问题。

    以上四种隔离级别最高的是Serializable级别,最低的是Read uncommitted级别,当然级别越高,执行效率就越低。像Serializable这样的级别,就是以锁表的方式使得其他的线程只能在锁外等待,所以平时选用何种隔离级别应该根据实际情况。在MySQL数据库中,支持上面四种隔离级别,默认的为Repeatable read (可重复读);而在Oracle数据库中,只支持Serializable (串行化)级别和Read committed (读已提交)这两种级别,其中默认的为Read committed级别。

    在MySQL数据库中查看当前事务的隔离级别:

    mysql> select @@transaction_isolation;
    +-------------------------+
    | @@transaction_isolation |
    +-------------------------+
    | READ-COMMITTED |
    +-------------------------+
    1 row in set

    mysql>

    或者

    mysql> show variables like 'transaction_isolation';
    +-----------------------+----------------+
    | Variable_name         | Value          |
    +-----------------------+----------------+
    | transaction_isolation | READ-COMMITTED |
    +-----------------------+----------------+
    1 row in set
    
    mysql> 

    在MySQL数据库中查看全局的隔离级别:

    mysql> select @@global.transaction_isolation;
    +--------------------------------+
    | @@global.transaction_isolation |
    +--------------------------------+
    | READ-COMMITTED                 |
    +--------------------------------+
    1 row in set

    在MySQL数据库中设置事务的隔离 级别:

    SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
    例如:
    mysql> set global transaction isolation level read committed; //全局的
    mysql> set session transaction isolation level read committed; //当前会话

    记住:设置数据库的隔离级别一定要是在开启事务之前!

    六、ACID特性-一致性

    1、概念

    一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)。

    2、实现

    一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。

    实现一致性的措施包括:

    • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
    • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
    • 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致

    七、事务在.NET中的应用

    在同一个数据库内进行CRUD时,应使用同一个DbConnection对象,且显式指定DbConnection均为同一个DbTransaction,示例代码如下:

    //在同一个DB中操作一个表时,可以不用显式指定事务,因为单条SQL命令就是一个最小的事务单元
            using (DbConnection conn = new SqlConnection("数据库连接字符串"))
            {
                var cmd = conn.CreateCommand();
                cmd.CommandText = "delete users";
                cmd.ExecuteNonQuery();
            }
     
     
            //在同一个DB中操作多个表或执行不同的SQL命令时,需要显式指定事务,且需确保每个Command均与同一个DbTransaction关联
            using (DbConnection conn = new SqlConnection("数据库连接字符串"))
            {
                DbTransaction tran = conn.BeginTransaction();
                try
                {
                    var cmd = conn.CreateCommand();
                    cmd.Transaction = tran;
                    cmd.CommandText = "delete users";
                    cmd.ExecuteNonQuery();
     
                    var cmd2 = conn.CreateCommand();
                    cmd2.Transaction = tran;
                    cmd2.CommandText = "delete roles";
                    cmd2.ExecuteNonQuery();
     
                    tran.Commit();
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                    tran.Rollback();
                }
            }
    View Code

    在同一个服务器上的不同数据库之间进行CRUD时,应使用同一个DbConnection对象,且显式指定DbConnection均为同一个DbTransaction,同时SQL命令语句中的包含的对象(表、视图、存储过程、函数等)应显式指定数据库名称,格式如:databasename.owner.tablename,如:Db1.dbo.Users;Db2.dbo.Users;(前提条件:多个数据库的用户名及密码相同的情况下,否则就只能使用分布式事务),示例代码如下:

    //在同一个Server不同的DB中操作多个表或执行不同的SQL命令时,需要显式指定事务,且需确保每个Command均与同一个DbTransaction关联,CommandText还应显式添加数据库名称
            using (DbConnection conn = new SqlConnection("数据库连接字符串"))
            {
                DbTransaction tran = conn.BeginTransaction();
                try
                {
                    var cmd = conn.CreateCommand();
                    cmd.Transaction = tran;
                    cmd.CommandText = "delete db1.dbo.users";
                    cmd.ExecuteNonQuery();
     
                    var cmd2 = conn.CreateCommand();
                    cmd2.Transaction = tran;
                    cmd2.CommandText = "delete db2.dbo.roles";
                    cmd2.ExecuteNonQuery();
     
                    tran.Commit();
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                    tran.Rollback();
                }
            }
    View Code

    在不同的DB服务器上进行CRUD时,应使用分布式事务,可以采取隐式或显式开启分布式事务,示例代码如下:

    //采用隐式开启分布式事务
            using (TransactionScope tranScope = new TransactionScope(TransactionScopeOption.RequiresNew))
            {
                using (DbConnection conn = new SqlConnection("数据库连接字符串"))
                {
                    conn.Open();
                    var cmd = conn.CreateCommand();
                    cmd.CommandText = "delete users";
                    cmd.ExecuteNonQuery();
                }
     
     
                using (DbConnection conn2 = new SqlConnection("数据库连接字符串2"))
                {
                    conn2.Open();
                    var cmd2 = conn2.CreateCommand();
                    cmd2.CommandText = "delete users";
                    cmd2.ExecuteNonQuery();
                }
                tranScope.Complete();
            }
     
            //采用显式开启分布式事务
            using (CommittableTransaction committableTransaction = new CommittableTransaction())
            {
                try
                {
                    using (DbConnection conn = new SqlConnection("数据库连接字符串"))
                    {
                        conn.Open();
                        conn.EnlistTransaction(committableTransaction); //将连接登记到可提交事务
                        var cmd = conn.CreateCommand();
                        cmd.CommandText = "delete users";
                        cmd.ExecuteNonQuery();
                    }
     
     
                    using (DbConnection conn2 = new SqlConnection("数据库连接字符串2"))
                    {
                        conn2.Open();
                        conn2.EnlistTransaction(committableTransaction); //将连接登记到可提交事务
                        var cmd2 = conn2.CreateCommand();
                        cmd2.CommandText = "delete users";
                        cmd2.ExecuteNonQuery();
                    }
     
                    committableTransaction.Commit();
                }
                catch (Exception ex)
                {
                    committableTransaction.Rollback(ex);
                }
            }
     
            //采用显式开启分布式事务,模拟TransactionScope用法的过程
            {
                Transaction originalTransaction = Transaction.Current; //记录当前的环境事务,用于后面的恢复
                CommittableTransaction committableTransaction = null;
                DependentTransaction dependentTransaction = null;
                committableTransaction = new CommittableTransaction();
                Transaction.Current = committableTransaction;//将定义的可提交事务作为当前的环境事务
     
                try
                {
     
                    using (DbConnection conn = new SqlConnection("数据库连接字符串"))
                    {
                        conn.Open();
                        var cmd = conn.CreateCommand();
                        cmd.CommandText = "delete users";
                        cmd.ExecuteNonQuery();
                    }
     
                    dependentTransaction = Transaction.Current.DependentClone(DependentCloneOption.RollbackIfNotComplete); //复制当前的环境事务从而产生新的依赖事务,且指定必需等到该事务完成
                    Transaction.Current = dependentTransaction;//将复制到的新的依赖事务
     
                    using (DbConnection conn2 = new SqlConnection("数据库连接字符串2"))
                    {
                        conn2.Open();
                        var cmd2 = conn2.CreateCommand();
                        cmd2.CommandText = "delete users";
                        cmd2.ExecuteNonQuery();
                    }
     
                    dependentTransaction.Complete();
                    committableTransaction.Commit();
                }
                catch (Exception ex)
                {
                    Transaction.Current.Rollback(ex);
                }
                finally //不论成功与否,最终都将恢复成原来的环境事务
                {
                    Transaction transaction = Transaction.Current;
                    Transaction.Current = originalTransaction;
                    transaction.Dispose();
                }
     
            }
    View Code

    最终总结一下:

    • 查询无需事务;
    • 涉及执行增、删、改的SQL命令时,应考虑是否需要确保执行数据的一致性,若需要则必需使用事务,否则可以采取默认方式;
    • 在同一个DB服务器中,尽可能的使用本地事务,跨多个DB服务器中,需要使用分布式事务;
    • 尽可能的缩小事务的使用范围,避免出现多层级的嵌套事务;
    • 若需要使用分布式事务,在WINDOWS下需要开启MS DTC服务(分布式事务管理器)

      

  • 相关阅读:
    HTML5和CSS3的学习视频
    webpack中bundler源码编写2
    webpack中bundler源码编写
    webpack中如何编写一个plugin
    webpack多页面打包配置
    webpack中配置eslint
    webpack解决单页面路由问题
    webpack中使用WebpackDevServer实现请求转发
    webpack中typeScript的打包配置
    rsync 同步
  • 原文地址:https://www.cnblogs.com/qtiger/p/13993111.html
Copyright © 2011-2022 走看看