从一个叫「回声」的产品说起
「回声」是一个为本书构造的假想产品,后文出现的 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 会改变带宽、序列化和超时预算,小读写比也可能压垮网关。
- 假设读写比是常量。活动、爬虫、客户端改版都会改变读写比,分类结果需要随产品迭代复核。
- 早期过度设计。把未来三年的形状一次性落地,通常会把当前阶段拖死。
- 给整个产品贴一个标签。多模块产品应该分别分类,再讨论模块之间的边界。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。