组织图改了,服务边界没改
北辰把《泡泡工坊》的研发团队拆成一个独立工作室,组织图上已经分开:负责人、预算、招聘都独立。三个月后做权限审计,发现工作室仍在用总部的数据库账号、总部的计费密钥和总部的发布流水线;成本报表也无法区分两个实体的支出,因为资源标签还是旧的。
组织变更本身不会自动改变架构。组织图决定谁向谁汇报,架构决定服务如何部署、数据存在哪里、谁能访问、成本记在谁头上。两者之间需要一份过渡计划,否则拆分只发生在通讯录里,事故和账单仍沿着旧边界流动。
这一节按四个问题展开:变更类型决定哪些架构面必须动;过渡期如何分阶段并设定退出条件;临时接口如何避免变成永久补丁;数据、权限、计费、流水线各自的最低动作是什么。
四种变更,四种架构影响
| 变更 | 服务边界 | 数据 | 权限 | 计费 | 流水线 | 值班 |
|---|---|---|---|---|---|---|
| 合并 | 两个 owner 合成一个,去重 | 合并库或建同步 | 统一角色,收回旧账号 | 合并成本中心 | 可共用一条 | 合并值班表 |
| 拆分 | 按故障域与发布节奏切开 | 拆库或明确只读边界 | 新团队独立身份 | 成本按新边界归集 | 必须隔离 | 两边都要覆盖 |
| 外包 | 合同定义接口,内部保留核心 | 导出最小数据集 | 供应商账号有期限 | 合同计价,内部可核算 | 独立或受控 | 内部保留首响 |
| 独立 | 完整边界,能独立发布 | 全量迁移并可回滚 | 独立身份体系 | 独立账单 | 独立流水线 | 独立值班 |
四种变更里,合并最容易低估数据去重,拆分最容易低估流水线,外包最容易低估权限回收,独立最容易低估计费归属。低估的共同原因是:组织变更的验收标准通常是“人到位、预算到位”,而不是“服务能独立运行”。
一个实用的判断是:变更完成后,新实体能否在不呼叫旧实体的情况下完成一次发布、一次回滚、一次数据恢复和一次账单核对。四件事都能独立完成,才算架构真的跟上了组织。
先定退出条件,再排迁移步骤
本节的 26 周上限、双轨时长与检查阈值均为示例值,需按数据量、回滚信心与组织规模调整。
过渡期不是一段模糊的“先这样跑着”,而是一个有状态、有退出条件的流程:
| 阶段 | 进入条件 | 允许做的事 | 退出证据 |
|---|---|---|---|
| planned | 计划批准 | 设计边界、准备环境 | 计划与退出日期 |
| dual-run | 新旧路径同时可用 | 双写、对账、影子流量 | 数据一致性与错误率达标 |
| cutover | 切换窗口开始 | 切流、回滚演练 | 切换完成且可回滚 |
| exiting | 新路径承载主要流量 | 清理旧路径、回收权限 | 旧路径无流量 |
| stable | 退出完成 | 只保留新路径 | 账单、权限、值班均已归属 |
| overdue | 超过退出周仍未退出 | 只允许收尾,不允许加新功能 | 需要重新决策 |
状态机的价值在于把“还在过渡”变成可检查的事实。超过退出周仍未退出,不应该继续叫“过渡期”,而应该标记为 overdue,并升级到 steering 决策:要么给出新的退出日期和资源,要么回滚。没有这个标记,过渡期会自然延长,直到所有人都忘记它曾经是临时的。
退出日期要写在计划里,而不是写在会议纪要里。计划至少要包含:切换周、退出周、每个步骤的 owner、哪些步骤可逆、哪些临时产物必须在退出前拆除。示例上限可以把切换至退出控制在 26 周以内;超过这个跨度,组织变更的收益通常已经被双轨成本吃掉。
双轨运行的成本与时限
双轨不是免费的。它至少带来四类成本:双写与对账、两套值班、配置漂移、以及“到底以哪边为准”的决策成本。双轨时间越长,临时补丁越多,最后清理旧路径的难度越大。
| 成本 | 具体表现 | 控制手段 |
|---|---|---|
| 双写与对账 | 两边数据不一致,需要定时修复 | 明确主库,写路径单向,定时对账 |
| 两套值班 | 同一故障两边都响应,责任不清 | 指定主值班,另一边只做备份 |
| 配置漂移 | 两边参数逐渐不同,回滚失效 | 配置版本化,差异清单化 |
| 决策成本 | 每个变更都要判断走哪边 | 设定切流比例与退出阈值 |
双轨的退出不能只看时间,还要看证据:数据对账差异在可接受范围内、新路径的错误率与延迟不劣于旧路径、回滚演练成功、旧路径流量降到零。四类证据里缺一项,就不应该宣布退出;但也不能因为追求完美而无限期延长,超过示例上限 26 周要重新决策。
短双轨和长双轨各有风险。短双轨节省成本,但数据迁移与回滚验证不充分;长双轨验证充分,但补丁累积、组织疲劳。选择依据是数据量与回滚信心,不是项目组的乐观程度。数据量大、回滚路径没演练过,宁可多留两周;数据量小、回滚已演练,尽快退出。
临时接口必须带拆除日期
组织变更一定会产生临时接口:共享登录令牌、跨工作室的 GM 命令、计费代理、临时数据库视图。它们本身不是问题,问题是没有人负责拆除。
规则可以写得很硬:任何标记为临时的步骤,必须同时写拆除周,且拆除周不得晚于退出周。没有拆除周的临时步骤,在计划评审时直接拒绝;拆除周晚于退出周的,视为永久补丁,必须重新设计。
| 临时产物 | 典型用途 | owner | 拆除条件 |
|---|---|---|---|
| 共享认证令牌 | 过渡期单点登录 | 安全团队 | 新身份体系全量切换 |
| 跨实体 GM 命令 | 客服临时处理两边数据 | 运营平台 | 工单系统完成隔离 |
| 计费代理 | 旧账单系统过渡 | 财务系统 | 新账单对账通过 |
| 只读数据库视图 | 报表临时取数 | 数据团队 | 数据仓库同步完成 |
“永久补丁”的识别不能靠人肉记忆。每季度扫一次计划里的临时标记:没有拆除周的、拆除周已过但仍存在的、owner 已离职的,全部生成整改单。临时接口的存在本身不是债务,没有拆除日期的临时接口才是。
数据、权限、计费、流水线四件事
数据迁移。 切换前的数据复制必须可逆,或者至少有可验证的备份与恢复步骤。不可逆的复制不能发生在切换点之前,否则一次失败的切换会同时失去新旧两条路径。双写阶段要明确主库,避免两边都能写。
权限归属。 新实体要有独立身份,旧账号要在退出前回收。外包场景还要给供应商账号设置到期时间,到期自动失效,而不是靠人记得删。权限边界不清楚,数据边界一定不清楚。
计费归属。 每个实体只能有一个账单 owner。共享成本可以分摊,但分摊规则要公开、可核对;资源标签要在切换前改好,否则第一个月的账单就无法归属。计费归属失败往往比技术故障更晚被发现,也更难追溯。
流水线隔离。 拆分和独立必须在切换前拥有独立流水线;合并可以在切换后共用,但需要先统一构建与发布权限。流水线不隔离,所谓独立团队仍然要被旧团队的发布窗口卡住,组织变更就没有完成。
| 变更 | 必须提前完成的步骤 | 可以延后的步骤 |
|---|---|---|
| 合并 | 计费 owner、权限统一 | 流水线合并、数据去重 |
| 拆分 | 流水线隔离、值班覆盖 | 数据物理拆分 |
| 外包 | 权限回收、计费合同、访问到期 | 内部工具替换 |
| 独立 | 流水线、权限、计费、数据回滚 | 历史报表迁移 |
可运行片段:计划模型与状态机校验
internal/orgevolution 把过渡计划建模成可校验的结构。Plan 记录变更类型、切换周、退出周、实际退出周和每一步的 owner;Validate 检查时间线、必需步骤、临时步骤的拆除周、不可逆数据复制的位置,以及步骤是否越过退出周。
// file: internal/orgevolution/plan.go
// tier: L1
// verify: go test ./internal/orgevolution/
package orgevolution
import (
"sort"
)
// ChangeKind is the org move that forces an architecture change.
type ChangeKind string
const (
Merge ChangeKind = "merge"
Split ChangeKind = "split"
Outsource ChangeKind = "outsource"
Independent ChangeKind = "independent"
)
// StepKind is one unit of transition work. The names are deliberately about
// state, not about meetings: dual-run, data-copy, cutover, exit.
type StepKind string
const (
StepDualRun StepKind = "dual-run"
StepDataCopy StepKind = "data-copy"
StepCutover StepKind = "cutover"
StepBilling StepKind = "billing-owner"
StepAccess StepKind = "access-review"
StepPipeline StepKind = "pipeline-isolation"
StepExit StepKind = "exit"
StepCleanup StepKind = "cleanup"
)
// Step is scheduled in relative weeks from the start of the change. Temporary
// steps must carry a removal week; that is what stops a bridge interface from
// becoming permanent.
type Step struct {
Kind StepKind
Week int
Owner string
Reversible bool
Temporary bool
RemovalWeek int
}
// Plan is one organization change with its architecture work. ExitedWeek is
// the actual completion week, zero while the change is still open.
type Plan struct {
ID string
Kind ChangeKind
From []string
To []string
CutoverWeek int
ExitWeek int
ExitedWeek int
Steps []Step
Services []string
DataStores []string
}
// Issue is one plan defect. Subject is the plan ID or a step kind so the
// output can be attached to a ticket without translation.
type Issue struct {
Subject string
Rule string
Detail string
}
func sortIssues(is []Issue) {
sort.SliceStable(is, func(i, j int) bool {
if is[i].Subject != is[j].Subject {
return is[i].Subject < is[j].Subject
}
if is[i].Rule != is[j].Rule {
return is[i].Rule < is[j].Rule
}
return is[i].Detail < is[j].Detail
})
}
func hasStep(steps []Step, kind StepKind) bool {
for _, s := range steps {
if s.Kind == kind {
return true
}
}
return false
}
func countStep(steps []Step, kind StepKind) int {
n := 0
for _, s := range steps {
if s.Kind == kind {
n++
}
}
return n
}
var knownKind = map[ChangeKind]bool{
Merge: true, Split: true, Outsource: true, Independent: true,
}
Validate 的核心不是“步骤是否齐全”,而是“退出是否可验证”。它要求存在双轨、唯一一次切换、退出步骤,以及不同变更类型各自的必需步骤:拆分要有流水线隔离,合并要有计费 owner,外包要有权限评审,独立要有流水线、权限和计费。
// file: internal/orgevolution/validate.go
// tier: L1
// verify: go test ./internal/orgevolution/
package orgevolution
import (
"fmt"
)
// Validate checks the transition plan as a state machine: there is one
// cutover, a dated exit, no temporary artifact without a removal week, and
// every step has an owner. It also requires the steps that the change kind
// cannot safely skip.
func Validate(p Plan) []Issue {
subject := p.ID
if subject == "" {
subject = "<plan>"
}
var out []Issue
add := func(rule, detail string) {
out = append(out, Issue{Subject: subject, Rule: rule, Detail: detail})
}
if p.ID == "" {
add("plan-without-id", "plan has no stable identifier")
}
if len(p.From) == 0 {
add("no-source-team", "change names no team on the leaving side")
}
if len(p.To) == 0 {
add("no-target-team", "change names no team on the receiving side")
}
if !knownKind[p.Kind] {
add("unknown-change-kind", fmt.Sprintf("kind %q is not recognized", p.Kind))
}
if p.CutoverWeek <= 0 {
add("invalid-cutover-week", fmt.Sprintf("cutover week=%d, want > 0", p.CutoverWeek))
}
switch {
case p.ExitWeek == 0:
add("missing-exit-deadline", "transition has no exit week")
case p.ExitWeek <= p.CutoverWeek:
add("exit-not-after-cutover", fmt.Sprintf("exit week=%d must be after cutover week=%d",
p.ExitWeek, p.CutoverWeek))
case p.ExitWeek-p.CutoverWeek > 26:
add("transition-too-long", fmt.Sprintf("cutover to exit spans %d weeks; cap is 26",
p.ExitWeek-p.CutoverWeek))
}
for _, s := range p.Steps {
step := string(s.Kind)
if s.Owner == "" {
out = append(out, Issue{Subject: subject + "/" + step, Rule: "step-without-owner",
Detail: "every transition step needs one accountable team"})
}
if s.Temporary {
switch {
case s.RemovalWeek == 0:
out = append(out, Issue{Subject: subject + "/" + step, Rule: "temporary-without-removal",
Detail: "temporary step has no removal week"})
case p.ExitWeek > 0 && s.RemovalWeek > p.ExitWeek:
out = append(out, Issue{Subject: subject + "/" + step, Rule: "temporary-outlives-exit",
Detail: fmt.Sprintf("removal week=%d is after exit week=%d", s.RemovalWeek, p.ExitWeek)})
}
}
if s.Kind == StepDataCopy && !s.Reversible && p.CutoverWeek > 0 && s.Week < p.CutoverWeek {
out = append(out, Issue{Subject: subject + "/" + string(s.Kind),
Rule: "irreversible-data-copy-before-cutover",
Detail: "data copy cannot be irreversible before the cutover point"})
}
if s.Kind != StepCleanup && p.ExitWeek > 0 && s.Week > p.ExitWeek {
out = append(out, Issue{Subject: subject + "/" + step, Rule: "step-after-exit",
Detail: fmt.Sprintf("week=%d is after exit week=%d", s.Week, p.ExitWeek)})
}
}
switch {
case !hasStep(p.Steps, StepDualRun):
add("missing-dual-run", "no dual-run period; the old and new paths never overlap")
case countStep(p.Steps, StepCutover) == 0:
add("missing-cutover", "no cutover step")
case countStep(p.Steps, StepCutover) > 1:
add("duplicate-cutover", "more than one cutover step")
}
if !hasStep(p.Steps, StepExit) {
add("missing-exit-step", "exit deadline has no corresponding step")
}
switch p.Kind {
case Split:
if !hasStep(p.Steps, StepPipeline) {
add("missing-pipeline-isolation", "split teams need separate build and release pipelines")
}
case Merge:
if !hasStep(p.Steps, StepBilling) {
add("missing-billing-owner", "merged services need one billing owner")
}
case Outsource:
if !hasStep(p.Steps, StepAccess) {
add("missing-access-review", "outsourcing needs an access review before cutover")
}
if !hasStep(p.Steps, StepBilling) {
add("missing-billing-owner", "outsourcing needs a named billing owner")
}
case Independent:
if !hasStep(p.Steps, StepAccess) {
add("missing-access-review", "independent studio needs its own access boundary")
}
if !hasStep(p.Steps, StepBilling) {
add("missing-billing-owner", "independent studio needs a billing owner")
}
if !hasStep(p.Steps, StepPipeline) {
add("missing-pipeline-isolation", "independent studio needs its own release pipeline")
}
}
sortIssues(out)
return out
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/orgevolution/
ok gsarchlab/internal/orgevolution 0.250s
该包共有 5 个表驱动测试,覆盖时间线、必需步骤、临时步骤、影响风险与阶段判定;gofmt -l 无输出。同包的 Risks 检查值班、回滚、流水线、计费与权限缺口,Status 根据当前周给出 planned、dual-run、cutover、exiting、stable 或 overdue。
四个失败路径
| 失败路径 | 识别信号 | 后果 | 修复 |
|---|---|---|---|
| 只改组织图 | 服务 owner、权限、账单仍是旧的 | 事故仍找旧团队,成本无法归属 | 用四件事验收:发布、回滚、恢复、对账 |
| 双轨无限期 | 超过退出周仍无退出证据 | 成本翻倍,补丁累积 | 标记 overdue,升级决策或回滚 |
| 不可逆迁移无回滚 | 切换前已做不可逆复制 | 切换失败时两条路径同时丢失 | 切换前只做可逆复制,保留备份 |
| 计费与权限仍共享 | 资源标签未改、账号未回收 | 审计失败,责任无法划分 | 切换前完成标签与账号归属 |
四个路径里,“只改组织图”最隐蔽,因为它在组织层面看起来已经完成;“双轨无限期”最昂贵,因为每天都在付两套成本;“不可逆迁移”最危险,因为它把一次可回滚的变更变成单程票。
检查表
- 变更类型是否明确为合并、拆分、外包或独立?
- 是否写明了切换周与退出周,且退出晚于切换?
- 切换至退出是否在示例上限 26 周以内;超出是否有重新决策机制?
- 是否有双轨阶段,且双轨有数据对账与主库约定?
- 每个临时步骤是否有 owner 和拆除周,拆除周不晚于退出周?
- 切换前的数据复制是否可逆,或是否有可验证的备份恢复?
- 新实体是否有独立身份,旧账号是否在退出前回收?
- 每个实体是否只有一个账单 owner,资源标签是否已改?
- 拆分或独立是否在切换前拥有独立发布流水线?
- 事故值班在过渡期是否有明确的主值班与备份?
第 2、5、6 条是硬门槛:没有退出日期、没有拆除日期、没有回滚路径的计划,不应该进入切换窗口。
小结
组织变更要落到四个架构面:服务边界、数据、权限与计费、发布流水线。合并、拆分、外包、独立四种变更的影响不同,验收标准是同一组:新实体能否独立完成发布、回滚、数据恢复和账单核对。过渡期用 planned、dual-run、cutover、exiting、stable 五个状态管理,超过退出周标记 overdue 并重新决策,示例上限 26 周。临时接口必须带拆除日期,不可逆数据复制不能发生在切换点之前。只改组织图、双轨无限期、不可逆迁移、计费权限共享,是四个需要提前拦住的失败路径。
下一篇进入第 10 章,讨论架构如何随时间演进:绞杀者模式与增量迁移,如何在不停止业务的情况下替换旧系统。
阅读导航:上一节:9.2 平台团队、产品团队与 SRE · 下一节:10.1 绞杀者与增量迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。