Skip to content

FILTER 子句 ​

同一张订单表上,报表往往要同时给出「总订单数、已付款数、已退款数、已付款金额、平均客单价」——如果每个指标写一条 SQL,表就要被扫五遍。PostgreSQL 9.4 起支持的 FILTER 子句解决了这个问题:让每个聚合函数自带一个 WHERE 条件,count(*) FILTER (WHERE status = 'paid') 就像给这个聚合计了一道私有门。它语法只多一个括号,但把「多指标报表」从多次扫描变成一次扫描,是写分析类 SQL 时最值得养成的习惯之一。

前置知识

阅读本节前,建议先了解:STDDEV / VARIANCE / REGR_xxx

基础概念 ​

概念术语说明
过滤子句FILTER (WHERE ...)附加在聚合函数后的私有条件,只影响该聚合
聚合输入集Aggregate Input进入聚合过渡函数的行集合
条件聚合Conditional Aggregate用 FILTER 或 CASE WHEN 只聚合部分行
行级过滤WHERE在聚合之前执行,所有聚合共享同一份输入
组级过滤HAVING在聚合之后执行,过滤的是分组结果
单次扫描Single Scan一条 SQL 只扫一次表,产出多个指标
伪 NULL 元素Spurious NULLCASE WHEN 缺 ELSE 时产生的中间 NULL,会污染 array_agg
过渡函数Transition Function聚合内部逐行累积状态的函数,FILTER 决定哪些行进入它

三种「过滤」的执行时机完全不同,这是本节的核心:

text
   FROM 表
     │
     ▼
   WHERE 条件          ← ① 行级过滤:决定哪些行进入聚合,所有聚合共享
     │
     ▼
   GROUP BY 分组
     │
     ▼
   聚合函数 FILTER (WHERE 条件)   ← ② 聚合级过滤:只决定这一行是否喂给这个聚合
     │
     ▼
   HAVING 条件         ← ③ 组级过滤:拿到聚合结果后再筛掉整组
     │
     ▼
   ORDER BY / LIMIT
子句执行阶段作用范围能引用聚合结果吗
WHERE聚合前全查询不能
FILTER聚合过程中(每一行、每一个聚合独立判断)单个聚合不能
HAVING聚合后整组能
QUALIFY(PostgreSQL 无此关键字)窗口后整行—

一句话记忆:WHERE 决定「谁能进场」,FILTER 决定「每个聚光灯照到谁」,HAVING 决定「谁留下来」。

FILTER 与 CASE WHEN 的关系也必须先理清:

写法等价形式结果是否一致
count(*) FILTER (WHERE c)count(CASE WHEN c THEN 1 END)一致
sum(x) FILTER (WHERE c)sum(CASE WHEN c THEN x END)一致
avg(x) FILTER (WHERE c)avg(CASE WHEN c THEN x END)一致
string_agg(x, ',') FILTER (WHERE c)string_agg(CASE WHEN c THEN x END, ',')一致(string_agg 跳过 NULL)
array_agg(x) FILTER (WHERE c)array_agg(CASE WHEN c THEN x END)不一致(后者会插入 NULL 元素)
bool_and(b) FILTER (WHERE c)bool_and(CASE WHEN c THEN b END)一致

最小可运行示例:

sql
SELECT
  count(*)                                  AS 全部,
  count(*) FILTER (WHERE amount > 100)      AS 大额,
  count(*) FILTER (WHERE amount IS NULL)    AS 缺金额
FROM (VALUES (50.00), (200.00), (300.00), (NULL), (80.00)) AS t(amount);
text
 全部 | 大额 | 缺金额
------+------+--------
    5 |    2 |      1
(1 row)

贯穿本节的示例表:

sql
-- 示例库:demo
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id     BIGINT        NOT NULL,
  status      TEXT          NOT NULL,     -- pending / paid / refunded / cancelled
  amount      NUMERIC(12,2),              -- 可为 NULL:未定价订单
  channel     TEXT          NOT NULL,     -- app / web / miniapp
  created_at  TIMESTAMPTZ   NOT NULL DEFAULT now()
);

DROP TABLE IF EXISTS order_items;
CREATE TABLE order_items (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id  BIGINT  NOT NULL REFERENCES orders(id),
  sku       TEXT    NOT NULL,
  qty       INTEGER NOT NULL
);

语法与用法 ​

sql
-- 基本形态
聚合函数(expr) FILTER (WHERE 条件)

-- 与 DISTINCT 同时出现(DISTINCT 在参数里,FILTER 在后面)
count(DISTINCT user_id) FILTER (WHERE status = 'paid')

-- 与聚合内的 ORDER BY 同时出现(ORDER BY 在括号内,FILTER 在括号外)
string_agg(sku, ', ' ORDER BY created_at) FILTER (WHERE qty > 1)

-- 与窗口函数同时出现(FILTER 在 OVER 之前)
sum(amount) FILTER (WHERE status = 'paid') OVER (PARTITION BY user_id)

