SpacetimeDB 深度实战:当数据库学会「消灭服务器」——从 Reducer 事务模型到生产级实时多人游戏后端的完全指南(2026)
你有没有想过,为什么写一个多人在线游戏的后端,需要搭 Web 服务器、配消息队列、搞 WebSocket 长连接、部署缓存层、管理数据库连接池?这一整套基础设施,动辄 Kubernetes + 微服务 + DevOps 全家桶,还没写一行业务代码,光运维就让人头秃。
SpacetimeDB 的回答很干脆:把这些全砍了。
它的核心理念只有一句——数据库就是服务器。你把业务逻辑直接写进数据库,客户端直连数据库执行逻辑,没有中间商赚差价。MMORPG BitCraft Online 的整个后端(聊天、物品、地形、玩家位置……全部)就跑在单个 SpacetimeDB 模块上,实时同步给数千玩家。
这不是玩具项目。GitHub 上超过 2 万 Star,背后是 Clockwork Labs 团队数年的工程积累。今天我们就从架构到代码,彻底拆解它。
一、架构革命:为什么「数据库即服务器」能成立
1.1 传统架构的痛点
一个典型的实时游戏后端架构:
客户端 ←→ Web/Game Server ←→ 消息队列 ←→ 缓存层 ←→ 数据库
(鉴权/路由) (解耦) (加速) (持久化)
问题在哪?
- 延迟叠加:每个中间层加 1-5ms,客户端到数据库的 RTT 轻松 20-50ms
- 状态同步地狱:缓存在内存,数据库在磁盘,消息队列在另一个进程,一致性靠开发者手动保证
- 运维复杂度:5 个组件 = 5 套监控 + 5 套扩缩容策略 + N 个版本兼容问题
- 开发成本:改一个字段,Server 层、缓存层、数据库层全得改
1.2 SpacetimeDB 的架构
客户端 ←→ SpacetimeDB(数据库 + 业务逻辑 + 实时推送)
就这样。没有中间层。客户端通过 WebSocket 直连 SpacetimeDB,调用 Reducer(业务逻辑),订阅 Table(数据变更推送)。
关键设计决策:
- 全内存存储:所有数据常驻内存,磁盘 commit log 仅做持久化。内存访问是纳秒级,磁盘是毫秒级,差 6 个数量级
- WASM 沙箱执行:业务逻辑编译为 WASM 在数据库内运行,隔离性 + 可热更新
- 主动推送:数据变更自动推送订阅客户端,不需要轮询
- ACID 事务:每个 Reducer 自动包裹在事务里,隔离性 + 原子性 + 一致性一样不少
1.3 和传统方案对比
| 维度 | 传统架构 | SpacetimeDB |
|---|---|---|
| 组件数 | 5-8 个 | 1 个 |
| 客户端→数据延迟 | 20-50ms | <1ms(内存直读) |
| 状态一致性 | 开发者手动保证 | 事务自动保证 |
| 部署复杂度 | K8s + DevOps | 单二进制 / 云托管 |
| 实时同步 | 需自建 WebSocket | 内建订阅机制 |
| 开发语言数 | Server/Gateway/DB 通常不同 | 一种语言搞定 |
二、核心概念拆解:Table、Reducer、Subscription
SpacetimeDB 的编程模型只有三个核心概念,简洁到令人惊讶:
2.1 Table:数据即内存
Table 是 SpacetimeDB 的数据存储单元。所有数据常驻内存,磁盘 commit log 保证持久性和崩溃恢复。
定义一个 Table(Rust):
use spacetimedb::{table, ReducerContext, Table};
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u32,
name: String,
level: u32,
health: i32,
max_health: i32,
}
关键特性:
- public vs private:
public的 Table 可以被客户端订阅(只读),private只能被 Reducer 访问。写入永远只能通过 Reducer - Accessor 命名:
accessor = player决定了在代码中的访问方式ctx.db.player() - 类型系统:支持 u8/u16/u32/u64/u128/u256、i8/i16/i32/i64/i128/i256、String、bool、Identity、Vec 等
TypeScript 定义方式:
import { schema, table, t } from 'spacetimedb/server';
const player = table(
{ name: 'player', public: true },
{
id: t.u32().primaryKey().autoInc(),
name: t.string().index('btree'),
level: t.u32(),
health: t.i32(),
max_health: t.i32(),
}
);
const spacetimedb = schema({ player });
export default spacetimedb;
Table 分解:按访问模式组织数据
SpacetimeDB 文档强烈建议按访问模式分解表,而非按实体合并。这在游戏开发中尤其重要:
// ❌ 不推荐:所有数据塞一张表
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u32,
name: String,
pos_x: f32, pos_y: f32, vel_x: f32, vel_y: f32, // 60Hz 更新
health: i32, max_health: i32, mana: i32, max_mana: i32, // 偶尔更新
total_kills: u32, total_deaths: u32, play_time: u64, // 很少更新
audio_volume: f32, graphics_quality: u8, // 极少更新
}
// ✅ 推荐:按更新频率分解
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u32,
name: String,
}
#[spacetimedb::table(accessor = player_state, public)]
pub struct PlayerState {
#[primary_key]
player_id: u32,
pos_x: f32, pos_y: f32,
vel_x: f32, vel_y: f32,
}
#[spacetimedb::table(accessor = player_resources, public)]
pub struct PlayerResources {
#[primary_key]
player_id: u32,
health: i32, max_health: i32,
mana: i32, max_mana: i32,
}
#[spacetimedb::table(accessor = player_stats, public)]
pub struct PlayerStats {
#[primary_key]
player_id: u32,
total_kills: u32, total_deaths: u32, play_time: u64,
}
#[spacetimedb::table(accessor = player_settings)]
pub struct PlayerSettings {
#[primary_key]
player_id: u32,
audio_volume: f32, graphics_quality: u8,
}
为什么?假设 1000 个玩家以 60Hz 更新位置:
- 合并表:每次位置更新,推送整行数据(含 name/stats/settings),带宽浪费巨大
- 分解表:客户端只订阅
player_state,只收位置数据,带宽节省 80%+ - 缓存友好:相同更新频率的数据在内存中连续排列,CPU Cache 命中率更高
- Schema 演进:给 PlayerStats 加列,不影响 PlayerState 的结构
2.2 Reducer:事务性业务逻辑
Reducer 是 SpacetimeDB 中唯一修改数据的方式。每个 Reducer 自动运行在数据库事务中:
#[spacetimedb::reducer]
pub fn create_player(ctx: &ReducerContext, name: String) {
if name.is_empty() {
log::info!("Player name cannot be empty");
return;
}
ctx.db.player().insert(Player {
id: 0,
name,
level: 1,
health: 100,
max_health: 100,
});
}
#[spacetimedb::reducer]
pub fn player_take_damage(ctx: &ReducerContext, player_id: u32, damage: i32) {
let mut player = match ctx.db.player().id().find(player_id) {
Some(p) => p,
None => return,
};
player.health = (player.health - damage).max(0);
ctx.db.player().id().update(player);
if player.health == 0 {
log::info!("Player {} has died!", player_id);
}
}
事务性保证:
- 隔离性:Reducer 之间看不到彼此的未提交修改
- 原子性:Reducer 中途出错(panic),所有修改自动回滚
- 一致性:失败的事务不会留下脏数据
严格限制:Reducer 运行在沙箱中,不能发起网络请求、访问文件系统、调用系统命令、依赖全局/静态变量。只能操作 Table。
为什么这么严格?因为 Reducer 可能被重放(崩溃恢复、并发冲突检测),外部副作用无法回滚。所有状态必须存 Table。
客户端调用 Reducer 就像调用本地函数:
conn.reducers.createPlayer('Alice');
conn.reducers.playerTakeDamage(playerId, 25);
2.3 Subscription:实时数据推送
这是 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('初始数据加载完成!');
for (const player of ctx.db.player.iter()) {
console.log(`Player: ${player.name}, Level: ${player.level}`);
}
})
.subscribe([tables.player, tables.playerState]);
})
.build();
conn.db.player.onInsert((ctx, player) => {
console.log(`新玩家加入: ${player.name}`);
});
conn.db.playerState.onUpdate((ctx, oldState, newState) => {
console.log(`玩家 ${oldState.player_id} 移动到 (${newState.pos_x}, ${newState.pos_y})`);
});
工作原理:客户端发送订阅请求 → 服务端立即返回匹配行(初始快照)→ 后续变更自动推送 → 客户端维护本地缓存,读取零延迟。
类型化 Query Builder(推荐,编译期类型检查):
tables.player // 订阅所有玩家
tables.shopItems.where(r => r.requiredLevel.lte(5)) // 条件过滤
tables.playerState.where(r => r.playerId.eq(myPlayerId)) // 精确匹配
2.4 Subscription 优化最佳实践
按生命周期分组订阅
// 全局数据:始终需要
const globalSub = conn.subscriptionBuilder()
.subscribe([tables.announcements, tables.badges]);
// 商店数据:随等级变化
const shopSub = conn.subscriptionBuilder()
.subscribe([tables.shopItems.where(r => r.requiredLevel.lte(currentLevel))]);
先订阅再取消
SpacetimeDB 的订阅是零拷贝设计,同一查询多次订阅不产生额外开销。所以先订阅新的,再取消旧的:
// ✅ 正确
const newSub = conn.subscriptionBuilder()
.subscribe([tables.shopItems.where(r => r.requiredLevel.lte(6))]);
oldSub.unsubscribe();
// ❌ 错误:先取消可能导致数据空洞
oldSub.unsubscribe();
const newSub = conn.subscriptionBuilder()
.subscribe([tables.shopItems.where(r => r.requiredLevel.lte(6))]);
避免重叠查询
// ❌ 严重重叠
tables.user
tables.user.where(r => r.id.ne(5)) // 99% 数据重复
// ✅ 几乎无重叠
tables.user
tables.user.where(r => r.id.eq(5)) // 仅 1 行重复
三、索引系统:内存数据库的性能关键
3.1 B-tree 索引
默认索引类型,支持等值查询和范围查询:
#[spacetimedb::table(accessor = inventory, public)]
pub struct Inventory {
#[primary_key]
#[auto_inc]
id: u32,
#[index(btree)]
player_id: u32,
item_type: String,
quantity: u32,
}
等值和范围查询:
// 等值查询
for item in ctx.db.inventory().player_id().filter(42) {
log::info!("Item: {} x{}", item.item_type, item.quantity);
}
// 范围查询
for player in ctx.db.player().level().filter(
Range::new(Bound::Included(10), Bound::Included(50))
) {
log::info!("Player {} is level {}", player.name, player.level);
}
3.2 Direct 索引:O(1) 的极致查找
针对密集无符号整数的 O(1) 索引,用数组下标替代树遍历:
#[spacetimedb::table(accessor = entity_position, public)]
pub struct EntityPosition {
#[primary_key]
#[index(direct)] // O(1) 查找
id: u32,
x: f32, y: f32, z: f32,
}
适用场景:自增 ID、ECS Entity ID、密集分布的无符号整数。不适用:稀疏 ID、随机分布值。
3.3 多列索引
支持前缀匹配:(player_id, level) 索引可优化 player_id = 42,但不能优化 level = 5。
const score = table(
{
name: 'score',
public: true,
indexes: [
{ accessor: 'by_player_and_level', algorithm: 'btree', columns: ['player_id', 'level'] },
],
},
{
player_id: t.u32(),
level: t.u32(),
points: t.i64(),
}
);
3.4 浮点数的索引技巧
SpacetimeDB 不支持 f32/f64 作为索引键(NaN 不可比较)。对于需要索引的坐标数据,存储为缩放整数:
// 将浮点坐标乘以 1000 存为 i32,保留 3 位小数精度
#[spacetimedb::table(accessor = world_entity, public)]
pub struct WorldEntity {
#[primary_key]
#[auto_inc]
id: u32,
#[index(btree)]
zone_id: u32,
x_scaled: i32, // 实际坐标 = x_scaled as f32 / 1000.0
y_scaled: i32,
z_scaled: i32,
}
四、Procedure:突破 Reducer 的边界
Reducer 不能访问外部世界——这是设计上的保证,确保事务可重放。但现实业务经常需要调用外部 API(支付、AI、通知)。Procedure 就是为这种场景设计的。
4.1 Procedure vs Reducer
| 特性 | Reducer | Procedure |
|---|---|---|
| 事务 | 自动包裹 | 手动 withTx |
| 网络请求 | ❌ | ✅ ctx.http.fetch |
| 返回值 | 无 | 有 |
| 广播变更 | 自动 | 事务内自动 |
| 副作用 | 无 | 有(HTTP) |
4.2 调用外部 AI API 的完整示例
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;
export const ask_ai = spacetimedb.procedure(
{ prompt: t.string(), apiKey: t.string() },
t.string(),
(ctx, { prompt, apiKey }) => {
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(5000),
});
if (response.status !== 200) {
throw new SenderError(`AI API error: ${response.status}`);
}
const data = response.json();
const aiResponse = data.choices[0].message.content;
ctx.withTx((txCtx) => {
txCtx.db.aiMessage.insert({
user: ctx.sender(),
prompt,
response: aiResponse,
createdAt: ctx.timestamp(),
});
});
return aiResponse;
}
);
4.3 Schedule Table:延迟执行 Procedure
Reducer 不能直接调用 Procedure,但可以通过 Schedule Table 间接调度:
import { ScheduleAt } from 'spacetimedb';
const fetchSchedule = table(
{ name: 'fetch_schedule', scheduled: (): any => fetch_external_data },
{
scheduled_id: t.u64().primaryKey().autoInc(),
scheduled_at: t.scheduleAt(),
url: t.string(),
}
);
// 立即执行
export const queueFetch = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) => {
ctx.db.fetchSchedule.insert({
scheduled_id: 0n,
scheduled_at: ScheduleAt.interval(0n),
url,
});
});
// 延迟 5 秒执行
export const scheduleDelayedFetch = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) => {
ctx.db.fetchSchedule.insert({
scheduled_id: 0n,
scheduled_at: ScheduleAt.interval(5000000000n),
url,
});
});
五、实战:从零构建多人实时游戏后端
5.1 项目初始化
# 安装 CLI
curl -sSf https://install.spacetimedb.com | sh
# 登录
spacetime login
# 创建 Rust 项目
spacetime dev --template basic-rs
5.2 完整数据模型
use spacetimedb::{table, ReducerContext, Table, Identity, Timestamp};
// 玩家基础信息
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
identity: Identity,
#[auto_inc]
#[unique]
player_id: u32,
name: String,
created_at: Timestamp,
}
// 玩家位置(60Hz 高频更新)
#[spacetimedb::table(accessor = player_position, public)]
pub struct PlayerPosition {
#[primary_key]
player_id: u32,
x: f32,
y: f32,
z: f32,
rotation: f32,
}
// 玩家战斗属性
#[spacetimedb::table(accessor = player_combat, public)]
pub struct PlayerCombat {
#[primary_key]
player_id: u32,
health: i32,
max_health: i32,
attack: i32,
defense: i32,
level: u32,
exp: u64,
}
// 聊天消息
#[spacetimedb::table(accessor = chat_message, public)]
pub struct ChatMessage {
#[primary_key]
#[auto_inc]
id: u64,
sender: Identity,
sender_name: String,
content: String,
sent_at: Timestamp,
}
5.3 Reducer 实现
// 连接/断开
#[spacetimedb::reducer]
pub fn connect(ctx: &ReducerContext) {
log::info!("New connection: {:?}", ctx.sender());
}
#[spacetimedb::reducer]
pub fn disconnect(ctx: &ReducerContext) {
if let Some(player) = ctx.db.player().identity().find(ctx.sender()) {
let pid = player.player_id;
ctx.db.player_position().player_id().delete(pid);
ctx.db.player_combat().player_id().delete(pid);
ctx.db.player().identity().delete(ctx.sender());
log::info!("Player {} disconnected", player.name);
}
}
// 玩家注册
#[spacetimedb::reducer]
pub fn register(ctx: &ReducerContext, name: String) {
if ctx.db.player().identity().find(ctx.sender()).is_some() {
log::info!("Already registered");
return;
}
if name.trim().is_empty() {
log::info!("Name cannot be empty");
return;
}
ctx.db.player().insert(Player {
identity: ctx.sender(),
player_id: 0,
name: name.clone(),
created_at: ctx.timestamp(),
});
let player = ctx.db.player().identity().find(ctx.sender()).unwrap();
let pid = player.player_id;
ctx.db.player_position().insert(PlayerPosition {
player_id: pid,
x: 0.0, y: 0.0, z: 0.0, rotation: 0.0,
});
ctx.db.player_combat().insert(PlayerCombat {
player_id: pid,
health: 100, max_health: 100,
attack: 10, defense: 5,
level: 1, exp: 0,
});
log::info!("Player {} registered with id {}", name, pid);
}
// 位置更新(高频调用,含反作弊)
#[spacetimedb::reducer]
pub fn update_position(ctx: &ReducerContext, x: f32, y: f32, z: f32, rotation: f32) {
let player = match ctx.db.player().identity().find(ctx.sender()) {
Some(p) => p,
None => return,
};
let mut pos = match ctx.db.player_position().player_id().find(player.player_id) {
Some(p) => p,
None => return,
};
// 反作弊:限制移动速度
let dx = x - pos.x;
let dy = y - pos.y;
let dz = z - pos.z;
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
if distance > 30.0 {
log::warn!("Player {} speed hack: moved {} units", player.player_id, distance);
return;
}
pos.x = x;
pos.y = y;
pos.z = z;
pos.rotation = rotation;
ctx.db.player_position().player_id().update(pos);
}
// 战斗系统
#[spacetimedb::reducer]
pub fn attack_player(ctx: &ReducerContext, target_id: u32) {
let attacker = match ctx.db.player().identity().find(ctx.sender()) {
Some(p) => p,
None => return,
};
let attacker_combat = match ctx.db.player_combat().player_id().find(attacker.player_id) {
Some(c) => c,
None => return,
};
if attacker.player_id == target_id { return; }
let mut target_combat = match ctx.db.player_combat().player_id().find(target_id) {
Some(c) => c,
None => return,
};
if target_combat.health <= 0 { return; }
let damage = (attacker_combat.attack - target_combat.defense / 2).max(1);
target_combat.health = (target_combat.health - damage).max(0);
ctx.db.player_combat().player_id().update(target_combat);
if target_combat.health == 0 {
// 击杀:增加经验 + 升级检查
let mut attacker_combat = attacker_combat;
attacker_combat.exp += 50;
let exp_needed = (attacker_combat.level as u64) * 100;
if attacker_combat.exp >= exp_needed {
attacker_combat.level += 1;
attacker_combat.max_health += 20;
attacker_combat.health = attacker_combat.max_health;
attacker_combat.attack += 3;
attacker_combat.defense += 2;
attacker_combat.exp -= exp_needed;
}
ctx.db.player_combat().player_id().update(attacker_combat);
}
}
// 复活
#[spacetimedb::reducer]
pub fn respawn(ctx: &ReducerContext) {
let player = match ctx.db.player().identity().find(ctx.sender()) {
Some(p) => p,
None => return,
};
let mut combat = match ctx.db.player_combat().player_id().find(player.player_id) {
Some(c) => c,
None => return,
};
if combat.health > 0 { return; }
combat.health = combat.max_health / 2;
ctx.db.player_combat().player_id().update(combat);
if let Some(mut pos) = ctx.db.player_position().player_id().find(player.player_id) {
pos.x = 0.0; pos.y = 0.0; pos.z = 0.0;
ctx.db.player_position().player_id().update(pos);
}
}
// 聊天
#[spacetimedb::reducer]
pub fn send_chat(ctx: &ReducerContext, content: String) {
let player = match ctx.db.player().identity().find(ctx.sender()) {
Some(p) => p,
None => return,
};
if content.trim().is_empty() { return; }
let truncated = if content.len() > 500 { &content[..500] } else { &content };
ctx.db.chat_message().insert(ChatMessage {
id: 0,
sender: ctx.sender(),
sender_name: player.name,
content: truncated.to_string(),
sent_at: ctx.timestamp(),
});
}
5.4 客户端集成(TypeScript + React)
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('游戏数据加载完成'))
.subscribe([
tables.player,
tables.playerPosition,
tables.playerCombat,
tables.chatMessage,
]);
})
.build();
// React Hook
function useTableData(table: any) {
const [rows, setRows] = useState<any[]>([]);
useEffect(() => {
table.onInsert((_: any, row: any) => setRows(prev => [...prev, row]));
table.onDelete((_: any, row: any) => setRows(prev => prev.filter(r => r.id !== row.id)));
table.onUpdate((_: any, oldRow: any, newRow: any) =>
setRows(prev => prev.map(r => r.id === oldRow.id ? newRow : r))
);
}, []);
return rows;
}
function Game() {
const players = useTableData(conn.db.player);
const positions = useTableData(conn.db.playerPosition);
const messages = useTableData(conn.db.chatMessage);
const [chatInput, setChatInput] = useState('');
const handleSendChat = () => {
if (chatInput.trim()) {
conn.reducers.sendChat(chatInput);
setChatInput('');
}
};
return (
<div>
<h2>在线玩家 ({players.length})</h2>
{players.map(p => (
<div key={p.player_id}>{p.name}</div>
))}
<h2>聊天</h2>
{messages.map(m => (
<div key={m.id}>{m.sender_name}: {m.content}</div>
))}
<input value={chatInput} onChange={e => setChatInput(e.target.value)} />
<button onClick={handleSendChat}>发送</button>
</div>
);
}
六、部署与运维
6.1 本地开发
# Docker 一键启动
docker run --rm --pull always -p 3000:3000 clockworklabs/spacetime start
# 或从源码编译
git clone https://github.com/clockworklabs/SpacetimeDB
cd SpacetimeDB
cargo build --locked --release -p spacetimedb-standalone -p spacetimedb-cli
6.2 发布到 Maincloud
spacetime dev --template chat-react-ts
# 自动创建项目 → 编译 → 发布 → 热更新监听
Maincloud 是 SpacetimeDB 的托管云服务,开箱即用,不需要自己管理服务器。
6.3 Unity / Unreal Engine 集成
SpacetimeDB 提供了 C# SDK(支持 Unity)和 C++ SDK(支持 Unreal Engine):
// Unity C# 客户端
var conn = DbConnection.Builder()
.WithUri("wss://maincloud.spacetimedb.com")
.WithDatabaseName("my_game")
.OnConnect((ctx) => {
ctx.SubscriptionBuilder()
.OnApplied(() => Debug.Log("Connected!"))
.Subscribe(new[] { Tables.Player, Tables.PlayerPosition });
})
.Build();
// 调用 Reducer
conn.Reducers.UpdatePosition(transform.position.x, transform.position.y, transform.position.z, transform.rotation.eulerAngles.y);
七、性能优化实战
7.1 减少订阅带宽
一个常见的性能陷阱:客户端订阅了太多不需要的数据。
// ❌ 订阅所有玩家位置(1000 玩家 * 60Hz = 60000 次更新/秒)
tables.playerPosition
// ✅ 只订阅附近玩家
tables.playerPosition.where(r => r.x.gte(myX - range).and(r.x.lte(myX + range)))
7.2 Reducer 执行优化
// ❌ 遍历全表查找
for player in ctx.db.player().iter() {
if player.name == target_name {
// ...
}
}
// ✅ 用索引查找
// 给 name 加 #[unique] 或 #[index(btree)],然后:
if let Some(player) = ctx.db.player().name().find(&target_name) {
// O(log n) 或 O(1)
}
7.3 Direct 索引的正确使用
// ✅ 适合 Direct 索引:自增 ID 从 0 开始连续分配
#[spacetimedb::table(accessor = entity, public)]
pub struct Entity {
#[primary_key]
#[index(direct)] // O(1) 查找
id: u32,
entity_type: u8,
}
// ❌ 不适合 Direct 索引:UUID 作为主键,稀疏分布
#[spacetimedb::table(accessor = session, public)]
pub struct Session {
#[primary_key]
id: u128, // 稀疏的 UUID,用默认 B-tree
user_id: u32,
}
八、适用场景与局限
8.1 SpacetimeDB 适合什么
- 多人实时游戏:MMO、MOBA、吃鸡、社交游戏——核心场景
- 实时协作应用:在线白板、协同编辑、项目看板
- IoT 仪表盘:设备状态实时推送
- 聊天/社交应用:消息实时同步
- 任何「状态变化需要实时推送给多人」的场景
8.2 SpacetimeDB 不适合什么
- 重度分析型查询:没有列存储、没有查询优化器,OLAP 场景不如 ClickHouse
- 超大数据集:全内存存储,数据量受物理内存限制
- 需要复杂 SQL 的场景:不支持 JOIN 语法(用程序化索引查找替代)、不支持子查询
- 需要文件/二进制存储的场景:Table 只支持结构化数据
- 已有成熟基础设施的团队:迁移成本 > 收益时没必要
8.3 与竞品对比
| 项目 | 定位 | 语言 | 实时推送 | 事务 | 适用场景 |
|---|---|---|---|---|---|
| SpacetimeDB | 数据库即服务器 | Rust/TS/C#/C++ | ✅ 内建 | ✅ ACID | 多人游戏、协作应用 |
| Supabase | BaaS | TypeScript | ✅ Realtime | ✅ PG | Web/Mobile 应用 |
| Firebase | BaaS | TypeScript | ✅ Snapshot | ❌ 最终一致 | 移动应用 |
| PlanetScale | Serverless MySQL | SQL | ❌ | ✅ | Web 应用 |
| CockroachDB | 分布式 SQL | SQL | ❌ | ✅ 分布式 | 金融/企业 |
SpacetimeDB 的独特定位:面向实时多人交互的结构化数据平台,在「数据库 + 业务逻辑 + 实时推送」三位一体上没有直接竞品。
九、许可证与生态
SpacetimeDB 采用 BSL 1.1 许可证,4 年后转为 AGPL v3 + Linking Exception。这意味着:
- 商业使用:4 年内有使用限制(不能直接作为云服务转售)
- 自己的业务代码:不需要开源(Linking Exception 保护)
- 对 SpacetimeDB 本身的修改:需要贡献回社区
多语言 SDK 生态:
- 服务端:Rust、TypeScript、C#、C++
- 客户端:TypeScript(React/Next.js/Vue/Svelte/Angular)、Rust、C#(Unity)、C++(Unreal Engine)
- Unity 和 Unreal Engine 有官方教程
十、总结
SpacetimeDB 的核心洞察很简单:在实时多人场景中,数据库和应用服务器之间的边界是多余的。数据在内存、逻辑在数据库、推送是内建的——三层合一,消除了传统架构中最大的复杂度和延迟来源。
三个关键取舍:
- 全内存 = 快但有限:纳秒级访问,但数据量受 RAM 限制。适合热数据,冷数据需归档
- 沙箱 Reducer = 安全但不自由:事务可重放,但不能有副作用。需要副作用用 Procedure
- 程序化查询 = 灵活但不 SQL:没有 SQL 解析器开销,但也不支持 JOIN 等高级查询
如果你的项目是实时多人游戏或协作应用,正在被传统后端架构的复杂度折磨,SpacetimeDB 值得认真评估。一个模块替代整个后端栈,这在工程效率上的提升是数量级的。
BitCraft Online 已经证明了这条路在生产级 MMORPG 上走得通。接下来,就看你的了。