AI 代码上线前的第 24 道门禁:上线失败时,怎样在 10 分钟内退回?

新版本上线,健康检查通过,五分钟后错误率爬升。准备回滚,发现上一个版本的镜像已经被覆盖,因为流水线按 latest 标签推同一个仓库路径;数据库 migration 只写了 up。团队一边排查一边重建旧版本,四十分钟后才恢复。

这段是按两个失败形态构造的示例,不对应某次真实事故。脚本里真正存在的是两个发布目录实现(FloatStore、DigestStore)、weak_health 与 strong_health、一张 SQLite 表加上 expand/contract 两条 migration、一份策略常量 POLICY,以及一次把坏版本部署出去再自动退回的 drill()。四十分钟、五分钟后错误率爬升都不在这些对象里。

回滚失败很少是"回滚命令写错",更多是两种情况:没有可回去的产物 ,或者回去了也跑不起来。这一道门禁要验证的就是这两种情况,外加"谁有权按下回滚"。

"10 分钟"是目标,不是口号。要成立,得同时满足:上一个可用版本还在([digest] 那两行)、部署指针能切回去([digest] 回滚 v2 那行的 指针=)、数据库兼容旧代码([db] expand 之后)、健康检查能识别坏版本([health] strong)、有人演练过这条路径([drill] 审计)。五个标签对应下面五个场景,缺哪一个都能在输出里直接看出来。

最小复现

脚本用临时目录做发布物、用 SQLite 做数据库,五个场景串在 __main__ 里。这里先点两处:FloatStore.publish() 每次发布先把旧的 releases/latest 目录删掉,再往同一个路径写新代码,上一个版本就地没了;strong_health() 里有一次真的执行 release["read"](conn),读不到列就判 false。这一节不单独摘代码,细节(publish() 的落盘与写指针、RollbackUnavailable() 的实参、strong_health() 结尾怎么汇总布尔项)连同其余对象都在下面这份脚本里。

数据库用 expand--contract 两条 migration:expand 加一列 state 并回填,contract 删掉旧列 status。v1 代码 SELECT id, status, amount,v2 代码 SELECT id, state, amount。

脚本里还有 DigestStore(按内容 digest 部署、靠 meta.json 的 version 反查目录、_gc() 按保留数量回收)、expand()/contract()、weak_health()/strong_health()、POLICY、should_rollback()、decision_minutes()、drill()、probe_read() 和两个读函数。实录 17 行由 __main__ 打印:五段场景占 14 行,结尾 3 行是 PASS、STOP、NOT_COVERED。PASS 由 checks 的九个键决定,全为真才打印:float_blocked、v1_gc_blocked 要两个场景各抛一次 RollbackUnavailable;weak_blind 取 weak_health() 那个恒真的 healthy;strong_catches 取演练里那次强健康为 false;expand_compat 要 expand 之后那行同时含 v1=ok rows=2、v2=ok rows=2;contract_breaks_v1 要 contract 之后报 no such column 且切回 v1 的强健康为 false;drill_rolled_back 要审计里 action 是 auto_rollback 且 business_ok 为真;drill_restored 要结束状态再查一次健康;budget 判断式 single + 2 <= 10 < consensus。任一项为假打印 FAIL 并以非 0 退出。本机实际运行的 实验/gate.py 全文如下,与实跑逐字相同,以它为准:

python 复制代码
"""G24:上线失败要退回时,旧版本可能已经被覆盖,或者根本跑不起来。

五个场景:浮动标签覆盖、不可变 digest 与保留窗口、健康检查强弱、expand-contract 兼容性、
一次带回滚与业务验证的演练。

运行:
    python 实验/gate.py
"""
import hashlib
import json
import shutil
import sqlite3
import tempfile
import time
from pathlib import Path


class RollbackUnavailable(RuntimeError):
    pass


def write_json(path: Path, data) -> None:
    path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")


def read_json(path: Path):
    return json.loads(path.read_text(encoding="utf-8"))


