本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
本文 的 原文 地址
本文作者:
- 第一作者 老架构师 肖恩(肖恩 是尼恩团队 高级架构师,负责写此文的第一稿,初稿 )
- 第二作者 老架构师 尼恩 (45岁老架构师, 负责 提升此文的 技术高度,让大家有一种 俯视 技术、俯瞰技术、 技术自由 的感觉)
电商系统订单查询性能优化案例
问题产生
在电商系统的性能测试中,发现查询用户订单详情的接口耗时在600-800ms之间,不满足200ms以内的性能要求,需要进行接口优化。
定位问题
使用SkyWalking定位,发现接口调用耗时666ms,其中SQL查询耗时585ms,此SQL导致接口慢响应。
SQL分析- SELECT
- o.*
- FROM
- users u LEFT JOIN orders o
- ON u.user_id = o.user_id
- WHERE
- 1 = 1
- AND o.order_status IN (1, 2) -- 1-待付款 2-已付款
- AND u.registration_channel = 'APP'
- AND u.city = '北京';
复制代码 两张表的数据量:
- users(用户表):200万条
- orders(订单表):200万条
问题分析
本次查询采用 Index Nested-Loop Join 执行流程(orders表在user_id字段有索引):
1、 从users表(驱动表)进行WHERE条件过滤(registration_channel='APP' AND city='北京'),此时数据量约100万行(北京用户多且大部分通过APP注册);
2、 读取过滤后的每一行用户记录U;
3、 取出U的user_id字段到orders表(被驱动表)查找,通过索引找到该用户的所有订单;
4、 检查订单状态是否为1或2,如果是则返回结果;
5、 重复步骤2-4,直到处理完所有100万行用户记录。
问题根源:
- 驱动表过滤后,仍有100万行数据
- 需要执行100万次索引查询(虽然单次索引查询很快,但100万次累计耗时高)
- 每个用户平均有1-2个订单,最终结果集约150万行
解决思路
方案一:程序分步查询(小数据集驱动大数据集)
1、 先查询orders表,获取状态为1或2的订单(结果集较小,约50万):- SELECT user_id, order_id FROM orders
- WHERE order_status IN (1, 2);
复制代码 2、 在Java代码中获取这些user_id(去重后约30万),然后查询用户:- SELECT * FROM users
- WHERE registration_channel = 'APP'
- AND city = '北京'
- AND user_id IN (30万个ID);
复制代码 3、 在内存中关联数据
方案二:SQL改写(小数据集驱动大数据集)
- SELECT
- u.*
- FROM
- (SELECT user_id FROM orders WHERE order_status IN (1, 2)) t -- 先过滤出小数据集
- JOIN users u
- ON t.user_id = u.user_id
- WHERE
- u.registration_channel = 'APP'
- AND u.city = '北京';
复制代码 优化原理:
- 先通过orders表的索引快速过滤出待付款和已付款订单(结果集50万行)
- 用这个50万行的小表驱动users表,通过user_id索引查询
- 总查询次数从100万次降低到50万次
经验总结
JOIN优化黄金法则:
执行计划分析:- EXPLAIN
- SELECT ...
- -- 检查type列:应出现ref/eq_ref,避免ALL
- -- 检查Extra列:应出现Using index,避免Using filesort
复制代码 监控预警:- -- 设置慢查询阈值
- SET GLOBAL long_query_time = 0.2;
复制代码 通过优化JOIN顺序、添加合适索引、利用缓存机制,成功将接口响应时间从600ms+降至100ms以内,满足性能要求。此案例展示了SQL优化中"小表驱动大表"原则的实际应用价值。
基础知识: 数据库 JOIN 算法
在数据库查询优化中,JOIN顺序优化是提升多表关联查询效率的核心手段之一,而“小表驱动大表”原则是其中最经典的优化思想。
在多表联合查询的时候,如果我们查看它的执行计划,就会发现里面有多表之间的连接方式。
mysql 多表之间的连接有多种方式,但是常见的是三种方式:Nested Loops,Hash Join 和 Sort Merge Join.
其中, Nested Loops 包括:
- Index Nested-Loop Join
- Simple Nested-Loop Join
- Block Nested-Loop Join
具体适用哪种类型的连接, 取决于
- 当前的优化器模式 (ALL_ROWS 和 RULE)
- 取决于表大小
- 取决于连接列是否有索引
- 取决于连接列是否排序
接下来,从定义、原理、与JOIN算法的关联、案例分析等维度,全面解析数据库 JOIN 算法。
1、索引嵌套循环 Index Nested-Loop Join
Nested loops 工作方式是循环从一张表中读取数据(驱动表outer table),然后访问另一张表(被查找表 inner table,通常有索引)。
驱动表中的每一行与inner表中的相应记录JOIN。
Nested loops 类似一个嵌套的循环。对于被连接的数据子集较小的情况,嵌套循环连接是个较好的选择。
在嵌套循环中,内表被外表驱动,外表返回的每一行都要在内表中检索找到与它匹配的行,因此整个查询返回的结果集不能太大(大于1 万不适合),要把返回子集较小表的作为外表( 默认外表是驱动表),而且在内表的连接字段上一定要有索引。
其中, Nested Loops 包括:
- Index Nested-Loop Join
- Simple Nested-Loop Join
- Block Nested-Loop Join
Index Nested-Loop Join 原理:
该算法是通过驱动表(外表)的结果集,去匹配 被驱动表(内表)的索引来完成连接。
它会先从驱动表中读取一条记录,然后在被驱动表上利用索引查找匹配的记录,重复这个过程,直到驱动表中的所有记录都处理完毕。
由于利用索引进行查找,在满足条件的情况下,能快速定位到被驱动表中匹配的行,减少数据扫描量。
- 如果被驱动表的连接列上有索引,且驱动表结果集较小,该算法效率很高。
- 但如果被驱动表的索引选择性差,可能导致大量随机 I/O ,性能下降 。
场景:驱动表(小表)与被驱动表(大表)通过索引快速匹配。
示例:
假设存在两个表:orders(订单表,1000 行)和customers(客户表,10000 行),customers.customer_id上有索引。- SELECT *
- FROM orders
- JOIN customers ON orders.customer_id = customers.customer_id;
复制代码 执行流程:
1、 从orders表读取一行数据(如customer_id=100)。
2、 通过customers.customer_id的索引快速定位到匹配的客户记录。
3、 重复步骤 1-2,直到orders表遍历完毕。
优势:利用索引减少扫描,适合大表与小表连接。
核心优势:
当被驱动表存在高效索引时(B+树),时间复杂度降至 O(M * log N) (M=驱动表行数,N=被驱动表行数)
优化点:
- 覆盖索引:避免回表
- MRR优化:批量索引查询减少磁盘随机IO
适用场景:
WHERE user.role_id = roles.id(roles.id有索引)
2、 简单嵌套循环 Simple Nested-Loop Join (暴力匹配)
此算法是最基础的连接算法,它通过两个嵌套的循环来实现表的连接。
外层循环遍历驱动表的每一行,对于驱动表的每一行,内层循环都会遍历被驱动表的每一行,检查是否满足连接条件,找到所有满足连接条件的行组合。
该算法逻辑简单,但效率较低,特别是在处理大表时,时间复杂度为 O (n*m),其中 n 和 m 分别是两个表的行数,会产生大量的 I/O 操作,在实际应用中,通常会尽量避免使用,仅适用于表数据量非常小的场景。
场景:无索引可用,逐行暴力匹配。
示例:
假设两个表:employees(员工表,100 行)和departments(部门表,10 行),均无索引。- SELECT *
- FROM employees
- JOIN departments ON employees.dept_id = departments.dept_id;
复制代码 执行流程:
1、 遍历employees表的每一行(共 100 次)。
2、 对于每一行,遍历departments表的所有行(共 10 次),检查dept_id是否匹配。
3、 总比较次数:100 × 10 = 1000 次。
劣势:时间复杂度高(O (n*m)),仅适用于极小表。
性能陷阱:
时间复杂度 O(M*N),磁盘IO随数据量指数上升
Example: 10K用户 × 100K订单 = 10亿次比对
优化替代方案:
- 强制添加索引 → Index Nested-Loop
- 无索引字段 → Block Nested-Loop
禁用警告:
生产环境绝对避免!仅当两表均 '2023-01-01';[/code]执行流程:
1、 从orders表过滤出符合条件的product_id(如 100 个),收集到缓存。
2、 对缓存中的product_id排序(如 10, 20, 30...)。
3、 批量访问products表,按排序后的顺序读取数据(顺序 I/O)。
优势:将随机 I/O 转换为顺序 I/O,适合大表 JOIN 且有索引的场景。
核心创新:
- Multi-Range Read (MRR):索引键排序 → 磁盘顺序IO
- 批量请求:减少索引树遍历次数
性能对比:
场景Index Nested-LoopBKA随机IO次数10000次100次响应时间1200ms150ms开启条件:
1、 被驱动表存在可用索引
2、 设置 optimizer_switch='mrr=on,batched_key_access=on'
5、哈希连接(Hash Join)
在MySQL中,哈希连接(Hash Join)是一种用于执行表连接的算法。
尽管MySQL传统上更倾向于使用嵌套循环连接(Nested Loop Join),但在某些特定场景下,哈希连接也可以提供更高的性能,特别是在处理大数据集时。
MySQL中使用哈希连接的一些典型场景:
1、 连接键没有索引
当连接键在两个表中都没有索引时,哈希连接可以避免全表扫描的高成本。在这种情况下,哈希连接通过构建哈希表来快速查找匹配的行,而不是对每个行进行逐行比较。
2、 大表连接
当连接的两个表都比较大,并且没有合适的索引时,哈希连接可以更高效地完成连接操作。哈希连接通过将较小的表(或结果集)构建哈希表,然后在较大的表上进行探测,从而减少磁盘 I/O 和比较次数。
3、 内存足够
如果构建表较小,可以完全放入内存中,哈希连接的性能会非常好。因为哈希表在内存中构建和访问速度很快,这可以显著减少磁盘 I/O 操作。
4、 不等式连接
在某些情况下,哈希连接可以用于处理不等式连接(如 WHERE table1.key table2.key)。尽管嵌套循环连接也可以处理这种场景,但哈希连接可能会更高效,特别是当数据量较大时。
5、 多表连接
在涉及多个表的复杂连接查询中,哈希连接可以与其他连接算法(如嵌套循环连接)结合使用,以优化整体查询性能。
6、 并行查询
在支持并行查询的环境中,哈希连接可以通过将哈希表分割成多个部分,分配给不同的处理器或线程来处理,从而提高性能。
注意事项
- 表顺序:哈希连接对表的顺序敏感。通常选择较小的表作为构建表,以减少内存占用。
- 内存限制:哈希表需要占用内存。如果构建表太大,无法完全放入内存,可能导致性能下降,因为需要将哈希表部分存储到磁盘上。
- 查询优化器:MySQL的查询优化器会根据表的统计信息自动选择最适合的连接算法。因此,在实际应用中,开发人员通常不需要手动指定连接算法,但了解其原理有助于编写更高效的SQL查询。
哈希连接(Hash Join) 示例
以下是一个使用哈希连接的SQL示例,其中连接键没有索引:- SELECT *
- FROM products JOIN categories
- ON products.cat_id = categories.cat_id;
复制代码 在这个示例中,table1和table2的连接键(id和t1_id)都没有索引。MySQL可能会选择使用哈希连接来执行这个查询,特别是如果table1较小而table2较大时。
总结来说,哈希连接在MySQL中主要用于处理连接键无索引、大表连接、内存足够等场景。通过合理利用哈希连接,可以在特定情况下显著提高查询性能。
6、MySQL 合并连接(Merge Join)应用场景详解
合并 JOIN 的运行依赖于数据有序性,其核心流程如下:先对参与连接的两张表,按照连接条件字段进行排序;接着同时扫描两张已排序的表,逐行比较连接字段的值。
- 如果值相等,就将这两行数据进行组合作为连接结果输出;
- 如果不相等,则将连接字段值较小的那行跳过,继续扫描该行所在表的下一行,持续此过程直至两张表扫描结束。
使用合并连接的理想场景
MySQL中的合并连接是一种高效的JOIN方式,特别适用于以下几种典型场景:
1. 数据已经排序的情况
典型场景:
当两个表都按JOIN字段排序时(通常是通过索引实现),合并连接最高效
实际案例:- SELECT *
- FROM orders JOIN products
- ON orders.product_id = products.product_id
- WHERE orders.order_date > '2023-01-01';
复制代码 2. 大表等值连接
适用情况
- 连接键使用等号(=)
- 两表数据量都很大(百万级以上)
优势:
比嵌套循环效率更高,比哈希连接内存占用更少
3. 内存敏感环境
- -- 创建表1:无索引
- CREATE TABLE table1 (
- id INT PRIMARY KEY,
- data1 VARCHAR(100)
- );
- -- 插入数据
- INSERT INTO table1 (id, data1) VALUES
- (1, 'A'),
- (2, 'B'),
- (3, 'C'),
- (4, 'D');
- -- 创建表2:无索引
- CREATE TABLE table2 (
- id INT PRIMARY KEY,
- data2 VARCHAR(100),
- t1_id INT
- );
- -- 插入数据
- INSERT INTO table2 (id, data2, t1_id) VALUES
- (101, 'X', 1),
- (102, 'Y', 2),
- (103, 'Z', 3),
- (104, 'W', 4);
- -- 使用哈希连接查询
- SELECT t1.id, t1.data1, t2.data2
- FROM table1 t1
- JOIN table2 t2 ON t1.id = t2.t1_id;
复制代码
- 当可用内存不足时(特别是MySQL的join_buffer_size较小)
- 比哈希连接占用内存少(不需要构建哈希表)
如何开启和优化合并连接
1、检查版本支持(MySQL 8.0+)- -- 两个表都有user_id的索引
- SELECT *
- FROM orders INDEX(idx_user) -- 强制使用索引排序
- JOIN users INDEX(idx_user) -- 强制使用索引排序
- ON orders.user_id = users.id;
复制代码 2、 索引优化建议- graph TB
- A[内存限制] --> B{选择连接算法}
- B -->|内存充足| C[哈希连接]
- B -->|内存紧张| D[合并连接]
复制代码 3、执行计划确认- SHOW VARIABLES LIKE 'version';
- -- 确保是MySQL 8.0或更高版本
复制代码 期望结果:- -- 为JOIN字段创建索引
- ALTER TABLE orders ADD INDEX idx_user_id(user_id);
- ALTER TABLE users ADD INDEX idx_id(id);
- -- 多字段连接时使用复合索引
- ALTER TABLE orders ADD INDEX idx_user_status(user_id, status);
复制代码 因为merge join需要做更多的排序,所以消耗的资源更多。
通常来讲,能够使用merge join的地方,hash join都可以发挥更好的性能,即散列连接的效果都比排序合并连接要好。
然而如果行源已经被排过序,在执行排序合并连接时不需要再排序了,这时排序合并连接的性能会优于散列连接。
性能对比测试
场景数据量连接算法耗时内存使用小表等值连接1万行嵌套循环50ms5MB合并连接45ms3MB大表等值连接100万行哈希连接1.2s200MB合并连接0.8s50MB内存受限50万行哈希连接3.5s(溢出磁盘)合并连接1.1s30MBJOIN顺序优化:小表驱动大表的执行原则
小表驱动大表:数据库JOIN优化的黄金法则
一、什么是小表驱动大表?
“小表驱动大表”(Small Table Drives Large Table)指的是在多表JOIN时,优先使用参与JOIN的行数少的表(小表)作为驱动表(外层循环),行数多的表(大表)作为被驱动表(内层循环)。
简单说就是:让数据量小的表先干活,数据量大的表后干活。
就像小组合作时,让任务量小的同学先完成自己的部分,再交给任务重的同学。
小表驱动大表 ,就是 通过减少外层循环的执行次数,降低整体查询的IO成本和计算开销。
核心原理
- 小表作为"驱动表"(先行动的表)
- 大表作为"被驱动表"(后行动的表)
- 减少数据库的无效工作量
二、核心原理:减少无效循环,降低资源消耗
JOIN本质是“行匹配”过程:驱动表的每一行, 都需要与被驱动表的行进行匹配(基于JOIN条件)。
- 若驱动表行数少,外层循环次数少,即使被驱动表行数多,总匹配次数也会显著减少。
举个直观的例子:
小表A有10行,大表B有1000行,JOIN条件为A.id = B.a_id。
- 若A驱动B:外层循环10次,每次匹配B的1000行,总匹配次数=10×1000=10,000。
- 若B驱动A:外层循环1000次,每次匹配A的10行,总匹配次数=1000×10=10,000。
表面看次数相同,但实际中:
- 被驱动表B 若有JOIN字段的索引,每次匹配是“索引查找”(成本低),此时外层次数少更优(10次索引查找总开销 < 1000次)。
- 小表数据更易加载到内存,减少磁盘IO(大表可能无法全加载,需频繁读盘)。
3、与JOIN算法的关联:不同算法对表顺序的敏感度
三大 常用的 数据库的JOIN算法:
三大 算法 对表顺序的依赖不同,但“小表驱动大表”原则在多数场景下均适用。
1. 嵌套循环关联 JOIN(Nested Loop Join, NLJ)
NLJ是最基础的JOIN算法,逻辑为:- EXPLAIN
- SELECT orders.*, users.name
- FROM orders
- JOIN users ON orders.user_id = users.id;
复制代码 对表顺序极其敏感:外层(驱动表)行数直接决定循环次数。
此时“小表驱动大表”可最大化减少外层循环,是最优选择。
2、 哈希关联 Hash Join)
哈希JOIN的逻辑分两步:
1、 构建阶段
用驱动表数据构建内存哈希表(key为JOIN字段,value为行数据);
2、 探测阶段
扫描被驱动表,用每行的JOIN字段到哈希表中匹配,输出结果。
对表顺序敏感,需要遵守 小表驱动大表的原则
哈希表需占用内存,
- 若驱动表是小表,哈希表体积小,可完全放入内存(避免溢出到磁盘),构建和探测效率均更高。
- 若用大表构建哈希表,可能因内存不足导致“磁盘哈希”,性能骤降。
3、 合并JOIN(Merge Join)
合并JOIN要求两表的JOIN字段已排序,逻辑为:- +----+-------------+--------+------+---------------+-------------+...
- | id | select_type | table | type | key | Extra |
- +----+-------------+--------+------+---------------+-------------+
- | 1 | SIMPLE | orders | ALL | NULL | |
- | 1 | SIMPLE | users | ref | idx_id | Using where |
- +----+-------------+--------+------+---------------+-------------+
复制代码 对表顺序敏感度低:
但排序阶段仍受表大小影响——小表排序成本更低(耗时短、内存占用少),因此实际中仍倾向用小表先排序,间接体现“小表驱动”思想。
三、不同JOIN算法如何应用
1、 嵌套循环(最常用)
- for each row in 驱动表:
- for each row in 被驱动表
- where JOIN条件匹配:
- 输出结果
复制代码 建议:小表放外层,就像先拿学生名单再找作业
2、 哈希匹配
建议:小表建哈希表,避免内存不够用
3、 合并排序
建议:小表先排序更省时
四、怎么判断谁是"小表"?
关键:看实际参与查询的数据量,不是表大小
表物理大小WHERE条件实际参与行数是否小表10GBstatus='active'100行✅ 是1GB无过滤条件100万行❌ 否需注意:“小表”指的是经过WHERE条件过滤后,实际参与JOIN的行数少的表,而非物理存储大小(如数据文件大小)。
例:
- 表C物理大小10GB(1000万行),但WHERE条件status = 'active'过滤后仅100行参与JOIN;
- 表D物理大小1GB(100万行),但无过滤条件,全部100万行参与JOIN。
此时表C是“小表”(有效行数100 < 100万),应作为驱动表。
五、实际案例对比
案例1:嵌套循环JOIN场景(订单与用户查询)
场景:
- 订单表orders(大表):100万行,user_id为JOIN字段(有索引),存储用户订单信息;
- 用户表users(小表):1万行,id为JOIN字段(主键索引),存储用户基本信息。
需求:
查询所有用户的订单详情(users.id = orders.user_id)。
优化前SQL(大表驱动小表)
若orders为驱动表,外层循环100万次,每次用user_id到users中匹配(主键索引查找,单次成本低)。
- 总循环次数:100万 × 1(每次匹配1行)= 100万次;
- 问题:外层循环次数过多,即使单次匹配快,总耗时仍高(尤其IO密集场景)。
- 同时扫描两个有序表,按JOIN字段顺序匹配(类似归并排序的合并过程)
复制代码 执行计划特点:
- 驱动表:orders(100万行)
- 外层循环:100万次
- 每次循环:通过索引查找users表
- 总查找次数:100万次索引查找
优化后SQL(小表驱动大表)
用users作为驱动表,外层循环1万次,每次用id到orders中匹配(user_id索引查找)。
总循环次数:
1万 × 平均100(每个用户100个订单)= 100万次(总次数相同);
优势:
外层循环次数从100万降至1万,内存可缓存users全表(减少磁盘IO),且索引查找的累计开销更低(1万次索引查找 < 100万次)。- graph TD
- A[小表] -->|逐行取出| B[大表]
- B -->|索引查找匹配| C[结果]
复制代码 执行计划特点:
- 驱动表:users(1万行)
- 外层循环:1万次
- 每次循环:通过索引查找orders表
- 总查找次数:1万次索引查找
案例2:哈希JOIN场景(商品与分类查询)
场景:
- 商品表products(小表):1000行,category_id为JOIN字段;
- 分类表categories(大表):10万行,id为JOIN字段;
需求:
查询每个分类下的商品(products.category_id = categories.id),且两表均无索引。
优化前SQL(大表构建哈希表)
若用categories(10万行)构建哈希表,哈希表体积大(假设每行100字节,需10MB),可能超出内存导致溢出到磁盘(磁盘哈希效率骤降)。- -- 大表orders驱动小表users
- SELECT
- u.id AS user_id,
- u.name AS user_name,
- o.order_id,
- o.order_date,
- o.amount
- FROM orders o -- 100万行的大表
- JOIN users u ON o.user_id = u.id -- 1万行的小表
- WHERE
- u.status = 'active'; -- 假设有1万活跃用户
复制代码 执行问题:
- 哈希表大小:5万行 × 100字节 ≈ 5MB
- 内存不足时:部分哈希表写入磁盘
- 探测阶段:需频繁磁盘I/O
优化后SQL(小表构建哈希表)
用products(1000行)构建哈希表(体积仅0.1MB,完全放入内存),然后扫描categories逐行探测匹配。
优势:内存哈希表无溢出,探测阶段仅需遍历大表1次,效率提升10倍以上- -- 小表users驱动大表orders
- SELECT
- u.id AS user_id,
- u.name AS user_name,
- o.order_id,
- o.order_date,
- o.amount
- FROM users u -- 1万行的小表
- JOIN orders o ON u.id = o.user_id -- 100万行的大表
- WHERE
- u.status = 'active'; -- 1万活跃用户
复制代码 优化效果:
- 哈希表大小:1000行 × 100字节 ≈ 100KB
- 完全内存操作:无磁盘I/O
- 探测阶段:单次扫描大表
总结
“小表驱动大表”原则的核心是通过减少外层循环次数,降低整体匹配成本,其有效性与JOIN算法(嵌套循环、哈希)紧密相关。
实际应用中,需结合表的有效行数、索引情况、数据库统计信息综合判断,必要时通过hint干预优化器,才能最大化提升JOIN效率。
六、什么时候需要人工干预?
干预方法:在 MySQL 用STRAIGHT_JOIN- -- 大表categories驱动小表products
- SELECT
- c.category_name,
- p.product_name,
- p.price
- FROM categories c -- 10万行的大表
- JOIN products p ON c.id = p.category_id -- 1000行的小表
- WHERE
- c.is_active = 1; -- 假设有5万活跃分类
复制代码 现代数据库(如MySQL、PostgreSQL、Oracle)的优化器会基于统计信息(表行数、索引分布、数据分布等)自动选择JOIN顺序, 以下场景需人工干预:
1、 统计信息过期
表数据更新后未及时analyze,优化器误判表大小(如小表被当作大表);
2、 复杂JOIN场景
多表(3张以上)JOIN时,优化器可能因计算成本过高选择次优顺序;
3、 特殊索引场景
被驱动表有高效索引,但优化器未识别(如联合索引前缀匹配)。
数据库通常自动优化,但以下情况需人工介入:
1、 统计信息过期(如新表未分析)
2、 超多表关联(5张表以上)
3、 特殊索引情况
七、常见误区提醒
篇幅太长,请参考原文
本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
本文 的 原文 地址
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |