第七章:SQL在ETL中的应用:复杂查询、窗口函数、CTE、临时表、存储过程、ETL中的SQL优化技巧
各位同学,欢迎来到第七章。这一章,我们聊聊ETL工程师吃饭的家伙——SQL。
说实话,我见过太多人把ETL当成“搬砖”工具。写个SELECT * INTO,再加个WHERE条件,就完事了。但真正高效的ETL,SQL功底必须扎实。你想想看,数据量一上来,一个烂SQL能把整个集群拖垮,而一个好SQL,可能几秒钟就跑完了。
这一章,我会把我在实战中积累的SQL技巧,尤其是那些能让你少加班的“骚操作”,都掏出来给你。
7.1 复杂查询:不只是SELECT
ETL里的复杂查询,说白了就是多表关联、子查询、聚合计算的组合拳。我见过很多新人,一遇到多表关联就写一堆嵌套子查询,性能差得离谱。
举个例子,我们要从订单表和用户表中,找出每个用户最近一笔订单的金额。很多人会这么写:
SELECT u.user_id, u.user_name,
(SELECT MAX(order_amount) FROM orders o WHERE o.user_id = u.user_id) AS last_amount
FROM users u;
嗯,这个写法没错,但性能堪忧。尤其是当用户表有几十万行时,每行都要执行一次子查询,数据库会疯掉的。
我建议用JOIN + 聚合函数替代:
SELECT u.user_id, u.user_name, MAX(o.order_amount) AS last_amount
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.user_name;
这样数据库只需要扫描一次订单表,效率高得多。
7.2 窗口函数:ETL的瑞士军刀
窗口函数,是我在ETL中最常用的工具之一。它能在不改变行数的情况下,对数据进行分组排序、累计计算、移动平均等操作。
我记得有一次,需要计算每个用户的历史累计消费金额。如果用自连接,代码会非常复杂。但用窗口函数,一行搞定:
SELECT user_id, order_date, order_amount,
SUM(order_amount) OVER (PARTITION BY user_id ORDER BY order_date) AS cumulative_amount
FROM orders;
你看,PARTITION BY 相当于分组,ORDER BY 指定排序,SUM() 做累计。这就是窗口函数的魅力。
常用的窗口函数还有:
ROW_NUMBER():给每行一个唯一序号,常用于去重RANK() / DENSE_RANK():排名,常用于Top-N分析LAG() / LEAD():取前一行/后一行的值,常用于环比、同比FIRST_VALUE() / LAST_VALUE():取分组内第一个/最后一个值
PARTITION BY,结果整个表的数据被当成一个组计算,跑出来的结果完全不对。所以,写窗口函数时,一定要先确认分组逻辑。
7.3 CTE:让SQL更清晰
CTE(公用表表达式),说白了就是给一段查询结果起个名字,方便后面复用。我特别喜欢用CTE来组织复杂的ETL逻辑。
比如,我们要先找出“高价值用户”(累计消费超过1万的),然后再统计他们的订单明细。用CTE写出来是这样的:
WITH high_value_users AS (
SELECT user_id, SUM(order_amount) AS total_amount
FROM orders
GROUP BY user_id
HAVING SUM(order_amount) > 10000
)
SELECT o.*, h.total_amount
FROM orders o
JOIN high_value_users h ON o.user_id = h.user_id;
你看,逻辑非常清晰。先定义“高价值用户”这个临时结果集,然后再用它去关联订单表。比嵌套子查询好读多了。
CTE还有一个好处:可以递归。比如处理树形结构(组织架构、分类层级)时,递归CTE是神器。
7.4 临时表:ETL的中间站
临时表,顾名思义,就是临时存放数据的表。它在ETL中非常有用,尤其是处理大数据量时。
我举个例子。有一次,我需要从源系统抽取几千万行数据,然后做复杂的清洗和转换。如果直接写一个超长的SQL,不仅难调试,而且一旦出错,整个任务都要重跑。
我的做法是:
- 先把原始数据插入临时表
- 对临时表做清洗(去重、类型转换、空值处理)
- 再做业务逻辑转换
- 最后插入目标表
这样每一步都可以单独调试,出错了也只需要重跑那一步。
临时表有两种:
- 本地临时表(#temp):只对当前会话可见,会话结束自动删除
- 全局临时表(##temp):对所有会话可见,但所有引用它的会话都关闭后才删除
7.5 存储过程:ETL的自动化引擎
存储过程,就是把一组SQL语句打包成一个可重复调用的单元。在ETL中,我经常用存储过程来实现:
- 数据抽取、清洗、加载的完整流程
- 错误处理和日志记录
- 动态SQL生成(比如根据参数动态拼接表名)
举个例子,一个简单的ETL存储过程:
CREATE PROCEDURE usp_ETL_LoadOrders
@StartDate DATE,
@EndDate DATE
AS
BEGIN
SET NOCOUNT ON;
-- 1. 抽取
SELECT * INTO #temp_orders
FROM source_orders
WHERE order_date BETWEEN @StartDate AND @EndDate;
-- 2. 清洗
DELETE FROM #temp_orders WHERE order_amount IS NULL;
-- 3. 加载
INSERT INTO target_orders (order_id, user_id, order_date, order_amount)
SELECT order_id, user_id, order_date, order_amount
FROM #temp_orders;
-- 4. 日志
INSERT INTO etl_log (table_name, rows_affected, run_time)
VALUES ('orders', @@ROWCOUNT, GETDATE());
DROP TABLE #temp_orders;
END;
你看,整个ETL流程封装在一个存储过程里,调用时只需要传两个参数:开始日期和结束日期。非常方便。
7.6 ETL中的SQL优化技巧
最后,我们来聊聊优化。这部分是我最想分享的,因为我在项目中踩过太多坑了。
7.6.1 索引策略
索引是SQL优化的第一道防线。在ETL中,我建议:
- 在JOIN字段、WHERE条件字段上建索引
- 避免在索引列上使用函数(比如
WHERE DATE(order_date) = '2024-01-01'会导致索引失效) - 对于大表,考虑使用分区表,按日期分区
7.6.2 避免SELECT *
这个我强调过很多次。在ETL中,SELECT * 会带来两个问题:
- 读取不必要的列,增加I/O开销
- 如果源表结构发生变化,ETL可能报错
我习惯显式列出需要的列名,哪怕多打几个字。
7.6.3 批量操作代替逐行操作
我曾经接手过一个ETL任务,里面用了游标(CURSOR)逐行处理数据。几百万行数据,跑了整整8个小时。我改成基于集合的批量操作后,只用了15分钟。
记住:SQL是集合语言,能用一条语句解决的,千万别用循环。
7.6.4 合理使用临时表和表变量
临时表和表变量各有优劣:
| 特性 | 临时表 (#temp) | 表变量 (@table) |
|---|---|---|
| 存储位置 | tempdb | 内存(可能溢出到tempdb) |
| 索引 | 支持 | 仅主键 |
| 事务 | 参与 | 不参与 |
| 统计信息 | 有 | 无 |
我的经验是:数据量小(几千行以内)用表变量,数据量大用临时表。
7.6.5 执行计划分析
这是优化SQL的终极武器。遇到慢查询,我会先看执行计划:
- 有没有表扫描(Table Scan)?如果有,考虑加索引
- 有没有哈希匹配(Hash Match)?如果有,考虑调整JOIN顺序
- 有没有排序(Sort)?如果有,考虑加索引或优化ORDER BY
好了,这一章的内容就到这里。SQL在ETL中的应用,远不止我上面提到的这些。但掌握了这些核心技巧,你已经能应对大部分ETL场景了。
记住,SQL是ETL工程师的基石。多写、多练、多看执行计划,你的SQL能力一定会突飞猛进。