8 个用户,29 条行为事件。看看 windowFunnel 怎么用一行 SQL 算出”24 小时内注册→浏览→下单→支付”的转化率。

一、漏斗分析,到底在分析什么

做过增长的同学对漏斗都不陌生。电商里最经典的一条:

注册 → 浏览商品 → 创建订单 → 支付成功

问题是:100 个注册用户,多少人浏览了?多少人下单了?多少人最终付钱了?每一步流失了多少?

传统做法你得写一堆 JOIN,还得自己算时间窗口——几十行 SQL,绕来绕去。ClickHouse 里有个专门干这事儿的函数,叫 windowFunnel,一行搞定。


二、搞点测试数据

建一张行为事件表,塞 8 个用户 29 条事件进去。

CREATE TABLE user_behavior (
    user_id String,
    event_type LowCardinality(String),  -- register / view_product / create_order / pay_success
    event_time DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, event_time);

8 个用户的数据(2026-06-05 当天)

用户场景事件序列说明
u_01快速全流程注册(08:00) → 浏览(08:30) → 下单(08:45) → 支付(09:00)1小时内完成
u_02慢速全流程注册(08:00) → 浏览(12:00) → 下单(20:00) → 支付(次日03:00)跨度19h,仍在24h内
u_03放弃支付注册(09:00) → 浏览(09:30) → 下单(10:00) → (无)下单后流失
u_04支付超时注册(09:00) → 浏览(09:30) → 下单(10:00) → 支付(次日11:00)支付了,但从注册到支付间隔26h
u_05放弃下单注册(10:00) → 浏览(10:30) → (无)浏览后流失
u_06仅注册注册(11:00) → (无)直接离开
u_07犹豫不决注册(12:00) → 浏览(12:30) → 浏览(14:00) → 浏览(16:00)反复看但不下单
u_08全流程多浏览注册(08:00) → 浏览×3 → 下单(14:00) → 浏览(15:00) → 支付(18:00)完整漏斗,中间有大量浏览

完整 INSERT 语句:

-- u_01: 完整漏斗,1h 内完成 (level=4)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_01', 'register',     '2026-06-05 08:00:00'),
    ('u_01', 'view_product', '2026-06-05 08:30:00'),
    ('u_01', 'create_order', '2026-06-05 08:45:00'),
    ('u_01', 'pay_success',  '2026-06-05 09:00:00');

-- u_02: 完整漏斗,跨度 19h (level=4, 仍在 24h 窗口内)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_02', 'register',     '2026-06-05 08:00:00'),
    ('u_02', 'view_product', '2026-06-05 12:00:00'),
    ('u_02', 'create_order', '2026-06-05 20:00:00'),
    ('u_02', 'pay_success',  '2026-06-06 03:00:00');

-- u_03: 创建订单但未支付 (level=3)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_03', 'register',     '2026-06-05 09:00:00'),
    ('u_03', 'view_product', '2026-06-05 09:30:00'),
    ('u_03', 'create_order', '2026-06-05 10:00:00');

-- u_04: 支付了但超出 24h 窗口 (level=3)
-- 从 register(09:00) 到 pay_success(次日11:00) 间隔 26h
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_04', 'register',     '2026-06-05 09:00:00'),
    ('u_04', 'view_product', '2026-06-05 09:30:00'),
    ('u_04', 'create_order', '2026-06-05 10:00:00'),
    ('u_04', 'pay_success',  '2026-06-06 11:00:00');

-- u_05: 浏览商品但未下单 (level=2)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_05', 'register',     '2026-06-05 10:00:00'),
    ('u_05', 'view_product', '2026-06-05 10:30:00');

-- u_06: 仅注册未浏览 (level=1)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_06', 'register', '2026-06-05 11:00:00');

-- u_07: 重复浏览但未下单 (level=2)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_07', 'register',     '2026-06-05 12:00:00'),
    ('u_07', 'view_product', '2026-06-05 12:30:00'),
    ('u_07', 'view_product', '2026-06-05 14:00:00'),
    ('u_07', 'view_product', '2026-06-05 16:00:00');

