《后端架构设计·演进与治理》11.2 黄金路径与自助服务:从工单到模板

从 arch-lab 一个新服务开通花了十一天、前后经手六张工单却没人对结果负责的场景切入,说明黄金路径为什么必须是带版本和输入校验的可执行模板而不是自由文本工单,并给出带审批、失败注入、补偿回滚与重试预算的自助状态机 Go 实现与本机测试输出。

新服务开通花了十一天

arch-lab 的订单团队要开一个只读查询服务。第一天提工单申请命名空间,第三天拿到;第四天申请数据库账号,第六天拿到;第七天申请消息队列,第九天拿到;第十天发现 CI 模板要求的分支保护和实际仓库不一致,又退回重改。第十一天服务终于能部署,但没有人能说清这十一天里哪一段是必要的审批,哪一段只是排队。

平台团队复盘时发现,六张工单里有四张问的是同一组问题:服务归谁、跑在哪个环境、需要哪些中间件、预算标签是什么。这些答案在每次开通时都重新填一遍,填错就再开一张工单。问题不是审批太多,而是平台没有把“标准服务长什么样”固化成一条可执行路径。

平台工程 与 开发者门户 都强调自助服务,但自助不等于把控制台权限发出去。真正的黄金路径要同时满足三点:输入可校验、执行可重复、失败可回滚。

模板不是文档,是可执行契约

把开通步骤写成 Wiki 只能减少提问,不能减少工单。模板必须带版本、允许的环境、必填标签和有序步骤;每次请求用同一份模板执行,才能比较不同团队的开通结果。

维度工单黄金路径
输入自由文本,缺项靠人追问结构化字段,提交前一次校验
审批每张工单单独判断规则前置,例外才升级
执行人工按经验操作固定步骤,失败停在确定位置
失败处理谁开的单谁收拾补偿回滚,状态可查
审计散落在评论里每次状态迁移带时间与理由

输入校验要一次返回全部问题,而不是让用户改一个再提交一次。下面的实现把校验放在 policy.go 的 GoldenPathPolicy.Check 里,检查团队、幂等键、能力 ID、环境白名单和必填标签;本节的重点是校验通过之后的状态机。

状态机把“自助”限制在安全路径

自助服务的关键不是让用户直接调用云 API,而是让请求按固定状态迁移。REQUESTED 只能进入 APPROVED;APPROVED 才能开始 PROVISIONING;执行完整进入 VALIDATING,验证通过才是 READY;任一步失败进入 FAILED,只能回滚或按预算重试。

当前状态允许的下一步触发者副作用
REQUESTEDAPPROVED审批人记录审批理由
APPROVEDPROVISIONING请求人或系统开始执行模板步骤
PROVISIONINGVALIDATING / FAILED执行器记录已完成步骤
VALIDATINGREADY / FAILED校验器验证资源可用
FAILEDROLLED_BACK / PROVISIONING请求人补偿或消耗一次重试

审批和回滚都做成幂等:重复点击“批准”返回 changed=false 而不是报错,重复回滚直接返回成功。这样前端重试、消息重复投递都不会把状态机推乱。

失败注入与补偿

执行器(Provisioner)是唯一有副作用的边界,三个方法都必须幂等,因为重试会从第一步重新播放模板。测试在 queue 步骤注入失败,状态机停在 FAILED,已完成列表只有 database;此时回滚只清理已经创建的资源,不会碰未执行的步骤。

重试预算防止“失败就再点一次”变成无限循环。每次重试消耗一次额度,额度用尽返回 ErrRetryExhausted,请求停留在 FAILED,必须由人决定回滚还是提新单。预算不是技术参数,而是治理选择:它决定平台愿意为一次失败承担多少次自动重放。

可运行实现:自助状态机

下面的 selfservice.go 只依赖标准库。时钟通过 now func() time.Time 注入,测试用固定起点每次前进一分钟,因此状态迁移的时间戳可复现。

// file: internal/platform/idp/selfservice.go
// tier: L2
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestSelfService'
package idp

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type RequestState string

const (
	StateRequested    RequestState = "REQUESTED"
	StateApproved     RequestState = "APPROVED"
	StateProvisioning RequestState = "PROVISIONING"
	StateValidating   RequestState = "VALIDATING"
	StateReady        RequestState = "READY"
	StateFailed       RequestState = "FAILED"
	StateRolledBack   RequestState = "ROLLED_BACK"
)

