《互联网产品的服务端架构·交易、增长与平台化》5.1 会员等级与成长值

会员体系最常见的错误是只存等级、不存成长值:门槛一改,老用户等级全部对不上,回滚也无从判断。文章把成长值、等级和权益拆成三件事,讨论降级策略与规则回放,并给出 LevelOf、AddGrowth 两个纯函数的 Go 实现、边界测试与真实运行输出。

一次改规则,三万用户的等级同时变了

「刚好」的会员体系有四个等级:普通、银卡、金卡、黑卡。上线第一版时,研发为了省事,用户表里只存了一个 level 字段,注册时按当时的成长值算一次,之后运营在后台手动改。

半年后运营想把金卡门槛从 5000 降到 3000,顺便增加一档铂金。规则发布当晚,问题来了:所有用户的 level 还是旧值,降门槛只影响新注册用户;已经达到 3000 但还挂着银卡的老用户不会自动升级,而原本 5000 以上的用户又不知道该怎么映射到新增的铂金。客服收到大量“我成长值够了为什么还是银卡”的工单。

更麻烦的是回滚。运营发现新门槛让金卡人数翻了三倍,权益成本压不住,想把门槛调回 5000。可是系统里只有等级,没有成长值,谁该降回银卡、谁该保留,无从判断。最后只能按等级批量回退,把一批本来已经够 5000 的用户也误伤了。

这个事故的根因不是门槛定错了,而是把“派生结果”当成了“事实”存储。等级是成长值和规则共同算出来的结果,规则一变,结果就应该跟着变;把结果冻在用户表里,等于把某一版规则永久钉死。

成长值、等级和权益是三件事

会员体系里最容易混在一起的是三个对象:成长值、等级、权益。它们的变化频率、数据归属和审计要求完全不同,混在一张表里,迟早会互相拖累。

对象是什么由谁产生变化频率建议的存储方式
成长值累计行为分,只增不减或按规则扣减交易、签到、任务、活动每次行为都可能变存明细或余额,可重放
等级成长值落在等级表的哪一档由成长值和规则计算成长值或规则变化时尽量派生,不单独作为事实
权益等级对应的具体能力等级映射或单独发放运营调整、发券、回收存发放记录,带来源和有效期
等级规则门槛、名称、保级与降级策略运营配置低频但影响所有人带版本号,可回放

成长值是事实,等级是视图。事实应该稳定、可追溯;视图应该允许随规则重算。判断标准很简单:如果明天运营把门槛从 5000 改成 3000,系统能不能在不变更任何用户事实的前提下,重新算出正确的等级?如果不能,说明等级被错误地当成了事实。

等级规则本身也需要版本化。只存一张“当前等级表”是不够的,因为事故复盘时你需要回答“这个用户在上周三为什么是金卡”。规则表至少要有生效时间,重要调整还应保留旧版本,方便按时间点回放。

降级策略要单独定义,不能默认“成长值掉了就立刻降级”。常见做法有三种:只升不降,适合权益成本可控的场景;按自然年或会员年度结算,结算时按当期成长值重算;设置保级期,例如降级前给一个月的缓冲。三种策略对用户预期的影响完全不同,必须在产品文档里写清楚,而不是藏在代码的 if 里。

失败场景:只存等级,规则一改全错

回到开头的事故。用户表里只有 level,没有 growth,也没有记录这个等级是按哪版规则算的。规则发布后,系统面对三类用户都没有正确答案:

  • 老用户 A 成长值 3200,旧规则下是银卡。新规则金卡门槛 3000,他应该升金卡,但系统不知道他的成长值,只能等他下一次产生行为时才顺带重算。
  • 老用户 B 成长值 8000,旧规则下是黑卡。新增铂金档后,8000 应该落在铂金还是黑卡,取决于新档的门槛,系统同样无法判断。
  • 新用户 C 注册时直接按新规则算,和同成长值的老用户 A 等级不同。同一个成长值对应两个等级,客诉时无法解释。

回滚时问题更严重。运营把门槛调回 5000,只能按 level 批量改:原金卡用户全部降回银卡,原黑卡用户保持不变。但其中有一部分金卡用户成长值其实已经超过 5000,他们被误降;另一部分银卡用户成长值在 3000 到 5000 之间,本来就不该升。一次回滚制造了两批错误数据。

第二个变种是并发覆盖。用户同时完成两笔订单,两个请求都读到成长值 1000,各自加 100 后写回 1100。正确结果应该是 1200,实际少了 100。如果只存等级,这种丢失更难发现,因为等级可能压根没跨档。

第三个变种是扣减。退款或风控撤销成长值时,代码直接做 growth -= delta,没有检查结果是否为负。一次大额退款把成长值扣成负数,等级计算随之报错,用户被卡在“无等级”状态。

