激励机制与反馈回路:把故障变成可验证的工程判断力
读过一百篇复盘,也替代不了被叫醒一次
text
[读者] 背着核心服务、还在写复盘的工程师和 TL
[痛点] 复盘写了一堆,同类故障还在重复,行动项没人验证
[现在读] 你正要调整 On-call 与复盘机制,不想让它变成形式
[读完] 能设计并跑通一条把故障闭环到验证的最小系统
text
[旧方案] 以功能上线为唯一成功标准,复盘靠自觉
|
v
[新需求] 可靠性、恢复时间、长期改进都要有人真正负责
|
v
[冲突] 做决策的人不承担后果,承担后果的人没有决策权
|
v
[后果] 复盘成了纪要,行动项成了清单,故障换个人再演一遍
我是老李,在深圳带一个后端团队。前几年我们只有一条主线:版本能不能按时上线。上线成功就有掌声,出了故障大家一起救火,救完散会,第二天继续排需求。这套做法在业务高速期很有效,它把所有人的注意力压在一个目标上。
转折出现在某个季度末。同一个降级开关的配置问题,三个月里踩了三次。每次都能在半小时内恢复,所以每次都不算"事故"。第三次之后我坐下来问自己:三次复盘都写了,为什么判断力一点没长?
后来我把这件事按芒格那套模型重新摆了一遍:激励机制决定谁为什么负责,反馈回路决定经验能不能沉淀。技术团队出问题,往往不是缺聪明人,而是这两样东西同时缺了一角。这篇文章不谈模型本身,只谈它在我们这套订单履约系统里怎么落地。
为什么读过很多故障复盘,真被叫醒时还是判断不出先做什么?
如果激励机制不改,复盘为什么会稳定退化成走形式?
反馈回路要怎么设计,才算真的闭环,而不是多填一张表?
01、故事
现场是一套订单履约系统:它接上游的支付与库存事件,把订单状态推进到履约、结算、通知三个下游。任务是让订单状态在这些事件之间保持收敛,任何订单都不能长期停在中转态。
当时的方案很朴素:版本开发完写单测,上线后盯大盘,出异常由值班同学救火。衡量成功的标准只有一条------需求交付率。故障处理被默认为"附属工作",做得再好也不会被看见,做得再差也只在事后被念叨两句。
变化来自业务形态:渠道从一个变成多个,依赖从内部服务变成内外混合,活动日的流量形态和平时完全不同。故障从"偶发事件"变成了"必然事件"。这时候冲突就出现了:救火的人没有权限改架构决策,做架构决策的人不承担恢复时间的后果。
我第一次意识到问题,是在一次评审会上。有人问:"这个降级开关的配置,为什么三个月踩了三次?"会议室安静了两秒,然后讨论转向了下个需求。
上线不是终点
恢复才是答卷
谁担后果谁决策
谁救火谁有话说
02、问题
旧方案隐含的假设是:故障是例外,例外可以靠个人的责任心和经验兜住。当故障变成常态,这个假设就崩了。靠英雄救火的模式不可扩展,因为英雄的注意力是有限资源,而故障是持续供给的。
业务影响很直接:订单停在中转态,下游结算和通知跟着延迟;同一个工程师被同一类问题反复打断,新需求的排期跟着抖动;更隐蔽的影响是,团队开始回避上报,"再观察一下"成了默认动作。
技术表现是可观察的,我列了四条:
- 同一个信号标签的事件在一个季度里反复出现;
- 行动项长期挂在 OPEN,没人能判定它是否真的完成;
- 复盘记录里开始出现人名归因,故障上报时间越来越晚;
- On-call 轮值没人愿意主动接。
要把这些问题变成可交付的目标,完成标准必须可验证。我给自己定了四条:
- 每个事件按 SIGNALED → LOCALIZED → RECOVERED → REVIEWED → CLOSED 前进,跳步必须被拒绝;
- 事件关闭的前提是所有行动项都处于 VERIFIED,且每条都带证据;
- 行动项从 OPEN 到 VERIFIED 不可跳步,没有证据不得进入 VERIFIED;
- 同一个 signal_tag 再次出现时,新事件必须带上指向上一次事件的 repeat_of。
这四条都不是"态度要求",而是"机器能判定的条件"。这是后面所有架构和代码的验收线。
旧法只防例外
新常态是必然
标准写成可验
才算真的完成
03、原理
这件事只需要四条原理。
第一条,判断力等于决策乘以后果乘以反馈速度。阅读提供的是先验地图,地图能告诉你"可能是配置问题",但只有后果能告诉你"先动哪一步、哪一步会带来更大的损失"。这就是"看过再多复盘也替代不了被叫醒一次"的机制解释:复盘是别人的地图,后果才是你自己的脚力。
第二条,反馈回路必须包含验证,否则它是开环的。识别信号 → 定位原因 → 处置恢复 → 无责复盘 → 改进验证,前四步产出的都是"说法",只有第五步产出"行为变化"。开环系统的典型症状就是:复盘记录越写越漂亮,故障模式一动不动。
反直觉判断:复盘的价值不在找到根因那一刻,而在验证改进是否真的改变了系统行为那一刻。没有验证的复盘,只是把事故翻译成散文。
第三条,责任必须绑定后果,否则激励信号会指向错误行为。如果激励是"上线即胜利",工程师的理性选择就是少报、晚报、把故障说得更轻,因为上报只会带来成本。要让信息流动,责任就要写成可验证的契约:谁负责恢复、谁负责验证、证据长什么样。
反直觉判断:责任写得越模糊,信息越容易丢失;但责任写成"人",信息同样会丢失。有效的中间形态是------明确到岗、明确到契约,不明确到人。追责追的是机制缺口,不是某次手抖。
第四条,代理指标一旦被考核就会失真。复盘数量、行动项数量、故障数量都可以被优化,所以机制必须盯住行为是否改变,而不是记录是否齐全。
地图不是脚力
后果才是老师
复盘若不验证
只是事故散文
04、架构
text
[输入] 生产异常信号 / 故障演练注入的异常
|
v
[模块] 事件登记 + 状态机 + 行动项跟踪
|
v
[数据/状态] incidents(状态 / 信号标签 / 重复指向)+ actions(状态 / 证据)
|
v
[处理] 无责复盘 → 系统改进项 → 证据验证
|
v
[输出] 已关闭事件 + 已验证的机制变更
这个架构刻意做得很小。它不替代监控、工单或知识库,只负责一件事:把"故障"翻译成"可验证的机制变更"。
边界:上游的监控与告警不在范围内,它只接收信号;纯噪音事件也不在范围内,应该在上游被过滤;它不管复盘会上怎么讨论,只管复盘产出的行动项有没有走到验证。
收益:事件可追溯;关闭有硬条件,不能靠一句"已修复"结束;重复故障会被显式关联,同一个信号第二次出现时不再是孤例。
代价:每个事件都要多走一步验证,事件关闭速度必然变慢;需要有人维护复盘模板、演练计划和行动项到期跟踪。
适用条件:故障成本明显高于验证成本的服务,且团队里至少有一个人愿意为机制负责。
输入是异常
状态是证据
处理靠复盘
输出靠验证
05、实战一次
环境:Python 3.11+、FastAPI、uvicorn,存储使用 Python 标准库自带的 SQLite。具体版本号以各自官方文档为准。
依赖安装:
bash
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn
完整实现(fault_loop.py):
python
# fault_loop.py
# V1:故障闭环的最小可运行版本
from __future__ import annotations
import sqlite3
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Iterator, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
DB_PATH = 'fault_loop.db'
INCIDENT_FLOW = ['SIGNALED', 'LOCALIZED', 'RECOVERED', 'REVIEWED', 'CLOSED']
ACTION_FLOW = ['OPEN', 'DONE', 'VERIFIED']
SCHEMA = '''
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
signal_tag TEXT NOT NULL,
owner TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
incident_id TEXT NOT NULL,
description TEXT NOT NULL,
state TEXT NOT NULL,
evidence TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (incident_id) REFERENCES incidents (id)
);
'''
def now() -> str:
return datetime.now(timezone.utc).isoformat(timespec='seconds')
@contextmanager
def db() -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON')
try:
yield conn
conn.commit()
finally:
conn.close()
with db() as _conn:
_conn.executescript(SCHEMA)
app = FastAPI(title='Fault Loop')
class IncidentIn(BaseModel):
title: str = Field(min_length=1)
signal_tag: str = Field(min_length=1)
owner: str = Field(min_length=1)
class StatusIn(BaseModel):
status: str
class ActionIn(BaseModel):
description: str = Field(min_length=1)
class ActionPatch(BaseModel):
state: str
evidence: Optional[str] = None
def get_incident(conn: sqlite3.Connection, incident_id: str) -> sqlite3.Row:
row = conn.execute(
'SELECT * FROM incidents WHERE id = ?', (incident_id,)
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='incident not found')
return row
@app.post('/incidents', status_code=201)
def create_incident(payload: IncidentIn) -> dict[str, Any]:
incident_id = uuid.uuid4().hex[:12]
with db() as conn:
conn.execute(
'INSERT INTO incidents (id, title, signal_tag, owner, status, created_at)'
' VALUES (?, ?, ?, ?, ?, ?)',
(incident_id, payload.title, payload.signal_tag, payload.owner, 'SIGNALED', now()),
)
return {'id': incident_id, 'status': 'SIGNALED'}
@app.patch('/incidents/{incident_id}/status')
def advance_status(incident_id: str, payload: StatusIn) -> dict[str, Any]:
if payload.status not in INCIDENT_FLOW:
raise HTTPException(status_code=400, detail=f'unknown status {payload.status}')
with db() as conn:
row = get_incident(conn, incident_id)
current = row['status']
if INCIDENT_FLOW.index(payload.status) != INCIDENT_FLOW.index(current) + 1:
raise HTTPException(
status_code=409,
detail=current + ' -> ' + payload.status + ' not allowed',
)
if payload.status == 'CLOSED':
pending = conn.execute(
'SELECT COUNT(*) AS c FROM actions WHERE incident_id = ? AND state <> ?',
(incident_id, 'VERIFIED'),
).fetchone()['c']
if pending:
raise HTTPException(
status_code=409,
detail=str(pending) + ' action(s) not verified',
)
conn.execute('UPDATE incidents SET status = ? WHERE id = ?', (payload.status, incident_id))
return {'id': incident_id, 'status': payload.status}
@app.post('/incidents/{incident_id}/actions', status_code=201)
def create_action(incident_id: str, payload: ActionIn) -> dict[str, Any]:
action_id = uuid.uuid4().hex[:12]
with db() as conn:
get_incident(conn, incident_id)
conn.execute(
'INSERT INTO actions (id, incident_id, description, state, evidence, updated_at)'
' VALUES (?, ?, ?, ?, ?, ?)',
(action_id, incident_id, payload.description, 'OPEN', None, now()),
)
return {'id': action_id, 'state': 'OPEN'}
@app.patch('/actions/{action_id}')
def update_action(action_id: str, payload: ActionPatch) -> dict[str, Any]:
if payload.state not in ACTION_FLOW:
raise HTTPException(status_code=400, detail=f'unknown state {payload.state}')
if payload.state == 'VERIFIED' and not (payload.evidence or '').strip():
raise HTTPException(status_code=422, detail='VERIFIED requires evidence')
with db() as conn:
row = conn.execute('SELECT * FROM actions WHERE id = ?', (action_id,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='action not found')
if ACTION_FLOW.index(payload.state) != ACTION_FLOW.index(row['state']) + 1:
raise HTTPException(
status_code=409,
detail=row['state'] + ' -> ' + payload.state + ' not allowed',
)
conn.execute(
'UPDATE actions SET state = ?, evidence = ?, updated_at = ? WHERE id = ?',
(payload.state, payload.evidence, now(), action_id),
)
return {'id': action_id, 'state': payload.state}
@app.get('/incidents/{incident_id}')
def read_incident(incident_id: str) -> dict[str, Any]:
with db() as conn:
row = get_incident(conn, incident_id)
actions = conn.execute(
'SELECT id, description, state, evidence FROM actions'
' WHERE incident_id = ? ORDER BY updated_at',
(incident_id,),
).fetchall()
data = dict(row)
data['actions'] = [dict(a) for a in actions]
return data
启动:
bash
uvicorn fault_loop:app --host 127.0.0.1 --port 8000
请求与验证。以下为未在当前环境实测的预期结果:
bash
BASE=http://127.0.0.1:8000
# 1. 登记事件
curl -s -X POST $BASE/incidents \
-H 'Content-Type: application/json' \
-d '{"title":"订单状态未推进","signal_tag":"degrade-switch","owner":"lisi"}'
示例输出:
json
{"id":"8f2a1c9d4e07","status":"SIGNALED"}
bash
# 2. 记录两条改进项
curl -s -X POST $BASE/incidents/8f2a1c9d4e07/actions \
-H 'Content-Type: application/json' \
-d '{"description":"降级开关纳入发布卡点校验"}'
# 3. 依次推进到 LOCALIZED / RECOVERED / REVIEWED
curl -s -X PATCH $BASE/incidents/8f2a1c9d4e07/status \
-H 'Content-Type: application/json' -d '{"status":"LOCALIZED"}'
# 4. 行动项还没验证就想关闭
curl -s -X PATCH $BASE/incidents/8f2a1c9d4e07/status \
-H 'Content-Type: application/json' -d '{"status":"CLOSED"}'
示例输出:
json
{"detail":"2 action(s) not verified"}
bash
# 5. 没有证据直接标 VERIFIED
curl -s -X PATCH $BASE/actions/<action_id> \
-H 'Content-Type: application/json' -d '{"state":"VERIFIED"}'
示例输出:
json
{"detail":"VERIFIED requires evidence"}
bash
# 6. 先 DONE,再带证据 VERIFIED
curl -s -X PATCH $BASE/actions/<action_id> \
-H 'Content-Type: application/json' -d '{"state":"DONE"}'
curl -s -X PATCH $BASE/actions/<action_id> \
-H 'Content-Type: application/json' \
-d '{"state":"VERIFIED","evidence":"发布卡点校验已合并,演练中配置漂移被拦截"}'
# 7. 全部验证后关闭
curl -s -X PATCH $BASE/incidents/8f2a1c9d4e07/status \
-H 'Content-Type: application/json' -d '{"status":"CLOSED"}'
示例输出:
json
{"id":"8f2a1c9d4e07","status":"CLOSED"}
V1 跑通之后,空缺也很清楚:没有 repeat_of,没有 source,没有行动项到期时间,证据也无法抽查。这些正好是第 07 章要补的。
先跑通一条线
再谈机制之美
四步走完闭环
硬条件守门
06、排查
诊断链一:同一个信号三个月里出现三次
现象:signal_tag 为 degrade-switch 的事件在一个季度内出现了三次,每次都在半小时内恢复,所以都没被升级为事故。
怀疑:最初怀疑是监控阈值太松,导致问题没有在更早的阶段被发现。
检查:把三次事件的复盘记录和行动项拉出来对照,按 signal_tag 查 incidents,再关联 actions 表逐条比对。
证据:三次复盘的第一句都是"配置未同步";行动项分别是"加强配置管理""完善发布流程""提升配置校验",三条都停在 OPEN,没有 owner,没有到期时间,没有验证证据。
根因:反馈回路在"改进验证"这一步断了。行动项无法被判定完成,于是永远 OPEN,也就永远不产生行为变化。这不是人不努力,是机制里根本没有"完成"这个状态。
修复:行动项进入 VERIFIED 必须携带证据;事件在行动项未全部验证之前不得关闭。
错误尝试: 一开始我们以为是值班同学经验不足,于是把 On-call 换成了更资深的同学。为什么错:把系统性缺失归因到个人熟练度上,等于换一个人去撞同一面墙。事实是三次事件的定位路径完全一样,换谁都会撞。
诊断链二:复盘会越来越短,上报越来越晚
现象:复盘会从一小时缩到二十分钟,会上开始出现"这次是谁操作失误"的讨论;与此同时,故障上报时间明显推迟。
怀疑:团队态度问题,或者士气问题。
检查:翻复盘模板,发现里面留着一个"责任人"字段;再统计复盘记录里人名出现的频次。
证据:责任人字段被填成了值班同学,改进项被填成了"加强意识""提高重视程度"。
根因:激励信号是"出错要有人背",那么理性的选择就是少报、晚报、把影响描述得更轻。信息不是没产生,是被激励结构压住了。
修复:模板去掉人名归因字段,只保留系统项;复盘产出物必须是可验证的机制变更,而不是态度承诺。
错误尝试: 有一阵子我们用"复盘数量"当考核指标。为什么错:代理指标一旦被考核就会被优化,于是出现了为凑数的小故障复盘,也出现了把一次故障拆成三条记录的操作。指标涨了,回路没动。
现象不是原因
归因先看机制
换人撞同一墙
不如改墙一条
07、优化
V2 的三处修改全部来自第 06 章的证据。
修改一:给事件加 source 与 repeat_of,让重复故障显式化
根因(来自诊断链一):同类信号第二次出现时没有关联,重复故障被当成新问题处理。
改法是给 incidents 表加两列,创建事件时按 signal_tag 自动查最近一次历史事件。
python
SCHEMA = '''
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
signal_tag TEXT NOT NULL,
owner TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'prod',
repeat_of TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
incident_id TEXT NOT NULL,
description TEXT NOT NULL,
state TEXT NOT NULL,
evidence TEXT,
due_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (incident_id) REFERENCES incidents (id)
);
'''
class IncidentIn(BaseModel):
title: str = Field(min_length=1)
signal_tag: str = Field(min_length=1)
owner: str = Field(min_length=1)
source: str = 'prod'
@app.post('/incidents', status_code=201)
def create_incident(payload: IncidentIn) -> dict[str, Any]:
incident_id = uuid.uuid4().hex[:12]
with db() as conn:
previous = conn.execute(
'SELECT id FROM incidents WHERE signal_tag = ? ORDER BY created_at DESC LIMIT 1',
(payload.signal_tag,),
).fetchone()
repeat_of = previous['id'] if previous else None
conn.execute(
'INSERT INTO incidents'
' (id, title, signal_tag, owner, source, repeat_of, status, created_at)'
' VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(incident_id, payload.title, payload.signal_tag, payload.owner,
payload.source, repeat_of, 'SIGNALED', now()),
)
return {'id': incident_id, 'status': 'SIGNALED', 'repeat_of': repeat_of}
新行为:同一个 signal_tag 第二次进入系统时,事件直接带上上一次的 id,复盘会上不再需要靠记忆发现"这个我们见过"。source 字段让演练注入的异常也能走同一条管道,同时保留区分口径的能力。
修改二:行动项必须有到期时间和可查询的超期视图
根因(来自诊断链一):行动项永远 OPEN,因为没人知道它什么时候该被追问。
python
class ActionIn(BaseModel):
description: str = Field(min_length=1)
due_at: Optional[str] = None
@app.post('/incidents/{incident_id}/actions', status_code=201)
def create_action(incident_id: str, payload: ActionIn) -> dict[str, Any]:
action_id = uuid.uuid4().hex[:12]
with db() as conn:
get_incident(conn, incident_id)
conn.execute(
'INSERT INTO actions'
' (id, incident_id, description, state, evidence, due_at, updated_at)'
' VALUES (?, ?, ?, ?, ?, ?, ?)',
(action_id, incident_id, payload.description, 'OPEN', None, payload.due_at, now()),
)
return {'id': action_id, 'state': 'OPEN'}
@app.get('/actions')
def list_actions(state: Optional[str] = None, overdue_only: bool = False) -> list[dict[str, Any]]:
sql = 'SELECT id, incident_id, description, state, due_at, evidence FROM actions WHERE 1 = 1'
params: list[Any] = []
if state:
sql += ' AND state = ?'
params.append(state)
if overdue_only:
sql += ' AND state <> ? AND due_at IS NOT NULL AND due_at < ?'
params.extend(['VERIFIED', now()])
sql += ' ORDER BY due_at IS NULL, due_at'
with db() as conn:
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
新行为:可以随时拉出"未验证且已超期"的行动项清单,跟踪从靠记性变成靠查询。
修改三:复盘模板结构化,只留系统项
根因(来自诊断链二):模板里有人名归因字段,激励信号把信息压住了。
改法是模板层面删除"责任人",字段固定为:信号、影响、定位路径、处置、系统改进项、验证证据。要注意,删除"责任人"不等于没人负责,owner 字段仍然保留在事件上,用于明确恢复与验证的分工。
验证方案: 用第 05 章那组请求重跑一遍,检查三件事------第二次用同一个 signal_tag 创建事件时返回体里的 repeat_of 不为空;存在未验证行动项时关闭事件返回 409;无证据进入 VERIFIED 返回 422。本轮未在当前环境实测,以上为预期结果。
证据指哪改哪
机制补上缺环
旧漏洞不复现
新代价需承认
08、演进
text
[同一次生产故障]
|
+--[V1] 记录 → 恢复 → 复盘 → 行动项挂起
| 代价:同根因换个时间再来一次
|
+--[V2] 记录 → 恢复 → 无责复盘 → 行动项 → 证据验证 → 关闭
| 代价:每次事件多出验证工时与跟踪成本
|
[Trade-off]
得到:可验证的机制变更 / 可追溯的判断依据 / 重复故障的显式关联
失去:事件关闭的速度 / 快速救火的即时满足
适用边界:故障成本明显高于验证成本的服务
正确性:V1 的正确性依赖个人;V2 把关闭条件写进状态机,正确性可以被机器判定。
稳定性:V1 在人员变动时迅速退化;V2 依赖模板和流程,人换掉但管道还在。
复杂度:V2 多了状态机、证据字段、重复检测、超期查询,代码量与维护成本都上升了一个量级。
成本:V2 的验证工时是实打实的支出,换来的是同根因不再重复出现。
适用范围:V2 适合故障成本高的核心链路;对实验性服务或一次性脚本,这套机制的代价明显大于收益。
遗留问题有三个:一是证据本身可以被伪造,需要抽查机制;二是演练产生的异常与真实故障走同一管道,会让统计口径混乱,需要在分析层面区分;三是小团队没有专职角色,验证工作会挤占开发时间,需要提前约定额度。
同一场故障
两种走法
得到是沉淀
失去是速度
09、洞见
9.1 责任要落到后果上,而不是落到态度上
"加强重视""提高意识"都不是可验证的改进。责任落到后果上意味着:这件事失败时谁承担什么、成功时谁证明了什么。反过来,态度型要求只能靠自觉维持,而自觉在压力下最先蒸发。
反直觉判断:追责越模糊,信息越少;但把责任写成人,信息同样会少。可行的位置在中间------责任明确到岗、到契约,不明确到人。
9.2 反馈回路的最小闭环不是复盘,是验证
识别信号、定位原因、处置恢复、无责复盘,这四步都在生产"说法"。只有改进验证在生产"行为变化"。很多团队以为自己有反馈回路,其实是一条开环的生产线,产出的是文档而不是判断力。
反直觉判断:复盘会上找到根因的那一刻,闭环进度只有一半。剩下的一半,是等下一次同类信号出现时,系统能不能证明它已经拦住了。
9.3 代理指标一旦被考核就会失真
复盘数量、行动项数量、故障数量、恢复时长都是代理指标。它们可以被优化,也可以被操纵。考核它们的必然结果是数字变好、回路不动。要盯住的是行为是否改变,而行为改变的判据是:同类信号是否被更早识别、更短处置、更少重复。
9.4 演练是给反馈回路做压力测试
真实故障的代价太高,不能靠它来练判断力。故障演练、轮值 On-call、容量压测、预案演习的价值在于:它们用低代价制造"承担后果"的机会,并且产出的异常可以走同一条闭环管道。演练如果不产生后果,就只是表演。
责任落到契约
信息才敢流动
指标一旦考核
立刻开始失真
10、系统落地
原来有什么:一套订单履约服务,加上一套以"按时上线"为唯一标准的交付流程;故障处理靠值班同学的个人经验;复盘靠自觉,行动项靠记忆。
本篇新增了什么:一条从信号到验证的最小闭环管道------事件状态机、行动项证据约束、重复故障关联、超期查询;一份只留系统项的复盘模板;一套把故障演练并入同一管道的约定。
现在能做什么:任何一次故障或演练异常都能被记录、被推进、被验证、被关闭,而且关闭是有条件的;同一个信号第二次出现时,系统会提醒你这不是新问题;行动项有到期时间,可以随时拉出超期清单。
还缺什么:证据的抽查机制;演练与真实故障的统计口径分离;与现有工单、监控系统的对接;跨团队故障的责任归属约定。
下一步如何演进:把验证证据接入 CI 或发布卡点,让验证从人工声明变成流水线判定;把重复检测从同 tag 扩展到影响面相似度;把 On-call 轮值与演练排期做成同一张表,让"承担后果"变成可排期的日常。
原来靠自觉
现在靠状态机
能关的是机制
缺的是抽查
11、小结
text
Q1 为什么读过很多复盘,被叫醒时还是判断不出?
→ 阅读给的是地图,判断力来自承担后果的次数
Q2 激励机制不改,复盘为什么退化?
→ 报故障只有成本没有收益,少报晚报成了理性选择
Q3 反馈回路怎么才算闭环?
→ 行动项带证据走到 VERIFIED,事件才允许关闭
状态 → 一条可运行的最小闭环服务 + 一套可执行的责任与复盘约定
三问有了答
闭环已经转
判断由后果铸
不改激励白忙
12、作业
12.1 理解题:为什么"看过很多复盘"替代不了"被叫醒一次"?
参考答案:复盘提供的是他人的先验地图,它只能告诉你可能性空间;判断力需要在真实情境下做决策、承担结果、观察反馈才能形成。反馈速度决定学习速度,而阅读不产生后果,也就不产生反馈。
12.2 实战题:把第 05 章的 V1 加上 repeat_of,并设计一条能自动检查的验证
参考答案:在 incidents 表增加 source 与 repeat_of 两列;创建事件时按 signal_tag 查最近一条历史事件,命中就把 repeat_of 写成它的 id。验证用一个脚本连续创建两条同 signal_tag 的事件,断言第二条返回的 repeat_of 等于第一条的 id;再创建一个不同 tag 的事件,断言 repeat_of 为空。
12.3 排障题:关闭事件时返回 409,怎么定位?
参考答案:409 的语义是当前状态不允许这次转移。先看返回 detail 里是状态跳步还是行动项未验证。若是行动项未验证,就查该事件的 actions 记录,找出 state 不等于 VERIFIED 的条目;再检查它们是否卡在缺少证据上,因为进入 VERIFIED 需要 evidence。定位顺序是:状态机顺序 → 行动项状态 → 证据字段,而不是先怀疑接口写错。
12.4 架构判断题:为什么不应该把"复盘数量"作为考核指标?
参考答案:复盘数量是代理指标,考核它会诱导团队为凑数拆单、造单,而真实的回路进度(同类故障是否减少、行动项是否验证)不会改善。考核应该指向行为改变:同类信号的重复率、行动项的验证率、验证证据的可抽查性。若一定要看数量,只能用于观察趋势,不能与个人评价挂钩。
会做题不算会
能复现才算懂
排障先看证据
判断先看代价
13、思考
回到开头那个冲突:做决策的人不承担后果,承担后果的人没有决策权。这套机制之所以能改,不是因为我们更聪明了,而是因为把责任从"人"挪到了"可验证的系统契约"上------恢复由谁负责、验证由谁负责、证据长什么样,全部写进状态机。
经验不等于经历过多少次故障,而等于有多少次把故障反馈转化成了更好的判断和机制。这句话反过来读更有用:如果一次故障没有产出任何被验证的机制变更,那么这次故障在能力层面等于没发生过。所以每次复盘之后都该问一句------这次我们改的到底是什么,谁来证明它改了。
一念辨边界
一证定根因
一改知取舍
一役见真章