var (
	ErrInvalidTransition = errors.New("idp: invalid transition")
	ErrRetryExhausted    = errors.New("idp: retry budget exhausted")
)

type GoldenPath struct {
	ID, CapabilityID string
	Version          int
	AllowedEnvs      []string
	RequiredLabels   []string
	Steps            []string
}

// Validate rejects a template that cannot be executed repeatably.
func (t GoldenPath) Validate() error {
	if blank(t.ID) || t.Version < 1 || blank(t.CapabilityID) || len(t.Steps) == 0 || len(t.AllowedEnvs) == 0 {
		return errors.New("golden path: id, version, capability, steps and envs are required")
	}
	return nil
}

type Request struct {
	ID, Team, CapabilityID, Env, IdempotencyKey string
	Labels                                      map[string]string
}

// Provisioner is the side-effecting adapter. Every method must be idempotent
// because retries replay the same request.
type Provisioner interface {
	Provision(ctx context.Context, req Request, step string) error
	Validate(ctx context.Context, req Request) error
	Rollback(ctx context.Context, req Request) error
}

type Transition struct {
	From, To RequestState
	At       time.Time
	Reason   string
}

type Machine struct {
	template  GoldenPath
	policy    GoldenPathPolicy
	prov      Provisioner
	req       Request
	now       func() time.Time
	state     RequestState
	history   []Transition
	attempts  int
	completed []string
}

func NewMachine(t GoldenPath, p GoldenPathPolicy, prov Provisioner, req Request, now func() time.Time) (*Machine, error) {
	if err := t.Validate(); err != nil {
		return nil, err
	}
	if prov == nil || now == nil {
		return nil, errors.New("idp: machine needs a provisioner and a clock")
	}
	if errs := p.Check(t, req); len(errs) > 0 {
		return nil, errors.Join(errs...)
	}
	if p.MaxAttempts < 1 {
		p.MaxAttempts = 1
	}
	m := &Machine{template: t, policy: p, prov: prov, req: req, now: now, state: StateRequested}
	m.history = append(m.history, Transition{To: StateRequested, At: now(), Reason: "created"})
	return m, nil
}

func (m *Machine) State() RequestState { return m.state }

func (m *Machine) transition(to RequestState, reason string) {
	m.history = append(m.history, Transition{From: m.state, To: to, At: m.now(), Reason: reason})
	m.state = to
}

// Approve is idempotent: approving an approved request is not an error.
func (m *Machine) Approve(actor, reason string) (bool, error) {
	if m.state == StateApproved {
		return false, nil
	}
	if m.state != StateRequested {
		return false, fmt.Errorf("%w: approve from %s", ErrInvalidTransition, m.state)
	}
	if blank(actor) {
		return false, errors.New("idp: approver is empty")
	}
	m.transition(StateApproved, reason)
	return true, nil
}

// Run executes every step then validates the result.
func (m *Machine) Run(ctx context.Context) error {
	switch m.state {
	case StateReady:
		return nil
	case StateApproved:
	default:
		return fmt.Errorf("%w: run from %s", ErrInvalidTransition, m.state)
	}
	if m.attempts == 0 {
		m.attempts = 1
	}
	m.transition(StateProvisioning, "approved")
	return m.execute(ctx)
}

func (m *Machine) execute(ctx context.Context) error {
	for _, step := range m.template.Steps {
		if err := m.prov.Provision(ctx, m.req, step); err != nil {
			m.transition(StateFailed, "provision "+step+": "+err.Error())
			return fmt.Errorf("idp: provision %s: %w", step, err)
		}
		m.completed = append(m.completed, step)
	}
	m.transition(StateValidating, "provisioned")
	if err := m.prov.Validate(ctx, m.req); err != nil {
		m.transition(StateFailed, "validate: "+err.Error())
		return fmt.Errorf("idp: validate: %w", err)
	}
	m.transition(StateReady, "validated")
	return nil
}

// Retry replays the template after a failure, within the attempt budget.
func (m *Machine) Retry(ctx context.Context) error {
	if m.state != StateFailed {
		return fmt.Errorf("%w: retry from %s", ErrInvalidTransition, m.state)
	}
	if m.attempts >= m.policy.MaxAttempts {
		return fmt.Errorf("%w: attempts=%d", ErrRetryExhausted, m.attempts)
	}
	m.attempts++
	m.completed = nil
	m.transition(StateProvisioning, "retry")
	return m.execute(ctx)
}