用 LevelOf 与 AddGrowth 收口规则

把等级计算和成长值变更做成纯函数,存储层只负责原子地读写成长值。这样规则可以随时替换,历史数据可以重放,测试也不需要数据库。

// file: internal/member/level.go
// tier: L1
// verify: go test ./internal/member/

// Package member 提供会员等级、权益发放回收与订阅续费的纯函数。
//
// 这些函数只做规则判断,不负责持久化。线上必须把读状态、调用纯函数、
// 写回新状态放在同一个事务或条件更新里,否则并发请求会互相覆盖。
package member

import (
	"errors"
	"fmt"
	"math"
)

var (
	// ErrInvalidLevels 表示等级表为空、乱序、重复或缺少名称。
	ErrInvalidLevels = errors.New("member: invalid levels")
	// ErrNoLevel 表示成长值低于等级表的第一档。
	ErrNoLevel = errors.New("member: no matching level")
	// ErrNegativeGrowth 表示当前成长值为负,或增减后结果为负。
	ErrNegativeGrowth = errors.New("member: negative growth")
	// ErrGrowthOverflow 表示成长值相加会超出 int64 上限。
	ErrGrowthOverflow = errors.New("member: growth overflow")
)

// Level 是等级表中的一档。MinGrowth 是进入该档所需的最低成长值,
// 成长值本身是累计值,不是“距离下一档还差多少”。
type Level struct {
	Name      string
	MinGrowth int64
}

// LevelOf 返回满足 MinGrowth <= growth 的最高等级。
//
// 等级表必须按 MinGrowth 严格升序:相等或倒序都返回 ErrInvalidLevels。
// 成长值为负返回 ErrNegativeGrowth;等级表为空返回 ErrInvalidLevels;
// growth 低于第一档的 MinGrowth 时返回 ErrNoLevel。函数只读 levels,
// 不会修改入参。
func LevelOf(growth int64, levels []Level) (Level, error) {
	if growth < 0 {
		return Level{}, fmt.Errorf("%w: %d", ErrNegativeGrowth, growth)
	}
	if len(levels) == 0 {
		return Level{}, fmt.Errorf("%w: empty table", ErrInvalidLevels)
	}
	for i, l := range levels {
		if l.Name == "" {
			return Level{}, fmt.Errorf("%w: level %d has empty name", ErrInvalidLevels, i)
		}
		if i > 0 && l.MinGrowth <= levels[i-1].MinGrowth {
			return Level{}, fmt.Errorf("%w: %s min=%d after %s min=%d",
				ErrInvalidLevels, l.Name, l.MinGrowth, levels[i-1].Name, levels[i-1].MinGrowth)
		}
	}

	var cur Level
	found := false
	for _, l := range levels {
		if l.MinGrowth > growth {
			break
		}
		cur = l
		found = true
	}
	if !found {
		return Level{}, fmt.Errorf("%w: growth %d below first level %s min=%d",
			ErrNoLevel, growth, levels[0].Name, levels[0].MinGrowth)
	}
	return cur, nil
}

// AddGrowth 在 cur 的基础上增加 delta,返回新的成长值。
//
// cur 必须非负。delta 可以为负,用于扣减或撤销成长值,但结果不能为负,
// 否则返回 ErrNegativeGrowth。正向相加前先检查是否会溢出 int64,
// 避免溢出后变成负数被误判成“扣减”。失败时返回 0 和错误,不返回
// 一个看起来可用的中间值。
func AddGrowth(cur, delta int64) (int64, error) {
	if cur < 0 {
		return 0, fmt.Errorf("%w: current %d", ErrNegativeGrowth, cur)
	}
	if delta > 0 && cur > math.MaxInt64-delta {
		return 0, fmt.Errorf("%w: %d + %d", ErrGrowthOverflow, cur, delta)
	}
	next := cur + delta
	if next < 0 {
		return 0, fmt.Errorf("%w: %d + %d = %d", ErrNegativeGrowth, cur, delta, next)
	}
	return next, nil
}

实现里有几个关键判断:

  • LevelOf 先校验成长值非负,再校验等级表:不能为空、名称不能为空、MinGrowth 必须严格升序。相等也算错误,因为重复门槛会让“最高等级”出现歧义。
  • 查找时从低到高遍历,遇到第一个超过成长值的档位就停止,返回上一档。这样天然得到“满足条件的最高等级”,不需要反向遍历。
  • 成长值低于第一档时返回 ErrNoLevel,而不是悄悄返回零值等级。零值 Level{} 的名字是空字符串,调用方很容易把它当成合法等级继续用。
  • AddGrowth 允许负的 delta,用于退款扣减,但结果不能为负;正向相加前先用 math.MaxInt64-delta 判断溢出,避免溢出成负数后被误认为扣减。
  • 失败时返回 0 和错误,不返回“尽量接近”的中间值。成长值一旦写错,后续所有等级判断都会跟着错。

