前两年整理运营周报,我遇到一条特别恶心的 SQL。要把用户的充值总和、下单次数、最近一次下单时间都按用户先汇总一遍,然后再拿这堆汇总结果去和用户等级表做关联、算复购率、算高价值区间,原来的同事把这段逻辑直接内联成了四层嵌套子查询。想改一个字段,得从最内层翻起,外面还有好几处引用它的地方,每次改都会漏,运行效率也没好到哪去。当时线上库是 MySQL 5.7,不敢随便建临时表,视图又要申请权限,改一条报表 SQL 跟做外科手术一样。
后来业务库升级到 8.0,我第一件事就是用 WITH AS 把那条历史 SQL 重写了一遍。中间结果只写一遍,起个名字,后面所有统计直接读这个逻辑名,维护的人谁看谁懂。这篇文章就把这段时间用 WITH AS 整理出来的经验写全一点,包括语法边界、实用场景、递归用法、执行计划和几个容易翻车的坑。如果你是写完子查询再打个括号就完事那种风格,建议看完第三节和第五节,这两部分最容易踩雷。
1. WITH AS 到底在做什么:语法边界先立清楚
1.1 基本语法结构
WITH AS 的官方名字叫 Common Table Expression,简称 CTE,公共表表达式。它的意思很简单:先给一段 SELECT 查询结果起个临时名字,后面主查询可以像查普通表一样引用它。基础语法长这样:
sql复制WITH cte_name AS (
SELECT column1, column2
FROM some_table
WHERE some_condition
)
SELECT *
FROM cte_name;
注意几个细节:
WITH关键字本身不需要和任何表绑定,它位于整条语句的最前面。- CTE 名字后面可以跟列名列表,例如
WITH t(a, b) AS (...),相当于给查询结果列重命名。如果不写,就直接沿用SELECT里的列名。 - 主查询可以是
SELECT,在 MySQL 8.0.19 之后也可以是UPDATE和DELETE,这个后面专门讲。 - 整条语句以主查询末尾的分号结束,不要在
AS (...)后面提前打分号,否则 MySQL 会认为语句已经结束了。
如果一段业务逻辑需要拆成多个步骤,可以连续定义多个 CTE,中间用逗号分隔:
sql复制WITH step1 AS (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
),
step2 AS (
SELECT user_id
FROM step1
WHERE total_amount > 1000
)
SELECT u.id, u.name
FROM users u
JOIN step2 s ON u.id = s.user_id;
后面的 CTE 可以引用前面已经定义好的 CTE,但反过来不行。这是 WITH AS 很顺手的一点:写分析 SQL 的时候天然就是一步一步往下推,代码顺序和思考顺序一致。
1.2 作用域和生命周期
CTE 只在定义它的这条语句内有效,语句执行完就释放了。它不进 information_schema,不占数据库物理文件空间,也不需要 DBA 去手动清理。和临时表最大的区别就是:临时表连接断开才消失,而且需要你先 CREATE TEMPORARY TABLE,再 INSERT,再查询,至少三条语句;CTE 则是把定义和查询揉进一条语句里,不落库、不占用连接状态。
CTE 的作用域还有一个容易被忽略的点:它只在主查询里可见。如果主查询里又嵌了一个子查询,子查询里是不是还能引用外层 CTE?这个取决于 MySQL 的解析规则。我在 8.0.32 上测试过,外层定义的 CTE 在内层子查询里依然可以引用,因为整个 WITH 的作用域覆盖后面整个语句。这个特性有时很好用,但也是命名冲突的温床,后面第五节细说。
1.3 CTE 和派生表、临时表的对比
我记得刚学 CTE 的时候,最困惑的就是它和 FROM (...) 的派生表有什么区别。用一张表来理清:
| 对比项 | CTE (WITH AS) | 派生表 (FROM 子查询) | 临时表 (TEMPORARY TABLE) |
|---|---|---|---|
| 定义位置 | 语句最前方 | FROM 子句内 | 独立建表语句 |
| 是否可被多次引用 | 同一语句内可多次引用 | 只能写一次,引用多次就要复制多遍 | 任意多次,跨语句 |
| 生命周期 | 当前 SQL 语句结束即释放 | 当前 SQL 语句结束即释放 | 会话结束或显式 DROP 才释放 |
| 是否需要建表权限 | 不需要 | 不需要 | 需要 CREATE TEMPORARY TABLE 权限 |
| 能否加索引 | 优化器内部决定,不能手动建索引 | 优化器内部决定,不能手动建索引 | 可以手动建索引 |
| 可读性 | 高,能命名每个中间逻辑 | 低,嵌套层级深时难读 | 中,要维护建表和清理语句 |
| 性能调优方式 | 通过执行计划观察合并或物化 | 类似 CTE,优化器可能合并或物化 | 可以主动分析、建索引、ANALYZE TABLE |
从上面能看得出来,CTE 主要解决的是代码组织问题,而不是替代临时表。数据量特别大且下游多个统计都要复用同一份中间结果时,临时表加上合适的索引,往往比让优化器去猜怎么处理 CTE 更可控。这一点千万别搞反,后文第五节还会再提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 业务里最吃香的三个场景:不复写、能串联、有点名
2.1 场景一:同一份统计结果被多处引用
这是 CTE 最典型的价值。举个例子,现在要算两个数:平均每个付费用户的总充值金额,以及充值超过平均线的人数占比。
不用 CTE 的写法是什么样呢?平均值子查询要写一遍,超过平均值的人数统计里又要引用一遍同一个子查询:
sql复制SELECT
AVG(total_amount) AS avg_amount
FROM (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
) t;
sql复制SELECT
COUNT(DISTINCT t.user_id) AS high_value_users,
COUNT(DISTINCT t.user_id) / (SELECT COUNT(*) FROM (SELECT DISTINCT user_id FROM orders) d) AS ratio
FROM (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
) t
WHERE t.total_amount > (
SELECT AVG(total_amount)
FROM (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
) avg_t
);
这种 SQL 不是不能跑,但你有三份几乎一样的"用户汇总"嵌套在同一个查询里。代码量一多,DBA 看一眼都想离职。改成 CTE 之后,只需要定义一次,后面随便引用:
sql复制WITH user_order_summary AS (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
),
stats AS (
SELECT AVG(total_amount) AS avg_amount
FROM user_order_summary
)
SELECT
COUNT(DISTINCT uos.user_id) AS high_value_users,
COUNT(DISTINCT uos.user_id) / (SELECT COUNT(*) FROM user_order_summary) AS ratio
FROM user_order_summary uos
CROSS JOIN stats
WHERE uos.total_amount > stats.avg_amount;
改成这样之后,中间那层"每个用户的总充值"只维护一处。后面想加一个"总充值超过 5000 的用户数",你直接在 stats 或者主查询里加条件就行,完全不用去翻最内层。
2.2 场景二:一条分析链路串多步加工
很多数据分析不是一次聚合能算完的,而是"先清洗去重 → 再打标记 → 再做汇总"。这种多步加工链路,特别适合用多个 CTE 串起来。
举个例子,要找出"最近 30 天有下单、但累计充值超过 500 元的非 VIP 用户":
sql复制WITH dedup_users AS (
SELECT id, name, email, vip_level
FROM users
WHERE deleted_at IS NULL
),
recent_orders AS (
SELECT user_id
FROM orders
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY user_id
),
aggregated AS (
SELECT u.id, u.name, SUM(o.amount) AS total_amount
FROM dedup_users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
)
SELECT a.id, a.name
FROM aggregated a
JOIN recent_orders ro ON a.id = ro.user_id
WHERE a.total_amount > 500
AND a.id NOT IN (
SELECT id FROM dedup_users WHERE vip_level = 'VIP'
);
这段逻辑如果用嵌套子查询写,你可能需要从右往左读代码:先是最近 30 天下单用户,再是总量聚合,最后又是一个反连接。等字段一多,很容易看着看着就不知道当前在哪一层。WITH AS 把每一步都命名成 dedup_users、recent_orders、aggregated,相当于给一段 SQL 加了注释和目录。
我当时重构线上报表最大的体会是:CTE 带来的可维护性提升,比性能提升更明显。一个没有中间层命名的复杂 SQL,三个月后连作者本人也未必还记得每个子查询的含义;而具名 CTE 让这段逻辑变成一条可读的流水线。
2.3 场景三:把复杂口径拆成可独立验证的片段
还有一种场景我觉得很多做报表的人会感同身受:统计口径太复杂,一条 SQL 写完根本不知道结果对不对。
比如有个业务口径叫"高价值流失风险用户":近 90 天有登录、但最近一笔订单时间距今超过 45 天、而且累计消费超过 3000 元的用户。这个口径中间牵扯登录记录、订单明细、消费汇总三张表。如果一口气写一个大子查询,你很难定位到底是哪一步的数据不对。
拆成 CTE 之后,问题就变成了可以逐段验证的单元:
sql复制WITH login_recent AS (
SELECT DISTINCT user_id
FROM login_logs
WHERE login_time >= DATE_SUB(NOW(), INTERVAL 90 DAY)
),
last_order AS (
SELECT user_id, MAX(order_date) AS last_order_date
FROM orders
GROUP BY user_id
),
consumer AS (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
)
SELECT u.id, u.name
FROM users u
JOIN login_recent lr ON u.id = lr.user_id
JOIN last_order lo ON u.id = lo.user_id
JOIN consumer c ON u.id = c.user_id
WHERE lo.last_order_date < DATE_SUB(NOW(), INTERVAL 45 DAY)
AND c.total_amount > 3000;
这样排错的时候可以先单独跑第一段 CTE,看登录用户是不是符合预期;再跑第二段,确认最近订单时间计算没错。每一段都是完整可执行的 SELECT,能单独加 LIMIT 去检查。这一点是嵌套子查询很难做到的——你没法只跑中间那层。
3. 递归 CTE 实操:部门树从建表到跑通
3.1 普通的 WITH AS 不会递归
WITH AS 还有一种特殊形式叫递归 CTE,语法上多一个 RECURSIVE 关键字。它能实现一个查询自己引用自己,典型的应用就是树形结构查询:部门层级、商品分类、BOM 物料清单、上下级推荐关系,都能用递归 CTE 一次查出来。
先建一张测试部门表:
sql复制CREATE TABLE dept (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
parent_id INT NULL
);
INSERT INTO dept (id, name, parent_id) VALUES
(1, '总公司', NULL),
(2, '技术部', 1),
(3, '产品部', 1),
(4, '后端组', 2),
(5, '前端组', 2),
(6, '中台组', 2),
(7, '小程序产品组', 3);
需求是:给定部门 id = 2,查出它下面所有的子部门,包括自己。SQL 这么写:
sql复制WITH RECURSIVE dept_tree AS (
-- 锚定成员:查询起始点
SELECT id, name, parent_id, 1 AS lvl
FROM dept
WHERE id = 2
UNION ALL
-- 递归成员:关联出一层子节点,并继续向上扩展
SELECT d.id, d.name, d.parent_id, dt.lvl + 1
FROM dept d
JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT id, name, parent_id, lvl
FROM dept_tree
ORDER BY lvl, id;
执行结果应该是:
text复制id name parent_id lvl
2 技术部 1 1
4 后端组 2 2
5 前端组 2 2
6 中台组 2 2
它的执行逻辑很像一层层扩散:第一次先取 id = 2 这一行作为种子;然后用种子集合的 id 去找表中 parent_id 等于这些 id 的行,得到第二层;再用第二层的 id 去找第三层,直到某一次 JOIN 找不到任何新行,递归自然终止。
3.2 递归部分三个关键点:锚点、UNION ALL、迭代结束条件
第一次自己写递归 CTE,最容易漏掉或者搞错三个地方。
第一,锚定成员。它是递归的初始集合,通常是一条非递归的 SELECT。没有这个初始集合,整个递归就没有起点。
第二,连接条件。递归成员里那个 JOIN dept_tree dt ON d.parent_id = dt.id 是灵魂。每次迭代都用"上次查出来的结果"去驱动"下一次查表"。连接方向写反了,结果就完全不对。
第三,UNION ALL。大多数树形查询场景里,子节点和父节点理论上没有重复,所以用 UNION ALL 直接拼接每一层的结果是最高效的。如果你不小心写了 UNION,MySQL 会对全集去重,逻辑上不报错,但多了一次开销,某些有环的特殊数据下还可能掩盖问题。
我还建议给递归结果加一个 lvl 层级字段,就是锚点里 1 AS lvl,递归成员里 dt.lvl + 1。这个字段后续做缩进展示、权限层级控制、限制最大展开层数都很有用。如果再想输出一个完整路径,比如"总公司 / 技术部 / 后端组",可以再用一个路径字段拼接:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 1 AS lvl,
CAST(name AS CHAR(500)) AS path
FROM dept
WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.lvl + 1,
CONCAT(dt.path, ' / ', d.name)
FROM dept d
JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT id, name, lvl, path
FROM dept_tree
ORDER BY path;
注意路径字段做了 CAST(... AS CHAR(500)),这是个实用小技巧。因为递归时 CONCAT 的结果长度会不断变化,如果不限定长度,MySQL 可能推导出的字段长度不够,导致出现截断或报错。
3.3 遇到"循环引用"和深度限制时怎么办
递归 CTE 有个很典型的坑:如果表里存在脏数据,比如某条记录的 parent_id 指向了自己,或者 A 的父是 B、B 的父是 A,递归就会像死循环一样一直 JOIN,直到撞上递归深度上限。
MySQL 默认的递归深度由系统变量 cte_max_recursion_depth 控制,默认值是 1000。超过时会直接报错,错误码类似这样:
text复制ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.
如果确实业务树很深,可以用 SET SESSION 临时调大:
sql复制SET SESSION cte_max_recursion_depth = 10000;
但调大之前一定要检查一下是不是脏数据导致的死循环。我的做法是在锚定成员里直接排除自引用:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 1 AS lvl
FROM dept
WHERE id = 2
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.lvl + 1
FROM dept d
JOIN dept_tree dt ON d.parent_id = dt.id
WHERE d.id <> dt.id -- 防止自引用死循环
)
SELECT * FROM dept_tree;
如果担心两个节点互相指向,可以从业务层面加一个路径去重判断,例如 WHERE dt.path NOT LIKE CONCAT('%/ ', d.id, '/%')。实际场景里我更建议先把数据清洗干净再跑递归,而不是全靠 SQL 里加各种防御条件——递归 CTE 里每多一个条件,执行计划可能就复杂一分。
4. 8.0 的新空间:DML 里的 CTE 和执行计划观察法
4.1 CTE 也能用在 UPDATE 和 DELETE 里
MySQL 8.0.19 开始,WITH AS 不再只能配合 SELECT 使用,UPDATE 和 DELETE 语句前面也可以定义 CTE。这个特性在做批量数据清理时非常方便。
比如有一个用户表 users 和订单表 orders,希望把"近一年没有任何订单的用户"统一标记为 inactive。没 CTE 的写法,你一般要先把目标用户 id 查出来,粘到一个临时表,再跑 UPDATE;或者直接在 UPDATE 里写一个很长很长带 EXISTS 的关联子查询。
用 CTE 的写法:
sql复制WITH inactive_users AS (
SELECT u.id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
AND o.order_date >= DATE_SUB(NOW(), INTERVAL 1 YEAR)
WHERE o.user_id IS NULL
)
UPDATE users u
JOIN inactive_users iu ON u.id = iu.id
SET u.status = 'inactive';
DELETE 同理:
sql复制WITH old_orders AS (
SELECT id
FROM orders
WHERE order_date < '2019-01-01'
AND refund_status = 'refunded'
)
DELETE o
FROM orders o
JOIN old_orders oo ON o.id = oo.id;
这种写法的好处是,你要删除或更新的条件可以单独抽成一段 CTE 先验证,而不是直接写进一条很长的多表 UPDATE 里。生产环境执行前,我习惯把 CTE 单独跑一遍,数一数影响行数是不是符合预期,确认没问题再加 UPDATE 关键字。
4.2 用 EXPLAIN 看 CTE 是被合并还是被物化
很多博客说 CTE 性能好,很多人也以为 CTE 只算一次,后面引用都是直接读缓存。这个说法其实不完全对。
MySQL 优化器处理 CTE 的方式有好几种。它可以把 CTE 当作派生表一样合并到外层查询,直接展开;也可以像临时表一样物化 CTE 的结果,再让外层去访问。具体走哪种,取决于代价估算。所以在判断一条 CTE SQL 性能好不好时,别拍脑袋,用 EXPLAIN 看。
MySQL 8.0.16 之后可以用 EXPLAIN FORMAT=TREE,8.0.18 之后可以用 EXPLAIN ANALYZE。举个简单例子:
sql复制EXPLAIN FORMAT=TREE
WITH t AS (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
)
SELECT *
FROM t
WHERE total_amount > 1000;
执行计划里如果出现类似 -> Filter: (t.total_amount > 1000) 后面的表扫描直接来自 orders 的聚合,说明 CTE 被合并进外层做了优化;如果出现 -> Table scan on t 或者某个 Materialize 节点,说明走了物化,中间结果真实落到了内部临时表。
我自己调一条复杂 SQL 时,会先 EXPLAIN ANALYZE 跑完整条语句,看哪些步骤的实际耗时最高。CTE 本身不是优化银弹,真正的优化点是让你能看清哪一步最重,然后针对那一步改统计口径、加索引或换 JOIN 顺序。
4.3 版本边界和 MariaDB 的细微差异
CTE 是 SQL 标准里很早就有的特性,但 MySQL 直到 8.0 版本才开始支持。如果你还在维护 5.7 或更老的库,看到这条 SQL 直接就是语法错误。MySQL 8.0.14 之前对 CTE 内部聚合和窗口函数的组合也有限制,8.0.14 以后放开很多。
MariaDB 从 10.2 开始也支持 WITH AS,而且对递归 CTE 的支持比 MySQL 还早一些。但两边在细节上有差异,比如某些函数行为的兼容性、对 WITH RECURSIVE 的限制,跨版本迁移时不要假设写法直接通用。如果一份 SQL 要在两个引擎之间切换,最好先在目标环境真实跑一遍,特别是递归场景,容易遇到深度限制相关的报错。
5. 用多了也会翻车:归纳一下我踩过的几个坑
5.1 同一个 CTE 被引用多次,不代表只算一次
这一点影响最大。我在项目里遇到过:为了算"符合某条件的用户数量占比",我在同一句话里引用了同一个 CTE 三次,天真地以为数据库会算一次、后面两次查缓存,结果慢得要命。
后来用 EXPLAIN FORMAT=TREE 一看,同一个 CTE 被展开成了多个物化步骤或者多次扫描。MySQL 有个术语叫 derived table merging,优化器对于简单 CTE 会合并进外层;但如果 CTE 被多次引用,各引用之间到底会不会共享一次物化结果,取决于版本和代价判断,不是绝对的。不同版本表现可能还不一样。
所以当你发现同一个 CTE 被引用了两次以上而且整体性能差时,别把锅甩给 CTE 语法。先去执行计划确认物化次数,再考虑要不要改成临时表。特别是那种 CTE 本身聚合较大、外层还反复过滤的情况,建一个带索引的临时表可能更快。
5.2 列名没起好,报错能找到你头大
CTE 在使用时,列名问题有几个常见坑。
第一,CTE 内部多表 JOIN 时容易出现同名列。比如:
sql复制WITH t AS (
SELECT u.id, o.id, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
)
SELECT * FROM t;
这里的 id 有两个,MySQL 会允许定义,但你在外面引用 t.id 时会发现它其实指向其中某一个列,一旦两个 id 在同一条记录里值不一样,取错列就会得到脏数据。建议在 CTE 内部就把列名重命名清楚:
sql复制WITH t AS (
SELECT u.id AS user_id, o.id AS order_id, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
)
第二,如果使用 WITH t(a, b) AS (...) 这种显式列名列表,列数量必须和内部 SELECT 列数量一致。少一个或者多一个,MySQL 直接报 The number of column names in the WITH clause does not match,排查起来其实很快,不过新写代码时容易忽略。
第三,CTE 不允许在同一条语句的同一层作用域里重名。比如定义两个 WITH t AS (...) 直接会报重复名错误。我之前在一个超长 SQL 里加了两个名字差不多但内容完全不同的 CTE,就是图省事全叫 tmp,结果报错后还得逐个去改命名。这类代码规范性错误,能避免就避免。
5.3 在递归 CTE 里用 ORDER BY / LIMIT
很多人会想在锚点成员或者递归成员里加 ORDER BY 和 LIMIT 来控制取数范围。MySQL 对递归 CTE 的约束比普通 SELECT 严格不少。锚点成员里如果用 ORDER BY ... LIMIT,在某些版本下可能报错或者行为不符合预期。递归部分更不用说了,每层都去排序,性能会非常糟糕。
如果你的目标是要做"最短路径"或者"优先展开某些分支",不能简单地在递归成员里 ORDER BY LIMIT,而应该先查出来完整路径,再在外层做排序、分组或过滤。设计上,递归部分只负责"展开节点",最终结果怎么排序放到主查询里,这样既清晰又不容易触发语法限制。
5.4 别拿 CTE 当万能优化工具
CTE 解决的更多是"代码可读性"和"语义拆分",而不是"性能提升"。它在某些场景下会物化成临时表,你也很难去控制物化结果有没有索引。如果中间结果集特别大,外层查询又要反复过滤、关联,物化的 CTE 没有索引,扫描代价就会很高。
我遇到过的情况是:一条报表 SQL 用 CTE 把上千万行订单汇总到几十万行用户级明细,然后后面所有统计都引用这份明细,MySQL 优化器在多个引用场景下选择了物化。物化出来的临时表没有索引,最后一次关联跑了上百秒。后来我把中间结果改成真正的临时表,再在关联字段上建索引,查询直接降到几秒。
所以我的建议很直接:SQL 刚写出来阶段,优先用 CTE 保证正确和可维护;当数据量上到一定规模、或者同一份中间结果被反复引用多次时,结合执行计划判断,如果物化成了瓶颈,果断转临时表加索引。技术选型不是非此即彼,好用才是标准。
另外还有个小细节,CTE 默认情况下是给整个语句用的。如果你想在 MySQL 客户端里一段一段排查 CTE,先选中 CTE 的 SELECT 段落单独跑,改个名字加上分号,确认数据集没问题,再贴回原来的 WITH 语句里。这个操作看起来笨,但很多线上诡异结果都是某一段 CTE 本身数据有问题,后面全跟着错。分段验证是排查复杂 SQL 最有效的手段,比盯着整条语句看半天快得多。
还有一条对我帮助很大的经验:每当遇到"嵌套三层以上的子查询"这种老代码,我会直接用 CTE 做一次无行为变更的重构。重构完成后先对比结果行数和关键统计值,确认一致,再去看执行计划有没有优化空间。这个流程走顺之后,普通报表 SQL 基本一次写对,不用来回调试。
多写几次你会有体会,CTE 真正厉害的地方不在于语法炫技,而是强制你给每一段中间逻辑起名字,逼自己把复杂的业务口径拆成能看懂的小步骤。当一段 SQL 从"整个一团"变成"第一步做什么、第二步做什么、第三步做什么",维护它的人心态会完全不一样。WITH AS 就是这种让代码变清楚的第一步。
