《互联网产品的服务端架构·产品、流量与基础能力》1.1 产品形态如何决定服务端形状

从「回声」的内容流、私信与下单三个产品事实出发,说明服务端分类为什么按实时、强一致、读写比、写多的优先级展开,给出四种形状的必备能力与主要风险,并用一段可测试的 Go 分类器把判断固定下来,文中还会拆解两次典型事故,帮助读者在选型前先问对问题。

从一个叫「回声」的产品说起

「回声」是一个为本书构造的假想产品,后文出现的 DAU、QPS、比例与耗时,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。

它同时有三块业务:发现页的内容流、用户之间的私信、站内下单。产品经理给服务端团队的需求只有三句形容词:内容要快,私信要实时,订单不能错。这三句话听起来都在说“性能”,落到服务端却是三种不同的故障模型。

  • 内容流:假设用户每天刷 40 次,作者每天发 0.5 条,读写比大约 80:1。允许一两秒的延迟,但不能因为一次缓存集中失效把数据库打穿。
  • 私信:假设端到端延迟要求 500 毫秒以内,消息不能丢、不能乱序,用户离线后还要能补拉最近的消息。
  • 下单:扣库存、锁优惠、写订单、等支付回调。假设每一笔都必须强一致,不能超卖,也不能因为回调重试而重复扣款。

团队最初的方案是“一个 MySQL、一层微服务、一个 Redis,三块业务共用”。上线第一周,内容流把数据库连接占满;第二周,私信的长连接把网关内存打爆;第三周,支付回调重试造成重复发货。三次事故的根因都不是代码质量,而是把三种产品事实塞进了同一种服务端形状。

所以本章不从中间件讲起,而是先回答一个更靠前的问题:怎样把产品事实翻译成服务端形状,并且让这个翻译过程可以写进代码、可以回归测试。

先分类,再选型:四种形状

分类的输入不是“用什么数据库”,而是产品对服务端提出的可观测特征。代码里把它收敛成四个字段:每次写对应的读次数、一致性要求、是否要求亚秒级双向实时、典型响应体大小。这四个字段都能从产品文档、埋点或压测里找到依据,而不是靠架构师的直觉。

分类的优先级是:实时 > 强一致 > 读多写少 > 写多。这个顺序不是因为实时“更高级”,而是因为这四类特征改变的是故障模型,越靠前的特征越会推翻后面特征的假设。

  • 实时改变的是连接模型。普通 HTTP 的失败是一次请求超时,长连接的失败是连接风暴、消息乱序、在线状态不一致,故障半径从单个请求扩大到一群用户。
  • 强一致改变的是写入边界。普通写入失败可以重试,强一致写入失败会留下半成品:库存扣了订单没写、钱扣了权益没发,需要事务边界、幂等键和对账来收尾。
  • 读写比改变的是读路径。读多写少可以把压力往缓存、读模型和 CDN 上转移,代价是缓存一致性和热 key。
  • 写多改变的是写路径。写多需要队列、分片和批量写,代价是写入放大和积压。
形状典型模块假设读写比必备能力主要风险
read-heavy内容流、商品详情、排行榜80:1多级缓存、读模型、CDN缓存一致性、热 key
realtime私信、协同编辑、直播弹幕接近 1:1 且双向连接网关、会话粘性、背压长连接放大故障半径
transactional下单、支付、库存写少但每次写都关键事务边界、幂等键、对账跨服务事务与补偿成本
write-heavy埋点、日志、消息落库1:10 甚至更低队列、分片、批量写写入放大与积压

表里的读写比是案例假设值,只用于说明量级,不是判定阈值。真正要记住的是最后一列:每种形状都有自己的主要风险,选型时应该问“我准备用什么手段接住这个风险”,而不是问“哪个中间件更流行”。

还要强调一点:Classify 是决策的起点,不是结论。它告诉你这块业务最可能以什么方式坏,以及必须提前准备哪些能力;它不告诉你现在就要拆几个服务。早期阶段一个 PostgreSQL 加一个 Redis 完全可以同时承载内容流和下单,分类的价值是让你知道哪条路径先需要加固,而不是让你在只有三个用户时先搭一套消息队列。