纯函数只保证单次计算正确。线上写回成长值必须用条件更新或事务,例如 UPDATE member SET growth = growth + ? WHERE user_id = ? AND growth + ? >= 0,不能先读后写。等级则建议在读路径上实时计算,或者写入一张带 rule_version 和 computed_at 的派生表,允许异步重算。

测试:边界、乱序与溢出

等级和成长值的错误大多发生在边界:刚好等于门槛、低一档、高一档、乱序的配置、重复门槛、负数和溢出。测试要把这些行为固定下来。

// file: internal/member/level_test.go
// tier: L1
// verify: go test ./internal/member/

package member

import (
	"errors"
	"reflect"
	"testing"
)

func testLevels() []Level {
	return []Level{
		{Name: "bronze", MinGrowth: 0},
		{Name: "silver", MinGrowth: 1000},
		{Name: "gold", MinGrowth: 5000},
	}
}

func TestLevelOfPicksHighest(t *testing.T) {
	cases := []struct {
		growth int64
		want   string
	}{
		{0, "bronze"},
		{999, "bronze"},
		{1000, "silver"},
		{4999, "silver"},
		{5000, "gold"},
		{1 << 40, "gold"},
	}
	for _, c := range cases {
		got, err := LevelOf(c.growth, testLevels())
		if err != nil {
			t.Fatalf("LevelOf(%d) = %v, want nil", c.growth, err)
		}
		if got.Name != c.want {
			t.Fatalf("LevelOf(%d) = %q, want %q", c.growth, got.Name, c.want)
		}
	}
}

func TestLevelOfRejectsBadInput(t *testing.T) {
	if _, err := LevelOf(-1, testLevels()); !errors.Is(err, ErrNegativeGrowth) {
		t.Fatalf("negative growth = %v, want ErrNegativeGrowth", err)
	}
	if _, err := LevelOf(0, nil); !errors.Is(err, ErrInvalidLevels) {
		t.Fatalf("empty table = %v, want ErrInvalidLevels", err)
	}
	if _, err := LevelOf(10, []Level{{Name: "gold", MinGrowth: 100}}); !errors.Is(err, ErrNoLevel) {
		t.Fatalf("below first = %v, want ErrNoLevel", err)
	}
	if _, err := LevelOf(10, []Level{{MinGrowth: 0}}); !errors.Is(err, ErrInvalidLevels) {
		t.Fatalf("empty name = %v, want ErrInvalidLevels", err)
	}
}

func TestLevelOfRejectsUnorderedOrDuplicate(t *testing.T) {
	cases := map[string][]Level{
		"descending": {{Name: "gold", MinGrowth: 5000}, {Name: "silver", MinGrowth: 1000}},
		"duplicate":  {{Name: "a", MinGrowth: 0}, {Name: "b", MinGrowth: 0}},
	}
	for name, levels := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := LevelOf(5000, levels); !errors.Is(err, ErrInvalidLevels) {
				t.Fatalf("LevelOf = %v, want ErrInvalidLevels", err)
			}
		})
	}
}

func TestLevelOfDoesNotMutateInput(t *testing.T) {
	levels := testLevels()
	before := append([]Level(nil), levels...)
	if _, err := LevelOf(1200, levels); err != nil {
		t.Fatalf("LevelOf = %v, want nil", err)
	}
	if !reflect.DeepEqual(levels, before) {
		t.Fatalf("levels mutated: %+v, want %+v", levels, before)
	}
}

func TestAddGrowth(t *testing.T) {
	if got, err := AddGrowth(100, 50); err != nil || got != 150 {
		t.Fatalf("AddGrowth(100, 50) = %d, %v; want 150, nil", got, err)
	}
	if got, err := AddGrowth(100, -40); err != nil || got != 60 {
		t.Fatalf("AddGrowth(100, -40) = %d, %v; want 60, nil", got, err)
	}
	if got, err := AddGrowth(0, 0); err != nil || got != 0 {
		t.Fatalf("AddGrowth(0, 0) = %d, %v; want 0, nil", got, err)
	}
}

func TestAddGrowthRejectsNegativeResult(t *testing.T) {
	if got, err := AddGrowth(30, -31); !errors.Is(err, ErrNegativeGrowth) || got != 0 {
		t.Fatalf("AddGrowth(30, -31) = %d, %v; want 0, ErrNegativeGrowth", got, err)
	}
	if _, err := AddGrowth(-1, 10); !errors.Is(err, ErrNegativeGrowth) {
		t.Fatalf("negative current = %v, want ErrNegativeGrowth", err)
	}
}

