一、当我们谈论SQL性能时在谈什么

记得上个月帮同事优化一个报表系统时,遇到个有趣的场景:要计算每个销售大区的月销售额排名。原始方案用的是自连接嵌套子查询,执行时间要8秒多,改成窗口函数后只需要0.3秒——这性能差就像从绿皮火车换到磁悬浮。但窗口函数真的在所有场景都万试万灵吗?今天我们就带着这问题,用真实数据来次性能大比拼。

技术栈说明:本文所有示例基于SQLite 3.32.0及以上版本(需支持窗口函数),测试环境为MacBook Pro M1/16GB。

二、战场准备:万级测试数据构建

先造点有说服力的测试数据。我们模拟某电商平台订单明细:

-- 创建带索引的测试表
CREATE TABLE orders (
    order_id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    category VARCHAR(20),  -- 商品类别
    amount DECIMAL(10,2),  -- 订单金额
    order_time DATETIME    -- 下单时间
);

-- 建立常用查询的复合索引
CREATE INDEX idx_user_time ON orders(user_id, order_time);
CREATE INDEX idx_category ON orders(category);

-- 生成百万级测试数据(现实场景请勿在事务中批量插入)
INSERT INTO orders(user_id, category, amount, order_time)
WITH RECURSIVE 
  counter(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM counter WHERE x<1000000)
SELECT 
  (x % 5000) + 1,
  CASE x%5 WHEN 0 THEN '3C' WHEN 1 THEN '服饰' WHEN 2 THEN '美妆' ELSE '家居' END,
  (RANDOM() % 5000)/100.0,
  DATETIME('now','-' || (RANDOM() % 365) || ' days')
FROM counter;

三、实战较量:三种实现方式的生死时速

场景1:计算每个用户最近10笔订单平均金额

3.1 子查询方案

EXPLAIN QUERY PLAN
SELECT 
    o1.user_id,
    o1.order_id,
    (SELECT AVG(amount) 
     FROM (SELECT amount 
           FROM orders o2 
           WHERE o2.user_id = o1.user_id 
           ORDER BY order_time DESC 
           LIMIT 10)) as avg_10
FROM orders o1
WHERE o1.user_id BETWEEN 100 AND 105;

执行计划显示:SCAN TABLE orders AS o1,每个外层记录都会触发一次子查询,导致总执行步骤复杂度达到O(n²)

3.2 自连接方案

WITH ranked_orders AS (
    SELECT 
        user_id,
        order_id,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn
    FROM orders
)
SELECT 
    r.user_id,
    r.order_id,
    AVG(o.amount) as avg_10
FROM ranked_orders r
JOIN orders o ON r.user_id = o.user_id
    AND o.order_time >= (SELECT order_time 
                        FROM ranked_orders 
                        WHERE user_id = r.user_id AND rn = r.rn
                        ORDER BY order_time DESC
                        LIMIT 1 OFFSET 9)
WHERE r.rn <= 10
GROUP BY r.user_id, r.order_id;

这种写法虽然避免了多重子查询,但关联条件和偏移量计算仍然需要复杂计算,导致实际执行时间超过子查询方案

3.3 窗口函数方案

SELECT 
    user_id,
    order_id,
    AVG(amount) OVER (
        PARTITION BY user_id 
        ORDER BY order_time DESC
        ROWS BETWEEN CURRENT ROW AND 9 FOLLOWING
    ) as avg_10
FROM orders
WHERE user_id BETWEEN 100 AND 105;

查看执行计划:SCAN TABLE orders USING INDEX idx_user_time,窗口函数只需要一次索引扫描就完成所有计算

测试结果对比(100用户数据量)

实现方式 执行时间 内存消耗 查询计划复杂度
子查询 6.82秒 38MB ★★★★☆
自连接 9.15秒 105MB ★★★★★
窗口函数 0.17秒 12MB ★★☆☆☆

四、那些年我踩过的坑:性能陷阱全解析

4.1 分组陷阱:错用PARTITION BY导致雪崩