class FloatStore:
    """按可变标签部署:每次都写同一个目录,上一个版本就地消失。"""

    def __init__(self, root: Path):
        self.root = root
        self.tag_dir = root / "releases" / "latest"

    def publish(self, version, code):
        if self.tag_dir.exists():
            shutil.rmtree(self.tag_dir)
        self.tag_dir.mkdir(parents=True)
        (self.tag_dir / "app.py").write_text(code, encoding="utf-8")
        write_json(self.root / "current.json", {"ref": "latest", "version": version})
        return version

    def rollback(self, version):
        if not (self.root / "releases" / version).exists():
            raise RollbackUnavailable(
                f"{version} 不是目录名,产物在 releases/latest 已被覆盖")
        return version

    def listing(self):
        return sorted(p.name for p in (self.root / "releases").iterdir())


class DigestStore:
    """按内容 digest 部署,只保留最近 retain 个可用版本。"""

    def __init__(self, root: Path, retain=2):
        self.root = root
        self.releases = root / "releases"
        self.pointer = root / "current.json"
        self.retain = retain
        self.order = []
        self.gc_log = []
        self._by_digest = {}

    def publish(self, version, code, release):
        digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:12]
        rel = self.releases / digest
        rel.mkdir(parents=True, exist_ok=True)
        (rel / "app.py").write_text(code, encoding="utf-8")
        write_json(rel / "meta.json", {"version": version, "digest": digest})
        self.order.append(digest)
        self._by_digest[digest] = release
        self._point(digest, version)
        self._gc()
        return digest

    def _point(self, digest, version):
        write_json(self.pointer, {"digest": digest, "version": version})

    def current(self):
        return read_json(self.pointer)

    def release(self, digest=None):
        digest = digest or self.current()["digest"]
        return self._by_digest[digest]

    def _gc(self):
        while len(self.order) > self.retain:
            old = self.order[0]
            if old == self.current()["digest"]:
                break
            shutil.rmtree(self.releases / old)
            self.order.pop(0)
            self.gc_log.append(old)

    def rollback(self, version):
        for digest in self.order:
            if read_json(self.releases / digest / "meta.json")["version"] == version:
                self._point(digest, version)
                return {"rolled_back_to": version, "digest": digest}
        raise RollbackUnavailable(
            f"{version} 已不在保留窗口(保留 {self.retain} 个:{self.order})")


def init_db(path: Path):
    conn = sqlite3.connect(path)
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT NOT NULL, amount INTEGER NOT NULL)")
    conn.executemany("INSERT INTO orders VALUES (?, ?, ?)",
                     [(1, "paid", 9900), (2, "paid", 15000)])
    conn.execute("PRAGMA user_version=1")
    conn.commit()
    return conn


def schema_version(conn):
    return conn.execute("PRAGMA user_version").fetchone()[0]


def v1_read(conn):
    return conn.execute("SELECT id, status, amount FROM orders").fetchall()


def v2_read(conn):
    return conn.execute("SELECT id, state, amount FROM orders").fetchall()


V1 = {"version": "v1", "read": v1_read, "schema_min": 1, "schema_max": 2, "dep_ok": True}
V2 = {"version": "v2", "read": v2_read, "schema_min": 2, "schema_max": 2, "dep_ok": True}


def expand(conn):
    conn.execute("ALTER TABLE orders ADD COLUMN state TEXT")
    conn.execute("UPDATE orders SET state=status")
    conn.execute("PRAGMA user_version=2")
    conn.commit()


def contract(conn):
    conn.execute("ALTER TABLE orders DROP COLUMN status")
    conn.commit()


def weak_health(conn, release):
    """只看进程活着:端口通就判健康。"""
    return {"healthy": True, "checks": {"port": True}}


def strong_health(conn, release):
    checks = {"port": True, "dep": release["dep_ok"]}
    ver = schema_version(conn)
    checks["schema_ok"] = release["schema_min"] <= ver <= release["schema_max"]
    try:
        checks["core_read"] = len(release["read"](conn)) > 0
    except sqlite3.Error as exc:
        checks["core_read"] = False
        checks["core_read_error"] = str(exc)
    healthy = all(v for k, v in checks.items() if isinstance(v, bool))
    return {"healthy": healthy, "checks": checks}


POLICY = {"error_rate_pct": 2.0, "health_fail_max": 0, "decider": "oncall-backend",
          "notify": "#incident-checkout"}