-- 一条 SELECT 里多个聚合各带各的 FILTER
SELECT
  count(*)                                AS 订单数,
  count(*) FILTER (WHERE status = 'paid') AS 已付款,
  sum(amount) FILTER (WHERE status = 'paid') AS 已付款额
FROM orders;

位置规则只有一条:FILTER 紧跟在聚合函数的参数括号之后、在 OVER 之前。写成 FILTER 后再接 DISTINCT、或把 FILTER 塞进参数括号里,都是语法错误。

sql
-- 正确
count(DISTINCT user_id) FILTER (WHERE status = 'paid')

-- 错误:DISTINCT 必须写在参数内
-- count(user_id) FILTER (WHERE status = 'paid') DISTINCT

详细说明 ​

FILTER 的语义:对每个聚合独立的分流器 ​

FILTER 不是「先过滤行再聚合」,而是「聚合时逐行判断这一行要不要参与」。多个聚合可以各自带不同的 FILTER,优化器仍然只扫描一次表:

sql
SELECT
  count(*)                                       AS 订单总数,
  count(*) FILTER (WHERE status = 'paid')        AS 已付款,
  count(*) FILTER (WHERE status = 'refunded')    AS 已退款,
  count(*) FILTER (WHERE status = 'cancelled')   AS 已取消,
  count(*) FILTER (WHERE status = 'pending')     AS 待付款
FROM orders;

四种状态的计数之和正好等于订单总数——这也是一个天然的自检断言:如果不等,说明 status 里出现了你没覆盖的取值。

sql
-- 用 HAVING 找出「有未知状态」的组
SELECT status, count(*)
FROM orders
GROUP BY status;

FILTER 与 CASE WHEN:一处不等价 ​

CASE WHEN 是老写法,FILTER 是新写法,绝大多数场景结果一致,但有一个必须记住的例外。

sql
-- 老写法:CASE WHEN 无 ELSE 时产生 NULL,array_agg 会把 NULL 塞进数组
SELECT array_agg(CASE WHEN qty > 1 THEN sku END) AS 老写法
FROM order_items;

-- 新写法:FILTER 版本数组里只有满足条件的元素
SELECT array_agg(sku) FILTER (WHERE qty > 1) AS 新写法
FROM order_items;
text
             老写法                     |   新写法
---------------------------------------+------------
 {A001,NULL,NULL,NULL,D020,NULL}        | {A001,D020}

原因在于 CASE WHEN ... END 缺 ELSE 时返回 NULL,而 array_agg 是保留 NULL 的聚合函数(见 STRING_AGG / ARRAY_AGG / JSONB_AGG)。string_agg / sum / avg / count / bool_and 都会跳过 NULL,所以看不出差别。

用 FILTER,不要用 CASE 造中间 NULL

array_agg / json_agg / jsonb_agg / jsonb_object_agg 都保留 NULL。用 CASE WHEN 实现条件聚合时,一旦漏写 ELSE,数组里就会多出一串 NULL,而这类 bug 在开发环境的小数据量下往往看不出来。条件聚合统一用 FILTER。

FILTER 还有一个隐性优势:条件写在聚合外面,读者一眼就能看到「这个指标只统计什么行」,而 CASE WHEN 需要钻进参数里找。

与 sum / avg 的 ELSE 0 陷阱 ​

CASE 写法还有一个比「多出 NULL」更危险的变体:为了「看起来干净」,给它补上 ELSE 0。对 count 和 avg 来说,ELSE 0 会直接算错。

sql
SELECT
  count(*)                                          AS 全部行,
  count(*)                FILTER (WHERE status = 'paid') AS filter_正确,
  count(CASE WHEN status = 'paid' THEN 1 END)             AS case_无else_正确,
  count(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)      AS case_else0_错误
FROM (VALUES ('paid'),('paid'),('paid'),
             ('canceled'),('canceled'),('canceled')) AS t(status);
text
 全部行 | filter_正确 | case_无else_正确 | case_else0_错误
--------+-------------+------------------+-----------------
      6 |           3 |                3 |               6
(1 row)

count 统计的是非 NULL 的行数,ELSE 0 让不满足条件的行也返回了一个非 NULL 值(0),于是它们全被计入,count 直接等于总行数。avg 的分母同样被撑大:

sql
SELECT
  round(avg(amount) FILTER (WHERE status = 'paid'), 2)            AS filter_正确,
  round(avg(CASE WHEN status = 'paid' THEN amount END), 2)        AS case_无else,
  round(avg(CASE WHEN status = 'paid' THEN amount ELSE 0 END), 2) AS case_else0_错误,
  round(sum(amount) FILTER (WHERE status = 'paid'), 2)            AS sum_filter,
  round(sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END), 2) AS sum_else0
FROM (VALUES ('paid',100.00),('paid',200.00),('paid',300.00),
             ('canceled',10.00),('canceled',20.00),('canceled',30.00)) AS t(status, amount);
text
 filter_正确 | case_无else | case_else0_错误 | sum_filter | sum_else0
-------------+-------------+-----------------+------------+-----------
      200.00 |      200.00 |          100.00 |     600.00 |    600.00
