MySQL锁详解

MySQL锁详解

update语句执行流程

MySQL的锁介绍

  • 按照锁的粒度来说,MySQL主要包含三种类型(级别)的锁定机制:

    • 全局锁:锁的是整个database。由MySQL的SQL layer层实现的

    • 表级锁:锁的是某个table。由MySQL的SQL layer层实现的

    • 行级锁:锁的是某行数据,也可能锁定行之间的间隙。由某些存储引擎实现,比如InnoDB

  • 按照锁的功能来分的话就分为:共享锁、排它锁

    • 共享锁 (S锁)

      • 兼容性:加了S锁的记录,允许其他事务再加S锁,不允许其他事务再加X锁

      • 加锁方式:select…lock in share mode

    • 排他锁 (X锁)

      • 兼容性:加了X锁的记录,不允许其他事务再加S锁或者X锁

      • 加锁方式:select…for update

全局锁

  • 全局锁就对整个数据库实例加锁,加锁后整个实例就处于只读状态 ,其余的事务提交语句都将被阻塞

  • 典型的使用场景是做全库的逻辑备份,对所有的表进行锁定,从而获取一致性视图,保证数据的完整性

    • 说到全局锁用于备份这个事情,还是很危险的

    • 因为如果在主库上加全局锁,则整个数据库将不能写入,备份期间影响业务运行

    • 如果在从库上加全局锁,则会导致不能执行主库同步过来的操作,造成主从延迟

  • 对于innodb这种支持事务的引擎,使用mysqldump备份时可以使用--single-transaction参数,利用mvcc提供一致性视图,而不使用全局锁,不会影响业务的正常运行。而对于用MyISAM这种不支持事务的表,就只能通过全局锁获得一致性视图,对应的mysqldump参数为--lock-all-tables

  • 加上全局锁:flush tables with read lock;

  • 释放全局锁:unlock tables;

    • 或者断开加锁session的连接,自动释放全局锁。

表级锁

  • MySQL的表级锁有四种

    • 表读、写锁

    • 元数据锁

    • 意向锁

    • 自增锁

表的读、写锁

  • 创建表 test1,并插入测试数据为下面做演示:

    • CREATE TABLE test1 (
          id int(11) NOT NULL AUTO_INCREMENT,
          NAME varchar(20) DEFAULT NULL,
          PRIMARY KEY (id)
      );
      ​
      INSERT INTO test1 (id,NAME) VALUES (1, 'a');
      INSERT INTO test1 (id,NAME) VALUES (2, 'b');
      INSERT INTO test1 (id,NAME) VALUES (3, 'c');
      INSERT INTO test1 (id,NAME) VALUES (4, 'd');
  • 表级锁定的争用状态查询:show status like 'table%';

  • 表锁有两种表现形式

    • 表共享读锁(Table Read Lock)

    • 表独占写锁(Table Write Lock)

  • 手动增加表锁

    • lock table 表名称 read(write),表名称2 read(write),其他;

  • 查看表锁情况

    • show open tables;

  • 删除表锁

    • unlock tables;

表共享读锁

Session ASession B
lock table test1 read; 【ok】  
select * from test1;【ok】 select * from test1;【ok】
select * from test;【error】 select * from test;【ok】
insert into test1(name) values('e');【error】 insert into test1(name) values('e');【wait】
  • Session A 给表test1上了表共享锁

    • Session A这个回话只能读取test1表,不能读取其他的表

    • Session A 不能对 test1表进行修改插入删除等操作

  • Session B 能读取被SessionA上了共享锁的test1表

    • SessionB 能读取其他的表

    • SessionB 对test1表的写操作会阻塞,直到Session A释放锁才执行

表独占写锁

Session ASession B
lock table test1 write; 【ok】  
select * from test1;【ok】 select * from test1;【wait】
select * from test;【error】 select * from test;【ok】
insert into test1(name) values('e');【ok】 insert into test1(name) values('e');【wait】
  • Session A 给表test1上了表独占锁

    • Session A这个回话只能读取test1表,不能读取其他的表

    • Session A 能对 test1表进行修改插入删除等操作

  • Session B 关于test1表的任何操作都会陷入阻塞状态,等到Session A释放锁才执行

元数据锁(MDL)

在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁 ,MDL的作用是,保证读写的正确性

MDL不需要显式使用,在访问一个表的时候会被自动加上,你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查

  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性

    • 如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行