def should_rollback(metrics, policy=POLICY):
    return metrics["error_rate_pct"] >= policy["error_rate_pct"] or not metrics["healthy"]


def decision_minutes(consensus=False):
    """人工环节耗时是模型值,不是实测:单人按策略 vs 等四人同意。"""
    return 12 if consensus else 1


def drill(store, conn, bad_release):
    audit = []
    store.publish("v1", "VERSION='v1'\n", V1)
    store.publish(bad_release["version"], f"VERSION='{bad_release['version']}' DEP=redis\n", bad_release)
    health = strong_health(conn, store.release())
    metrics = {"error_rate_pct": 0.4, "healthy": health["healthy"]}
    start = time.perf_counter()
    outcome = {"action": "keep", "trigger": None}
    if should_rollback(metrics):
        trigger = "health:" + next(k for k, v in health["checks"].items()
                                   if isinstance(v, bool) and not v)
        outcome = store.rollback("v1")
        outcome.update({"action": "auto_rollback", "trigger": trigger,
                        "operator": POLICY["decider"]})
    verified = v1_read(conn)
    outcome["business_ok"] = len(verified) == 2
    outcome["elapsed_ms"] = round((time.perf_counter() - start) * 1000, 2)
    audit.append(outcome)
    return health, audit


def probe_read(conn, release):
    try:
        return f"ok rows={len(release['read'](conn))}"
    except sqlite3.Error as exc:
        return f"error {exc}"


if __name__ == "__main__":
    work = Path(tempfile.mkdtemp(prefix="g24-"))
    try:
        # 场景 1:浮动标签,上一个版本就地消失
        float_root = work / "float"
        fs = FloatStore(float_root)
        fs.publish("v1", "VERSION='v1'\n")
        fs.publish("v2", "VERSION='v2' BUG\n")
        try:
            fs.rollback("v1")
            float_blocked = False
        except RollbackUnavailable as exc:
            float_blocked = True
            print(f"[float] releases目录={fs.listing()} 回滚 v1 → RollbackUnavailable: {exc}")

        # 场景 2:不可变 digest + 保留窗口
        dg_root = work / "digest"
        ds = DigestStore(dg_root, retain=2)
        d1 = ds.publish("v1", "VERSION='v1'\n", V1)
        d2 = ds.publish("v2", "VERSION='v2' DEP=redis\n", V2)
        d3 = ds.publish("v3", "VERSION='v3' DEP=redis\n", V2)
        print(f"[digest] v1={d1} v2={d2} v3={d3} 保留窗口={ds.order} 已回收={ds.gc_log}")
        try:
            ds.rollback("v1")
            v1_blocked = False
        except RollbackUnavailable as exc:
            v1_blocked = True
            print(f"[digest] 回滚 v1 → RollbackUnavailable: {exc}")
        print(f"[digest] 回滚 v2 → {ds.rollback('v2')} 指针={ds.current()}")

        # 场景 3:健康检查强弱(migration 还没跑就上了 v2)
        db = work / "s3.db"
        conn = init_db(db)
        print(f"[health] schema={schema_version(conn)} 部署 v2(未跑 expand)")
        print(f"[health] weak   → {json.dumps(weak_health(conn, V2), ensure_ascii=False)}")
        print(f"[health] strong → {json.dumps(strong_health(conn, V2), ensure_ascii=False)}")
        conn.close()

        # 场景 4:expand-contract 决定旧代码能不能跑
        db = work / "s4.db"
        conn = init_db(db)
        expand(conn)
        after_expand = f"schema={schema_version(conn)} v1={probe_read(conn, V1)} v2={probe_read(conn, V2)}"
        contract(conn)
        after_contract = f"schema={schema_version(conn)} v1={probe_read(conn, V1)} v2={probe_read(conn, V2)}"
        strong_after_contract = strong_health(conn, V1)
        print(f"[db] expand 之后 {after_expand}")
        print(f"[db] contract 之后 {after_contract}")
        print(f"[db] contract 后切回 v1 的强健康检查 → "
              f"{json.dumps(strong_after_contract, ensure_ascii=False)}")
        conn.close()

        # 场景 5:一次演练(部署坏版本 → 健康失败 → 回滚 → 业务验证)
        drill_root = work / "drill"
        dstore = DigestStore(drill_root, retain=2)
        db = work / "s5.db"
        conn = init_db(db)
        expand(conn)
        bad = dict(V2, dep_ok=False)
        health, audit = drill(dstore, conn, bad)
        print(f"[drill] v2 健康 → {json.dumps(health, ensure_ascii=False)}")
        print(f"[drill] 审计 → {json.dumps(audit, ensure_ascii=False)}")
        print(f"[drill] 结束状态 指针={dstore.current()} "
              f"强健康={json.dumps(strong_health(conn, dstore.release()), ensure_ascii=False)}")

        single = decision_minutes()
        consensus = decision_minutes(consensus=True)
        print(f"[budget] 回滚决策耗时:按策略单人={single} 分钟,等四人共识={consensus} 分钟"
              f"(model 值,非实测);10 分钟预算内容得下单人路径={single + 2 <= 10},"
              f"容不下共识路径={consensus > 10}")
        checks = {
            "float_blocked": float_blocked,
            "v1_gc_blocked": v1_blocked,
            "weak_blind": weak_health(conn, V2)["healthy"] is True,
            "strong_catches": not health["healthy"],
            "expand_compat": "v1=ok rows=2 v2=ok rows=2" in after_expand,
            "contract_breaks_v1": "no such column" in after_contract and not strong_after_contract["healthy"],
            "drill_rolled_back": audit[0]["action"] == "auto_rollback" and audit[0]["business_ok"],
            "drill_restored": strong_health(conn, dstore.release())["healthy"],
            "budget": single + 2 <= 10 < consensus,
        }
        conn.close()
    finally:
        shutil.rmtree(work, ignore_errors=True)

    if all(checks.values()):
        print("PASS: 浮动标签覆盖与保留窗口外的旧版本都回不去;expand 后旧代码仍能读,"
              "contract 后切回旧代码立刻报错;强健康检查抓住坏版本,演练回滚后业务验证通过")
        print("STOP: 没有保留可回滚产物、数据库不向前兼容、健康检查只看端口、回滚未演练,任一成立都算不可回滚")
        print("NOT_COVERED: 本机用文件目录与 SQLite 模拟。未验证 Kubernetes/负载均衡/蓝绿金丝雀的流量切换与连接排空,"
              "未验证 MySQL/PostgreSQL 的 DDL 行为,决策与审批耗时是模型值,本机毫秒数不代表生产回滚时间")
    else:
        print("FAIL: " + str({k: v for k, v in checks.items() if not v}))
        raise SystemExit(1)

