编程 SpacetimeDB 彻底颠覆后端架构:Rust 编写的「数据库即服务器」如何消灭中间层——从 WASM 模块引擎到 Unity 实时游戏服务器的全链路实战

2026-06-10 00:22:57 +0800 CST views 3

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 的核心设计原则:

  1. 数据库即服务器:应用逻辑以模块(Module)形式上传到数据库内运行
  2. 零基础设施:无需单独的 Web 服务器、无需容器编排、无需 DevOps
  3. 实时同步原生支持:数据变更自动推送给订阅的客户端,无需手动实现 WebSocket
  4. ACID 保证:完整的传统 RDBMS 事务语义
  5. 全内存 + 提交日志:所有状态驻留内存保证速度,磁盘提交日志保证持久性

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,
}

关键设计点:

  • public vs 私有public 表允许客户端订阅并接收实时更新;私有表只有模块内的 reducer 可以读写
  • Identity 类型:SpacetimeDB 原生的身份认证机制,每个连接都有唯一 Identity
  • Timestamp 类型:数据库生成的时间戳,保证全局一致
  • #[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?

  1. 沙箱隔离:用户模块无法访问宿主系统的文件系统、网络等资源
  2. 语言无关:Rust、C#、TypeScript、C++ 都可以编译到 WASM
  3. 接近原生性能:WASM 的执行效率接近原生代码,远超解释型语言
  4. 安全验证:WASM 模块在加载前可以进行静态验证,确保不会出现未定义行为

4.3 订阅推送引擎

SpacetimeDB 的订阅推送是整个系统最精妙的部分。它不是简单的"表变更就推送",而是:

  1. 查询级订阅:客户端订阅的是 SQL 查询,不是整张表
  2. 增量计算:当数据变更时,SpacetimeDB 计算变更是否影响某个订阅的结果集
  3. 精确推送:只推送影响该客户端的行级变更(insert/update/delete)
数据变更事件流:
表 T 上的 INSERT/UPDATE/DELETE
  → 遍历所有订阅了 T 的查询
  → 评估变更行是否匹配查询条件
  → 生成行级变更事件
  → 推送给匹配的客户端

这意味着:如果客户端 A 订阅了 room_id = 1 的消息,客户端 B 在 room_id = 2 发消息,A 不会收到任何推送。这不是客户端过滤,而是服务端精确推送

4.4 事务与并发模型

SpacetimeDB 的 Reducer 执行采用串行化策略:

  • 同一时刻只有一个 Reducer 在执行
  • Reducer 内的操作是原子的
  • 不需要锁、不需要并发控制

这听起来像是性能瓶颈?实际上:

  1. Reducer 执行极快:纯内存操作,微秒级完成
  2. I/O 是瓶颈所在的传统服务器:SpacetimeDB 没有网络 I/O 瓶颈(数据库内部执行)
  3. 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

维度SpacetimeDBFirebase RTDB
数据模型关系型(SQL 语义)树形 JSON
查询能力完整 SQL有限过滤
事务ACID有限事务
服务端逻辑WASM 模块(Rust/C#/TS)Cloud Functions(Node.js)
延迟极低(全内存)较低
自部署支持不支持
供应商锁定

Firebase 的实时同步是特性,SpacetimeDB 的实时同步是架构基础。

6.2 SpacetimeDB vs Supabase Realtime

维度SpacetimeDBSupabase Realtime
架构数据库即服务器PostgreSQL + WebSocket 网关
中间层Realtime 服务
事务原生 ACIDPostgreSQL 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 代表的不仅仅是"又一个数据库",而是一种架构范式的转移

  1. 从三层到单层:数据库即服务器,消除中间层
  2. 从拉取到推送:数据变更主动推送,不再需要轮询
  3. 从基础设施到业务:零运维,开发者只关心业务逻辑
  4. 从碎片化到统一:一个模块、一个语言、一次部署

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

推荐文章

微信小程序开发资源汇总
2026-05-11 16:11:29 +0800 CST
在JavaScript中实现队列
2024-11-19 01:38:36 +0800 CST
避免 Go 语言中的接口污染
2024-11-19 05:20:53 +0800 CST
Nginx 性能优化有这篇就够了!
2024-11-19 01:57:41 +0800 CST
html一份退出酒场的告知书
2024-11-18 18:14:45 +0800 CST
纯CSS绘制iPhoneX的外观
2024-11-19 06:39:43 +0800 CST
跟着 IP 地址,我能找到你家不?
2024-11-18 12:12:54 +0800 CST
使用 Vue3 和 Axios 实现 CRUD 操作
2024-11-19 01:57:50 +0800 CST
Vue3如何执行响应式数据绑定?
2024-11-18 12:31:22 +0800 CST
用 Rust 构建一个 WebSocket 服务器
2024-11-19 10:08:22 +0800 CST
Flet 构建跨平台应用的 Python 框架
2025-03-21 08:40:53 +0800 CST
程序员茄子在线接单