意向锁

  • InnoDB也实现了表级锁,也就是意向锁,意向锁是mysql内部使用的,不需要用户干预

  • 意向锁和行锁可以共存

  • 意向锁的主要作用是为了【全表更新数据】时的性能提升

    • 否则在全表更新数据时,需要先检索该表是否某些记录上面有行锁

  • 比如:事务A修改user表的记录1,会给记录1上一把行级的排他锁(X),同时会给user表上一把意向排他锁(IX)

  • 这时事务B要给user表上一个表级的排他锁就会被阻塞

  • 意向锁通过这种方式实现了行锁和表锁共存且满足事务隔离性的要求

  • 意向锁的分类以及作用

    • 意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁

    • 意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁

  • 当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁)

    • 如果意向锁是行锁,则需要遍历每一行数据去确认

    • 如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能

意向锁、共享锁、排他锁的兼容关系

  • IS:意向共享锁

  • IX:意向排他锁

  • S:共享读锁

  • X:排他写锁

  • 意向锁相互兼容,因为IX、IS只是表明申请更低层次级别元素(比如 page、记录)的X、S操作

  • 因为上了表级S锁后,不允许其他事务再加X锁,所以表级S锁和X、IX锁不兼容

  • 上了表级X锁后,会修改数据,所以表级X锁和 IS、IX、S、X(即使是行排他锁,因为表级锁定的行肯定包括行级速订的行,所以表级X和IX、行级X)不兼容

  • 上了行级X锁后,行级X锁不会因为有别的事务上了IX而堵塞,一个mysql是允许多个行级X锁同时存在的,只要他们不是针对相同的数据行

自增锁

AUTO-INC锁是一种特殊的表级锁,发生涉及AUTO_INCREMENT列的事务性插入操作时产生

行级锁

先建表,准备测试数据再说

CREATE TABLE `test2` (
    `id` INT ( 11 ) NOT NULL,
    `pubtime` INT ( 11 ) NULL DEFAULT NULL,
    PRIMARY KEY ( `id` ) USING BTREE,
INDEX `idx_pu` ( `pubtime` ) USING BTREE 
) ENGINE = INNODB;
​
INSERT INTO `test2` VALUES (1, 10);
INSERT INTO `test2` VALUES (4, 3);
INSERT INTO `test2` VALUES (6, 100);
INSERT INTO `test2` VALUES (8, 5);
INSERT INTO `test2` VALUES (10, 1);
INSERT INTO `test2` VALUES (100, 20);

行级锁介绍

MySQL的行级锁,是由存储引擎来实现的,这里我们主要讲解InnoDB的行级锁

InnoDB行锁是通过给索引上的索引项加锁来实现的,因此InnoDB这种行锁实现特点意味着:只有通过 索引条件检索的数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

  • InnoDB的行级锁,按照锁定范围来说,分为四种 :

    • 记录锁:锁定索引中一条记录

    • 间隙锁:要么锁住索引记录中间的值,要么锁住第一个索引记录前面的值或者最后一个索引记录后面的值

    • 临键锁:是索引记录上的记录锁和在索引记录之前的间隙锁的组合(间隙锁+记录锁)

    • 插入意向锁:做insert操作时添加的对记录id的锁

  • InnoDB的行级锁,按照功能来说,分为两种:

    • 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁

    • 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁

  • 对于update、delete和insert语句,InnoDB会自动给涉及数据集加排他锁(X)

  • 对于普通select语句,InnoDB不会加任何锁

事务可以通过以下语句显示给记录集加共享锁或排他锁

  • 手动添加共享锁(S)

    • select * reom table_name where ... lock in share mode

  • 手动添加排他锁(x)

    • select * reom table_name where... for update

记录锁

记录锁, 仅仅锁住索引记录的一行,在单条索引记录上加锁

记录锁锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的

  • 加记录共享锁

    • select * from test2 where id = 1 lock in share mode;

  • 加记录排他锁

    • select * from test2 where id = 1 for update;

间隙锁

  • 区间锁, 仅仅锁住一个索引区间(开区间,不包括双端端点)

  • 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录本身

  • 间隙锁可用于防止幻读,保证索引间的不会被插入数据

比如:

session 1:

  • select * from test2 where id > 4 for update;

    • 这个sql,会对命中的数据上记录排他锁,此时只要id>4的记录行都被包括在内

session 2:

  • insert into test2 values (7,100); 【error】

    • 因为 7 > 4 ,被上面的记录排他锁囊括了,所以插入失败

  • insert into test2 values (3,100); 【ok】

    • 因为 3< 4,所以不在上面间隙排他所得范围内,所以插入成功