运行:

bash 复制代码
python -X utf8 实验/gate.py

本机完整输出(Python 3.11.9,Windows 10,SQLite 3.45.1 取 sqlite3.sqlite_version,即 Python 自带那份;[drill] 审计 里的 elapsed_ms 是 time.perf_counter() 实测,每次运行都变,留存日志 8.45、本文重跑一次 6.24,其余 16 行逐字相同;运行日志另记的 shell 提示符行和 exit=0 不是脚本打印的,没抄进来):

text 复制代码
[float] releases目录=['latest'] 回滚 v1 → RollbackUnavailable: v1 不是目录名,产物在 releases/latest 已被覆盖
[digest] v1=00f0dc39146e v2=2efbf835eaba v3=fec9c144b142 保留窗口=['2efbf835eaba', 'fec9c144b142'] 已回收=['00f0dc39146e']
[digest] 回滚 v1 → RollbackUnavailable: v1 已不在保留窗口(保留 2 个:['2efbf835eaba', 'fec9c144b142'])
[digest] 回滚 v2 → {'rolled_back_to': 'v2', 'digest': '2efbf835eaba'} 指针={'digest': '2efbf835eaba', 'version': 'v2'}
[health] schema=1 部署 v2(未跑 expand)
[health] weak   → {"healthy": true, "checks": {"port": true}}
[health] strong → {"healthy": false, "checks": {"port": true, "dep": true, "schema_ok": false, "core_read": false, "core_read_error": "no such column: state"}}
[db] expand 之后 schema=2 v1=ok rows=2 v2=ok rows=2
[db] contract 之后 schema=2 v1=error no such column: status v2=ok rows=2
[db] contract 后切回 v1 的强健康检查 → {"healthy": false, "checks": {"port": true, "dep": true, "schema_ok": true, "core_read": false, "core_read_error": "no such column: status"}}
[drill] v2 健康 → {"healthy": false, "checks": {"port": true, "dep": false, "schema_ok": true, "core_read": true}}
[drill] 审计 → [{"rolled_back_to": "v1", "digest": "00f0dc39146e", "action": "auto_rollback", "trigger": "health:dep", "operator": "oncall-backend", "business_ok": true, "elapsed_ms": 8.45}]
[drill] 结束状态 指针={'digest': '00f0dc39146e', 'version': 'v1'} 强健康={"healthy": true, "checks": {"port": true, "dep": true, "schema_ok": true, "core_read": true}}
[budget] 回滚决策耗时:按策略单人=1 分钟,等四人共识=12 分钟(model 值,非实测);10 分钟预算内容得下单人路径=True,容不下共识路径=True
PASS: 浮动标签覆盖与保留窗口外的旧版本都回不去;expand 后旧代码仍能读,contract 后切回旧代码立刻报错;强健康检查抓住坏版本,演练回滚后业务验证通过
STOP: 没有保留可回滚产物、数据库不向前兼容、健康检查只看端口、回滚未演练,任一成立都算不可回滚
NOT_COVERED: 本机用文件目录与 SQLite 模拟。未验证 Kubernetes/负载均衡/蓝绿金丝雀的流量切换与连接排空,未验证 MySQL/PostgreSQL 的 DDL 行为,决策与审批耗时是模型值,本机毫秒数不代表生产回滚时间