(1 row)
聚合ELSE 0 是否影响结果原因
count影响(3 → 6)0 非 NULL,被计入行数
avg影响(200 → 100)分母从 3 行变成 6 行,分子不变
sum不影响(600 = 600)加 0 不改变总和
min / max影响(若 0 越界)0 可能小于真实最小值
string_agg影响0 会被拼进字符串
array_agg影响数组里多出一串 0

avg 用 CASE 时必须写 ELSE NULL,或者干脆不写 ELSE

avg 的语义是「非 NULL 值的平均」,分母只应包含真正满足条件的行。写成 ELSE 0 会把所有其它行的 0 也算进分母,平均值被系统性压低。ELSE NULL(或省略 ELSE)是唯一正确的写法;而用了 FILTER 就完全不存在这个选择,因为条件根本不在参数里。

结论很直接:CASE 的写错方式有两种(漏 ELSE 多出 NULL、补 ELSE 0 撑大分母),FILTER 一种都没有。

FILTER 的语法禁区 ​

除「条件里不能放聚合」之外,FILTER 还有三条硬约束,每一条都会直接报语法或语义错误。

① 只能跟在聚合函数后面,非聚合函数会报错:

sql
-- 错误:upper 是普通函数,不是聚合
-- SELECT upper(status) FILTER (WHERE id > 1) FROM orders;
-- ERROR:  FILTER specified, but upper is not an aggregate function

② 不能叠加,一个聚合只能带一个 FILTER:

sql
-- 错误:两个 FILTER 连写
-- SELECT count(*) FILTER (WHERE id > 1) FILTER (WHERE id < 5) FROM orders;
-- ERROR:  syntax error at or near "FILTER"

需要「两个条件同时满足」时应合并成一个条件:count(*) FILTER (WHERE id > 1 AND id < 5)。

③ 条件里不能出现窗口函数:

sql
-- 错误:窗口函数不允许出现在 FILTER 条件中
-- SELECT count(*) FILTER (WHERE row_number() OVER () > 0) FROM orders;
-- ERROR:  window functions are not allowed in FILTER
写法结果
count(*) FILTER (WHERE c1 AND c2)合法(合并条件)
count(*) FILTER (WHERE c1) FILTER (WHERE c2)syntax error at or near "FILTER"
upper(x) FILTER (WHERE c)FILTER specified, but upper is not an aggregate function
count(*) FILTER (WHERE count(*) > 1)aggregate functions are not allowed in FILTER
count(*) FILTER (WHERE row_number() OVER () > 0)window functions are not allowed in FILTER

FILTER 内不能出现聚合函数 ​

FILTER 的条件是对单行的判断,不能引用聚合结果:

sql
-- 错误:FILTER 里放聚合
-- SELECT count(*) FILTER (WHERE count(*) > 1) FROM orders;
-- ERROR:  aggregate functions are not allowed in FILTER

也不允许子查询:

sql
-- 错误:FILTER 里的子查询
-- SELECT count(*) FILTER (WHERE amount > (SELECT avg(amount) FROM orders)) FROM orders;
-- ERROR:  aggregate functions are not allowed in FILTER

「只保留达到平均值的行」这类需求要分两步:先用 CTE 算出基准,再在外层过滤。

sql
WITH baseline AS (
  SELECT avg(amount) AS avg_amount FROM orders WHERE amount IS NOT NULL
)
SELECT count(*) FILTER (WHERE o.amount > b.avg_amount) AS 高于均价,
       count(*)                                        AS 全部
FROM orders o
CROSS JOIN baseline b;

「按组筛掉整组」用 HAVING,而不是 FILTER:

sql
-- 只保留订单数 >= 3 的用户,正确写法是 HAVING
SELECT user_id, count(*) AS cnt
FROM orders
GROUP BY user_id
HAVING count(*) >= 3;

FILTER 与 WHERE:别把组织者当成了入场券 ​

一个常见误解是「FILTER 能减少扫描量,所以可以替代 WHERE」。事实相反:FILTER 不会让优化器少读一行,它只是在已经读到的行上做分流。

sql
-- 写法 A:WHERE 限制输入,扫描器只读 paid 行(若有分区/索引可跳过其它行)
SELECT count(*), sum(amount)
FROM orders
WHERE status = 'paid';

-- 写法 B:FILTER 分流,仍然读全部行,只是把非 paid 行丢给别的聚合
SELECT count(*) FILTER (WHERE status = 'paid')        AS paid_cnt,
       sum(amount) FILTER (WHERE status = 'paid')     AS paid_sum,
       count(*) FILTER (WHERE status = 'refunded')    AS refund_cnt
FROM orders;

两者用途不同:

目标正确选择
只要一个指标的明细/金额WHERE(能利用索引或分区裁剪)
一条 SQL 里要多个互斥指标FILTER(一次扫描换多个指标)
指标之间不互斥、需要重叠计数FILTER(WHERE 做不到)
与其它明细列一起返回FILTER + 窗口函数

