SpacetimeDB 核心架构详解:Host、Database、Table、Reducer、Procedure 与 View 全解析
2026/9/12 23:35:33 网站建设 项目流程

SpacetimeDB 核心架构详解:Host、Database、Table、Reducer、Procedure 与 View 全解析

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

本文以 SpacetimeDB 官方文档《Key Architecture》为骨架,系统梳理其核心架构概念——从承载数据库运行的Host,到定义 schema 与业务逻辑的Module,再到存储数据的Table、处理写操作的Reducer、支持外部 I/O 的Procedure、只读计算的View,以及围绕它们的ClientIdentityConnectionIdEnergy体系。读完本文,你将理解 SpacetimeDB 各组件间的职责边界与协作关系,掌握在 TypeScript、C#、Rust、C++ 四种语言中定义表、编写约减器(reducer)、过程(procedure)与视图(view)并完成客户端调用的完整实战方法,并了解事务原子性、身份认证与实时订阅在底层是如何工作的。

本文所述核心概念原文位于 docs/docs/00100-intro/00100-getting-started/00400-key-architecture.md,文中所有实现细节均可结合仓库中的源码与配套文档交叉验证。

Host:承载数据库的服务器

Host(主机)是托管Database(数据库)的服务器。你可以运行自己的 host,也可以使用 SpacetimeDB 官方提供的托管云服务(maincloud)。一个 host 上可以同时运行多个数据库。

从源码结构看,host 侧的实现分布在仓库的多个核心 crate 中,例如负责事务执行与状态管理的 crates/execution/src、负责数据库存储引擎的 crates/datastore/src,以及负责订阅与实时推送的 crates/subscription/src。如需自建 host,可以参考 docs/docs/00300-resources/00100-how-to/00100-deploy/00200-self-hosting.md 中的说明。

Database:运行在 Host 上的应用实例

Database(数据库)是运行在 host 上的一个应用。它向外导出两类东西:

  • Table(表):用于存储数据;
  • Reducer(约减器):允许Client(客户端)发起请求。

一个数据库的 schema 与业务逻辑由名为Module(模块)的软件定义。模块可以用 C#、C++、Rust 或 TypeScript 编写。

从技术上严格来说,SpacetimeDB 模块是一个 WebAssembly 模块 中定义的低级 WebAssembly ABI,并导出少量特殊函数。不过,SpacetimeDB 的服务端库把这些底层细节都封装掉了——对开发者而言,编写模块与编写普通应用几乎无异,唯一的区别是部署时使用专用的 CLI 工具(spacetime)来完成。

Module 与 Database 的区别

关于 Module 与 Database 的区别,数据库模块文档 给出了清晰的说明:

  • Module是你编写的代码:定义 schema(表)与业务逻辑(reducer、procedure、view),编译后部署到 SpacetimeDB;
  • Database是模块的一个运行实例:拥有模块的 schema 与逻辑,外加真实存储的数据。

同一个模块可以部署到多个数据库(例如分别用于测试、预发、生产环境),每个数据库拥有相互独立的数据。当你更新模块代码并重新发布时,SpacetimeDB 会更新该数据库的 schema 与逻辑,已有数据保持不变(复杂 schema 变更可能需要谨慎处理迁移)。

数据库的命名与运维

发布模块时你需要给数据库命名,名称必须匹配正则/^[a-z0-9]+(-[a-z0-9]+)*$/,即只允许小写 ASCII 字母和数字,用短横线分隔。合法的示例包括my-game-serverchat-app-productiontest123。每个数据库创建时还会获得一个唯一的身份标识(hex 字符串),客户端既可以用名称连接,也可以用身份标识连接。

日常运维通过spacetimeCLI 完成:

# 创建或更新数据库(发布模块) spacetime publish <DATABASE_NAME> # 永久删除数据库及其全部数据(--yes 可在脚本中跳过确认) spacetime delete <DATABASE_NAME> # 以 SQL 直接查询数据库(作为数据库所有者可绕过表可见性限制) spacetime sql <DATABASE_NAME> "SELECT * FROM user" # 以匿名客户端身份查询,遵守表可见性规则 spacetime sql --anonymous <DATABASE_NAME> "SELECT * FROM user" # 查看日志(--follow 实时流式输出,--num-lines 限制条数) spacetime logs <DATABASE_NAME> # 列出当前身份关联的所有数据库 spacetime list

Table:SQL 数据库表