五个场景分别说明什么

场景 输出关键值 拿走什么
浮动标签 releases目录=['latest'] 按可变标签部署时目录只剩 latest,releases/v1 这个目录名不存在,rollback('v1') 因此抛错
digest + 保留窗口 三个 digest,回收 00f0... 后回滚 v1 失败、回滚 v2 成功 保留数量直接决定你能退回几步;保留 2 个就只能退回 1 步
健康检查强弱 weak healthy=true,strong core_read_error="no such column: state" 只看端口的检查会把坏版本判成健康,发布流程照着绿灯继续走。weak_health() 在脚本里硬编码返回 {"healthy": True, "checks": {"port": True}},不看连接也不看版本,checks["weak_blind"] 因此恒真;这一格有意义的是同一份 v2 过两种检查得到相反结论
expand / contract expand 后 v1、v2 都 rows=2;contract 后 v1 no such column: status 数据库的向前兼容是先加后删两步换来的,contract 一执行这个窗口就关掉,旧代码立刻读不到列
演练 elapsed_ms=8.45、business_ok=true、结束强健康 true 回滚要连着业务验证一起跑,只看指针切回去不算完成

[db] contract 后切回 v1 的强健康检查 那行是本文最想让你记住的一处:schema_ok=true 但 core_read=false。schema 版本号是 2,v1 声明能兼容 1 和 2,这一项检查通过,可它读的列已经不在了。**版本号检查会骗你,核心读路径不会。**健康检查里必须有一次真实的核心读,最好就是你线上会走的那条 SQL。

8.45ms 也不能当成"10 分钟达标"的证据。它只是本机写指针文件、跑两次健康检查、执行一次回滚查询的耗时,重跑会变。[budget] 那行的数全来自脚本常数:decision_minutes() 返回 1 或 12,判断式 single + 2 <= 10 < consensus 里那个 2 是给部署本身留的模型值,脚本没有对应测量。真实链路里大头是流量切换、连接排空、缓存预热和依赖方的兼容确认,这些本机没测,也不该用那个毫秒数去推断。

失败在哪里