-- u_08: 完整漏斗,中间穿插多次浏览 (level=4)
INSERT INTO user_behavior (user_id, event_type, event_time) VALUES
    ('u_08', 'register',     '2026-06-05 08:00:00'),
    ('u_08', 'view_product', '2026-06-05 08:30:00'),
    ('u_08', 'view_product', '2026-06-05 09:00:00'),
    ('u_08', 'view_product', '2026-06-05 10:00:00'),
    ('u_08', 'create_order', '2026-06-05 14:00:00'),
    ('u_08', 'view_product', '2026-06-05 15:00:00'),
    ('u_08', 'pay_success',  '2026-06-05 18:00:00');

数据就绪,开搞。


三、windowFunnel 初体验

语法长这样:

windowFunnel(window_size)(timestamp, cond1, cond2, cond3, ...)

规则其实很简单:对每个用户,从第一个条件匹配到的那条事件开始计时,顺着时间往后找第二个条件、第三个条件……所有步骤必须发生在 window_size 秒之内。

先跑一版查询看看

SELECT
    level,
    count() AS user_count
FROM (
    SELECT
        user_id,
        windowFunnel(86400)(event_time,
            event_type = 'register',
            event_type = 'view_product',
            event_type = 'create_order',
            event_type = 'pay_success'
        ) AS level
    FROM user_behavior
    WHERE event_time >= '2026-06-01' AND event_time < '2026-06-12'
    GROUP BY user_id
)
GROUP BY level
ORDER BY level ASC;

结果出来了:

leveluser_count
11
22
32
43

看到这个结果你的第一反应可能是:啥意思?

windowFunnel 到底返回了什么

windowFunnel 对每个用户返回一个整数——这个人按顺序走到了第几步。注意,是”止步于第几步”,不是”经过了第几步”。这是个关键区别,很多第一次用的人会搞混。

一个一个看:

用户行为链windowFunnel 判断level
u_01注册→浏览→下单→支付(全部1h内)第4步在窗口内完成4
u_02注册→浏览→下单→支付(跨度19h)第4步在24h窗口内4
u_03注册→浏览→下单(无支付)第3步匹配,第4步不存在3
u_04注册→浏览→下单→支付(注册到支付26h)第3步匹配,第4步超出24h窗口被忽略3
u_05注册→浏览(无下单)第2步匹配,第3步不存在2
u_06注册(无浏览)第1步匹配,第2步不存在1
u_07注册→浏览×3第2步匹配,第3步不存在2
u_08注册→浏览×3→下单→浏览→支付全部4步在10h窗口内完成4

看懂了没?level=1 只有1人,不是说 8 个人里只有 1 个注册了——而是这 8 个人里只有 1 个人走到注册就停了(u_06)。剩下 7 个都往下走了,被归到了更高的 level 里。

GROUP BY level 的含义:

怎么从卡住人数反推漏斗?往上加就行:

支付成功 = level 4         = 3人
创建订单 = level 3 + level 4    = 2+3 = 5人
浏览商品 = level 2 + 3 + 4    = 2+2+3 = 7人
注册   = level 1 + 2 + 3 + 4  = 1+2+2+3 = 8人

所以漏斗长这样:8 → 7 → 5 → 3。每步转化率:87.5% → 71.4% → 60.0%。


四、直接拿到累积值,别自己加

上面那种”卡住人数”虽然忠实反映了 windowFunnel 的原始语义,但做报表的时候不够直观。能不能让查询直接吐出累积人数?

ARRAY JOIN 把每个用户的 level 展开:

SELECT
    step,
    count() AS user_count
FROM (
    SELECT
        user_id,
        windowFunnel(86400)(event_time,
            event_type = 'register',
            event_type = 'view_product',
            event_type = 'create_order',
            event_type = 'pay_success'
        ) AS level
    FROM user_behavior
    WHERE event_time >= '2026-06-01' AND event_time < '2026-06-12'
    GROUP BY user_id
)
ARRAY JOIN [1,2,3,4] AS step
WHERE level >= step
GROUP BY step
ORDER BY step ASC;

结果:

