《MMO 服务器开发》6.3 留存、付费与活动转化分析

埋点进了数仓,接下来回答运营最关心的三个问题:玩家第二天还来不来、愿不愿意花钱、活动每一步漏掉了多少人。这一节把次日/7 日/30 日留存的口径与去重、付费率与首充复购、活动转化漏斗逐段算清,并用一组构造数据集把每一步的数字断言成可执行的测试。

6.3 留存、付费与活动转化分析

6.1 定了口径,6.2 把原始事件送进了数仓。现在到了运营每天真正盯着看的那三块屏幕:留存、付费、活动转化。这三块看起来是三种分析,其实共用同一套底层动作——去重、分组、算比例——只要口径错了,三块全错。

这一节不引入任何新中间件,全部是纯计算。正因为纯,它才最适合被钉成断言:一组构造数据进去,每个留存率、每个付费率、每一步转化率出来必须分毫不差。下面把三类分析的算法写清楚,再用一份 internal/analytics/analysis.go 和它的测试,把「次日留存 50%」「付费率 30%」「漏斗第三步掉一半」这些数字锁死。

留存:次日、7 日、30 日

留存的口径只有一个定义:一批玩家(cohort)里,隔了 N 天还回来的比例。它有三个容易做错的点:

  • cohort 是「首次活跃日」等于某天的人,不是「某天活跃过的所有人」。一个第 1 天就来过、第 2 天又来的老玩家,不属于第 2 天的 cohort——否则每一批 cohort 都会被老玩家稀释,看起来「留存永远很高」。
  • 必须按天去重。一个玩家一天开三次游戏,是「当天活跃」一次,不是三次。不去重,留存率会超过 100%,一眼假。
  • 分母是 cohort 大小,分子是 cohort 里隔 N 天又活跃的人数。次日留存看第 1 天,7 日留存看第 7 天,30 日留存看第 30 天,三个数是同一条留存曲线上的三个点。

留存曲线是长线游戏最核心的健康指标:次日留存低,说明新手引导或首日体验有问题;7 日留存低,说明中期玩法留不住人;30 日留存低,说明长线内容或社交没建起来。 三个点各自指向不同的产品问题,所以口径必须统一、可比较。

付费分析:付费率、ARPU、首充与复购

付费侧要同时看「多少人付」和「付了多少」,缺一不可:

指标定义看什么
付费率付费玩家 / DAU盘子有多大、多少人愿意掏钱
ARPU营收 / DAU平摊到每个活跃玩家身上多少钱
ARPPU营收 / 付费玩家每个付费玩家掏了多少钱
首充当天发生首笔订单的玩家数新玩家转化、首充礼包效果
复购当天有 ≥2 笔订单的玩家数付费深度、二次付费设计

两个关键口径:首充要跨天判定——一个玩家今天付了钱,但他昨天就付过,那他不是「首充」,只是「续充」。判定首充必须回看这个玩家历史上所有订单,只看当天的单子会把老玩家的续费误算成新首充。复购按「当天订单笔数」而非「人数」——同一个玩家一天内买了两单,是一笔复购,不是两个付费玩家。这就是为什么埋点要保留订单粒度,而不是只存「玩家今天付了多少钱」的汇总。

活动转化漏斗:每一步的人数与转化率

活动漏斗把玩家从「看到活动」到「拿到奖励」的路径拆成若干步,每步记一个去重后的人数,然后算两种转化率:

  • 单步转化率 = 本步人数 / 上一步人数。它回答「这一步漏了多少」。
  • 整体转化率 = 本步人数 / 第一步人数。它回答「从入口算起还剩多少」。

以「登录 → 打开活动页 → 报名 → 完成 → 领奖」为例,如果报名到完成的单步转化率特别低,问题就出在「报名后要做的任务太难」;如果整体转化率只有 9%,说明整个活动设计偏长。漏斗的价值不在于那条曲线好不好看,而在于它能一眼指出最陡的那一级台阶——找到它,才有优化方向。

可运行产物:把三类分析写成断言

下面这段是 L1,go test 可以直接跑。internal/analytics/analysis.go 把留存、漏斗、付费三类计算放在一个文件里,全部是纯函数:

// file: internal/analytics/analysis.go
// tier: L1
// verify: go test ./internal/analytics/ -run 'TestRetention|TestFunnel|TestPayment'
//
// This file answers the three questions a live-ops team asks every morning:
// does the cohort come back (retention), do players pay (payment), and where
// does the activity funnel leak (conversion). All three are pure functions over
// constructed data, so all three are fully testable.
package analytics