临键锁

  • 遵守左开右闭区间 ,比如(2,6]

  • 默认情况下,innodb使用next-key locks来锁定记录

    • select … for update

  • 但当查询的索引含有唯一属性的时候,Next-Key Lock 会进行优化

    • 将其降级为Record Lock,即仅锁住索引本身,不是范围

临键锁在不同的场景中会退化

具体效果我们使用我们的测试表来测试一下,现在我们的表数据如下所示

  • 会话A:

    • select * from test2 where pubtime = 20 for update;

      • 间隙锁区间:(10,20 ],(20,100 ]

  • 会话B:

    • insert into test2 values (16, 19); 【阻塞】19位于(10-20 ] 区间

    • select * from test2 where pubtime = 20 for update; 【阻塞】20位于(10-20 ] 区间

    • insert into test2 values (16, 50); 【阻塞】50位于(20-100 ] 区间

    • insert into test2 values (16, 101); 【成功】 101不在临键锁区间

行锁加锁规则

  • 主键索引

    • 等值查询

      • 命中记录,加记录锁

      • 未命中记录,加间隙锁

    • 范围查询

      • 没有命中任何一条记录时,加间隙锁

      • 命中1条或者多条,包含where条件的临键区间,加临键锁

  • 辅助索引

    • 等值查询

      • 命中记录,命中记录的辅助索引项+主键索引项加记录锁,辅助索引项两侧加间隙锁

      • 未命中记录,加间隙锁

    • 范围查询

      • 没有命中任何一条记录时,加间隙锁

      • 命中1条或者多条,包含where条件的临键区间加临键锁。命中记录的id索引项加记录锁

插入意向锁

  • 插入意向锁是一种间隙锁,不是意向锁,在insert操作时产生

  • 在多事务同时写入不同数据至同一索引间隙的时候,并不需要等待其他事务完成,不会发生锁等待

  • 假设有一个记录索引包含键值4和7,不同的事务分别插入5和6,每个事务都会产生一个加在4-7之间的插入意向锁,获取在插入行上的排它锁,但是不会被互相锁住,因为数据行并不冲突

  • 插入意向锁不会阻止任何锁,对于插入的记录会持有一个记录锁

锁相关参数

Innodb所使用的行级锁定争用状态查看

  • show status like 'innodb_row_lock%';

在上面五个参数中,我们需要留意的只有这三个:(上面已经标注)

  • 等待总时长

  • 等待平均时长

  • 等待总次数

尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手指定优化计划

查看事务、锁的sql

  • select * from information_schema.innodb_locks;
    select * from information_schema.innodb_lock_waits;
    select * from information_schema.innodb_trx  

行锁原理分析

下面我们通过具有代表性的sql来详细分析MySQL的加锁处理

select * from test where id = 10;
delete   from test where id = 10;

问:上面的两条简单的sql加的什么锁

  • 第一条查询语句:不加锁,因为MySQL是使用多版本并发控制的,读不加锁

  • 第二天删除语句:对id = 10的记录加写锁 (走主键索引)

实际上呢,真的准确嘛 ???

对于上面的两条sql的加锁解释有可能是正确的,也有可能是错误的,因为已知条件不足,不足以判断对错

  • id列是不是主键?

  • 当前系统的隔离级别是什么?

  • id列如果不是主键,那么id列上有索引吗?

  • id列上如果有二级索引,那么这个索引是唯一索引吗?

  • 两个SQL的执行计划是什么?索引扫描?全表扫描?

下面的这些组合,需要做一个前提假设,也就是有索引时,执行计划一定会选择使用索引进行过滤 (索引扫描)。但实际情况会复杂很多,真正的执行计划,还是需要根据MySQL输出的为准

  1. id列是主键,RC隔离级别

  2. id列是二级唯一索引,RC隔离级别

  3. id列是二级非唯一索引,RC隔离级别

  4. id列上没有索引,RC隔离级别

  5. id列是主键,RR隔离级别

  6. id列是二级唯一索引,RR隔离级别

  7. id列是二级非唯一索引,RR隔离级别

  8. id列上没有索引,RR隔离级别

  9. Serializable隔离级别

......

我们就以上面列举出来的这些组合来做一个说明

  • 在前面八种组合下,也就是RC读已提交,RR可重复读隔离级别下

    • sql1:select操作均不加锁,采用的是快照读,因此在下面的讨论中就忽略了,

    • 主要讨论sql2:delete操作的加锁

组合一:id列是主键,RC隔离级别

  • 这个组合,是最简单,最容易分析的组合

  • id是主键,Read Committed隔离级别,给定SQL:delete from test where id = 10;

  • 只需要将主键上id = 10的记录加上X锁即可