为什么不是先看写多

写多看起来最“重”,但它的失败通常是可见、可缓冲的:队列积压、写入变慢、消费延迟。只要业务允许异步,写多可以通过削峰和批量消化。实时和强一致的失败则往往不可缓冲:消息丢了就是丢了,库存超卖就是超卖,补偿成本远高于排队成本。

这也是分类顺序和“哪个更难做”不是一回事的原因。写多的工程量可能很大,但它不改变请求的正确性边界;实时和强一致会改变,所以必须先问。

一个模块也可能有多个形状

同一个“商品详情页”也可能同时有读多写少和强一致:商品描述可以缓存,库存和价格必须实时准确。这时不要强行给页面贴一个形状,而应该把它拆成两个读路径,分别套用不同的能力和风险清单。

如果拆不开,就按更严格的那个形状准备能力,并明确告知产品这意味着更高的成本和更低的峰值容量。最怕的是嘴上说“最终一致”,却在用户看到价格不一致时才发现承诺错了。

判断一个模块是否需要拆,可以问四个问题:

  • 两条路径能否接受不同的延迟目标?
  • 两条路径能否接受不同的正确性承诺?
  • 失败时能否只降级其中一条?
  • 两条路径的流量是否相差一个数量级?

四个问题里有两个以上回答“是”,就值得拆;否则先按严格形状统一处理,等流量和团队都准备好再拆。

三个事故:形状错配的代价

第一个事故来自把内容流当强一致事务系统做。团队为了“数据绝对正确”,把每次列表查询都包进事务,关掉了缓存,读请求直接落到主库。假设晚高峰 3000 QPS,主库 CPU 很快到 90%,p99 从 80 毫秒涨到 600 毫秒。团队的第一反应是加只读副本,但因为读路径被要求强一致,副本的复制延迟又让它们不敢接流量,最后变成“加了机器却没有效果”。

更糟的是,这个决定会自我强化:越慢越不敢用缓存,越不用缓存越慢。内容流的业务语义本来允许一两秒的延迟,团队却用最强的正确性去换最不需要的正确性,把成本花在了错误的地方。

第二个事故来自把私信当普通 HTTP 做。第一版用客户端每 2 秒轮询一次收件箱。假设 10 万在线用户,仅空轮询就是 5 万 QPS(案例假设值),消息延迟最高 2 秒,移动端还因为频繁唤醒更费电。团队随后改成 WebSocket 长连接,但没有做背压和广播限流:一次面向 10 万人的活动通知,把连接网关的内存打爆,所有在线用户一起掉线。

长连接的故障模型和 HTTP 不同。HTTP 服务过载时表现为超时,客户端重试即可;长连接服务过载时表现为连接堆积、心跳超时、重连风暴,一批用户同时掉线又同时重连,第二波流量往往比第一波更大。

第三个事故来自把下单当最终一致做。库存服务先扣减,订单服务异步落库,中间用消息队列解耦。听起来很标准,但库存扣减成功、消息发送失败的那几毫秒里,系统既没有订单也没有回滚,库存被永久占住;而支付回调重试又没有幂等键,同一个订单被发了两次货。这类问题不会在压测里出现,只会在真实用户的异常路径上出现。

三个事故的共同点:团队先选了中间件,再回头解释产品需求。正确的顺序应该反过来,先把产品事实分类,再让分类结果决定需要哪些能力。

这三个事故还有同一个隐藏前提:团队假设所有模块的故障可以互相隔离。实际上共用的数据库、网关和发布流程会把局部故障放大成全局故障,形状分类必须和部署边界一起看。

用一段 Go 代码把判断固定下来

分类规则如果只写在文档里,迟早会变成“上次是谁说的”。把它写成纯函数有三个好处:输入是显式结构体,优先级可以被测试锁定,新增产品形态时只需要补一个用例,而不是重新开会。

下面这段代码来自本书配套的 webapp-lab,Classify 接收一个 Trait,返回形状、必备能力和主要风险。注意它先检查实时,再检查强一致,最后才看读写比;顺序本身就是本章的核心判断。