// Rollback is idempotent and only valid after a failure.
func (m *Machine) Rollback(ctx context.Context, reason string) error {
	switch m.state {
	case StateRolledBack:
		return nil
	case StateFailed:
	default:
		return fmt.Errorf("%w: rollback from %s", ErrInvalidTransition, m.state)
	}
	if err := m.prov.Rollback(ctx, m.req); err != nil {
		return fmt.Errorf("idp: rollback: %w", err)
	}
	m.transition(StateRolledBack, reason)
	return nil
}

execute 记录已完成步骤,是为了让回滚知道清理范围;Retry 清空这个列表,是因为重试会从第一步重放。真正执行清理的是 Provisioner.Rollback,状态机只保证“回滚只发生在 FAILED 之后”,不替执行器判断该删哪些资源。

测试:固定时钟与失败注入

测试用一个会失败的 fakeProvisioner 在指定步骤注入错误,用固定时钟验证状态迁移时间,用 MaxAttempts 验证重试预算;下面贴出成功与失败回滚两个用例,重试用例与策略校验分别在完整测试文件和 policy_test.go 中。

// file: internal/platform/idp/selfservice_test.go
// tier: L2
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestSelfService'
package idp

import (
	"context"
	"testing"
	"time"
)

type testClock struct{ cur time.Time }

func (c *testClock) Now() time.Time {
	c.cur = c.cur.Add(time.Minute)
	return c.cur
}

type fakeProvisioner struct {
	failStep    string
	provisioned []string
	rollbacks   int
}

func (f *fakeProvisioner) Provision(_ context.Context, _ Request, step string) error {
	if step == f.failStep {
		return errors.New("injected provision failure")
	}
	f.provisioned = append(f.provisioned, step)
	return nil
}

func (f *fakeProvisioner) Validate(_ context.Context, _ Request) error { return nil }

func (f *fakeProvisioner) Rollback(_ context.Context, _ Request) error {
	f.rollbacks++
	return nil
}

func testTemplate() GoldenPath {
	return GoldenPath{ID: "svc-template", Version: 3, CapabilityID: "cap-deploy",
		AllowedEnvs: []string{"dev", "staging"}, RequiredLabels: []string{"owner", "cost-center"},
		Steps: []string{"database", "queue", "workload"}}
}

func testRequest() Request {
	return Request{ID: "req-1", Team: "order", CapabilityID: "cap-deploy", Env: "staging",
		Labels: map[string]string{"owner": "order", "cost-center": "cc-42"}, IdempotencyKey: "idem-1"}
}

func newTestMachine(t *testing.T, prov Provisioner, p GoldenPathPolicy) (*Machine, *testClock) {
	t.Helper()
	clk := &testClock{cur: time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC)}
	m, err := NewMachine(testTemplate(), p, prov, testRequest(), clk.Now)
	if err != nil {
		t.Fatalf("NewMachine: %v", err)
	}
	return m, clk
}

func TestSelfServiceHappyPath(t *testing.T) {
	prov := &fakeProvisioner{}
	m, clk := newTestMachine(t, prov, GoldenPathPolicy{MaxAttempts: 2})
	changed, err := m.Approve("lead", "budget approved")
	if err != nil || !changed {
		t.Fatalf("approve = %v, %v", changed, err)
	}
	if changed, err = m.Approve("lead", "duplicate click"); err != nil || changed {
		t.Fatalf("duplicate approve = %v, %v", changed, err)
	}
	if err := m.Run(context.Background()); err != nil {
		t.Fatalf("run: %v", err)
	}
	h := m.history
	if m.State() != StateReady || len(prov.provisioned) != 3 || len(h) != 5 || h[0].At != clk.cur.Add(-4*time.Minute) {
		t.Fatalf("state=%s provisioned=%v history=%+v", m.State(), prov.provisioned, h)
	}
	if err := m.Run(context.Background()); err != nil {
		t.Fatalf("idempotent run: %v", err)
	}
	t.Logf("state=%s steps=%v transitions=%d last_at=%s", m.State(), m.completed, len(h), h[4].At.Format(time.RFC3339))
}

