事故:周五下午改了一行配置
案例设定里,方舟有一次周五下午的紧急修复:策划在表格里改了一行活动时间,跳过评审直接让配置平台发布。表格里的时间列是本地时区文本,导出器按服务器时区解析,活动比预期提前结束。更麻烦的是,回滚时有人试图把新表「反向迁移」回旧结构,结果卡牌枚举被改回旧值,反而让新客户端读不了。
这次事故暴露的不是某个字段写错,而是流程缺口:导入没有固定入口,校验只是发布按钮旁边的一个提示,评审没有差异可看,回滚没有可恢复的上一版产物。配置错误之所以危险,是因为它不需要发版就能触达全量玩家,而代码缺陷至少还有构建和商店审核两道缓冲。
流水线:六个状态与准入门槛
把配置发布写成状态机,关键不是状态名称,而是每个状态的准入条件和拒绝后的动作。状态只能前进,回滚是唯一的向后动作,并且必须留下原因。
| 状态 | 准入条件 | 拒绝后的动作 |
|---|---|---|
imported | 表格已导出为带 schema 版本与数据版本的产物 | 保留原始文件,修正导出器 |
validated | 5.2 的校验报告零错误 | 退回导入,不允许「先发再修」 |
reviewed | 差异报告已生成,评审人数满足风险等级 | 留在 validated,补齐评审 |
canary | 评审通过,灰度样本与指标门禁已配置 | 留在 reviewed,不得直接全量 |
live | 灰度判据为 promote | 回到 canary 或执行回滚 |
rolled-back | 有上一版 live 产物与回滚原因 | 人工介入,不允许自动猜版本 |
状态机还要记录审计:谁在什么时间、因为什么原因推动了状态。没有审计的流水线只能证明「系统允许发布」,不能证明「这次发布被谁批准」。审计记录同时也是复盘材料,能区分「校验漏了」和「校验报了但被人忽略」。
前向迁移,反向回滚
配置结构变化用迁移解决,配置内容回退用产物解决,这是两条不同的路。
| 动作 | 方向 | 输入 | 结果 | 失败处理 |
|---|---|---|---|---|
| 迁移 | 旧 schema → 新 schema | 旧结构产物 | 新结构产物 | 缺步骤就失败,不猜默认值 |
| 回滚 | 新产物 → 上一版产物 | 上一版 live 产物 | 恢复旧内容 | 没有上一版就拒绝,不反向迁移 |
迁移必须是前向的、确定性的、不修改输入的纯函数。每个步骤只负责一个版本跨度,v1 → v3 由 v1 → v2 和 v2 → v3 串起来;缺少任何一步就报错,而不是让调用方「大概补一下」。这样同一份旧配置在任何机器上都能得到同一份新配置,评审和复现才有意义。
回滚不执行反向迁移。反向迁移要处理信息丢失、枚举收回和时间字段还原,任何一处猜错都会制造第二次事故。正确做法是保留上一版 live 产物,回滚时直接切回;如果上一版不存在,就明确失败,让人工决定。兼容窗口的意义也在这里:旧产物在窗口内仍然能被旧客户端读取,回滚才有退路。
评审看差异,不看表格
评审最常见的失效方式是「打开表格看一眼」。几百行的表,人眼只能看到被高亮的那几行,看不到删除了什么、哪些引用因此悬空。流水线应该先算差异,再让人评审差异。
差异按表拆成新增、删除、修改三类行 id。删除任何一行都算高风险,因为可能有别的表正在引用它;卡牌表和活动表的修改也算高风险,因为它们直接影响战斗与奖励。高风险变更要求两位不同的评审者,低风险变更一位即可。同一个人重复点击「通过」不算第二票。
评审记录要和差异绑定:评审的是哪一版差异,就必须在审计里留下对应的变更行数。否则事后无法回答「当时看的是不是这一版」。
可运行模型:version-migrate.ts
下面的 version-migrate.ts 实现三件事:按版本链做前向迁移、按行 id 计算差异并判定风险、用状态机约束发布与回滚。时钟通过 Clock 注入,测试可以精确检查审计时间;模型不接触真实配置平台。
// file: src/config/version-migrate.ts
// tier: L1
// verify: node --test test/config/version-migrate.test.ts
import type { Clock } from "../core/clock.ts";
import { fail, ok, type Result } from "../core/result.ts";
import type { ConfigTableName } from "./schema.ts";
import { CONFIG_TABLES, type ConfigRow, type ConfigSet, type ValidationReport } from "./table.ts";
export interface Migration {
readonly from: number;
readonly to: number;
readonly migrate: (table: ConfigTableName, row: ConfigRow) => ConfigRow;
}
function renamed(row: ConfigRow, from: string, to: string): ConfigRow {
const next: Record<string, unknown> = { ...row };
if (!(from in next)) return next;
next[to] = next[from];
delete next[from];
return next;
}
export const ARK_MIGRATIONS: readonly Migration[] = [
{
from: 1,
to: 2,
migrate(table, row) {
if (table === "level") return renamed(row, "enemies", "enemyCardIds");
if (table === "card") return renamed(row, "power", "attack");
return { ...row };
},
},
{
from: 2,
to: 3,
migrate(table, row) {
const next: Record<string, unknown> = { ...row };
if (table === "card" && next.rarity === "normal") next.rarity = "common";
if (table === "activity" && typeof next.enabled === "boolean") {
next.status = next.enabled ? "live" : "draft";
delete next.enabled;
}
return next;
},
},
];
export function migrationPath(from: number, to: number, migrations: readonly Migration[] = ARK_MIGRATIONS): Result<readonly Migration[], string> {
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < 1) {
return fail("invalid-version", `versions must be positive integers: v${from} -> v${to}`);
}
if (to < from) {
return fail("no-downgrade", `cannot migrate v${from} down to v${to}; restore the previous artifact instead`);
}
const path: Migration[] = [];
let current = from;
while (current < to) {
const step = migrations.find((candidate) => candidate.from === current && candidate.to <= to);
if (step === undefined) return fail("missing-migration", `no migration step from v${current} toward v${to}`);
path.push(step);
current = step.to;
if (path.length > migrations.length) return fail("migration-loop", "migration graph does not terminate");
}
return ok(path);
}
export function migrateSet(set: ConfigSet, targetVersion: number, migrations: readonly Migration[] = ARK_MIGRATIONS): Result<ConfigSet, string> {
const path = migrationPath(set.schemaVersion, targetVersion, migrations);
if (!path.ok) return fail(path.error, path.reason);
let tables: Readonly<Record<ConfigTableName, readonly ConfigRow[]>> = set.tables;
for (const step of path.value) {
const next: Record<ConfigTableName, readonly ConfigRow[]> = { level: [], card: [], activity: [] };
for (const table of CONFIG_TABLES) next[table] = tables[table].map((row) => step.migrate(table, row));
tables = next;
}
return ok({ ...set, schemaVersion: targetVersion, tables });
}
export type DiffRisk = "low" | "high";
export interface TableDiff { readonly table: ConfigTableName; readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[]; }
export interface BundleDiff { readonly tables: readonly TableDiff[]; readonly risk: DiffRisk; readonly changedRows: number; }
export interface DiffPolicy { readonly highRiskTables: readonly ConfigTableName[]; }
export const ARK_DIFF_POLICY: DiffPolicy = { highRiskTables: ["card", "activity"] };
function rowKey(row: ConfigRow): string {
return typeof row.id === "string" ? row.id : "<missing-id>";
}
function canonical(row: ConfigRow): string {
const keys = Object.keys(row).sort();
return JSON.stringify(keys.map((key) => [key, row[key]]));
}
export function diffSets(before: ConfigSet, after: ConfigSet, policy: DiffPolicy = ARK_DIFF_POLICY): BundleDiff {
const tables = CONFIG_TABLES.map((table) => {
const beforeRows = new Map(before.tables[table].map((row) => [rowKey(row), row]));
const afterRows = new Map(after.tables[table].map((row) => [rowKey(row), row]));
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
for (const [id, row] of afterRows) {
const previous = beforeRows.get(id);
if (previous === undefined) added.push(id);
else if (canonical(previous) !== canonical(row)) changed.push(id);
}
for (const id of beforeRows.keys()) if (!afterRows.has(id)) removed.push(id);
return { table, added: added.sort(), removed: removed.sort(), changed: changed.sort() };
});
const changedRows = tables.reduce((sum, table) => sum + table.added.length + table.removed.length + table.changed.length, 0);
const highRisk = tables.some(
(table) => table.removed.length > 0 || (policy.highRiskTables.includes(table.table) && table.added.length + table.changed.length > 0),
);
return { tables, risk: highRisk ? "high" : "low", changedRows };
}
export type ReleaseState = "imported" | "validated" | "reviewed" | "canary" | "live" | "rolled-back";
export interface AuditEntry { readonly state: ReleaseState; readonly atMs: number; readonly actor: string; readonly note: string; }
export class ReleasePipeline {
readonly #clock: Clock;
readonly #version: number;
readonly #previousLiveVersion: number | null;
readonly #history: AuditEntry[] = [];
readonly #reviewers = new Set<string>();
#state: ReleaseState = "imported";
constructor(clock: Clock, version: number, previousLiveVersion: number | null = null) {
if (!Number.isInteger(version) || version < 1) throw new RangeError(`config version must be a positive integer: ${version}`);
this.#clock = clock;
this.#version = version;
this.#previousLiveVersion = previousLiveVersion;
this.#history.push({ state: "imported", atMs: clock.nowMs(), actor: "import", note: `config v${version} imported` });
}
get state(): ReleaseState { return this.#state; }
get version(): number { return this.#version; }
get previousLiveVersion(): number | null { return this.#previousLiveVersion; }
get history(): readonly AuditEntry[] { return [...this.#history]; }
#record(state: ReleaseState, actor: string, note: string): void {
this.#state = state;
this.#history.push({ state, atMs: this.#clock.nowMs(), actor, note });
}
#note(actor: string, note: string): void {
this.#history.push({ state: this.#state, atMs: this.#clock.nowMs(), actor, note });
}
validate(report: ValidationReport, actor = "ci"): Result<ReleaseState, string> {
if (this.#state !== "imported") return fail("illegal-transition", `validate requires imported, got ${this.#state}`);
if (!report.ok) return fail("validation-failed", `refusing a report with ${report.errorCount} errors`);
this.#record("validated", actor, `${report.errorCount} errors`);
return ok(this.#state);
}
review(reviewer: string, diff: BundleDiff): Result<ReleaseState, string> {
if (this.#state !== "validated") return fail("illegal-transition", `review requires validated, got ${this.#state}`);
const name = reviewer.trim();
if (name.length === 0) return fail("reviewer-required", "reviewer must not be empty");
if (this.#reviewers.has(name)) return fail("duplicate-reviewer", `${name} has already reviewed this release`);
this.#reviewers.add(name);
const required = diff.risk === "high" ? 2 : 1;
if (this.#reviewers.size < required) {
this.#note(name, `review ${this.#reviewers.size}/${required}; risk=${diff.risk}`);
return ok(this.#state);
}
this.#record("reviewed", name, `${this.#reviewers.size} reviewers; risk=${diff.risk}; ${diff.changedRows} changed rows`);
return ok(this.#state);
}
startCanary(actor = "release"): Result<ReleaseState, string> {
if (this.#state !== "reviewed") return fail("illegal-transition", `canary requires reviewed, got ${this.#state}`);
this.#record("canary", actor, "canary started");
return ok(this.#state);
}
promote(actor = "release"): Result<ReleaseState, string> {
if (this.#state !== "canary") return fail("illegal-transition", `promote requires canary, got ${this.#state}`);
this.#record("live", actor, `config v${this.#version} is live`);
return ok(this.#state);
}
rollback(actor: string, reason: string): Result<number, string> {
if (this.#state !== "canary" && this.#state !== "live") {
return fail("illegal-transition", `rollback requires canary or live, got ${this.#state}`);
}
if (reason.trim().length === 0) return fail("reason-required", "rollback needs a recorded reason");
if (this.#previousLiveVersion === null) return fail("no-previous-release", "no previous live version to roll back to");
this.#record("rolled-back", actor, `rollback to v${this.#previousLiveVersion}: ${reason}`);
return ok(this.#previousLiveVersion);
}
}
配套测试 test/config/version-migrate.test.ts 覆盖 13 条路径:迁移链、缺步骤、禁止降级、输入不可变、差异风险、双人评审、非法状态跳转和无上一版时的回滚拒绝。运行命令是 node --test test/config/version-migrate.test.ts。
双案例:周更活动与双周版本
消消以周活动为主,配置结构稳定,流水线可以更轻:一位评审、当天灰度、失败就切回上周产物。它的关键是把「改完就发」变成「校验通过才发」。
方舟每两周一个版本、每周还有活动,卡牌与关卡的引用关系更深,高风险变更需要两位评审。它的回滚目标不是「把表改回去」,而是「切回上一版已验收的配置产物」。
与相邻章节的关系
5.1 定义 schema 与兼容窗口,5.2 提供校验报告与灰度判据,本节把两者串成可审计的发布流程。存档结构的迁移可以参考存档格式与迁移 ,区域化配置的发布可以参考区域化配置与内容分级 ;配置驱动数据表的分工可以参考配置驱动的数据表 。
小结
- 配置发布是状态机:每个状态有准入门槛,回滚是唯一向后动作且必须记录原因。
- 迁移只向前,回滚靠上一版产物;反向迁移会把信息丢失变成第二次事故。
- 迁移函数必须是确定性纯函数,缺步骤就失败,不猜默认值。
- 评审要基于差异报告:删除行、卡牌与活动变更属于高风险,需要两位不同评审者。
- 审计记录要绑定版本、差异与原因,才能区分「系统允许」和「有人批准」。
阅读导航:上一节:5.2 校验、引用完整性与灰度 · 下一节:6.1 资源句柄、引用计数与生命周期 。
常见错误与边界
- 跳过评审直接发布。配置不需要发版就能触达全量玩家,跳过评审等于把线上当测试环境;流水线应把评审设为硬门槛。
- 用反向迁移做回滚。反向迁移要还原已丢失的信息,猜错会制造第二次事故;回滚应切回上一版产物。
- 迁移函数修改输入对象。同一份旧配置被迁移两次会得到不同结果,评审和复现都失去意义;迁移必须是纯函数。
- 把「同一个账号点了两次通过」当成两位评审。高风险变更需要不同的人独立评审;重复评审者应被拒绝。
- 回滚时临时挑一个版本号。没有上一版 live 产物就应失败并转人工,临时猜版本会把未知配置推给玩家。
- 边界:本节不实现配置平台 UI、权限系统与真实审批流。状态机是模型,企业内的审批人、权限与留痕需要与平台账号体系对接。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。