// file: internal/platform/product/shape.go
// tier: L1
// verify: go test ./internal/platform/product/

package product

import "fmt"

// Trait 是产品对服务端提出的可观测特征,不是技术选型。
type Trait struct {
	ReadPerWrite float64 // 每写一次对应的读次数
	Consistency  string  // strong | eventual | session
	Realtime     bool    // 是否要求亚秒级双向实时
	PayloadKB    int     // 典型响应体大小
}

// Shape 是由产品特征推导出的服务端形状。
type Shape string

const (
	ReadHeavy     Shape = "read-heavy"
	WriteHeavy    Shape = "write-heavy"
	Realtime      Shape = "realtime"
	Transactional Shape = "transactional"
)

// Driver 是形状对应的必备能力与主要风险。
type Driver struct {
	Shape    Shape
	Required []string
	Risk     string
}

// Classify 按“实时 > 强一致 > 读多写少 > 写多”的优先级归类。
// 优先级不是价值判断:实时与强一致会压倒读写比,因为它们改变故障模型。
func Classify(t Trait) (Driver, error) {
	if t.ReadPerWrite < 0 || t.PayloadKB < 0 {
		return Driver{}, fmt.Errorf("product: negative trait")
	}
	switch {
	case t.Realtime:
		return Driver{
			Shape:    Realtime,
			Required: []string{"connection-gateway", "session-affinity", "backpressure"},
			Risk:     "长连接与多端同步放大故障半径",
		}, nil
	case t.Consistency == "strong":
		return Driver{
			Shape:    Transactional,
			Required: []string{"transaction-boundary", "idempotency-key", "reconciliation"},
			Risk:     "跨服务事务与补偿成本",
		}, nil
	case t.ReadPerWrite >= 20:
		return Driver{
			Shape:    ReadHeavy,
			Required: []string{"cache-layers", "read-model", "cdn"},
			Risk:     "缓存一致性与热 key",
		}, nil
	default:
		return Driver{
			Shape:    WriteHeavy,
			Required: []string{"queue", "sharding", "write-batch"},
			Risk:     "写入放大与积压",
		}, nil
	}
}

这段代码有几个值得说明的边界。

  • Trait 的字段都是产品可观测特征,不是技术选型。把 Realtime 写成 bool 是一种简化:真实系统还要区分“亚秒推送”和“秒级刷新”,但作为分类起点,布尔值足够暴露冲突。
  • Classify 对负数输入返回错误,而不是默默归类。容量、比例这类输入一旦为负,通常意味着上游口径错了,早失败比晚失败便宜。
  • ReadPerWrite >= 20 是一个经验阈值(案例假设),不是物理定律。它的作用是给讨论一个默认值,真正落地时要结合缓存成本、数据一致性和读放大的实际测量调整。
  • 返回值里的 Required 和 Risk 比 Shape 更有用。形状只是一个标签,必备能力和主要风险才是后续设计、评审和演练的检查清单。

还要注意代码没有回答的问题:一个产品往往同时有多个形状。内容流是 read-heavy,私信是 realtime,下单是 transactional,它们应该按模块分别分类,而不是给整个产品贴一个标签。把“产品形状”理解成“模块形状的集合”,后面的容量、可用性和边界讨论才不会互相污染。

测试:优先级的边界要可回归

优先级这种东西最容易在口头讨论里漂移。今天有人说“强一致更重要”,明天有人说“读多写少才是主流”,如果没有测试,代码会跟着人的记忆一起变。下面的表驱动测试把五条边界固定下来:实时压过强一致、强一致压过读多写少、普通读多写少、写多、以及零读的特殊情况。

// file: internal/platform/product/shape_test.go
// tier: L1
// verify: go test ./internal/platform/product/

package product

import "testing"

