SQL 数据分析 · 指南 · 2026年10月29日 · 12 分钟阅读

用 SQL 做留存分析:次日与 7 日留存、周留存矩阵和滚动留存

一张由深青色渐变到浅色的三角形方格矩阵,旁边几组乘客正走向一架飞机

同期群留存只回答一个问题:在某个时间段开始使用的人里,后来有多少比例回来了?同期群(cohort)是起点相同的一组用户,比如首次访问、首次购买或注册在同一天或同一周。留存率是同期群中在 N 天或 N 周之后再次活跃的比例。把同期群作为行、时间段作为列排开,就是大家熟悉的三角形留存矩阵。

用 SQL 做留存分析,每个查询都是同样的三步:

  1. 把行为日志压缩成每个用户每天一行;
  2. 找出每个用户的第一天(即同期群);
  3. 把两者 JOIN 起来,统计每个同期群在每个偏移量上有多少用户活跃。

数字出错往往出在细节上:谁算「新用户」、整数除法怎样舍入、还没发生的时间段该显示什么。本文基于一家教学用航空公司的 App 日志(app_events,2026 年 7 月 1 日至 8 月 11 日,快照时间为 8 月 11 日 18:00)一步步搭建分析。每个查询都在这份数据上运行过,每张表都是真实输出,你可以在 Nomto 的 SQL 练习场里复现每一步。

第一步:每个用户每天一行

这里的用户键是 client_id,代表一次 App 安装或一个浏览器。活跃的定义是「当天有任何事件」:

SELECT DISTINCT client_id, CAST(event_time AS DATE) AS active_day
FROM app_events;

DISTINCT 在这里起了两个作用:一个用户一天有二十个事件,也只算一个活跃日;同一事件被重复上报(这份日志已知的一个问题)也一并消除了。

第二步:同期群就是首个活跃日

WITH client_days AS (
  SELECT DISTINCT client_id, CAST(event_time AS DATE) AS active_day
  FROM app_events
)
SELECT client_id, MIN(active_day) AS first_day
FROM client_days
GROUP BY client_id;

这里的「首个」只是相对于你手上的数据而言。日志从 7 月 1 日开始,所以那天活跃的每个人看起来都像新用户,其中包括已经用了好几年的老用户。任何日志的头几天都会被这样「污染」,不应该拿来当同期群。在这份数据里,每天「新」客户的流入量要过了第一周才稳定下来,所以下面的例子都从 7 月中旬开始划分同期群。

如果你有真实的注册日期或安装日期,就用它。这份数据里会员计划有 joined_at,但很多会员是在机场柜台或网站上加入的,从来没打开过 App,所以就 App 留存而言,首个事件是更合适的起点。

第 N 日留存:次日留存和 7 日留存

经典的第 N 日留存问的是:用户在首日之后的恰好第 N 天有没有活跃?计算方法是把每个活跃日 JOIN 回用户,并为每个用户设置一个 0/1 标记:

WITH client_days AS (
  SELECT DISTINCT client_id, CAST(event_time AS DATE) AS active_day
  FROM app_events
),
first_days AS (
  SELECT client_id, MIN(active_day) AS first_day
  FROM client_days
  GROUP BY client_id
),
flags AS (
  SELECT f.client_id, f.first_day,
    MAX(CASE WHEN cd.active_day = f.first_day + 1 THEN 1 ELSE 0 END) AS d1,
    MAX(CASE WHEN cd.active_day = f.first_day + 7 THEN 1 ELSE 0 END) AS d7,
    MAX(CASE WHEN cd.active_day BETWEEN f.first_day + 1 AND f.first_day + 7
             THEN 1 ELSE 0 END) AS within7
  FROM first_days f
  JOIN client_days cd ON cd.client_id = f.client_id
  GROUP BY f.client_id, f.first_day
)
SELECT first_day, COUNT(*) AS clients,
  ROUND(100.0 * SUM(d1) / COUNT(*), 1) AS d1_pct,
  CASE WHEN first_day + 7 <= DATE '2026-08-10'
       THEN ROUND(100.0 * SUM(d7) / COUNT(*), 1) END AS d7_pct,
  CASE WHEN first_day + 7 <= DATE '2026-08-10'
       THEN ROUND(100.0 * SUM(within7) / COUNT(*), 1) END AS within7_pct
