SpacetimeDB 深度实战:「数据库即服务器」架构如何干掉你的整个后端——从零理解实时数据库的革命性设计到生产级部署完全指南(2026)
引言:后端开发的终极困境
你有没有算过,一个看似简单的实时应用——在线聊天、多人游戏、协作编辑器——到底需要多少基础设施?
客户端 → CDN → 负载均衡 → Web服务器 → 缓存层(Redis) → 消息队列 → 应用服务器 → 数据库
↓
WebSocket网关
↓
状态同步服务
这条链路里,每一个环节都是故障点,每一层都是延迟来源,每一个组件都需要运维。你写的是业务逻辑,但花 80% 的时间在折腾基础设施。
2026 年,Clockwork Labs 开源的 SpacetimeDB 提出了一个激进的方案:把应用逻辑直接写进数据库,客户端直连数据库,干掉中间所有的服务器层。
这不是一个玩具项目。它的整个后端支撑着 MMORPG BitCraft Online——聊天、物品系统、地形、玩家位置,全部跑在一个 SpacetimeDB 模块里,实时同步给数千玩家。GitHub 上已有 2 万+ Star,BSL 1.1 许可证(4 年后转 AGPL + linking exception)。
今天我们深挖 SpacetimeDB 的架构哲学、核心机制、代码实战和生产部署,看看「数据库即服务器」到底是噱头还是未来。
一、核心架构:为什么「数据库即服务器」行得通
1.1 传统架构的致命问题
传统的三层架构(客户端 → 服务器 → 数据库)有一个根本矛盾:数据在数据库里,但逻辑在服务器里,客户端想要数据必须经过服务器中转。
这带来三个问题:
- 延迟叠加:客户端 → 服务器 → 数据库 → 服务器 → 客户端,一次操作至少两次网络往返
- 状态同步地狱:服务器必须维护 WebSocket 连接,监听数据库变更,再推送给你客户端。每个实时功能都要实现一套发布/订阅逻辑
- 运维爆炸:服务器、缓存、消息队列、容器编排……每一层都要监控、扩容、故障恢复
1.2 SpacetimeDB 的激进方案
SpacetimeDB 的核心思想是:既然所有操作最终都要经过数据库,为什么不把逻辑直接放在数据旁边?
传统架构: Client ←WebSocket→ Server ←SQL→ Database
SpacetimeDB:Client ←WebSocket→ Database(内嵌你的逻辑)
具体来说:
- 你用 Rust/C#/TypeScript/C++ 写一个模块(Module),定义表结构和业务逻辑
- SpacetimeDB 把这个模块编译成 WASM,加载到数据库内部运行
- 客户端通过 WebSocket 直连数据库,调用模块中的 Reducer(类似 API 端点)
- 数据变更自动推送到订阅了相关表的客户端
关键洞察:数据库本身就是一个事件驱动的状态机。每次写入都是一次事件,每次事件都可以触发通知。SpacetimeDB 只是把这个机制显性化了。
1.3 架构图解
┌─────────────────────────────────────────────────────┐
│ SpacetimeDB │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 你的 Module (WASM) │ │
│ │ │ │
│ │ Tables: Reducers: │ │
│ │ - users - create_user() │ │
│ │ - messages - send_message() │ │
│ │ - game_state - move_player() │ │
│ │ - update_item() │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────┼───────────────────────┐ │
│ │ Subscription Engine (订阅引擎) │ │
│ │ - 跟踪每个客户端的订阅范围 │ │
│ │ - 表变更时计算受影响的客户端 │ │
│ │ - 推送增量更新(Row Insert/Update/Delete) │ │
│ └─────────────────────┼───────────────────────┘ │
│ │ │
│ ┌─────────────────────┼───────────────────────┐ │
│ │ Storage Engine (存储引擎) │ │
│ │ - 内存存储(热数据,毫秒级访问) │ │
│ │ - Commit Log(持久化,崩溃恢复) │ │
│ │ - ACID 事务保证 │ │
│ └─────────────────────┼───────────────────────┘ │
│ │ │
└────────────────────────┼─────────────────────────────┘
│ WebSocket
┌──────────────┼──────────────┐
│ │ │
Client A Client B Client N
(React/TS) (Unity/C#) (Unreal/C++)
核心要点:
- 内存优先:所有热数据在内存中,毫秒级访问。磁盘 Commit Log 只做持久化
- 多语言 SDK:客户端可以用 TypeScript(React/Vue/Svelte)、C#(Unity)、C++(Unreal)、Rust
- ACID 保证:虽然数据在内存中,但每次 Reducer 调用都是一个事务,要么全部成功,要么全部回滚
二、核心概念深度解析
2.1 Table(表)—— 不只是存储
SpacetimeDB 的 Table 不是传统数据库的表。它更像是一个实时同步的数据结构。
#[spacetimedb::table(accessor = messages, public)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
sender: Identity,
text: String,
sent_at: Timestamp,
}
关键特性:
public标记意味着客户端可以订阅这张表- 任何 insert/update/delete 都会自动推送给订阅者
Identity是 SpacetimeDB 内置类型,表示调用者的身份(基于加密认证)Timestamp也是内置类型,由数据库自动管理
表还有 private 模式——只有模块内部的 Reducer 可以读写,客户端无法订阅。这在实现内部状态(如游戏逻辑中的中间计算结果)时非常有用。
2.2 Reducer(归约器)—— 你的 API 端点
Reducer 是 SpacetimeDB 中唯一的「写」入口。客户端不能直接写表,只能调用 Reducer,由 Reducer 来执行写操作。
#[spacetimedb::reducer]
pub fn send_message(ctx: &ReducerContext, text: String) {
// 权限检查
if text.is_empty() {
return; // 静默忽略空消息
}
if text.len() > 500 {
return; // 消息过长也忽略
}
// 写入表
ctx.db.messages().insert(Message {
id: 0, // auto_inc 会自动生成
sender: ctx.sender,
text,
sent_at: ctx.timestamp,
});
}
Reducer 的执行模型:
- 客户端通过 WebSocket 发送 Reducer 调用请求
- SpacetimeDB 将调用排入事务队列
- 串行执行——同一个模块的 Reducer 不会并发执行
- 执行过程中所有的表操作要么全部提交,要么全部回滚
- 提交后,订阅引擎计算受影响的客户端,推送增量更新
这个串行执行模型非常关键。它意味着你不需要锁、不需要并发控制。所有的状态操作都是天然线程安全的。代价是吞吐量受限于单线程执行速度,但考虑到数据全在内存中,这个速度已经足够快了(BitCraft 的实践证明可以支撑数千并发玩家)。
2.3 Subscription(订阅)—— 实时同步的灵魂
这是 SpacetimeDB 最精妙的设计。客户端不是「查询」数据,而是「订阅」数据。
// React 客户端
const [messages] = useTable(tables.message);
// messages 会自动更新!不需要轮询,不需要 refetch
背后的机制:
- 客户端发送订阅请求(可以带过滤条件)
- SpacetimeDB 计算当前满足条件的行,发送给客户端(初始快照)
- 之后任何影响该订阅的写操作,都会生成增量更新推送给客户端
- 增量更新格式:
RowInsert、RowUpdate、RowDelete
更高级的订阅——查询式订阅:
// 只订阅特定用户的消息
const [myMessages] = useTable(tables.message,
message => message.sender.equals(myIdentity)
);
// 订阅特定区域内的玩家(游戏场景)
const [nearbyPlayers] = useTable(tables.player,
player => player.x > minX && player.x < maxX
&& player.z > minZ && player.z < maxZ
);
这种设计把「读」操作从服务器端完全移到了客户端。服务器不需要实现任何查询 API——客户端直接声明需要什么数据,数据库自动推送。
2.4 Identity(身份)—— 内建认证
SpacetimeDB 内建了基于加密的身份认证系统:
- 每个客户端在首次连接时生成一个 Ed25519 密钥对
- 公钥的哈希就是
Identity,作为客户端的唯一标识 - 每次调用 Reducer 时,
ctx.sender就是调用者的 Identity - 不需要 JWT、不需要 OAuth、不需要 session 管理
#[spacetimedb::table(accessor = users, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: String,
online: bool,
}
#[spacetimedb::reducer]
pub fn set_name(ctx: &ReducerContext, name: String) {
// 只有自己能改自己的名字
if let Some(mut user) = ctx.db.users().identity().find(&ctx.sender) {
user.name = name;
ctx.db.users().identity().update(user);
} else {
ctx.db.users().insert(User {
identity: ctx.sender,
name,
online: true,
});
}
}
三、BSATN 协议:为什么自定义二进制协议比 JSON 快 10 倍
SpacetimeDB 支持两种 WebSocket 协议:
v1.bsatn.spacetimedb——自定义二进制协议(BSATN)v1.json.spacetimedb——JSON 文本协议(调试用)
3.1 BSATN 设计哲学
BSATN(Binary Spacetime Algebraic Type Notation)的设计目标是在保持类型安全的同时,最大限度地减少序列化/反序列化的开销。
核心设计:
- 无 schema 开销:双方共享类型定义(通过模块的 WASM),不需要在消息中重复字段名
- 紧凑编码:数值用变长整数编码(类似 protobuf 的 varint),字符串用长度前缀
- 零拷贝友好:对齐的字段布局,可以安全地进行内存映射
3.2 与 JSON 的性能对比
以一条聊天消息为例:
// JSON: ~120 字节
{"id": 42, "sender": "0x1234abcd...", "text": "Hello World", "sent_at": 1717488000}
// BSATN: ~35 字节
[42 as varint] [32 bytes identity] [11 as varint] [Hello World] [8 bytes timestamp]
在 BitCraft 的生产环境中,BSATN 相比 JSON:
- 消息体积减少约 70%
- 序列化/反序列化速度快 5-10 倍
- 内存分配减少约 80%
对于每秒需要处理数万条增量更新的实时游戏来说,这个差距是致命的。
3.3 什么时候用 JSON
JSON 协议在以下场景有用:
- 调试和开发阶段——可以直接看消息内容
- 与不支持 BSATN 的第三方工具集成
- 性能要求不高的低频操作
生产环境一定要用 BSATN。
四、实战:从零构建一个实时协作白板
让我们用 SpacetimeDB 构建一个完整的实时协作白板应用。功能:多用户同时在画布上绘制,实时看到彼此的光标和笔画。
4.1 项目初始化
# 安装 SpacetimeDB CLI
curl -sSf https://install.spacetimedb.com | sh
# 登录
spacetime login
# 创建项目
mkdir whiteboard && cd whiteboard
spacetime init --lang rust --template minimal
项目结构:
whiteboard/
├── server/ # Rust 模块(SpacetimeDB)
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── client/ # React + TypeScript
│ ├── package.json
│ └── src/
│ ├── App.tsx
│ └── ...
└── Dockerfile
4.2 服务端模块设计
// server/src/lib.rs
use spacetimedb::{reducer, table, Identity, Timestamp, ReducerContext};
/// 用户表——追踪在线状态和光标位置
#[spacetimedb::table(accessor = users, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: String,
online: bool,
cursor_x: f32,
cursor_y: f32,
color: String, // 用户颜色标识
}
/// 画笔路径点
#[spacetimedb::table(accessor = stroke_points, public)]
pub struct StrokePoint {
#[primary_key]
#[auto_inc]
id: u64,
stroke_id: u64, // 属于哪条笔画
x: f32,
y: f32,
order: u32, // 点在笔画中的顺序
}
/// 笔画元数据
#[spacetimedb::table(accessor = strokes, public)]
pub struct Stroke {
#[primary_key]
#[auto_inc]
id: u64,
author: Identity,
color: String,
width: f32,
created_at: Timestamp,
finished: bool, // 笔画是否结束
}
/// 预分配的颜色池
const COLORS: &[&str] = &[
"#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4",
"#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
];
#[spacetimedb::reducer]
/// 用户加入白板
pub fn join_whiteboard(ctx: &ReducerContext, name: String) {
let color_index = ctx.db.users().count() as usize % COLORS.len();
let color = COLORS[color_index].to_string();
ctx.db.users().insert(User {
identity: ctx.sender,
name: name.clone(),
online: true,
cursor_x: 0.0,
cursor_y: 0.0,
color,
});
log::info!("User {} joined the whiteboard", name);
}
#[spacetimedb::reducer]
/// 用户离开
pub fn leave_whiteboard(ctx: &ReducerContext) {
if let Some(mut user) = ctx.db.users().identity().find(&ctx.sender) {
user.online = false;
ctx.db.users().identity().update(user);
}
}
#[spacetimedb::reducer]
/// 更新光标位置(高频调用)
pub fn move_cursor(ctx: &ReducerContext, x: f32, y: f32) {
if let Some(mut user) = ctx.db.users().identity().find(&ctx.sender) {
user.cursor_x = x;
user.cursor_y = y;
ctx.db.users().identity().update(user);
}
}
#[spacetimedb::reducer]
/// 开始一条新笔画
pub fn start_stroke(ctx: &ReducerContext, color: String, width: f32) {
let user = ctx.db.users().identity().find(&ctx.sender);
let stroke_color = if user.is_some() {
user.unwrap().color.clone()
} else {
color
};
ctx.db.strokes().insert(Stroke {
id: 0,
author: ctx.sender,
color: stroke_color,
width,
created_at: ctx.timestamp,
finished: false,
});
}
#[spacetimedb::reducer]
/// 向笔画添加点(高频调用)
pub fn add_point(ctx: &ReducerContext, stroke_id: u64, x: f32, y: f32, order: u32) {
// 验证这条笔画存在且属于当前用户
if let Some(stroke) = ctx.db.strokes().id().find(&stroke_id) {
if stroke.author != ctx.sender {
return; // 不能往别人的笔画上加点
}
if stroke.finished {
return; // 笔画已结束
}
ctx.db.stroke_points().insert(StrokePoint {
id: 0,
stroke_id,
x,
y,
order,
});
}
}
#[spacetimedb::reducer]
/// 结束一条笔画
pub fn end_stroke(ctx: &ReducerContext, stroke_id: u64) {
if let Some(mut stroke) = ctx.db.strokes().id().find(&stroke_id) {
if stroke.author == ctx.sender {
stroke.finished = true;
ctx.db.strokes().id().update(stroke);
}
}
}
#[spacetimedb::reducer]
/// 清空画布(仅限笔画作者或管理员逻辑)
pub fn clear_strokes(ctx: &ReducerContext) {
// 删除所有笔画和点
let stroke_ids: Vec<u64> = ctx.db.strokes().iter()
.map(|s| s.id)
.collect();
for sid in stroke_ids {
// 删除该笔画的所有点
let point_ids: Vec<u64> = ctx.db.stroke_points().iter()
.filter(|p| p.stroke_id == sid)
.map(|p| p.id)
.collect();
for pid in point_ids {
ctx.db.stroke_points().id().delete(pid);
}
ctx.db.strokes().id().delete(sid);
}
}
4.3 客户端实现
// client/src/App.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
SpacetimeDBClient,
Identity,
ConnectionId,
} from '@clockworklabs/spacetimedb-sdk';
import { tables, reducers } from './module_bindings';
const CLIENT_ID = Math.random().toString(36).substring(7);
function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [client, setClient] = useState<SpacetimeDBClient | null>(null);
const [identity, setIdentity] = useState<Identity | null>(null);
const [users, setUsers] = useState<any[]>([]);
const [strokes, setStrokes] = useState<any[]>([]);
const [strokePoints, setStrokePoints] = useState<any[]>([]);
const [currentStrokeId, setCurrentStrokeId] = useState<number | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const pointOrderRef = useRef(0);
// 连接 SpacetimeDB
useEffect(() => {
const client = new SpacetimeDBClient(
'ws://localhost:3000',
'whiteboard',
{ protocol: 'v1.bsatn.spacetimedb' } // 使用 BSATN 协议
);
client.onConnect((identity, connectionId) => {
setIdentity(identity);
reducers.joinWhiteboard(client, `User_${CLIENT_ID}`);
});
client.onConnectError((error) => {
console.error('Connection error:', error);
});
// 订阅表更新
client.subscribe(['SELECT * FROM users', 'SELECT * FROM strokes', 'SELECT * FROM stroke_points']);
// 监听表变化
tables.user.onInsert((ctx, user) => {
setUsers(prev => [...prev, user]);
});
tables.user.onUpdate((ctx, oldUser, newUser) => {
setUsers(prev => prev.map(u => u.identity === newUser.identity ? newUser : u));
});
tables.user.onDelete((ctx, user) => {
setUsers(prev => prev.filter(u => u.identity !== user.identity));
});
tables.stroke.onInsert((ctx, stroke) => {
setStrokes(prev => [...prev, stroke]);
});
tables.stroke.onUpdate((ctx, oldStroke, newStroke) => {
setStrokes(prev => prev.map(s => s.id === newStroke.id ? newStroke : s));
});
tables.strokePoint.onInsert((ctx, point) => {
setStrokePoints(prev => [...prev, point]);
});
setClient(client);
client.connect();
return () => {
if (identity) {
reducers.leaveWhiteboard(client);
}
client.disconnect();
};
}, []);
// 绘制画布
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制所有已完成的笔画
strokes.filter(s => s.finished).forEach(stroke => {
const points = strokePoints
.filter(p => p.stroke_id === stroke.id)
.sort((a, b) => a.order - b.order);
if (points.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
});
// 绘制进行中的笔画
strokes.filter(s => !s.finished).forEach(stroke => {
const points = strokePoints
.filter(p => p.stroke_id === stroke.id)
.sort((a, b) => a.order - b.order);
if (points.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([5, 5]); // 进行中用虚线
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
ctx.setLineDash([]);
});
// 绘制其他用户的光标
users.filter(u => u.online && u.identity !== identity?.toHexString()).forEach(user => {
ctx.beginPath();
ctx.fillStyle = user.color;
ctx.arc(user.cursor_x, user.cursor_y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.font = '12px sans-serif';
ctx.fillText(user.name, user.cursor_x + 12, user.cursor_y + 4);
});
}, [users, strokes, strokePoints, identity]);
// 鼠标事件处理
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!client) return;
const rect = canvasRef.current!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setIsDrawing(true);
pointOrderRef.current = 0;
reducers.startStroke(client, '#000000', 3.0);
}, [client]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!client) return;
const rect = canvasRef.current!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 总是更新光标位置
reducers.moveCursor(client, x, y);
// 如果正在绘制,添加点
if (isDrawing && currentStrokeId !== null) {
reducers.addPoint(client, currentStrokeId, x, y, pointOrderRef.current);
pointOrderRef.current += 1;
}
}, [client, isDrawing, currentStrokeId]);
const handleMouseUp = useCallback(() => {
if (!client || currentStrokeId === null) return;
reducers.endStroke(client, currentStrokeId);
setIsDrawing(false);
setCurrentStrokeId(null);
}, [client, currentStrokeId]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ padding: '8px', background: '#1a1a2e', color: '#fff', display: 'flex', justifyContent: 'space-between' }}>
<span>实时协作白板 | 在线: {users.filter(u => u.online).length} 人</span>
<button onClick={() => client && reducers.clearStrokes(client)}>
清空画布
</button>
</div>
<canvas
ref={canvasRef}
width={1200}
height={800}
style={{ border: '1px solid #333', cursor: 'crosshair' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
);
}
export default App;
4.4 关键设计决策解析
为什么 move_cursor 和 add_point 是独立的 Reducer?
在传统架构中,你可能会把光标位置和绘画点塞进同一个 WebSocket 消息。但在 SpacetimeDB 中,每个 Reducer 是一个独立的事务。分离它们的好处是:
- 光标更新不会阻塞绘画操作——虽然 Reducer 是串行执行的,但光标更新非常轻量(只修改一行),不会造成瓶颈
- 客户端可以选择性订阅——移动端可能只需要绘画数据,不需要其他用户的光标
- 独立重试——光标更新丢了无所谓,但绘画点不能丢
高频 Reducer 的节流策略:
// 客户端节流:光标位置每 50ms 最多发一次
const throttledMoveCursor = useMemo(
() => throttle((x: number, y: number) => {
if (client) reducers.moveCursor(client, x, y);
}, 50),
[client]
);
服务端不需要节流——因为 Reducer 串行执行,即使客户端疯狂发请求,也不会有并发问题,只是队列会积压。
五、存储引擎深度剖析:内存 + Commit Log
5.1 内存存储的权衡
SpacetimeDB 将所有热数据放在内存中。这个选择背后的逻辑:
| 维度 | 内存存储 | 磁盘存储(传统DB) |
|---|---|---|
| 随机读取 | ~100ns | ~10ms(SSD) |
| 顺序写入 | ~50ns | ~100μs(SSD) |
| 容量 | 受 RAM 限制 | 几乎无限 |
| 成本 | $10/GB | $0.1/GB |
| 持久化 | 需要额外机制 | 天然持久 |
对于实时应用来说,延迟是第一优先级。10ms 的磁盘读取意味着你每秒只能处理 100 次操作(串行),而内存可以做到 1000 万次。
5.2 Commit Log 持久化
SpacetimeDB 使用预写日志(Write-Ahead Log)来保证持久性:
- 每次 Reducer 执行完毕,所有写操作先写入 Commit Log
- Commit Log 写入磁盘后才返回成功
- 崩溃恢复时,从最近的快照 + Commit Log 重放
时间线:
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│Snapshot│ │Reducer │ │Reducer │ │Reducer │
│ (Full) │→ │ #101 │→ │ #102 │→ │ #103 │
└────────┘ └────────┘ └────────┘ └────────┘
↑ 写入磁盘
Commit Log
这种设计意味着:
- 正常操作:数据在内存中,毫秒级响应
- 崩溃恢复:最多丢失最后一次 fsync 的数据(通常 < 1ms)
- 快照策略:定期将内存状态序列化到磁盘,减少重放时间
5.3 WASM 模块执行环境
你的 Rust/C# 代码被编译成 WASM,运行在 SpacetimeDB 内嵌的 WASM 运行时中。关键约束:
- 无网络访问:模块不能发起网络请求(纯计算 + 表操作)
- 无文件系统:模块不能读写本地文件
- 有内存限制:默认 4GB WASM 线性内存
- 确定性执行:同一输入必须产生同一输出(用于冲突解决)
这些约束看似严格,但正是它们保证了 SpacetimeDB 的核心属性——可复制性。如果两个节点执行相同的 Reducer 序列,它们必须达到相同的状态。
六、生产级部署
6.1 Docker 自托管
# 拉取并启动 SpacetimeDB
docker run --rm --pull always \
-p 3000:3000 \
-v spacetimedb-data:/spacetimedb \
clockworklabs/spacetime start
# 发布你的模块
spacetime publish --server localhost:3000 whiteboard
6.2 Maincloud 托管
Clockwork Labs 提供了名为 Maincloud 的托管服务:
# 发布到 Maincloud(需要 spacetime login)
spacetime publish whiteboard
优点:零运维,自动扩缩容。
缺点:目前仍在 beta,定价模型尚未完全确定。
6.3 性能调优
Reducer 粒度优化
// ❌ 不好:一个巨大的 Reducer 做太多事
#[spacetimedb::reducer]
pub fn update_game_state(ctx: &ReducerContext, /* 一大堆参数 */) {
// 处理移动、碰撞、物品、聊天……
// 这会阻塞所有其他 Reducer
}
// ✅ 好:拆分成小 Reducer
#[spacetimedb::reducer]
pub fn move_player(ctx: &ReducerContext, x: f32, y: f32) { /* ... */ }
#[spacetimedb::reducer]
pub fn pick_up_item(ctx: &ReducerContext, item_id: u64) { /* ... */ }
#[spacetimedb::reducer]
pub fn send_chat(ctx: &ReducerContext, message: String) { /* ... */ }
Reducer 是串行执行的,所以一个大 Reducer 会阻塞所有其他操作。拆分成小 Reducer 可以让高优先级操作(如玩家移动)插队。
订阅过滤优化
// ❌ 不好:订阅整张表
client.subscribe(['SELECT * FROM stroke_points']);
// 对于白板应用,这意味着每个客户端都会收到所有笔画点
// 1000 个点 × 100 个客户端 = 100,000 次推送
// ✅ 好:只订阅当前视口内的数据
client.subscribe([`
SELECT * FROM stroke_points
WHERE stroke_id IN (
SELECT id FROM strokes WHERE finished = true
)
`]);
内存管理
SpacetimeDB 的所有数据在内存中,所以需要主动清理过期数据:
#[spacetimedb::reducer]
pub fn cleanup_old_messages(ctx: &ReducerContext) {
let cutoff = ctx.timestamp - Duration::from_secs(3600); // 1小时前
let old_ids: Vec<u64> = ctx.db.messages().iter()
.filter(|m| m.sent_at < cutoff)
.map(|m| m.id)
.collect();
for id in old_ids {
ctx.db.messages().id().delete(id);
}
}
可以用定时 Reducer 自动触发清理:
#[spacetimedb::reducer(repeating = 600_000)] // 每 10 分钟执行一次
pub fn scheduled_cleanup(ctx: &ReducerContext) {
cleanup_old_messages(ctx);
}
6.4 监控与可观测性
# 查看模块状态
spacetime sql --server localhost:3000 whiteboard "SELECT COUNT(*) FROM messages"
# 查看连接数
spacetime sql --server localhost:3000 whiteboard "SELECT COUNT(*) FROM users WHERE online = true"
# 查看 Reducer 调用统计
spacetime logs --server localhost:3000 whiteboard --follow
SpacetimeDB 目前没有内建的 Prometheus metrics 导出,但你可以通过定时查询系统表来构建监控面板。
七、SpacetimeDB vs 传统方案对比
7.1 vs Firebase Realtime Database
| 维度 | SpacetimeDB | Firebase RTDB |
|---|---|---|
| 逻辑位置 | 数据库内(WASM) | 客户端 + Cloud Functions |
| 延迟 | < 1ms(本地内存) | 10-100ms(网络 + 冷启动) |
| 事务 | ACID | 有限(多路径更新) |
| 查询能力 | SQL 风格订阅 | 受限的 orderBy/filter |
| 自托管 | 支持 | 不支持 |
| 语言 | Rust/C#/TS/C++ | JS/TS only |
| 许可证 | BSL 1.1 | 闭源 |
7.2 vs 传统 Web Server + PostgreSQL + WebSocket
| 维度 | SpacetimeDB | 传统方案 |
|---|---|---|
| 代码量 | 1 个模块 | 服务器 + ORM + API + WS 网关 |
| 部署复杂度 | 1 个进程 | 3-5 个服务 + 编排 |
| 延迟 | 1 次 RTT | 2-3 次 RTT |
| 实时同步 | 内建 | 需自建(Listen/Notify + WS) |
| 扩展性 | 单节点(当前) | 几乎无限 |
7.3 SpacetimeDB 的局限性(2026年现状)
诚实地说,SpacetimeDB 不适合所有场景:
- 单节点限制:目前不支持分布式部署。一个模块跑在一个节点上,无法水平扩展。对于 BitCraft 这种 MMO 是够用的,但对超大规模应用有瓶颈
- 无网络访问:模块不能调用外部 API。如果你的业务需要调用第三方服务(支付、邮件),需要在客户端侧处理或用其他架构补充
- 查询能力有限:不支持 JOIN,复杂的关联查询需要手动在模块中实现
- 生态早期:SDK、工具链、社区都还在快速迭代中,API 可能变动
八、高级模式与最佳实践
8.1 客户端预测与权威服务器
对于游戏场景,SpacetimeDB 的 Reducer 串行执行会导致输入延迟。解决方案是客户端预测:
// 客户端预测:立即更新本地状态
function handleMoveInput(dx: number, dy: number) {
// 1. 乐观更新本地状态
localPlayer.x += dx;
localPlayer.y += dy;
render();
// 2. 发送给服务器(权威状态)
reducers.movePlayer(client, localPlayer.x, localPlayer.y);
}
// 服务器推送的权威状态到达时,纠正本地预测
tables.player.onUpdate((ctx, oldPlayer, newPlayer) => {
if (newPlayer.identity === myIdentity) {
// 平滑纠正而不是瞬移
interpolateTo(newPlayer.x, newPlayer.y);
}
});
8.2 房间/频道隔离
SpacetimeDB 的订阅机制天然支持频道隔离:
#[spacetimedb::table(accessor = rooms, public)]
pub struct Room {
#[primary_key]
id: u64,
name: String,
}
#[spacetimedb::table(accessor = room_messages, public)]
pub struct RoomMessage {
#[primary_key]
#[auto_inc]
id: u64,
room_id: u64,
sender: Identity,
text: String,
}
客户端只订阅自己所在房间的消息:
client.subscribe([`SELECT * FROM room_messages WHERE room_id = ${currentRoomId}`]);
8.3 权限控制模式
SpacetimeDB 没有内置的 RBAC,但可以通过 Reducer 逻辑实现:
#[spacetimedb::table(accessor = roles, private)] // private! 客户端不能直接读
pub struct Role {
#[primary_key]
identity: Identity,
role: String, // "admin", "moderator", "user"
}
fn is_admin(ctx: &ReducerContext, identity: &Identity) -> bool {
ctx.db.roles().identity().find(identity)
.map(|r| r.role == "admin")
.unwrap_or(false)
}
#[spacetimedb::reducer]
pub fn admin_ban_user(ctx: &ReducerContext, target: Identity) {
if !is_admin(ctx, &ctx.sender) {
return; // 静默拒绝
}
// 执行封禁逻辑
if let Some(mut user) = ctx.db.users().identity().find(&target) {
user.online = false;
ctx.db.users().identity().update(user);
}
}
使用 private 表存储敏感数据,确保客户端无法绕过 Reducer 直接读取权限信息。
8.4 批量操作优化
当需要一次性插入大量数据时,批量操作可以减少 Reducer 调用次数:
#[spacetimedb::reducer]
pub fn batch_insert_points(ctx: &ReducerContext, stroke_id: u64, points: Vec<(f32, f32)>) {
for (i, (x, y)) in points.into_iter().enumerate() {
ctx.db.stroke_points().insert(StrokePoint {
id: 0,
stroke_id,
x,
y,
order: i as u32,
});
}
}
客户端侧缓冲:
const pointBuffer: [number, number][] = [];
let bufferTimer: number | null = null;
function addPointBuffered(x: number, y: number) {
pointBuffer.push([x, y]);
if (!bufferTimer) {
bufferTimer = window.setTimeout(() => {
reducers.batchInsertPoints(client, currentStrokeId!, pointBuffer);
pointBuffer.length = 0;
bufferTimer = null;
}, 100); // 每 100ms 批量发送一次
}
}
九、与 AI Agent 的结合
2026 年,SpacetimeDB 的「数据库即服务器」架构为 AI Agent 开发带来了新的可能性。
9.1 Agent 状态即数据库状态
传统 AI Agent 架构中,Agent 的状态(记忆、任务队列、工具调用历史)分散在向量数据库、KV 存储、文件系统中。SpacetimeDB 可以把它们统一成一张表:
#[spacetimedb::table(accessor = agent_memory, public)]
pub struct AgentMemory {
#[primary_key]
#[auto_inc]
id: u64,
agent_id: Identity,
category: String, // "fact", "task", "reflection"
content: String,
importance: f32,
created_at: Timestamp,
accessed_at: Timestamp,
access_count: u32,
}
#[spacetimedb::table(accessor = agent_tasks, public)]
pub struct AgentTask {
#[primary_key]
#[auto_inc]
id: u64,
agent_id: Identity,
description: String,
status: String, // "pending", "running", "done", "failed"
priority: u32,
result: Option<String>,
}
#[spacetimedb::reducer]
pub fn agent_add_memory(ctx: &ReducerContext, category: String, content: String, importance: f32) {
ctx.db.agent_memory().insert(AgentMemory {
id: 0,
agent_id: ctx.sender,
category,
content,
importance,
created_at: ctx.timestamp,
accessed_at: ctx.timestamp,
access_count: 0,
});
}
#[spacetimedb::reducer]
pub fn agent_update_task(ctx: &ReducerContext, task_id: u64, status: String, result: Option<String>) {
if let Some(mut task) = ctx.db.agent_tasks().id().find(&task_id) {
if task.agent_id != ctx.sender { return; }
task.status = status;
task.result = result;
ctx.db.agent_tasks().id().update(task);
}
}
9.2 多 Agent 实时协作
多个 AI Agent 可以连接到同一个 SpacetimeDB 实例,通过订阅彼此的状态变化来协作:
// Agent A 订阅 Agent B 的任务状态
client.subscribe([`SELECT * FROM agent_tasks WHERE agent_id = '${agentB_identity}'`]);
// 当 Agent B 完成任务时,Agent A 自动收到通知
tables.agentTask.onUpdate((ctx, oldTask, newTask) => {
if (newTask.status === 'done' && newTask.agent_id !== myIdentity) {
console.log(`Agent B completed task: ${newTask.description}`);
// 基于结果决定下一步行动
}
});
这种模式的优势是零延迟——不需要轮询、不需要消息队列、不需要服务发现。Agent 之间的通信完全通过数据库状态变更来完成。
十、总结与展望
SpacetimeDB 的核心价值
- 极简架构:一个二进制替代一整条后端链路
- 实时内建:订阅机制让状态同步成为默认行为而非额外功能
- 类型安全:从模块定义到客户端 SDK,全程类型安全
- ACID 保证:不牺牲一致性来换取性能
- 多语言支持:Rust、C#、TypeScript、C++,覆盖游戏和 Web 开发
适用场景
| 场景 | 适合度 | 原因 |
|---|---|---|
| 多人游戏 | ⭐⭐⭐⭐⭐ | 正是为此设计 |
| 实时协作工具 | ⭐⭐⭐⭐⭐ | 白板、文档编辑、看板 |
| 聊天应用 | ⭐⭐⭐⭐ | 简单直接,但大规模需要分片 |
| IoT 仪表盘 | ⭐⭐⭐⭐ | 实时推送是强需求 |
| 电商后台 | ⭐⭐⭐ | 实时性要求没那么高,传统方案更成熟 |
| 数据分析 | ⭐⭐ | 需要复杂查询,SpacetimeDB 不擅长 |
未来方向
根据 Clockwork Labs 的路线图,SpacetimeDB 正在开发:
- 分布式支持:多节点复制,解决单节点瓶颈
- 更丰富的 SQL:JOIN、聚合、窗口函数
- 时间旅行查询:查询任意历史时刻的状态
- 边缘部署:在 CDN 节点运行 SpacetimeDB 实例,进一步降低延迟
如果分布式支持落地,SpacetimeDB 有潜力成为实时应用的事实标准后端。即使在当前单节点限制下,它也已经是中小型实时应用的最佳选择之一。
快速上手清单
# 1. 安装 CLI
curl -sSf https://install.spacetimedb.com | sh
# 2. 登录
spacetime login
# 3. 从模板创建项目
spacetime dev --template chat-react-ts
# 4. 本地开发(自动重载)
spacetime dev .
# 5. 发布到 Maincloud
spacetime publish my-app
# 6. Docker 自托管
docker run --rm -p 3000:3000 clockworklabs/spacetime start
项目地址:https://github.com/clockworklabs/SpacetimeDB
文档:https://spacetimedb.com/docs
Discord:https://discord.gg/spacetimedb
SpacetimeDB 代表的不只是一个数据库,而是一种全新的后端架构范式。当你不再需要区分「服务器逻辑」和「数据逻辑」,当你不再需要手动搭建实时同步管道——后端开发的复杂度会下降一个数量级。这不是银弹,但对于实时应用来说,它可能是最接近「正确」的答案。