组合二:id唯一索引 + RC隔离级别

  • id不是主键,而是一个Unique的二级索引键值,在 RC隔离级别下

  • 由于id是unique索引,因此delete语句会选择走id列的索引进行where条件的过滤

  • 在找到id=10的记录后,首先会将unique索引上的id=10索引记录加上X锁

  • 同时,会根据读取到的主键数据,回表(聚簇索引),然后将聚簇索引上的主键 = 主键值对应的主键索引项加X锁

  • 为什么聚簇索引上的记录也要加锁?

    • 如果并发的一个SQL,是通过主键索引来更新:update test set id = 100 where 主键= 'xx';

    • 如果刚刚的delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,

    • 违背了同一记录上的更新/删除需要串行执行的约束

组合三:id非唯一索引+RC

  • 现在id只有一个普通的索引。假设仍旧选择id列上的索引进行过滤where条件

  • 首先因为id不是唯一索引,所以索引树种极有可能存在多个id = 10的索引节点

  • 凡是id = 10的索引全部都会加 X锁,这个无需质疑

  • 和组合二一样的道理,会根据这些id = 10 的索引树节点,得到主键数据

  • 然后回表将聚簇索引上的主键 = 主键值对应的主键索引项加X锁,至于为什么?和组合二一致

组合四:id无索引+RC

  • id列上没有索引,where id = 10;这个过滤条件,没法通过索引进行过滤,那么只能走全表扫描做过滤

  • 对应于这个组合,SQL会加什么锁?或者是换句话说,全表扫描时,会加什么锁?

    • 由于id列上没有索引,因此只能走聚簇索引,进行全部扫描

    • 聚簇索引上所有的记录,都会被加上了X锁。无论记录是否满足条件,全部被加上X锁

    • 若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束

组合五:id主键+RR

  • 在上面的几个列子中,我们都是在读已提的环境下,下面我们来到可重复读的隔离级别下看看会有什么不同呢

  • id列是主键列,Repeatable Read隔离级别,

    • 针对delete from test where id = 10; 这条SQL,加锁与组合一:[id主键,Read Committed]一致

组合六:id唯一索引+RR

  • 与组合二:[id唯一索引,Read Committed]一致。id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个

组合七:id非唯一索引+RR

  • RC隔离级别允许幻读,而RR隔离级别,不允许存在幻读。但是在组合五、组合六中,加锁行为又是与RC下的加锁行为完全一致,那么RR隔离级别下,如何防止幻读呢?

    • delete from test where id = 10;

    • 假设选择id列上的索引进行条件过滤

      • 相对于组合三,很大程度的相同,但是却又很大的区别:多了一个GAP锁

      • GAP锁就是RR隔离级别,相对于RC隔离级别,不会出现幻读的关键

      • GAP锁 的作用:

        • 如何保证两次当前读返回一致的记录,那就需要在第一次当前读与第二次当前读之间,其他的事务不会 插入新的满足条件的记录并提交 ,为了实现这个功能,GAP锁应运而生

      • 为了防止幻读,如图所示

        • 三根竖黄线,又名GAP,想要保证这个隔离区不会出现幻读情况,MySQL选择了用GAP锁,将这三个GAP给锁起来

        • 如果我们想插入一条[10,*]的数据到树中,考虑到B+树索引的有序性,满足条件的项一定是连续存放的

        • 在记录 [5,E]之前肯定是不会存在[10,*]这样的数据的

        • 但在[5,E] 和 [10,G]之间是可以插入[10,*]这样的数据的

        • [10,G] 与 [10,J] 之间也是可以插入[10,*]这样的数据的

        • [10,J] 与 [11,L] 之间也是可以插入[10,*]这样的数据的

        • 因此,为了保证[5,E] 和 [10,G]间,[10,G] 与 [10,J] 间,[10,J] 与 [11,L]不会插入新的满足条件的记录

        • MySQL选择了用GAP锁,将这三个GAP给锁起来

      • Insert操作,如insert [10,A],首先会定位到[5,E]与[10,G]间,然后在插入前,会检查这个GAP是否已经被锁上,如果被锁上,则Insert不能插入记录

      • 因此,通过第一遍的当前读,不仅将满足条件的记录锁上 (X锁),与组合三类似

      • 同时还是增加3把GAP锁,将可能插入满足条件记录的3个GAP给锁上,保证后续的Insert不能插入新的id=10的记录 ,也就杜绝了同一事务的第二次当前读,出现幻象的情况

    • 出现问题:既然防止幻读,需要靠GAP锁的保护,为什么组合五、组合六,也是RR隔离级别,却不需要加GAP锁呢?

      • GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况

      • 而组合五,id是主键;组合六,id是unique键,都能够保证唯一性

      • 一个等值查询,最多只能返回一条记录,而且新的相同取值的记录,一定不会在新插入进来,因此也就避免了GAP锁的使用

    • 所以整个组合七的结论是:通过id索引定位到第一条满足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,然后加主键聚簇索引上的记录X锁,然后返回;然后读取下一条,重复进行

