SpacetimeDB 深度实战:当数据库吞噬了服务器——从「零基础设施」架构到百万级实时同步的生产级完全指南(2026)
一、为什么需要 SpacetimeDB?——传统三层架构的终结
1.1 我们习以为常的痛苦
每个全栈开发者都经历过这样的部署架构:
客户端 (React/Vue/Unity)
↓ HTTP/WebSocket
应用服务器 (Node.js/Go/Java)
↓ SQL/ORM
数据库 (PostgreSQL/MySQL)
↓
缓存层 (Redis)
↓
消息队列 (Kafka/RabbitMQ)
这带来了一系列不可避免的问题:
- 延迟叠加:客户端 → 服务器 → 数据库 → 服务器 → 客户端,一次数据操作至少两跳网络
- 状态同步地狱:服务器要维护 WebSocket 连接,手动推送数据变更给客户端,写一堆
io.emit() - 基础设施爆炸:K8s、Docker Compose、CI/CD 流水线、健康检查、服务发现……你不是在写业务,你是在运维基础设施
- 缓存一致性:Redis 缓存和数据库不一致,又要引入 Cache Invalidation 策略
- 扩缩容复杂度:服务器要管理连接池、数据库要管理会话、缓存要管理淘汰策略
SpacetimeDB 的核心洞察极其简单:如果数据库本身就是服务器呢?
1.2 SpacetimeDB 的架构革命
客户端 (React/Vue/Unity/Unreal)
↓ 直接连接
SpacetimeDB(数据库 + 应用逻辑 + 实时推送)
没有中间层。客户端直接连接数据库,执行应用逻辑,自动接收实时更新。这不是"把业务逻辑塞进存储过程"——这是从根本上重新思考后端架构。
SpacetimeDB 的核心设计原则:
- 数据库即服务器:应用逻辑以模块(Module)形式上传到数据库内运行
- 零基础设施:无需单独的 Web 服务器、无需容器编排、无需 DevOps
- 实时同步原生支持:数据变更自动推送给订阅的客户端,无需手动实现 WebSocket
- ACID 保证:完整的传统 RDBMS 事务语义
- 全内存 + 提交日志:所有状态驻留内存保证速度,磁盘提交日志保证持久性
1.3 谁在用?BitCraft Online 的验证
Clockwork Labs 自家的 MMORPG BitCraft Online 整个后端运行在单个 SpacetimeDB 模块上——聊天、物品、地形、玩家位置,全部实时同步给数千玩家。如果 SpacetimeDB 能扛住 MMO 的实时负载,那对绝大多数 Web 应用来说绰绰有余。
二、核心概念全景图
2.1 Tables(表)——你的数据模型
SpacetimeDB 的表不是普通的数据库表。它是 自动同步的数据结构——定义一次,客户端自动获得增删改的实时通知。
// Rust 模块中定义表
#[spacetimedb::table(accessor = users, public)]
pub struct User {
#[primary_key]
identity: Identity, // 内置身份类型,关联客户端认证
username: String,
score: u64,
created_at: Timestamp,
}
#[spacetimedb::table(accessor = messages, public)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
sender: Identity,
room_id: u64,
content: String,
sent_at: Timestamp,
}
// 私有表:只有模块内部逻辑可以访问
#[spacetimedb::table(accessor = admin_logs)]
pub struct AdminLog {
#[primary_key]
#[auto_inc]
id: u64,
admin: Identity,
action: String,
target: String,
timestamp: Timestamp,
}
关键设计点:
publicvs 私有:public表允许客户端订阅并接收实时更新;私有表只有模块内的 reducer 可以读写Identity类型:SpacetimeDB 原生的身份认证机制,每个连接都有唯一 IdentityTimestamp类型:数据库生成的时间戳,保证全局一致#[auto_inc]:自增主键,和传统数据库一致
2.2 Reducers(归约器)——你的 API 端点
Reducer 是 SpacetimeDB 中唯一的"写入入口"。客户端不能直接写表,只能调用 Reducer,由 Reducer 执行业务逻辑和权限校验。
#[spacetimedb::reducer]
pub fn send_message(ctx: &ReducerContext, room_id: u64, content: String) {
// 1. 权限校验
if content.trim().is_empty() {
return; // 静默拒绝空消息
}
if content.len() > 1000 {
return; // 消息过长
}
// 2. 检查用户是否在房间中
let sender_identity = ctx.sender;
let membership = ctx.db.room_members().iter()
.find(|m| m.identity == sender_identity && m.room_id == room_id);
if membership.is_none() {
return; // 不在房间中,无权发言
}
// 3. 写入消息
ctx.db.messages().insert(Message {
id: 0, // auto_inc 会自动分配
sender: sender_identity,
room_id,
content,
sent_at: ctx.timestamp,
});
}
#[spacetimedb::reducer]
pub fn join_room(ctx: &ReducerContext, room_id: u64) {
// 幂等操作:已存在则跳过
let existing = ctx.db.room_members().iter()
.find(|m| m.identity == ctx.sender && m.room_id == room_id);
if existing.is_some() {
return;
}
ctx.db.room_members().insert(RoomMember {
identity: ctx.sender,
room_id,
joined_at: ctx.timestamp,
});
// 系统消息
ctx.db.messages().insert(Message {
id: 0,
sender: ctx.sender,
room_id,
content: format!("用户加入了房间"),
sent_at: ctx.timestamp,
});
}
ReducerContext 是 Reducer 的核心上下文对象:
| 字段 | 说明 |
|---|---|
ctx.sender | 调用者的 Identity |
ctx.timestamp | 服务器生成的时间戳 |
ctx.db | 访问所有表的入口 |
ctx.args | 客户端传入的参数 |
关键特性:Reducer 是事务性的。一个 Reducer 要么完全成功,要么完全回滚。不存在"写了一半"的中间状态。
2.3 Subscriptions(订阅)——实时数据推送的灵魂
这是 SpacetimeDB 最强大的特性。客户端不是"请求数据",而是"订阅查询"——数据库主动推送变更。
// 在模块中定义订阅规则
#[spacetimedb::client_subscription_filter]
pub fn chat_filter(ctx: &SubscriptionContext) -> Vec<Query> {
// 客户端只能订阅自己所在房间的消息
let my_rooms: Vec<u64> = ctx.db.room_members().iter()
.filter(|m| m.identity == ctx.sender)
.map(|m| m.room_id)
.collect();
my_rooms.into_iter()
.map(|room_id| {
Query::new(format!("SELECT * FROM messages WHERE room_id = {}", room_id))
})
.collect()
}
客户端侧(TypeScript/React):
// 订阅表 — 自动接收实时更新
const [messages] = useTable(tables.message);
// 订阅带过滤条件
const [roomMembers] = useSubscription(
`SELECT * FROM room_member WHERE room_id = ${roomId}`
);
// 调用 Reducer
const sendMessage = (text: string) => {
connection.reducers.send_message(roomId, text);
};
数据流是单向的:
客户端调用 Reducer → 数据库执行逻辑 → 表数据变更 → 自动推送订阅者
没有轮询,没有手动 WebSocket,没有 useEffect 里写 fetch。数据变了,UI 自动更新。
2.4 生命周期钩子
SpacetimeDB 提供了特殊的 Reducer 钩子来处理连接生命周期:
#[spacetimedb::reducer]
pub fn __identity_connected(ctx: &ReducerContext) {
// 新客户端连接时触发
log::info!("用户连接: {:?}", ctx.sender);
ctx.db.online_users().insert(OnlineUser {
identity: ctx.sender,
connected_at: ctx.timestamp,
});
}
#[spacetimedb::reducer]
pub fn __identity_disconnected(ctx: &ReducerContext) {
// 客户端断开时触发
log::info!("用户断开: {:?}", ctx.sender);
// 清理在线状态
ctx.db.online_users().delete().identity(&ctx.sender);
// 通知其他用户
ctx.db.system_messages().insert(SystemMessage {
id: 0,
content: format!("用户已离线"),
timestamp: ctx.timestamp,
});
}
三、从零搭建:实时协作白板应用
3.1 环境准备
# macOS / Linux 安装
curl -sSf https://install.spacetimedb.com | sh
# 验证
spacetime --version
# 登录(GitHub OAuth)
spacetime login
3.2 初始化项目
# 使用 Rust 模板创建项目
spacetime init --lang rust whiteboard
cd whiteboard
项目结构:
whiteboard/
├── src/
│ └── lib.rs # 模块主文件
├── Cargo.toml
└── spacetime.toml # SpacetimeDB 配置
3.3 定义数据模型
// src/lib.rs
use spacetimedb::{table, reducer, Identity, Timestamp, ReducerContext};
/// 画布上的元素
#[table(accessor = elements, public)]
pub struct Element {
#[primary_key]
id: u64,
canvas_id: u64,
element_type: String, // "rect" | "circle" | "line" | "text"
x: f64,
y: f64,
width: f64,
height: f64,
fill_color: String,
stroke_color: String,
stroke_width: f64,
text_content: String,
created_by: Identity,
updated_at: Timestamp,
}
/// 画布
#[table(accessor = canvases, public)]
pub struct Canvas {
#[primary_key]
id: u64,
name: String,
owner: Identity,
is_public: bool,
created_at: Timestamp,
}
/// 画布成员
#[table(accessor = canvas_members, public)]
pub struct CanvasMember {
#[primary_key]
#[unique]
canvas_id: u64,
identity: Identity,
role: String, // "owner" | "editor" | "viewer"
}
/// 在线用户光标位置
#[table(accessor = cursors, public)]
pub struct Cursor {
#[primary_key]
identity: Identity,
canvas_id: u64,
x: f64,
y: f64,
color: String,
updated_at: Timestamp,
}
/// 操作日志(私有表,用于审计)
#[table(accessor = operation_logs)]
pub struct OperationLog {
#[primary_key]
#[auto_inc]
id: u64,
canvas_id: u64,
operator: Identity,
operation: String, // "create" | "update" | "delete"
element_id: u64,
detail: String,
timestamp: Timestamp,
}
3.4 实现核心 Reducer
#[reducer]
pub fn create_canvas(ctx: &ReducerContext, name: String, is_public: bool) {
let canvas_id = ctx.db.canvases().iter().count() as u64 + 1;
ctx.db.canvases().insert(Canvas {
id: canvas_id,
name,
owner: ctx.sender,
is_public,
created_at: ctx.timestamp,
});
// 创建者自动成为 owner
ctx.db.canvas_members().insert(CanvasMember {
canvas_id,
identity: ctx.sender,
role: "owner".to_string(),
});
}
#[reducer]
pub fn add_element(
ctx: &ReducerContext,
canvas_id: u64,
element_type: String,
x: f64,
y: f64,
width: f64,
height: f64,
fill_color: String,
) {
// 权限检查
if !can_edit(ctx, canvas_id, ctx.sender) {
log::warn!("无权编辑画布: canvas_id={}", canvas_id);
return;
}
let id = ctx.db.elements().iter().count() as u64 + 1;
ctx.db.elements().insert(Element {
id,
canvas_id,
element_type,
x,
y,
width,
height,
fill_color,
stroke_color: "#000000".to_string(),
stroke_width: 1.0,
text_content: String::new(),
created_by: ctx.sender,
updated_at: ctx.timestamp,
});
// 记录操作日志
log_operation(ctx, canvas_id, "create", id, "创建元素");
}
#[reducer]
pub fn move_element(ctx: &ReducerContext, element_id: u64, new_x: f64, new_y: f64) {
// 查找元素
let element = match ctx.db.elements().id().find(&element_id) {
Some(e) => e,
None => return,
};
// 权限检查
if !can_edit(ctx, element.canvas_id, ctx.sender) {
return;
}
// 更新位置
ctx.db.elements().id().update(Element {
x: new_x,
y: new_y,
updated_at: ctx.timestamp,
..element
});
log_operation(ctx, element.canvas_id, "update", element_id, "移动元素");
}
#[reducer]
pub fn update_cursor(ctx: &ReducerContext, canvas_id: u64, x: f64, y: f64) {
let color = get_user_color(ctx, ctx.sender);
// Upsert 光标位置
match ctx.db.cursors().identity().find(&ctx.sender) {
Some(cursor) => {
ctx.db.cursors().identity().update(Cursor {
canvas_id,
x,
y,
color,
updated_at: ctx.timestamp,
..cursor
});
}
None => {
ctx.db.cursors().insert(Cursor {
identity: ctx.sender,
canvas_id,
x,
y,
color,
updated_at: ctx.timestamp,
});
}
}
}
#[reducer]
pub fn delete_element(ctx: &ReducerContext, element_id: u64) {
let element = match ctx.db.elements().id().find(&element_id) {
Some(e) => e,
None => return,
};
// 只有创建者或画布 owner 可以删除
let is_owner = ctx.db.canvas_members().iter()
.any(|m| m.canvas_id == element.canvas_id
&& m.identity == ctx.sender
&& m.role == "owner");
if element.created_by != ctx.sender && !is_owner {
return;
}
ctx.db.elements().id().delete(&element_id);
log_operation(ctx, element.canvas_id, "delete", element_id, "删除元素");
}
// ---- 辅助函数 ----
fn can_edit(ctx: &ReducerContext, canvas_id: u64, identity: Identity) -> bool {
// 检查是否是成员且有编辑权限
ctx.db.canvas_members().iter()
.any(|m| m.canvas_id == canvas_id
&& m.identity == identity
&& (m.role == "owner" || m.role == "editor"))
// 或者画布是公开的
|| ctx.db.canvases().id().find(&canvas_id)
.map(|c| c.is_public)
.unwrap_or(false)
}
fn get_user_color(ctx: &ReducerContext, identity: Identity) -> String {
// 基于 Identity 生成稳定的颜色
let hash = format!("{:?}", identity).as_bytes()
.iter()
.fold(0u32, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u32));
let hue = hash % 360;
format!("hsl({}, 70%, 50%)", hue)
}
fn log_operation(ctx: &ReducerContext, canvas_id: u64, op: &str, element_id: u64, detail: &str) {
ctx.db.operation_logs().insert(OperationLog {
id: 0,
canvas_id,
operator: ctx.sender,
operation: op.to_string(),
element_id,
detail: detail.to_string(),
timestamp: ctx.timestamp,
});
}
3.5 连接生命周期
#[reducer]
pub fn __identity_connected(ctx: &ReducerContext) {
log::info!("用户连接: {:?}", ctx.sender);
}
#[reducer]
pub fn __identity_disconnected(ctx: &ReducerContext) {
// 清理光标
ctx.db.cursors().identity().delete(&ctx.sender);
log::info!("用户断开: {:?}", ctx.sender);
}
3.6 前端接入(React + TypeScript)
# 创建前端项目
npm create vite@latest whiteboard-client -- --template react-ts
cd whiteboard-client
npm install @clockworklabs/spacetimedb-sdk
// src/lib/spacetimedb.ts
import { SpacetimeDBClient } from '@clockworklabs/spacetimedb-sdk';
const MODULE_NAME = 'whiteboard';
const HOST = 'ws://localhost:3000';
export const client = new SpacetimeDBClient(HOST, MODULE_NAME);
// 导出表类型
export const tables = {
elements: client.db.element,
canvases: client.db.canvas,
canvasMembers: client.db.canvasMember,
cursors: client.db.cursor,
};
// 连接
export async function connect() {
await client.connect();
// 订阅所有公开画布和自己的私有画布
client.subscribe([
'SELECT * FROM canvas WHERE is_public = true',
'SELECT * FROM canvas_member WHERE identity = :sender',
'SELECT * FROM element WHERE canvas_id IN (SELECT id FROM canvas WHERE is_public = true)',
'SELECT * FROM cursor',
]);
}
// src/hooks/useWhiteboard.ts
import { useState, useCallback, useEffect } from 'react';
import { client, tables } from '../lib/spacetimedb';
export function useWhiteboard(canvasId: number) {
// 实时元素列表 — 自动同步
const elements = tables.elements.useQuery({
filter: (e) => e.canvasId === canvasId,
});
// 实时光标
const cursors = tables.cursors.useQuery({
filter: (c) => c.canvasId === canvasId,
});
// 添加元素
const addRect = useCallback((x: number, y: number) => {
client.reducers.addElement(
canvasId,
'rect',
x, y,
100, 60, // 默认宽高
'#4A90D9', // 默认填充色
);
}, [canvasId]);
// 移动元素
const moveElement = useCallback((elementId: number, x: number, y: number) => {
client.reducers.moveElement(elementId, x, y);
}, []);
// 更新光标
const updateCursor = useCallback((x: number, y: number) => {
client.reducers.updateCursor(canvasId, x, y);
}, [canvasId]);
// 删除元素
const deleteElement = useCallback((elementId: number) => {
client.reducers.deleteElement(elementId);
}, []);
return {
elements,
cursors,
addRect,
moveElement,
updateCursor,
deleteElement,
};
}
// src/components/Canvas.tsx
import { useRef, useEffect } from 'react';
import { useWhiteboard } from '../hooks/useWhiteboard';
export function Canvas({ canvasId }: { canvasId: number }) {
const { elements, cursors, addRect, moveElement, updateCursor, deleteElement } = useWhiteboard(canvasId);
const canvasRef = useRef<HTMLCanvasElement>(null);
const draggingRef = useRef<number | null>(null);
// 绘制
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制元素
elements.forEach((el) => {
ctx.fillStyle = el.fillColor;
ctx.strokeStyle = el.strokeColor;
ctx.lineWidth = el.strokeWidth;
switch (el.elementType) {
case 'rect':
ctx.fillRect(el.x, el.y, el.width, el.height);
ctx.strokeRect(el.x, el.y, el.width, el.height);
break;
case 'circle':
ctx.beginPath();
ctx.ellipse(el.x + el.width / 2, el.y + el.height / 2, el.width / 2, el.height / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
break;
case 'text':
ctx.font = '16px sans-serif';
ctx.fillText(el.textContent, el.x, el.y);
break;
}
});
// 绘制其他用户光标
cursors.forEach((cursor) => {
if (cursor.identity === client.identity) return;
ctx.fillStyle = cursor.color;
ctx.beginPath();
ctx.arc(cursor.x, cursor.y, 6, 0, Math.PI * 2);
ctx.fill();
});
}, [elements, cursors]);
const handleMouseMove = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
updateCursor(x, y);
if (draggingRef.current !== null) {
moveElement(draggingRef.current, x, y);
}
};
const handleDoubleClick = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
addRect(e.clientX - rect.left, e.clientY - rect.top);
};
return (
<canvas
ref={canvasRef}
width={1200}
height={800}
onMouseMove={handleMouseMove}
onDoubleClick={handleDoubleClick}
style={{ border: '1px solid #ccc' }}
/>
);
}
注意看——没有一行 fetch、没有 useEffect 做轮询、没有手动 WebSocket 管理。数据变更自动反映到 UI,这就是 SpacetimeDB 的威力。
四、架构深度解析:SpacetimeDB 内部是怎么工作的
4.1 全内存架构 + 提交日志
SpacetimeDB 将所有活跃状态保存在内存中,这意味着:
- 读取零磁盘 I/O:所有查询直接从内存返回
- 写入先内存后落盘:写入先更新内存中的数据结构,然后追加到提交日志(Commit Log)
- 崩溃恢复:重启时回放提交日志重建内存状态
写入流程:
Client → Reducer调用
→ 内存中执行事务(ACID)
→ 事务提交成功
→ 追加到 Commit Log
→ 推送变更给订阅者
读取流程:
Client → 订阅查询
→ 直接从内存读取
→ 返回当前快照
→ 后续变更通过订阅推送
这和传统数据库的 Buffer Pool 机制有本质区别——传统数据库的数据页可能不在内存中,需要磁盘 I/O 换入。SpacetimeDB 是纯内存,不存在这个路径。
4.2 WASM 模块执行引擎
你写的 Rust/C#/TS 模块被编译成 WASM,在 SpacetimeDB 内部的 WASM 运行时中执行:
Rust 源码 → wasm32-unknown-unknown 目标 → WASM 字节码 → SpacetimeDB WASM 运行时
为什么选择 WASM?
- 沙箱隔离:用户模块无法访问宿主系统的文件系统、网络等资源
- 语言无关:Rust、C#、TypeScript、C++ 都可以编译到 WASM
- 接近原生性能:WASM 的执行效率接近原生代码,远超解释型语言
- 安全验证:WASM 模块在加载前可以进行静态验证,确保不会出现未定义行为
4.3 订阅推送引擎
SpacetimeDB 的订阅推送是整个系统最精妙的部分。它不是简单的"表变更就推送",而是:
- 查询级订阅:客户端订阅的是 SQL 查询,不是整张表
- 增量计算:当数据变更时,SpacetimeDB 计算变更是否影响某个订阅的结果集
- 精确推送:只推送影响该客户端的行级变更(insert/update/delete)
数据变更事件流:
表 T 上的 INSERT/UPDATE/DELETE
→ 遍历所有订阅了 T 的查询
→ 评估变更行是否匹配查询条件
→ 生成行级变更事件
→ 推送给匹配的客户端
这意味着:如果客户端 A 订阅了 room_id = 1 的消息,客户端 B 在 room_id = 2 发消息,A 不会收到任何推送。这不是客户端过滤,而是服务端精确推送。
4.4 事务与并发模型
SpacetimeDB 的 Reducer 执行采用串行化策略:
- 同一时刻只有一个 Reducer 在执行
- Reducer 内的操作是原子的
- 不需要锁、不需要并发控制
这听起来像是性能瓶颈?实际上:
- Reducer 执行极快:纯内存操作,微秒级完成
- I/O 是瓶颈所在的传统服务器:SpacetimeDB 没有网络 I/O 瓶颈(数据库内部执行)
- BitCraft 的验证:MMO 场景下串行执行足以支撑数千并发玩家
对于需要超大规模并行的场景,SpacetimeDB 也支持分片(Sharding)策略,将不同逻辑分区分配到不同实例。
五、性能优化实战
5.1 减少订阅带宽
SpacetimeDB 的订阅推送是实时的好处,但也意味着带宽消耗。优化策略:
// ❌ 不好:订阅整张表
// 客户端会收到所有画布的所有元素更新
// ✅ 好:精确订阅
#[spacetimedb::client_subscription_filter]
pub fn element_filter(ctx: &SubscriptionContext) -> Vec<Query> {
// 只推送用户所在画布的元素
let my_canvases: Vec<u64> = ctx.db.canvas_members().iter()
.filter(|m| m.identity == ctx.sender)
.map(|m| m.canvas_id)
.collect();
my_canvases.into_iter()
.map(|cid| Query::new(format!(
"SELECT * FROM element WHERE canvas_id = {}", cid
)))
.collect()
}
5.2 批量操作
避免在循环中逐条插入,利用 Reducer 的事务特性一次性处理:
#[reducer]
pub fn batch_create_elements(ctx: &ReducerContext, canvas_id: u64, elements_json: String) {
// 解析批量数据
let items: Vec<ElementInput> = serde_json::from_str(&elements_json)
.expect("Invalid JSON");
// 一次性插入,单次事务
for item in items {
ctx.db.elements().insert(Element {
id: 0,
canvas_id,
element_type: item.element_type,
x: item.x,
y: item.y,
width: item.width,
height: item.height,
fill_color: item.fill_color,
stroke_color: item.stroke_color.unwrap_or_else(|| "#000".to_string()),
stroke_width: item.stroke_width.unwrap_or(1.0),
text_content: item.text_content.unwrap_or_default(),
created_by: ctx.sender,
updated_at: ctx.timestamp,
});
}
// 整个 Reducer 要么全部成功,要么全部回滚
}
5.3 冷热数据分离
对于历史数据不需要实时推送的场景:
// 热数据:实时推送
#[table(accessor = active_games, public)]
pub struct ActiveGame {
#[primary_key]
id: u64,
player1: Identity,
player2: Identity,
board_state: String,
updated_at: Timestamp,
}
// 冷数据:不推送,按需查询
#[table(accessor = finished_games)]
pub struct FinishedGame {
#[primary_key]
id: u64,
player1: Identity,
player2: Identity,
winner: Option<Identity>,
final_state: String,
duration_secs: u64,
finished_at: Timestamp,
}
#[reducer]
pub fn finish_game(ctx: &ReducerContext, game_id: u64, winner: Option<Identity>) {
let game = match ctx.db.active_games().id().find(&game_id) {
Some(g) => g,
None => return,
};
// 从热表删除
ctx.db.active_games().id().delete(&game_id);
// 写入冷表
ctx.db.finished_games().insert(FinishedGame {
id: game.id,
player1: game.player1,
player2: game.player2,
winner,
final_state: game.board_state,
duration_secs: (ctx.timestamp.to_micros_since_epoch() - game.updated_at.to_micros_since_epoch()) / 1_000_000,
finished_at: ctx.timestamp,
});
}
5.4 避免大 Reducer
单个 Reducer 不应执行过重的计算。如果需要复杂处理,拆分为多个步骤:
// ❌ 不好:一个 Reducer 做所有事
#[reducer]
pub fn process_all_pending_orders(ctx: &ReducerContext) {
// 可能有数千个订单要处理,阻塞其他 Reducer
for order in ctx.db.pending_orders().iter() {
// 复杂处理...
}
}
// ✅ 好:每次处理一批
#[reducer]
pub fn process_order_batch(ctx: &ReducerContext, batch_size: u32) {
let processed = ctx.db.pending_orders().iter()
.take(batch_size as usize)
.filter_map(|order| {
// 处理单个订单
Some(process_single_order(ctx, order))
})
.count();
log::info!("处理了 {} 个订单", processed);
// 如果还有剩余,调度下一批
if ctx.db.pending_orders().iter().count() > 0 {
// 客户端可以检查剩余数量并再次调用
}
}
六、与现有技术栈的对比
6.1 SpacetimeDB vs Firebase Realtime Database
| 维度 | SpacetimeDB | Firebase RTDB |
|---|---|---|
| 数据模型 | 关系型(SQL 语义) | 树形 JSON |
| 查询能力 | 完整 SQL | 有限过滤 |
| 事务 | ACID | 有限事务 |
| 服务端逻辑 | WASM 模块(Rust/C#/TS) | Cloud Functions(Node.js) |
| 延迟 | 极低(全内存) | 较低 |
| 自部署 | 支持 | 不支持 |
| 供应商锁定 | 无 | 强 |
Firebase 的实时同步是特性,SpacetimeDB 的实时同步是架构基础。
6.2 SpacetimeDB vs Supabase Realtime
| 维度 | SpacetimeDB | Supabase Realtime |
|---|---|---|
| 架构 | 数据库即服务器 | PostgreSQL + WebSocket 网关 |
| 中间层 | 无 | Realtime 服务 |
| 事务 | 原生 ACID | PostgreSQL ACID + 事后推送 |
| 逻辑位置 | 数据库内 | Edge Functions / 触发器 |
| 延迟 | 最低(无中间层) | 多一跳 |
Supabase 的 Realtime 是在 PostgreSQL 之上加了推送层,本质上还是传统三层架构。SpacetimeDB 是从零设计的单层架构。
6.3 SpacetimeDB vs 传统自建(PostgreSQL + Node.js + WebSocket)
| 维度 | SpacetimeDB | 传统自建 |
|---|---|---|
| 代码量 | 极少 | 大量 |
| 基础设施 | 零 | K8s/Docker/CI/CD |
| 实时同步 | 内置 | 手动实现 |
| 一致性 | 天然保证 | 需要设计 |
| 运维成本 | 极低 | 高 |
| 灵活性 | 受限(WASM 沙箱) | 无限 |
| 生态 | 新兴 | 成熟 |
七、生产级部署指南
7.1 Docker 自部署
# 拉取并启动
docker run --rm --pull always \
-p 3000:3000 \
-v spacetimedb-data:/spacetimedb \
clockworklabs/spacetime start
7.2 Docker Compose 生产配置
# docker-compose.yml
version: '3.8'
services:
spacetimedb:
image: clockworklabs/spacetime:latest
ports:
- "3000:3000"
volumes:
- spacetimedb-data:/spacetimedb
environment:
- SPACETIMEDB_LOG_LEVEL=info
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G # 根据数据量调整
cpus: '2.0'
# 可选:Nginx 反向代理
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- spacetimedb
volumes:
spacetimedb-data:
7.3 发布模块
# 构建 Rust 模块
cd whiteboard
cargo build --target wasm32-unknown-unknown --release
# 发布到本地 SpacetimeDB
spacetime publish whiteboard --project-path .
# 发布到 Maincloud(托管服务)
spacetime publish whiteboard --project-path . --server maincloud
7.4 监控与日志
# 查看模块日志
spacetime logs whiteboard
# 查看数据库状态
spacetime sql whiteboard "SELECT COUNT(*) FROM elements"
# 监控连接数
spacetime sql whiteboard "SELECT COUNT(*) FROM online_users"
八、TypeScript 模块开发(不写 Rust 也能玩)
SpacetimeDB 1.0+ 支持 TypeScript 编写模块,对 Rust 不熟悉的开发者也能快速上手:
// src/module.ts
import { table, reducer, Table, ReducerContext, Identity, Timestamp } from '@clockworklabs/spacetimedb-sdk';
@table({ accessor: 'todos', public: true })
class Todo {
@primary_key
@auto_inc
id: number;
title: string;
completed: boolean;
assignee: Identity;
created_at: Timestamp;
}
@reducer
function createTodo(ctx: ReducerContext, title: string) {
ctx.db.todos.insert({
id: 0,
title,
completed: false,
assignee: ctx.sender,
created_at: ctx.timestamp,
});
}
@reducer
function toggleTodo(ctx: ReducerContext, todoId: number) {
const todo = ctx.db.todos.id.find(todoId);
if (!todo) return;
// 只有分配人可以切换状态
if (todo.assignee !== ctx.sender) return;
ctx.db.todos.id.update({
...todo,
completed: !todo.completed,
});
}
@reducer
function deleteTodo(ctx: ReducerContext, todoId: number) {
ctx.db.todos.id.delete(todoId);
}
TypeScript 模块的部署:
spacetime publish todo-app --lang typescript --project-path .
九、游戏开发场景:实时多人游戏服务器
SpacetimeDB 最初就是为游戏设计的。以下是实时多人游戏的核心模式:
9.1 游戏房间模型
#[table(accessor = game_rooms, public)]
pub struct GameRoom {
#[primary_key]
id: u64,
name: String,
host: Identity,
max_players: u32,
status: String, // "waiting" | "playing" | "finished"
created_at: Timestamp,
}
#[table(accessor = players, public)]
pub struct Player {
#[primary_key]
identity: Identity,
room_id: u64,
name: String,
hp: u32,
x: f64,
y: f64,
score: u32,
is_alive: bool,
}
#[table(accessor = game_events, public)]
pub struct GameEvent {
#[primary_key]
#[auto_inc]
id: u64,
room_id: u64,
event_type: String,
data: String, // JSON 格式的事件数据
timestamp: Timestamp,
}
9.2 游戏逻辑 Reducer
#[reducer]
pub fn move_player(ctx: &ReducerContext, x: f64, y: f64) {
let player = match ctx.db.players().identity().find(&ctx.sender) {
Some(p) => p,
None => return,
};
if !player.is_alive {
return;
}
// 简单的边界检查
let new_x = x.clamp(0.0, 1000.0);
let new_y = y.clamp(0.0, 1000.0);
ctx.db.players().identity().update(Player {
x: new_x,
y: new_y,
..player
});
}
#[reducer]
pub fn attack_player(ctx: &ReducerContext, target: Identity) {
let attacker = match ctx.db.players().identity().find(&ctx.sender) {
Some(p) if p.is_alive => p,
_ => return,
};
let target_player = match ctx.db.players().identity().find(&target) {
Some(p) if p.is_alive && p.room_id == attacker.room_id => p,
_ => return,
};
// 距离检查
let dx = attacker.x - target_player.x;
let dy = attacker.y - target_player.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance > 50.0 {
return; // 超出攻击范围
}
// 造成伤害
let damage = 25;
let new_hp = target_player.hp.saturating_sub(damage);
ctx.db.players().identity().update(Player {
hp: new_hp,
is_alive: new_hp > 0,
..target_player
});
// 记录事件
ctx.db.game_events().insert(GameEvent {
id: 0,
room_id: attacker.room_id,
event_type: "attack".to_string(),
data: serde_json::json!({
"attacker": format!("{:?}", attacker.identity),
"target": format!("{:?}", target),
"damage": damage,
"remaining_hp": new_hp,
}).to_string(),
timestamp: ctx.timestamp,
});
// 如果目标死亡,增加攻击者分数
if new_hp == 0 {
ctx.db.players().identity().update(Player {
score: attacker.score + 1,
..attacker
});
}
}
十、安全模型深度解析
10.1 三层安全防线
第一层:身份认证(Identity)
→ 每个连接通过 GitHub/Email 认证获得唯一 Identity
→ Identity 不可伪造
第二层:表级访问控制(public vs private)
→ public 表:客户端可订阅,但只能通过 Reducer 写入
→ private 表:客户端完全不可见
第三层:Reducer 逻辑校验
→ 在 Reducer 中手动校验 ctx.sender 的权限
→ 可以实现任意复杂的权限模型
10.2 防作弊设计
#[reducer]
pub fn collect_resource(ctx: &ReducerContext, resource_id: u64) {
// 1. 资源是否存在
let resource = match ctx.db.resources().id().find(&resource_id) {
Some(r) => r,
None => return,
};
// 2. 资源是否已被采集
if resource.collected {
return;
}
// 3. 玩家是否在资源附近(服务端校验,防作弊)
let player = match ctx.db.players().identity().find(&ctx.sender) {
Some(p) => p,
None => return,
};
let dx = player.x - resource.x;
let dy = player.y - resource.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance > 100.0 {
log::warn!("作弊尝试: 玩家 {:?} 远距离采集资源", ctx.sender);
return;
}
// 4. 采集冷却检查
let last_collect = ctx.db.player_cooldowns().identity().find(&ctx.sender);
if let Some(cd) = last_collect {
let elapsed = ctx.timestamp.to_micros_since_epoch() - cd.last_collect_at.to_micros_since_epoch();
if elapsed < 1_000_000 { // 1 秒冷却
return;
}
}
// 5. 执行采集
ctx.db.resources().id().update(Resource {
collected: true,
collected_by: Some(ctx.sender),
..resource
});
ctx.db.player_cooldowns().identity().update(PlayerCooldown {
identity: ctx.sender,
last_collect_at: ctx.timestamp,
});
}
关键原则:永远不要信任客户端提交的位置数据,服务端必须做二次校验。
十一、C# 模块开发(Unity 游戏集成)
SpacetimeDB 提供 Unity SDK,让游戏开发者直接在 Unity 中使用:
// GameModule.cs
using SpacetimeDB;
using System;
[Table(Name = "player_position", Public = true)]
public partial class PlayerPosition
{
[PrimaryKey]
public Identity Identity;
public float X;
public float Y;
public float Z;
public float RotationY;
public Timestamp UpdatedAt;
}
[Table(Name = "game_state", Public = true)]
public partial class GameState
{
[PrimaryKey]
public uint RoomId;
public string Status;
public uint RoundNumber;
public Timestamp RoundEndTime;
}
[Reducer]
public static void UpdatePosition(ReducerContext ctx, float x, float y, float z, float rotationY)
{
var existing = ctx.Db.player_position.Identity.Find(ctx.Sender);
if (existing != null)
{
ctx.Db.player_position.Identity.Update(new PlayerPosition
{
Identity = ctx.Sender,
X = x,
Y = y,
Z = z,
RotationY = rotationY,
UpdatedAt = ctx.Timestamp,
});
}
else
{
ctx.Db.player_position.Insert(new PlayerPosition
{
Identity = ctx.Sender,
X = x,
Y = y,
Z = z,
RotationY = rotationY,
UpdatedAt = ctx.Timestamp,
});
}
}
Unity 客户端:
// SpacetimeDBManager.cs
using SpacetimeDB.Client;
using UnityEngine;
public class SpacetimeDBManager : MonoBehaviour
{
private SpacetimeDBClient client;
async void Start()
{
client = new SpacetimeDBClient("ws://localhost:3000", "game_module");
await client.Connect();
// 订阅玩家位置
client.Db.player_position.OnInsert += OnPlayerJoined;
client.Db.player_position.OnUpdate += OnPlayerMoved;
client.Db.player_position.OnDelete += OnPlayerLeft;
}
void OnPlayerJoined(PlayerPosition pos)
{
// 在场景中创建玩家对象
var playerObj = Instantiate(playerPrefab, new Vector3(pos.X, pos.Y, pos.Z), Quaternion.identity);
playerObj.GetComponent<NetworkPlayer>().Identity = pos.Identity;
}
void OnPlayerMoved(PlayerPosition oldPos, PlayerPosition newPos)
{
// 平滑移动玩家对象
var player = FindPlayer(newPos.Identity);
if (player != null)
{
player.TargetPosition = new Vector3(newPos.X, newPos.Y, newPos.Z);
player.TargetRotation = Quaternion.Euler(0, newPos.RotationY, 0);
}
}
void Update()
{
// 发送本地玩家位置(节流:每 100ms 一次)
if (Time.time - lastSendTime > 0.1f)
{
var pos = localPlayer.transform.position;
var rot = localPlayer.transform.rotation.eulerAngles.y;
client.Reducers.UpdatePosition(pos.x, pos.y, pos.z, rot);
lastSendTime = Time.time;
}
}
}
十二、适用场景与局限性
12.1 适合 SpacetimeDB 的场景
- 实时协作应用:白板、文档编辑、项目管理
- 多人游戏:MMO、实时对战、棋牌游戏
- 实时仪表盘:监控、数据可视化、交易系统
- 聊天应用:群聊、私信、频道
- IoT 控制面板:设备状态实时同步
- 小型到中型 SaaS:后端逻辑不太复杂、实时性要求高
12.2 不太适合的场景
- 重度批处理:ETL、数据分析、报表生成(Reducer 串行执行会成为瓶颈)
- 超大规模数据集:全内存架构意味着数据量受限于 RAM
- 复杂微服务架构:需要服务间异步通信、事件驱动的场景
- 已有成熟后端的项目:迁移成本高,收益不明显
- 需要文件系统访问:WASM 沙箱限制,无法直接读写文件
12.3 与传统架构混合使用
SpacetimeDB 不必是唯一的后端。你可以:
客户端
├── SpacetimeDB → 实时数据(游戏状态、聊天、协作)
└── 传统 API → 批处理、文件上传、第三方集成
// 混合架构示例
const spacetimeData = useTable(tables.gameState); // 实时数据来自 SpacetimeDB
const reportData = await fetch('/api/reports'); // 批量数据来自传统 API
十三、迁移指南:从传统后端迁移到 SpacetimeDB
13.1 渐进式迁移策略
阶段一:新功能用 SpacetimeDB
已有功能 → 传统后端(不动)
新功能 → SpacetimeDB 模块
阶段二:实时性要求高的功能迁移
聊天系统 → SpacetimeDB
通知系统 → SpacetimeDB
在线状态 → SpacetimeDB
其他功能 → 传统后端(暂不动)
阶段三:全面迁移
所有业务逻辑 → SpacetimeDB
传统后端 → 仅保留文件处理、第三方 API 代理
13.2 数据模型映射
-- 传统 PostgreSQL
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- SpacetimeDB Rust 模块
#[table(accessor = users, public)]
pub struct User {
#[primary_key]
#[auto_inc]
id: u64,
username: String,
email: String,
created_at: Timestamp,
}
-- 传统 PostgreSQL 多对多
CREATE TABLE room_members (
room_id INT REFERENCES rooms(id),
user_id INT REFERENCES users(id),
role VARCHAR(20),
PRIMARY KEY (room_id, user_id)
);
-- SpacetimeDB(无外键约束,在 Reducer 中手动校验)
#[table(accessor = room_members, public)]
pub struct RoomMember {
#[primary_key]
room_id: u64,
identity: Identity,
role: String,
}
十四、总结与展望
14.1 SpacetimeDB 带来的范式转移
SpacetimeDB 代表的不仅仅是"又一个数据库",而是一种架构范式的转移:
- 从三层到单层:数据库即服务器,消除中间层
- 从拉取到推送:数据变更主动推送,不再需要轮询
- 从基础设施到业务:零运维,开发者只关心业务逻辑
- 从碎片化到统一:一个模块、一个语言、一次部署
14.2 当前限制与未来发展
- 内存限制:当前版本数据全在内存,超大数据集需要分片策略。未来可能引入冷热分层
- 生态早期:社区、工具链、第三方库还在成长阶段
- 调试体验:WASM 内部调试仍有挑战,日志是最主要的调试手段
- 横向扩展:单实例串行 Reducer 在超大规模下可能需要分片支持
14.3 谁应该关注 SpacetimeDB
- 独立游戏开发者:一个人就能搞定多人游戏后端
- 全栈开发者:厌倦了搭基础设施,只想写业务
- 实时应用开发者:协作工具、聊天、仪表盘
- 技术探索者:对"数据库即服务器"范式感兴趣的人
SpacetimeDB 证明了:最好的基础设施是没有基础设施。当你不再需要操心服务器、WebSocket、缓存、消息队列,你才能真正专注于创造价值。
在这个 Agent 和实时协作成为标配的 2026 年,SpacetimeDB 的"零基础设施"哲学来得正是时候。
项目地址:https://github.com/clockworklabs/SpacetimeDB
官方文档:https://spacetimedb.com/docs
在线体验:spacetime dev --template chat-react-ts一键启动聊天模板
Star 数:20,000+(2026年6月)
许可证:BSL 1.1