func TestSelfServiceFailureAndRollback(t *testing.T) {
	prov := &fakeProvisioner{failStep: "queue"}
	m, _ := newTestMachine(t, prov, GoldenPathPolicy{MaxAttempts: 2})
	if _, err := m.Approve("lead", "ok"); err != nil {
		t.Fatalf("approve: %v", err)
	}
	if err := m.Run(context.Background()); err == nil || m.State() != StateFailed || len(m.completed) != 1 {
		t.Fatalf("run err=%v state=%s completed=%v", err, m.State(), m.completed)
	}
	if err := m.Rollback(context.Background(), "provision failed"); err != nil {
		t.Fatalf("rollback: %v", err)
	}
	t.Logf("failed_at=queue completed=%v state=%s rollbacks=%d", m.completed, m.State(), prov.rollbacks)
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0。gofmt -l . 与 go vet ./... 均无输出;go test 原始输出节选如下,完整日志在 .local/reports/be-arch-gov/ch11-full.txt。

$ go test ./internal/platform/idp/ -count=1 -v -run 'TestSelfService'
=== RUN   TestSelfServicePolicy
    policy_test.go:31: missing_team -> request: id and team are required
    policy_test.go:31: wrong_env -> request: env "prod" is not allowed
    policy_test.go:31: missing_label -> request: label "cost-center" is required
    policy_test.go:31: wrong_capability -> request: capability "cap-db" does not match template "cap-deploy"
    policy_test.go:31: missing_idempotency -> request: idempotency key is required
--- PASS: TestSelfServicePolicy (0.00s)
=== RUN   TestSelfServiceHappyPath
    selfservice_test.go:82: state=READY steps=[database queue workload] transitions=5 last_at=2026-11-01T09:05:00Z
--- PASS: TestSelfServiceHappyPath (0.00s)
=== RUN   TestSelfServiceFailureAndRollback
    selfservice_test.go:97: failed_at=queue completed=[database] state=ROLLED_BACK rollbacks=1
--- PASS: TestSelfServiceFailureAndRollback (0.00s)
=== RUN   TestSelfServiceRetryBudget
    selfservice_test.go:115: state=FAILED retries_exhausted=true
--- PASS: TestSelfServiceRetryBudget (0.00s)
PASS
ok  	archlab/internal/platform/idp	0.229s

读数分三组。成功组:审批通过后用固定时钟前进,五次迁移依次落在 09:01 到 09:05,最终 READY,重复 Run 不再产生副作用。失败组:在 queue 注入错误后状态为 FAILED,已完成只有 database,回滚后为 ROLLED_BACK,补偿只调用一次。预算组:两次重试都失败后,第三次返回 ErrRetryExhausted,状态仍停在 FAILED。

边界要说清楚:这是单进程内存测试,执行器是假实现,不验证真实云资源、数据库权限和并发请求;0.229 秒是本机测试耗时,不代表真实开通时长。生产上还要把状态机与工单系统、审计日志和人工升级路径接起来,测试只能证明状态迁移和补偿调用顺序。

常见错误与边界

  • 把模板做成脚本集合。没有状态机和幂等入口,重试会重复创建资源,失败后也没人知道停在哪一步。
  • 输入校验一次只报一个错。用户要提交多轮才能补齐字段,自助体验退化成变相工单。
  • 重试没有预算。执行器持续失败时,自动重试会把故障放大成资源泄漏和配额消耗。
  • 回滚不区分已完成步骤。清理未创建的资源可能误删同名的既有对象,回滚必须基于本次请求的执行记录。

小结

黄金路径不是文档,而是带版本、输入校验和有序步骤的可执行模板。模板把“标准服务”从人的经验变成可比较的输入;状态机把自助限制在 REQUESTED → APPROVED → PROVISIONING → VALIDATING → READY 的安全路径上,失败时停在确定位置并允许补偿。

固定时钟、失败注入和重试预算让这条路径可以被测试,而不是只能靠演练。下一节转向度量:模板有了,怎么判断团队真的在用、用了之后是否更快,以及一次平台投入的反悔成本有多高。

阅读导航:上一节:11.1 平台即产品:能力边界与用户旅程 · 下一节:11.3 平台度量:采纳率、交付效率与反悔成本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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