SpacetimeDB Tables 全面指南:内存数据库的表设计、定义与访问控制
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
SpacetimeDB 以"表"(Table)作为全部数据的存储与组织单元——所有数据常驻内存以获得极低延迟与高吞吐,同时自动持久化到磁盘保证可靠性。本指南围绕 SpacetimeDB 1.12.0 的表系统展开,覆盖表的核心设计哲学(数据导向设计、物理与逻辑独立性、表分解)、TypeScript/C#/Rust 三种服务端语言中表的定义与访问器命名规则、公开/私有可见性、同一类型的多表模式、约束与自增列,并结合仓库源码(如系统表实现crates/datastore/src/system_tables.rs)剖析底层原理。读完本文,你将能够设计出高性能的 SpacetimeDB 表结构,并在模块代码中正确声明、查询与组织数据。
Tables 是什么:内存优先、自动持久化的数据存储单元
Tables 是 SpacetimeDB 中存储数据的唯一方式。其核心架构决策有两点:
- 数据常驻内存:所有表数据保存在内存中,为读写提供极低延迟与极高吞吐;
- 自动持久化:SpacetimeDB 会在后台自动将所有数据持久化到磁盘,重启后数据不丢失,无需开发者手动管理持久化逻辑。
这一"内存 + 自动落盘"的组合,是 SpacetimeDB 面向实时、高频交互场景(如多人游戏、实时协作)的基石:查询路径上享受内存访问的速度,可靠性上又具备数据库级别的持久化保证。
Why Tables:表作为数据组织的基本单元
表在 SpacetimeDB 中的地位,正如文件在 Unix 中的地位——都是系统中最基本的数据组织单位。但表比文件更具一般性:
- Unix 需要用独立的"文件系统"(filesystem)概念来组织和描述文件;
- SpacetimeDB 则自我描述:表和表结构的表示本身也存储在表中,这些表被称为系统表(system tables),例如
st_table与st_column。
直接查询系统表
系统表完全开放查询,你可以用 SQL 直接检视当前数据库的元数据:
SELECT * FROM st_table; SELECT * FROM st_column;:::warning 你可以查询系统表,但不应直接修改它们。所有 schema 变更都应通过模块代码中的正常定义机制完成,绕开系统表的写入可能导致数据库状态不一致。 :::
从源码层面看,系统表在crates/datastore/src/system_tables.rs中被定义为一个固定 ID 的集合,例如:
ST_TABLE_ID = TableId(1),即st_table,存储所有表的元数据;ST_COLUMN_ID = TableId(2),即st_column,存储所有列的元数据;- 后续还有
st_sequence(自增序列)、st_index(索引)、st_constraint(约束)、st_module、st_client、st_var、st_scheduled(调度表)等共 20 余张系统表,覆盖了表结构、序列、索引、约束、连接客户端、系统变量、定时调度等全部元信息。
系统表的 ID 范围被保留(用户创建的表从保留范围之后开始分配),这也印证了"系统表是一等公民、Schema 即数据"的设计。
表与数据导向设计(Data-Oriented Design)
关系模型是数据导向设计的逻辑终点。常见的实体组件系统(ECS)模式实际上只是关系模型能力的一个严格子集,而表赋予你完整的关系理论能力——这是五十多年来被反复验证的数据组织与查询技术。
数据导向设计的核心原则是:任何程序的目的都是将数据从一种形式转换为另一种形式。表为数据提供了有原则的、通用的表示,并由此带来四项关键能力:
- 高效的访问模式:通过索引(indexes)实现;
- 数据完整性:通过约束(constraints)实现;
- 灵活的查询:通过关系运算(relational operations)实现;
- 实时同步:通过订阅(subscriptions)实现。
关于这一设计哲学的深入讨论,可参见 The Zen of SpacetimeDB。
物理与逻辑独立性
关系模型的一个核心目标是分离逻辑访问模式与物理数据表示。当你编写一条订阅查询时,你表达的是需要什么数据,而不是数据库应该如何检索它。这种分离使得 SpacetimeDB 可以出于性能原因改变数据的物理表示,而无须你重写查询。
最典型的例子是索引:
- 当你为某列添加索引时,改变了该数据的物理组织方式——SpacetimeDB 会构建额外的数据结构来加速查找;
- 但你的订阅查询原样继续工作:同一查询之前是全表扫描,现在自动走索引;
- 你通过修改 schema 而非修改查询来获得性能提升。
这种独立性还延伸到索引之外。SpacetimeDB 可以在不同版本间改变内部存储格式、内存布局与访问算法,而你的查询保持稳定,因为查询工作在逻辑层面(行与列),而非物理层面(字节与指针)。
表分解(Table Decomposition):按访问模式组织数据
设计关系 schema 时的一个常见疑虑:是把数据合并进更少的大表,还是分散到许多小表?传统 SQL 数据库中,join 需要冗长的查询语法并产生显著的执行开销,这种摩擦促使开发者走向"少而宽"的反规范化 schema。
SpacetimeDB 的约束完全不同:你的 reducer 通过程序化 API而非 SQL 字符串与表交互。一次 join 操作退化为一次索引查找:从一张表取出一行,提取键值,再用该键到另一张表中查找相关行。由于所有数据常驻内存,这类查找往往在纳秒级完成。
考虑一个游戏应用的 schema 设计:
合并式方案(不推荐):
Player ├── id ├── name ├── position_x, position_y, velocity_x, velocity_y (updates: 60Hz) ├── health, max_health, mana, max_mana (updates: occasional) ├── total_kills, total_deaths, play_time (updates: rare) └── audio_volume, graphics_quality (updates: very rare)分解式方案(推荐):
Player PlayerState PlayerStats PlayerSettings ├── id ←── ├── player_id ├── player_id ├── player_id └── name ├── position_x ├── total_kills ├── audio_volume ├── position_y ├── total_deaths └── graphics_quality ├── velocity_x └── play_time └── velocity_y PlayerResources ├── player_id ├── health ├── max_health ├── mana └── max_mana分解式方案带来多项优势:
- 降低带宽消耗:订阅玩家位置的客户端在设置变更时不会收到更新。对于 1000 名并发玩家以 60Hz 更新位置的场景,这种削减相当可观。
- 缓存效率:更新频率相近的数据驻留在连续内存中。更新玩家位置不需要加载或失效包含生涯统计的缓存行。
- 语义清晰:每张表职责单一。
PlayerState服务性能关键的游戏循环,PlayerStats服务排行榜查询,PlayerSettings支撑设置界面。 - Schema 演化:你可以向
PlayerStats添加列,而不影响PlayerState的结构或性能特征。
指导原则:按访问模式组织数据,而不是按实体描述组织数据。把需要一起读取的数据放在同一张表中,把在不同时间或不同频率读取的数据分开。
Defining Tables:三种语言的定义方式
表在模块代码中定义,包含名称、列和可选配置。三种服务端语言各有对应的声明语法。
TypeScript:table函数
import { table, t } from 'spacetimedb/server'; const people = table( { name: 'people', public: true }, { id: t.u32().primaryKey().autoInc(), name: t.string().index('btree'), email: t.string().unique(), } );第一个参数定义表选项(名称、可见性等),第二个参数定义列。
C#:[SpacetimeDB.Table]特性
[SpacetimeDB.Table(Name = "Person", Public = true)] public partial struct Person { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public uint Id; [SpacetimeDB.Index.BTree] public string Name; [SpacetimeDB.Unique] public string Email; }partial修饰符是必需的,以便代码生成器扩展该类型。
Rust:#[spacetimedb::table]过程宏
#[spacetimedb::table(name = person, public)] pub struct Person { #[primary_key] #[auto_inc] id: u32, #[index(btree)] name: String, #[unique] email: String, }:::note Rust 可见性与 SpacetimeDB 可见性的区别 struct 上的pub修饰符遵循 Rust 常规可见性规则,对 SpacetimeDB没有意义。它控制的是该结构体能否从 crate 内的其他 Rust 模块访问,而不是表是否对客户端公开。请使用#[spacetimedb::table]中的public属性来控制客户端可见性。 :::
表命名与访问器(Accessor)
表名决定了你在代码中如何访问该表。理解这层关系对编写正确的 SpacetimeDB 模块至关重要。
访问器名称如何派生
TypeScript:访问器名由 snake_case 转换为 camelCase:
// Table definition const player_scores = table( { name: 'player_scores', public: true }, { /* columns */ } ); // Accessor uses camelCase ctx.db.playerScores.insert({ /* ... */ });| Table Name | Accessor |
|---|---|
'user' | ctx.db.user |
'player_scores' | ctx.db.playerScores |
'game_session' | ctx.db.gameSession |
C#:访问器名与Name特性值完全一致:
// Table definition [SpacetimeDB.Table(Name = "Player", Public = true)] public partial struct Player { /* columns */ } // Accessor matches Name exactly ctx.Db.Player.Insert(new Player { /* ... */ });| Name Attribute | Accessor |
|---|---|
Name = "User" | ctx.Db.User |
Name = "Player" | ctx.Db.Player |
Name = "GameSession" | ctx.Db.GameSession |
:::warning 大小写敏感 访问器区分大小写,必须与Name值完全匹配。Name = "user"产生ctx.Db.user,而不是ctx.Db.User。 :::
Rust:访问器名与name特性值完全一致:
// Table definition #[spacetimedb::table(name = player, public)] pub struct Player { /* columns */ } // Accessor matches name exactly ctx.db.player().insert(Player { /* ... */ });| name Attribute | Accessor |
|---|---|
name = user | ctx.db.user() |
name = player | ctx.db.player() |
name = game_session | ctx.db.game_session() |
推荐命名约定
| Language | Convention | Example Table | Example Accessor |
|---|---|---|---|
| TypeScript | snake_case | 'player_score' | ctx.db.playerScore |
| C# | PascalCase | Name = "PlayerScore" | ctx.Db.PlayerScore |
| Rust | lower_snake_case | name = player_score | ctx.db.player_score() |
这些约定与各语言的标准风格指南一致,让代码在其生态中显得自然。
表可见性:私有与公开
表默认是私有的,也可以设为公开:
- 私有表:仅 reducers 与数据库所有者可见,客户端无法访问;
- 公开表:通过 subscriptions 向客户端开放读取权限,写入仍然只通过 reducers 进行。
三种语言的定义方式:
const publicTable = table({ name: 'user', public: true }, { /* ... */ }); const privateTable = table({ name: 'secret', public: false }, { /* ... */ });[SpacetimeDB.Table(Name = "User", Public = true)] public partial struct User { /* ... */ } [SpacetimeDB.Table(Name = "Secret", Public = false)] public partial struct Secret { /* ... */ }#[spacetimedb::table(name = user, public)] pub struct User { /* ... */ } #[spacetimedb::table(name = secret)] pub struct Secret { /* ... */ }如果需要更细粒度的访问控制,可以使用 view functions 向客户端暴露经过计算的数据子集——视图可以在暴露前过滤行、选择特定列或连接多张表的数据。
可见性与访问模式的完整细节参见 Access Permissions:私有表适合内部配置、密码哈希/API 密钥等敏感数据与中间计算结果;公开表适合客户端需要展示或交互的数据(游戏状态、用户资料等)。reducers 持有对全部表(公开与私有)的完整读写权限(增删改查),客户端则只能读取公开表数据并通过调用 reducers 间接修改。
同一类型的多张表
你可以让多张表共享同一个行类型——对单个 struct 应用多个 table 特性即可。每张表独立存储自己的行集合,但共享同一 schema。
TypeScript:定义共享同一列 schema 的多个表变量:
import { table, t } from 'spacetimedb/server'; // Define the shared column schema const playerColumns = { identity: t.Identity.primaryKey(), playerId: t.i32().unique().autoInc(), name: t.string(), }; // Create two tables with the same schema const Player = table({ name: 'Player', public: true }, playerColumns); const LoggedOutPlayer = table({ name: 'LoggedOutPlayer' }, playerColumns);C#:对同一 struct 应用多个[Table]特性:
[SpacetimeDB.Table(Name = "Player", Public = true)] [SpacetimeDB.Table(Name = "LoggedOutPlayer")] public partial struct Player { [PrimaryKey] public Identity Identity; [Unique, AutoInc] public int PlayerId; public string Name; }每张表都有自己的访问器:
// Insert into different tables ctx.Db.Player.Insert(new Player { /* ... */ }); ctx.Db.LoggedOutPlayer.Insert(new Player { /* ... */ }); // Move a row between tables var player = ctx.Db.LoggedOutPlayer.Identity.Find(ctx.Sender); if (player != null) { ctx.Db.Player.Insert(player.Value); ctx.Db.LoggedOutPlayer.Identity.Delete(player.Value.Identity); }Rust:对同一 struct 应用多个#[spacetimedb::table]特性:
#[spacetimedb::table(name = player, public)] #[spacetimedb::table(name = logged_out_player)] pub struct Player { #[primary_key] identity: Identity, #[unique] #[auto_inc] player_id: i32, name: String, }每张表都有自己的访问器:
// Insert into different tables ctx.db.player().insert(Player { /* ... */ }); ctx.db.logged_out_player().insert(Player { /* ... */ }); // Move a row between tables if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender) { ctx.db.player().insert(player.clone()); ctx.db.logged_out_player().identity().delete(&player.identity); }这一模式适用于:
- 状态管理:区分活跃用户与非活跃用户、在线玩家与离线玩家;
- 归档:将旧记录迁移到归档表,同时保持相同 schema;
- 暂存(Staging):将待处理记录暂存在一张表中,之后移入主表。
:::note 共享约束[PrimaryKey]、[Unique]、[AutoInc]、[Index]等列特性会应用到该类型定义的所有表。每张表都会拥有结构相同但相互独立的主键、唯一约束与索引。 :::
约束(Constraints)
表支持若干约束以强制数据完整性:
- 主键(Primary Keys):唯一标识每一行,并定义更新与删除的行为;
- 唯一约束(Unique Constraints):确保没有两行在某列上共享相同值。
完整细节参见 Constraints,核心要点包括:
- 每张表最多一个主键;主键定义了行的身份,修改主键值等同于删除旧行并插入新行(订阅者会先收到 delete 事件再收到 insert 事件);
- 主键天然唯一,SpacetimeDB 通过自动创建的唯一索引实现;
- 目前不支持多列组合主键,需要按多列查找时可使用"自增主键 + 多列 btree 索引"的组合;
- 表可以没有主键,此时整行充当主键:行由其完整内容标识、重复行不可能出现(插入完全相同的行无效果)、更新需要匹配所有字段——SpacetimeDB 始终维护集合语义,区别只在于唯一性的定义者是主键列还是整行;
- 唯一列可以有多个,且同样会创建索引以支持高效查找。主键定位行身份、唯一列保障数据完整性,更新行为上前者是 delete + insert,后者是原地更新。
自增列(Auto-Increment)
自增列自动为新行生成唯一的整数值。SpacetimeDB 使用**序列(sequences)**实现自增——一种参照 PostgreSQL 序列设计的机制,提供崩溃安全的值生成与可配置参数。完整细节参见 Auto-Increment。
关键行为:
- 自增列必须是整数类型(
u8到u64、i8到i64等); - 触发值:插入时该列传 0 则触发自增;传非零值则直接使用该值(可用于迁移带已知 ID 的存量数据);
- 序列参数包括
start(起始值)、min_value(最小值)、max_value(最大值)、increment(步长,可为负); - 回绕行为:序列到达最大值后回绕到最小值继续。如
min_value = 1, max_value = 10, increment = 1的序列产生 1,2,...,10,1,2,...; - 崩溃恢复:序列按4096 个一批分配值,耗尽时先持久化分配边界再发值。数据库重启后从下一个分配边界继续——可能跳过已分配未使用的值,但保证绝不重复赋值。例如插入 Alice/Bob/Carol 得到 id 1、2、3,重启后插入 Dave 会得到 4097;
- 不保证连续:崩溃恢复跳号、并发事务(SpacetimeDB 当前串行执行事务,但保留未来并发执行的权利)都可能导致缺口。同一 reducer 内连续插入也不保证产生连续值;
- 若应用需要严格连续编号(如发票号),请在一个独立计数表中显式维护计数器,计数器更新与行插入发生在同一事务内即可保证顺序;
- 自增可与主键、唯一约束组合,但不能与默认值组合(两者都会自动填充该列)。
调度表(Schedule Tables)
表可以通过包含一个调度列(scheduling column),在特定时间触发 reducers。这使得你可以安排未来的动作,例如发送提醒、内容过期或周期性维护。详见 Schedule Tables。
更进一步:表设计最佳实践
围绕本文提到的能力,表设计性能指南 提供了最佳实践汇总。结合前文内容,核心设计要点可归纳为:
- 按访问频率分解表:高频更新(位置)与低频更新(设置、统计)分离,降低订阅带宽与缓存失效代价;
- 索引服务于查询:为查询条件列建索引,利用物理/逻辑独立性在不改查询的前提下提升性能;
- 可见性最小化:客户端不需要的数据一律私有,公开表只暴露必要字段,必要时用视图做细粒度裁剪;
- 主键即身份:优先使用自增主键或
Identity主键;全表迭代场景可省略主键换取性能; - 自增用于唯一标识、计数表用于连续编号:明确区分"唯一"与"连续"两种需求。
相关阅读(Next Steps)
- Column Types —— 支持的列类型与性能考量
- Constraints —— 主键与唯一约束
- Auto-Increment —— 基于序列的自动 ID 生成
- Default Values —— 借助列默认值进行 schema 演化
- Indexes —— 用单列与多列索引加速查询
- Access Permissions —— 公开表与私有表
- Schedule Tables —— 基于时间的 reducer 执行
- Performance —— 表设计最佳实践
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考