import "sort"

// ActivityRecord is one raw "player was active on day" row. The same player can
// appear many times for one day (several sessions); retention must dedup them.
type ActivityRecord struct {
	Day      string
	PlayerID uint64
}

// ActiveByDay collapses raw records into day -> distinct players.
func ActiveByDay(records []ActivityRecord) map[string]map[uint64]struct{} {
	out := make(map[string]map[uint64]struct{})
	for _, r := range records {
		if out[r.Day] == nil {
			out[r.Day] = make(map[uint64]struct{})
		}
		out[r.Day][r.PlayerID] = struct{}{}
	}
	return out
}

// Cohort returns the players whose FIRST active day is exactly cohortDay: a
// player already active earlier is not a member, so retention is not diluted.
func Cohort(active map[string]map[uint64]struct{}, cohortDay string) map[uint64]struct{} {
	days := make([]string, 0, len(active))
	for d := range active {
		days = append(days, d)
	}
	sort.Strings(days)
	first := make(map[uint64]string)
	for _, d := range days {
		for pid := range active[d] {
			if _, ok := first[pid]; !ok {
				first[pid] = d
			}
		}
	}
	cohort := make(map[uint64]struct{})
	for pid, d := range first {
		if d == cohortDay {
			cohort[pid] = struct{}{}
		}
	}
	return cohort
}

// RetentionRate is day-N retention: of the cohort, the fraction active again on
// laterDay.
func RetentionRate(active map[string]map[uint64]struct{}, cohortDay, laterDay string) float64 {
	cohort := Cohort(active, cohortDay)
	if len(cohort) == 0 {
		return 0
	}
	retained := 0
	for pid := range cohort {
		if _, ok := active[laterDay][pid]; ok {
			retained++
		}
	}
	return float64(retained) / float64(len(cohort))
}

// FunnelStep is one stage of a conversion funnel with its headcount.
type FunnelStep struct {
	Name  string
	Users int
}

// FunnelResult is a step's headcount plus its step-over-step and overall rates.
type FunnelResult struct {
	Name        string
	Users       int
	StepRate    float64 // users / previous step
	OverallRate float64 // users / first step
}

// Analyze computes the conversion at each step; the first step's StepRate is 1.
func Analyze(steps []FunnelStep) []FunnelResult {
	out := make([]FunnelResult, 0, len(steps))
	if len(steps) == 0 {
		return out
	}
	first := steps[0].Users
	for i, s := range steps {
		r := FunnelResult{Name: s.Name, Users: s.Users}
		if i == 0 {
			r.StepRate = 1
		} else if prev := steps[i-1].Users; prev > 0 {
			r.StepRate = float64(s.Users) / float64(prev)
		}
		if first > 0 {
			r.OverallRate = float64(s.Users) / float64(first)
		}
		out = append(out, r)
	}
	return out
}

// BiggestDrop returns the index of the step with the largest drop (the funnel's
// bottleneck) and that step's rate.
func BiggestDrop(steps []FunnelStep) (int, float64) {
	if len(steps) < 2 {
		return -1, 0
	}
	worst, worstRate := -1, 1.0
	for i := 1; i < len(steps); i++ {
		if steps[i-1].Users == 0 {
			continue
		}
		if rate := float64(steps[i].Users) / float64(steps[i-1].Users); rate < worstRate {
			worst, worstRate = i, rate
		}
	}
	return worst, worstRate
}

// Payment is one order. A player with two orders in a day counts once as a
// payer but twice towards repurchase.
type Payment struct {
	OrderID  string
	PlayerID uint64
	Day      string
	Cents    int64
}

// PaymentReport is a day's payment analysis.
type PaymentReport struct {
	DAU            int
	Revenue        int64
	Payers         int
	PaymentRate    float64 // payers / DAU
	ARPU           float64 // revenue / DAU
	ARPPU          float64 // revenue / payers
	FirstPayers    int     // payers whose first-ever order is on this day
	Repurchasers   int     // payers with >= 2 orders on this day
	RepurchaseRate float64 // repurchasers / payers
}

