SpacetimeDB Tables 全面指南:内存数据库的表设计、定义与访问控制
2026/9/13 6:56:34 网站建设 项目流程

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_tablest_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_modulest_clientst_varst_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

分解式方案带来多项优势:

  1. 降低带宽消耗:订阅玩家位置的客户端在设置变更时不会收到更新。对于 1000 名并发玩家以 60Hz 更新位置的场景,这种削减相当可观。
  2. 缓存效率:更新频率相近的数据驻留在连续内存中。更新玩家位置不需要加载或失效包含生涯统计的缓存行。
  3. 语义清晰:每张表职责单一。PlayerState服务性能关键的游戏循环,PlayerStats服务排行榜查询,PlayerSettings支撑设置界面。
  4. 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 NameAccessor
'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 AttributeAccessor
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 AttributeAccessor
name = userctx.db.user()
name = playerctx.db.player()
name = game_sessionctx.db.game_session()

推荐命名约定

LanguageConventionExample TableExample Accessor
TypeScriptsnake_case'player_score'ctx.db.playerScore
C#PascalCaseName = "PlayerScore"ctx.Db.PlayerScore
Rustlower_snake_casename = player_scorectx.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。

关键行为:

  • 自增列必须是整数类型(u8u64i8i64等);
  • 触发值:插入时该列传 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。

更进一步:表设计最佳实践

围绕本文提到的能力,表设计性能指南 提供了最佳实践汇总。结合前文内容,核心设计要点可归纳为:

  1. 按访问频率分解表:高频更新(位置)与低频更新(设置、统计)分离,降低订阅带宽与缓存失效代价;
  2. 索引服务于查询:为查询条件列建索引,利用物理/逻辑独立性在不改查询的前提下提升性能;
  3. 可见性最小化:客户端不需要的数据一律私有,公开表只暴露必要字段,必要时用视图做细粒度裁剪;
  4. 主键即身份:优先使用自增主键或Identity主键;全表迭代场景可省略主键换取性能;
  5. 自增用于唯一标识、计数表用于连续编号:明确区分"唯一"与"连续"两种需求。

相关阅读(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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询