236 lines
9.5 KiB
Markdown
236 lines
9.5 KiB
Markdown
# SpacetimeDB 表结构变更约束
|
||
|
||
本文档总结 SpacetimeDB 开发过程中修改表结构时需要遵守的约束,以及发生迁移冲突后如何在保留旧数据的前提下手动迁移。
|
||
|
||
## 背景
|
||
|
||
当对已有数据库重新执行 `spacetime publish` 时,SpacetimeDB 会尝试根据旧 module schema 和新 module schema 生成自动迁移计划。当前实现的重点是自动 schema migration,不是通用的脚本式数据迁移框架。
|
||
|
||
相关实现入口:
|
||
|
||
- 迁移计划生成:`crates/schema/src/auto_migrate.rs`
|
||
- 迁移执行:`crates/core/src/db/update.rs`
|
||
- 发布前预检查:`crates/client-api/src/routes/database.rs`
|
||
- CLI 发布确认逻辑:`crates/cli/src/subcommands/publish.rs`
|
||
|
||
## 与 PostgreSQL 迁移模型的差别
|
||
|
||
PostgreSQL 的常见迁移模型是脚本驱动的 DDL migration。开发者显式写出每一步迁移动作,例如:
|
||
|
||
```sql
|
||
ALTER TABLE users DROP COLUMN old_name;
|
||
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
|
||
DROP TABLE old_table;
|
||
```
|
||
|
||
也就是说,PG 里能做什么主要取决于 PostgreSQL DDL 能力、锁风险、现有数据是否满足约束,以及 migration 工具如何组织执行。
|
||
|
||
SpacetimeDB 的迁移模型不同。schema 来自模块代码里的 `#[table]`、reducers 和类型定义。开发者修改模块代码后执行:
|
||
|
||
```sh
|
||
spacetime publish <DATABASE_NAME>
|
||
```
|
||
|
||
host 会比较新模块声明的 schema 和旧数据库 schema,然后尝试自动迁移。自动迁移能力是有限的:SpacetimeDB 更像是“模块代码声明世界,publish 时数据库尝试跟上,但只走安全路径”。
|
||
|
||
对常见变更,可以按下面方式理解:
|
||
|
||
| 变更 | PostgreSQL | SpacetimeDB |
|
||
| --- | --- | --- |
|
||
| 加表 | `CREATE TABLE` migration | 自动允许 |
|
||
| 删表 | `DROP TABLE`,生产需谨慎 | 高风险。当前代码只有限支持删除空表;非空表会失败 |
|
||
| 加字段 | `ALTER TABLE ADD COLUMN`,可带默认值 | 只允许加在表定义末尾,并且必须有 default,例如 Rust `#[default(...)]` |
|
||
| 删字段 | `ALTER TABLE DROP COLUMN` | 自动迁移禁止 |
|
||
| 改字段类型、重命名、调顺序 | 可用 DDL 或拆成多步 migration | 自动迁移通常禁止 |
|
||
| 加普通索引 | `CREATE INDEX` / `CREATE INDEX CONCURRENTLY` | 自动允许 |
|
||
| 删索引 | `DROP INDEX` | 允许,但可能破坏某些 subscription query |
|
||
| 加唯一约束或主键 | 可先清理数据再加 | 对已有表自动迁移禁止 |
|
||
| 删除唯一约束 | 可做 | 自动允许移除 `#[unique]` |
|
||
| 删除主键注解 | 可做 | 允许,但可能破坏旧客户端缓存行为 |
|
||
|
||
一句话概括:PG 迁移更自由,但风险由开发者和 migration 流程管理;SpacetimeDB 迁移更保守,系统只自动接受它能证明相对安全、客户端不容易坏的 schema 演进。
|
||
|
||
## 总体原则
|
||
|
||
1. 对已有表做变更时,应优先选择向后兼容的增量式变更。
|
||
2. 不要直接删除、改名、重排或重定义已有列。
|
||
3. 对复杂结构变更,应新增目标表,通过 reducer 或后台批处理把旧数据搬到新表。
|
||
4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。
|
||
5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。
|
||
|
||
## 通常安全的变更
|
||
|
||
这些变更一般可以自动迁移,并且通常不会破坏现有客户端:
|
||
|
||
- 新增表。
|
||
- 新增索引。
|
||
- 添加或移除 auto-increment/sequence 类注解,但新增 sequence 会有额外数据范围预检查。
|
||
- 将表从 private 改为 public。
|
||
- 新增 reducer。
|
||
- 移除 unique constraint。
|
||
- 新增 view,或在兼容范围内更新 view。
|
||
|
||
## 可能破坏客户端但可确认后发布的变更
|
||
|
||
这些变更可以生成自动迁移计划,但可能使旧客户端不兼容。CLI 会要求用户确认,并通过 `BreakClients` policy 携带 token 继续发布。
|
||
|
||
- 给已有表末尾新增带 default 的列,例如 Rust `#[default(...)]`。
|
||
- 删除空表。
|
||
- 删除或改变 view 的返回列、参数、上下文等不兼容部分。
|
||
- 将 public 表改为 private。
|
||
- 移除 primary key 注解。
|
||
- 移除索引,尤其是旧客户端订阅查询依赖该索引时。
|
||
- RLS 规则增加、删除或变化。
|
||
- 删除或修改 reducer,使旧客户端继续调用旧 reducer 时失败。
|
||
|
||
## 会触发冲突或拒绝自动迁移的变更
|
||
|
||
以下变更通常会在自动迁移规划阶段失败,服务端返回 `AutoMigrateError`,CLI 表现为需要 manual migration:
|
||
|
||
- 删除已有列。
|
||
- 重命名已有列。
|
||
- 重排已有列。
|
||
- 在已有表中间新增列。
|
||
- 给已有表新增没有 default value 的列。
|
||
- 修改已有列类型,除非是布局兼容的受限变更。
|
||
- 修改 product/sum 类型时减少字段或 variant。
|
||
- 修改 product/sum 类型时重命名字段或 variant。
|
||
- 修改类型导致 layout size 或 alignment 不兼容。
|
||
- 给已有表新增 unique constraint。
|
||
- 修改已有 unique constraint。
|
||
- 修改 table type。
|
||
- 修改 event flag。
|
||
- 修改 index accessor name。
|
||
|
||
## 规划通过但执行时仍会失败的情况
|
||
|
||
有些变更可以生成迁移计划,但执行阶段仍可能失败:
|
||
|
||
- 删除非空表:当前代码会生成 `RemoveTable`,但执行时会检查 row count。只有空表可以删除,非空表会失败并回滚。
|
||
- schedule 相关变更:规划层可能生成 `AddSchedule` 或 `RemoveSchedule`,但执行层目前仍返回 not implemented。
|
||
- 新增或修改 sequence 时,如果已有数据落在新 sequence 的分配范围内,预检查会失败。
|
||
- 新增索引、RLS、view 重算等操作如果底层校验或执行失败,也会导致本次更新回滚。
|
||
|
||
## 冲突后的系统行为
|
||
|
||
发生冲突时,SpacetimeDB 默认不会自动修改旧数据,也不会执行用户自定义迁移脚本。
|
||
|
||
常见结果:
|
||
|
||
- 发布前预检查发现冲突:CLI 打印原因并中止发布。
|
||
- 服务端迁移规划失败:API 返回 `400 Database update rejected: ...`。
|
||
- 迁移执行中失败:当前事务 rollback,旧数据库和旧 module 继续运行。
|
||
- 只有显式使用 `--delete-data` 或 `--delete-data=on-conflict` 时,才会清空旧数据并用新 module 重建数据库。
|
||
|
||
## 保留旧数据的手动迁移方式
|
||
|
||
生产环境推荐使用增量迁移,而不是直接修改旧表结构。
|
||
|
||
建议步骤:
|
||
|
||
1. 保留旧表。
|
||
|
||
不要直接删除或修改旧表的关键字段。例如保留 `character`。
|
||
|
||
2. 新增目标表。
|
||
|
||
新建 `character_v2`,结构定义为目标 schema。新增表属于自动迁移支持范围。
|
||
|
||
3. 发布兼容中间版本。
|
||
|
||
中间版本同时包含旧表和新表,并添加迁移 helper/reducer:
|
||
|
||
- 读数据时,优先读新表。
|
||
- 新表没有对应行时,从旧表读取,转换后写入新表。
|
||
- 写数据时,优先写新表。
|
||
- 如果仍需兼容旧客户端,同步写旧表。
|
||
- 大量历史数据通过 `migrate_batch(limit)` 之类的 reducer 分批迁移。
|
||
|
||
4. 验证迁移完成。
|
||
|
||
检查新表行数、主键覆盖、关键字段一致性和客户端升级情况。
|
||
|
||
5. 清空旧表数据。
|
||
|
||
通过 reducer 分批删除旧表数据。因为当前实现只允许删除空表。
|
||
|
||
6. 删除旧表定义。
|
||
|
||
旧表为空后,再从 schema 中移除旧表并发布最终版本。
|
||
|
||
## 增量迁移示例
|
||
|
||
下面示例展示从旧表 `character` 迁移到新表 `character_v2` 的基本模式:
|
||
|
||
```rust
|
||
fn get_character(ctx: &ReducerContext, player: Identity) -> CharacterV2 {
|
||
if let Some(row) = ctx.db.character_v2().player_id().find(player) {
|
||
return row;
|
||
}
|
||
|
||
let old = ctx
|
||
.db
|
||
.character()
|
||
.player_id()
|
||
.find(player)
|
||
.expect("character not found");
|
||
|
||
let migrated = CharacterV2 {
|
||
player_id: old.player_id,
|
||
nickname: old.nickname,
|
||
level: old.level,
|
||
class: old.class,
|
||
alliance: Alliance::Neutral,
|
||
};
|
||
|
||
ctx.db.character_v2().insert(migrated.clone());
|
||
migrated
|
||
}
|
||
```
|
||
|
||
批量迁移时,应限制每次处理的行数,避免单个事务过大:
|
||
|
||
```rust
|
||
#[spacetimedb::reducer]
|
||
fn migrate_character_batch(ctx: &ReducerContext, limit: u32) {
|
||
let mut migrated = 0;
|
||
|
||
for old in ctx.db.character().iter() {
|
||
if migrated >= limit {
|
||
break;
|
||
}
|
||
|
||
if ctx.db.character_v2().player_id().find(old.player_id).is_some() {
|
||
continue;
|
||
}
|
||
|
||
ctx.db.character_v2().insert(CharacterV2 {
|
||
player_id: old.player_id,
|
||
nickname: old.nickname,
|
||
level: old.level,
|
||
class: old.class,
|
||
alliance: Alliance::Neutral,
|
||
});
|
||
|
||
migrated += 1;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 发布前检查清单
|
||
|
||
发布涉及表结构变更的 module 前,至少检查:
|
||
|
||
- 是否删除、改名、重排或修改了已有列。
|
||
- 新增列是否位于表定义末尾,并且是否有 default value。
|
||
- 是否给已有表新增了 unique 或 primary key 约束。
|
||
- 是否删除了非空表。
|
||
- 是否修改了 event table、schedule table、RLS 或 view。
|
||
- 是否会破坏旧客户端订阅、reducer 调用或本地缓存假设。
|
||
- 是否需要先发布中间版本做增量迁移。
|
||
- 是否已在 staging 数据库用真实规模样本测试过发布。
|
||
|
||
## 结论
|
||
|
||
SpacetimeDB 的表结构变更策略应以“新增、兼容、分阶段”为核心。实际开发时应尽量只做 additive change:加表、加索引、在末尾加带默认值的字段。遇到自动迁移冲突时,不要直接清库或强行修改旧表;应保留旧数据,新增目标 schema,通过 reducer 分批迁移数据,逐步切换客户端,最后在旧数据清空后移除旧表。
|