前提没准备,比脚本写错更常见:

  • 发布物按浮动标签或同一路径覆盖,旧版本产物不存在;
  • 保留策略只按数量或天数清理,回滚窗口比故障发现时间短;
  • migration 只向前,旧代码依赖的列被删或被改名,切回指针等于切到一个跑不起来的版本;
  • 健康检查只探端口或 /healthz 返回 200,不看依赖、schema 和核心读路径;
  • 回滚脚本从未在同等环境演练,第一次执行就是故障当天;
  • 配置、密钥、缓存与新版本绑定,切回旧版后读到新格式的数据;
  • 流量切换与连接排空没设计,回滚后仍有请求打在旧代码处理不了的形态上。

后三条(配置/缓存绑定、流量切换、连接排空)本文脚本没有覆盖,只在检查项里列了名字。AI 生成的发布脚本恰恰容易停在"切指针 + 返回成功"这一步,因为它看不到你的流量层和数据形态。

门禁怎么写

第一,发布物不可变并按 digest 部署。每个版本的内容 digest 单独存,至少保留最近两个可用版本;保留窗口要和"故障发现平均耗时"对齐,而不是只按磁盘清理。回收动作要留记录,上面 已回收=['00f0dc39146e'] 就是那一步的账,没账的话你只会在使用时才发现版本不见了。

官方文档里有同样的说法。Kubernetes 镜像概念页写着:You should avoid using the :latest tag when deploying containers in production as it is harder to track which version of the image is running and more difficult to roll back properly.(生产环境别用 :latest 标签,它让运行中的镜像版本难以确认,也让回滚难以做对。)同页在讲镜像名可以带 digest 时补了一句:Only the digest will be used for pulling.(带 digest 时只按 digest 拉取。)本文的 FloatStore 就是那个反面:标签固定,目录就地被覆盖。页面核对时间 2026-11-02。

第二,数据库变更走 expand--contract。先加新列(可空或有默认值并回填),旧代码忽略它;新代码稳定运行一个周期后,再删除旧结构。contract 之前必须确认"回滚不再需要",否则回滚就是二次事故。删列、改名这类动作,要在演练环境里做一次"切回旧代码 + 跑核心读"的验证。

《Evolutionary Database Design》(Pramod Sadalage、Martin Fowler 合写)里有原话:On the whole we prefer to write our migrations so that the database access section can work with both the old and new version of the database.(我们倾向于把 migration 写成数据访问层同时兼容新旧两版数据库的样子。)该文给 customer 改名的例子用的词是 Parallel Change。实录 [db] expand 之后 那行(v1、v2 都 rows=2)就是这条兼容窗口,contract 之后 是窗口关掉。页面核对时间 2026-11-02。

第三,健康检查要能判业务可用。至少四项:进程/端口、关键依赖、schema 版本区间、一次真实核心读。任何一项失败即判不健康,并自动停止发布或触发回滚。核心读要用线上真实的查询形态,而不是 SELECT 1。

第四,回滚脚本化并定期演练。演练就做上面那五步:部署新版本 → 制造一次可控失败 → 健康检查判 false → 回滚到上一个 digest → 跑一次业务验证,并把触发原因、执行人、耗时和验证结果落成一条审计记录。发布前至少演练一次这一条路径;带流量切换和依赖方的完整演练单独排期,因为本文脚本证明不了那一段。

第五,回滚决策写进策略,不等共识。写清楚"什么指标到什么阈值就回滚、谁按按钮、通知谁"。上面 operator=oncall-backend 来自 POLICY,触发条件是 health:dep。脚本里等四人共识记 12 分钟、单人按策略记 1 分钟,两个都是模型值,只用来说明同一条预算下两种路径的量级差别;切换本身花多久本文没有测,所以这里给不出"大一个数量级"这类倍数结论,要这个数的人得在自己的发布系统上计时。

停止条件:无可回滚产物或保留窗口不足、migration 不向前兼容、健康检查缺核心读与依赖项、回滚从未演练、决策人与阈值没写进文档,任一成立就按"不可回滚"处理,停止发布。前五条本文测了四条:产物缺失与保留窗口看 float_blocked、v1_gc_blocked,兼容性看 expand_compat、contract_breaks_v1,健康检查看 strong_catches,演练看 drill_rolled_back、drill_restored。决策人与阈值这一条只有脚本里的 POLICY 常量,没有真实工单与审批链;operator=oncall-backend 是示例值。所以本篇示例能证明的是检查项怎么测,不能证明你的团队已经具备这条路径。