func TestClassifyPriority(t *testing.T) {
	cases := []struct {
		name string
		t    Trait
		want Shape
	}{
		{"realtime-wins-over-strong", Trait{ReadPerWrite: 2, Consistency: "strong", Realtime: true}, Realtime},
		{"strong-wins-over-read-heavy", Trait{ReadPerWrite: 100, Consistency: "strong"}, Transactional},
		{"read-heavy", Trait{ReadPerWrite: 40, Consistency: "eventual"}, ReadHeavy},
		{"write-heavy", Trait{ReadPerWrite: 3, Consistency: "eventual", PayloadKB: 4}, WriteHeavy},
		{"zero-reads", Trait{ReadPerWrite: 0, Consistency: "eventual"}, WriteHeavy},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Classify(c.t)
			if err != nil {
				t.Fatal(err)
			}
			if got.Shape != c.want {
				t.Fatalf("shape = %s, want %s", got.Shape, c.want)
			}
			if len(got.Required) == 0 || got.Risk == "" {
				t.Fatalf("driver incomplete: %+v", got)
			}
		})
	}
}

func TestClassifyRejectsNegativeTrait(t *testing.T) {
	if _, err := Classify(Trait{ReadPerWrite: -1}); err == nil {
		t.Fatal("want error for negative read/write ratio")
	}
	if _, err := Classify(Trait{PayloadKB: -1}); err == nil {
		t.Fatal("want error for negative payload")
	}
}

测试里最值得看的是前两个用例。realtime-wins-over-strong 同时打开实时和强一致,期望得到 realtime;strong-wins-over-read-heavy 把读写比拉到 100,期望仍然是 transactional。这两个用例的作用不是证明代码聪明,而是防止后人为了“让某个业务通过”偷偷调整 switch 的顺序。

zero-reads 也值得留意。读写比为 0 时不会进入 read-heavy 分支,而是落到 write-heavy。这是有意的:只写不读的系统(例如纯埋点收集)主要风险是写入积压,不是缓存一致性。

测试没有覆盖的部分也要说清楚:它不验证阈值 20 是否合理,不验证 Required 里的能力是否真的被实现,也不验证多个模块组合后的系统行为。这些需要容量模型、SLO 和演练来回答,后面的章节会逐步展开。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/platform/product/ -count=1

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

ok  	webapp/internal/platform/product	0.366s

ok 表示包内所有测试通过。输出里没有显示用例数量,是因为没有加 -v;需要逐个用例确认时,可以加 -v 再跑一次。无论是否加 -v,判定标准都一样:退出码为 0,且没有 FAIL。

小结

产品形态决定服务端形状,这句话的重点不在“形态”这个词,而在“决定”这个动作:把产品事实写成显式输入,把优先级写成可测试的代码,把必备能力和主要风险写成检查清单。分类的优先级是实时 > 强一致 > 读写比 > 写多,因为越靠前的特征越会改变故障模型。

Classify 只回答“最可能怎么坏、要提前准备什么”,不回答“现在要拆几个服务”。真实选型还要看阶段、团队规模和成本:早期用一个数据库承载多种形状并不可耻,可耻的是不知道自己正在承担哪种风险。

下一节会把这三个产品事实换算成用户量、请求量和成本三条曲线,看看“感觉要扩容”怎样变成一组可以追溯的假设。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 用户量、请求量与成本的三条曲线 。

常见错误与边界

  • 把读写比当唯一指标。读写比只影响读路径,实时和强一致会直接推翻它。
  • 把“实时”理解成“低延迟 HTTP”。轮询也能做到低延迟,但连接模型、顺序保证和故障半径完全不同。
  • 强一致范围过大。整条链路都强一致,通常意味着既慢又贵;应该先问哪一步真的不能错。
  • 用分类结果直接决定微服务数量。分类输出的是能力和风险,不是部署单元。
  • 忽略响应体大小。大 payload 会改变带宽、序列化和超时预算,小读写比也可能压垮网关。
  • 假设读写比是常量。活动、爬虫、客户端改版都会改变读写比,分类结果需要随产品迭代复核。
  • 早期过度设计。把未来三年的形状一次性落地,通常会把当前阶段拖死。
  • 给整个产品贴一个标签。多模块产品应该分别分类,再讨论模块之间的边界。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查