ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL存储引擎详解:InnoDB索引、事务、锁与性能调优实践

MySQL存储引擎详解:InnoDB索引、事务、锁与性能调优实践 做MySQL的人早晚会在某一天突然发现自己以为在写SQL其实是在跟存储引擎打交道。我跟组里的新人常说你优化的十条SQL里有八条最后的瓶颈都不在SQL本身而在表背后的引擎做了什么、没做什么。MySQL的存储引擎是一个绕不开的话题。只要你在生产环境碰过慢查询、死锁、锁等待、主从延迟大概率最后都会追到引擎头上。这篇文章我打算把MySQL存储引擎的来龙去脉、InnoDB内部的工作机制、以及实际调优和排障经验完整讲一遍。不管你是准备面试、正在用MySQL做业务开发还是遇到线上问题不知道怎么排查应该都能从这里找到参考。1. 先把存储引擎这件事说清楚1.1 MySQL特殊在哪里插件式存储引擎MySQL和大多数数据库不一样的地方在于它把数据存储这块做成了可插拔的架构。SQL语句进来经过解析器、优化器、执行器之后最终要落到底层去读写数据文件、索引文件、处理事务和锁这些活全交给存储引擎来做。你可以通过SHOW ENGINES看当前实例支持哪些引擎。我在生产环境里见到过的主要是这几个引擎事务锁粒度崩溃恢复典型使用场景InnoDB支持行锁强绝大多数业务表MyISAM不支持表锁弱已逐渐淘汰的边缘表MEMORY不支持表锁无重启清空临时表、缓存表ARCHIVE不支持行锁插入一般日志归档CSV不支持表锁弱外部CSV数据交换插件式架构带来的好处是你可以针对不同表选择不同引擎但实际上生产环境中绝大多数人会老老实实全部用InnoDB。原因后面细说先理解一个观点存储引擎决定了你的表能干什么、不能干什么。比如ALTER TABLE的时候如果你用的是MyISAM那整表锁住如果你用的是InnoDB至少还有行级锁和事务兜底。面试的时候经常有人把MySQL的架构背得滚瓜烂熟但真问到一条UPDATE语句在InnoDB里到底发生了什么事情就卡住了。核心原因就是对引擎层的执行过程不够熟。这也是我写这篇文章的动机之一。1.2 存储引擎决定着一张表的性格每张表在磁盘上怎么存、有没有事务、锁到什么粒度、崩溃之后能不能恢复这些都是引擎决定的。你可以把引擎理解成一张表的性格有的表比较莽锁整个表写起来一条一条排队有的表心思细锁行并发写还能撑住还有的表干脆不记事掉电就丢数据。在MySQL 8.0之前InnoDB表的数据结构存在.frm文件里数据和索引存在.ibd文件里系统公共信息在ibdata1里。MyISAM表则有.MYD数据和.MYI索引。从8.0开始表结构元数据统一收归数据字典不再有.frm文件。这些区别平时你可能感觉不到但做物理备份、迁移、归档的时候就会遇到。想快速看一张表用的是哪个引擎最常用的是SHOW TABLE STATUS FROM your_database LIKE your_table\G或者查系统库SELECT table_name, engine, table_rows, data_length FROM information_schema.TABLES WHERE table_schema your_database;table_rows是估算值别拿它当真但engine字段是准的。很多时候线上出现莫名的慢第一件事就是看表引擎很多问题一眼就能定位比如明明是查询很频繁的表结果建成了MyISAM连接一多直接锁表堵死。2. InnoDB为什么能一路走到默认宝座2.1 InnoDB和MyISAM的核心差异MySQL 5.5之前MyISAM是默认引擎5.5之后默认引擎改成InnoDB这个转变不是偶然的。你自己对比一次就知道了MyISAM的特性是简单、快、脆。查询确实快扫描小表的时候尤其明显但写入的时候是表级锁一条INSERT卡住后面所有读写全部排队。更麻烦的是崩溃后恢复能力很差我曾经遇到过一台服务器异常掉电MyISAM表直接标记为 crashed必须REPAIR TABLE才能恢复运气不好连数据都对不齐。InnoDB就不一样。它支持事务支持行级锁有redo log和undo log做崩溃恢复双击换页有doublewrite缓冲防止半页写坏。这些能力正好补上了MyISAM的致命短板。代价是同样的数据量InnoDB占的磁盘空间更大内存消耗也更大。但如果让我选我宁愿多花点磁盘也不愿意整天提心吊胆地处理表损坏。还有一个容易忽略的点全文索引。以前很多人为了用全文索引特意把表建MyISAM。但从MySQL 5.7开始InnoDB也支持全文索引了这个理由也不成立了。2.2 从MyISAM切到InnoDB我踩过的坑前两年帮一个老系统做技术改造里面有几十张历史表还是MyISAM因为业务倒不是很核心一直没人动。后来报表任务一上线几张表每秒钟几十次INSERT表锁把整个系统拖死SQL堆积一片。我们决定把表从MyISAM改成InnoDB执行命令很简单ALTER TABLE your_table ENGINE InnoDB;但真正做的时候踩了几个坑。第一大表ALTER TABLE不能随便在生产直接跑。即使InnoDB的在线DDL比MyISAM时代强很多改引擎这个操作在大多数情况下还是要重建表数据量大时会额外占用空间而且有锁表风险。我的做法是先在备库上执行观察完成时间然后主从切换再在原主库上执行最后切回来。如果表实在太大就要考虑用pt-online-schema-change或者gh-ost这类工具平滑变更。第二切换之后要关注主从延迟。引擎变更本身会产生大量binlog从库追数据会追一段时间。如果业务对延迟敏感最好选低峰期操作。第三改完之后一定要重新统计信息。ANALYZE TABLE一下否则优化器可能用错索引。从那次之后我自己的原则是新表一律InnoDB老表除非有非常明确的理由否则也尽快统一到InnoDB。3. InnoDB的底层机制索引、事务和日志3.1 B树索引与聚簇主键为什么主键设计很关键InnoDB的索引底层是B树。为什么不用哈希表、不用二叉树因为数据库数据的典型操作是范围查询、排序、前缀匹配B树是一种多路平衡查找树叶子节点之间有指针串联既支持点查又支持范围扫描而且树的高度很低。InnoDB的聚簇索引和二级索引是必须理解的核心概念聚簇索引主键索引表中每行数据直接存在主键索引的叶子节点上。也就是说找到了主键就等于找到了整行数据。所以InnoDB表其实就是一个大索引。二级索引非主键索引叶子节点存的是主键值而不是整行数据。你用二级索引查数据的时候先通过二级索引找到主键值再回聚簇索引去取整行这个过程叫回表bookmark lookup。聚簇索引的设计导致一个结果主键的选择极其重要。如果主键是自增整数新插入的行总是追加到B树末尾顺序写性能好页分裂也少。如果主键是UUID或者随机字符串插入时数据位置是随机的B树中间节点频频分裂磁盘碎片增多而且二级索引的叶子节点里存了这个又长又随机的字符串主键索引体积也会变大。我在指导团队设计表结构时说最多的一句话就是别为了省一个自增ID字段就搞业务主键除非你有极强的业务幂等诉求。尤其在订单、交易这类高频写入的表中一个简单的bigint自增主键能省下大量麻烦。为什么说三层B树能支撑千万级数据来简单算一下。InnoDB默认页大小是16KB。非叶子节点里每条目录项大概占十几字节一个页大约能存1000多条目录项。叶子节点里如果每行数据按1KB算一个页能存16行。顶层是根第二层有1000多个页第三层就是1000100016差不多1600万行。所以对大多数业务来讲三层B树足够了。你又可以理解为大部分查询只要做两三次磁盘IO就够了。3.2 MVCC与事务隔离级别快照读是怎么工作的面试聊InnoDB必问事务和MVCC。MVCC全称是Multi-Version Concurrency Control多版本并发控制。它解决的问题是读操作和写操作之间不用互相阻塞读的人读自己该读的版本写的人写自己的新版本。关键机制有三块隐藏列、undo log、ReadView。InnoDB每一行数据里隐藏了两个重要的列一个是最近修改该行的事务IDDB_TRX_ID一个是指向undo log版本的指针DB_ROLL_PTR。当一行数据被事务A修改时旧版本会记录到undo log中这一行的隐藏指针指向那个旧版本当前行数据本身被改成事务A的新版本。ReadView就是某个时刻系统活跃事务的一个快照视图。当你想查询一行数据时InnoDB会判断当前这个版本对你是否可见如果这行的DB_TRX_ID比生成ReadView时最小活跃事务ID还小说明这个版本在本次查询前已经提交可见。如果DB_TRX_ID在活跃事务列表里说明这个版本还没提交不可见需要沿着undo log找更早的版本。如果DB_TRX_ID比ReadView创建时的最大事务ID还大说明是这个查询之后才开始的不可见。隔离级别的区别在于ReadView的生成时机。READ COMMITTED下事务里每次SELECT都会生成一个新的ReadView所以你能读到其他事务已经提交的最新数据。REPEATABLE READ下事务里第一次SELECT生成ReadView之后就一直复用所以整个事务看到的数据快照一致。这也是MySQL默认用REPEATABLE READ的原因之一它通过MVCC已经实现了事务内多次查询结果一致而且还能解决部分幻读问题。需要注意的是MVCC只对普通读快照读有效。如果你用的是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE或者执行UPDATE、DELETE这些属于当前读直接读数据当前最新版本并且会加锁。3.3 一条UPDATE语句背后redo log、buffer pool与崩溃恢复并发、事务、索引都有了我再把一条UPDATE语句在InnoDB里的完整路径走一遍理解了这条链路后面调优你就有感觉了。假设现在执行UPDATE t SET name new WHERE id 10;第一步通过主键索引定位到id10这条记录所在的数据页。如果这个页面不在buffer pool缓冲池里就从磁盘把它读进来。第二步在内存中把这条记录改掉。第三步同时把旧版本写入undo log方便回滚和MVCC。第四步为了不把每次修改都立刻刷盘那样太慢InnoDB会把这次修改操作记录到redo log buffer里在事务提交的时候把redo log写入磁盘。第五步事务返回成功。至于数据页什么时候从buffer pool刷到磁盘那是后台线程慢慢干的事。这里最精华的设计就是WALWrite-Ahead Logging。它保证的是只要redo log成功落盘即使数据页还没刷盘事务也算提交了。万一数据库突然崩溃启动恢复时InnoDB会重放redo log把没来得及刷盘的数据页恢复过来。所以磁盘和内存之间这笔账InnoDB算得很清楚你先把操作日志写稳数据本身可以慢慢来。崩溃恢复能力与性能之间的取舍最直接反映在innodb_flush_log_at_trx_commit这个参数上参数值提交时行为崩溃风险性能特点1每次提交都刷盘几乎不丢数据最慢但最安全2每次提交写操作系统缓存不立即刷盘机器掉电可能丢1秒数据较快0每秒统一刷盘一次可能丢最多1秒数据最快生产环境下数据重要性永远是第一位的我的建议是老老实实保持1。如果确实对性能有高要求且能容忍1秒级丢失可以折中设成2但不要为了性能直接设0还觉得自己赚了。4. 锁、死锁和事务冲突面试必考的重灾区4.1 行锁、表锁、间隙锁的真实关系InnoDB的锁其实是加在索引记录上的。这是很多人的认知盲区。它不是一个给这一行数据加锁那么简单而是通过索引项加锁。这也带来一个推论如果WHERE条件里的列没有索引InnoDB只能扫描全部聚簇索引记录然后把扫过的每一条都加锁最终效果等同于全表锁。我见过一个生产事故就是这样一张几百万行的流水表where条件里用的字段没有索引执行UPDATE时恰好撞上大促高峰把所有行都锁住后面的插入全部堵死连接池瞬间耗尽。后来加了一个索引同样的SQL毫秒级完成。InnoDB的锁类型日常关心的主要是三种记录锁Record Lock锁住一条索引记录这是最基本的行锁。间隙锁Gap Lock锁住两个索引记录之间的间隙防止其他事务在这个间隙插入记录。临键锁Next-Key Lock记录锁间隙锁的组合锁住记录以及它前面的一段区间这也是默认REPEATABLE READ级别下防止幻读的主要手段。幻读指的是事务里同一个查询执行两次第二次多出来了一行本来不存在的记录。REPEATABLE READ下快照读已经看不到新插入的行但当前读和写操作如果没有间隙锁还是会出问题因为另一个事务可能在你读的区间里插入新数据。间隙锁的作用就是封锁这个区间让插入根本进不来。唯一索引等值查询命中已有记录时只需要记录锁不需要加间隙锁。但如果是普通索引或者范围查询别以为InnoDB只锁你命中的那几条它可能连周围的空位一起锁住。4.2 死锁日志怎么读一次线上排查实录先明确一个概念死锁和锁等待超时是两回事。死锁是事务A持有锁1想等锁2事务B持有锁2想等锁1两边互相等InnoDB检测到之后会立刻回滚一个小事务。锁等待超时则是在innodb_lock_wait_timeout默认50秒还没等到锁直接报错1205 Lock wait timeout exceeded。死锁是InnoDB的常态不用怕关键是能看懂日志并找到根因。排查死锁第一步执行SHOW ENGINE INNODB STATUS\G在输出中间位置找LATEST DETECTED DEADLOCK部分。你会看到类似下面的东西*** (1) TRANSACTION: TRANSACTION 2001, ACTIVE 0 sec LOCK WAIT ... *** (1) HOLDS THE LOCK(S): ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: ... *** (2) TRANSACTION: ... *** (2) HOLDS THE LOCK(S): ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... *** WE ROLL BACK TRANSACTION (2)日志告诉你两件事每个事务持有哪些锁、正在等待哪些锁。我处理死锁的套路是这样的先看两个事务的SQL涉及哪些表和索引。最常见的情况是两个事务以不同的顺序更新了同一批记录。比如事务A先更新id1再更新id2事务B先更新id2再更新id1。并发时正好各自锁了一条然后互相等另一条死锁就来了。解决方法也简单统一更新顺序让所有事务都按id从小到大去操作同一批数据。另一个常见场景是一个事务先做范围查询再插入另一个事务也在同一范围插入间隙锁互相冲突。这种就要检查查询条件是否能用唯一索引命中固定记录或者把隔离级别改成READ COMMITTED虽然能减少间隙锁但你要想清楚业务上能不能接受。线上真出现死锁时不要恐慌。InnoDB会自动回滚其中一个事务应用层只要做好异常捕获和重试就行。真正要做的是复盘持续出现的死锁场景而不是试图把死锁数量清零。4.3 等待超时和事务残留的排查流程相比死锁我更怕的是锁等待超时。因为它通常代表线上有一个事务长时间不提交把一堆锁攥在手里别人全在外头排队。碰到Lock wait timeout exceeded我按这个顺序查-- 查看当前有哪些事务在运行 SELECT * FROM information_schema.INNODB_TRX\G -- 查看锁等待关系MySQL 8.0有performance_schema.data_locks SELECT * FROM performance_schema.data_locks\G -- 查看正在执行的线程 SHOW FULL PROCESSLIST;先看INNODB_TRX里有没有长时间不结束的事务trx_started字段显示事务开始时间。如果发现一个事务已经挂了十几分钟而且trx_state是RUNNING、trx_query是NULL那基本可以断定是应用层开了事务没提交。定位到之后杀掉-- 通过trx_mysql_thread_id找到连接ID然后KILL这个连接 KILL connection_id;更多的时候根因在应用代码里。比如有的框架在开启事务后还没commit就发生了异常异常处理又不干净连接池把连接交还给池子时事务还是打开的。这种连接看着空闲实际上锁一直没释放。所以排查事务超时别光盯着数据库顺便查一下应用的事务边界和连接池配置。5. 存储引擎选型和性能调优实操5.1 查看与修改表引擎的正确姿势查询引擎前面已经提过这里我补充一下在业务里真正用得上的姿势。建新表时可以显式指定引擎CREATE TABLE user_info ( id BIGINT NOT NULL AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, age INT DEFAULT 0, ... PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;如果建表时没指定就用实例级别的default-storage-engine参数我建议在my.cnf里最好显式写上[mysqld] default-storage-engineINNODB很多老项目是从早期版本迁移上来的建表语句里可能还写着ENGINEMyISAM或者干脆没写。这时候可以用一段SQL把所有MyISAM表列出来做一次统一检查SELECT table_schema, table_name, engine FROM information_schema.TABLES WHERE engine MyISAM AND table_schema NOT IN (mysql, information_schema, performance_schema, sys);修改单表引擎ALTER TABLE your_table ENGINE InnoDB;但正如前面说的这条命令大表慎用最好在低峰期或者借助在线DDL工具完成。改完记得ANALYZE TABLE。5.2 InnoDB关键参数怎么调一个可参考的示例存储引擎相关的性能调优本质上是给InnoDB分配资源和控制刷盘策略。很多新手一上来就照着网上的配置一顿改完全没有根据自己机器的内存和IO能力算过账这是大忌。我最建议先调的是innodb_buffer_pool_size。这个参数是InnoDB的内存缓冲池大小数据页、索引页、undo页都会缓存在这里。读多写多的业务buffer pool命中率越高磁盘IO越少。建议的分配原则是专用数据库实例上可以取物理内存的60%到75%左右。32G内存的机器我一般给20G到22G如果机器上还跑着其他应用保守起见给到50%左右。8.0版本还支持设置多个buffer pool实例[mysqld] innodb_buffer_pool_size 20G innodb_buffer_pool_instances 8在MySQL 8.0里你可以直接在线修改buffer pool大小不用重启SET GLOBAL innodb_buffer_pool_size 20 * 1024 * 1024 * 1024;另一个容易忽略的是刷盘方式。Linux下我一般会设innodb_flush_method O_DIRECT这个参数的目的是让InnoDB绕过操作系统缓存直接写入磁盘避免double bufferInnoDB缓冲一份OS缓存又有一份减少内存浪费。注意云盘和本地盘的情况不同实际效果需要压测验证。还有两个参数也值得关注。一个是innodb_file_per_table ON每个表独立表空间删除表或清数据时更容易回收磁盘空间。另一个是innodb_redo_log_capacity8.0.30之后InnoDB的redo log容量由这个参数控制设置太小会导致频繁刷盘、写入抖动一般给个1G到4G并不为过具体看写入量。旧版本对应的参数是innodb_log_file_size和innodb_log_files_in_group。容器化部署的场景我要多说一句。我在Kubernetes里部署MySQL时最常见的问题就是容器内存定得不准。你给容器限了8G内存但innodb_buffer_pool_size还默认128M数据库性能惨不忍睹反过来容器限了4G你buffer pool却设了6G那直接OOM。容器部署MySQL一定要把buffer pool、连接线程内存、操作系统内存一起纳入资源配额的计算里。5.3 连接池、主从复制与存储引擎的联动这三个词是热搜常客看起来各管各但在生产环境里它们都跟存储引擎绑在一起。先说连接池。连接池本身不是MySQL的东西是应用层的。但它和InnoDB的事务、锁状态有直接联动。我之前排查过一个问题应用在事务里更新了一行数据后因为业务校验失败代码直接break掉了既没有commit也没有rollback。连接在池子里看起来是空闲的底层的InnoDB事务却一直开着那行记录被锁了一下午。后来处理的方式很简单应用层强制要求事务统一走try-catch-rollback同时把连接池的maxLifetime缩短让长时间异常的连接能被回收。再说主从复制。从库延迟、主从数据不一致很多时候也跟引擎有关。8.0默认用binlog_format ROW这个格式下从库能严格按照主库的行变更同步即使主从表结构有细微差异也不容易出偏差。如果是statement格式遇到NOW()、UUID()这类函数不同实例上值可能不一样再碰上MyISAM这种非事务表主库写到一半崩溃binlog和表的实际数据可能对不上从库跟着就乱套了。所以从引擎选型那一刻开始InnoDBROW格式就是最稳妥的搭配。6. 常见问题与排查技巧速查6.1 锁等待、连接池悬挂事务怎么揪出来这一节算是我长期实战的浓缩先整理一下踩坑最多的场景。第一种一条UPDATE或者DELETE卡了很久始终不返回。先看information_schema.innodb_trx如果有大量长事务再结合performance_schema.data_locks看锁被谁占着。定位到源头后KILL掉事务对应的线程通常立竿见影。第二种部分从库延迟极不正常。除了大事务和DDL还要想到存储引擎层面的因素。比如从库上某张表是MyISAM复制线程写入时碰到表锁读写全部排队延迟就上去了。第三种应用报Deadlock found when trying to get lock; try restarting transaction。直接看SHOW ENGINE INNODB STATUS的死锁段把两个事务的SQL、索引情况、执行顺序一起分析。注意不要把死锁和锁超时混为一谈一个是被回滚错误码1213一个是等满了innodb_lock_wait_timeout错误码1205处理思路完全不同。6.2 几个看着和引擎无关、其实很相关的SQL坑有些问题表面上是SQL写法问题但根源还是没理解引擎的索引机制。第一个坑在索引列上做运算导致索引失效。比如一张表有id INT PRIMARY KEY一个业务需求要查id加5等于10的记录新手可能会写SELECT * FROM t WHERE id 5 10;这种写法让MySQL无法直接走主键索引因为每个索引项都要先做一次加法运算才能判断等不等于10优化器只能全表扫描。改法很简单把运算挪到常量那一边SELECT * FROM t WHERE id 10 - 5;类似的问题还有在字符串字段上隐式转换。比如手机号是VARCHAR(20)查询条件写成WHERE phone 13800000000MySQL会把字段隐式转成数字再比较索引就失效了。正确做法是字符串就写字符串SELECT * FROM t WHERE phone 13800000000;第二个坑ORDER BY排序慢。很多慢SQL用EXPLAIN一看就是Using filesort。filesort不一定是性能灾难但要认识到它意味着MySQL没有利用索引的有序性而是把数据读出来放到sort buffer里排序。如果排序量超过sort_buffer_size还得落盘用文件排序那才是真的慢。优化思路通常是建一个包含查询列和排序列在内的联合索引直接用索引顺序返回。第三个坑字符串转日期不会用函数。如果表里存的是VARCHAR类型的日期字段比较时尽量用STR_TO_DATE转换而不是随手拼字符串SELECT * FROM orders WHERE create_time STR_TO_DATE(2024-06-01, %Y-%m-%d);说到底这些坑背后的逻辑都是一样的MySQL优化器要尽量利用索引而任何对列本身做运算、转换的写法都会让索引失效。理解和遵循这一点很多SQL问题可以提前避免。存储过程这里也提一句。用MySQL存储过程封装业务逻辑时引擎层的事务错误要能被正确捕获。比如在存储过程里执行带事务的更新加入异常处理器才能在死锁或锁超时时明确回滚并返回错误码DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;6.3 常见问题速查表现象常见原因排查方向处理建议应用报Lock wait timeout exceeded长事务未提交锁被占住information_schema.innodb_trx、performance_schema.data_locks定位并KILL长事务修复应用事务边界应用报Deadlock found多事务交叉申请锁SHOW ENGINE INNODB STATUS规整业务更新顺序统一索引命中路径UPDATE/DELETE慢CPU高where条件列无索引行锁升级为全表锁EXPLAIN看key字段给条件列加索引查询排序慢未走索引Using filesortEXPLAIN看Extra优化联合索引覆盖查询列Error 2002 (HY000)MySQL未启动/socket路径不一致检查/tmp/mysql.sock或my.cnf的socket配置正确指定socket或启动mysqldSSL连接错误客户端与服务器SSL配置不匹配查看require_secure_transport、证书配置合理配置SSL测试时可用--ssl-modeDISABLED排除机器掉电后表损坏MyISAM表崩溃恢复能力弱myisamchk或REPAIR TABLE尽快换成InnoDB你看排障这件事很多问题最后都能追到表引擎选错或者没利用好InnoDB的索引和事务机制上。把存储引擎理解透不只是为了面试背几道题更是为了线上出问题时你能稳稳接住。最后再分享一个很实际的经验。我每次接手一个陌生的MySQL项目第一件事不是看代码而是把information_schema.TABLES里的引擎分布、表行数、数据量拉一遍。这个习惯救过我很多次。引擎和索引选对了数据库能省下你大量的运维精力选错了后面多少条SQL优化都补不回来。你在实际运维中也可以从这几张表开始把自己的家底盘一遍心里有数了出问题时才知道该往哪个方向查。
返回列表