当 WHERE 的条件能命中索引时,写法 A 的扫描代价更低;但如果你本来就要在同一张表上算 5 个互斥指标,写法 B 的一次扫描通常比 5 次带索引扫描更快——尤其是这些指标覆盖了全表大部分行的时候。

FILTER 与 ORDER BY 同时使用 ​

聚合内的 ORDER BY 写在参数括号内部,FILTER 写在参数括号外部,两者顺序不能调换:

sql
-- 每个订单里数量 >= 2 的商品,按加入顺序拼成字符串
SELECT order_id,
       string_agg(sku, ', ' ORDER BY id) FILTER (WHERE qty >= 2) AS 多件商品
FROM order_items
GROUP BY order_id;
text
 order_id | 多件商品
----------+----------
        1 | A001
        2 | D020

注意:只有订单 1、2 含 qty >= 2 的行,其余订单根本不在 order_items 里,所以没有输出行;但若某订单有行却全被 FILTER 排除,string_agg 返回的是 NULL,不是空字符串。报表里若要显示 -,得包 coalesce:

sql
coalesce(string_agg(sku, ', ' ORDER BY id) FILTER (WHERE qty >= 2), '-') AS 多件商品

FILTER 与 GROUP BY / HAVING / ROLLUP 组合 ​

FILTER 只是聚合的一个修饰,因此它可以出现在任何聚合可以出现的位置。最常用的组合是「GROUP BY 提供行维度 + FILTER 提供列维度」:

sql
SELECT
  channel,
  count(*)                                       AS 总数,
  count(*) FILTER (WHERE status = 'paid')        AS 已付款,
  count(*) FILTER (WHERE status = 'refunded')    AS 已退款,
  count(*) FILTER (WHERE status = 'cancelled')   AS 已取消
FROM orders
GROUP BY channel
ORDER BY channel;
text
 channel | 总数 | 已付款 | 已退款 | 已取消
---------+------+--------+--------+--------
 app     |    4 |      1 |      1 |      0
 miniapp |    1 |      1 |      0 |      0
 web     |    3 |      2 |      0 |      1
(3 rows)

HAVING 里同样可以引用带 FILTER 的聚合,用来「按条件聚合的结果筛组」:

sql
SELECT channel,
       count(*)                                AS 总数,
       count(*) FILTER (WHERE status = 'paid') AS 已付款
FROM orders
GROUP BY channel
HAVING count(*) FILTER (WHERE status <> 'paid') >= 2
ORDER BY channel;
text
 channel | 总数 | 已付款
---------+------+--------
 app     |    4 |      1
(1 row)

ROLLUP / CUBE / GROUPING SETS 也可以与 FILTER 叠加,一次算出明细行与小计行:

sql
SELECT channel,
       count(*)                                AS 总数,
       count(*) FILTER (WHERE status = 'paid') AS 已付款
FROM orders
GROUP BY ROLLUP(channel)
ORDER BY channel NULLS LAST;
text
 channel | 总数 | 已付款
---------+------+--------
 app     |    4 |      1
 miniapp |    1 |      1
 web     |    3 |      2
         |    8 |      4
(4 rows)

最后一行是小计(channel 为 NULL),FILTER 在各层级上照常生效。

一句话区分三个子句的职责:

需求用什么原因
只要 paid 的明细WHERE聚合前缩小输入,可用索引
一条 SQL 里 paid / refunded / cancelled 各一个指标FILTER一次扫描,各自分流
筛掉「非付款单数 < 2」的渠道HAVING过滤的是分组结果

FILTER 与有序集聚合 ​

FILTER 可以与 WITHIN GROUP 的有序集聚合(如 percentile_cont)一起用,先按条件筛行,再在剩下的行上算分位数:

sql
SELECT
  percentile_cont(0.5) WITHIN GROUP (ORDER BY id) FILTER (WHERE id < 4) AS 过滤后中位数,
  percentile_cont(0.5) WITHIN GROUP (ORDER BY id)                        AS 全部中位数
FROM orders;
text
 过滤后中位数 | 全部中位数
--------------+------------
            2 |        4.5
(1 row)

书写顺序是:参数 → WITHIN GROUP (...) → FILTER (...)。与普通聚合一样,FILTER 写在最后、OVER 之前。

FILTER 条件是三值逻辑,条件为 NULL 的行会被丢弃 ​

FILTER (WHERE cond) 只在 cond 求值为 TRUE 时放行。若 cond 的结果是 NULL(三值逻辑里的「未知」),该行不会被计入——这和 WHERE 的行为一致:

sql
SELECT
  count(*)                              AS 全部,
  count(*) FILTER (WHERE amount > 100)  AS 大于100,
  count(*) FILTER (WHERE amount IS NULL) AS 金额为NULL,
  count(*) FILTER (WHERE NOT (amount > 100)) AS 不满足大于100
FROM (VALUES (50.00),(200.00),(NULL),(300.00),(NULL)) AS t(amount);
text
 全部 | 大于100 | 金额为NULL | 不满足大于100
------+---------+------------+---------------
    5 |       2 |          2 |             1