func TestAddGrowthDetectsOverflow(t *testing.T) {
	max := int64(^uint64(0) >> 1)
	if _, err := AddGrowth(max, 1); !errors.Is(err, ErrGrowthOverflow) {
		t.Fatalf("overflow = %v, want ErrGrowthOverflow", err)
	}
	if got, err := AddGrowth(max-1, 1); err != nil || got != max {
		t.Fatalf("exact max = %d, %v; want %d, nil", got, err, max)
	}
}

TestLevelOfPicksHighest 覆盖每一档的门槛值和门槛前后一格,确认返回的是最高满足项。TestLevelOfRejectsBadInput 覆盖负成长值、空等级表、低于第一档和空名称。TestLevelOfRejectsUnorderedOrDuplicate 用两张坏配置确认乱序和重复都会被拒绝,而不是靠调用方自觉。

TestLevelOfDoesNotMutateInput 拷贝一份等级表,调用后逐项比较,保证纯函数没有偷偷排序或改写入参。TestAddGrowth 覆盖正常增加、负向扣减和零操作;TestAddGrowthRejectsNegativeResult 确认扣成负数会报错;TestAddGrowthDetectsOverflow 在 MaxInt64 上再加一,确认返回溢出错误,同时验证 MaxInt64-1 + 1 能正常得到上限。

这些用例刻意不依赖数据库和时钟。等级规则里唯一和时间有关的是降级结算,那部分应该由调度任务和规则版本控制,不属于单次纯函数的职责。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/member/ -run 'TestLevelOf|TestAddGrowth' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestLevelOfPicksHighest
--- PASS: TestLevelOfPicksHighest (0.00s)
=== RUN   TestLevelOfRejectsBadInput
--- PASS: TestLevelOfRejectsBadInput (0.00s)
=== RUN   TestLevelOfRejectsUnorderedOrDuplicate
=== RUN   TestLevelOfRejectsUnorderedOrDuplicate/descending
=== RUN   TestLevelOfRejectsUnorderedOrDuplicate/duplicate
--- PASS: TestLevelOfRejectsUnorderedOrDuplicate (0.00s)
    --- PASS: TestLevelOfRejectsUnorderedOrDuplicate/descending (0.00s)
    --- PASS: TestLevelOfRejectsUnorderedOrDuplicate/duplicate (0.00s)
=== RUN   TestLevelOfDoesNotMutateInput
--- PASS: TestLevelOfDoesNotMutateInput (0.00s)
=== RUN   TestAddGrowth
--- PASS: TestAddGrowth (0.00s)
=== RUN   TestAddGrowthRejectsNegativeResult
--- PASS: TestAddGrowthRejectsNegativeResult (0.00s)
=== RUN   TestAddGrowthDetectsOverflow
--- PASS: TestAddGrowthDetectsOverflow (0.00s)
PASS
ok  	webapp/internal/member	0.286s

PASS 表示 5.1 的 7 个顶层用例和 2 个子用例全部通过。它们验证的是规则计算和边界,不验证并发写入;并发部分仍然需要存储层的条件更新和重放工具配合。

小结

成长值是事实,等级是成长值和规则算出来的视图,权益是等级或其他来源发放的具体能力。三者混存,规则一改就会同时出现升级遗漏、降级误伤和回滚困难。

LevelOf 把“最高满足档位”收成一个纯函数,拒绝空表、乱序、重复和负成长值;AddGrowth 允许扣减但拒绝负结果,并在相加前检查溢出。规则表带版本、成长值可重放、写回走原子更新,等级才不会被某一版门槛钉死。

阅读导航:上一节:目录与学习路径 · 下一节:5.2 权益发放与回收 。

常见错误与边界

  • 只存等级不存成长值。规则一改,老用户等级全部对不上,回滚时也没有事实依据。
  • 把等级表当成可变配置直接覆盖。至少保留生效时间和版本号,否则无法回答“当时为什么是这个等级”。
  • 认为成长值只能增加。退款、风控和撤销都会产生负向变更,必须定义扣减下限和审计记录。
  • 用 >= 和 > 时没有和产品文案对齐。门槛值本身属于哪一档,要在测试里写死。
  • 在应用层先读后写成长值。并发订单会丢失增量,必须用条件更新或事务。
  • 降级策略藏在代码里。只升不降、按年结算、保级期对用户预期影响很大,必须产品和研发共同确认。
  • 等级计算依赖当前时间却没有注入时钟。测试和重放会变得不可控,时间应作为参数传入。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查