# 升级与数据库迁移

schema 迁移在启动时怎么跑，以及多实例部署该按什么顺序升级。

最后校对于 2026-10-10

换二进制的命令在[升级](https://n9e.github.io/zh/docs/install/upgrade.md)里。这页讲运行侧的另一半： **进程启动时会对你的库做什么，以及从第一个实例停掉到最后一个实例回来的这段时间里， 多实例部署是什么样子。**

## 迁移在进程启动时跑，没有单独的命令[​](#迁移在进程启动时跑没有单独的命令 "迁移在进程启动时跑，没有单独的命令的直接链接")

没有迁移命令，也没有迁移状态表。`n9e` 连上元数据库之后——在连 Redis 之前、 在建内存缓存之前、在监听端口之前——就用 GORM 的 AutoMigrate 把编译进二进制的那份模型列表 过一遍。新版本需要的表和列要么这时出现，要么就没有。

由此带来两条性质，都很重要：

* **它只增不减。** 缺的表、列、索引会被建出来，但从不删任何东西，也不记录改之前的样子。 所以不存在反向迁移——见[回滚](https://n9e.github.io/zh/docs/install/rollback.md)。
* **schema 的来源是模型，不是仓库里的 SQL 文件。** `docker/sqlite.sql` 和 `docker/initsql/a-n9e.sql` 是全新安装用的，而且落后于代码：`sqlite.sql` 里 根本没有 `notify_rule`、`notify_channel_config`、`message_template`、`event_pipeline`、 `ai_llm_config` 这些表。它们在跑着的 v9 里存在，**纯粹是 AutoMigrate 建出来的**。 别拿线上的表结构去和这两个文件比对，来判断升级有没有成功。

启动顺序，按「哪一步会失败」排列：

| 步骤                               | 失败了会怎样         |
| ---------------------------------- | -------------------- |
| 连接元数据库                       | **进程直接退出**     |
| 跑 schema 迁移                     | 记一行日志，继续启动 |
| 建 root 用户、JWT 签名密钥         | 记一行日志，继续启动 |
| 连接 Redis                         | **进程直接退出**     |
| 把配置加载进内存缓存、启动告警引擎 | 规则不判定           |
| 监听 17000                         | 端口被占，进程退出   |

## 分两批：小表挡启动，大表不挡[​](#分两批小表挡启动大表不挡 "分两批：小表挡启动，大表不挡的直接链接")

| 批次           | 覆盖                                               | 是否挡启动 |
| -------------- | -------------------------------------------------- | ---------- |
| 同步           | 除事件表以外的所有表——规则、用户、数据源、通知配置 | 挡         |
| 异步 goroutine | `alert_cur_event`、`alert_his_event`，外加三个索引 | 不挡       |

异步这一批还要建三个索引，落在两张会无限长大的表上——而且**建法不一样**：

* `notification_record` 上那两个是刻意不挡写的：MySQL 上显式带 `ALGORITHM=INPLACE, LOCK=NONE`， PostgreSQL 上走 `CREATE INDEX CONCURRENTLY`。MySQL 上在线加不了索引时它宁可报错， 也不会静默退化成阻塞写入的 DDL；
* `alert_his_event` 上的 `idx_group_last_eval_time` 是**普通的 `CREATE INDEX`**，没有这些子句。 在 PostgreSQL 上它整个构建期间会持有该表的共享锁，把写入挡住——而 `alert_his_event` 通常就是你库里最大的那张表。PostgreSQL 上数据量大的部署，要预期历史事件在这段时间写不进去。

也就是说，**库里最大的那张表还在改结构的时候**，进程已经在回 `/ping`、在提供界面、 在判定规则了。`alert_his_event` 上了几个 GB 的话，这事要跑几十分钟。 不要为了「把它弄活」去重启：重启只会让同样的活儿从头再来一遍。

有一条警告是预期内的：如果 `notification_record` 还没有 `notify_rule_id` 列， 依赖这个列的那个索引会被跳过，日志写 *will retry on next start*，另一个索引照常建。 别管它。

## 迁移失败不会让进程停下来[​](#迁移失败不会让进程停下来 "迁移失败不会让进程停下来的直接链接")

每一条迁移错误都是记日志然后吞掉，迁移里的 panic 也会被 recover 掉。 这是有意为之——另一个实例或者下次重启会把 schema 补齐——但带来的后果很容易漏掉： **一个实例可以正常启动、正常服务、正常上报心跳，同时缺着它需要的某一列。** 你不会看到启动失败，你会在好几天以后看到某一个功能在运行时报 `Unknown column`。

所以升级之后要做的检查是 grep 日志，不是看健康检查：

```
grep -iE "failed to migrate table|failed to create index" /opt/n9e/logs/*.log
```

**预期结果**：没有输出。有输出的话几乎都是权限问题——`[DB] DSN` 里那个账号能读能写， 但没有 `CREATE` / `ALTER` 权限。补上授权重启，或者按 [v8 升级 v9](https://n9e.github.io/zh/docs/migration/v8-to-v9.md) 里的说法，把 `docker/migratesql/migrate.sql` 里对应版本的段落交给 DBA 执行。

## 先让一个实例把迁移跑完，再滚其余的[​](#先让一个实例把迁移跑完再滚其余�的 "先让一个实例把迁移跑完，再滚其余的的直接链接")

迁移代码里针对「多实例同时启动」写了两处显式防御，这说明这两件事是真会发生的：

* 两个实例并发对同一张表发 `ALTER`，会让 MySQL 驱动查列类型时解引用到空值。 panic 会被 recover，但这次启动里那个迁移入口就等于放弃了；
* 大表 `alert_his_event` 上重复执行的 `CREATE INDEX` 会卡在元数据锁上，把启动挂住—— 不是卡几秒，是一直卡着。

两种都不会弄坏数据，但都会白白搭进去一次启动，还得回头排查。避开这两件事的顺序是：

1. 停**一个**实例，换二进制，起来。迁移由它来做。
2. 等它的日志安静下来：没有 `failed to migrate`，索引也不再有动静。
3. 然后才动下一个，之后一个一个来。

![告警引擎页面](https://n9e.github.io/zh/img/docs/operations/alerting-engines.zh.webp)![告警引擎页面](https://n9e.github.io/zh/img/docs/operations/alerting-engines.zh.dark.webp)

[升级](https://n9e.github.io/zh/docs/install/upgrade.md)里要求一个一个滚，是为了让告警不中断。 迁移竞争是**同一条规则的第二个、独立的理由**——也是「第一个实例升完要停一停再动第二个」 而不是「隔固定时间动下一个」的原因。

## 升级窗口里到底损失了什么[​](#升级窗口里到底损失了什么 "升级窗口里到底损失了什么的直接链接")

以单个实例为单位，从 `systemctl stop` 到恢复稳态：

| 功能                         | 这个实例重启期间                                                                                                |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 规则判定                     | 它的规则 30 秒内被其他实例接管，之后照常跑                                                                      |
| 规则判定（重启的这个实例上） | 启动后还要再等 30 秒才开始（`[Alert] EngineDelay`）                                                             |
| 指标写入                     | 写到这个实例 `/prometheus/v1/write` 的请求会失败；前面挂负载均衡，或者接受这段缺口                              |
| 界面和 API                   | 只有这个实例上不可用                                                                                            |
| 通知                         | 跟着判定走——谁判定谁发                                                                                          |
| 内置时序库                   | 整个重启期间既读不了也写不了，何况集群里本来就不该开它                                                          |
| 边缘机房                     | `n9e-edge` 继续在本地判定和发通知，见[边缘断网行为](https://n9e.github.io/zh/docs/operations/edge-partition.md) |

所有实例一起重启，会把上面第一行变成「升级多久，就多久没有任何规则在判定」—— 这正是要滚动升级的全部理由。

## 相关[​](#相关 "相关的直接链接")

* [升级](https://n9e.github.io/zh/docs/install/upgrade.md)——具体命令
* [v8 升级 v9](https://n9e.github.io/zh/docs/migration/v8-to-v9.md)——跨大版本的破坏性变更
* [高可用与故障域](https://n9e.github.io/zh/docs/operations/high-availability.md)——规则在实例之间怎么流转
* [回滚与恢复](https://n9e.github.io/zh/docs/operations/rollback.md)——升级不顺利的时候
* [升级或数据库迁移为什么失败？](https://n9e.github.io/zh/docs/troubleshooting/upgrade-fails.md)——日志里真有东西的时候
