SpacetimeDB 深度实战:当数据库变成了服务器——从 Reducer 事务模型到实时订阅推送、从 WASM 模块到全栈后端替代的生产级完全指南(2026)
引言:一个"离经叛道"的数据库
2026 年的 GitHub Trending 上,有一个项目始终占据着 Rust 语言榜单的前列:SpacetimeDB。它的 Star 数已经突破 20,000,而且增长曲线依然陡峭。但你可能没注意到,这个项目的定位描述极其"离经叛道"——
A relational database that is also a server.
一个关系型数据库,同时也是一个服务器。
这是什么意思?简单来说:你不再需要写后端 API 了。你把业务逻辑直接写进数据库里,客户端通过 WebSocket 直连数据库,数据变更自动推送到所有订阅了该数据的客户端。没有 ORM,没有 Controller/Service/Repository 三层架构,没有 Redis 缓存层,没有 WebSocket 手动管理——SpacetimeDB 把这一切都吞了进去。
这听起来像是"存储过程"的文艺复兴?某种意义上是的。但 SpacetimeDB 做的事情远比你爷爷时代的 PostgreSQL PL/pgSQL 存储过程要激进得多。它重新定义了整个后端架构范式。
本文将从架构原理到代码实战,全面拆解 SpacetimeDB 的技术内核,并给出生产环境部署的实操指南。
一、架构全景:SpacetimeDB 到底是什么?
1.1 核心理念:The Zen of SpacetimeDB
SpacetimeDB 的设计哲学可以归纳为五条原则,理解了这五条,你就理解了它的全部设计决策:
Everything is a Table —— 所有应用状态都存储在表中。用户、消息、游戏实体、会话,全部是表。没有独立的缓存层,没有需要在内存和数据库之间同步的"状态"。
Everything is Persistent —— 所有数据默认持久化,包括每一行数据的完整变更历史。SpacetimeDB 将所有数据保留在内存中以实现极低延迟,同时自动持久化到磁盘。
Everything is Real-Time —— 客户端是服务端的"副本"。你订阅数据,SpacetimeDB 将数据镜像到客户端并自动保持同步。你不需要轮询,不需要 fetch,只需要订阅,数据就会流动。
Everything is Transactional —— 每个 Reducer 都运行在事务中。它们是原子的:要么完全完成,要么完全不执行。出错就回滚,没有部分更新,没有脏数据。
Everything is Programmable —— 你的模块是运行在数据库内部的真实代码(Rust、C#、TypeScript 或 C++),不是配置文件,不是声明式规则。
传统技术栈: SpacetimeDB:
┌─────────────────┐ ┌─────────────────┐
│ Application │ │ │
├─────────────────┤ │ Tables │
│ Cache │ → 消灭了 → │ (All-in-One) │
├─────────────────┤ │ │
│ Database │ │ │
└─────────────────┘ └─────────────────┘
1.2 架构分层:Module、Database、Client
SpacetimeDB 的架构有三个核心概念:
Module(模块):你编写的代码,包含表定义(Schema)和业务逻辑(Reducers、Procedures、Views)。Rust/C#/C++ 模块编译为 WebAssembly,TypeScript 模块运行在 V8 引擎上。
Database(数据库):模块的运行实例。当你发布一个模块时,SpacetimeDB 会创建或更新一个数据库实例,其中包含模块定义的表结构和逻辑,以及实际存储的数据。
Client(客户端):通过 WebSocket 连接到数据库的应用程序。客户端可以订阅表数据、调用 Reducers、执行 SQL 查询。
┌──────────────────────────────────────────┐
│ Client SDK │
│ (TypeScript / Rust / C# / Unreal) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ │
│ │ Local │ │ Reducer │ │ SQL │ │
│ │ Cache │ │ Calls │ │ Query │ │
│ └────┬────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ │ WebSocket │
└────────────────────┼─────────────────────┘
│
┌───────────▼───────────┐
│ SpacetimeDB │
│ │
│ ┌─────────────────┐ │
│ │ WASM / V8 │ │
│ │ Module Runtime│ │
│ ├─────────────────┤ │
│ │ Reducers │ │
│ │ Procedures │ │
│ │ Views │ │
│ ├─────────────────┤ │
│ │ Tables (RAM) │ │
│ │ + Persistence │ │
│ │ (Disk/SSD) │ │
│ └─────────────────┘ │
│ │
│ Subscription Engine │
│ (Auto-sync to clients)│
└───────────────────────┘
1.3 性能基准:碾压传统技术栈
SpacetimeDB 官方公布的性能基准相当惊人。在事务吞吐量(TPS)测试中:
| 技术栈 | 大致 TPS |
|---|---|
| Node.js + SQLite | ~5,000 |
| Node.js + Postgres | ~8,000 |
| Node.js + Supabase | ~10,000 |
| Bun + Postgres | ~15,000 |
| Node.js + PlanetScale (HA) | ~20,000 |
| Convex | ~50,000 |
| Node.js + CockroachDB | ~30,000 |
| SpacetimeDB | ~235,000+ |
这个数字差距不是百分之几的提升,而是数量级的碾压。核心原因是:所有数据在内存中,Reducer 直接在数据库进程内执行,没有网络跳转、没有序列化/反序列化的开销、没有 ORM 翻译层。
当然,基准测试永远是理想条件下的数字。生产环境中你的实际吞吐量取决于数据规模、Reducer 复杂度和订阅模式。但即使打折来看,这个性能优势也是压倒性的。
二、核心概念深度拆解
2.1 Tables:数据导向设计的天花板
SpacetimeDB 的表设计哲学直接来自 Data-Oriented Design(数据导向设计)。核心理念是:按访问模式组织数据,而不是按实体组织数据。
以一个游戏应用为例,传统设计会把玩家所有信息塞进一张表:
-- 传统设计(不推荐)
CREATE TABLE player (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
position_x FLOAT,
position_y FLOAT,
velocity_x FLOAT,
velocity_y FLOAT, -- 60Hz 更新
health INT,
max_health INT,
mana INT,
max_mana INT, -- 偶尔更新
total_kills INT,
total_deaths INT,
play_time BIGINT, -- 很少更新
audio_volume FLOAT,
graphics_quality INT -- 极少更新
);
SpacetimeDB 推荐的做法是按更新频率拆分:
// SpacetimeDB 推荐设计(Rust 示例)
use spacetimedb::table;
// 玩家基础信息 - 很少变化
#[table(name = "player", public = true)]
pub struct Player {
#[primary_key]
id: u32,
name: String,
}
// 位置状态 - 60Hz 高频更新
#[table(name = "player_state", public = true)]
pub struct PlayerState {
#[unique]
player_id: u32,
position_x: f32,
position_y: f32,
velocity_x: f32,
velocity_y: f32,
}
// 资源数据 - 偶尔更新
#[table(name = "player_resources", public = true)]
pub struct PlayerResources {
#[unique]
player_id: u32,
health: i32,
max_health: i32,
mana: i32,
max_mana: i32,
}
// 统计数据 - 很少更新
#[table(name = "player_stats", public = true)]
pub struct PlayerStats {
#[unique]
player_id: u32,
total_kills: u64,
total_deaths: u64,
play_time: u64,
}
// 设置数据 - 极少更新
#[table(name = "player_settings", public = false)]
pub struct PlayerSettings {
#[unique]
player_id: u32,
audio_volume: f32,
graphics_quality: i32,
}
为什么要这样拆?三个关键优势:
带宽优化:客户端订阅了 player_state 表,当 player_settings 变化时,客户端不会收到通知。1000 个玩家以 60Hz 更新位置,这个带宽节省是巨大的。
缓存效率:更新频率相似的数据在内存中是连续的。更新位置不会加载或失效包含统计数据的缓存行。
Schema 演进:你可以给 player_stats 加列而不影响 player_state 的结构或性能特征。
TypeScript 定义表的方式
import { schema, 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(),
}
);
const spacetimedb = schema({ people });
export default spacetimedb;
注意 schema() 接受一个对象,不是单个表也不是数组。表名用 snake_case,访问器自动转为 camelCase(player_scores → ctx.db.playerScores)。
2.2 Reducers:事务即函数
Reducer 是 SpacetimeDB 中修改数据的唯一方式。每个 Reducer 自动运行在数据库事务内,提供隔离性、原子性和一致性保证。
import { schema, table, t } from 'spacetimedb/server';
const account = table(
{ name: 'account', public: true },
{
id: t.u64().primaryKey().autoInc(),
owner: t.identity(),
balance: t.u64(),
}
);
const spacetimedb = schema({ account });
export default spacetimedb;
// 转账 Reducer - 事务性保证
export const transfer_funds = spacetimedb.reducer(
{ from: t.u64(), to: t.u64(), amount: t.u64() },
(ctx, { from, to, amount }) => {
const sender = ctx.db.account.id.find(from);
if (!sender) {
throw new Error('Sender not found');
}
if (sender.balance < amount) {
throw new Error('Insufficient funds');
// 抛出异常 → 自动回滚,所有变更都不会生效
}
const receiver = ctx.db.account.id.find(to);
if (!receiver) {
throw new Error('Receiver not found');
}
// 更新余额
sender.balance -= amount;
receiver.balance += amount;
ctx.db.account.id.update(sender);
ctx.db.account.id.update(receiver);
}
);
Reducer 的隔离特性意味着它不能做这些事:
- ❌ 网络请求
- ❌ 文件系统访问
- ❌ 系统调用
- ✅ 只能操作数据库表
全局变量是未定义行为。SpacetimeDB 可能在新的 WASM/V8 实例中运行每个 Reducer,模块更新会创建新的执行环境,崩溃恢复不会保留实例内存。所有状态必须存储在表中。
Rust 版本的 Reducer
use spacetimedb::{reducer, ReducerContext, Table};
#[spacetimedb::table(name = "account", public = true)]
pub struct Account {
#[primary_key]
#[auto_inc]
id: u64,
owner: Identity,
balance: u64,
}
#[reducer]
fn transfer_funds(
ctx: &ReducerContext,
from: u64,
to: u64,
amount: u64,
) -> Result<(), String> {
let mut sender = ctx.db.account().id().find(from)
.ok_or("Sender not found")?;
if sender.balance < amount {
return Err("Insufficient funds".to_string());
// 返回 Err → 自动回滚
}
let mut receiver = ctx.db.account().id().find(to)
.ok_or("Receiver not found")?;
sender.balance -= amount;
receiver.balance += amount;
ctx.db.account().id().update(sender);
ctx.db.account().id().update(receiver);
Ok(())
}
2.3 Procedures:打破隔离边界
当你需要在数据库逻辑中调用外部 API 时,Reducer 的隔离限制就成了问题。Procedures 就是解决方案——它们可以发起 HTTP 请求,但不会自动运行在事务中。
import { schema, t, table, SenderError } from 'spacetimedb/server';
import { TimeDuration } from 'spacetimedb';
const aiMessage = table(
{ name: 'ai_message', public: true },
{
user: t.identity(),
prompt: t.string(),
response: t.string(),
createdAt: t.timestamp(),
}
);
const spacetimedb = schema({ aiMessage });
export default spacetimedb;
// 调用外部 AI API 的 Procedure
export const ask_ai = spacetimedb.procedure(
{ prompt: t.string(), apiKey: t.string() },
t.string(),
(ctx, { prompt, apiKey }) => {
// 发起 HTTP 请求 - Reducer 做不到的事
const response = ctx.http.fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
}),
timeout: TimeDuration.fromMillis(3000),
});
if (response.status !== 200) {
throw new SenderError(`API returned status ${response.status}`);
}
const data = response.json();
const aiResponse = data.choices?.[0]?.message?.content;
if (!aiResponse) {
throw new SenderError('Failed to parse AI response');
}
// 手动开启事务来写入数据库
ctx.withTx(txCtx => {
txCtx.db.aiMessage.insert({
user: txCtx.sender,
prompt,
response: aiResponse,
createdAt: txCtx.timestamp,
});
});
return aiResponse;
}
);
关键点:ctx.withTx() 包裹的函数可能被多次调用(类似乐观锁重试),所以函数必须是幂等的,不能捕获可变状态。
2.4 Views:只读计算视图
Views 是只读函数,从表中计算并返回结果。它们不修改数据,运行在事务内以保证隔离性。Views 可以被订阅,当底层数据变化时自动更新客户端。
// View: 计算在线玩家排行榜
export const leaderboard = spacetimedb.view(
t.array(t.struct({
playerId: t.u32(),
name: t.string(),
score: t.u64(),
})),
(ctx) => {
return Array.from(ctx.db.playerStats.iter())
.sort((a, b) => b.total_kills - a.total_kills)
.slice(0, 100)
.map(stat => {
const player = ctx.db.player.id.find(stat.player_id);
return {
playerId: stat.player_id,
name: player?.name ?? 'Unknown',
score: stat.total_kills,
};
});
}
);
2.5 Subscriptions:实时数据同步引擎
订阅是 SpacetimeDB 最具颠覆性的特性。客户端订阅查询后,SpacetimeDB 立即发送匹配的行,然后每当这些行发生变化时推送更新。
import { DbConnection, tables } from './module_bindings';
const conn = DbConnection.builder()
.withUri('wss://maincloud.spacetimedb.com')
.withDatabaseName('my_game')
.onConnect((ctx) => {
// 订阅玩家和消息数据
ctx.subscriptionBuilder()
.onApplied(() => {
console.log('Subscription ready!');
// 初始数据已在本地缓存中
for (const user of ctx.db.user.iter()) {
console.log(`User: ${user.name}`);
}
})
.subscribe([tables.user, tables.message]);
})
.build();
// 响应新行插入
conn.db.user.onInsert((ctx, user) => {
console.log(`New user joined: ${user.name}`);
});
// 响应行删除
conn.db.user.onDelete((ctx, user) => {
console.log(`User left: ${user.name}`);
});
// 响应行更新
conn.db.user.onUpdate((ctx, oldUser, newUser) => {
console.log(`${oldUser.name} changed name to ${newUser.name}`);
});
订阅优化最佳实践
1. 按生命周期分组订阅
// 永久订阅 - 应用生命周期内不需要取消
const globalSubscriptions = conn
.subscriptionBuilder()
.subscribe([
tables.announcements,
tables.badges,
]);
// 临时订阅 - 玩家升级后可以取消
const shopSubscription = conn
.subscriptionBuilder()
.subscribe([
tables.shopItems.where(r => r.requiredLevel.lte(5)),
]);
2. 先订阅再取消订阅
SpacetimeDB 的订阅是零拷贝的。订阅同一个查询多次不会产生额外处理开销,取消一个被多次订阅的查询也不会触发服务端处理。
// 玩家从 5 级升到 6 级
// 先订阅新的查询范围
const newShopSubscription = conn
.subscriptionBuilder()
.subscribe([
tables.exchangeRates,
tables.shopItems.where(r => r.requiredLevel.lte(6)),
]);
// 再取消旧的订阅
if (shopSubscription.isActive()) {
shopSubscription.unsubscribe();
}
3. 避免重叠查询
// ❌ 不好的做法 - 两个查询有大面积重叠
tables.user
tables.user.where(r => r.id.ne(5))
// 服务端需要处理 User 表的每一行两次
// ✅ 好的做法
tables.user
tables.user.where(r => r.id.eq(5))
// 第二个查询走索引,只序列化一行
三、代码实战:从零构建一个实时聊天应用
让我们用 Rust + TypeScript 完整实现一个实时聊天后端。
3.1 环境安装
# 安装 SpacetimeDB CLI
curl -sSf https://install.spacetimedb.com | sh
# 启动本地 SpacetimeDB 实例
spacetime start
# 服务默认监听 3000 端口
# 登录(通过 GitHub OAuth)
spacetime login
# 初始化新项目
spacetime init --lang rust chat-app
cd chat-app
3.2 定义数据模型
// src/lib.rs
use spacetimedb::{table, Identity, Timestamp};
// 用户表 - 连接时自动创建记录
#[table(name = "user", public = true)]
pub struct User {
#[primary_key]
#[auto_inc]
id: u32,
#[unique]
identity: Identity,
name: String,
online: bool,
created_at: Timestamp,
}
// 频道表
#[table(name = "channel", public = true)]
pub struct Channel {
#[primary_key]
#[auto_inc]
id: u32,
name: String,
created_by: u32,
created_at: Timestamp,
}
// 消息表
#[table(name = "message", public = true)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
channel_id: u32,
sender_id: u32,
content: String,
sent_at: Timestamp,
}
// 频道成员表
#[table(name = "channel_member", public = true)]
pub struct ChannelMember {
#[primary_key]
#[auto_inc]
id: u64,
channel_id: u32,
user_id: u32,
joined_at: Timestamp,
}
3.3 编写 Reducer 业务逻辑
use spacetimedb::{reducer, ReducerContext, Table};
// 生命周期 Reducer: 客户端连接时触发
#[reducer]
fn client_connected(ctx: &ReducerContext) {
// 检查用户是否已存在
let existing_user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender);
if let Some(user) = existing_user {
// 已有用户,标记为在线
let mut user = user;
user.online = true;
ctx.db.user().id().update(user);
} else {
// 新用户,自动注册
ctx.db.user().insert(User {
id: 0, // auto_inc 会自动赋值
identity: ctx.sender,
name: format!("Guest_{}", &ctx.sender.to_hex()[..8]),
online: true,
created_at: ctx.timestamp,
});
}
}
// 生命周期 Reducer: 客户端断开时触发
#[reducer]
fn client_disconnected(ctx: &ReducerContext) {
if let Some(mut user) = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
{
user.online = false;
ctx.db.user().id().update(user);
}
}
// 创建频道
#[reducer]
fn create_channel(ctx: &ReducerContext, name: String) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Channel name cannot be empty".to_string());
}
let user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
.ok_or("User not found")?;
let channel = ctx.db.channel().insert(Channel {
id: 0,
name: name.clone(),
created_by: user.id,
created_at: ctx.timestamp,
});
// 创建者自动加入频道
ctx.db.channel_member().insert(ChannelMember {
id: 0,
channel_id: channel.id,
user_id: user.id,
joined_at: ctx.timestamp,
});
Ok(())
}
// 加入频道
#[reducer]
fn join_channel(ctx: &ReducerContext, channel_id: u32) -> Result<(), String> {
let user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
.ok_or("User not found")?;
// 验证频道存在
let _channel = ctx.db.channel().id().find(channel_id)
.ok_or("Channel not found")?;
// 检查是否已加入
let already_member = ctx.db.channel_member().iter()
.any(|m| m.channel_id == channel_id && m.user_id == user.id);
if already_member {
return Err("Already a member of this channel".to_string());
}
ctx.db.channel_member().insert(ChannelMember {
id: 0,
channel_id,
user_id: user.id,
joined_at: ctx.timestamp,
});
Ok(())
}
// 发送消息
#[reducer]
fn send_message(ctx: &ReducerContext, channel_id: u32, content: String) -> Result<(), String> {
if content.trim().is_empty() {
return Err("Message cannot be empty".to_string());
}
if content.len() > 4096 {
return Err("Message too long".to_string());
}
let user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
.ok_or("User not found")?;
// 验证用户是该频道成员
let is_member = ctx.db.channel_member().iter()
.any(|m| m.channel_id == channel_id && m.user_id == user.id);
if !is_member {
return Err("Not a member of this channel".to_string());
}
ctx.db.message().insert(Message {
id: 0,
channel_id,
sender_id: user.id,
content,
sent_at: ctx.timestamp,
});
Ok(())
}
// 修改用户名
#[reducer]
fn set_username(ctx: &ReducerContext, name: String) -> Result<(), String> {
if name.trim().is_empty() || name.len() > 32 {
return Err("Invalid username".to_string());
}
let mut user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
.ok_or("User not found")?;
user.name = name;
ctx.db.user().id().update(user);
Ok(())
}
3.4 编译与发布
# 编译 Rust 模块为 WASM
spacetime build
# 发布到本地实例
spacetime publish chat-app
# 发布到 SpacetimeDB 云端
spacetime publish chat-app --server maincloud
# 查看 SQL 查询
spacetime sql chat-app "SELECT * FROM user"
spacetime sql chat-app "SELECT * FROM message ORDER BY sent_at DESC LIMIT 10"
# 查看实时日志
spacetime logs --follow chat-app
3.5 客户端连接(TypeScript)
import { DbConnection, tables } from './module_bindings';
class ChatApp {
private conn: DbConnection;
constructor() {
this.conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('chat-app')
.onConnect(this.onConnect.bind(this))
.onDisconnect(this.onDisconnect.bind(this))
.build();
this.setupListeners();
}
private onConnect(ctx: any) {
console.log('Connected to SpacetimeDB');
// 订阅所有公开表
ctx.subscriptionBuilder()
.onApplied(() => {
console.log('Data synced!');
this.renderChannels();
this.renderMessages();
})
.subscribe([
tables.user,
tables.channel,
tables.channelMember,
tables.message,
]);
}
private onDisconnect() {
console.log('Disconnected');
}
private setupListeners() {
// 监听新消息
this.conn.db.message.onInsert((ctx, msg) => {
const sender = this.conn.db.user.id.find(msg.senderId);
console.log(`[${this.getChannelName(msg.channelId)}] ${sender?.name}: ${msg.content}`);
});
// 监听用户上线
this.conn.db.user.onUpdate((ctx, oldUser, newUser) => {
if (!oldUser.online && newUser.online) {
console.log(`${newUser.name} came online`);
}
if (oldUser.online && !newUser.online) {
console.log(`${newUser.name} went offline`);
}
});
// 监听新频道
this.conn.db.channel.onInsert((ctx, channel) => {
console.log(`New channel created: ${channel.name}`);
});
}
// 调用 Reducer: 发送消息
public sendMessage(channelId: number, content: string) {
this.conn.reducers.sendMessage(channelId, content);
}
// 调用 Reducer: 创建频道
public createChannel(name: string) {
this.conn.reducers.createChannel(name);
}
// 调用 Reducer: 加入频道
public joinChannel(channelId: number) {
this.conn.reducers.joinChannel(channelId);
}
// 调用 Reducer: 设置用户名
public setUsername(name: string) {
this.conn.reducers.setUsername(name);
}
private getChannelName(channelId: number): string {
const channel = this.conn.db.channel.id.find(channelId);
return channel?.name ?? 'unknown';
}
private renderChannels() { /* ... */ }
private renderMessages() { /* ... */ }
}
// 启动
const app = new ChatApp();
四、深度架构分析
4.1 WASM 模块运行时
SpacetimeDB 的模块执行环境选择很精妙。Rust、C#、C++ 模块编译为 WebAssembly(WASM),TypeScript 模块运行在 V8 引擎上。
选择 WASM 的好处:
沙箱安全:WASM 默认无法访问文件系统、网络或宿主环境。这与 Reducer 的隔离要求完美匹配——WASM 模块根本"不可能"执行系统调用。
确定性执行:WASM 的执行是确定性的(给定相同输入产生相同输出),这对于数据库事务的串行化恢复至关重要。如果检测到可串行化异常,SpacetimeDB 可以用相同参数重新执行 Reducer。
热更新:SpacetimeDB 可以在不断开客户端连接的情况下热替换模块代码。这是因为所有状态都在表中,不在模块内存中。新模块加载后,表结构和数据保持不变,只是逻辑代码被替换了。
跨语言支持:WASM 是一个通用编译目标,Rust、C#、C++ 都可以编译到 WASM,SpacetimeDB 不需要为每种语言实现单独的运行时。
4.2 内存优先存储引擎
SpacetimeDB 将所有数据保存在内存中,同时自动持久化到磁盘。这个设计决策的核心理念是:
持久化保证只会增加延迟,永远不会降低吞吐量。
现代 SSD 可以以 15 GB/s 的速度写入数据,DRAM 大约只快 4 倍。SpacetimeDB 充分利用了这个带宽差异——所有写入先到内存(纳秒级),异步刷盘到 SSD(微秒级),吞吐量不受磁盘 I/O 限制。
SpacetimeDB 还保存了所有行的完整变更历史。你可能会问:这不会占用太多存储吗?官方给了一个有趣的计算:
更新 100 万个玩家位置数据,每秒 10 次,持续一年,大约使用 10 PB 未压缩数据。SpacetimeDB 可以压缩 5-10 倍,意味着 100 万并发玩家的游戏,保存一年所有位置数据只需 1-2 PB。在 Amazon S3 上每月成本约 $2,300-$5,600——比一个工程师的薪水还低。
4.3 订阅引擎:零拷贝数据同步
SpacetimeDB 的订阅引擎是其性能优势的关键。当你订阅一个查询时:
- SpacetimeDB 解析查询,确定需要监听哪些表的变更
- 初始匹配的行被序列化并发送到客户端
- 客户端在本地维护一个缓存副本
- 当表数据变更时(通过 Reducer),订阅引擎检查变更是否影响该订阅
- 如果影响,只发送变更的 diff(增量更新),而不是重新发送整个结果集
关键优化:订阅是零拷贝的。订阅同一个查询多次不会产生额外处理开销,取消一个被多次订阅的查询也不会触发服务端处理。这使得你可以安全地在多个组件中订阅同一数据而无需担心性能。
4.4 事务模型:Reducer 的 ACID 保证
客户端调用 Reducer
│
▼
┌─────────────────┐
│ 开始事务 │
│ (获取写锁) │
├─────────────────┤
│ 执行 Reducer │
│ 函数体 │
│ (读取/修改表) │
├─────────────────┤
│ 成功? │
│ ├─ 是 → 提交 │
│ │ ├─ 持久化到磁盘
│ │ ├─ 通知订阅引擎
│ │ └─ 推送更新到客户端
│ └─ 否 → 回滚 │
│ └─ 丢弃所有变更
└─────────────────┘
每个 Reducer 都是一个 ACID 事务。如果 Reducer 抛出异常或返回错误,所有变更自动回滚。这意味着你可以大胆地编写业务逻辑——尝试修改数据,如果中途失败,数据库保持一致。
对于 Procedures,事务模型稍有不同。Procedures 不自动运行在事务中,需要手动通过 ctx.withTx() 开启事务。这允许 Procedures 在 HTTP 请求期间不持有锁,只在真正需要修改数据时才开启事务。
4.5 三种函数类型的对比
| 特性 | Reducers | Procedures | Views |
|---|---|---|---|
| 读取表 | ✅ | ✅ (手动事务) | ✅ |
| 写入表 | ✅ | ✅ (手动事务) | ❌ |
| 自动事务 | ✅ | ❌ (手动) | ✅ (只读) |
| 原子性 | ✅ | 手动控制 | ✅ |
| 确定性 | ✅ | ❌ | ✅ |
| 外部 I/O | ❌ | ✅ (HTTP) | ❌ |
| 副作用 | ❌ | ✅ | ❌ |
| 可调度 | ✅ | ✅ (通过 Schedule Table) | ❌ |
| 可订阅 | ❌ | ❌ | ✅ |
五、生产环境部署指南
5.1 自托管部署
# Docker 部署
docker run -d \
--name spacetimedb \
-p 3000:3000 \
-v spacetimedb_data:/data \
clockworklabs/spacetimedb:latest \
spacetime start --data-dir /data --listen-addr 0.0.0.0:3000
# 或直接下载二进制
curl -sSf https://install.spacetimedb.com | sh
spacetime start --listen-addr 0.0.0.0:3000
5.2 生产环境架构建议
┌─────────────────┐
│ Load Balancer │
│ (Nginx / HAProxy)│
└────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌────────▼───┐ ┌───────▼────┐ ┌──────▼─────┐
│ SpacetimeDB │ │ SpacetimeDB│ │ SpacetimeDB│
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ (Primary) │ │ (Replica) │ │ (Replica) │
└─────────────┘ └───────────┘ └───────────┘
│
┌────────▼───┐
│ SSD Storage│
│ (NVMe) │
└────────────┘
5.3 监控与运维
# 查看数据库列表
spacetime list
# 查看 SQL(检查数据)
spacetime sql my-app "SELECT COUNT(*) FROM user"
spacetime sql my-app "SELECT * FROM message ORDER BY sent_at DESC LIMIT 5"
# 以匿名用户身份查询(测试权限)
spacetime sql --anonymous my-app "SELECT * FROM user"
# 实时日志
spacetime logs --follow my-app
# 查看最近 100 行日志
spacetime logs --num-lines 100 my-app
# 删除数据库(危险操作!)
spacetime delete my-app --yes
5.4 Schema 演进与迁移
SpacetimeDB 支持自动 Schema 迁移。当你重新发布模块时:
安全变更(自动迁移):
- 添加新表
- 添加新列(带默认值)
- 添加索引
- 删除索引
破坏性变更(需要增量迁移):
- 删除表
- 删除列
- 修改列类型
- 修改约束
对于复杂的 Schema 变更,可以编写迁移 Reducer:
// 迁移 Reducer: 在新列上填充数据
#[reducer]
fn migrate_fill_display_name(ctx: &ReducerContext) {
// 为每个没有 display_name 的用户填充默认值
for user in ctx.db.user().iter() {
if user.display_name.is_empty() {
let mut user = user;
user.display_name = user.name.clone();
ctx.db.user().id().update(user);
}
}
}
5.5 安全注意事项
表可见性:默认表是私有的,只有 Reducer 和数据库所有者可以访问。设置为 public: true 才允许客户端通过订阅读取。写入只能通过 Reducer。
身份认证:SpacetimeDB 使用 Identity(加密身份)来标识客户端。每个连接的客户端都有一个唯一的 Identity,可以通过 ctx.sender 在 Reducer 中获取。
权限控制:通过 View 函数可以实现细粒度的访问控制。你可以只暴露过滤后的数据子集:
// 只返回当前用户自己的消息
export const my_messages = spacetimedb.view(
t.array(t.struct({
id: t.u64(),
content: t.string(),
sentAt: t.timestamp(),
})),
(ctx) => {
return Array.from(ctx.db.message.iter())
.filter(m => m.sender_id === ctx.sender)
.map(m => ({
id: m.id,
content: m.content,
sentAt: m.sent_at,
}));
}
);
5.6 调度任务:Schedule Tables
SpacetimeDB 的 Schedule Tables 允许你定时触发 Reducer,适用于定时任务、过期检查、周期性维护等场景:
use spacetimedb::{table, ScheduleAt};
// 定义调度表
#[table(name = "reminder", scheduled = "send_reminder")]
pub struct Reminder {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
#[scheduled_at]
at: ScheduleAt,
user_id: u32,
message: String,
}
// 被调度的 Reducer
#[reducer]
fn send_reminder(ctx: &ReducerContext, reminder: &Reminder) {
// 查找用户并推送提醒
if let Some(user) = ctx.db.user().id().find(reminder.user_id) {
ctx.db.notification().insert(Notification {
id: 0,
user_id: user.id,
content: reminder.message.clone(),
created_at: ctx.timestamp,
});
}
// Reducer 执行完毕后,该行自动从 reminder 表中删除
}
// 在另一个 Reducer 中设置定时提醒
#[reducer]
fn schedule_reminder(ctx: &ReducerContext, user_id: u32, message: String, delay_seconds: u64) {
ctx.db.reminder().insert(Reminder {
scheduled_id: 0,
at: ScheduleAt::Time(ctx.timestamp.add_seconds(delay_seconds as i64)),
user_id,
message,
});
}
六、适用场景与局限性
6.1 SpacetimeDB 的理想场景
实时多人游戏:这是 SpacetimeDB 的原生场景。游戏需要高频状态更新、低延迟、实时同步——SpacetimeDB 的所有设计都为此优化。官方的 Blackholio Demo 就是一个完整的多人游戏示例。
实时协作应用:协作文档、白板、项目管理工具。所有参与者需要看到彼此的实时变更,订阅引擎天然支持这种模式。
聊天与社交应用:Discord 类的实时聊天应用。消息推送、在线状态、频道管理——全都可以用 Table + Reducer + Subscription 三件套实现。
AI Agent 后端:SpacetimeDB 官方明确将 AI Agent 作为重点方向。LLM 可以直接生成 Reducer 代码,不需要理解复杂的后端架构。"让 AI 发布整个后端"——这是 SpacetimeDB 的愿景。官网甚至直接提供了让 Claude 构建 Discord 克隆的命令行示例。
6.2 不适合的场景
重度分析型工作负载:虽然 SpacetimeDB 支持 SQL 查询,但它不是为 OLAP 场景设计的。如果你需要复杂的聚合分析、大规模数据仓库查询,DuckDB 或 ClickHouse 仍然是更好的选择。
需要复杂外部集成的系统:如果你的应用需要频繁调用大量外部 API、消息队列、流处理引擎,Procedures 的 HTTP 请求能力可能不够用。传统微服务架构在这些场景下更灵活。
已有大量遗留代码的系统:如果你的后端已经有大量微服务和基础设施,迁移到 SpacetimeDB 的成本可能超过收益。
6.3 与竞品对比
| 特性 | SpacetimeDB | Supabase | Firebase | Convex |
|---|---|---|---|---|
| 实时同步 | ✅ 原生 | 需要 Realtime | ✅ 原生 | ✅ 原生 |
| 服务端逻辑位置 | 数据库内 (WASM) | Edge Functions | Cloud Functions | 数据库内 (JS) |
| 数据存储 | 内存 + 磁盘 | PostgreSQL | Firestore | 内存 + 磁盘 |
| 语言支持 | Rust/C#/TS/C++ | JS/TS | JS/Python | JS/TS |
| 自托管 | ✅ | ✅ | ❌ | ❌ |
| 开源 | ✅ (BSL) | ✅ | ❌ | ❌ |
| 性能(TPS) | ~235K+ | ~10K | ~5K | ~50K |
| 持久化历史 | ✅ 全量 | ❌ | ❌ | ✅ |
七、性能优化进阶
7.1 索引策略
SpacetimeDB 支持 B-Tree 索引,用于加速查询和订阅过滤。
// Rust: 通过 #[index] 属性添加索引
#[table(name = "message", public = true)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
#[index]
channel_id: u32, // B-Tree 索引
#[index]
sender_id: u32, // B-Tree 索引
content: String,
sent_at: Timestamp,
}
// TypeScript: 通过 .index() 方法添加索引
const message = table(
{ name: 'message', public: true },
{
id: t.u64().primaryKey().autoInc(),
channelId: t.u32().index('btree'),
senderId: t.u32().index('btree'),
content: t.string(),
sentAt: t.timestamp(),
}
);
索引选择建议:
- 主键:自动索引,用于唯一标识和快速查找
- 唯一约束:自动索引,用于保证值唯一性
- 外键列:手动添加索引,用于加速 JOIN 操作
- 频繁过滤的列:如果经常在
where子句中过滤某列,添加索引
7.2 数据分区策略
对于大规模应用,合理的表拆分可以显著提升性能:
// ❌ 不好的设计:一张大表存所有数据
#[table(name = "event_log", public = true)]
pub struct EventLog {
id: u64,
event_type: String, // 100+ 种事件类型
data: String,
created_at: Timestamp,
}
// ✅ 好的设计:按事件类型分表
#[table(name = "player_action_log", public = true)]
pub struct PlayerActionLog {
#[primary_key]
#[auto_inc]
id: u64,
#[index]
player_id: u32,
action: String,
created_at: Timestamp,
}
#[table(name = "system_event_log", public = true)]
pub struct SystemEventLog {
#[primary_key]
#[auto_inc]
id: u64,
#[index]
severity: u8,
event: String,
created_at: Timestamp,
}
7.3 批量操作优化
Reducer 内的批量操作比多次单独调用 Reducer 高效得多:
// ❌ 不好的做法:客户端循环调用单条消息发送
// for (let msg of messages) {
// conn.reducers.sendMessage(channelId, msg);
// }
// ✅ 好的做法:批量发送
#[reducer]
fn send_batch_messages(
ctx: &ReducerContext,
channel_id: u32,
messages: Vec<String>,
) -> Result<(), String> {
let user = ctx.db.user().iter()
.find(|u| u.identity == ctx.sender)
.ok_or("User not found")?;
let is_member = ctx.db.channel_member().iter()
.any(|m| m.channel_id == channel_id && m.user_id == user.id);
if !is_member {
return Err("Not a member".to_string());
}
for content in messages {
if content.len() > 4096 {
continue; // 跳过过长的消息
}
ctx.db.message().insert(Message {
id: 0,
channel_id,
sender_id: user.id,
content,
sent_at: ctx.timestamp,
});
}
Ok(())
}
八、总结与展望
SpacetimeDB 代表了一种激进的后端架构范式转变。它不是在现有技术栈上做增量改进,而是从根本上重新思考了"后端"应该是什么样子。
核心价值主张:将数据库、服务器、缓存、实时同步层合而为一,用一种统一的数据模型(表)和一种统一的编程模型(Reducer)来替代整个后端技术栈。
对开发者的意义:
开发效率:不再需要搭建微服务、配置 ORM、管理缓存层、编写 WebSocket 代码。定义表 + 写 Reducer = 完整后端。
性能天花板:内存优先存储 + 进程内逻辑执行,性能远超传统架构。对于需要极低延迟的实时应用,这是质的飞跃。
AI 协作友好:LLM 理解"表 + 函数"的模型远比理解"微服务 + 消息队列 + API 网关 + ORM"的模型容易。SpacetimeDB 天然适配 AI 辅助开发。
风险与挑战:
生态成熟度:相比 PostgreSQL、Redis 等老牌基础设施,SpacetimeDB 的生态还很年轻。社区、工具链、运维经验都在早期积累阶段。
运维复杂性:虽然开发简化了,但运维一个内存数据库 + WASM 运行时的新基础设施,需要团队学习新的技能。
许可证:SpacetimeDB 使用 BSL(Business Source License),不是传统的开源协议。商业使用需要注意条款。
数据迁移:现有系统的数据迁移到 SpacetimeDB 没有现成工具链,需要自定义迁移脚本。
展望:随着 AI Agent 的爆发,后端架构正在从"给人写 API"向"给 AI 写 API"转变。SpacetimeDB 的极简模型——表 + Reducer + 订阅——恰好是 LLM 最容易理解和生成的后端范式。如果这个趋势持续,SpacetimeDB 可能成为 AI 原生应用的首选后端基础设施。
对于正在评估新后端架构的团队,SpacetimeDB 值得在以下几个场景做 POC 验证:
- 新的实时应用(聊天、游戏、协作工具)
- AI Agent 的后端服务
- 需要极高性能的事务处理系统
但如果你有稳定的传统技术栈,且没有明确的痛点,不必急于迁移。技术选型永远是"合适"比"先进"更重要。
本文基于 SpacetimeDB 官方文档和 GitHub Trending 2026 年 6 月数据撰写。SpacetimeDB 项目地址:https://github.com/clockworklabs/SpacetimeDB