三年计划收尾时,三本账对不上
「回声」的三年规模化计划在第十三个季度收尾。复盘会开了三个小时,桌上有三本账。
第一本是产品账:三年里从“一个内容社区”变成“内容加交易”,订单、售后、商户后台都是后长出来的,首页改版了七次,推荐策略换了四版。第二本是架构账:从单体到按域拆分,从单库到分片,从单地域到双地域,中间还经历了一次消息中间件的替换。第三本是人的账:团队从八个人变成四十个人,值班从“谁写的谁修”变成轮值,发布从每周一次变成每天多次。
三本账单独看都挺漂亮,放在一起却对不上。产品同学说架构太慢,一个活动需求要排两周;架构同学说产品太急,刚拆好的域又被新需求打穿;值班同学说两边都不管他,事故来了只能照着手册一步步试。会议最后没有得出“谁对谁错”的结论,因为问题不在任何一方,而在三种演进的速度本来就不一样。
这一节不再讲某个具体技术,而是收口全书:产品、架构和人各自按什么节奏变化,错位时会发生什么,以及怎样用一个最小的判断工具,避免把“还在进行”误当成“已经完成”。
产品、架构与人的三种节奏
把三者放在同一张表里,错位会非常直观。下表中的周期是「回声」的案例假设值,不是行业规律;每个产品都应该用自己的发布记录和事故记录重新估算:
| 对象 | 典型变化周期 | 主要驱动 | 滞后信号 | 常见错位 |
|---|---|---|---|---|
| 产品 | 周到季度 | 用户反馈、竞争、营收目标 | 需求开始绕过现有能力 | 用活动需求倒逼架构返工 |
| 架构 | 季度到年 | 容量、故障、成本、合规 | 变更越来越慢、回滚越来越难 | 架构完成后没有产品使用 |
| 人 | 年到数年 | 招聘、轮岗、事故、培训 | 值班只能找原作者 | 新架构上线,旧习惯照旧 |
产品变化最快,因为它面向外部,反馈直接。一个活动、一次改版、一个新品类,几周就能上线。架构变化慢,因为每一次结构性调整都涉及数据迁移、双写、回滚预案和跨团队协调,周期通常以季度计。人的变化最慢,因为习惯来自重复,而重复需要时间;一个人从“出了问题找作者”变成“先看监控再定位”,往往要经历几次自己独立处理的故障。
三种节奏没有优劣。快的那一层负责试探方向,慢的那一层负责承接后果,最慢的那一层决定前两层能不能持续。问题出在把三者当成同一个项目来管理:给架构定一个和产品一样的上线日期,或者指望组织调整在一个季度内改变值班习惯。
错位发生时,先修哪一层
错位暴露出来时,团队的第一反应往往是修自己最熟悉的那一层。产品背景的负责人倾向于加需求、加人手;架构背景的负责人倾向于换中间件、加抽象;管理背景的负责人倾向于做培训、调组织。三种反应都可能有效,但如果用错了层,就会把一个真问题拖成三个假问题。
判断应该先修哪一层,可以问三个问题。第一,这个需求是否在反复绕过现有能力?如果是,问题可能在产品边界,也可能在架构契约,但通常不在执行层。第二,变更是否越来越慢、回滚是否越来越难?如果是,先修架构的可逆性,而不是要求团队“再拼一点”。第三,事故来了是否只能找原作者?如果是,先修人的能力与流程,而不是再加一层监控。
顺序上有一条朴素的原则:先止血,再修契约,最后补能力。值班同学看不懂告警,先补值班手册和演练;跨团队接口天天打架,先把契约版本和变更流程定下来;容量还有余量时,不要因为增长曲线好看就提前做结构性改造。反过来,如果系统已经在持续失血,再完整的培训也救不了当晚的故障。
还要警惕“用架构项目解决产品不确定性”。产品方向没想清楚时,团队容易寄希望于“平台化之后什么需求都能快速支持”,结果平台做完,需求方向又变了。架构可以提供可逆性和扩展点,但不能替产品做选择。同样,也不能用培训解决契约缺失:两个人对接口的理解不一致,靠多开几次会通常没用,必须把契约写下来并版本化。
用季度校准,而不是用年度叙事
年度计划适合讲方向,不适合跟踪漂移。一年太长,中间会发生太多事:产品假设可能被证伪,架构约束可能提前撞上,关键的人可能离职。如果只在年初和年末各看一次,团队很容易用“大方向没变”解释掉所有中间信号。
「回声」后来把校准改成季度一次,每次只回答三个问题:过去一个季度,哪个产品假设被推翻或加强?哪个架构约束第一次成为交付瓶颈?哪个“人的能力”信号仍然缺失?答案不需要长篇报告,一页纸、三行字即可。它的作用不是考核,而是让下一季度的资源分配有事实依据。
季度校准的另一个好处是允许“停止”。年度计划一旦立下,停止一个项目往往被理解为失败;季度校准把“停止”变成一次正常的判断:假设不成立了,或者收益已经被成本超过。前面提到的服务化项目,如果每季度都被问一次“退出条件满足了没有”,它不会在“进行中”的状态里空转十八个月。
校准记录要保留,但不要做成排行榜。记录的价值在于对比“当时怎么想”和“后来发生了什么”,而不是给团队打分。三种节奏的错位不会因为一次校准消失,但会因为定期面对而变得更早被发现。
产品拉架构,架构也限制产品
产品需求是架构演进最真实的驱动力。没有订单,就不会有分库分表;没有多地域用户,就不会有跨地域复制。反过来,架构一旦成型,也会限制产品能做什么:分片键选了用户,按商户的实时报表就会变贵;服务按域拆了,跨域事务就要改成最终一致。
健康的共同演进不是“架构追着产品跑”,而是双方都承认约束存在。产品在提需求时,应该知道哪些能力是“加个开关”,哪些是“要动数据模型”;架构在规划时,应该知道哪些能力有明确的产品场景,哪些只是技术兴趣。两边都说不清的时候,先做一个可逆的小实验,比做一份三年的架构蓝图更有用。
「回声」后来设了一个很小的机制:每个季度评审一次“产品路线图里有哪些需求会触碰架构约束”。这个评审不审批需求,只标注影响面。它让架构团队提前两个季度知道“跨境结算要来了”,也让产品团队知道“这个功能会撞上分片键”。机制很轻,但它把两种节奏的错位从“上线前才发现”提前到了“规划时就知道”。
人的变化最慢,也最容易被忽略
架构文档可以一个下午写完,监控大盘可以一周搭好,但“遇到告警先看什么”这种判断,只能靠反复练习。很多团队在完成一次架构升级后,会默认组织能力也同步升级了;直到下一次故障,才发现值班同学仍然在等原作者上线。
人的演进有几个可观察的信号:值班同学能在没有原作者的情况下完成一次定位;新同学能在两周内独立发布一个小变更;事故复盘里开始出现“流程问题”而不只是“某个人疏忽”;跨团队接口变更前,双方会主动确认契约版本。这些信号不产生功能,但它们决定架构能不能被真正使用。
反过来,人的滞后也会反过来拖住架构。一个团队如果长期靠“英雄式救火”,就很难接受灰度发布和自动化回滚,因为那意味着把控制权交给流程。架构演进的最后一步,往往不是技术选型,而是让团队相信新流程比旧习惯更可靠。这件事没有捷径,只能靠一次次成功的小规模实践积累信任。
一次只有目标没有退出条件的年度计划
第三年年初,团队写了一份年度技术计划,标题叫“平台化、服务化、全球化”。正文有三段,每段都写了愿景和要做的项目,但没有一段写“做到什么程度算完成”。评审会上有人问“平台化什么时候算做完”,回答是“这是一个持续的过程”。
这句话听起来很成熟,实际上让计划失去了判断力。第一季度,三个方向都启动了;第二季度,平台化因为缺人暂停,服务化因为产品需求插队延期,全球化因为合规评估没通过而搁置;第三季度,三个方向都恢复了一点,但没有任何一个能拿出可验收的成果;第四季度,团队开始为“明年继续推进”写新的计划,措辞和去年几乎一样。
更具体的代价发生在服务化项目上。团队花了一年半拆出六个服务,但因为没有定义退出条件,没人能回答“拆完之后调用延迟是否可接受”“跨服务故障的恢复时间是否达标”“团队是否真的能独立发布”。项目一直在“进行中”,预算一直在续,直到有一次跨服务故障导致核心链路中断四十分钟,大家才发现拆分带来的运维复杂度已经超过了收益。如果年初就写了退出条件,这个项目可能在第六个月就被叫停,或者被调整方向。
事故的根因不是目标太大,而是目标没有被翻译成可验收的事实。“平台化”可以翻译成“新业务接入从两周缩短到两天”;“服务化”可以翻译成“任一服务可独立发布并在五分钟内回滚”;“全球化”可以翻译成“第二区域的核心接口 p99 与主区域差距小于 20%”。有了这些事实,团队才能在季度末判断“继续、调整还是停止”。
这也是 13.1 那套阶段校验存在的理由:路线图不是愿望清单,它必须回答“怎么知道已经离开上一站”。没有退出条件的计划,本质上是一份没有刹车的承诺。
把目标翻译成退出条件,有一个简单的格式:一条可观察的事实、一个责任人、一个时间窗口。例如“第二区域核心接口的 p99 与主区域差距小于 20%,连续观察两周,由值班负责人确认”,就比“完成全球化”可执行得多。事实让判断有依据,责任人让判断有人做,时间窗口让判断有终点。
格式不能保证目标正确,但能保证目标可被证伪。一个计划如果写不出任何可观察的事实,通常说明团队还没有想清楚要解决什么问题,或者只是把技术名词当成了方向。这时候最该做的不是排期,而是回到产品场景,重新问一次“谁遇到了什么问题”。
「回声」后来给每个年度目标都加了一列“停止条件”:什么情况下应该停止、调整或缩小范围。这个列比目标本身更难填,但它逼着团队在投入之前就想好退路。承认一个项目可能失败,并不会让它更容易失败;相反,它让团队在失败信号出现时不必为了保护面子而继续加码。
年度计划因此从一份承诺变成了一个假设集合。假设可以被验证,也可以被推翻;被推翻不是执行失败,而是信息更新。三种节奏的错位不会消失,但团队至少能在错位变成事故之前,用一次季度校准把它摆到桌面上。
收口工具:把发现汇总成可判断的结论
自评的最后一步,不是算一个总分,而是回答一个二元问题:当前这批发现,是否还在可接受的范围内。下面的实现把发现按区域汇总,并提供一个“是否达标”的判断。它刻意不做加权、不做排名,因为到了收口阶段,阻断项不应该被平均掉。
为什么收口不用总分
前面 13.2 的加权分适合看趋势,不适合做收口。趋势关心“今年比去年好了多少”,收口关心“现在能不能接受”。这是两个不同的问题。把一个阻断级的安全发现和十条低危的文案问题放进同一个加权公式,得到的分数可能仍然“健康”,但这个结论对决策没有帮助。
收口要的是可执行的结论:还有没有超过阈值的发现,分布在哪些区域,谁必须在什么时间处理。按区域取最大值,可以让每个区域只回答一次“最坏到什么程度”;再用一个阈值判断整体是否达标。这样报告很短,但每条信息都能对应到一个动作。
这个工具也不能替代评审。它能告诉你“有一条 severity 为 3 的发现”,但不能告诉你这条发现是否真的不可接受、是否需要停机、是否可以先接受风险。这些判断需要人来做,而且需要写进记录。工具负责不让阻断项被平均掉,人负责决定怎么处理。
还有一个边界:Meets 只检查单条发现的 severity,不检查发现的数量。二十条 severity 为 2 的发现会通过阈值为 2 的判断,但它们合起来可能已经说明某个区域在系统性退化。数量趋势应该由 13.2 的健康度评分和季度校准来观察,而不是塞进这个二元判断里。
// file: internal/scalebase/summary.go
// tier: L1
// verify: go test ./internal/scalebase/
package scalebase
// Finding 是一条自评发现。Severity 沿用团队既有口径:0 是提示,3 是阻断。
// Area 是维度或系统名,Detail 写清楚“哪里、什么现象、为什么不达标”。
type Finding struct {
Area string
Detail string
Severity int
}
// Aggregate 按 Area 汇总每个区域最严重的一条。
// 这里取最大值而不是求和:十个低危问题不应该把一个阻断问题平均掉,
// 报告读者首先要知道的是“这个区域最坏坏到什么程度”。
func Aggregate(fs []Finding) map[string]int {
out := make(map[string]int, len(fs))
for _, f := range fs {
cur, ok := out[f.Area]
if !ok || f.Severity > cur {
out[f.Area] = f.Severity
}
}
return out
}
// Meets 判断所有发现是否都不超过 maxSeverity。
// 没有任何发现时返回 true:自评的意义是发现问题,而不是要求必须有问题。
func Meets(fs []Finding, maxSeverity int) bool {
for _, f := range fs {
if f.Severity > maxSeverity {
return false
}
}
return true
}
Aggregate 取每个区域的最大 severity,而不是求和。求和会让“很多小问题”淹没“一个大问题”,而收口阶段最需要知道的恰恰是“有没有阻断项”。最大值的语义也更稳定:新增十条低危发现不会改变某个区域的汇总值,报告不会因为噪音而剧烈波动。
空 Area 不会被丢弃,而是作为空字符串键保留。这个选择有点别扭,但它比静默丢弃更诚实:如果上游忘了填区域,报告里会出现一个空键,评审时一眼就能看见。静默丢弃会让问题从报告里消失,而“消失”比“难看”危险得多。
Meets 在没有任何发现时返回 true。这不是纵容,而是承认“自评没有发现问题”本身是一个合法结果。真正需要警惕的是“为了让报告好看而不记录发现”,这要靠评审流程解决,而不是靠函数返回 false 来惩罚。
Meets 没有校验 maxSeverity 的范围。调用方可以传 0 到 3,也可以传更大的数;函数只做比较,不做政策判断。阈值应该由团队在评审规则里定义,而不是硬编码进工具。
测试:汇总取最坏值,空发现视为达标
收口工具的测试重点不是算法复杂度,而是语义边界:同一个区域多条发现时取哪条、空输入算不算达标、等于阈值算不算通过。
// file: internal/scalebase/summary_test.go
// tier: L1
// verify: go test ./internal/scalebase/
package scalebase
import "testing"
func TestAggregateKeepsWorstPerArea(t *testing.T) {
fs := []Finding{
{Area: "database", Detail: "slow query", Severity: 1},
{Area: "database", Detail: "no failover drill", Severity: 3},
{Area: "edge", Detail: "stale cache", Severity: 2},
{Area: "edge", Detail: "missing header", Severity: 0},
{Area: "", Detail: "unclassified", Severity: 1},
}
got := Aggregate(fs)
if len(got) != 3 {
t.Fatalf("Aggregate size = %d, want 3", len(got))
}
if got["database"] != 3 || got["edge"] != 2 || got[""] != 1 {
t.Fatalf("Aggregate = %v, want database=3 edge=2 \"\"=1", got)
}
if fs[0].Severity != 1 || fs[1].Severity != 3 {
t.Fatal("Aggregate must not modify its input")
}
if len(Aggregate(nil)) != 0 {
t.Fatal("Aggregate(nil) should be empty")
}
}
func TestMeets(t *testing.T) {
fs := []Finding{
{Area: "database", Severity: 2},
{Area: "edge", Severity: 1},
}
if !Meets(fs, 2) {
t.Fatal("Meets(fs, 2) = false, want true")
}
if Meets(fs, 1) {
t.Fatal("Meets(fs, 1) = true, want false")
}
if !Meets(nil, 0) {
t.Fatal("no findings should be treated as meeting the bar")
}
if !Meets([]Finding{}, -1) {
t.Fatal("empty findings should meet any bar")
}
if !Meets([]Finding{{Area: "edge", Severity: 0}}, 0) {
t.Fatal("severity equal to max should pass")
}
}
TestAggregateKeepsWorstPerArea 同时锁住三件事:同一区域取最大值、空区域保留为空键、函数不修改输入切片。第三个断言经常被忽略,但 Aggregate 接收的是切片,元素是值类型,正常实现不会改到原数据;如果未来有人把实现改成“先排序再取最后一个”,这个断言会提醒他排序会改变调用方看到的数据顺序。
TestMeets 覆盖了三个边界:severity 等于阈值时通过、超过阈值时失败、空发现时通过。最后一条尤其重要,因为很多实现会写成“发现数量大于零且都低于阈值”,从而把“没有发现问题”误判成“不达标”。
测试没有覆盖 severity 为负数的情形。负数在业务上通常表示“信息”或“未分级”,Aggregate 会保留它,Meets 会把它当成通过。如果团队决定禁止负数,应该在数据入口校验,而不是在这个工具里增加隐式规则。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/scalebase/ -run 'TestAggregate|TestMeets' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestAggregateKeepsWorstPerArea
--- PASS: TestAggregateKeepsWorstPerArea (0.00s)
=== RUN TestMeets
--- PASS: TestMeets (0.00s)
PASS
ok webapp/internal/scalebase 0.346s
两个用例全部通过,退出码为 0。-run 只筛选了汇总与达标判断的用例,同包的路线图和健康度评分测试没有执行;需要全量确认时去掉 -run 再跑一次。
小结
产品、架构和人以不同速度演进,这不是需要消除的缺陷,而是需要被承认的事实。产品负责试探方向,架构负责承接后果,人负责让前两者可持续。三者错位时,最危险的不是某一层慢,而是用同一套时间表和同一套验收方式去要求它们。
三年计划收尾时,「回声」真正学到的不是某个中间件怎么选,而是三件事:路线图必须有退出条件,健康度分数只能用于同口径的纵向比较,人的能力必须被当成架构的一部分来建设。技术方案可以复制,这三件事只能自己长出来。
全书到这里收口。下一站回到目录与学习路径,按需重读某个专题,或者把某一节的代码放进自己的项目里跑一遍。架构没有终点,只有一次次被事实修正的判断。
阅读导航:上一节:13.2 架构健康度自评 · 下一节:目录与学习路径 。
常见错误与边界
- 用同一套节奏要求产品、架构和人。产品按季度变,架构按年变,人的习惯更慢;强行同步只会让某一层被形式化。
- 路线图只写目标,不写退出条件。“平台化”“服务化”如果没有可验收的事实,就永远处于“进行中”。
- 把“持续的过程”当成不设终点的借口。持续演进不等于没有阶段,每个阶段仍然需要出口。
- 让架构规划脱离产品场景。没有使用者的架构项目,最后往往变成维护成本。
- 忽略人的滞后。新架构上线不等于团队会用,值班、发布、复盘的习惯需要单独建设。
- 用分数或排名替代问题清单。收口阶段应先看阻断项,再看趋势,最后才看总分。
- 把空发现当成“没问题”的充分证据。没有发现可能意味着系统健康,也可能意味着自评没有认真做,需要用评审流程区分。
- 把工具当成答案。代码只能固定语义,判断仍然需要人来做,而且需要被事实反复修正。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。