编程 SpacetimeDB 深度实战:当数据库学会「消灭服务器」——从 Reducer 事务模型到生产级实时多人游戏后端的完全指南(2026)

2026-06-14 09:20:57 +0800 CST views 5

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(数据变更推送)。

关键设计决策:

  1. 全内存存储:所有数据常驻内存,磁盘 commit log 仅做持久化。内存访问是纳秒级,磁盘是毫秒级,差 6 个数量级
  2. WASM 沙箱执行:业务逻辑编译为 WASM 在数据库内运行,隔离性 + 可热更新
  3. 主动推送:数据变更自动推送订阅客户端,不需要轮询
  4. 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 privatepublic 的 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

特性ReducerProcedure
事务自动包裹手动 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多人游戏、协作应用
SupabaseBaaSTypeScript✅ Realtime✅ PGWeb/Mobile 应用
FirebaseBaaSTypeScript✅ Snapshot❌ 最终一致移动应用
PlanetScaleServerless MySQLSQLWeb 应用
CockroachDB分布式 SQLSQL✅ 分布式金融/企业

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 的核心洞察很简单:在实时多人场景中,数据库和应用服务器之间的边界是多余的。数据在内存、逻辑在数据库、推送是内建的——三层合一,消除了传统架构中最大的复杂度和延迟来源。

三个关键取舍:

  1. 全内存 = 快但有限:纳秒级访问,但数据量受 RAM 限制。适合热数据,冷数据需归档
  2. 沙箱 Reducer = 安全但不自由:事务可重放,但不能有副作用。需要副作用用 Procedure
  3. 程序化查询 = 灵活但不 SQL:没有 SQL 解析器开销,但也不支持 JOIN 等高级查询

如果你的项目是实时多人游戏或协作应用,正在被传统后端架构的复杂度折磨,SpacetimeDB 值得认真评估。一个模块替代整个后端栈,这在工程效率上的提升是数量级的。

BitCraft Online 已经证明了这条路在生产级 MMORPG 上走得通。接下来,就看你的了。

复制全文 生成海报 SpacetimeDB Rust 实时游戏 数据库 WebSocket

推荐文章

Vue3中的v-model指令有什么变化?
2024-11-18 20:00:17 +0800 CST
Rust 与 sqlx:数据库迁移实战指南
2024-11-19 02:38:49 +0800 CST
Python实现Zip文件的暴力破解
2024-11-19 03:48:35 +0800 CST
JavaScript 实现访问本地文件夹
2024-11-18 23:12:47 +0800 CST
程序员茄子在线接单