stepuser_count含义
188 人注册
277 人浏览了商品
355 人创建了订单
433 人支付成功

这样就直接拿到了漏斗累积值,不用自己手动加了。

这查询在干吗?

ARRAY JOIN 就是把每个用户的一行拆成多行:

拿 u_01 来说,原始:user_id='u_01', level=4
ARRAY JOIN [1,2,3,4] AS step 展开:
  u_01, level=4, step=1  → level >= 1 ✓ 留下
  u_01, level=4, step=2  → level >= 2 ✓ 留下
  u_01, level=4, step=3  → level >= 3 ✓ 留下
  u_01, level=4, step=4  → level >= 4 ✓ 留下

拿 u_06 来说,原始:user_id='u_06', level=1
ARRAY JOIN [1,2,3,4] AS step 展开:
  u_06, level=1, step=1  → level >= 1 ✓ 留下
  u_06, level=1, step=2  → level >= 2 ✗ 干掉
  u_06, level=1, step=3  → level >= 3 ✗ 干掉
  u_06, level=1, step=4  → level >= 4 ✗ 干掉

逻辑就是:你走到了 level N,说明你必然经过了 step 1 到 step N。GROUP BY step 一数,就是漏斗本来的样子。

另一个路子:sumIf 条件聚合

ARRAY JOIN 的思路是先展开再过滤。8 个人展开成 32 行,问题不大。但如果是千万级用户呢?

1000 万用户 × 4 个步骤 = 4000 万行(展开后)

数据量大起来,行爆炸对内存就不太友好了。这时候可以用 sumIf——直接在聚合结果上做条件计数,一行都不膨胀:

SELECT
    sumIf(1, level >= 1) AS step1_register,
    sumIf(1, level >= 2) AS step2_view_product,
    sumIf(1, level >= 3) AS step3_create_order,
    sumIf(1, level >= 4) AS step4_pay_success
FROM (
    SELECT
        user_id,
        windowFunnel(86400)(event_time,
            event_type = 'register',
            event_type = 'view_product',
            event_type = 'create_order',
            event_type = 'pay_success'
        ) AS level
    FROM user_behavior
    WHERE event_time >= '2026-06-01' AND event_time < '2026-06-12'
    GROUP BY user_id
);

结果:

step1_registerstep2_view_productstep3_create_orderstep4_pay_success
8753

sumIf(1, condition) 就是 countIf():条件为真加 1,否则加 0。子查询输出 8 行(每个用户一行),外层在 8 行上做 4 次条件判断,没有行膨胀。

如果需要每步一行的格式给前端用,套个 UNION ALL 就好:

SELECT '注册' AS step, sumIf(1, level >= 1) AS user_count FROM subquery
UNION ALL
SELECT '浏览商品', sumIf(1, level >= 2) FROM subquery
UNION ALL
SELECT '创建订单', sumIf(1, level >= 3) FROM subquery
UNION ALL
SELECT '支付成功', sumIf(1, level >= 4) FROM subquery;

两种方案怎么选

方案中间行数内存适用规模写起来
ARRAY JOIN + GROUP BY用户数 × 步数较高百万级以内直观
sumIf / countIf用户数千万级以上略啰嗦
UNION ALL + sumIf用户数 × 查询次数千万级以上最啰嗦

日常写探索查询用 ARRAY JOIN,思路清晰好调。上线面对大量数据切成 sumIf。两个方案结果一模一样,先用 ARRAY JOIN 把逻辑验证过,切过去放心。


五、u_04 的巧妙之处——时间窗口到底是什么

注意 u_04:他注册、浏览、下单、支付,四步全走完了,但 windowFunnel 只给了 level=3。为什么?

u_04: 注册(06-05 09:00) → 浏览(06-05 09:30) → 下单(06-05 10:00) → 支付(06-06 11:00)
                                                                        ^^^^^^^^^^^^^^^^
                                                                    距注册已经 26 小时了

windowFunnel(86400) 的窗口是 86400 秒 = 24 小时。窗口从第一步匹配到的时间起算——u_04 的窗口从 06-05 09:00 开始,06-06 09:00 结束。他的支付发生在 06-06 11:00,超了 2 小时,所以没算进去。