上周碰到个典型案例:某同事想统计每类商品销量Top 10%

错误写法:

SELECT *
FROM (
    SELECT 
        category,
        amount,
        NTILE(10) OVER (PARTITION BY category) as bucket
    FROM orders
) 
WHERE bucket = 1;

这个查询会导致全表扫描,当遇到PARTITION BY category时,如果category基数很小(比如只有5种),SQLite会使用临时表存储中间结果。正确做法是强制走索引:

SELECT *
FROM (
    SELECT 
        category,
        amount,
        NTILE(10) OVER (ORDER BY category, amount DESC) as bucket
    FROM orders INDEXED BY idx_category
) 
WHERE bucket <= 1;

4.2 框架函数的选择:ROW_NUMBER vs RANK

处理并列排名时需特别注意:

-- 错误案例:错用ROW_NUMBER处理考试成绩排名
SELECT 
    student_id,
    ROW_NUMBER() OVER (ORDER BY score DESC) as rank
FROM exams;

-- 正确姿势:处理有并列情况
SELECT 
    student_id,
    RANK() OVER (ORDER BY score DESC) as rank
FROM exams;

当遇到相同分数时,RANK会跳过名次(如100分并列第一后第三名直接显示3),而DENSE_RANK则保持连续排名。

五、技术选型路线图:什么时候该选谁

5.1 优先使用窗口函数的场景

  • 需要访问前后记录的运算(如移动平均)
  • 涉及分组内的动态计算(如分组累计值)
  • 需要多层级聚合时(如既要总排名又要分组排名)

5.2 子查询/自连接仍有优势的情况

  • 仅需要简单分组聚合(求和、计数等)
  • 目标数据库版本不支持窗口函数
  • 需要强制使用特定索引的场景

5.3 混合使用技巧

实际业务中最优方案往往是组合拳:

-- 先用窗口函数快速筛选,再用子查询获取明细
WITH base AS (
    SELECT 
        user_id,
        SUM(amount) OVER (PARTITION BY user_id) as total,
        ROW_NUMBER() OVER (ORDER BY order_time) as rn
    FROM orders
    WHERE order_time > '2023-01-01'
)
SELECT *
FROM base
WHERE total > 5000
    AND rn BETWEEN 100 AND 200;

六、专家级优化锦囊(附真实代码)

6.1 内存控制妙招

当处理超大结果集时,强制分片计算:

-- 分段计算用户分层(每处理1万用户提交一次)
BEGIN TRANSACTION;
WITH users_chunk AS (
    SELECT user_id 
    FROM users 
    WHERE user_id > ? 
    ORDER BY user_id 
    LIMIT 10000
)
INSERT INTO user_segments
SELECT 
    user_id,
    CASE 
        WHEN total_orders > 100 THEN 'VIP' 
        ELSE '普通' 
    END as segment
FROM (
    SELECT 
        user_id,
        COUNT(*) OVER (PARTITION BY user_id) as total_orders
    FROM orders
    WHERE user_id IN (SELECT user_id FROM users_chunk)
);
COMMIT;

6.2 强制索引的黑魔法

SQLite默认不会在窗口函数中使用索引,需要显式指定:

-- 通过INDEXED BY强制使用索引
SELECT 
    user_id,
    SUM(amount) OVER (PARTITION BY user_id ORDER BY order_time) 
FROM orders INDEXED BY idx_user_time
WHERE user_id < 5000;

七、总结:没有银弹但有指南针

经过多个项目的实战验证,关于SQLite窗口函数的性能真理是:对于中等数据量(<100万行)的复杂分析,窗口函数的性能普遍比传统写法快5-10倍。但当处理超大宽表时,内存消耗可能成为新瓶颈。

最终决策树:

  1. 是否需要跨行访问 → 选窗口函数
  2. 是否简单聚合 → 传统写法更优
  3. 结果集是否超大 → 分段处理+索引强制
  4. 是否需要复用中间结果 → CTE+窗口函数组合