amount 为 NULL 的两行:amount > 100 是 NULL,NOT (amount > 100) 也是 NULL,所以两个条件都不收它们。只有 amount IS NULL 这种能明确判定真假的条件才能选中它们。需要把「NULL 也算作不满足」时,必须显式写成 FILTER (WHERE amount > 100 OR amount IS NULL) 或先 coalesce。

FILTER 与窗口函数结合 ​

FILTER 放在 OVER 之前,窗口照常按分区与帧滑动:

sql
SELECT
  id, user_id, status, amount,
  sum(amount) FILTER (WHERE status = 'paid')
              OVER (PARTITION BY user_id ORDER BY created_at
                    ROWS UNBOUNDED PRECEDING) AS 累计已付款,
  count(*) FILTER (WHERE status = 'refunded')
           OVER (PARTITION BY user_id)        AS 该用户退款总次数
FROM orders;

第一条窗口算「到当前订单为止,该用户累计已付款金额」,第二条算「该用户全程退款次数」。FILTER 在窗口里不会改变帧的边界,它只在帧内的行上再做一次筛选。关于窗口聚合的完整用法见 窗口聚合函数。

窗口 + FILTER 仍要显式写帧

OVER (PARTITION BY user_id ORDER BY created_at) 默认帧是 RANGE UNBOUNDED PRECEDING AND CURRENT ROW,在遇到 created_at 相同的行时会一起去包含(peer 行)。需要严格的「逐行累加」时显式写 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。

性能:一次扫描 vs 多次扫描 ​

用 UNION ALL 把五个指标写成五条子查询,与一条 FILTER 查询对比计划:

sql
-- 写法 A:五次扫描
SELECT 'paid' AS metric, count(*) FROM orders WHERE status = 'paid'
UNION ALL
SELECT 'refunded', count(*) FROM orders WHERE status = 'refunded'
UNION ALL
SELECT 'cancelled', count(*) FROM orders WHERE status = 'cancelled'
UNION ALL
SELECT 'pending', count(*) FROM orders WHERE status = 'pending'
UNION ALL
SELECT 'total', count(*) FROM orders;

-- 写法 B:一次扫描
SELECT
  count(*) FILTER (WHERE status = 'paid')      AS paid,
  count(*) FILTER (WHERE status = 'refunded')  AS refunded,
  count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
  count(*) FILTER (WHERE status = 'pending')   AS pending,
  count(*)                                     AS total
FROM orders;

写法 B 的计划里只有一个 Aggregate 节点、一次 Seq Scan,四个条件作为 Filter 挂在聚合节点上:

text
 Aggregate
   Filter: (status = 'paid'::text)
   Filter: (status = 'refunded'::text)
   Filter: (status = 'cancelled'::text)
   Filter: (status = 'pending'::text)
   ->  Seq Scan on orders  (cost=0.00..30.40 rows=2040 width=4)

写法 A 的计划则是五次 Seq Scan 加上 Append / Result 拼接。

FILTER 条件不会下推到扫描节点

WHERE status = 'paid' 会成为扫描节点的 Filter,有机会配合索引做位图扫描;而 FILTER (WHERE status = 'paid') 只挂在 Aggregate 节点上,扫描节点仍需读出全部行。判断依据看计划里 Seq Scan 上有没有 Filter: 行。

这个差别在有索引时非常直观。造一张 30 万行的表,并建一个只覆盖 paid 行的部分索引:

sql
DROP TABLE IF EXISTS big_orders;
CREATE TABLE big_orders (
  id     BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  status TEXT NOT NULL,
  amount NUMERIC(10,2)
);
INSERT INTO big_orders (status, amount)
SELECT (ARRAY['paid','refunded','canceled','pending'])[1 + (i % 4)],
       (i % 500)::numeric
FROM generate_series(1, 300000) i;

CREATE INDEX idx_big_paid ON big_orders (id) WHERE status = 'paid';
ANALYZE big_orders;

同一个条件,写成 FILTER 与写成 WHERE 的计划完全不同:

sql
-- 写法 A:FILTER —— 部分索引用不上
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FILTER (WHERE status = 'paid') FROM big_orders;
text
 Finalize Aggregate (actual time=19.513..20.834 rows=1.00 loops=1)
   Buffers: shared hit=1911
   ->  Gather (actual time=19.404..20.812 rows=2.00 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Partial Aggregate (actual time=18.210..18.210 rows=1.00 loops=2)
               ->  Parallel Seq Scan on big_orders (actual time=0.006..10.614 rows=150000.00 loops=2)
sql
-- 写法 B:WHERE —— 部分索引只读 paid 行
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM big_orders WHERE status = 'paid';
text
 Aggregate (actual time=14.252..14.253 rows=1.00 loops=1)
   Buffers: shared hit=1911 read=206
   ->  Index Only Scan using idx_big_paid on big_orders (actual time=0.040..11.076 rows=75000.00 loops=1)
         Heap Fetches: 75000
写法计划节点读到的行数
count(*) FILTER (WHERE status = 'paid')Parallel Seq Scan(全表)300000
count(*) WHERE status = 'paid'Index Only Scan using idx_big_paid75000

单个指标时不要用 FILTER

FILTER 的价值在于「一条 SQL 同时产出多个互斥指标」。当查询只算一个指标时,FILTER 相比 WHERE 没有任何收益,反而会丢掉索引——WHERE 能命中部分索引、分区裁剪、位图扫描,而 FILTER 只能全表扫描后再分流。判断标准:SELECT 列表里只有一个带 FILTER 的聚合,且没有其它需要全表输入的聚合 → 改写成 WHERE。

大表上可以用 BUFFERS 验证读了多少页:

sql
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FILTER (WHERE status = 'paid') AS paid,
       count(*) FILTER (WHERE status = 'refunded') AS refunded
FROM orders;

FILTER 只过滤已有行,不补缺失行 ​

这是「留存漏斗」「权限矩阵」类报表的头号陷阱:

sql
-- 想知道每个用户「付款后是否退款」
SELECT user_id,
       bool_or(status = 'paid')     AS 付过款,
       bool_or(status = 'refunded') AS 退过款
FROM orders
GROUP BY user_id;

如果某用户从未退款,bool_or(...) 在只有 false 行时返回 false——看起来没问题。但如果该用户的订单完全没有相应状态(例如根本没有 refunded 这条记录),bool_or 返回的是 false(因为有其它行)还是 NULL(因为过滤后无行)取决于写法。用 FILTER 时更容易踩:

sql
-- 陷阱:该用户从无退款记录 → 过滤后零行 → bool_or 返回 NULL,不是 false
SELECT user_id,
       bool_or(status = 'refunded') FILTER (WHERE status = 'refunded') AS 退过款
FROM orders
GROUP BY user_id;

要得到确定的 true / false,必须先把维度补全(CROSS JOIN 字典)再 LEFT JOIN 事实表:

sql
WITH users AS (SELECT DISTINCT user_id FROM orders),
     states AS (SELECT unnest(ARRAY['paid','refunded']) AS status)
SELECT u.user_id, s.status,
       coalesce(bool_or(o.id IS NOT NULL), false) AS 有过
FROM users u
CROSS JOIN states s
LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = s.status
GROUP BY u.user_id, s.status;

FILTER 过滤掉全部行时返回的是 NULL

bool_or / bool_and / sum / avg / max 在被 FILTER 清空时会返回 NULL,而 count 返回 0。漏掉 coalesce 的直接后果就是:报表上「未退款」显示为空白,下游把它当成「未知」而不是「否」。

实战示例 ​

第 1 步:准备数据 ​

sql
INSERT INTO orders (user_id, status, amount, channel, created_at) VALUES
  (1, 'paid',     200.00, 'app',     now() - interval '10 days'),
  (1, 'paid',     150.00, 'web',     now() - interval '7 days'),
  (1, 'refunded', 120.00, 'app',     now() - interval '5 days'),
  (2, 'paid',     999.00, 'miniapp', now() - interval '9 days'),
  (2, 'pending',  300.00, 'app',     now() - interval '1 days'),
  (3, 'cancelled', NULL,  'web',     now() - interval '8 days'),
  (3, 'paid',      50.00, 'web',     now() - interval '2 days'),
  (4, 'pending',   80.00, 'app',     now() - interval '1 days');

INSERT INTO order_items (order_id, sku, qty) VALUES
  (1, 'A001', 2), (1, 'B007', 1), (1, 'C010', 1),
  (2, 'A001', 1), (2, 'D020', 3),
  (4, 'E030', 1);

第 2 步:一次扫描的多维销售报表 ​

sql
SELECT
  count(*)                                          AS 订单总数,
  count(*) FILTER (WHERE status = 'paid')           AS 已付款单数,
  count(*) FILTER (WHERE status = 'pending')        AS 待付款单数,
  count(*) FILTER (WHERE status IN ('refunded','cancelled')) AS 失效单数,
  coalesce(sum(amount) FILTER (WHERE status = 'paid'), 0)    AS 已付款金额,
  round(avg(amount) FILTER (WHERE status = 'paid'), 2)       AS 付款平均单价,
  round(100.0 * count(*) FILTER (WHERE status = 'paid')
              / nullif(count(*), 0), 2)                      AS 付款率百分比
FROM orders;
text
 订单总数 | 已付款单数 | 待付款单数 | 失效单数 | 已付款金额 | 付款平均单价 | 付款率百分比
----------+------------+------------+----------+------------+--------------+--------------
        8 |          4 |          2 |        2 |    1399.00 |       349.75 |        50.00
(1 row)

注意三点:sum 包了 coalesce(否则一个 paid 都没有时是 NULL);avg 的分母是「paid 且 amount 非 NULL」的行数(此处恰为 4 行);除以 count(*) 时用 nullif 防除零。

第 3 步:按渠道与状态的双维交叉表 ​

GROUP BY 提供一维,FILTER 提供另一维:

sql
SELECT
  channel AS 渠道,
  count(*)                                   AS 总数,
  count(*) FILTER (WHERE status = 'paid')     AS 已付款,
  coalesce(sum(amount) FILTER (WHERE status = 'paid'), 0) AS 已付款额,
  count(*) FILTER (WHERE amount IS NULL)      AS 缺金额
FROM orders
GROUP BY channel
ORDER BY 已付款额 DESC, 渠道;
text
  渠道   | 总数 | 已付款 | 已付款额 | 缺金额
---------+------+--------+----------+--------
 miniapp |    1 |      1 |   999.00 |      0
 app     |    4 |      1 |   200.00 |      0
 web     |    3 |      2 |   200.00 |      1
(3 rows)

交叉表的两种维度分配

「行维度」用 GROUP BY,「列维度」用 FILTER。若两个维度基数都很大(如 50 个渠道 × 20 个状态),FILTER 会把 SQL 写成 1000 个聚合表达式,此时应改写成「GROUP BY 维度1, 维度2 + 应用层做透视」。

第 4 步:转化漏斗(含留存) ​

漏斗要回答「进入下一步的人里有多少走到了再下一步」,需要先补全所有步骤,再按步骤打标:

sql
WITH steps(step_no, step_name) AS (
  VALUES (1, '浏览'), (2, '加购'), (3, '下单'), (4, '付款')
),
funnel AS (
  SELECT user_id, 1 AS step_no FROM orders
  UNION
  SELECT user_id, 2 FROM orders WHERE status <> 'cancelled'
  UNION
  SELECT user_id, 3 FROM orders
  UNION
  SELECT user_id, 4 FROM orders WHERE status = 'paid'
)
SELECT s.step_no, s.step_name,
       count(DISTINCT f.user_id)                        AS 到达人数,
       count(*) FILTER (WHERE f.step_no > s.step_no)     AS 未继续人数
FROM steps s
LEFT JOIN funnel f ON f.step_no >= s.step_no
GROUP BY s.step_no, s.step_name
ORDER BY s.step_no;

本例是简化模型(用同一张订单表模拟各步骤),真实漏斗应让每一步来自对应的事件表。要点仍是:FILTER 只对已有的行做分流,缺失的步骤要靠 LEFT JOIN 补成 NULL 行,再用 count / count(DISTINCT) 计数(count 遇 NULL 返回 0,正好符合「无人到达」的语义)。

第 5 步:每个订单的「多件商品」清单 ​

FILTER 与聚合内 ORDER BY 配合:

sql
SELECT o.id AS 订单号, o.status,
       coalesce(string_agg(i.sku, ', ' ORDER BY i.id) FILTER (WHERE i.qty >= 2), '-') AS 多件商品,
       count(*) FILTER (WHERE i.qty >= 2)                    AS 多件行数,
       coalesce(sum(i.qty) FILTER (WHERE i.qty >= 2), 0)     AS 多件总件数
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.status
ORDER BY o.id;
text
 订单号 |  status   | 多件商品 | 多件行数 | 多件总件数
--------+-----------+----------+----------+------------
      1 | paid      | A001     |        1 |          2
      2 | paid      | D020     |        1 |          3
      3 | refunded  | -        |        0 |          0
      4 | paid      | -        |        0 |          0
      5 | pending   | -        |        0 |          0
      6 | cancelled | -        |        0 |          0
      7 | paid      | -        |        0 |          0
      8 | pending   | -        |        0 |          0
(8 rows)

订单 2 有 A001(1 件)和 D020(3 件),FILTER (WHERE qty >= 2) 只留下 D020。订单 3 到 8 没有任何 order_items,LEFT JOIN 产生的全 NULL 行被 FILTER 排除后 string_agg 返回 NULL,由 coalesce 显示为 -。

第 6 步:窗口 + FILTER 的累计指标 ​

sql
SELECT
  id, user_id, status, amount, created_at::date AS 日期,
  coalesce(sum(amount) FILTER (WHERE status = 'paid')
           OVER (PARTITION BY user_id ORDER BY created_at, id
                 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 0) AS 累计已付款
FROM orders
ORDER BY user_id, created_at, id;
text
 id | user_id |  status  | amount  |    日期    | 累计已付款
----+---------+----------+---------+------------+------------
  1 |       1 | paid     |  200.00 | 2026-09-14 |     200.00
  2 |       1 | paid     |  150.00 | 2026-09-17 |     350.00
  3 |       1 | refunded |  120.00 | 2026-09-19 |     350.00
  4 |       2 | paid     |  999.00 | 2026-09-15 |     999.00
  5 |       2 | pending  |  300.00 | 2026-09-23 |     999.00
  6 |       3 | cancelled|         | 2026-09-16 |       0.00
  7 |       3 | paid     |   50.00 | 2026-09-22 |      50.00
  8 |       4 | pending  |   80.00 | 2026-09-23 |       0.00

排序键写成 ORDER BY created_at, id 而不是只写 created_at,是为了在时间戳相同时有确定顺序;id 是主键,天然唯一,是最常用的兜底排序键。

第 7 步:清理 ​

sql
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS big_orders;

注意事项 ​

  1. FILTER 的位置固定在聚合参数括号之后、OVER 之前。count(DISTINCT x) FILTER (WHERE ...) OVER (...) 的书写顺序是 DISTINCT → 参数 → FILTER → OVER。
  2. FILTER 里不能出现聚合函数或子查询,报 aggregate functions are not allowed in FILTER;「高于平均值」这类需求必须先用 CTE 算基准。
  3. FILTER 不等于 WHERE:它不会减少扫描行数,也不会下推到扫描节点,只在 Aggregate 内部做分流。
  4. FILTER 清空输入时,count 返回 0 而其余聚合返回 NULL;报表里给非 count 的指标配 coalesce。
  5. CASE WHEN 与 FILTER 在 array_agg / json_agg 上不等价:CASE 缺 ELSE 会插入 NULL 元素,FILTER 不会。
  6. 用 CASE 时补 ELSE 0 会让 count 和 avg 算错:count(CASE WHEN c THEN 1 ELSE 0 END) 等于总行数,avg(CASE WHEN c THEN x ELSE 0 END) 的分母被撑大、均值被压低。ELSE NULL 或不写 ELSE 才是正确写法。
  7. FILTER 只能跟在聚合函数后,upper(x) FILTER (...) 报 FILTER specified, but upper is not an aggregate function。
  8. FILTER 不可叠加,agg(...) FILTER (a) FILTER (b) 是语法错误;两个条件必须合并成 FILTER (WHERE a AND b)。
  9. FILTER 条件中不能出现窗口函数,count(*) FILTER (WHERE row_number() OVER () > 0) 报 window functions are not allowed in FILTER。
  10. FILTER 条件是三值逻辑:条件求值为 NULL 的行不会被计入,NOT (x > 1) 与 x > 1 都不收 x IS NULL 的行。
  11. 只算一个指标时用 WHERE 而非 FILTER:FILTER 不减少扫描行数,会丢掉部分索引与分区裁剪的优势。
  12. 多个互斥指标的计数之和应等于 count(*),不等就说明覆盖了未知的状态取值,应回头检查数据而不是改 SQL。
  13. FILTER 条件里引用外部列时要注意别名作用域:FILTER 在 WHERE 之后、GROUP BY 之后求值,不能引用 SELECT 里刚定义的别名。
  14. FILTER 不会让 WHERE 的索引失效:两者可以叠加使用,WHERE 先做粗筛(走索引),FILTER 再做细分。
  15. 窗口 OVER 里的 FILTER 不改变帧边界,只对帧内的行再筛选一次;帧内全被滤掉时结果同样是 NULL。
  16. 不要用 FILTER 实现「分组后筛组」,那是 HAVING 的职责;FILTER 表达式里放聚合会直接报错。
  17. FILTER 条件应写成「正向可读」形式,例如 FILTER (WHERE status = 'paid') 优于 FILTER (WHERE NOT (status <> 'paid')),便于与指标名对齐核对。
  18. 一条 SQL 里 FILTER 数量不建议超过十几个:每个 FILTER 都是一个聚合状态,过多会让计划与 SQL 都难以维护,此时应改为长表(GROUP BY 维度)+ 应用层透视。

最佳实践 ​

  1. 「多个互斥指标」一律用 FILTER 写一条 SQL,避免同一张大表被扫多遍,也让指标口径集中在同一处便于评审。
  2. 给每个 FILTER 指标配一个中文别名,别名直接对应报表列名,减少下游映射工作:
    sql
    count(*) FILTER (WHERE status = 'paid') AS 已付款单数
  3. 非 count 的 FILTER 指标一律包 coalesce,把「无数据」明确成 0 或 -,不要把它留给前端判断。
  4. FILTER 与 WHERE 分工明确:先想清楚「这个条件是要缩小全场输入,还是只影响这一个指标」。前者写 WHERE,后者写 FILTER。
  5. 条件聚合统一用 FILTER,不用 CASE WHEN 造 NULL,特别是涉及 array_agg / json_agg 时。
  6. FILTER 中的条件与业务状态枚举保持一致,并定期用 GROUP BY status 核对是否出现枚举外的新取值。
  7. 写多重 FILTER 报表时附一条自检断言:互斥指标之和 = count(*),可以做成回归测试。
  8. 窗口 + FILTER 一定显式写帧(ROWS BETWEEN ...),不要依赖默认的 RANGE 帧,否则时间戳相同的行会被一起累加。
  9. 排序键用「业务字段 + 主键」(ORDER BY created_at, id),保证结果稳定可复现。
  10. 漏斗类报表先补维度再聚合:CROSS JOIN 步骤字典 + LEFT JOIN 事实表,FILTER 只在有行时有效。
  11. 用 EXPLAIN (ANALYZE, BUFFERS) 确认「一次扫描」:计划里应只有一个扫描节点;出现多个就说明还没合并干净。
  12. 给高频 FILTER 条件配套索引:若报表常用 WHERE channel = ? 缩小范围,索引建在 channel 上;FILTER 内的条件本身不需要索引,但当它能转化为 WHERE(只有一个指标时)时应转化为 WHERE 以利用索引。

下一节

继续学习:ORDER BY 在聚合中

参考链接 ​