Table(表)本质上就是一张 SQL 数据库表,在模块的原生语言中声明。表的内容可以被Reducer读取和更新;标记为public的表还可以被Client直接读取。

四种语言中的表声明

语言声明方式
TypeScripttable({ name, public }, { 列定义 })
C#[SpacetimeDB.Table(Accessor = "Player", Public = true)]+partial struct
Rust#[spacetimedb::table(accessor = players, public)]+struct
C++SPACETIMEDB_STRUCT+SPACETIMEDB_TABLE+FIELD_PrimaryKey

以一张包含自增主键、名字、年龄和用户身份的Player表为例,四种语言的定义如下:

import { table, t } from 'spacetimedb/server'; const players = table( { name: 'players', public: true }, { id: t.u64().primaryKey(), name: t.string(), age: t.u32(), user: t.identity(), } );
[SpacetimeDB.Table(Accessor = "Player", Public = true)] public partial struct Player { [SpacetimeDB.PrimaryKey] uint playerId; string name; uint age; Identity user; }
#[spacetimedb::table(accessor = players, public)] pub struct Player { #[primary_key] id: u64, name: String, age: u32, user: Identity, }
struct Player { uint64_t id; std::string name; uint32_t age; Identity user; }; SPACETIMEDB_STRUCT(Player, id, name, age, user) SPACETIMEDB_TABLE(Player, players, Public) FIELD_PrimaryKey(players, id)

从仓库的真实模块代码可以看出同样的模式。例如 templates/basic-rs/spacetimedb/src/lib.rs 中的最小 Rust 模块:

use spacetimedb::{ReducerContext, Table}; #[spacetimedb::table(accessor = person, public)] pub struct Person { name: String, }

在 Rust 中,inserttry_insertitercount等表操作由Tabletrait 提供,需要显式导入use spacetimedb::Table;。表还可以配合主键、唯一约束、索引(btree/hash)、自动递增等能力使用,详见 列类型、索引 与 约束 等文档。

Reducer:数据库导出的远程过程调用入口

Reducer(约减器)是数据库导出的一种函数。已连接的Client可以调用 reducer 来与数据库交互,这本质上是一种远程过程调用(RPC)

服务端定义与客户端调用

TypeScript 模块中定义 reducer:

export const setPlayerName = spacetimedb.reducer({ id: t.u64(), name: t.string() }, (ctx, { id, name }) => { // ... });

TypeScript 客户端调用:

function main() { // ...setup code, then... ctx.reducers.setPlayerName(57n, "Marceline"); }

C# 模块中定义:

[SpacetimeDB.Reducer] public static void SetPlayerName(ReducerContext ctx, uint playerId, string name) { // ... }

C# 客户端调用:

void Main() { // ...setup code, then... Connection.Reducer.SetPlayerName(57, "Marceline"); }

Rust 模块中定义:

#[spacetimedb::reducer] pub fn set_player_name(ctx: &spacetimedb::ReducerContext, id: u64, name: String) -> Result<(), String> { // ... }

Rust 客户端调用:

fn main() { // ...setup code, then... ctx.reducers.set_player_name(57, "Marceline".into()); }

C++ 模块中定义:

SPACETIMEDB_REDUCER(set_player_name, ReducerContext ctx, uint64_t id, std::string name) { // ... return Ok(); }

Unreal C++ 客户端调用:

void AMyGameManager::UpdatePlayerName() { // ...setup code, then... Conn->Reducers->SetPlayerName(57, "Marceline"); }

这些调用看起来与普通函数调用无异,但底层是客户端通过互联网发送请求,由数据库处理后返回响应。

ReducerContext 与调用方身份

ReducerContext是 reducer 唯一必选的参数,其中包含调用者 Identity 的信息,可用于对调用者做鉴权。在 reducer context 文档 中可以看到更完整的上下文能力说明。

事务性:全有或全无

每个 reducer 都在自己独立的原子数据库事务中运行:

  • 当 reducer 成功完成时,它所做的一切变更(例如插入一行)会被commit(提交)到数据库;
  • 当 reducer 返回错误或抛出异常时,数据库会**拒绝该请求并回滚(revert)**所有变更。

也就是说,reducer 与事务是"全有或全无"的请求,不可能保留 reducer 前半部分变更而丢弃后半部分。

不支持嵌套事务

事务只能由数据库外部的请求发起。当一个 reducer 直接调用另一个 reducer(如下例),被调用 reducer 的变更不会运行在独立的子事务中;即使被嵌套调用的 reducer "优雅地出错",只要整体 reducer 成功完成,嵌套 reducer 的变更依然会被持久化。

#[spacetimedb::reducer] pub fn hello(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { if world(ctx).is_err() { other_changes(ctx); } } #[spacetimedb::reducer] pub fn world(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { clear_all_tables(ctx); }

TypeScript 与 C# 的对应写法:

export const hello = spacetimedb.reducer((ctx) => { try { world(ctx); } catch { otherChanges(ctx); } }); export const world = spacetimedb.reducer((ctx) => { clearAllTables(ctx); // ... });
[SpacetimeDB.Reducer] public static void Hello(ReducerContext ctx) { if(!World(ctx)) { OtherChanges(ctx); } } [SpacetimeDB.Reducer] public static void World(ReducerContext ctx) { ClearAllTables(ctx); // ... }
SPACETIMEDB_REDUCER(world, ReducerContext ctx) { clear_all_tables(ctx); return Ok(); } SPACETIMEDB_REDUCER(hello, ReducerContext ctx) { if (world(ctx).is_err()) { other_changes(ctx); } return Ok(); }

虽然 SpacetimeDB 不支持嵌套事务,但 reducer 可以通过调度表(schedule tables)来调度另一个 reducer 按固定间隔或在指定时间运行(Rust 侧亦可参考 docs.rs 上 spacetimedb 的 scheduled reducers 文档)。

关于 reducer 的更多细节(ACID 保证、嵌套调用、最佳实践、全局变量陷阱等),可参阅 Reducers 完整文档 与事务与原子性。值得特别注意的是:reducer 是修改数据库状态的唯一途径,所有数据库变更都必须经过 reducer;同时 reducer 运行在隔离环境中,不能发起网络请求、访问文件系统或执行系统调用,这类能力属于下一节的过程(procedure)。

Procedure:支持外部 I/O 的数据库函数

Procedure(过程)是数据库导出的一种函数,与 reducer 类似,已连接的客户端可以调用它。procedure 能执行 reducer 中无法完成的操作,包括向外部服务发起 HTTP 请求。但 procedure 不会自动运行在数据库事务中,必须手动开启并提交事务才能读取或修改数据库状态。因此,除非确实需要 procedure 的特殊能力,否则优先使用 reducer。

各语言定义与调用

TypeScript:

export const makeRequest = spacetimedb.procedure(t.string(), ctx => { // ... })

客户端调用,并注册完成回调接收返回值:

ctx.procedures.makeRequest().then( res => console.log(`Procedure make_request returned ${res}`), err => console.error(`Procedure make_request failed! ${err}`), );

C#:

[SpacetimeDB.Procedure] public static string MakeRequest(ProcedureContext ctx) { // ... return "result"; }

客户端调用与回调(C# 中 procedure 属于 unstable 特性,需要在文件顶部加#pragma warning disable STDB_UNSTABLE):

ctx.Procedures.MakeRequestThen((ctx, res) => { if (res.IsSuccess) { Log.Debug($"Procedure `make_request` returned {res.Value!}"); } else { throw new Exception($"Procedure `make_request` failed: {res.Error!}"); } });

Rust:

因为 procedure 尚不稳定,Rust 模块需要在Cargo.toml中显式开启unstablefeature:

[dependencies] spacetimedb = { version = "2.*", features = ["unstable"] }
#[spacetimedb::procedure] pub fn make_request(ctx: &mut spacetimedb::ProcedureContext) -> String { // ... }

Rust 客户端调用与回调:

ctx.procedures.make_request_then(|ctx, res| { match res { Ok(string) => log::info!("Procedure `make_request` returned {string}"), Err(e) => log::error!("Procedure `make_request` failed! {e:?}"), } })

C++:

SPACETIMEDB_PROCEDURE(std::string, make_request, ProcedureContext ctx) { // ... return std::string{"result"}; }

procedure 与 reducer 的关键差异

维度ReducerProcedure
事务自动运行在独立原子事务中不自动开启事务,需手动with_tx/WithTx开启并提交
外部 I/O禁止(无网络、无文件系统)支持 HTTP 请求外部服务
返回值广播给订阅者仅发送给调用者,不广播给其他客户端
典型场景一切数据库写操作HTTP 集成、外部服务交互

关于 procedure 更完整的说明(手动事务、try_with_tx失败处理、从事务中读出值、HTTP 请求与 30 秒默认超时/180 秒上限等),见 Procedures 文档。此外,reducer 无法直接调用 procedure(procedure 可能产生与事务执行不兼容的副作用),而是通过往调度表插入记录来调度 procedure 在指定时间执行。

View:只读的计算查询

View(视图)是数据库导出的一种只读函数,它基于表计算并返回结果。与 reducer 不同,view不会修改数据库状态,只负责查询并返回数据。view 非常适合在把结果发送给客户端之前,先在服务端完成派生数据计算、聚合或多表连接。

View 必须声明为public,并且只接受一个上下文参数。它可以返回单行或多行。与表一样,view 可以被订阅,并在其底层数据变化时自动更新。

各语言定义

TypeScript:

export const myPlayer = spacetimedb.view( { name: 'my_player', public: true }, t.option(players.rowType), (ctx) => { const row = ctx.db.players.identity.find(ctx.sender); return row ?? undefined; } );

C#:

[SpacetimeDB.View(Accessor = "MyPlayer", Public = true)] public static Player? MyPlayer(ViewContext ctx) { return ctx.Db.Player.Identity.Find(ctx.Sender) as Player; }

Rust:

#[spacetimedb::view(accessor = my_player, public)] fn my_player(ctx: &spacetimedb::ViewContext) -> Option<Player> { ctx.db.player().identity().find(ctx.sender()) }

C++:

SPACETIMEDB_VIEW(std::optional<Player>, my_player, Public, ViewContext ctx) { return ctx.db[player_identity].find(ctx.sender()); }

用 SQL 查询与订阅 View

SELECT * FROM my_player;

ViewContext 与 AnonymousViewContext 的性能差异

View 使用两种上下文之一,选择对性能影响显著(详见 Views 文档):

  • ViewContext:通过ctx.sender暴露调用者的Identity,适合视图结果依赖查询者身份的场景(如"我的背包"、"我的消息");
  • AnonymousViewContext:不提供调用者信息,适合所有订阅者结果相同的场景(如全局排行榜、商店库存、世界地图区域)。

匿名视图(AnonymousViewContext)可以在所有订阅者之间共享:SpacetimeDB 知道结果对每个客户端都相同,因此只物化一次并广播给所有人;而按用户视图(ViewContext)必须为每个订阅者单独计算——若有 1000 个在线用户,就需要 1000 次独立计算与变更跟踪。设计时应尽可能使用AnonymousViewContext,例如把"我附近的实体"改造成"区域 X 的实体",让同一区域的玩家共享同一份物化结果。

View 的索引访问约束与 Query Builder

View 只能通过索引查找find()filter())和表级元数据查询(如count())访问数据,不能使用.iter()全表扫描。原因在于 view 函数是黑盒(图灵完备代码),SpacetimeDB 无法静态分析:当 view 使用.iter()扫描整张表时,其"读集"包含表中每一行,任何一行变化都会触发整表重算;而索引查找能让数据库精确定位依赖行,实现定向失效,保持更新快速可预测。

如果视图逻辑主要是过滤与连接,官方推荐使用模块端 Query Builderctx.from.players.where(...)/ctx.From.Player().Where(...)),把工作下推到查询引擎——查询引擎可以进行全局优化、增量求值(无需整段重算),并避免在 WASM/V8 边界反复物化行数据。对于 join 密集型视图,这一差异往往非常显著。

Client:连接到数据库的应用

Client(客户端)是连接到数据库的应用。客户端使用Identity登录,并获得一个ConnectionId来标识该连接。此后,它可以调用Reducer并查询公开的Table

客户端使用客户端侧 SDK编写。spacetimeCLI 工具可以通过spacetime generate自动生成与客户端 SDK 配套的类型安全绑定代码,从而与特定数据库通信。客户端 SDK 内部维护一条到 SpacetimeDB 的长连接流式通信(WebSocket),支撑高性能的实时交互。

客户端是普通软件应用,开发者可以自由选择部署方式(Steam、应用商店、包管理器或其他方式)。

客户端 SDK 与本地缓存

各语言 SDK(Rust、C#、TypeScript、Unreal C++/Blueprint)功能保持一致,切换语言主要只是语法差异。客户端通过订阅(subscriptions)在本地维护一份数据库行的缓存,订阅数据变化时自动同步。客户端 SDK 提供以下回调:

  • 订阅更新:订阅查询被应用或失败时;
  • 行变更:本地缓存中的行被插入、更新、删除时;
  • Reducer 调用:服务端 reducer 运行时;
  • Procedure 结果:procedure 调用完成时通过回调返回结果。

客户端连接与 SDK 使用细节,可参阅 连接文档 与 SDK API 文档。

Identity:跨连接的全局用户身份

Identity标识与数据库交互的某个用户。它是一个长期有效、公开、全局唯一的标识符,即使跨越不同连接也始终指向同一个终端用户。

  • 用户的Identity会被附加到他们发起的每一次 reducer 调用上,你可以据此决定允许他们做什么(鉴权);
  • 模块本身也有 Identity:当你执行spacetime publish发布模块时,系统会自动为它签发一个 Identity,用于与其他模块区分。你的客户端应用连接 host 时需要提供该 Identity。

Identity 的签发机制

Identity 依据OpenID Connect规范签发。数据库开发者负责给自己的终端用户签发 Identity。OpenID Connect 让用户可以通过 Google、Facebook 等标准服务登录这些账户。

具体而言,Identity 由 JSON Web Token (JWT) 的 issuer(签发方)与 subject(主体)字段哈希推导而来,伪代码如下:

def identity_from_claims(issuer: str, subject: str) -> [u8; 32]: hash1: [u8; 32] = blake3_hash(issuer + "|" + subject) id_hash: [u8; 26] = hash1[:26] checksum_hash: [u8; 32] = blake3_hash([ 0xC2, 0x00, *id_hash ]) identity_big_endian_bytes: [u8; 32] = [ 0xC2, 0x00, *checksum_hash[:4], *id_hash ] return identity_big_endian_bytes

你可以从 SpacetimeDB 的开箱即用身份提供者 SpacetimeAuth 获取 JWT,也可以从任何符合 OpenID Connect 规范的第三方身份提供者获取。

ConnectionId:标识单条客户端连接

ConnectionId标识客户端到 SpacetimeDB 数据库的一条连接。一个用户只有一个Identity,但可能同时打开多条连接——每条连接都会获得一个唯一的ConnectionId

Energy:支付存储与计算成本的通证

Energy(能量)是在 SpacetimeDB host 中用于支付数据存储计算操作成本的货币。

注意:该章节在官方文档中标注为待完善("TODO(1.0): Rewrite this section after finalizing energy SKUs."),具体计价 SKU 尚未定型,实际计费规则以 host 侧最终实现为准。

全链路协作:一次调用背后的完整流程

综合上述概念,一次典型的 SpacetimeDB 交互流程如下:

  1. 模块开发与发布:用 Rust/C#/C++/TypeScript 编写模块(表 + reducer + procedure + view),通过spacetime publish发布到 host 上的某个数据库,模块自动获得自己的 Identity;
  2. 客户端连接与鉴权:客户端用 Identity(来自 JWT)登录数据库,获得 ConnectionId,并建立长连接流式通信;
  3. 订阅实时数据:客户端用 SQL 或类型化查询构建器订阅公开表与 view,host 在提交事务后以TransactionUpdate消息推送变更,SDK 原子地更新本地缓存并触发回调(订阅语义详见 docs/docs/00200-core-concepts/00400-subscriptions/00200-subscription-semantics.md);
  4. 调用 reducer:客户端发起远程过程调用,reducer 在独立原子事务中执行——成功则提交并广播变更,失败则整体回滚;
  5. 调用 procedure:需要外部 I/O 时,客户端调用 procedure,由其在事务外执行 HTTP 请求,再通过with_tx手动提交数据库变更,结果仅返回给调用者;
  6. 只读计算:客户端按需查询或订阅 view,服务端基于索引读集做定向失效与增量更新。

延伸阅读

  • 数据库模块(Module 与 Database 详解)
  • Reducers 完整参考
  • Procedures 完整参考
  • Views 完整参考(含性能指南与 Query Builder)
  • 事务与原子性
  • 客户端 SDK 概览
  • 订阅语义
  • Module ABI 参考(底层 WebAssembly ABI)
  • 真实模块示例:templates/basic-rs/spacetimedb/src/lib.rs(Rust)、templates/basic-ts/spacetimedb(TypeScript)、templates/basic-cs/spacetimedb(C#)、templates/basic-cpp/spacetimedb(C++)

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询