证据要求

项目 要留下什么 为什么
产物 当前与上一个版本的 digest、存放位置、保留策略与回收记录 证明版本真的还在
数据库 schema 版本、expand/contract 计划、一次"切回旧代码 + 核心读"的记录 证明旧代码能跑
健康检查 检查项清单、失败阈值、一次真实失败输出 证明能识别坏版本,而不是只探活
演练 开始/结束时间、执行人、触发原因、结果、耗时 证明这条路径被走过
业务验证 回滚后的核心请求结果或关键指标恢复 证明服务真的恢复
决策 阈值、决策人、通知路径 证明不用临时开会
时间线 从检测、决策、切换到验证的各段耗时 证明 10 分钟是量出来的。本文只落了 elapsed_ms 一个数,还是本机写指针和跑检查的耗时;检测与决策两个环节没有计时

审计记录里要有"为什么回滚"(trigger=health:dep)和"回滚后验过什么"(business_ok=true)。只有"已回滚"两个字的记录,复盘时等于没有。

不适用范围

本机用文件目录模拟发布物、用 SQLite 模拟数据库。Kubernetes 的镜像拉取与滚动更新、负载均衡与服务网格的流量切换、蓝绿与金丝雀的切回、连接排空与缓存预热都没有覆盖。

删列这一步在 SQLite 上有明确限制。官方 ALTER TABLE 页写:The DROP COLUMN command removes the named column from the table, and rewrites its content to purge the data associated with that column.(DROP COLUMN 删掉这一列,并重写表内容把该列数据清出去。)紧接着一条:The DROP COLUMN command only works if the column is not referenced by any other parts of the schema and is not a PRIMARY KEY and does not have a UNIQUE constraint.(这一列被 schema 其他地方引用、或是主键、或带 UNIQUE 约束时,DROP COLUMN 不成立。)脚本里 contract() 只有一句 ALTER TABLE orders DROP COLUMN status,落在一张三个字段、没有索引和外键的表上;换成有视图、触发器或索引的表,同一条语句可能直接失败。MySQL 与 PostgreSQL 的 DDL 行为(在线改表是否锁表、耗时、能否回退)本文没验证,要对着它们的官方文档核对并在目标库演练。页面核对时间 2026-11-02。

决策与审批耗时是模型值,只用来比较"单人按策略"和"等多人同意"两种路径的量级差别,不代表任何团队的实际响应时间。"10 分钟"是否合理取决于系统规模与发布方式。本文要验的是这一段能不能走完并留下记录:坏版本被判 false、指针切回上一个 digest、切回后再查一次强健康、审计行里有 trigger 和 business_ok。这几个字段齐了才算可验证的回滚,缺一个就得到故障现场再补。

说明

本文由 AI 起草,实验脚本和输出在本机实际运行后整理;版本、目录、表结构与错误均为构造示例,不对应任何真实发布系统、数据库或线上事故。

相关推荐
NishikiAsumi072117 分钟前
一次170万行的导出:sql优化远没有加索引这么简单
后端
humcomm1 小时前
设计与架构 · 01 sidecar 会消失吗:从 Istio ambient GA 到 AWS App Mesh 关停,一次服务网格数据面的重新洗牌
架构·istio·aws
xjz18421 小时前
Rust 注释:从 // 到 rustdoc
后端
Eyfcom2 小时前
〖物流低代码实战 01〗物流页面可视化搭建的底层架构:协议、引擎与产物的三重奏
低代码·架构·快递系统·物料体系·物流低代码实战
syyjhchou3 小时前
自己搭企业内部中转站,怎么让请求更容易命中缓存
人工智能·后端·语言模型·aigc
特立独行的猫A3 小时前
Ionic + 仓颉语言的 cj-tauri 开发框架实战:开发精美移动端 APP
java·后端·python
SomeOtherTime4 小时前
Rust集合(AI回答)
开发语言·后端·rust
我不会起名字3224 小时前
req.Seqs[len-1]:空切片防御缺口与 RPC 二次校验
后端·mongodb·go·即时通讯·im·故障排查
脚本自动化开发5 小时前
京东PLUS开卡红包刷50元的方法
后端