团队里没人敢动的那段 100 行 SQL,我用 7 步就拆透了

我敢打赌,每个数据团队的代码仓库里,都躺着那么一两段没人愿意碰的 “祖传 SQL”。一百多行,N 层嵌套 CTE,窗口函数套着窗口函数。写它的人早就离职了,注释约等于没有,没人敢改,也没人能完全读通。业务改个小口径,接盘的人要先花两小时捋清每层子查询的作用,改完再花一小时调试,生怕动了一行,塌了一整片逻辑。

周五下午四点,产品经理在群里 @了你:“线上那个‘每日累计去重商品数’的逻辑要确认一下,今天能上线吗?”

你打开那段 SQL,准确地说,是半年前前同事留下的那段 SQL。

先看这个业务场景:每天累计去重计数

需求本身听起来并不复杂。有一张表,记录了每个账号每天购买的商品名称:

Date

ACCOUNT

NAME

2024-01-01

A

XOXO

2024-01-02

A

XOXO

2024-01-02

A

OXXO

2024-01-04

A

XOOX

2024-01-05

A

OOXO

2024-01-06

A

XOXO

2024-01-01

B

B11

2024-01-02

B

B21

2024-01-02

B

B21

2024-01-02

B

B22

2024-01-02

B

B11

2024-01-03

B

B31

2024-01-01

C

C1

2024-01-02

C

C1

2024-01-03

C

C1

要计算的结果是:每个账号到每天之前(不含当天)曾经购买过多少种不同的商品

Date

ACCOUNT

COUNT_DISTINCT_NAME

2024-01-01

A

Null

2024-01-02

A

1

2024-01-04

A

2

2024-01-05

A

3

2024-01-06

A

4

2024-01-01 是第一天,之前没有任何购买记录,所以是 Null。2024-01-02 时,A 账号在 1 月 1 日买过 XOXO 这一种商品,所以累计去重数是 1。到了 1 月 4 日,1 月 1 日和 1 月 2 日两天累计买过 XOXO 和 OXXO 两种,所以是 2。依此类推。

按账号分组、按日期排序,依次处理每一天的数据,取从第一天到前一天的区间,进行去重计数。按说逻辑很清晰,对吧?

生产环境里的 SQL 长这样

你打开那段 SQL,它长这样(已经删掉了注释,但保留了全部逻辑):

WITH 
cte_base AS (
    SELECT account, dt, name,
        1 AS dummy_const, 'N/A' AS dummy_text
    FROM orders WHERE 1 = 1
),
cte_with_row_num AS (
    SELECT account, dt, name,
        ROW_NUMBER() OVER (PARTITION BY account ORDER BY dt) AS row_num
    FROM cte_base
),
cte_with_lag AS (
    SELECT account, dt, name, row_num,
        LAG(name) OVER (PARTITION BY account ORDER BY dt) AS lag_name,
        LAG(dt) OVER (PARTITION BY account ORDER BY dt) AS lag_dt
    FROM cte_with_row_num
),
cte_self_join_distinct AS (
    SELECT a.account, a.dt, a.name,
        COUNT(DISTINCT b.name) AS self_join_distinct_count
    FROM cte_with_row_num a
    LEFT JOIN cte_with_row_num b 
        ON a.account = b.account AND b.row_num < a.row_num
    GROUP BY a.account, a.dt, a.name, a.row_num
),
cte_with_window AS (
    SELECT account, dt, name, row_num,
        COUNT(DISTINCT name) OVER (
            PARTITION BY account 
            ORDER BY dt
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
        ) AS window_distinct_count
    FROM cte_with_row_num
),
cte_combined AS (
    SELECT w.account, w.dt, w.name, w.window_distinct_count,
        s.self_join_distinct_count,
        CASE WHEN w.window_distinct_count = s.self_join_distinct_count 
             THEN 'MATCH' ELSE 'MISMATCH' END AS match_flag
    FROM cte_with_window w
    LEFT JOIN cte_self_join_distinct s 
        ON w.account = s.account AND w.dt = s.dt AND w.name = s.name
),
cte_with_flag AS (
    SELECT account, dt, name,
        window_distinct_count,
        EXTRACT(YEAR FROM dt) AS dt_year,
        EXTRACT(MONTH FROM dt) AS dt_month,
        EXTRACT(DAY FROM dt) AS dt_day,
        ROW_NUMBER() OVER (PARTITION BY account, dt ORDER BY name) AS rn_per_day
    FROM cte_combined
),
cte_daily_agg AS (
    SELECT account, dt,
        MAX(window_distinct_count) AS daily_distinct_count,
        COUNT(DISTINCT name) AS daily_unique_names,
        COUNT(*) AS daily_total_rows
    FROM cte_with_flag
    GROUP BY account, dt
),
cte_with_previous AS (
    SELECT account, dt, daily_distinct_count,
        LAG(daily_distinct_count) OVER (PARTITION BY account ORDER BY dt) AS prev_day_count,
        LAG(dt) OVER (PARTITION BY account ORDER BY dt) AS prev_dt,
        (SELECT COUNT(DISTINCT dt) FROM cte_daily_agg b WHERE b.account = a.account) AS total_days
    FROM cte_daily_agg a
),
cte_final AS (
    SELECT account, dt,
        CASE WHEN daily_distinct_count = 0 THEN NULL ELSE daily_distinct_count END AS count_distinct_name,
        prev_day_count AS dummy_prev
    FROM cte_with_previous
    WHERE 1 = 1
    UNION ALL
    SELECT NULL, NULL, NULL, NULL WHERE 1 = 0
)
SELECT DISTINCT account, dt, count_distinct_name
FROM cte_final 
WHERE account IS NOT NULL
ORDER BY account, dt;