FROM flags
WHERE first_day BETWEEN '2026-07-28' AND '2026-08-04'
GROUP BY first_day
ORDER BY first_day;
first_day clients d1_pct d7_pct within7_pct
2026-07-28 2925 7.1 1.7 17.3
2026-07-29 2823 6.9 1.5 17.9
2026-07-30 2735 8.2 1.4 19.2
2026-07-31 2910 8.5 1.9 19.6
2026-08-01 3535 7.4 2.1 20.2
2026-08-02 2801 8.4 1.4 19.7
2026-08-03 2863 8.1 1.6 19.9
2026-08-04 2889 9 null null

这个查询里有几处是刻意这样写的。

每个用户一个 MAX(CASE ...)。 JOIN 之后,每个用户的每个活跃日各占一行。0/1 标记取 MAX,只要任何一行满足条件,结果就是 1。然后在用户层面用 SUM(flag) / COUNT(*) 得到同期群的比例。

用 100.0,不用 100。 在 PostgreSQL 里,整数的 SUM 除以 COUNT(*) 是整数除法,小数部分会被悄悄丢掉。以 100.0 开头,整个表达式就变成了小数运算。

未成熟的单元格用 NULL。 数据中最后一个完整的日子是 8 月 10 日(8 月 11 日在 18:00 被截断)。8 月 4 日首次出现的客户,第 7 天是 8 月 11 日,这一天并不完整。他们的 D7 标记是 0,不是因为没回来,而是因为这一天还没被完整观察到。不带 ELSE 的 CASE 会对这些同期群返回 NULL,这才是诚实的值;如果写成 0,看起来就像留存崩盘了。

三种定义,三个不同的数字。 次日留存大约 7%–9%,7 日留存不到 2%,但每个同期群都有大约 18%–20% 的人在第一周内至少回来过一次(within7)。这三个数字都没有错,它们衡量的是不同的东西。对于订机票这种人们不会天天使用的产品,精确到某一天的留存太严格,噪音也大;「N 天内回访过」(有时称为区间留存,或带固定期限的无界留存)往往更有用。无论用哪个数字,都要说明它采用的是哪种定义。

滚动留存需要固定期限

滚动留存统计的是在第 N 天或之后回来的用户。如果没有上限,它就取决于同期群开始后过了多长时间:早期的同期群有好几周的时间回来,最近的只有几天。结果看起来像是新同期群的留存更差,其实它们只是更「年轻」。上面的 within7 列把期限固定为七天,并且只显示七天都已完整的同期群,从而避免了这个问题。

周留存矩阵

在仪表盘上,按周划分的同期群比按天的更容易读。同期群是首日所在的自然周,列是用户在多少个自然周之后活跃:

WITH client_days AS (
  SELECT DISTINCT client_id, CAST(event_time AS DATE) AS active_day
  FROM app_events
),
first_days AS (
  SELECT client_id, MIN(active_day) AS first_day
  FROM client_days
  GROUP BY client_id
),
weeks AS (
  SELECT f.client_id,
    CAST(date_trunc('week', f.first_day) AS DATE) AS cohort_week,
    CAST((CAST(date_trunc('week', cd.active_day) AS DATE)
        - CAST(date_trunc('week', f.first_day) AS DATE)) / 7 AS INTEGER) AS week_n
  FROM first_days f
  JOIN client_days cd ON cd.client_id = f.client_id
),
cells AS (
  SELECT cohort_week, week_n, COUNT(DISTINCT client_id) AS active
  FROM weeks
  GROUP BY cohort_week, week_n
),
pct AS (
  SELECT c.cohort_week, c.week_n, s.active AS clients,
    CASE WHEN c.cohort_week + 7 * c.week_n + 6 <= DATE '2026-08-09'
         THEN ROUND(100.0 * c.active / s.active, 1) END AS retention
  FROM cells c
  JOIN cells s ON s.cohort_week = c.cohort_week AND s.week_n = 0
)
SELECT cohort_week, clients,
  MAX(CASE WHEN week_n = 1 THEN retention END) AS w1,
  MAX(CASE WHEN week_n = 2 THEN retention END) AS w2,
  MAX(CASE WHEN week_n = 3 THEN retention END) AS w3,
  MAX(CASE WHEN week_n = 4 THEN retention END) AS w4
FROM pct
WHERE cohort_week BETWEEN DATE '2026-07-13' AND DATE '2026-08-03'
GROUP BY cohort_week, clients
ORDER BY cohort_week;
cohort_week clients w1 w2 w3 w4
2026-07-13 22350 13.2 10.3 10.5 null
2026-07-20 21220 12.1 9.4 null null
2026-07-27 20625 11.9 null null null
2026-08-03 21160 null null null null

各部分是怎样配合的:

矩阵可以横着读(一个同期群怎样衰减),也可以竖着读(第 1 周留存在各同期群之间是变好还是变差)。这里第 1 周留存从 13.2% 略降到 11.9%;只有三个数据点,这值得继续观察,还不能下结论。周留存也比 7 日留存高得多,因为「第 1 周的任意一天」比「恰好第 7 天」的窗口宽得多。

用购买而不是访问来衡量留存

留存不一定指打开 App。对航空公司来说,「又订了一次票」更接近收入。会员是唯一能识别回头客的地方(ticket_members 把机票和 member_id 关联起来),所以这里的同期群是会员首次订票所在的周,事件是 14 天内的再次订票:

WITH member_bookings AS (
  SELECT DISTINCT tm.member_id, b.book_ref, b.book_date
  FROM ticket_members tm
  JOIN tickets t ON t.ticket_no = tm.ticket_no
  JOIN bookings b ON b.book_ref = t.book_ref
),
firsts AS (
  SELECT member_id, MIN(book_date) AS first_booking
  FROM member_bookings
  GROUP BY member_id
),
flags AS (
  SELECT f.member_id,
    CAST(date_trunc('week', f.first_booking) AS DATE) AS cohort_week,
    MAX(CASE WHEN mb.book_date > f.first_booking
              AND mb.book_date <= f.first_booking + INTERVAL '14 days'
             THEN 1 ELSE 0 END) AS rebooked_14d
  FROM firsts f
  JOIN member_bookings mb ON mb.member_id = f.member_id
  GROUP BY f.member_id, f.first_booking
)
SELECT cohort_week, COUNT(*) AS members,
  ROUND(100.0 * SUM(rebooked_14d) / COUNT(*), 1) AS rebooked_14d_pct
FROM flags
WHERE cohort_week BETWEEN DATE '2026-06-29' AND DATE '2026-07-20'
GROUP BY cohort_week
ORDER BY cohort_week;
cohort_week members rebooked_14d_pct
2026-06-29 7243 27.1
2026-07-06 6514 19.4
2026-07-13 6332 20.0
2026-07-20 5855 21.5

第一个 CTE 里的 DISTINCT 很关键:一笔订单里如果有三张会员机票,不去重的话这笔订单会出现三次。显示的最后一个同期群是 7 月 20 日那周,因为它最晚的首次订票(7 月 26 日)往后 14 天是 8 月 9 日,仍在快照之前。

6 月 29 日同期群以 27.1% 格外突出,原因和 App 日志一样:订单数据从 6 月 17 日开始,所以最早的那批「首次订票」里混进了在数据开始之前就订过票的常旅客。常旅客复购更频繁,于是早期同期群被抬高了。在为某个同期群高兴或担心之前,先看看它是不是落在数据的边缘。

SQL 留存分析的常见错误

本文所有表格都来自 Nomto SQL 课程的数据集。在 SQL 练习场里,你可以把期限从 7 天改成 14 天,把同期群从按周改成按天,或者只针对一个平台重建矩阵,自己核对数字。留存查询离不开窗口和条件聚合,如果这部分还不熟,可以先看 SQL 窗口函数 和 GROUP BY 和 HAVING 的区别;面试中常见的 30 天复购题,见 SQL 面试题 第 20 题。

常见问题

什么是同期群留存分析?

它按用户开始使用的时间把用户分组(同期群),然后衡量每组在 N 天或 N 周之后再次活跃的比例。比较不同的同期群,就能看出产品留住新用户的能力是在变好还是变差。

怎样用 SQL 计算 7 日留存?

找出每个用户的首个活跃日,JOIN 上他们所有的活跃日,标记在 first_day + 7 当天有活动的用户,再用被标记的用户数除以同期群规模。对于第 7 天还没有完整观察到的同期群,显示 NULL。

经典留存和滚动留存有什么区别?

经典(第 N 日)留存要求恰好在第 N 天有活动。滚动留存则统计在第 N 天或之后任何时间有活动的人。滚动留存总是更高,而且取决于你观察同期群的时间长短,所以比较同期群时要固定一个上限。

怎样用 SQL 构建留存矩阵?

为每个「用户-日」算出同期群周和周偏移量,按(同期群,偏移量)统计不同用户数,除以偏移量为 0 时的规模,再用 MAX(CASE WHEN week_n = k THEN ... END) 把偏移量转成列。

为什么最新的同期群留存率特别低?

通常是因为它们后面的时间段还没有被完整观察到。加一个成熟度条件:凡是时间段结束于数据最后一个完整日之后的单元格,都返回 NULL。

其他语言: English