// AnalyzePayments needs every order so far, not just today's, to know who is a
// first-time payer.
func AnalyzePayments(active map[uint64]struct{}, payments []Payment, day string) PaymentReport {
	r := PaymentReport{DAU: len(active)}
	firstDay := make(map[uint64]string)
	for _, p := range payments {
		if cur, ok := firstDay[p.PlayerID]; !ok || p.Day < cur {
			firstDay[p.PlayerID] = p.Day
		}
	}
	orders := make(map[uint64]int)
	for _, p := range payments {
		if p.Day != day {
			continue
		}
		r.Revenue += p.Cents
		orders[p.PlayerID]++
	}
	r.Payers = len(orders)
	for pid, n := range orders {
		if firstDay[pid] == day {
			r.FirstPayers++
		}
		if n >= 2 {
			r.Repurchasers++
		}
	}
	if r.DAU > 0 {
		r.PaymentRate = float64(r.Payers) / float64(r.DAU)
		r.ARPU = float64(r.Revenue) / float64(r.DAU)
	}
	if r.Payers > 0 {
		r.ARPPU = float64(r.Revenue) / float64(r.Payers)
		r.RepurchaseRate = float64(r.Repurchasers) / float64(r.Payers)
	}
	return r
}

测试 internal/analytics/analysis_test.go 用一组故意埋了重复行的数据(玩家 1 在第 1 天出现两次)断言留存去重,用一条五级漏斗断言每一步的两种转化率,再用一份订单断言首充与复购的口径。关键断言节选如下:

// snippet: internal/analytics/analysis_test.go (节选)
// cohort size is 10, not 11: the duplicate row for player 1 must be deduped.
if len(Cohort(ActiveByDay(retentionRecords()), "2026-10-01")) != 10 {
	t.Fatal("cohort must dedup repeated sessions")
}
// D1=50%, D2=20%, D7=10%, D30=20% -- three points on one retention curve.
if got := RetentionRate(active, "2026-10-01", "2026-10-02"); !approx(got, 0.5) {
	t.Errorf("D1 retention = %.4f, want 0.50", got)
}

// the funnel's worst step is "报名 -> 完成", dropping to 0.40.
if idx, rate := BiggestDrop(steps); idx != 3 || !approx(rate, 0.4) {
	t.Errorf("bottleneck at step %d (%.4f), want step 3 at 0.40", idx, rate)
}

// p1 paid on 2026-09-30, so today it is a repurchase, not a first charge.
if r.FirstPayers != 2 || r.Repurchasers != 1 || !approx(r.RepurchaseRate, 1.0/3.0) {
	t.Errorf("first=%d repurchase=%d, want 2 and 1", r.FirstPayers, r.Repurchasers)
}

本机实跑结果(go test ./internal/analytics/ -run 'TestRetention|TestFunnel|TestPayment' -v):

=== RUN   TestRetentionDedupsAndMeasuresCohort
--- PASS: TestRetentionDedupsAndMeasuresCohort (0.00s)
=== RUN   TestFunnelConversionAndBottleneck
--- PASS: TestFunnelConversionAndBottleneck (0.00s)
=== RUN   TestPaymentReport
--- PASS: TestPaymentReport (0.00s)
PASS
ok  	aegis/internal/analytics	0.885s

三条测试各守一个口径底线:留存测试断言 cohort 大小是 10 而不是 11,证明重复行被去重;漏斗测试断言第三步「报名→完成」的单步转化率是 0.40,BiggestDrop 也精确指向它;付费测试断言 p1 因为 9 月 30 日已付过,不算当天首充,而它当天两笔订单又让它成为唯一复购——首充跨天判定、复购按笔数这两个最容易踩的口径,被钉死在断言里。

本节小结

这一节确立了三件事:

  1. 留存的口径是「cohort 首次活跃日 + 按天去重」。次日/7 日/30 日是同一条曲线的三个点,分别指向新手体验、中期玩法、长线内容三类问题。
  2. 付费要同时看人数与金额:付费率、ARPU、ARPPU 三个指标缺一不可;首充必须跨天回看历史订单,复购按当天订单笔数。
  3. 漏斗要算两种转化率(单步与整体),并用 BiggestDrop 自动定位最陡的一级台阶——这才是漏斗指导优化的方式。

到这里,数据分析与埋点的三节就齐了:6.1 定口径、6.2 送数据、6.3 出结论。下一章换个方向——这些数字和配置最终要靠人来操作,GM 后台与运营控制台的权限、审计与命令体系,就是第 7 章要写的事。

阅读导航:上一节:6.2 埋点与数据管道 · 下一节:7.1 权限与审计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标