组合八:id无索引+RR

  • 此时sql没有其他的路径可以选择,只能进行全表扫描。最终的加锁情况

  • 其他任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死

  • MySQL最大化的优化就是:组合四类似

    • 对于不满足查询条件的记录,MySQL会提前放锁

组合九:Serializable

  • 对于delete from test where id = 10而言:与Repeatable Read隔离级别效果完全一致

  • 对于select * from test where id = 10而言

    • 在RC,RR隔离级别下,都是快照读,不加锁

    • 在Serializable隔离级别,SQL1会加读锁,也就是说快照读不复存在

一条复杂SQL的加锁分析

  • test3(iduseridblogidpubtimecomment

  • index_pu:(pubtime,userid)

  • primary key: ( id )

给出下面一个SQL,我们一起来分析分析(RR隔离级别 )

  • delete from test3 where pubtime > 10 and pubtime <= 30 and userid = '111' and comment is not null;

在详细分析这条SQL的加锁情况前,SQL中的where条件如何拆分?在这里,我直接给出分析后的结果

  • Index Key : pubtime > 10 and pubtime < 30

    • 此条件,用于确定SQL在组合索引上的查询范围

  • Index Filter:userid = '111'

    • 此条件,可以在组合索引 ndex_pu 上进行过滤,但不属于Index Key

  • Table Filter:comment is not null

    • 此条件,在组合索引 index_pu 上无法过滤,只能在聚簇索引上过滤

  • 从图中可以看出,在Repeatable Read隔离级别下,由Index Key所确定的范围,被加上了GAP锁;

  • Index Filter锁给定的条件 (userid = ‘111’)何时过滤,视MySQL的版本而定

    • 在MySQL 5.6版本之前,不支持Index Condition Pushdown(ICP),因此Index Filter在MySQL Server层过滤

      • 若不支持ICP,不满足Index Filter的记录,也需要加上记录X锁

    • 在5.6后支持了Index Condition Pushdown,则在index上过滤

      • 若支持ICP,则不满足Index Filter的记录,无需加记录X锁

  • 而Table Filter对应的过滤条件,则在聚簇索引中读取后,在MySQLServer层面过滤

    • 因此聚簇索引上也需要X锁

  • 最后,选取出了一条满足条件的记录[8,111,3,30,Hello React],但是加锁的数量,要远远大于满足条件的记录数量

在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件

  • Index Key确定的范围,需要加上GAP锁;

  • Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记录,不加X锁,否则需要X锁

  • Table Filter过滤条件,无论是否满足,都需要加X锁

死锁原理分析

  • 死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象

两个Session的两条SQL产生死锁

  • Session A

    • 1、访问test表,查询并将 id= 1的数据给锁住了

    • 3、然后企图设置test表中 id = 3的数据,发现该数据被Seesion B锁住,等待ing

  • Session B

    • 2、访问test表,查询并将 id= 3的数据给锁住了

    • 4、然后企图设置test表中 id = 1的数据,发现该数据被Seesion A锁住,等待ing

两个Session的一条SQL产生死锁

  • 两个回话都只有一条SQL,仍旧会产生死锁

  • Session A 从 name索引树中依次拿到 两个Tom索引的主键ID,依次回聚簇索引树上加锁

    • 读到 【Tom,4】、【Tom,6】,该name索引上记录X锁,而且会在聚簇索引上加记录X锁

  • Session B 从 address索引树中依次拿到 两个索引的主键ID,依次回聚簇索引树上加锁

    • 读到 【bbb,6】、【ccc,4】,该name索引上记录X锁,而且会在聚簇索引上加记录X锁

  • 两个会话在聚簇索引上的加锁顺序图中我已经表明,此时死锁发生

如何避免死锁

  • MySQL默认会主动探知死锁,并回滚某一个影响最小的事务。等另一事务执行完成之后,再重新执行该事务

  • 保持事务的轻量

  • 避免使用子查询,尽量使用主键等等

  • 尽量快提交事务,减少持有锁的时间

.

 

 

posted @ 2021-03-25 12:28  鞋破露脚尖儿  阅读(581)  评论(0编辑  收藏  举报