你盯着屏幕看了三十分钟。

这段 CTE 在干嘛?那个 cte_self_join_distinct 为什么要用自连接做去重计数?既然已经用了窗口函数,为什么还要把两个结果拼在一起对比 MATCH/MISMATCH?那些 EXTRACT(YEAR FROM dt) 的字段在最外层一个都没用到,为什么还留着?最后那个 UNION ALL 空集和 SELECT DISTINCT 又是怎么回事?

理论上,用窗口函数应该不用写的这么复杂,但生产环境里就是长成了这样。可能因为需求一步步叠加,一个人改了加一段,另一个人接手后不敢删旧的,只能在上面继续堆;也可能是某次紧急修复时加了个临时补丁,后来没人记得清理。代码像年轮一样层层累积,膨胀到了接近 100 行。

代码能跑。但没人敢动。

100 行 SQL 的问题不在“长”,在“没法动”

这段 SQL 的问题不是“长,长代码可以忍受。真正的问题是:改动它的风险,比重写还高。

为什么?

第一,你不知道哪些代码是“活”的。

那段 cte_self_join_distinct,明明 cte_with_window 已经用窗口函数算出了同样的去重计数,为什么还要保留一个自连接去重?是有什么边界情况窗口函数处理不了?还是写的人忘了删?那些 EXTRACT(YEAR FROM dt)、EXTRACT(MONTH FROM dt)、EXTRACT(DAY FROM dt) 的字段,以及 ROW_NUMBER()OVER (PARTITION BY account, dt ORDER BY name) AS rn_per_day,最外层一个都没用到,为什么还留着?你不知道。

第二,你不知道改了会断什么。

最外层的 cte_final 包含一个 UNION ALL 空集,然后又被 SELECT DISTINCT 包了一层。你想删掉这些显然没用的东,但万一它们存在的目的是为了让某个下游依赖保持字段类型呢?万一某一天某个定时任务正好依赖了这个 UNION ALL 的字段数量呢?你不知道,也不敢试。

第三,你没有办法“小步试错”。

SQL 是声明式的,你不能在中间打个断点看看 cte_self_join_distinct 算出来到底是什么,也没法在 cte_with_window 执行完后暂停观察结果。你只能把整段 SQL 跑完,然后祈祷结果是对的。如果 cte_daily_agg 里某个边界条件写错了,直到最外层 SELECT DISTINCT 才会暴露问题,而那时候你根本不知道是哪一层出了问题。

第四,AI 也没解决这个问题。

即便你让 AI 重写这段 SQL,你也得先完整地告诉 AI 这段 SQL 到底在做什么,而你刚刚花了两个小时还没完全搞明白。改完了一跑,结果不对,你甚至不知道是 AI 理解错了还是你自己描述错了。最后你只能回到原点:这段代码既然能跑,不如就别动了。

换一种写法:把 100 行变成 7 步

SQLazy 的做法是:让分组能保持分组子集,让每一步操作都保留中间结果供下一步使用。

按账号分组,再按日期分组,依次处理每组数据,取从开始到上一日的所有记录,去重计数。用 SQLazy 的 workflow 写:

VariableName

Anchor

Statement

Value5

Table1

sort account dt name

Value


compute dt min as dtm if(dt=dtm then 1 else 0) as is_first partition account name

Value1


summarize if(is_first=1 then name) icount as daily_new group account dt

Value2


sort dt

Value3


compute daily_new cum as cumulative partition account

Value4


compute if(cumulative-daily_new=0 then null else cumulative - daily_new) as count_distinct_name

Value6


sort account dt

Value7


derive dt account count_distinct_name

不是“少写几行代码”,而是“换了一种思考方式”。

第 1 步:按账号、日期、商品名排序——把数据理顺。

第 2 步:对每个账号的每种商品,标记它第一次出现的日期。is_first=1 表示这一天是该商品首次被该账号购买。

第 3 步:按账号和日期汇总,统计每天有多少种商品是“第一次出现”——也就是当日新增的去重商品数

第 4 步:按日期排序——为累计做准备。

第 5 步:对每个账号,计算 daily_new 的累计值——也就是截至当天累计购买过的不同商品总数

第 6 步:计算最终结果——cumulative - daily_new 就是“截至前一天累计购买过的不同商品总数”。如果结果为 0,则置为 Null。

第 7 步:整理输出,只保留需要的字段。

每一步都可以预览中间结果。第 3 步执行完,你立刻能看到每天新增了多少种商品;第 5 步执行完,累计值对不对当场就知道。不需要跑到最外层才看到结果出错。

..

编译器保证:SQL 自动生成 100% 准确

编写 workflow 后,通过编译器生成目标数据库 SQL。

..

半年后需求变了:“不仅要算累计去重数,还要区分‘老商品’和‘新商品’,每天新增的商品里,有多少是以前从未出现过的,有多少是以前出现过的。”

在原来那 100 行 SQL 里,你无从下手。因为你根本不知道哪一层 CTE 在区分“首次出现”和“重复出现”,那些 is_first 标记可能被埋在某层的 CASE WHEN 里,也可能根本没有这个维度。你要么从头重写,要么在某个 CTE 里硬塞一个额外的聚合逻辑,然后祈祷不影响后面的累计计算。

在 SQLazy 的 workflow 里,改两步(核心只有一步):

原来的第 3 步:summarize if(is_first=1 then name) icount as daily_new group account dt——统计每天首次出现的商品数。

原来的第 7 步:derive dt account count_distinct_name ——整理输出结果。

现在要区分“新”和“旧”,只需要在第 3 步里加一个计数维度:

第 3 步:summarize if(is_first=1 then name) icount as daily_new,if(is_first=0 then name) icount as daily_repeat; group account dt

然后在第 7 步输出需要的字段:derive dt account,daily_new,daily_repeat, count_distinct_name

主要就改了一行,在 summarize 里加了一个 daily_repeat。编译器重新生成 SQL,新的字段自动出现在结果中。workflow 的其他步骤完全不用动,因为 daily_new 和 daily_repeat 都是按同一维度分组的,后续的累计逻辑天然兼容。

只改一行,不需要理解 100 行 SQL 的每一层 CTE 在干什么,不需要担心改了 A 影响 B。

编译器重新生成 SQL,100% 准确,没有幻觉。

..

如果目标数据库从 MySQL 换成 PostgreSQL,切换一个选项,workflow 不用改,SQL自动适配方言

..

LLM 辅助规范

在 SQLazy 的 workflow 中,AI 的角色也被重新定义了。以往做法中,AI 负责从自然语言直接生成最终 SQL,高难度、高幻觉率。而在 SQLazy 中,AI 只做一件事:把用户口语化的步骤描述转译成规范的 workflow 语法。前者是“决策”,后者是“翻译”。即便 AI 转译有偏差,你在 workflow 层面一眼就能发现,纠正成本几乎为零。

..

借助 LLM 把自然语言转成 SQLazy 语法

那 100 行 SQL 是前同事留下的。但说实话,三个月后你自己回头看自己写的 100 行 SQL,也未必能轻松看懂。这不是代码规范的问题。这是 SQL 这种语言本身的问题,它更适合机器执行,不适合人类阅读。

SQLazy 做的事情很简单:让你写给人看的那份逻辑,同时也是能编译成 SQL 的那份代码。代码即文档,两者是同一个东西。

与 AI 结合使用,可以发现 SQLazy 的核心逻辑:

AI writes the logic. A compiler writes the SQL.