这个案例其实挺有意思的:u_04 愿意付钱,只是决策周期长了一点。如果把窗口从 24h 扩到 48h(windowFunnel(172800)),他就是 level=4 了。窗口设多大,得看你的业务场景——用户从注册到转化,正常的周期是多长?设短了漏人,设长了稀释漏斗的区分度。


六、windowFunnel 到底好在哪

跟手写 SQL 比一下:

方案代码量复杂度性能
手写多步 JOIN + 时间差~50行得写一堆子查询和窗口函数
windowFunnel~10行一行函数向量化执行,快

不只电商漏斗,能用的地方还挺多:


七、mode 参数:让漏斗更”严格”

前面用的都是默认模式。windowFunnel 的完整签名其实有三个参数:

windowFunnel(window_size, mode, auto_fix)(timestamp, cond1, cond2, ...)

mode 有三个值,区别在于对”中间噪音”的容忍程度。

7.1 strict_deduplication(默认)

无关事件直接跳过。 最宽松,也是前面所有例子用的模式。

u_08: register(08:00) → view(08:30) → view(09:00) → view(10:00) → order(14:00) → pay(18:00)

链条匹配:register(08:00) → view(08:30) → order(14:00) → pay(18:00) → level 4

中间那几次多余的浏览直接忽略。只要按顺序找到每个条件的第一次命中,链条就成立。

7.2 strict_order

链条中间不能出现”回退事件”。 比默认严格——在某步之后如果又冒出前面的步骤事件,链条就断了。

假设有个用户 u_09,注册了两次(可能是 bug 或者手滑):

u_09: register(08:00) → view(09:00) → register(10:00) → order(11:00) → pay(12:00)
模式匹配过程level
strict_deduplicationregister(08:00) → view(09:00) → register(10:00) 跳过 → order(11:00) → pay(12:00)4
strict_orderregister(08:00) → view(09:00) → register(10:00) 又来了个 register,链条断了2

strict_order 的逻辑:正在找 create_order 呢,结果冒出一个 register(步骤 1 的事件),说明用户行为回退了,漏斗不成立。

7.3 strict_increase

strict_order 的基础上,还要时间戳严格递增。 最严的模式。

现实中可能会遇到这种情况——同一秒来了多个事件(批量导入、客户端时钟问题):

u_10: register(08:00) → view(09:00) → order(09:00) → pay(10:00)
                                    ^^^^^^^^^^^^^^^^
                                 view 和 order 时间戳一样
模式匹配过程level
strict_orderregister(08:00) → view(09:00) → order(09:00 同时间戳放行) → pay(10:00)4
strict_increaseregister(08:00) → view(09:00) → order(09:00) 时间戳没递增,断了2

模式速查

模式中间无关事件前序事件回退相同时间戳什么时候用
strict_deduplication放过放过放过绝大多数场景,默认用这个
strict_order放过拦住放过不能容忍行为回退
strict_increase放过拦住拦住排查数据质量问题、最严格场景

SQL 怎么写

-- 默认模式(不写 mode 就行)
SELECT user_id, windowFunnel(86400)(event_time,
    event_type = 'register',
    event_type = 'view_product',
    event_type = 'create_order',
    event_type = 'pay_success'
) AS level
FROM user_behavior
GROUP BY user_id;

-- 严格顺序
SELECT user_id, windowFunnel(86400, 'strict_order')(event_time,
    event_type = 'register',
    event_type = 'view_product',
    event_type = 'create_order',
    event_type = 'pay_success'
) AS level
FROM user_behavior
GROUP BY user_id;

-- 严格递增 + 开启时钟修正
SELECT user_id, windowFunnel(86400, 'strict_increase', 1)(event_time,
    event_type = 'register',
    event_type = 'view_product',
    event_type = 'create_order',
    event_type = 'pay_success'
) AS level
FROM user_behavior
GROUP BY user_id;

九成场景默认模式够用了。strict_order 在你确实需要验证”用户没有倒退”的时候才开。strict_increase 基本是排查数据问题时候的杀手锏。


八、可视化实战

