PAYATHON 2026

如何计算从订阅到首次付款的平均时间

支付老李

结论

这个需求需要分两层计算:先按 user_id 使用 MIN() 取得每位 subscriber 的首次付款时间,再按 category 使用 AVG() 计算平均间隔。

SELECT
    s.category,
    AVG(
        TIMESTAMPDIFF(
            HOUR,
            s.`timestamp`,
            fp.first_payment_at
        )
    ) AS avg_hours_to_first_payment
FROM subscribers AS s
JOIN (
    SELECT
        user_id,
        MIN(`timestamp`) AS first_payment_at
    FROM payments
    GROUP BY user_id
) AS fp
    ON fp.user_id = s.user_id
GROUP BY s.category;

如果实际表名是 subs,把查询中的 subscribers 替换为 subs 即可。

为什么原查询会报错

原查询直接计算了:

AVG(TIMESTAMPDIFF(HOUR, subs.timestamp, MIN(payments.timestamp)))

这相当于把聚合函数 MIN() 嵌套在另一个聚合函数 AVG() 中。MySQL 无法在同一聚合层级中确定:

  • MIN() 应针对哪位 subscriber 计算;
  • AVG() 应对 category 中的哪些结果求平均。

这个需求本身包含两个统计层级:

  1. 在 subscriber 层级取得每位用户最早的付款时间;
  2. 在 category 层级计算这些用户时间差的平均值。

因此,需要通过子查询、CTE 或其他方式分开完成这两层计算。

另一种写法:先计算每位 subscriber 的时间差

也可以在内层查询中直接计算每位 subscriber 到首次付款的小时数,再由外层查询求平均值:

SELECT
    category,
    AVG(hours_to_first_payment) AS avg_hours_to_first_payment
FROM (
    SELECT
        s.user_id,
        s.category,
        TIMESTAMPDIFF(
            HOUR,
            s.`timestamp`,
            MIN(p.`timestamp`)
        ) AS hours_to_first_payment
    FROM subscribers AS s
    JOIN payments AS p
        ON p.user_id = s.user_id
    GROUP BY
        s.user_id,
        s.category,
        s.`timestamp`
) AS subscriber_first_payments
GROUP BY category;

这种写法的计算过程更直观:内层查询为每位 subscriber 生成一行,外层再按 category 汇总。

需要注意的细节

没有付款记录的 subscriber

以上查询使用 INNER JOIN,所以从未付款的 subscriber 不会参与平均值计算。通常,这符合“从订阅到首次付款”的统计口径。

如果需要保留未付款用户,可以改用 LEFT JOIN。由于这些用户没有首次付款时间,TIMESTAMPDIFF() 会返回 NULL,而 AVG() 默认忽略 NULL,因此平均值仍只基于已付款用户计算。

整数小时与小数小时

TIMESTAMPDIFF(HOUR, ...) 返回完整小时数,不足一小时的部分会被截断。如果需要更精确的小数小时,可以先计算秒数,再除以 3600.0:

SELECT
    s.category,
    AVG(
        TIMESTAMPDIFF(
            SECOND,
            s.`timestamp`,
            fp.first_payment_at
        ) / 3600.0
    ) AS avg_hours_to_first_payment
FROM subscribers AS s
JOIN (
    SELECT
        user_id,
        MIN(`timestamp`) AS first_payment_at
    FROM payments
    GROUP BY user_id
) AS fp
    ON fp.user_id = s.user_id
GROUP BY s.category;

这样,1 小时 30 分钟会以 1.5 小时参与平均,而不是被记为 1 小时。

时间字段名称

timestamp 容易与 SQL 数据类型或关键字混淆,因此示例中使用了反引号:

s.`timestamp`

如果条件允许,可以改用含义更明确的字段名,例如:

subscribed_at
paid_at

还要确认数据中没有付款时间早于订阅时间的情况,否则计算结果可能出现负数。

备注:内容仅供参考。