SQL 查出来数据了,最后一步得让老板看到能看得懂的图表。数据扔进漏斗图长这样:

    ┌─────────────────────────────────────────┐
    │         注册            8 人            │
    │                 87.5%                   │
    ├─────────────────────────────────┤
    │       浏览商品         7 人             │
    │                 71.4%                   │
    ├──────────────────────────┤
    │      创建订单         5 人              │
    │                 60.0%                   │
    ├────────────────────┤
    │     支付成功        3 人                │
    └────────────────────┘

下面来看看怎么把这个数据变成一个实际可用的系统。

8.1 后端 API

封一个接口,把 ARRAY JOIN 查询的结果吐成 JSON:

GET /api/funnel
{
  "steps": [
    { "name": "注册", "value": 8, "rate": "100.0%" },
    { "name": "浏览商品", "value": 7, "rate": "87.5%" },
    { "name": "创建订单", "value": 5, "rate": "71.4%" },
    { "name": "支付成功", "value": 3, "rate": "60.0%" }
  ]
}

Go、Ruby、Python、Node 都行,逻辑一样:执行 ClickHouse 查询,把 step 和 user_count 一对一对映射成 name 和 value,顺手算一下每步转化率。

8.2 前端 ECharts 漏斗图

ECharts 自带漏斗图类型,几行代码出图:

<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<div id="chart" style="width:600px;height:400px"></div>
<script>
  const data = [
    { name: '注册', value: 8 },
    { name: '浏览商品', value: 7 },
    { name: '创建订单', value: 5 },
    { name: '支付成功', value: 3 }
  ];

  const chart = echarts.init(document.getElementById('chart'));
  chart.setOption({
    series: [{
      type: 'funnel',
      data,
      label: { show: true, position: 'inside' }
    }]
  });
</script>

ECharts 漏斗图效果

保存成 funnel.html 打开就能看。实际项目里把 data 换成 fetch('/api/funnel') 就接上了。

8.3 多维度对比

业务上经常需要按渠道拆开看——比如微信推广 vs 抖音推广的漏斗对比:

SELECT
    channel,
    step,
    count() AS user_count
FROM (
    SELECT
        user_id,
        channel,
        windowFunnel(86400)(event_time,
            event_type = 'register',
            event_type = 'view_product',
            event_type = 'create_order',
            event_type = 'pay_success'
        ) AS level
    FROM user_behavior
    WHERE event_time >= '2026-06-01' AND event_time < '2026-06-12'
    GROUP BY user_id, channel
)
ARRAY JOIN [1,2,3,4] AS step
WHERE level >= step
GROUP BY channel, step
ORDER BY channel, step;

前端用 ECharts 的多个漏斗并排,或者用 dataset + transform 切换维度,哪个渠道转化好一目了然。

8.4 整条链路

┌──────────┐    ┌───────────┐    ┌──────────┐    ┌──────────┐
│ 行为埋点  │ →  │ ClickHouse│ →  │ 后端 API  │ →  │ 前端图表 │
│ (SDK采集)│    │windowFunnel│    │(/api/     │    │(ECharts) │
│          │    │  + ARRAY    │    │ funnel)   │    │          │
│          │    │   JOIN     │    │           │    │          │
└──────────┘    └───────────┘    └──────────┘    └──────────┘

从埋点入库到可视化,核心查询就一行 windowFunnel,前后端加起来也不过百来行。现代分析型数据库配上成熟的图表库,做漏斗真的没那么费劲。


九、总结

  1. windowFunnel 返回的是每个用户的最高到达层级,不是累积人数——这个一开始容易搞混
  2. 漏斗累积值 = 各层级止步人数从高到低累加,或者直接用 ARRAY JOIN
  3. 数据量大时 sumIf 替代 ARRAY JOIN,避免行膨胀
  4. 时间窗口从第一步事件的时间戳起算,不是查询的时间范围
  5. 窗口大小看业务,设短了漏人,设长了没区分度

8 个用户,29 条事件,一行 windowFunnel,整个漏斗清清楚楚。这就是为什么 ClickHouse 做分析爽。


参考文档